Skip to content

Commit 6d6ed47

Browse files
authored
feat(cli): deterministic image builds for deployments (#2778)
This PR makes our image builds deterministic and reproducible by ensuring that identical source code always produces the same image layers and image digest. This means that deployments where nothing has changed will no longer invalidate the image cache in our worker cluster nodes, thus avoid making the cold starts for runs worse. **Context** New deployments currently increase the cold start times for runs, as they generate a new image which needs to be pulled in the worker cluster where runs are executed. It happens also when the source code for the deployment has not changed due to non-deterministic steps in our build system. This addresses the latter issue by making builds reproducible. **Main changes** - Avoided baking `TRIGGER_DEPLOYMENT_ID` and `TRIGGER_DEPLOYMENT_VERSION` in the image, we now pass these via the supervisor instead. - Used `json-stable-stringify` for consistent key ordering in the files we generate for the build, e.g., `package.json`, `build.json`, `index.json`. - Removed `metafile.json` from the image contents as it is not actually used in the container. This is only relevant for the `analyze` command. - Added `SOURCE_DATE_EPOCH=0` and `rewrite-timestamp=true` to Docker builds to normalize file timestamps. - Removed some `timings` and `outputHashes` from build outputs and manifests. The builds are now reproducible for both native build server and Depot paths. This should also lead to better image layer cache reuse in general.
1 parent 7f7f993 commit 6d6ed47

File tree

15 files changed

+104
-46
lines changed

15 files changed

+104
-46
lines changed

.changeset/five-pens-fly.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"trigger.dev": minor
3+
"@trigger.dev/core": minor
4+
---
5+
6+
feat(cli): deterministic image builds for deployments

apps/supervisor/src/index.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -244,6 +244,12 @@ class ManagedSupervisor {
244244
}
245245

246246
try {
247+
if (!message.deployment.friendlyId) {
248+
// mostly a type guard, deployments always exists for deployed environments
249+
// a proper fix would be to use a discriminated union schema to differentiate between dequeued runs in dev and in deployed environments.
250+
throw new Error("Deployment is missing");
251+
}
252+
247253
await this.workloadManager.create({
248254
dequeuedAt: message.dequeuedAt,
249255
envId: message.environment.id,
@@ -252,6 +258,8 @@ class ManagedSupervisor {
252258
machine: message.run.machine,
253259
orgId: message.organization.id,
254260
projectId: message.project.id,
261+
deploymentFriendlyId: message.deployment.friendlyId,
262+
deploymentVersion: message.backgroundWorker.version,
255263
runId: message.run.id,
256264
runFriendlyId: message.run.friendlyId,
257265
version: message.version,

apps/supervisor/src/workloadManager/docker.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,8 @@ export class DockerWorkloadManager implements WorkloadManager {
7272
`TRIGGER_DEQUEUED_AT_MS=${opts.dequeuedAt.getTime()}`,
7373
`TRIGGER_POD_SCHEDULED_AT_MS=${Date.now()}`,
7474
`TRIGGER_ENV_ID=${opts.envId}`,
75+
`TRIGGER_DEPLOYMENT_ID=${opts.deploymentFriendlyId}`,
76+
`TRIGGER_DEPLOYMENT_VERSION=${opts.deploymentVersion}`,
7577
`TRIGGER_RUN_ID=${opts.runFriendlyId}`,
7678
`TRIGGER_SNAPSHOT_ID=${opts.snapshotFriendlyId}`,
7779
`TRIGGER_SUPERVISOR_API_PROTOCOL=${this.opts.workloadApiProtocol}`,

apps/supervisor/src/workloadManager/kubernetes.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,14 @@ export class KubernetesWorkloadManager implements WorkloadManager {
123123
name: "TRIGGER_ENV_ID",
124124
value: opts.envId,
125125
},
126+
{
127+
name: "TRIGGER_DEPLOYMENT_ID",
128+
value: opts.deploymentFriendlyId,
129+
},
130+
{
131+
name: "TRIGGER_DEPLOYMENT_VERSION",
132+
value: opts.deploymentVersion,
133+
},
126134
{
127135
name: "TRIGGER_SNAPSHOT_ID",
128136
value: opts.snapshotFriendlyId,

apps/supervisor/src/workloadManager/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ export interface WorkloadManagerCreateOptions {
2929
envType: EnvironmentType;
3030
orgId: string;
3131
projectId: string;
32+
deploymentFriendlyId: string;
33+
deploymentVersion: string;
3234
runId: string;
3335
runFriendlyId: string;
3436
snapshotId: string;

internal-packages/run-engine/src/engine/systems/dequeueSystem.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -559,6 +559,8 @@ export class DequeueSystem {
559559
friendlyId: result.worker.friendlyId,
560560
version: result.worker.version,
561561
},
562+
// TODO: use a discriminated union schema to differentiate between dequeued runs in dev and in deployed environments.
563+
// Would help make the typechecking stricter
562564
deployment: {
563565
id: result.deployment?.id,
564566
friendlyId: result.deployment?.friendlyId,

packages/cli-v3/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,10 +92,10 @@
9292
"@opentelemetry/resources": "2.0.1",
9393
"@opentelemetry/sdk-trace-node": "2.0.1",
9494
"@opentelemetry/semantic-conventions": "1.36.0",
95+
"@s2-dev/streamstore": "^0.17.6",
9596
"@trigger.dev/build": "workspace:4.2.0",
9697
"@trigger.dev/core": "workspace:4.2.0",
9798
"@trigger.dev/schema-to-json": "workspace:4.2.0",
98-
"@s2-dev/streamstore": "^0.17.6",
9999
"ansi-escapes": "^7.0.0",
100100
"braces": "^3.0.3",
101101
"c12": "^1.11.1",
@@ -117,6 +117,7 @@
117117
"import-in-the-middle": "1.11.0",
118118
"import-meta-resolve": "^4.1.0",
119119
"ini": "^5.0.0",
120+
"json-stable-stringify": "^1.3.0",
120121
"jsonc-parser": "3.2.1",
121122
"magicast": "^0.3.4",
122123
"minimatch": "^10.0.1",

packages/cli-v3/src/build/buildWorker.ts

Lines changed: 15 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import { join, relative, sep } from "node:path";
1212
import { generateContainerfile } from "../deploy/buildImage.js";
1313
import { writeFile } from "node:fs/promises";
1414
import { buildManifestToJSON } from "../utilities/buildManifest.js";
15-
import { readPackageJSON, writePackageJSON } from "pkg-types";
15+
import { readPackageJSON } from "pkg-types";
1616
import { writeJSONFile } from "../utilities/fileSystem.js";
1717
import { isWindows } from "std-env";
1818
import { pathToFileURL } from "node:url";
@@ -192,20 +192,23 @@ async function writeDeployFiles({
192192
) ?? {};
193193

194194
// Step 3: Write the resolved dependencies to the package.json file
195-
await writePackageJSON(join(outputPath, "package.json"), {
196-
...packageJson,
197-
name: packageJson.name ?? "trigger-project",
198-
dependencies: {
199-
...dependencies,
195+
await writeJSONFile(
196+
join(outputPath, "package.json"),
197+
{
198+
...packageJson,
199+
name: packageJson.name ?? "trigger-project",
200+
dependencies: {
201+
...dependencies,
202+
},
203+
trustedDependencies: Object.keys(dependencies).sort(),
204+
devDependencies: {},
205+
peerDependencies: {},
206+
scripts: {},
200207
},
201-
trustedDependencies: Object.keys(dependencies),
202-
devDependencies: {},
203-
peerDependencies: {},
204-
scripts: {},
205-
});
208+
true
209+
);
206210

207211
await writeJSONFile(join(outputPath, "build.json"), buildManifestToJSON(buildManifest));
208-
await writeJSONFile(join(outputPath, "metafile.json"), bundleResult.metafile);
209212
await writeContainerfile(outputPath, buildManifest);
210213
}
211214

packages/cli-v3/src/build/bundle.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -414,7 +414,9 @@ export async function createBuildManifestFromBundle({
414414
otelImportHook: {
415415
include: resolvedConfig.instrumentedPackageNames ?? [],
416416
},
417-
outputHashes: bundle.outputHashes,
417+
// `outputHashes` is only needed for dev builds for the deduplication mechanism during rebuilds.
418+
// For deploys builds, we omit it to ensure deterministic builds
419+
outputHashes: target === "dev" ? bundle.outputHashes : {},
418420
};
419421

420422
if (!workerDir) {

packages/cli-v3/src/deploy/buildImage.ts

Lines changed: 13 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,7 @@ async function remoteBuildImage(options: DepotBuildImageOptions): Promise<BuildI
201201
const outputOptions = getOutputOptions({
202202
imageTag: undefined, // This is already handled via the --save flag
203203
push: true, // We always push the image to the registry
204+
load: options.load,
204205
compression: options.compression,
205206
compressionLevel: options.compressionLevel,
206207
forceCompression: options.forceCompression,
@@ -213,18 +214,17 @@ async function remoteBuildImage(options: DepotBuildImageOptions): Promise<BuildI
213214
options.noCache ? "--no-cache" : undefined,
214215
"--platform",
215216
options.imagePlatform,
216-
options.load ? "--load" : undefined,
217217
"--provenance",
218218
"false",
219219
"--metadata-file",
220220
"metadata.json",
221221
"--build-arg",
222+
`SOURCE_DATE_EPOCH=0`,
223+
"--build-arg",
222224
`TRIGGER_PROJECT_ID=${options.projectId}`,
223225
"--build-arg",
224226
`TRIGGER_DEPLOYMENT_ID=${options.deploymentId}`,
225227
"--build-arg",
226-
`TRIGGER_DEPLOYMENT_VERSION=${options.deploymentVersion}`,
227-
"--build-arg",
228228
`TRIGGER_CONTENT_HASH=${options.contentHash}`,
229229
"--build-arg",
230230
`TRIGGER_PROJECT_REF=${options.projectRef}`,
@@ -534,6 +534,7 @@ async function localBuildImage(options: SelfHostedBuildImageOptions): Promise<Bu
534534
const outputOptions = getOutputOptions({
535535
imageTag,
536536
push,
537+
load,
537538
compression,
538539
compressionLevel,
539540
forceCompression,
@@ -563,18 +564,17 @@ async function localBuildImage(options: SelfHostedBuildImageOptions): Promise<Bu
563564
options.imagePlatform,
564565
options.network ? `--network=${options.network}` : undefined,
565566
addHost ? `--add-host=${addHost}` : undefined,
566-
load ? "--load" : undefined,
567567
"--provenance",
568568
"false",
569569
"--metadata-file",
570570
"metadata.json",
571571
"--build-arg",
572+
`SOURCE_DATE_EPOCH=0`,
573+
"--build-arg",
572574
`TRIGGER_PROJECT_ID=${options.projectId}`,
573575
"--build-arg",
574576
`TRIGGER_DEPLOYMENT_ID=${options.deploymentId}`,
575577
"--build-arg",
576-
`TRIGGER_DEPLOYMENT_VERSION=${options.deploymentVersion}`,
577-
"--build-arg",
578578
`TRIGGER_CONTENT_HASH=${options.contentHash}`,
579579
"--build-arg",
580580
`TRIGGER_PROJECT_REF=${options.projectRef}`,
@@ -588,8 +588,6 @@ async function localBuildImage(options: SelfHostedBuildImageOptions): Promise<Bu
588588
...(options.extraCACerts ? ["--build-arg", `NODE_EXTRA_CA_CERTS=${options.extraCACerts}`] : []),
589589
"--progress",
590590
"plain",
591-
"-t",
592-
imageTag,
593591
".", // The build context
594592
].filter(Boolean) as string[];
595593

@@ -814,15 +812,11 @@ USER bun
814812
WORKDIR /app
815813
816814
ARG TRIGGER_PROJECT_ID
817-
ARG TRIGGER_DEPLOYMENT_ID
818-
ARG TRIGGER_DEPLOYMENT_VERSION
819815
ARG TRIGGER_CONTENT_HASH
820816
ARG TRIGGER_PROJECT_REF
821817
ARG NODE_EXTRA_CA_CERTS
822818
823819
ENV TRIGGER_PROJECT_ID=\${TRIGGER_PROJECT_ID} \
824-
TRIGGER_DEPLOYMENT_ID=\${TRIGGER_DEPLOYMENT_ID} \
825-
TRIGGER_DEPLOYMENT_VERSION=\${TRIGGER_DEPLOYMENT_VERSION} \
826820
TRIGGER_CONTENT_HASH=\${TRIGGER_CONTENT_HASH} \
827821
TRIGGER_PROJECT_REF=\${TRIGGER_PROJECT_REF} \
828822
UV_USE_IO_URING=0 \
@@ -928,15 +922,11 @@ USER node
928922
WORKDIR /app
929923
930924
ARG TRIGGER_PROJECT_ID
931-
ARG TRIGGER_DEPLOYMENT_ID
932-
ARG TRIGGER_DEPLOYMENT_VERSION
933925
ARG TRIGGER_CONTENT_HASH
934926
ARG TRIGGER_PROJECT_REF
935927
ARG NODE_EXTRA_CA_CERTS
936928
937929
ENV TRIGGER_PROJECT_ID=\${TRIGGER_PROJECT_ID} \
938-
TRIGGER_DEPLOYMENT_ID=\${TRIGGER_DEPLOYMENT_ID} \
939-
TRIGGER_DEPLOYMENT_VERSION=\${TRIGGER_DEPLOYMENT_VERSION} \
940930
TRIGGER_CONTENT_HASH=\${TRIGGER_CONTENT_HASH} \
941931
TRIGGER_PROJECT_REF=\${TRIGGER_PROJECT_REF} \
942932
UV_USE_IO_URING=0 \
@@ -1129,18 +1119,20 @@ function shouldLoad(load?: boolean, push?: boolean) {
11291119
function getOutputOptions({
11301120
imageTag,
11311121
push,
1122+
load,
11321123
compression,
11331124
compressionLevel,
11341125
forceCompression,
11351126
}: {
11361127
imageTag?: string;
11371128
push?: boolean;
1129+
load?: boolean;
11381130
compression?: "zstd" | "gzip";
11391131
compressionLevel?: number;
11401132
forceCompression?: boolean;
11411133
}): string[] {
11421134
// Always use OCI media types for compatibility
1143-
const outputOptions: string[] = ["type=image", "oci-mediatypes=true"];
1135+
const outputOptions: string[] = ["type=image", "oci-mediatypes=true", "rewrite-timestamp=true"];
11441136

11451137
if (imageTag) {
11461138
outputOptions.push(`name=${imageTag}`);
@@ -1150,6 +1142,10 @@ function getOutputOptions({
11501142
outputOptions.push("push=true");
11511143
}
11521144

1145+
if (load) {
1146+
outputOptions.push("load=true");
1147+
}
1148+
11531149
// Only add compression args when using zstd (gzip is the default, no args needed)
11541150
if (compression === "zstd") {
11551151
outputOptions.push("compression=zstd");

0 commit comments

Comments
 (0)