From c4b5e272584cdfa35b870b23841cb9f3cf7d38af Mon Sep 17 00:00:00 2001 From: Saadi Myftija Date: Fri, 14 Aug 2026 12:27:06 +0200 Subject: [PATCH] feat(cli): build deployment images on prebuilt base images (#4602) The generated deploy Containerfile now starts from the prebuilt base images published by base-images/ (`triggerdotdev/node` and `triggerdotdev/bun` on DockerHub, pinned by digest) instead of installing system packages during every project's build. Uncustomized projects run no apt at all and their base layers are identical across every project, so worker nodes cache one copy fleet-wide. The build stage uses the -build toolchain variant for uncustomized and package-only projects; projects with image instructions build FROM base so instructions and their downloads run exactly once. ### Notes - User packages install in their own sorted RUN with --allow-downgrades (a pin of a preinstalled package is a downgrade against the prebuilt base), preceded by a dpkg repair whenever instructions came first, since apt-get install refuses to run on state a dpkg -i instruction left broken. - Deployed runtime images inherit newer package versions than today's live-archive installs (the published bases upgrade everything to their snapshot), plus the base images' OCI labels. Runtime env, user, workdir, and entrypoint are unchanged. --- .changeset/prebuilt-base-images.md | 5 + packages/cli-v3/src/deploy/buildImage.test.ts | 129 +++++++++++++++++- packages/cli-v3/src/deploy/buildImage.ts | 120 ++++++++++------ 3 files changed, 210 insertions(+), 44 deletions(-) create mode 100644 .changeset/prebuilt-base-images.md diff --git a/.changeset/prebuilt-base-images.md b/.changeset/prebuilt-base-images.md new file mode 100644 index 000000000..4f3f0be09 --- /dev/null +++ b/.changeset/prebuilt-base-images.md @@ -0,0 +1,5 @@ +--- +"trigger.dev": patch +--- + +Deployment builds now use custom base layer images and no longer install system packages during every build. This improves layer caching resulting in both faster deployments and faster image pulls on the worker cluster side. diff --git a/packages/cli-v3/src/deploy/buildImage.test.ts b/packages/cli-v3/src/deploy/buildImage.test.ts index cbeb58c08..3540d7579 100644 --- a/packages/cli-v3/src/deploy/buildImage.test.ts +++ b/packages/cli-v3/src/deploy/buildImage.test.ts @@ -1,20 +1,36 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; import type { BuildRuntime } from "@trigger.dev/core/v3/schemas"; import { describe, expect, it } from "vitest"; -import { generateContainerfile } from "./buildImage.js"; +import { + BASE_IMAGE, + BUILD_IMAGE, + DEFAULT_PACKAGES, + TOOLCHAIN_PACKAGES, + generateContainerfile, +} from "./buildImage.js"; -const nodeImages: Array<[BuildRuntime, string]> = [ +const images: Array<[BuildRuntime, string, string]> = [ [ "node-24", - "node:24.18.0-bookworm-slim@sha256:6f7b03f7c2c8e2e784dcf9295400527b9b1270fd37b7e9a7285cf83b6951452d", + "triggerdotdev/node:24-bookworm@sha256:d2d0c01822409f6d2de1cc69a9e718424048fa12fc64b223dfa84f6db44d0ffd", + "triggerdotdev/node:24-bookworm-build@sha256:19322289508ae9b4be0b769acac179637e4b57d363844682f4b116feb951267d", ], [ "node-26", - "node:26.4.0-bookworm-slim@sha256:ec82d089a8ae2cf02628da7b34ea57dc357b24db724d557fe2d240e6beb659c1", + "triggerdotdev/node:26-bookworm@sha256:0e9b19f814f32d3766a8cf167835a499349df5bd34702611577b1f75bc2d2026", + "triggerdotdev/node:26-bookworm-build@sha256:de5cdfd683dabad582182c79779135d59faac0e6893b6cf04520d5a0dc826dd7", + ], + [ + "bun", + "triggerdotdev/bun:1.3-node20-bookworm@sha256:25b467196277b9d75a37773ee36d28b65ca81a6f41786f7d7d7f1fad95fb5a31", + "triggerdotdev/bun:1.3-node20-bookworm-build@sha256:a2d5e6d1ec25946ca1d86abdd9ffb9c589376df64ee1b490c461607953931245", ], ]; describe("generateContainerfile", () => { - it.each(nodeImages)("selects the pinned multiplatform image for %s", async (runtime, image) => { + it.each(images)("uses the pinned published base images for %s", async (runtime, base, build) => { const containerfile = await generateContainerfile({ runtime, build: {}, @@ -23,7 +39,108 @@ describe("generateContainerfile", () => { entrypoint: "entrypoint.js", }); - expect(containerfile).toContain(`FROM ${image} AS base`); + expect(containerfile).toContain(`FROM ${base} AS base`); + expect(containerfile).toContain(`FROM ${build} AS build`); + }); + + it.each(["node", "bun"] as BuildRuntime[])( + "runs no package installation for uncustomized projects on %s", + async (runtime) => { + const containerfile = await generateContainerfile({ + runtime, + build: {}, + image: undefined, + indexScript: "index.js", + entrypoint: "entrypoint.js", + }); + + expect(containerfile).not.toContain("apt-get"); + expect(containerfile).not.toContain("FROM base AS build"); + } + ); + + it("pairs every runtime image with its -build variant", () => { + for (const runtime of Object.keys(BASE_IMAGE) as BuildRuntime[]) { + const baseRef = BASE_IMAGE[runtime].split("@")[0]; + const buildRef = BUILD_IMAGE[runtime].split("@")[0]; + + expect(buildRef).toBe(`${baseRef}-build`); + expect(BASE_IMAGE[runtime]).toMatch(/@sha256:[a-f0-9]{64}$/); + expect(BUILD_IMAGE[runtime]).toMatch(/@sha256:[a-f0-9]{64}$/); + } + }); + + it("matches the published base image package lists", () => { + const imagesJson = JSON.parse( + readFileSync( + join(fileURLToPath(import.meta.url), "../../../../../base-images/images.json"), + "utf-8" + ) + ); + + expect(imagesJson.packages.split(" ").sort()).toEqual([...DEFAULT_PACKAGES].sort()); + expect(imagesJson.buildPackages).toBe(TOOLCHAIN_PACKAGES); + }); + + it("applies instructions once and builds FROM base when they are present", async () => { + const containerfile = await generateContainerfile({ + runtime: "node-22", + build: {}, + image: { + pkgs: ["jq", "curl", "git"], + instructions: ["RUN echo custom > /etc/marker"], + }, + indexScript: "index.js", + entrypoint: "entrypoint.js", + }); + + // sorted, defaults filtered out, downgrades allowed for pinned defaults, + // and repaired first: apt-get install refuses to run on dpkg state broken + // by an instruction + const installRun = `apt-get --fix-broken install -y --no-install-recommends && \\ + apt-get install -y --no-install-recommends --allow-downgrades curl jq`; + const first = containerfile.indexOf(installRun); + + expect(first).toBeGreaterThan(containerfile.indexOf("RUN echo custom > /etc/marker")); + expect(containerfile.indexOf(installRun, first + 1)).toBe(-1); + expect(containerfile).toContain("FROM base AS build"); + expect(containerfile).toContain(TOOLCHAIN_PACKAGES); + }); + + it("keeps the prebuilt toolchain image for package-only projects", async () => { + const containerfile = await generateContainerfile({ + runtime: "node-22", + build: {}, + image: { pkgs: ["jq"] }, + indexScript: "index.js", + entrypoint: "entrypoint.js", + }); + + const installLine = "apt-get install -y --no-install-recommends --allow-downgrades jq"; + const first = containerfile.indexOf(installLine); + + // installed in both stages, since base and build are separate images + expect(first).toBeGreaterThan(-1); + expect(containerfile.indexOf(installLine, first + 1)).toBeGreaterThan(first); + expect(containerfile).not.toContain("FROM base AS build"); + // a pristine base has nothing to repair + expect(containerfile).not.toContain("--fix-broken"); + }); + + it("repairs dpkg state after instructions when there are no user packages", async () => { + const containerfile = await generateContainerfile({ + runtime: "node-22", + build: {}, + image: { instructions: ["RUN echo custom > /etc/marker"] }, + indexScript: "index.js", + entrypoint: "entrypoint.js", + }); + + const instructions = containerfile.indexOf("RUN echo custom > /etc/marker"); + const repair = containerfile.indexOf("apt-get --fix-broken install -y"); + + expect(instructions).toBeGreaterThan(-1); + expect(repair).toBeGreaterThan(instructions); }); it.each(["node", "bun"] as BuildRuntime[])( diff --git a/packages/cli-v3/src/deploy/buildImage.ts b/packages/cli-v3/src/deploy/buildImage.ts index 210a70be3..0a077a5d8 100644 --- a/packages/cli-v3/src/deploy/buildImage.ts +++ b/packages/cli-v3/src/deploy/buildImage.ts @@ -687,18 +687,31 @@ export type GenerateContainerfileOptions = { entrypoint: string; }; -const BASE_IMAGE: Record = { - bun: "imbios/bun-node:1.3.3-20-slim@sha256:59d84856a7e31eec83afedadb542f7306f672343b8b265c70d733404a6e8834b", - node: "node:21.7.3-bookworm-slim@sha256:dfc05dee209a1d7adf2ef189bd97396daad4e97c6eaa85778d6f75205ba1b0fb", +// Prebuilt in base-images/; both maps must be bumped together, from one publish run +export const BASE_IMAGE: Record = { + bun: "triggerdotdev/bun:1.3-node20-bookworm@sha256:25b467196277b9d75a37773ee36d28b65ca81a6f41786f7d7d7f1fad95fb5a31", + node: "triggerdotdev/node:21-bookworm@sha256:49c6575cda32f63ac21a4aeaabc360dc50c6f767b86b675b44de7a9e9b6ca3fc", "node-22": - "node:22.16.0-bookworm-slim@sha256:048ed02c5fd52e86fda6fbd2f6a76cf0d4492fd6c6fee9e2c463ed5108da0e34", + "triggerdotdev/node:22-bookworm@sha256:3d1b59a1d50c3df713078a7b18386441cf7fdbaeea6da799247df5a2e180bdd5", "node-24": - "node:24.18.0-bookworm-slim@sha256:6f7b03f7c2c8e2e784dcf9295400527b9b1270fd37b7e9a7285cf83b6951452d", + "triggerdotdev/node:24-bookworm@sha256:d2d0c01822409f6d2de1cc69a9e718424048fa12fc64b223dfa84f6db44d0ffd", "node-26": - "node:26.4.0-bookworm-slim@sha256:ec82d089a8ae2cf02628da7b34ea57dc357b24db724d557fe2d240e6beb659c1", + "triggerdotdev/node:26-bookworm@sha256:0e9b19f814f32d3766a8cf167835a499349df5bd34702611577b1f75bc2d2026", }; -const DEFAULT_PACKAGES = ["busybox", "ca-certificates", "dumb-init", "git", "openssl"]; +export const BUILD_IMAGE: Record = { + bun: "triggerdotdev/bun:1.3-node20-bookworm-build@sha256:a2d5e6d1ec25946ca1d86abdd9ffb9c589376df64ee1b490c461607953931245", + node: "triggerdotdev/node:21-bookworm-build@sha256:98f2bc6beb124da3c3aa9abba587a6c3d08a1aada217b5bb91f843364184d1d0", + "node-22": + "triggerdotdev/node:22-bookworm-build@sha256:acc6f0143021f532b601bf9fa2cd7745b07612358f94acb8e1cd864468320a81", + "node-24": + "triggerdotdev/node:24-bookworm-build@sha256:19322289508ae9b4be0b769acac179637e4b57d363844682f4b116feb951267d", + "node-26": + "triggerdotdev/node:26-bookworm-build@sha256:de5cdfd683dabad582182c79779135d59faac0e6893b6cf04520d5a0dc826dd7", +}; + +// Preinstalled in the published base images; must match base-images/images.json +export const DEFAULT_PACKAGES = ["busybox", "ca-certificates", "dumb-init", "git", "openssl"]; export async function generateContainerfile(options: GenerateContainerfileOptions) { switch (options.runtime) { @@ -714,6 +727,31 @@ export async function generateContainerfile(options: GenerateContainerfileOption } } +// repair: apt-get install refuses to run on dpkg state an instruction left +// broken (the dpkg -i pattern). --allow-downgrades: a user pin of a +// preinstalled package is a downgrade by the time this runs. +function aptInstall(packages: string[], { repair }: { repair: boolean }): string { + const repairStep = repair + ? `apt-get --fix-broken install -y --no-install-recommends && \\ + ` + : ""; + + return `RUN apt-get update && \\ + ${repairStep}apt-get install -y --no-install-recommends --allow-downgrades ${packages.join(" ")} && \\ + apt-get clean && \\ + rm -rf /var/lib/apt/lists/*`; +} + +function aptRepair(): string { + return `RUN apt-get update && \\ + apt-get --fix-broken install -y --no-install-recommends && \\ + apt-get clean && \\ + rm -rf /var/lib/apt/lists/*`; +} + +// Must match base-images/images.json buildPackages, which the -build images preinstall +export const TOOLCHAIN_PACKAGES = "python3 make g++"; + const parseGenerateOptions = (options: GenerateContainerfileOptions) => { const buildArgs = Object.entries(options.build.env || {}) .flatMap(([key]) => `ARG ${key}`) @@ -726,43 +764,59 @@ const parseGenerateOptions = (options: GenerateContainerfileOptions) => { const postInstallCommands = (options.build.commands || []).map((cmd) => `RUN ${cmd}`).join("\n"); const baseInstructions = (options.image?.instructions || []).join("\n"); - const packages = Array.from(new Set(DEFAULT_PACKAGES.concat(options.image?.pkgs || []))).join( - " " - ); + const userPackages = Array.from(new Set(options.image?.pkgs || [])) + .filter((pkg) => !DEFAULT_PACKAGES.includes(pkg)) + .sort(); + + const customization = [ + baseInstructions, + userPackages.length > 0 + ? aptInstall(userPackages, { repair: baseInstructions.length > 0 }) + : baseInstructions + ? aptRepair() + : "", + ] + .filter(Boolean) + .join("\n\n"); + + // Instructions run once (FROM base) since their downloads are unbounded; + // package-only projects keep the prebuilt toolchain and repeat the small install + const buildStage = baseInstructions + ? `FROM base AS build + +RUN apt-get update && \\ + apt-get install -y --no-install-recommends ${TOOLCHAIN_PACKAGES} && \\ + apt-get clean && \\ + rm -rf /var/lib/apt/lists/*` + : `FROM ${BUILD_IMAGE[options.runtime]} AS build + +ENV DEBIAN_FRONTEND=noninteractive${ + userPackages.length > 0 ? `\n\n${aptInstall(userPackages, { repair: false })}` : "" + }`; return { baseImage: BASE_IMAGE[options.runtime], - baseInstructions, + buildStage, + customization, buildArgs, buildEnvVars, - packages, postInstallCommands, }; }; async function generateBunContainerfile(options: GenerateContainerfileOptions) { - const { baseImage, buildArgs, buildEnvVars, postInstallCommands, baseInstructions, packages } = + const { baseImage, buildStage, buildArgs, buildEnvVars, postInstallCommands, customization } = parseGenerateOptions(options); return `# syntax=docker/dockerfile:1 # check=skip=SecretsUsedInArgOrEnv FROM ${baseImage} AS base -${baseInstructions} - ENV DEBIAN_FRONTEND=noninteractive -RUN apt-get update && \ - apt-get --fix-broken install -y && \ - apt-get install -y --no-install-recommends ${packages} && \ - apt-get clean && \ - rm -rf /var/lib/apt/lists/* -FROM base AS build +${customization} -RUN apt-get update && \ - apt-get install -y --no-install-recommends python3 make g++ && \ - apt-get clean && \ - rm -rf /var/lib/apt/lists/* +${buildStage} USER bun WORKDIR /app @@ -853,28 +907,18 @@ CMD [] } async function generateNodeContainerfile(options: GenerateContainerfileOptions) { - const { baseImage, buildArgs, buildEnvVars, postInstallCommands, baseInstructions, packages } = + const { baseImage, buildStage, buildArgs, buildEnvVars, postInstallCommands, customization } = parseGenerateOptions(options); return `# syntax=docker/dockerfile:1 # check=skip=SecretsUsedInArgOrEnv FROM ${baseImage} AS base -${baseInstructions} - ENV DEBIAN_FRONTEND=noninteractive -RUN apt-get update && \ - apt-get --fix-broken install -y && \ - apt-get install -y --no-install-recommends ${packages} && \ - apt-get clean && rm -rf /var/lib/apt/lists/* -FROM base AS build +${customization} -# Install build dependencies -RUN apt-get update && \ - apt-get install -y --no-install-recommends python3 make g++ && \ - apt-get clean && \ - rm -rf /var/lib/apt/lists/* +${buildStage} USER node WORKDIR /app