Pull icon
Commands icon
Docker icon
OutputValues icon
Log icon
SlackIncomingWebhook icon
If icon
Pause icon
Loop icon
Fail icon
Set icon
Schedule icon

Docker Container Log Rotation and Disk Guard

Detect containers running without log max-size, measure the disk their json-file logs hold and truncate the worst behind an approval.

Categories
Infrastructure

Docker's default json-file log driver has no size limit. A chatty container writes to /var/lib/docker/containers/<id>/<id>-json.log forever. That file is invisible to the usual checks: it is not in the container's filesystem, docker ps says nothing about it and docker system df does not count it. The ending is a full host disk, which does not only break the chatty container. The daemon and every other container lose the ability to write.

This blueprint reads the log configuration the containers actually run with, measures the real bytes their log files hold on the data root filesystem and names the worst offender with the exact options to set. It can truncate the oversized files behind a human approval to buy time, then measures again and fails the run if the space did not come back.

This blueprint was created by zkasuran.

Findings

Three things make this hard to see without a flow like this one.

  • The limit is per container, set at creation. docker run with no --log-opt max-size means no limit, whatever the daemon default is today, so a host can hold a mix of bounded and unbounded containers.
  • Truncating in place works while the container runs. Docker appends to the log file instead of writing at a remembered offset, so the next line lands at the start again: measured seconds after a truncation, the file was 3810542 bytes apparent against 3818496 bytes allocated, with no sparse hole. Deleting the file instead frees nothing while the daemon holds it open, which is why the flow truncates and reports allocated blocks (du) next to the apparent size.
  • Truncation buys time, it does not fix the setting. Changing a log driver option needs the container recreated, so the flow reports the option to set and deliberately does not recreate anything.

How it works

  1. prepare_helper (io.kestra.plugin.docker.cli.Pull) pulls the small helper image on the target daemon, so the measuring step cannot fail on a missing image.

  2. measure (io.kestra.plugin.scripts.shell.Commands with the Docker CLI) reads docker info for the daemon default log driver and the data root, then docker inspect for every container's HostConfig.LogConfig with its max-size and max-file. It then runs the helper on the same daemon with the data root mounted read-only and reads the allocated KiB (du -k) and apparent bytes (stat) of every *-json.log* file, plus df on that filesystem.

  3. assess (io.kestra.plugin.core.output.OutputValues) attributes each file to its container by id, sums rotated files then classifies:

    • UNBOUNDED_AND_LARGE: json-file, no max-size, already holding more than log_size_warn_mib.
    • UNBOUNDED: no max-size but still small, a latent version of the same bug.
    • OK: a max-size is set.
    • OTHER_DRIVER: not json-file, so these log files are not where its logs live.

    It ranks containers by disk held, flags the data root as at risk when free space is under min_free_percent and compares every container with the KiB stored by the previous run, so a chatty deploy shows up as growth rather than as a number with no history.

  4. report logs one line per container with the verdict, the disk held, the cap when there is one and the growth since the last run. findings_alert posts to Slack only when a container is urgent or the filesystem is short on space.

  5. apply_gate (io.kestra.plugin.core.flow.If) requires auto_remediate true, dry_run false and at least one offender matching truncate_allowlist. Then approve_truncation (io.kestra.plugin.core.flow.Pause) holds the execution for a human, because truncation discards log lines. On approval, remediate_truncate (io.kestra.plugin.core.flow.Loop, concurrencyLimit: 1) truncates one container's files per iteration, mounting only that container's log directory.

  6. measure_after reads the daemon again, compare puts the before and after KiB side by side per container. verify fails the run through io.kestra.plugin.core.execution.Fail if any targeted file did not shrink or free space did not come back. Only a clean verification advances the baseline.

  7. errors alerts Slack when the flow fails, for example on an unreachable daemon.

What you get

  • The containers that will fill the disk, named before they do, with the bytes each one already holds.
  • A measurement of the log files themselves, which docker ps and docker system df do not report.
  • Growth per container since the last run, so a sudden change in log volume is visible.
  • An immediate truncation path behind an approval, an allowlist and a measured re-check.

Who it's for

  • Teams running services on a single Docker host or a few, without a log shipper taking the files away.
  • Platform teams whose self-hosted CI runners and edge nodes die of a full disk every few months.
  • Anyone who has recreated a container to add --log-opt max-size after an outage and wants the list before the next one.

Why orchestrate this with Kestra

The check needs the daemon API, a container started on the daemon to read the filesystem, a comparison with the previous run and an approval gate before anything is discarded. A shell script can do the first part. Kestra adds the stored baseline, the Pause for a human, the sequential remediation loop, the re-measurement that decides whether the run passes or fails plus a per-host execution history that shows which container keeps coming back.

Tested end to end

On Kestra 2.0.5 OSS against an isolated Docker 27.5.1 daemon (Docker-in-Docker), with a local HTTP sink standing in for Slack. The daemon ran three containers on purpose: chatty-api on the default driver with no max-size, bounded-worker with --log-opt max-size=1m --log-opt max-file=2 writing the same volume plus a quiet quiet-cron. Every number below was confirmed with du -k and df -Pk inside the daemon, not only through the API.

Run Inputs Result
1 defaults, threshold 256 MiB state LATENT: chatty-api 55 MiB UNBOUNDED, bounded-worker 1 MiB OK with cap 1m x 2, quiet-cron UNBOUNDED. Nothing urgent, nothing touched
2 log_size_warn_mib: 16 state ACTION: chatty-api caught at 59 MiB, growth 4 MiB against the stored baseline, Slack notice sent, still nothing touched
3 threshold 16, auto_remediate: true, dry_run: true, allowlist ^chatty- state ACTION with 1 allowed target, nothing truncated. The dry run reports the plan only
4 threshold 16, auto_remediate: true, dry_run: false, default allowlist ^$ the gate stays shut and the log says chatty-api is urgent but not in the allowlist
5 threshold 16, auto_remediate: true, dry_run: false, allowlist ^chatty- paused, resumed with approved: true. chatty-api 65496 KiB to 148 KiB, every json-file log 66540 KiB to 1456 KiB, free space 77032076 KiB to 77095924 KiB, verified by the second measurement
6 min_free_percent: 99 state ACTION on free space alone, no urgent container
7 free_space_slack_mib: -4096, so the run demands 4 GiB back the files shrank but the re-check failed on free space, verify failed the run and the errors block posted to Slack
8 unreachable daemon prepare_helper fails and the errors block posts to Slack
9 every container recreated with max-size=1m, threshold lowered to 1 MiB state OK, no finding at the lowest useful threshold
10 one unbounded container started again, inputs exactly as run 9 state ACTION: chatty-api 6 MiB UNBOUNDED_AND_LARGE (5964 KiB by du inside the daemon), its HostConfig.LogConfig read back as json-file with an empty option map
11 threshold 1 MiB, auto_remediate: true, dry_run: false, default slack chatty-api 8652 KiB to 208 KiB, free space 77053436 KiB to 77062608 KiB, verify passed and the baseline advanced

Runs 9 and 10 are the same flow with the same inputs against a clean daemon then a broken one, so the OK is a measurement rather than a default. bounded-worker wrote the same volume as chatty-api the whole time and never went past 1.9 MiB, while chatty-api reached 65 MiB. That gap is what the recommendation rests on.

Prerequisites

  • A Docker daemon API endpoint reachable from the Kestra task containers, docker_host. The typed io.kestra.plugin.docker.cli.Pull task also accepts a socket path when the worker has /var/run/docker.sock mounted, but the CLI tasks run in their own container, so give them a TCP endpoint (TLS in production) or mount the socket into task containers by setting kestra.tasks.scripts.docker.volume-enabled to true.
  • docker_network has to be a network from which docker_host resolves.
  • The helper container mounts the Docker data root read-only to measure and one container's log directory read-write to truncate. The daemon has to allow bind mounts, which is the default.
  • Only json-file logs are measured. A container on journald, local or a shipper is reported as OTHER_DRIVER with no byte count, because its logs are not in these files.

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 inspect
docker_network bridge Network the task containers join
docker_cli_image docker:27-cli Image holding the Docker CLI
helper_image alpine:3.20 Image started on the daemon to read file sizes
log_size_warn_mib 256 Disk held that makes an unbounded container urgent
min_free_percent 15 Free space on the data root below this is at risk
recommended_max_size 100m The max-size the report tells you to set
recommended_max_file 3 The max-file the report tells you to set
truncate_allowlist ^$ Regex on the container name, matches nothing by default
free_space_slack_mib 128 How far free space may end below the start, for other writers
auto_remediate false Must be true before truncation is possible
dry_run true Must be false before truncation is possible

Quick start

  1. Add the SLACK_WEBHOOK_URL secret.
  2. Import the flow, set docker_host and docker_network for one host and run it with the defaults. Read the per container line in the logs.
  3. Lower log_size_warn_mib until the report matches what you consider urgent on that host.
  4. Recreate the containers it names with --log-opt max-size=100m --log-opt max-file=3, then put the same log-opts in /etc/docker/daemon.json so new containers inherit them.
  5. For an urgent disk, set truncate_allowlist to the container names you accept losing logs for, run with auto_remediate: true and dry_run: false, then approve the paused execution.
  6. Enable the hourly trigger once the thresholds are right.

How to extend

  • Loop over a list of hosts and report every Docker host in one execution.
  • Flag a container as bounded but still oversized when max-size times max-file is larger than your threshold, which is the other way a log driver eats a disk.
  • Ship the lines somewhere before truncating: add a task that copies the log file to object storage, so the truncation discards nothing that was not stored.
  • Replace the recommendation with the change itself in a non-production namespace: stop the container, recreate it with the log options and verify the new HostConfig.LogConfig through the same inspect step.
  • Feed the measured KiB per container into a metric so the growth is on a dashboard rather than in a log line.

Links

See How

New to Kestra?

Use blueprints to kickstart your first workflows.