The runner's base image, pushed as Wrangler builds images, with pushes that fail loudly
5 files+80−120/5 viewed
| 346 | 346 | so a runner change pushes about 5 MB (`scripts/deploy/image.mjs`). | |
| 347 | 347 | - **Ask.** Document `--cache-to type=registry` support on `registry.cloudflare.com`, or a hosted build | |
| 348 | 348 | cache. | |
| 349 | + | - **Also hit (2026-10-06).** The first push of the 2.7 GB base to `registry.cloudflare.com` failed after | |
| 350 | + | several layers with `error from registry: blob unknown to registry`, from `docker push` and from | |
| 351 | + | `wrangler containers push` alike. The same push, run again an hour later, found every layer there and | |
| 352 | + | finished in 17 s. The image was an OCI index carrying a BuildKit provenance attestation (an | |
| 353 | + | `unknown/unknown` manifest), the default for `docker buildx build --load`; Wrangler's own builds pass | |
| 354 | + | `--provenance=false`. We now build as Wrangler does (one manifest, no provenance or SBOM) and push up | |
| 355 | + | to three times, failing loudly when no digest comes back. Ask: say whether manifests may reference | |
| 356 | + | just-uploaded blobs at once, and whether indexes with attestation manifests are supported. | |
| 349 | 357 | ||
| 350 | 358 | ## Tooling and account | |
| 351 | 359 |
| 188 | 188 | image uploads only its own layer (about 5 MB): the base's layers are | |
| 189 | 189 | already there. | |
| 190 | 190 | ||
| 191 | + | Both are built as Wrangler builds images (`--platform linux/amd64 | |
| 192 | + | --provenance=false --sbom=false`): one manifest, not an OCI index with a | |
| 193 | + | BuildKit attestation beside it. A push is tried up to three times and counts | |
| 194 | + | only when Docker reports the digest; the first push of the base once failed | |
| 195 | + | with `blob unknown to registry` and went through when run again (see | |
| 196 | + | `docs/CLOUDFLARE_FEEDBACK.md`, C3). `build-base` and `image` exit non-zero | |
| 197 | + | when a push fails, and `base.json` is written only after the push. | |
| 198 | + | ||
| 191 | 199 | **The base** is recorded in `services/runner/base.json`: its reference, its | |
| 192 | 200 | digest, a hash of its folder (`inputs`), when it was built, its size and | |
| 193 | 201 | each toolchain's version. `node scripts/deploy.mjs build-base` builds it, | |
| 306 | 314 | `preserve_order` from `api` and `actions`, `digest` features from | |
| 307 | 315 | `secrets`), so each worker-build afterwards compiled its own variant | |
| 308 | 316 | again. | |
| 317 | + | - **The runner's images**, measured on the same machine on 2026-10-06 | |
| 318 | + | (Docker Desktop, 8 vCPUs; its disk was busy with other containers, so | |
| 319 | + | the cold figures are slow and noisy): | |
| 320 | + | ||
| 321 | + | | | Before (one image) | Now | | |
| 322 | + | | --- | --- | --- | | |
| 323 | + | | Size, unpacked / compressed (what a machine pulls) | 3.08 GB / 819 MB | 2.68 GB / 686 MB | | |
| 324 | + | | A change to the runner | Docker rebuilds the image's Rust stage and pushes the image (1198 s in the first deploy after a prune) | binary 46–53 s (6 s unchanged), image 7 s, push one 5 MB layer (1.4 s to a local registry) | | |
| 325 | + | | The base from nothing | 431 s (whole image, cold) | 758 s cold, rarely: weekly or when its folder changes | | |
| 326 | + | | The base after `docker builder prune` | as from nothing | 68 s, its layers pulled from the registry it was pushed to | | |
| 327 | + | | The runner binary, cold (builder container) | | 99 s | | |
| 328 | + | | A Rust CI job's build (events, search, repos; 4 vCPUs) | | 51 s cold, 13 s with the Cargo target restored (107 MB zstd entry) | | |
| 329 | + | ||
| 309 | 330 | - **Only what changed** is the largest saving: a change to one service | |
| 310 | 331 | deploys one service. | |
| 311 | 332 | ||
| 373 | 394 | `jobHosts`). Under flagon-io/g1t's **Settings → Guardrails** | |
| 374 | 395 | (Maintain role or higher), **Workflow-only domains**: | |
| 375 | 396 | ||
| 376 | − | ``` | |
| 397 | + | ```text | |
| 377 | 398 | api.cloudflare.com | deploy.yml | production | |
| 378 | 399 | registry.cloudflare.com | deploy.yml, runner-base.yml | production | |
| 379 | 400 | ``` |
| 14 | 14 | baseTag, | |
| 15 | 15 | contentHash, | |
| 16 | 16 | lockFor, | |
| 17 | + | pushImage, | |
| 18 | + | pushedDigest, | |
| 17 | 19 | readBaseLock, | |
| 18 | 20 | registryHas, | |
| 19 | 21 | removeDeployConfig, | |
| 215 | 217 | await assert.rejects(registryHas("not-a-reference", { fetchImpl, credentials })); | |
| 216 | 218 | }); | |
| 217 | 219 | ||
| 220 | + | test("a push counts only when it ends with a digest, and is tried again", async () => { | |
| 221 | + | const digest = "sha256:" + "a".repeat(64); | |
| 222 | + | assert.equal(pushedDigest({ code: 0, out: `base: digest: ${digest} size: 856` }), digest); | |
| 223 | + | assert.equal(pushedDigest({ code: 0, out: "error from registry: blob unknown to registry" }), null); | |
| 224 | + | assert.equal(pushedDigest({ code: 1, out: `digest: ${digest}` }), null); | |
| 225 | + | assert.equal(pushedDigest({ code: 0, out: "Pushed" }), null); | |
| 226 | + | const answers = [{ code: 1, out: "error from registry: blob unknown to registry" }, { code: 0, out: `x: digest: ${digest} size: 1` }]; | |
| 227 | + | assert.equal(await pushImage("r/x:y", { run: async () => answers.shift() }), digest); | |
| 228 | + | await assert.rejects(pushImage("r/x:y", { attempts: 2, run: async () => ({ code: 0, out: "blob unknown" }) }), /docker push r\/x:y failed/); | |
| 229 | + | }); | |
| 230 | + | ||
| 218 | 231 | test("a static Linux binary is told from a dynamic one", () => { | |
| 219 | 232 | const dir = mkdtempSync(join(tmpdir(), "g1t-elf-")); | |
| 220 | 233 | try { |
| 137 | 137 | if (creds && creds.until > Date.now() && (!push || creds.push)) return creds; | |
| 138 | 138 | const args = ["containers", "registries", "credentials", REGISTRY, "--pull", "--json", "--expiration-minutes", "60"]; | |
| 139 | 139 | if (push) args.push("--push"); | |
| 140 | − | const got = await wrangler(args, { cwd: ROOT }); | |
| 140 | + | // In a unit's folder, not the root, whose .env may hold a token for something else. | |
| 141 | + | const got = await wrangler(args, { cwd: join(ROOT, "services/runner") }); | |
| 141 | 142 | if (got.code !== 0) throw new Error(`could not get registry credentials:\n${lastLines(got.out)}`); | |
| 142 | 143 | const { username, password } = jsonFrom(got.out); | |
| 143 | 144 | creds = { username, password, push, until: Date.now() + 50 * 60 * 1000 }; | |
| 195 | 196 | */ | |
| 196 | 197 | export async function buildBase(unit, { tag, previous, onLine, account = ACCOUNT_ID, noCache = false }) { | |
| 197 | 198 | const ref = `${repositoryOf(unit, account)}:${tag}`; | |
| 198 | − | const args = ["buildx", "build", "--platform", "linux/amd64", "--progress", "plain", "--load", "--build-arg", "BUILDKIT_INLINE_CACHE=1", "-t", ref]; | |
| 199 | + | const args = ["buildx", "build", ...PLAIN_IMAGE, "--progress", "plain", "--load", "--build-arg", "BUILDKIT_INLINE_CACHE=1", "-t", ref]; | |
| 199 | 200 | if (previous && !noCache) args.push("--cache-from", `type=registry,ref=${previous}`); | |
| 200 | 201 | if (noCache) args.push("--no-cache"); | |
| 201 | 202 | args.push(join(ROOT, unit.image.base.context)); | |
| 222 | 223 | } | |
| 223 | 224 | ||
| 224 | 225 | /** Pushes `ref`; returns its digest in the registry. */ | |
| 225 | − | export async function pushImage(ref, { onLine } = {}) { | |
| 226 | − | const pushed = await exec("docker", ["push", ref], { onLine }); | |
| 227 | − | if (pushed.code !== 0) throw new Error(`docker push ${ref} failed:\n${lastLines(pushed.out)}`); | |
| 228 | − | return /digest: (sha256:[0-9a-f]{64})/.exec(pushed.out)?.[1] ?? null; | |
| 226 | + | export async function pushImage(ref, { onLine = () => {}, attempts = 3, run = exec } = {}) { | |
| 227 | + | let last = ""; | |
| 228 | + | for (let attempt = 1; attempt <= attempts; attempt++) { | |
| 229 | + | const pushed = await run("docker", ["push", ref], { onLine }); | |
| 230 | + | last = pushed.out; | |
| 231 | + | const digest = pushedDigest(pushed); | |
| 232 | + | if (digest) return digest; | |
| 233 | + | // Cloudflare's registry can answer "blob unknown" for a layer it has | |
| 234 | + | // just taken; pushing again finds the layers there and finishes. | |
| 235 | + | if (attempt < attempts) onLine(`docker push ${ref} did not finish (attempt ${attempt} of ${attempts}); trying again`); | |
| 236 | + | } | |
| 237 | + | throw new Error(`docker push ${ref} failed:\n${lastLines(last)}`); | |
| 238 | + | } | |
| 239 | + | ||
| 240 | + | /** | |
| 241 | + | * The digest a `docker push` ended with, or null if it did not end with | |
| 242 | + | * one, whatever its exit code: an error from the registry is a failure | |
| 243 | + | * even when Docker exits 0. | |
| 244 | + | */ | |
| 245 | + | export function pushedDigest({ code, out }) { | |
| 246 | + | if (code !== 0 || /error from registry|blob unknown|unknown blob|denied|unauthorized/i.test(out)) return null; | |
| 247 | + | return /digest: (sha256:[0-9a-f]{64})/.exec(out)?.[1] ?? null; | |
| 229 | 248 | } | |
| 230 | 249 | ||
| 231 | 250 | /** | |
| 251 | + | * Build flags for an image Cloudflare Containers takes as Wrangler builds | |
| 252 | + | * it: one manifest for linux/amd64, with no provenance or SBOM attestation, | |
| 253 | + | * which would make it an index with an `unknown/unknown` manifest. | |
| 254 | + | */ | |
| 255 | + | export const PLAIN_IMAGE = ["--platform", "linux/amd64", "--provenance=false", "--sbom=false"]; | |
| 256 | + | ||
| 257 | + | /** | |
| 232 | 258 | * Builds the runner's image: the base and the binary, from a build | |
| 233 | 259 | * context holding only the binary (target/runner-image). | |
| 234 | 260 | */ | |
| 235 | 261 | export async function buildRunnerImage(unit, { ref, base, binaryDir, onLine }) { | |
| 236 | 262 | const built = await exec( | |
| 237 | 263 | "docker", | |
| 238 | − | ["build", "--platform", "linux/amd64", "--progress", "plain", "--build-arg", `BASE=${base}`, "-f", join(ROOT, unit.image.dockerfile), "-t", ref, binaryDir], | |
| 264 | + | ["buildx", "build", ...PLAIN_IMAGE, "--progress", "plain", "--load", "--build-arg", `BASE=${base}`, "-f", join(ROOT, unit.image.dockerfile), "-t", ref, binaryDir], | |
| 239 | 265 | { onLine }, | |
| 240 | 266 | ); | |
| 241 | 267 | if (built.code !== 0) throw new Error(`docker build of the runner's image failed:\n${lastLines(built.out, 30)}`); |
| 1 | 1 | { | |
| 2 | 2 | "image": "registry.cloudflare.com/1e6f2cffa3f445920836e8ebe446bb58/g1t-runner:base-20261006-809f7651f6d9", | |
| 3 | − | "digest": null, | |
| 4 | − | "pushed": false, | |
| 3 | + | "digest": "sha256:17899aa70d29de209c1fc5bed77e6e090d338a1158b911ea8673ab3b811ef006", | |
| 4 | + | "pushed": true, | |
| 5 | 5 | "inputs": "809f7651f6d994e790729cd365be7100eb00e50b637bef313bb04361ab39f394", | |
| 6 | − | "built_at": "2026-10-06T07:43:55.123Z", | |
| 7 | − | "size_bytes": 2683372742, | |
| 6 | + | "built_at": "2026-10-06T08:22:38.137Z", | |
| 7 | + | "size_bytes": 2683372502, | |
| 8 | 8 | "versions": { | |
| 9 | 9 | "node": "v24.21.0", | |
| 10 | 10 | "npm": "11.19.0", |