From 568945e7684e74cafa2dfe78db07e84b1d60e0d8 Mon Sep 17 00:00:00 2001 From: Ilyas Oruc Date: Tue, 14 Jul 2026 21:24:05 +0300 Subject: [PATCH] fix(server): pin locally-built swarm services to the build node A locally built application image (git/dockerfile/buildpacks, no registry configured) only exists on the node that built it. The swarm service is created with no placement constraint, so on a multi-node swarm the scheduler can place a task on another node that lacks the image. That task is rejected with "No such image" while the previous task keeps running, so the deployment reports success ("done") but the app is never updated. Pin such services to the build node by adding a `node.id==` placement constraint (resolved from the deploy connection's `docker info` Swarm.NodeID), mirroring how bind mounts are already pinned. Only applied for locally-built images and only when the user has not configured their own placement; images pushed to a registry and external (docker) images are unaffected. --- .../server/mechanizeDockerContainer.test.ts | 120 +++++++++++++++--- packages/server/src/utils/builders/index.ts | 16 +++ 2 files changed, 118 insertions(+), 18 deletions(-) diff --git a/apps/dokploy/__test__/server/mechanizeDockerContainer.test.ts b/apps/dokploy/__test__/server/mechanizeDockerContainer.test.ts index daf2dbe54..04619f7f1 100644 --- a/apps/dokploy/__test__/server/mechanizeDockerContainer.test.ts +++ b/apps/dokploy/__test__/server/mechanizeDockerContainer.test.ts @@ -8,28 +8,37 @@ type MockCreateServiceOptions = { StopGracePeriod?: number; Ulimits?: Array<{ Name: string; Soft: number; Hard: number }>; }; + Placement?: { Constraints?: string[] }; }; [key: string]: unknown; }; -const { inspectMock, getServiceMock, createServiceMock, getRemoteDockerMock } = - vi.hoisted(() => { - const inspect = vi.fn<() => Promise>(); - const getService = vi.fn(() => ({ inspect })); - const createService = vi.fn< - (opts: MockCreateServiceOptions) => Promise - >(async () => undefined); - const getRemoteDocker = vi.fn(async () => ({ - getService, - createService, - })); - return { - inspectMock: inspect, - getServiceMock: getService, - createServiceMock: createService, - getRemoteDockerMock: getRemoteDocker, - }; - }); +const { + inspectMock, + getServiceMock, + createServiceMock, + getRemoteDockerMock, + infoMock, +} = vi.hoisted(() => { + const inspect = vi.fn<() => Promise>(); + const getService = vi.fn(() => ({ inspect })); + const createService = vi.fn< + (opts: MockCreateServiceOptions) => Promise + >(async () => undefined); + const info = vi.fn(async () => ({ Swarm: { NodeID: "node-123" } })); + const getRemoteDocker = vi.fn(async () => ({ + getService, + createService, + info, + })); + return { + inspectMock: inspect, + getServiceMock: getService, + createServiceMock: createService, + getRemoteDockerMock: getRemoteDocker, + infoMock: info, + }; +}); vi.mock("@dokploy/server/utils/servers/remote-docker", () => ({ getRemoteDocker: getRemoteDockerMock, @@ -70,9 +79,12 @@ describe("mechanizeDockerContainer", () => { getServiceMock.mockClear(); createServiceMock.mockClear(); getRemoteDockerMock.mockClear(); + infoMock.mockClear(); + infoMock.mockResolvedValue({ Swarm: { NodeID: "node-123" } }); getRemoteDockerMock.mockResolvedValue({ getService: getServiceMock, createService: createServiceMock, + info: infoMock, }); }); @@ -158,4 +170,76 @@ describe("mechanizeDockerContainer", () => { const [settings] = call; expect(settings.TaskTemplate?.ContainerSpec).not.toHaveProperty("Ulimits"); }); + + it("pins a locally built image to the build node", async () => { + const application = createApplication({ + sourceType: "github", + registry: null, + placementSwarm: null, + }); + + await mechanizeDockerContainer(application); + + const call = createServiceMock.mock.calls[0]; + if (!call) { + throw new Error("createServiceMock should have been called once"); + } + const [settings] = call; + expect(settings.TaskTemplate?.Placement?.Constraints).toContain( + "node.id==node-123", + ); + }); + + it("does not pin external (docker) images to the build node", async () => { + const application = createApplication({ sourceType: "docker" }); + + await mechanizeDockerContainer(application); + + const call = createServiceMock.mock.calls[0]; + if (!call) { + throw new Error("createServiceMock should have been called once"); + } + const [settings] = call; + expect(settings.TaskTemplate?.Placement?.Constraints ?? []).not.toContain( + "node.id==node-123", + ); + }); + + it("does not pin images that are pushed to a registry", async () => { + const application = createApplication({ + sourceType: "github", + registry: null, + rollbackRegistry: {} as ApplicationNested["rollbackRegistry"], + }); + + await mechanizeDockerContainer(application); + + const call = createServiceMock.mock.calls[0]; + if (!call) { + throw new Error("createServiceMock should have been called once"); + } + const [settings] = call; + expect(settings.TaskTemplate?.Placement?.Constraints ?? []).not.toContain( + "node.id==node-123", + ); + }); + + it("respects a user-defined placement instead of auto-pinning", async () => { + const application = createApplication({ + sourceType: "github", + registry: null, + placementSwarm: { Constraints: ["node.labels.zone==eu"] }, + }); + + await mechanizeDockerContainer(application); + + const call = createServiceMock.mock.calls[0]; + if (!call) { + throw new Error("createServiceMock should have been called once"); + } + const [settings] = call; + expect(settings.TaskTemplate?.Placement?.Constraints).toEqual([ + "node.labels.zone==eu", + ]); + }); }); diff --git a/packages/server/src/utils/builders/index.ts b/packages/server/src/utils/builders/index.ts index 82bbf4eff..5dd004cc9 100644 --- a/packages/server/src/utils/builders/index.ts +++ b/packages/server/src/utils/builders/index.ts @@ -126,6 +126,22 @@ export const mechanizeDockerContainer = async ( const authConfig = await getAuthConfig(application); const docker = await getRemoteDocker(application.serverId); + const isNodeLocalImage = + application.sourceType !== "docker" && + !application.registry && + !application.buildRegistry && + !application.rollbackRegistry; + + if (isNodeLocalImage && !application.placementSwarm) { + const buildNodeId = (await docker.info())?.Swarm?.NodeID; + if (buildNodeId) { + Placement.Constraints = [ + ...(Placement.Constraints ?? []), + `node.id==${buildNodeId}`, + ]; + } + } + const settings: CreateServiceOptions = { authconfig: authConfig, Name: appName,