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==<build node>`
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.
This commit is contained in:
Ilyas Oruc 2026-07-14 21:24:05 +03:00
parent 7ba9818894
commit 568945e768
2 changed files with 118 additions and 18 deletions

View File

@ -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<never>>();
const getService = vi.fn(() => ({ inspect }));
const createService = vi.fn<
(opts: MockCreateServiceOptions) => Promise<void>
>(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<never>>();
const getService = vi.fn(() => ({ inspect }));
const createService = vi.fn<
(opts: MockCreateServiceOptions) => Promise<void>
>(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",
]);
});
});

View File

@ -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,