From bc4221a07e987df701f4a430575383f303e3227f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 30 Jul 2026 20:47:03 -0700 Subject: [PATCH] fix(docker): keep custom-port containers healthy (#116639) * fix(docker): probe the active gateway port * fix(docker): register the healthcheck runtime boundary --- Dockerfile | 2 +- config/knip.config.ts | 2 + docker-compose.yml | 3 +- src/docker-healthcheck.test.ts | 95 +++++++++++++++++++++++++++++++++ src/docker-healthcheck.ts | 67 +++++++++++++++++++++++ src/dockerfile.test.ts | 11 ++++ src/infra/tsdown-config.test.ts | 7 +++ tsdown.config.ts | 1 + 8 files changed, 185 insertions(+), 3 deletions(-) create mode 100644 src/docker-healthcheck.test.ts create mode 100644 src/docker-healthcheck.ts diff --git a/Dockerfile b/Dockerfile index 8410de587a6a..70c69e2e8e68 100644 --- a/Dockerfile +++ b/Dockerfile @@ -381,6 +381,6 @@ USER node # - aliases: /health and /ready # For external access from host/ingress, override bind to "lan" and set auth. HEALTHCHECK --interval=3m --timeout=10s --start-period=15s --retries=3 \ - CMD node -e "fetch('http://127.0.0.1:18789/healthz').then((r)=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" + CMD ["node", "dist/docker-healthcheck.js"] ENTRYPOINT ["tini", "-s", "--"] CMD ["node", "openclaw.mjs", "gateway"] diff --git a/config/knip.config.ts b/config/knip.config.ts index ce6ba73167f5..9851aca2922d 100644 --- a/config/knip.config.ts +++ b/config/knip.config.ts @@ -103,6 +103,8 @@ const rootEntries = [ "openclaw.mjs!", "src/index.ts!", "src/entry.ts!", + // Built as the official image's Docker HEALTHCHECK entrypoint. + "src/docker-healthcheck.ts!", // Shipped compatibility facade for statusCommand and getStatusSummary. "src/commands/status.ts!", "src/cli/daemon-cli.ts!", diff --git a/docker-compose.yml b/docker-compose.yml index 888454231517..59d3129f8ac6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -81,8 +81,7 @@ services: [ "CMD", "node", - "-e", - "fetch('http://127.0.0.1:18789/healthz').then((r)=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))", + "dist/docker-healthcheck.js", ] interval: 30s timeout: 5s diff --git a/src/docker-healthcheck.test.ts b/src/docker-healthcheck.test.ts new file mode 100644 index 000000000000..b235d7e984c2 --- /dev/null +++ b/src/docker-healthcheck.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it, vi } from "vitest"; +import { probeDockerGatewayHealth, resolveDockerHealthcheckPort } from "./docker-healthcheck.js"; + +describe("Docker healthcheck", () => { + it("prefers the active Gateway lock port used by --port", async () => { + const getRuntimeConfig = vi.fn(() => ({ gateway: { port: 19002 } })); + const resolveGatewayPort = vi.fn(() => 19003); + + await expect( + resolveDockerHealthcheckPort({ + env: { OPENCLAW_GATEWAY_PORT: "19001" }, + getRuntimeConfig, + readActiveGatewayLockPort: vi.fn(async () => 19000), + resolveGatewayPort, + }), + ).resolves.toBe(19000); + expect(getRuntimeConfig).not.toHaveBeenCalled(); + expect(resolveGatewayPort).not.toHaveBeenCalled(); + }); + + it.each([ + { + name: "environment", + env: { OPENCLAW_GATEWAY_PORT: "19001" }, + config: { gateway: { port: 19002 } }, + expected: 19001, + }, + { + name: "config", + env: {}, + config: { gateway: { port: 19002 } }, + expected: 19002, + }, + ])("falls back to the canonical $name port", async ({ env, config, expected }) => { + await expect( + resolveDockerHealthcheckPort({ + env, + getRuntimeConfig: () => config, + readActiveGatewayLockPort: vi.fn(async () => undefined), + }), + ).resolves.toBe(expected); + }); + + it("falls back to config when the active lock cannot be read", async () => { + await expect( + resolveDockerHealthcheckPort({ + env: {}, + getRuntimeConfig: () => ({ gateway: { port: 19002 } }), + readActiveGatewayLockPort: vi.fn(async () => { + throw new Error("lock unavailable"); + }), + resolveGatewayPort: (config) => config.gateway?.port ?? 18789, + }), + ).resolves.toBe(19002); + }); + + it("probes the unauthenticated liveness endpoint on the resolved port", async () => { + const fetch = vi.fn(async () => ({ ok: true }) as Response); + + await expect( + probeDockerGatewayHealth({ + env: {}, + fetch, + getRuntimeConfig: () => ({ gateway: { port: 19002 } }), + readActiveGatewayLockPort: vi.fn(async () => 19000), + resolveGatewayPort: vi.fn(() => 19002), + }), + ).resolves.toBe(true); + expect(fetch).toHaveBeenCalledWith("http://127.0.0.1:19000/healthz"); + }); + + it("reports an unsuccessful or unreachable liveness endpoint as unhealthy", async () => { + const baseDeps = { + env: {}, + getRuntimeConfig: () => ({ gateway: { port: 19002 } }), + readActiveGatewayLockPort: vi.fn(async () => 19000), + resolveGatewayPort: vi.fn(() => 19002), + }; + + await expect( + probeDockerGatewayHealth({ + ...baseDeps, + fetch: vi.fn(async () => ({ ok: false }) as Response), + }), + ).resolves.toBe(false); + await expect( + probeDockerGatewayHealth({ + ...baseDeps, + fetch: vi.fn(async () => { + throw new Error("connection refused"); + }), + }), + ).resolves.toBe(false); + }); +}); diff --git a/src/docker-healthcheck.ts b/src/docker-healthcheck.ts new file mode 100644 index 000000000000..a39abb03d60b --- /dev/null +++ b/src/docker-healthcheck.ts @@ -0,0 +1,67 @@ +// Resolves and probes the Gateway port for the official Docker image healthcheck. +import { fileURLToPath } from "node:url"; +import { getRuntimeConfig } from "./config/config.js"; +import { resolveGatewayPort } from "./config/paths.js"; +import type { OpenClawConfig } from "./config/types.js"; +import { readActiveGatewayLockPort } from "./infra/gateway-lock.js"; +import { isMainModule } from "./infra/is-main.js"; + +type DockerHealthcheckPortDeps = { + env: NodeJS.ProcessEnv; + getRuntimeConfig: () => OpenClawConfig; + readActiveGatewayLockPort: (opts: { env: NodeJS.ProcessEnv }) => Promise; + resolveGatewayPort: (config: OpenClawConfig, env: NodeJS.ProcessEnv) => number; +}; + +type DockerHealthcheckDeps = Partial & { + fetch?: typeof globalThis.fetch; +}; + +export async function resolveDockerHealthcheckPort( + deps: Partial = {}, +): Promise { + const env = deps.env ?? process.env; + const readActivePort = deps.readActiveGatewayLockPort ?? readActiveGatewayLockPort; + + try { + // The live lock records CLI --port and is authoritative. Config/env only cover startup + // before the Gateway has acquired its lock or platforms where the owner cannot be verified. + const activePort = await readActivePort({ env }); + if (activePort !== undefined) { + return activePort; + } + } catch { + // A best-effort lock read must not hide a healthy Gateway on the configured port. + } + + const config = ( + deps.getRuntimeConfig ?? + (() => + getRuntimeConfig({ + pin: false, + skipPluginValidation: true, + skipShellEnvFallback: true, + })) + )(); + return (deps.resolveGatewayPort ?? resolveGatewayPort)(config, env); +} + +export async function probeDockerGatewayHealth(deps: DockerHealthcheckDeps = {}): Promise { + try { + const port = await resolveDockerHealthcheckPort(deps); + const response = await (deps.fetch ?? globalThis.fetch)(`http://127.0.0.1:${port}/healthz`); + return response.ok; + } catch { + return false; + } +} + +if ( + isMainModule({ + currentFile: fileURLToPath(import.meta.url), + }) +) { + void probeDockerGatewayHealth().then((healthy) => { + process.exitCode = healthy ? 0 : 1; + }); +} diff --git a/src/dockerfile.test.ts b/src/dockerfile.test.ts index 1216d5d462a2..6e235b23f2b0 100644 --- a/src/dockerfile.test.ts +++ b/src/dockerfile.test.ts @@ -8,6 +8,7 @@ import { describe, expect, it } from "vitest"; const repoRoot = resolve(fileURLToPath(new URL(".", import.meta.url)), ".."); const dockerfilePath = join(repoRoot, "Dockerfile"); +const dockerComposePath = join(repoRoot, "docker-compose.yml"); const dockerInstallDocsPath = join(repoRoot, "docs/install/docker.md"); const dockerReleaseWorkflowPath = join(repoRoot, ".github/workflows/docker-release.yml"); const fullReleaseValidationWorkflowPath = join( @@ -35,6 +36,16 @@ function resolveOptionalAptPackages(dockerfile: string, env: NodeJS.ProcessEnv): } describe("Dockerfile", () => { + it("runs the built port-aware Gateway liveness probe", async () => { + const dockerfile = collapseDockerContinuations(await readFile(dockerfilePath, "utf8")); + const compose = await readFile(dockerComposePath, "utf8"); + + expect(dockerfile).toContain('CMD ["node", "dist/docker-healthcheck.js"]'); + expect(dockerfile).not.toContain("127.0.0.1:18789/healthz"); + expect(compose).toContain('"dist/docker-healthcheck.js"'); + expect(compose).not.toContain("127.0.0.1:18789/healthz"); + }); + it("does not force an external Dockerfile frontend pull", async () => { for (const path of dockerSetupDockerfilePaths) { const dockerfile = await readFile(join(repoRoot, path), "utf8"); diff --git a/src/infra/tsdown-config.test.ts b/src/infra/tsdown-config.test.ts index fc09613f606e..e5099f0ff659 100644 --- a/src/infra/tsdown-config.test.ts +++ b/src/infra/tsdown-config.test.ts @@ -108,6 +108,7 @@ describe("tsdown config", () => { "media-understanding/apply.runtime", "index", "commands/status.summary.runtime", + "docker-healthcheck", "provider-dispatcher.runtime", "plugins/hook-runner-global", "plugins/provider-discovery.runtime", @@ -124,6 +125,12 @@ describe("tsdown config", () => { } }); + it("builds the Docker healthcheck as a stable dist entry", () => { + const distGraph = requireUnifiedDistGraph(); + + expect(entrySources(distGraph)["docker-healthcheck"]).toBe("src/docker-healthcheck.ts"); + }); + it("keeps root-package-excluded external plugins out of the root dist graph", () => { const distGraph = requireUnifiedDistGraph(); const keys = entryKeys(distGraph); diff --git a/tsdown.config.ts b/tsdown.config.ts index d98234e90825..908c5d09aea1 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -272,6 +272,7 @@ function buildCoreDistEntries(): Record { return { index: "src/index.ts", entry: "src/entry.ts", + "docker-healthcheck": "src/docker-healthcheck.ts", // Ensure this module is bundled as an entry so legacy CLI shims can resolve its exports. "cli/daemon-cli": "src/cli/daemon-cli.ts", // Keep long-lived lazy runtime boundaries on stable filenames so rebuilt