fix(docker): keep custom-port containers healthy (#116639)

* fix(docker): probe the active gateway port

* fix(docker): register the healthcheck runtime boundary
This commit is contained in:
Peter Steinberger
2026-07-30 20:47:03 -07:00
committed by GitHub
parent 9979ee312d
commit bc4221a07e
8 changed files with 185 additions and 3 deletions
+1 -1
View File
@@ -381,6 +381,6 @@ USER node
# - aliases: /health and /ready # - aliases: /health and /ready
# For external access from host/ingress, override bind to "lan" and set auth. # For external access from host/ingress, override bind to "lan" and set auth.
HEALTHCHECK --interval=3m --timeout=10s --start-period=15s --retries=3 \ 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", "--"] ENTRYPOINT ["tini", "-s", "--"]
CMD ["node", "openclaw.mjs", "gateway"] CMD ["node", "openclaw.mjs", "gateway"]
+2
View File
@@ -103,6 +103,8 @@ const rootEntries = [
"openclaw.mjs!", "openclaw.mjs!",
"src/index.ts!", "src/index.ts!",
"src/entry.ts!", "src/entry.ts!",
// Built as the official image's Docker HEALTHCHECK entrypoint.
"src/docker-healthcheck.ts!",
// Shipped compatibility facade for statusCommand and getStatusSummary. // Shipped compatibility facade for statusCommand and getStatusSummary.
"src/commands/status.ts!", "src/commands/status.ts!",
"src/cli/daemon-cli.ts!", "src/cli/daemon-cli.ts!",
+1 -2
View File
@@ -81,8 +81,7 @@ services:
[ [
"CMD", "CMD",
"node", "node",
"-e", "dist/docker-healthcheck.js",
"fetch('http://127.0.0.1:18789/healthz').then((r)=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))",
] ]
interval: 30s interval: 30s
timeout: 5s timeout: 5s
+95
View File
@@ -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);
});
});
+67
View File
@@ -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<number | undefined>;
resolveGatewayPort: (config: OpenClawConfig, env: NodeJS.ProcessEnv) => number;
};
type DockerHealthcheckDeps = Partial<DockerHealthcheckPortDeps> & {
fetch?: typeof globalThis.fetch;
};
export async function resolveDockerHealthcheckPort(
deps: Partial<DockerHealthcheckPortDeps> = {},
): Promise<number> {
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<boolean> {
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;
});
}
+11
View File
@@ -8,6 +8,7 @@ import { describe, expect, it } from "vitest";
const repoRoot = resolve(fileURLToPath(new URL(".", import.meta.url)), ".."); const repoRoot = resolve(fileURLToPath(new URL(".", import.meta.url)), "..");
const dockerfilePath = join(repoRoot, "Dockerfile"); const dockerfilePath = join(repoRoot, "Dockerfile");
const dockerComposePath = join(repoRoot, "docker-compose.yml");
const dockerInstallDocsPath = join(repoRoot, "docs/install/docker.md"); const dockerInstallDocsPath = join(repoRoot, "docs/install/docker.md");
const dockerReleaseWorkflowPath = join(repoRoot, ".github/workflows/docker-release.yml"); const dockerReleaseWorkflowPath = join(repoRoot, ".github/workflows/docker-release.yml");
const fullReleaseValidationWorkflowPath = join( const fullReleaseValidationWorkflowPath = join(
@@ -35,6 +36,16 @@ function resolveOptionalAptPackages(dockerfile: string, env: NodeJS.ProcessEnv):
} }
describe("Dockerfile", () => { 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 () => { it("does not force an external Dockerfile frontend pull", async () => {
for (const path of dockerSetupDockerfilePaths) { for (const path of dockerSetupDockerfilePaths) {
const dockerfile = await readFile(join(repoRoot, path), "utf8"); const dockerfile = await readFile(join(repoRoot, path), "utf8");
+7
View File
@@ -108,6 +108,7 @@ describe("tsdown config", () => {
"media-understanding/apply.runtime", "media-understanding/apply.runtime",
"index", "index",
"commands/status.summary.runtime", "commands/status.summary.runtime",
"docker-healthcheck",
"provider-dispatcher.runtime", "provider-dispatcher.runtime",
"plugins/hook-runner-global", "plugins/hook-runner-global",
"plugins/provider-discovery.runtime", "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", () => { it("keeps root-package-excluded external plugins out of the root dist graph", () => {
const distGraph = requireUnifiedDistGraph(); const distGraph = requireUnifiedDistGraph();
const keys = entryKeys(distGraph); const keys = entryKeys(distGraph);
+1
View File
@@ -272,6 +272,7 @@ function buildCoreDistEntries(): Record<string, string> {
return { return {
index: "src/index.ts", index: "src/index.ts",
entry: "src/entry.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. // Ensure this module is bundled as an entry so legacy CLI shims can resolve its exports.
"cli/daemon-cli": "src/cli/daemon-cli.ts", "cli/daemon-cli": "src/cli/daemon-cli.ts",
// Keep long-lived lazy runtime boundaries on stable filenames so rebuilt // Keep long-lived lazy runtime boundaries on stable filenames so rebuilt