If icon
Fail icon
Request icon
ImageLs icon
OutputValues icon
Log icon
Pause icon
SlackIncomingWebhook icon
Schedule icon

Guard the Docker BuildKit Build Cache with a Ceiling and a Most-Recently-Used Floor

Detect BuildKit build cache over a ceiling, reclaim to a most-recently-used floor behind approval, then verify the daemon is under the ceiling.

Categories
Infrastructure

BuildKit cache is usually the largest thing on a CI host. It grows without a bound. docker builder prune is the only lever most teams reach for. That lever is blind: with no flags it keeps everything a build still references, with -a it throws away the cache the next build needs. So the disk alarm fires, someone flushes the whole cache by hand, every pipeline then runs cold for an hour.

This blueprint puts two watermarks around that cache. A ceiling decides when anything happens at all. A most-recently-used floor decides what survives. Between the two it reclaims least-recently-used records only, behind an approval gate, then reads the daemon again and fails the run if the cache is still over the ceiling or if a record inside the floor was evicted.

This blueprint was created by zkasuran.

Safety

A BuildKit prune cannot be scoped by label. There is no per-project, per-branch or per-team build cache: the daemon holds one cache and a prune applies to all of it. So this flow is only safe pointed at a build host you own end to end. Point it at a daemon shared with other teams, with production containers, with another CI tenant: an approved run takes their cache too. There is no undo. docker_host is a single daemon on purpose.

How it works

  1. check_watermarks refuses a floor at or above the ceiling before any daemon call, because that configuration can never satisfy the ceiling.
  2. read_cache reads /system/df?type=build-cache, which returns every cache record with its size, created time, last used time, usage count plus whether an image layer shares it.
  3. list_images lists the images on the same daemon through the Docker plugin.
  4. plan sizes the cache, splits it into the part a prune can release and the part image layers pin, ranks the releasable records by last used time, then marks the records that fit inside the floor as protected.
  5. report prints the numbers, including how much of the cache a prune cannot touch.
  6. Under the ceiling, the flow logs that and stops.
  7. Over the ceiling in read-only mode, it prints the reclaim plan plus the bytes it would release. It calls nothing.
  8. With auto_remediate true plus dry_run false, approve_reclaim pauses. On an approval, reclaim_build_cache calls the daemon's build cache prune with keep-storage set to the floor, so BuildKit drops unused records least-recently-used first until the releasable pool fits that floor.
  9. reread_cache reads the records again. verify compares the new total against the ceiling and checks every protected record is still there. verify_gate fails the run when either is wrong.

What you get

  • A cache guard that triggers on size, not on a cron that flushes everything every night.
  • A floor expressed in MiB of most-recently-used releasable cache, so the layers the last builds touched stay warm. BuildKit orders eviction by last used, not by created, so a record built days ago that today's build still hits survives.
  • The share of the cache that image layers pin, reported before the prune. This is the number that makes a naive prune look broken: docker system df counts a shared record as zero bytes while docker buildx du counts it in full. A cache prune can release none of it.
  • A read-back that fails the run, so a prune that reported success while the disk stayed full does not pass as green.
  • An approval gate on the one irreversible step.

Who it's for

  • Platform teams running self-hosted CI runners where BuildKit cache is the top disk consumer.
  • Anyone who has flushed a build cache by hand at 2am then watched every pipeline run cold.
  • Teams that want a cache budget per build host instead of a nightly prune -a.

Why orchestrate this with Kestra

The reclaim is a sequence, not a command: measure, classify, decide, pause for a human, write, measure again, fail if the write did not land. A cron calling docker builder prune has nowhere to put the floor, nowhere to put the approval plus no way to tell you it ran and achieved nothing. Here the inputs carry the policy, the If tasks carry the decision, the Pause carries the human, the re-read carries the proof. Kestra keeps the measurements as outputs, so the before and after numbers of every run stay attached to that run.

Tested end to end

Run against a real Docker 27.5.1 daemon on Kestra 2.0.5, with BuildKit cache generated by throwaway builds of 5 MB random layers. Every number below was read back with docker buildx du plus docker system df inside that daemon.

  • Under the ceiling: 4 records, 10,000,040 bytes against a 48 MiB ceiling. Zero action.
  • Over the ceiling, read-only: 10,000,040 bytes against an 8 MiB ceiling, reported 1,611,432 bytes over plus 5,805,736 releasable, called nothing. The daemon read 10 MB before plus after.
  • Approved reclaim: 9 records, 35,000,040 bytes against a 20 MiB ceiling with a 10 MiB floor. The daemon released 25,000,000 bytes across 5 records, the read-back found 4 records at 10,000,040 bytes, under the ceiling.
  • Declined approval: zero bytes released, 4 records at 10,000,040 bytes before plus after.
  • The floor held: the 4 protected ids survived the approved reclaim, confirmed by id in docker buildx du --verbose. Three of them were the oldest records in the set by creation time, kept because the last build touched them, while a record created 12 minutes later was evicted.
  • Re-verify failure: with 4 images pinning 20,000,000 bytes of cache, a 12 MiB ceiling plus a 5 MiB floor released only the 5,000,000 bytes that were free. The cache stayed at 25,000,040 bytes, the run failed, the Slack error block fired.
  • Inverted watermarks (floor at or above the ceiling) failed before any daemon call.

Prerequisites

Secrets

  • SLACK_WEBHOOK_URL: incoming webhook for the failure alert.

Inputs

  • docker_host: the build host daemon, over TCP, for example tcp://build-01:2375. The flow reads /system/df plus calls /build/prune, so it needs the Engine API reachable. A Unix socket works for the Docker plugin task but not for the two API calls.
  • api_version: pinned Engine API version, v1.47 by default.
  • max_cache_mb: the ceiling. Nothing happens below it.
  • mru_keep_mb: the floor the reclaim stops at. Must be below the ceiling.
  • dry_run: true by default.
  • auto_remediate: false by default.

Quick start

  1. Add the SLACK_WEBHOOK_URL secret.
  2. Set docker_host to one build host you own. Read the Safety note first.
  3. Run it as shipped. It reports the cache size, the pinned share plus the reclaim it would make. Nothing changes.
  4. Set the ceiling plus the floor to your disk budget. A floor around a third of the ceiling keeps recent layers warm without letting the cache sit at the limit.
  5. To reclaim, run with auto_remediate true plus dry_run false, then resume the pause with approved true.
  6. Enable the nightly trigger once the reported plan matches what you expect.

How to extend

  • Docker 28 renamed the prune parameter from keep-storage to reserved-space. On that daemon, bump api_version plus change the parameter name on reclaim_build_cache together.
  • Add an age floor with &filters={"until":{"24h":true}} on the prune call to keep anything used in the last day regardless of size.
  • Pinned cache needs the images gone first. Chain io.kestra.plugin.docker.cli.Prune with pruneType: IMAGES plus dangling: true after the verify when pinned_bytes is the thing holding the ceiling.
  • Send the plan to a dashboard instead of Slack by writing outputs.plan.values.p to your metrics store.
  • Run it per build host with a Loop over your runner list, one sub-execution each, so a floor can differ per host.

Links

See How

New to Kestra?

Use blueprints to kickstart your first workflows.