Request icon
OutputValues icon
Loop icon
Log icon
If icon
SlackIncomingWebhook icon
Pause icon
Stop icon
Fail icon
Set icon
Schedule icon

Docker Container Restart Loop Sentinel

Track the RestartCount delta per container on a Docker host, classify flapping, crash on start, OOM kills and missing restart policies, then contain it.

Categories
Infrastructure

A container with restart: always that dies on startup goes into a loop. Docker restarts it, it dies, Docker restarts it. docker ps keeps showing it as "Up 2 seconds", so a health check that looks at the current state sees a running container and reports fine. The service is down. The restart churn burns CPU and floods the daemon log. On a plain Docker host, which is where most small deployments live, nothing is watching.

The signal is not the current state. It is RestartCount climbing over time. This blueprint reads it per container, keeps a baseline in Kestra KV then reports the delta since the previous run.

This blueprint was created by zkasuran.

Findings

Three things make a restart loop hard to see from the outside. Each one shaped a task in this flow.

  • GET /containers/json carries no restart counter. The list that docker ps prints cannot tell you a container has restarted 40 times, which is why every container is inspected individually.
  • A lifetime RestartCount is not a symptom. A container that restarted once during a host reboot last month reads the same as one restarting every 30 seconds. Only the delta between two observations separates them, so the previous counts live in a KV key.
  • State.OOMKilled stays true on a container that was killed for memory and then came back. Stopping it would hide a memory problem that only a memory limit fixes, so an OOM kill is classified apart from a crash loop and is never a containment target.

Safety

  • Read-only by default. dry_run is true and auto_remediate is false, so a first run only reports and records the baseline.
  • The one write to the Docker host is a graceful stop. It sits behind a Pause that a human resumes.
  • protected_name_pattern is an allowlist of containers this flow never stops. They are still reported.
  • delete: false on the stop, so the container, its logs and its restart history survive for whoever fixes it.
  • Containment is verified against the daemon. If a container is still restarting, the run fails.

How it works

  1. list_containers (io.kestra.plugin.core.http.Request) reads GET /containers/json?all=true from the Docker Engine API. watched keeps the names matching container_name_filter.
  2. inspect_each (io.kestra.plugin.core.flow.Loop, 4 at a time, read-only) inspects each of them and keeps RestartCount, State.Status, State.ExitCode, State.Error, State.OOMKilled, State.StartedAt, State.FinishedAt, HostConfig.RestartPolicy and Config.Image.
  3. window reads the baseline from the KV key and computes the short-uptime cutoff. assess classifies every container:
    • OOM_KILLED: the last exit was a recent out-of-memory kill. The fix is memory, not restarts.
    • FLAPPING: RestartCount grew by at least flap_threshold since the previous run.
    • CRASH_ON_START: at least one restart, a non-zero exit code, plus a status of restarting or a status of running with a current run shorter than crash_uptime_seconds. A container that is simply stopped is not reported, however it exited.
    • RESTART_POLICY_MISSING: the name matches important_name_pattern but the policy is no, so it will not come back after a host reboot.
    • OK: nothing of the above.
  4. summary logs the counts and the timestamp the deltas are measured from.
  5. route (io.kestra.plugin.core.flow.If) only continues when something is wrong. fetch_logs reads the last log_tail_lines of each failing container and strips the Engine API stream framing. report prints every finding with its log tail. alert posts the summary to Slack. held_back_notice names the containers that are flapping but on the allowlist.
  6. remediate_gate runs only when auto_remediate is true and dry_run is false. approval (io.kestra.plugin.core.flow.Pause) waits for a human. contain (io.kestra.plugin.docker.cli.Stop, one at a time, kill: false, delete: false) stops each target.
  7. verify inspects the targets again. compare pairs the RestartCount before and after. verify_result fails the run if any target is still running, still restarting or was started again after the stop.
  8. record_baseline (io.kestra.plugin.core.kv.Set) stores this run's counts for the next delta.
  9. errors posts to Slack, so an unreachable daemon never reads as a healthy host.

What you get

  • A restart loop caught from the counter delta, not from a current state that lies.
  • Four separate causes, each with a different fix, instead of one "container unhealthy" alert.
  • The failing container's own last log lines in the report and in the execution history.
  • Containment that a human approves, that keeps the evidence then gets checked against the daemon afterwards.

Who it's for

  • Teams running services with Docker or Compose on plain hosts and VMs, with no Kubernetes to restart-loop for them.
  • Platform teams operating build hosts, edge nodes or self-hosted CI runners where a looping container quietly eats the box.
  • Anyone who has found a container that had been restarting for a week because the dashboard said "running".

Why orchestrate this with Kestra

The delta needs state between runs, which is what a cron script never has. Kestra keeps the previous counts in KV. It gates the one destructive action behind a Pause and a pair of inputs. It re-reads the daemon after the change and fails the run when the change did not hold. Every observation stays in the execution history with its log tail. That history is also the answer to "how long has this been looping", which is the first question anyone asks.

Tested end to end

On Kestra 2.0.5 OSS against an isolated docker:27-dind daemon (Docker 27.5.1), with a local HTTP echo container standing in for the Slack webhook. Seven containers were put into deliberate states inside that daemon: two crash loopers (one exits 3, one exits 5), one repeatedly killed by a 20 MB memory limit, one crash looper named to match the allowlist, one healthy long runner, one running container with policy no, one plain stopped container. Every number below was cross-checked with docker inspect against that daemon.

Run Inputs Result
1 filter ^app-web$, flap_threshold: 1 1 watched, OK=1, all_stable, baseline recorded. The CLI agreed: restarts=0 status=running
2 same threshold, crash looper added to the filter CRASH_ON_START=1, OK=1. restarts=16, first observation, Would stop: app-api. The log tail carried FATAL: config key DATABASE_URL missing
3 same inputs 80 seconds later FLAPPING=1, OK=1 on restarts=18 baseline=16 delta=2
4 all seven containers, flap_threshold: 1, second observation FLAPPING=3, OK=2, OOM_KILLED=1, RESTART_POLICY_MISSING=1. Each before and after pair matched docker inspect -f '{{.RestartCount}}'
5 auto_remediate: true, dry_run: true reported Would stop: app-ghost, changed nothing. docker inspect 70 seconds later: restarts=17 status=restarting running=true
6 auto_remediate: true, dry_run: false paused, resumed through the API, stopped it. Re-inspect: RestartCount 72 to 72, status exited. The CLI agreed: status=exited running=false
7 the same inputs again, after containment OK=1, all_stable. A container that is simply stopped is not reported as a crash looper
8 a flapping container matching protected_name_pattern reported, held back by name, never stopped
9 an outside supervisor started the container again right after the stop verify_result failed the run with "still running, status running, RestartCount 11 to 0" and the errors block alerted
10 unreachable daemon the flow failed on the first read and the errors block alerted

Prerequisites

  • A Docker Engine endpoint this flow can read. The Engine API has no bulk inspect call, so the reads go over HTTP, which means a TCP endpoint or a read-only socket proxy rather than a bare Unix socket. Keep it off any untrusted network: an open Docker API is root on that host.
  • The io.kestra.plugin.docker plugin, for the one write.
  • Local testing: docker run -d --privileged --name dind -e DOCKER_TLS_CERTDIR= docker:27-dind --tls=false --host=tcp://0.0.0.0:2375, then create a loop inside it with docker run -d --restart always busybox sh -c 'exit 3'. Point docker_host at tcp://dind:2375.

Secrets

  • SLACK_WEBHOOK_URL: used by alert and by 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-01:2375 Docker daemon to watch
container_name_filter ^.*$ Containers watched
important_name_pattern ^(api|app|svc|web|worker)-.*$ Must survive a host reboot
protected_name_pattern ^(kestra.*|traefik.*|.*-(db|database|cache|proxy|gateway))$ Never stopped
flap_threshold 3 Restart delta that counts as flapping
crash_uptime_seconds 120 A run shorter than this is short-lived
log_tail_lines 20 Log lines pulled per failing container
baseline_key docker_restart_baseline KV key holding the previous counts
auto_remediate false Allow the stop
dry_run true Must be false for the stop to run

Quick start

  1. Add the SLACK_WEBHOOK_URL secret.
  2. Import the flow, set docker_host then run it with the defaults. The first run only records the baseline, because a delta needs two observations.
  3. Run it again. Now the report carries a restart delta per container.
  4. Tighten container_name_filter and important_name_pattern to the containers that matter. Put your databases and proxies in protected_name_pattern.
  5. Enable the every_fifteen_minutes trigger. Leave it in safe mode until the reports look right, then decide whether containment should be automatic.

How to extend

  • Watch several hosts by giving each one its own execution and its own baseline_key, through a parent flow and io.kestra.plugin.core.flow.Subflow.
  • Open an incident instead of posting to Slack, with the log tail as the description.
  • Add a per-container restart budget by keeping a rolling window of deltas in the same KV key.
  • Replace the stop with a rollback to the previously running image tag, after the approval gate.
  • Feed the OOM findings into a memory limit review, since those need a limit change rather than containment.

Links

See How

New to Kestra?

Use blueprints to kickstart your first workflows.