mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
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:
committed by
GitHub
parent
9979ee312d
commit
bc4221a07e
+1
-1
@@ -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"]
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user