Skip to content

cloud-build-docker: reproducible image digests via buildx#34

Open
jwbron wants to merge 2 commits into
mainfrom
jwies-reproducible-build-digests
Open

cloud-build-docker: reproducible image digests via buildx#34
jwbron wants to merge 2 commits into
mainfrom
jwies-reproducible-build-digests

Conversation

@jwbron
Copy link
Copy Markdown
Contributor

@jwbron jwbron commented May 20, 2026

Summary

Reworks cloud-build-docker's cloudbuild.yml so the image digest it produces is a pure function of build content — two builds of identical sources yield the identical pushed digest.

BuildKit by default stamps the image config, per-step history entries, and file mtimes inside layers with wall-clock time, so byte-identical content produces a different digest on every build. Anything that pins images by digest (e.g. a Cloud Run job) then sees spurious "updates" on every rebuild.

What changed

Switches from docker build to docker buildx build with the docker-container driver, and:

  • --build-arg SOURCE_DATE_EPOCH=0 — clamps the image config + history[].created timestamps
  • --output type=image,...,rewrite-timestamp=true — clamps file mtimes inside the layer blobs (requires the docker-container driver; the default docker driver can't do it)
  • --provenance=false --sbom=false — attestations embed build metadata (timestamps, build IDs) that is non-deterministic and would defeat the clamping
  • --cache-from type=registry / --cache-to type=inline — replaces docker pull + the BUILDKIT_INLINE_CACHE build-arg; same branch-based layer caching
  • --output type=image,push=true — replaces the images: block; only $_IMAGE_TAG is ever pushed (so no cache-tag clobber — supersedes the earlier clobber fix)

Why the first commit wasn't enough

The first commit on this branch only added --build-arg SOURCE_DATE_EPOCH=0. Testing showed that clamps config/history timestamps but not layer file mtimes — two --no-cache builds still diverged (the RUN-layer blob differed). rewrite-timestamp is the missing piece, and it forces the buildx rework.

Verification (local, Docker 29.4 / BuildKit)

Build Two builds → digest
Control (no flags) differ
SOURCE_DATE_EPOCH build-arg only still differ (layer mtimes)
SOURCE_DATE_EPOCH + rewrite-timestamp + --provenance=false identical
+ cache pruned & re-imported from registry identical, 3 steps CACHED

Test plan

  • Tag a release (e.g. cloud-build-docker-v0.5.0) after merge.
  • Confirm gcr.io/cloud-builders/docker in Cloud Build provides docker buildx and that docker buildx create --driver docker-container works in that environment.
  • Confirm --output ...,push=true from the docker-container builder authenticates to Artifact Registry (buildx forwards the docker CLI credentials).
  • In a consumer repo, build the same source twice and confirm the manifest digest is identical, and that the runner terraform plan stops showing a recurring image-digest change.

jwbron added 2 commits May 19, 2026 18:19
Pass SOURCE_DATE_EPOCH=0 to the BuildKit build so the resulting image
digest is a pure function of content.

Without it, the Dockerfile frontend stamps the image config — the
top-level `created` field and every `history[].created` entry — with
the current wall-clock time. Two builds of byte-identical content
(identical layers, even a 100% cache hit) therefore produce different
image *config* blobs and thus different manifest digests.

Anything that pins images by digest then sees a phantom change on every
rebuild. In Khan/internal-services this kept the GitHub Actions Runner
Cloud Run job's image digest "drifting" between every terraform plan:
the runner image was rebuilt on each plan, got a new digest purely from
the timestamp, and the apply -> chained re-plan loop turned each one
into a fresh "image digest update" PR even though nothing in the image
had actually changed.

Clamping the epoch to a constant makes the config timestamps
deterministic, so the digest changes only when image content changes.

This addresses the config/history non-determinism specifically (which
is what was observed). Layer file mtimes for *rebuilt* layers are also
clamped by SOURCE_DATE_EPOCH; full base-image timestamp rewriting would
additionally need the BuildKit `rewrite-timestamp` image-exporter
option, which requires a buildx migration and is out of scope here.
The earlier commit on this branch only passed --build-arg
SOURCE_DATE_EPOCH=0. Testing showed that is insufficient: it clamps the
image config and history timestamps, but file mtimes inside rebuilt
layer blobs still drift, so two builds of identical content still
produce different digests on any cache-miss rebuild.

Full reproducibility additionally needs the image exporter's
rewrite-timestamp option, which clamps layer file mtimes. That option
requires the docker-container buildx driver (the default "docker"
driver does not support it), so this reworks the build from
`docker build` to `docker buildx build`:

  - docker-container driver, so rewrite-timestamp is available
  - SOURCE_DATE_EPOCH=0 + rewrite-timestamp=true: clamp every timestamp
    (config, history, and layer file mtimes) to a constant
  - --provenance=false --sbom=false: attestations embed build metadata
    that is non-deterministic and would defeat the clamping
  - --cache-from type=registry / --cache-to type=inline: replaces the
    old `docker pull` + BUILDKIT_INLINE_CACHE build-arg; same
    branch-based layer caching
  - --output type=image,push=true: replaces the `images:` block; only
    $_IMAGE_TAG is ever pushed (no cache-tag clobber)

Verified locally: two --no-cache builds, and a rebuild with the local
cache pruned and re-imported from the registry, all produce the
identical pushed manifest digest.
@jwbron jwbron changed the title cloud-build-docker: make image digests reproducible cloud-build-docker: reproducible image digests via buildx May 20, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant