Request icon
OutputValues icon
Loop icon
Log icon
If icon
SlackIncomingWebhook icon
Pause icon
Rm icon
Fail icon
Schedule icon

Reap Exited Docker Containers by Age and Label

Remove terminal Docker containers past an age threshold, scoped by label and a protect-list, with their anonymous volumes, never forced, then verify.

Categories
Infrastructure

Exited, created and dead containers pile up on CI runners and build hosts. Every one of them still pins its writable layer and its anonymous volumes, so the disk fills with the remains of builds that finished weeks ago. docker system prune frees it, but it is all-or-nothing and age-blind: it takes the container someone kept to read its logs, the one a job exited two minutes ago, the data volume nobody meant to lose. So teams either never run it or run it and lose something.

This blueprint reaps by age inside a boundary you set. It reads only containers carrying a label you apply yourself, keeps anything matching a protect-list, keeps anything that stopped recently, caps how many go in one run, then removes the rest with their anonymous volumes and without --force. Then it lists the host again and fails the run if a target survived.

This blueprint was created by zkasuran.

Safety

Deleting containers is not reversible, so the blueprint is built so that it cannot reach anything but its own target:

  • Label scoping. The Engine API query filters on label_selector, so a container without that label is never even returned, let alone removed. The label is one you apply to the containers your builds create.
  • A protect-list. protected_name_pattern wins over every other rule. A container matching it is kept even when it carries the label and is old.
  • force: false. A running container is refused by the daemon rather than killed. This is what makes the gap between detection and removal safe: a container that started again in between stays up, so the verification step fails the run instead of reporting success.
  • An age threshold. Nothing that stopped inside min_age_minutes is a target.
  • A removal ceiling. max_removals bounds one run, so a selector that matches more than you expected cannot empty a host in one execution.
  • Three gates before any write. auto_remediate true, dry_run false, then an approved Pause. The shipped defaults and the disabled schedule are read-only.

How it works

  1. list_terminal (io.kestra.plugin.core.http.Request, read-only) asks the Docker Engine API for containers matching label_selector and sitting in one of target_states. candidates keeps their ids and names.

  2. inspect_each (io.kestra.plugin.core.flow.Loop, read-only) inspects each candidate. The stop time, the exit code and the anonymous volume names are in the inspect payload only, not in the list.

  3. window freezes the age cutoff once, so detection and verification agree. assess gives every candidate a verdict:

    • KEEP_RUNNING: the daemon reports it running;
    • KEEP_PROTECTED: its name matches protected_name_pattern;
    • KEEP_FRESH: it stopped after the cutoff (a never-started container is dated by its creation time);
    • KEEP_OVER_CAP: eligible, but beyond max_removals for this run;
    • REAP: terminal, old, unprotected, inside the cap.

    summary logs the counts and every target.

  4. route stops with a log line when there is nothing to reap. Otherwise findings_alert posts the plan to Slack and remediate_gate checks auto_remediate and dry_run.

  5. hold (io.kestra.plugin.core.flow.Pause) waits for approval. remediate_reap (Loop, concurrencyLimit: 1) then runs one io.kestra.plugin.docker.cli.Rm per target with removeVolumes: true and force: false.

  6. relist reads every labelled container again, in any state. result compares the plan with the host. verify fails the run if a target is still present or if a container the plan kept has gone.

  7. errors alerts Slack when the flow fails, including when the verification gate fails.

What you get

  • Age-based container reaping with a boundary, instead of an all-or-nothing prune.
  • Anonymous volumes removed with the container that owns them, so a reaped build leaves nothing behind.
  • A run that fails when the removal did not land, so a stuck host cannot read as a clean one.

Who it's for

  • Teams running self-hosted CI runners (GitHub Actions, GitLab, Buildkite, Jenkins agents) whose disks fill with finished job containers.
  • Platform teams operating shared build hosts or Docker-based edge nodes.
  • Anyone who has lost a container to docker system prune and stopped running it since.

Why orchestrate this with Kestra

A cron job running docker container prune --filter until=24h cannot keep a protect-list, cannot stop at a ceiling, cannot ask for approval and cannot tell you what it removed. It also cannot check: if the removal is refused, the exit code is lost in a log nobody reads. Kestra reads the host, computes the plan, holds the change behind an approval, removes one container at a time so a failure is attributable, then reads the host again and fails the execution when the result does not match the plan. The execution history is the record of what was removed from which host and who approved it.

Tested end to end

On Kestra 2.0.5 OSS against an isolated Docker 27.5.1 daemon (Docker-in-Docker on its own network), with a local HTTP sink standing in for Slack. The daemon held six containers: reap-me (labelled, exited, with an anonymous volume), created-only (labelled, never started), prod-ledger (labelled, exited, matching the protect-list), too-fresh (labelled, exited seconds ago), no-label (exited, no label, with an anonymous volume), keep-running (labelled, running).

Run Inputs Result
1 defaults, min_age_minutes: 1440 3 candidates read, 0 targets, everything inside the age window. Nothing removed
2 min_age_minutes: 2 4 candidates, 2 targets (reap-me, created-only). too-fresh kept as fresh, prod-ledger kept by the protect-list, no-label never returned by the query, keep-running never returned by the state filter
3 run 2 plus auto_remediate: true Same plan, nothing removed. The dry run blocks the change even with auto_remediate on
4 run 3 plus dry_run: false, pause approved Both targets removed with the one anonymous volume they owned. Verified with the daemon's own CLI: 6 containers before, 4 after, 2 anonymous volumes before, 1 after
5 same, pause resumed with approved: false Nothing removed
6 same, pause resumed with no answer at all Nothing removed, an unanswered pause does not read as approval
7 same, but the target was started again during the pause The daemon refused the removal with HTTP 409 because force is false, verify failed the run and errors posted to Slack
8 min_age_minutes: 0, max_removals: 1 2 eligible, 1 target, the other reported as KEEP_OVER_CAP
9 unreachable daemon errors alert sent

Prerequisites

  • A Docker daemon reachable over TCP from the Kestra worker. The container list comes from the Engine API, which the worker cannot read through a mounted unix socket.
  • A label on the containers you want reaped. Add --label kestra.reap=true to the docker run in your CI job or set it in the runner configuration, then point label_selector at it.
  • Local testing: docker run -d --privileged --name dind -e DOCKER_TLS_CERTDIR= docker:27-dind --host=tcp://0.0.0.0:2375, create a few labelled containers inside it, then set docker_host to tcp://dind:2375.

Secrets

  • SLACK_WEBHOOK_URL: used by findings_alert and the errors block. In Kestra OSS, provide it as the base64-encoded environment variable SECRET_SLACK_WEBHOOK_URL.

Inputs

Input Default Purpose
docker_host tcp://docker-host:2375 Daemon to clean
label_selector kestra.reap=true The only containers read
target_states exited, created, dead States treated as terminal
protected_name_pattern ^(prod|db|state|kestra)([-_.].*)?$ Names never removed
min_age_minutes 1440 How long a container must have been stopped
max_removals 25 Ceiling for one run
auto_remediate false Must be true to remove anything
dry_run true Must be false to remove anything

Quick start

  1. Add the SLACK_WEBHOOK_URL secret.
  2. Label the containers your CI creates, for example docker run --label kestra.reap=true ....
  3. Import the flow, set docker_host and label_selector, run it with the defaults and read the plan.
  4. Run it with auto_remediate: true and dry_run: false, read the plan in the paused execution, then resume it with approved: true.
  5. Enable the nightly trigger once the plans look right. Change its inputs when you want it to act.

How to extend

  • Loop over a list of build hosts to reap every runner in one execution.
  • Trigger the flow from a disk usage alert instead of a schedule, so it runs when the space is actually needed.
  • Add io.kestra.plugin.docker.cli.Prune with VOLUMES to also clear volumes whose container is already gone.
  • Keep the exit code in the decision, for example by keeping failed containers longer than successful ones so a developer can still read their logs.
  • Record the reaped ids in a KV store to report how much a runner churns per week.

Links

See How

New to Kestra?

Use blueprints to kickstart your first workflows.