diff --git a/Dockerfile b/Dockerfile index 288e6ef9e580..76cb776236e9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,6 +9,9 @@ # Build stages use full bookworm; the runtime image is always bookworm-slim. ARG OPENCLAW_EXTENSIONS="" ARG OPENCLAW_BUNDLED_PLUGIN_DIR=extensions +ARG OPENCLAW_DOCKER_BUILD_NODE_OPTIONS="--max-old-space-size=8192" +ARG OPENCLAW_DOCKER_BUILD_TSDOWN_MAX_OLD_SPACE_MB="" +ARG OPENCLAW_DOCKER_BUILD_SKIP_DTS=1 ARG OPENCLAW_NODE_BOOKWORM_IMAGE="docker.io/library/node:24-bookworm@sha256:8530f76a96d88820d288761f022e318970dda93d01536919fbc16076b7983e63" ARG OPENCLAW_NODE_BOOKWORM_SLIM_IMAGE="docker.io/library/node:24-bookworm-slim@sha256:242549cd46785b480c832479a730f4f2a20865d61ea2e404fdb2a5c3d3b73ecf" ARG OPENCLAW_NODE_BOOKWORM_SLIM_DIGEST="sha256:242549cd46785b480c832479a730f4f2a20865d61ea2e404fdb2a5c3d3b73ecf" @@ -49,6 +52,9 @@ FROM ${OPENCLAW_BUN_IMAGE} AS bun-binary FROM ${OPENCLAW_NODE_BOOKWORM_IMAGE} AS build ARG OPENCLAW_BUNDLED_PLUGIN_DIR ARG OPENCLAW_EXTENSIONS +ARG OPENCLAW_DOCKER_BUILD_NODE_OPTIONS +ARG OPENCLAW_DOCKER_BUILD_TSDOWN_MAX_OLD_SPACE_MB +ARG OPENCLAW_DOCKER_BUILD_SKIP_DTS # Copy pinned Bun binary from the official image instead of fetching via curl. COPY --from=bun-binary /usr/local/bin/bun /usr/local/bin/bun @@ -119,7 +125,7 @@ RUN pnpm_config_verify_deps_before_run=false pnpm canvas:a2ui:bundle || \ RUN if printf '%s\n' "$OPENCLAW_EXTENSIONS" | tr ',' ' ' | tr ' ' '\n' | grep -qx 'qa-lab'; then \ export OPENCLAW_BUILD_PRIVATE_QA=1 OPENCLAW_ENABLE_PRIVATE_QA_CLI=1; \ fi && \ - NODE_OPTIONS=--max-old-space-size=8192 pnpm_config_verify_deps_before_run=false pnpm build:docker + OPENCLAW_RUN_NODE_SKIP_DTS_BUILD="$OPENCLAW_DOCKER_BUILD_SKIP_DTS" OPENCLAW_TSDOWN_MAX_OLD_SPACE_MB="$OPENCLAW_DOCKER_BUILD_TSDOWN_MAX_OLD_SPACE_MB" NODE_OPTIONS="$OPENCLAW_DOCKER_BUILD_NODE_OPTIONS" pnpm_config_verify_deps_before_run=false pnpm build:docker # Force pnpm for UI build (Bun may fail on ARM/Synology architectures) ENV OPENCLAW_PREFER_PNPM=1 RUN pnpm_config_verify_deps_before_run=false pnpm ui:build diff --git a/docs/install/docker.md b/docs/install/docker.md index b7c8e14a465d..c60feaf31eb2 100644 --- a/docs/install/docker.md +++ b/docs/install/docker.md @@ -161,25 +161,28 @@ and setup-time config writes through `openclaw-gateway` with The setup script accepts these optional environment variables: -| Variable | Purpose | -| ------------------------------------------ | --------------------------------------------------------------------- | -| `OPENCLAW_IMAGE` | Use a remote image instead of building locally | -| `OPENCLAW_IMAGE_APT_PACKAGES` | Install extra apt packages during build (space-separated) | -| `OPENCLAW_IMAGE_PIP_PACKAGES` | Install extra Python packages during build (space-separated) | -| `OPENCLAW_EXTENSIONS` | Pre-install plugin dependencies at build time (space-separated names) | -| `OPENCLAW_EXTRA_MOUNTS` | Extra host bind mounts (comma-separated `source:target[:opts]`) | -| `OPENCLAW_HOME_VOLUME` | Persist `/home/node` in a named Docker volume | -| `OPENCLAW_SANDBOX` | Opt in to sandbox bootstrap (`1`, `true`, `yes`, `on`) | -| `OPENCLAW_SKIP_ONBOARDING` | Skip the interactive onboarding step (`1`, `true`, `yes`, `on`) | -| `OPENCLAW_DOCKER_SOCKET` | Override Docker socket path | -| `OPENCLAW_DISABLE_BONJOUR` | Disable Bonjour/mDNS advertising (defaults to `1` for Docker) | -| `OPENCLAW_DISABLE_BUNDLED_SOURCE_OVERLAYS` | Disable bundled plugin source bind-mount overlays | -| `OTEL_EXPORTER_OTLP_ENDPOINT` | Shared OTLP/HTTP collector endpoint for OpenTelemetry export | -| `OTEL_EXPORTER_OTLP_*_ENDPOINT` | Signal-specific OTLP endpoints for traces, metrics, or logs | -| `OTEL_EXPORTER_OTLP_PROTOCOL` | OTLP protocol override. Only `http/protobuf` is supported today | -| `OTEL_SERVICE_NAME` | Service name used for OpenTelemetry resources | -| `OTEL_SEMCONV_STABILITY_OPT_IN` | Opt in to latest experimental GenAI semantic attributes | -| `OPENCLAW_OTEL_PRELOADED` | Skip starting a second OpenTelemetry SDK when one is preloaded | +| Variable | Purpose | +| ----------------------------------------------- | --------------------------------------------------------------------- | +| `OPENCLAW_IMAGE` | Use a remote image instead of building locally | +| `OPENCLAW_IMAGE_APT_PACKAGES` | Install extra apt packages during build (space-separated) | +| `OPENCLAW_IMAGE_PIP_PACKAGES` | Install extra Python packages during build (space-separated) | +| `OPENCLAW_EXTENSIONS` | Pre-install plugin dependencies at build time (space-separated names) | +| `OPENCLAW_DOCKER_BUILD_NODE_OPTIONS` | Override the local source-build Node options | +| `OPENCLAW_DOCKER_BUILD_TSDOWN_MAX_OLD_SPACE_MB` | Override the local source-build tsdown heap in MB | +| `OPENCLAW_DOCKER_BUILD_SKIP_DTS` | Skip declaration output during runtime-only local image builds | +| `OPENCLAW_EXTRA_MOUNTS` | Extra host bind mounts (comma-separated `source:target[:opts]`) | +| `OPENCLAW_HOME_VOLUME` | Persist `/home/node` in a named Docker volume | +| `OPENCLAW_SANDBOX` | Opt in to sandbox bootstrap (`1`, `true`, `yes`, `on`) | +| `OPENCLAW_SKIP_ONBOARDING` | Skip the interactive onboarding step (`1`, `true`, `yes`, `on`) | +| `OPENCLAW_DOCKER_SOCKET` | Override Docker socket path | +| `OPENCLAW_DISABLE_BONJOUR` | Disable Bonjour/mDNS advertising (defaults to `1` for Docker) | +| `OPENCLAW_DISABLE_BUNDLED_SOURCE_OVERLAYS` | Disable bundled plugin source bind-mount overlays | +| `OTEL_EXPORTER_OTLP_ENDPOINT` | Shared OTLP/HTTP collector endpoint for OpenTelemetry export | +| `OTEL_EXPORTER_OTLP_*_ENDPOINT` | Signal-specific OTLP endpoints for traces, metrics, or logs | +| `OTEL_EXPORTER_OTLP_PROTOCOL` | OTLP protocol override. Only `http/protobuf` is supported today | +| `OTEL_SERVICE_NAME` | Service name used for OpenTelemetry resources | +| `OTEL_SEMCONV_STABILITY_OPT_IN` | Opt in to latest experimental GenAI semantic attributes | +| `OPENCLAW_OTEL_PRELOADED` | Skip starting a second OpenTelemetry SDK when one is preloaded | The official Docker image does not ship Homebrew. During onboarding, OpenClaw hides brew-only skill dependency installers when it is running in a Linux @@ -190,6 +193,15 @@ or installed manually. For dependencies available from Debian packages, use For Python dependencies, use `OPENCLAW_IMAGE_PIP_PACKAGES`. This runs `python3 -m pip install --break-system-packages` during the image build, so pin package versions and use only package indexes you trust. +Source builds default `OPENCLAW_DOCKER_BUILD_NODE_OPTIONS` to +`--max-old-space-size=8192` and leave +`OPENCLAW_DOCKER_BUILD_TSDOWN_MAX_OLD_SPACE_MB` unset so the tsdown wrapper can +respect container memory limits. They also default +`OPENCLAW_DOCKER_BUILD_SKIP_DTS=1` because runtime images prune declaration +files after build. If Docker reports `ResourceExhausted`, `cannot allocate +memory`, or aborts during `tsdown`, increase the Docker builder memory limit or +retry with smaller explicit heaps, for example +`OPENCLAW_DOCKER_BUILD_NODE_OPTIONS=--max-old-space-size=4096 OPENCLAW_DOCKER_BUILD_TSDOWN_MAX_OLD_SPACE_MB=4096`. Maintainers can test bundled plugin source against a packaged image by mounting one plugin source directory over its packaged source path, for example diff --git a/scripts/docker/setup.sh b/scripts/docker/setup.sh index 276c93765816..0719ba8c8336 100755 --- a/scripts/docker/setup.sh +++ b/scripts/docker/setup.sh @@ -521,6 +521,9 @@ export OPENCLAW_IMAGE="$IMAGE_NAME" export OPENCLAW_IMAGE_APT_PACKAGES="${OPENCLAW_IMAGE_APT_PACKAGES-${OPENCLAW_DOCKER_APT_PACKAGES:-}}" export OPENCLAW_IMAGE_PIP_PACKAGES="${OPENCLAW_IMAGE_PIP_PACKAGES:-}" export OPENCLAW_EXTENSIONS="${OPENCLAW_EXTENSIONS:-}" +export OPENCLAW_DOCKER_BUILD_NODE_OPTIONS="${OPENCLAW_DOCKER_BUILD_NODE_OPTIONS---max-old-space-size=8192}" +export OPENCLAW_DOCKER_BUILD_TSDOWN_MAX_OLD_SPACE_MB="${OPENCLAW_DOCKER_BUILD_TSDOWN_MAX_OLD_SPACE_MB:-}" +export OPENCLAW_DOCKER_BUILD_SKIP_DTS="${OPENCLAW_DOCKER_BUILD_SKIP_DTS:-1}" export OPENCLAW_INSTALL_BROWSER="${OPENCLAW_INSTALL_BROWSER:-}" export OPENCLAW_EXTRA_MOUNTS="$EXTRA_MOUNTS" export OPENCLAW_HOME_VOLUME="$HOME_VOLUME_NAME" @@ -726,6 +729,9 @@ upsert_env "$ENV_FILE" \ OPENCLAW_IMAGE_APT_PACKAGES \ OPENCLAW_IMAGE_PIP_PACKAGES \ OPENCLAW_EXTENSIONS \ + OPENCLAW_DOCKER_BUILD_NODE_OPTIONS \ + OPENCLAW_DOCKER_BUILD_TSDOWN_MAX_OLD_SPACE_MB \ + OPENCLAW_DOCKER_BUILD_SKIP_DTS \ OPENCLAW_INSTALL_BROWSER \ OPENCLAW_SANDBOX \ OPENCLAW_DOCKER_SOCKET \ @@ -752,6 +758,9 @@ elif [[ "$IMAGE_NAME" == "openclaw:local" ]]; then --build-arg "OPENCLAW_IMAGE_APT_PACKAGES=${OPENCLAW_IMAGE_APT_PACKAGES}" \ --build-arg "OPENCLAW_IMAGE_PIP_PACKAGES=${OPENCLAW_IMAGE_PIP_PACKAGES}" \ --build-arg "OPENCLAW_EXTENSIONS=${OPENCLAW_EXTENSIONS}" \ + --build-arg "OPENCLAW_DOCKER_BUILD_NODE_OPTIONS=${OPENCLAW_DOCKER_BUILD_NODE_OPTIONS}" \ + --build-arg "OPENCLAW_DOCKER_BUILD_TSDOWN_MAX_OLD_SPACE_MB=${OPENCLAW_DOCKER_BUILD_TSDOWN_MAX_OLD_SPACE_MB}" \ + --build-arg "OPENCLAW_DOCKER_BUILD_SKIP_DTS=${OPENCLAW_DOCKER_BUILD_SKIP_DTS}" \ --build-arg "OPENCLAW_INSTALL_BROWSER=${OPENCLAW_INSTALL_BROWSER}" \ --build-arg "OPENCLAW_INSTALL_DOCKER_CLI=${OPENCLAW_INSTALL_DOCKER_CLI:-}" \ -t "$IMAGE_NAME" \ diff --git a/scripts/lib/docker-build.sh b/scripts/lib/docker-build.sh index 751d73f9deba..9374e2d2d170 100644 --- a/scripts/lib/docker-build.sh +++ b/scripts/lib/docker-build.sh @@ -55,6 +55,19 @@ docker_build_transient_failure() { "$log_file" } +docker_build_resource_exhausted_failure() { + local log_file="$1" + grep -Eqi 'ResourceExhausted|cannot allocate memory|out of memory|exit code: 137|signal: killed|Killed' "$log_file" +} + +docker_build_print_resource_exhausted_hint() { + cat >&2 <<'EOF' +Docker build failed because the builder ran out of memory. +Try increasing the Docker/BuildKit memory limit, closing other memory-heavy processes, or rebuilding with a smaller OpenClaw build heap, for example: + OPENCLAW_DOCKER_BUILD_NODE_OPTIONS=--max-old-space-size=4096 OPENCLAW_DOCKER_BUILD_TSDOWN_MAX_OLD_SPACE_MB=4096 ./scripts/docker/setup.sh +EOF +} + docker_build_retry_count() { local configured="${OPENCLAW_DOCKER_BUILD_RETRIES:-2}" if [[ "$configured" =~ ^[0-9]+$ ]]; then @@ -235,6 +248,9 @@ docker_build_with_retries() { if [ "$attempt" -ge "$max_attempts" ] || ! docker_build_transient_failure "$log_file"; then docker_e2e_print_log "$log_file" + if docker_build_resource_exhausted_failure "$log_file"; then + docker_build_print_resource_exhausted_hint + fi rm -f "$log_file" return 1 fi diff --git a/src/docker-setup.e2e.test.ts b/src/docker-setup.e2e.test.ts index e49e466fc7ed..78b5838d1dcc 100644 --- a/src/docker-setup.e2e.test.ts +++ b/src/docker-setup.e2e.test.ts @@ -364,6 +364,9 @@ describe("scripts/docker/setup.sh", () => { expect(result.status).toBe(0); const envFile = await readFile(join(activeSandbox.rootDir, ".env"), "utf8"); expect(envFile).toContain("OPENCLAW_IMAGE_APT_PACKAGES=curl wget"); + expect(envFile).toContain("OPENCLAW_DOCKER_BUILD_NODE_OPTIONS=--max-old-space-size=8192"); + expect(envFile).toContain("OPENCLAW_DOCKER_BUILD_TSDOWN_MAX_OLD_SPACE_MB="); + expect(envFile).toContain("OPENCLAW_DOCKER_BUILD_SKIP_DTS=1"); expect(envFile).toContain("OPENCLAW_EXTRA_MOUNTS="); expect(envFile).toContain("OPENCLAW_HOME_VOLUME=openclaw-home"); // pragma: allowlist secret expect(envFile).toContain("OPENCLAW_DISABLE_BONJOUR="); @@ -382,6 +385,11 @@ describe("scripts/docker/setup.sh", () => { expect(extraCompose).toContain("openclaw-home:"); const log = await readDockerLog(activeSandbox); expect(log).toContain("--build-arg OPENCLAW_IMAGE_APT_PACKAGES=curl wget"); + expect(log).toContain( + "--build-arg OPENCLAW_DOCKER_BUILD_NODE_OPTIONS=--max-old-space-size=8192", + ); + expect(log).toContain("--build-arg OPENCLAW_DOCKER_BUILD_TSDOWN_MAX_OLD_SPACE_MB="); + expect(log).toContain("--build-arg OPENCLAW_DOCKER_BUILD_SKIP_DTS=1"); expect(log).toContain( `run --rm --no-deps ${prestartContainerEnvFlags} --entrypoint node openclaw-gateway dist/index.js onboard --mode local --no-install-daemon --gateway-auth token --gateway-token-ref-env OPENCLAW_GATEWAY_TOKEN --skip-ui --suppress-gateway-token-output`, ); diff --git a/src/dockerfile.test.ts b/src/dockerfile.test.ts index d94e5530d1b8..dec4e774cd8d 100644 --- a/src/dockerfile.test.ts +++ b/src/dockerfile.test.ts @@ -203,7 +203,7 @@ describe("Dockerfile", () => { "export OPENCLAW_BUILD_PRIVATE_QA=1 OPENCLAW_ENABLE_PRIVATE_QA_CLI=1", ); const buildDockerIndex = collapsed.indexOf( - "NODE_OPTIONS=--max-old-space-size=8192 pnpm_config_verify_deps_before_run=false pnpm build:docker", + 'OPENCLAW_RUN_NODE_SKIP_DTS_BUILD="$OPENCLAW_DOCKER_BUILD_SKIP_DTS" OPENCLAW_TSDOWN_MAX_OLD_SPACE_MB="$OPENCLAW_DOCKER_BUILD_TSDOWN_MAX_OLD_SPACE_MB" NODE_OPTIONS="$OPENCLAW_DOCKER_BUILD_NODE_OPTIONS" pnpm_config_verify_deps_before_run=false pnpm build:docker', ); const qaLabBuildIndex = collapsed.indexOf( "pnpm_config_verify_deps_before_run=false pnpm qa:lab:build", @@ -236,6 +236,11 @@ describe("Dockerfile", () => { const dockerfile = await readFile(dockerfilePath, "utf8"); expect(dockerfile).toContain("FROM build AS runtime-assets"); expect(dockerfile).toContain("ARG OPENCLAW_EXTENSIONS"); + expect(dockerfile).toContain( + 'ARG OPENCLAW_DOCKER_BUILD_NODE_OPTIONS="--max-old-space-size=8192"', + ); + expect(dockerfile).toContain('ARG OPENCLAW_DOCKER_BUILD_TSDOWN_MAX_OLD_SPACE_MB=""'); + expect(dockerfile).toContain("ARG OPENCLAW_DOCKER_BUILD_SKIP_DTS=1"); expect(dockerfile).toContain("ARG OPENCLAW_BUNDLED_PLUGIN_DIR"); expect(dockerfile).toContain( "Opt-in plugin dependencies at build time (space- or comma-separated directory names).", diff --git a/test/scripts/docker-build-helper.test.ts b/test/scripts/docker-build-helper.test.ts index 5c757607fe63..5c0aa60e9511 100644 --- a/test/scripts/docker-build-helper.test.ts +++ b/test/scripts/docker-build-helper.test.ts @@ -185,6 +185,7 @@ describe("docker build helper", () => { expect(helper).toContain('docker_build_run_logged "$label" "$timeout_value" "$log_file"'); expect(helper).toContain("OPENCLAW_DOCKER_BUILD_REQUIRE_TIMEOUT"); expect(helper).toContain("frontend grpc server closed unexpectedly"); + expect(helper).toContain("docker_build_resource_exhausted_failure()"); }); it("treats Docker registry auth 5xx failures as transient build failures", () => { @@ -214,6 +215,32 @@ docker_build_transient_failure "$LOG_PATH" } }); + it("detects Docker builder memory exhaustion failures", () => { + const workDir = mkdtempSync(join(tmpdir(), "openclaw-docker-build-memory-")); + + try { + const logPath = join(workDir, "docker-build.log"); + writeFileSync( + logPath, + [ + 'ERROR: failed to build: failed to solve: ResourceExhausted: process "/bin/sh -c pnpm build:docker" did not complete successfully: cannot allocate memory', + ].join("\n"), + ); + const rootDir = process.cwd(); + const script = ` +set -euo pipefail +ROOT_DIR=${shellQuote(rootDir)} +LOG_PATH=${shellQuote(logPath)} +source "$ROOT_DIR/scripts/lib/docker-build.sh" +docker_build_resource_exhausted_failure "$LOG_PATH" +`; + + execFileSync("bash", ["-lc", script], { encoding: "utf8" }); + } finally { + rmSync(workDir, { recursive: true, force: true }); + } + }); + it("keeps shell-script Docker builds behind the helper", () => { for (const path of CENTRALIZED_BUILD_SCRIPTS) { const script = readFileSync(path, "utf8"); diff --git a/test/scripts/test-install-sh-docker.test.ts b/test/scripts/test-install-sh-docker.test.ts index 031fd1b197c3..d36e21a800a3 100644 --- a/test/scripts/test-install-sh-docker.test.ts +++ b/test/scripts/test-install-sh-docker.test.ts @@ -1,13 +1,6 @@ // Test Install Sh Docker tests cover test install sh docker script behavior. import { spawn, spawnSync } from "node:child_process"; -import { - existsSync, - mkdirSync, - mkdtempSync, - readFileSync, - rmSync, - writeFileSync, -} from "node:fs"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import path, { join } from "node:path"; import { runInNewContext } from "node:vm"; @@ -448,7 +441,12 @@ describe("test-install-sh-docker", () => { const dockerfile = readFileSync("Dockerfile", "utf8"); expect(dockerfile).toContain( - "NODE_OPTIONS=--max-old-space-size=8192 pnpm_config_verify_deps_before_run=false pnpm build:docker", + 'ARG OPENCLAW_DOCKER_BUILD_NODE_OPTIONS="--max-old-space-size=8192"', + ); + expect(dockerfile).toContain('ARG OPENCLAW_DOCKER_BUILD_TSDOWN_MAX_OLD_SPACE_MB=""'); + expect(dockerfile).toContain("ARG OPENCLAW_DOCKER_BUILD_SKIP_DTS=1"); + expect(dockerfile).toContain( + 'OPENCLAW_RUN_NODE_SKIP_DTS_BUILD="$OPENCLAW_DOCKER_BUILD_SKIP_DTS" OPENCLAW_TSDOWN_MAX_OLD_SPACE_MB="$OPENCLAW_DOCKER_BUILD_TSDOWN_MAX_OLD_SPACE_MB" NODE_OPTIONS="$OPENCLAW_DOCKER_BUILD_NODE_OPTIONS" pnpm_config_verify_deps_before_run=false pnpm build:docker', ); });