id: docker-build-cache-reclaim-guard
namespace: company.team
description: >-
Guard the BuildKit build cache on one Docker build host with a high watermark
that triggers and a most-recently-used floor that survives. The flow reads the
daemon's own build cache records, works out how much of the cache is shared
with image layers and cannot be released, reclaims down to the floor behind an
approval gate, then reads the records again and fails the run if the cache is
still over the ceiling or if a record inside the floor was evicted. A BuildKit
prune is daemon global, so point this at a single build host.
labels:
system: docker
surface: buildkit
inputs:
- id: docker_host
type: STRING
displayName: Docker host
description: >-
The daemon whose build cache is guarded, reached over TCP, for example
tcp://build-01:2375. A BuildKit prune cannot be scoped by label, so it
applies to every build on this daemon. Never point this at a shared or
production daemon.
defaults: "tcp://build-01:2375"
- id: api_version
type: STRING
displayName: Docker Engine API version
description: >-
Pinned API version used for the two daemon calls. Docker 28 renamed the
prune parameter, so bump this only together with the parameter name on the
reclaim task.
defaults: "v1.47"
- id: max_cache_mb
type: INT
displayName: Build cache ceiling in MiB
description: Total build cache above this size triggers the flow. Nothing
happens below it.
defaults: 20480
- id: mru_keep_mb
type: INT
displayName: Most-recently-used floor in MiB
description: >-
The reclaim stops here. BuildKit evicts least-recently-used records first,
so this many MiB of the cache the builds touched last stays in place. Must
be below the ceiling.
defaults: 8192
- id: dry_run
type: BOOL
displayName: Dry run
description: True reports the reclaim without calling the daemon's prune endpoint.
defaults: true
- id: auto_remediate
type: BOOL
displayName: Auto remediate
description: False keeps the flow read-only. A reclaim needs this true, dry_run
false plus an approval.
defaults: false
tasks:
- id: check_watermarks
type: io.kestra.plugin.core.flow.If
description: A floor at or above the ceiling can never satisfy the ceiling, so
stop before touching the daemon.
condition: "{{ inputs.mru_keep_mb >= inputs.max_cache_mb }}"
then:
- id: watermarks_inverted
type: io.kestra.plugin.core.execution.Fail
errorMessage: >-
mru_keep_mb is {{ inputs.mru_keep_mb }} MiB and max_cache_mb is {{
inputs.max_cache_mb }} MiB. The floor must be below the ceiling.
- id: read_cache
type: io.kestra.plugin.core.http.Request
description: Read-only. Every build cache record on the daemon with its size,
last used time plus whether an image layer shares it.
uri: "{{ inputs.docker_host | replace({'tcp://': 'http://'}) }}/{{
inputs.api_version }}/system/df?type=build-cache"
method: GET
- id: list_images
type: io.kestra.plugin.docker.cli.ImageLs
description: Read-only. Images on the same daemon, used to explain the share of
the cache a prune cannot release.
host: "{{ inputs.docker_host }}"
showAll: true
- id: plan
type: io.kestra.plugin.core.output.OutputValues
description: >-
Size the cache, split it into the part a prune can release and the part
image layers pin, then rank the releasable records by last used time and
mark the ones inside the floor as protected.
values:
p: >-
{{ {'df': outputs.read_cache.body, 'ceiling': inputs.max_cache_mb *
1048576, 'floor': inputs.mru_keep_mb * 1048576} | toJson | jq('
(.df | fromjson) as $d | .ceiling as $ceiling | .floor as $floor
| ($d.BuildCache // []) as $r
| ([$r[].Size] | add // 0) as $total
| ([$r[] | select(.Shared == true) | .Size] | add // 0) as $pinned
| ([$r[] | select(.Shared != true)] | sort_by(.LastUsedAt // .CreatedAt // "") | reverse) as $mru
| ([$mru[].Size] | add // 0) as $releasable
| [foreach $mru[] as $x (0; . + $x.Size; {id: $x.ID, used: ($x.LastUsedAt // $x.CreatedAt // ""), size: $x.Size, cum: .})] as $cum
| [$cum[] | select(.cum <= $floor)] as $prot
| (if $releasable < $floor then $releasable else $floor end) as $kept
| ($pinned + $kept) as $predicted
| {records: ($r | length), total_bytes: $total, pinned_bytes: $pinned,
pinned_records: ([$r[] | select(.Shared == true)] | length),
releasable_bytes: $releasable, ceiling_bytes: $ceiling, floor_bytes: $floor,
over_bytes: (if $total > $ceiling then $total - $ceiling else 0 end),
release_bytes: ($releasable - $kept), predicted_bytes: $predicted,
mru_id: ($mru[0].ID // ""), mru_used: ($mru[0].LastUsedAt // ""),
protected_ids: [$prot[] | .id], protected_bytes: ([$prot[] | .cum] | max // 0),
ceiling_reachable: ($predicted <= $ceiling)}') | first | toJson }}
- id: report
type: io.kestra.plugin.core.log.Log
message: >-
Build cache on {{ inputs.docker_host }}: {{
fromJson(outputs.plan.values.p).records }} records, {{
fromJson(outputs.plan.values.p).total_bytes }} bytes against a ceiling of
{{ fromJson(outputs.plan.values.p).ceiling_bytes }} bytes. {{
fromJson(outputs.plan.values.p).pinned_bytes }} bytes in {{
fromJson(outputs.plan.values.p).pinned_records }} records are shared with
image layers ({{ outputs.list_images.images | length }} images on this
daemon), so a cache prune cannot release them. Releasable now: {{
fromJson(outputs.plan.values.p).releasable_bytes }} bytes. Over the
ceiling by {{ fromJson(outputs.plan.values.p).over_bytes }} bytes. Floor
{{ fromJson(outputs.plan.values.p).floor_bytes }} bytes protects {{
fromJson(outputs.plan.values.p).protected_ids | length }} records ({{
fromJson(outputs.plan.values.p).protected_bytes }} bytes), most recent {{
fromJson(outputs.plan.values.p).mru_id }} last used {{
fromJson(outputs.plan.values.p).mru_used }}.
- id: over_ceiling
type: io.kestra.plugin.core.flow.If
condition: "{{ fromJson(outputs.plan.values.p).over_bytes > 0 }}"
then:
- id: pinned_blocks_ceiling
type: io.kestra.plugin.core.log.Log
runIf: "{{ not fromJson(outputs.plan.values.p).ceiling_reachable }}"
level: WARN
message: >-
Image layers pin {{ fromJson(outputs.plan.values.p).pinned_bytes }}
bytes of cache, which a build cache prune cannot release. Even a full
reclaim down to the floor leaves {{
fromJson(outputs.plan.values.p).predicted_bytes }} bytes against a
ceiling of {{ fromJson(outputs.plan.values.p).ceiling_bytes }} bytes.
Remove the images that hold those layers first.
- id: planned_reclaim
type: io.kestra.plugin.core.log.Log
message: >-
Reclaim plan: drop least-recently-used records until the releasable
pool fits {{ fromJson(outputs.plan.values.p).floor_bytes }} bytes,
which releases {{ fromJson(outputs.plan.values.p).release_bytes }}
bytes and leaves about {{
fromJson(outputs.plan.values.p).predicted_bytes }} bytes, keeping {{
fromJson(outputs.plan.values.p).protected_ids | length }} protected
records in place.
- id: read_only_notice
type: io.kestra.plugin.core.log.Log
runIf: "{{ inputs.dry_run or not inputs.auto_remediate }}"
message: >-
Reported only. dry_run is {{ inputs.dry_run }} plus auto_remediate is
{{ inputs.auto_remediate }}, so the daemon prune endpoint was not
called. No cache record was removed.
- id: reclaim_gate
type: io.kestra.plugin.core.flow.If
condition: "{{ inputs.auto_remediate and not inputs.dry_run }}"
then:
- id: approve_reclaim
type: io.kestra.plugin.core.flow.Pause
description: >-
A BuildKit prune cannot be undone. Resume with approved true to
reclaim, approved false to walk away. An unanswered resume reads
as false.
onResume:
- id: approved
type: BOOL
displayName: Reclaim the build cache on this daemon
defaults: false
- id: apply_reclaim
type: io.kestra.plugin.core.flow.If
condition: "{{ outputs.approve_reclaim.onResume.approved ?? false }}"
then:
- id: reclaim_build_cache
type: io.kestra.plugin.core.http.Request
description: >-
The daemon's own build cache prune, floored. keep-storage is
the only interface that keeps a most-recently-used working
set. It applies to the releasable pool only, so BuildKit drops
unused records least-recently-used first until that pool fits
the floor. Records an image layer shares are left alone.
uri: "{{ inputs.docker_host | replace({'tcp://': 'http://'}) }}/{{
inputs.api_version }}/build/prune?keep-storage={{
fromJson(outputs.plan.values.p).floor_bytes }}"
method: POST
- id: reclaim_result
type: io.kestra.plugin.core.log.Log
message: >-
Daemon reclaimed {{ outputs.reclaim_build_cache.body |
jq('.SpaceReclaimed') | first }} bytes across {{
outputs.reclaim_build_cache.body | jq('.CachesDeleted |
length') | first }} cache records.
- id: reread_cache
type: io.kestra.plugin.core.http.Request
description: Read the records again. The prune reporting success says nothing
about the size that is left.
uri: "{{ inputs.docker_host | replace({'tcp://': 'http://'}) }}/{{
inputs.api_version }}/system/df?type=build-cache"
method: GET
- id: verify
type: io.kestra.plugin.core.output.OutputValues
values:
v: >-
{{ {'df': outputs.reread_cache.body, 'ceiling':
inputs.max_cache_mb * 1048576,
'before': fromJson(outputs.plan.values.p).total_bytes,
'protected': fromJson(outputs.plan.values.p).protected_ids} | toJson | jq('
(.df | fromjson) as $d | .ceiling as $ceiling | .before as $before
| ($d.BuildCache // []) as $r | [$r[].ID] as $ids
| ([$r[].Size] | add // 0) as $after
| {after_bytes: $after, records: ($r | length), freed_bytes: ($before - $after),
under_ceiling: ($after <= $ceiling),
kept: [.protected[] | select(. as $p | ($ids | index($p)) != null)],
lost: [.protected[] | select(. as $p | ($ids | index($p)) == null)]}') | first | toJson }}
- id: verify_result
type: io.kestra.plugin.core.log.Log
message: >-
After the reclaim: {{
fromJson(outputs.verify.values.v).records }} records, {{
fromJson(outputs.verify.values.v).after_bytes }} bytes, freed
{{ fromJson(outputs.verify.values.v).freed_bytes }} bytes.
Under ceiling: {{
fromJson(outputs.verify.values.v).under_ceiling }}. Protected
records still present: {{
fromJson(outputs.verify.values.v).kept | length }} of {{
fromJson(outputs.plan.values.p).protected_ids | length }}.
- id: verify_gate
type: io.kestra.plugin.core.flow.If
description: The run fails if the cache is still over the ceiling or if the
floor did not hold.
condition: "{{ (fromJson(outputs.verify.values.v).under_ceiling == false) or
(fromJson(outputs.verify.values.v).lost | length > 0) }}"
then:
- id: reclaim_did_not_land
type: io.kestra.plugin.core.execution.Fail
errorMessage: >-
Build cache on {{ inputs.docker_host }} is {{
fromJson(outputs.verify.values.v).after_bytes }} bytes
after the reclaim against a ceiling of {{
fromJson(outputs.plan.values.p).ceiling_bytes }} bytes,
with {{ fromJson(outputs.verify.values.v).lost | length }}
protected records lost. {{
fromJson(outputs.plan.values.p).pinned_bytes }} bytes are
pinned by image layers.
else:
- id: reclaim_landed
type: io.kestra.plugin.core.log.Log
message: >-
Build cache is back under the ceiling at {{
fromJson(outputs.verify.values.v).after_bytes }} bytes
with the floor intact.
else:
- id: reclaim_declined
type: io.kestra.plugin.core.log.Log
message: Reclaim declined at the approval gate. The build cache was not touched.
else:
- id: under_ceiling
type: io.kestra.plugin.core.log.Log
message: >-
Build cache is {{ fromJson(outputs.plan.values.p).total_bytes }}
bytes, under the ceiling of {{
fromJson(outputs.plan.values.p).ceiling_bytes }} bytes. Nothing to
reclaim.
errors:
- id: alert_failure
type: io.kestra.plugin.slack.notifications.SlackIncomingWebhook
url: "{{ secret('SLACK_WEBHOOK_URL') }}"
payload: |
{{ {'text': 'Build cache guard failed on ' ~ inputs.docker_host ~ ' (execution ' ~ execution.id ~ '). The cache may still be over the ceiling, check the logs.'} | toJson }}
triggers:
- id: nightly
type: io.kestra.plugin.core.trigger.Schedule
description: Shipped disabled. Reports in read-only mode until you change the inputs.
cron: "20 3 * * *"
disabled: true
inputs:
dry_run: true
auto_remediate: false