← All runbooks in Docker & Containers
Runbook: Drain a Docker host for maintenance
1 · Prerequisites
Confirm every item is in place before any state change.
- A change window is agreed and the remaining hosts can carry the traffic this host is currently taking
- You can reach the load balancer or service registry to remove this host, and you have tested that you can put it back
- The dependency order of the stacks on this host is known: which service writes state, which only reads, which is the front door
- The compose files or unit files for every stack on the host are in version control and their paths are known
- Out-of-band or console access, so the maintenance does not depend on an SSH path through a container
- A recent, restorable backup exists for every named volume on the host
- You know whether this host is a Swarm node, because a Swarm node is drained from a manager, not locally
2 · Pre-checks
Read-only diagnostic commands. If any of these don't match expected output, stop and investigate further.
- · docker ps --format "{{.Names}} {{.Image}} {{.Status}} {{.Ports}}" - the full inventory of what is running and what it publishes
- · docker ps --format "{{.Names}}" > /var/tmp/docker-predrain.txt - the list you must be able to restore exactly
- · docker compose -f "$COMPOSE_FILE" config --services - the service names and, read alongside the file, their depends_on order
- · docker volume ls --format "{{.Name}} {{.Driver}}" - every named volume that must be quiesced before the host goes down
- · docker inspect --format "{{.Name}} {{.Config.StopSignal}} {{.Config.StopTimeout}}" $(docker ps -q) - what each container expects on shutdown
- · ss -tn state established - how many client connections this host is currently holding
- · docker info - read the Swarm line; if it is active this host is drained from a manager instead
- · docker node ls (on a Swarm manager only) - confirm the cluster has capacity for this node to leave
3 · Procedure
Execute each step in order. Verify the expected output of a step before moving to the next.
- 1Record the inventory. Write docker ps --format "{{.Names}}" to /var/tmp/docker-predrain.txt. Expect a file listing every container you must bring back.
- 2Remove the host from the load balancer or service registry FIRST, before touching any container. Expect the load balancer to report the host as down or draining.
- 3Wait for in-flight requests to finish. Watch ss -tn state established until the established connection count stops falling. Expect it to settle near zero, not merely to decrease.
- 4Confirm no new work is arriving: tail the front-door container log for 60 seconds with docker logs --since 60s --follow "$FRONTEND". Expect no new request lines.
- 5On a Swarm node, drain from a manager instead of stopping containers by hand: docker node update --availability drain "$NODE". Expect docker node ls to show AVAILABILITY Drain and the tasks to be rescheduled elsewhere.
- 6Stop the front door first, so nothing can enqueue new work: docker stop --timeout 30 "$FRONTEND". Expect the container to exit before the timeout, not to be killed at it.
- 7Stop the stateless middle tier next, in reverse dependency order. Expect each docker stop to return within its timeout.
- 8Stop the stateful services last, with a timeout long enough for their own shutdown: docker stop --timeout 120 "$DATABASE". Expect a clean shutdown message in docker logs, not a signal-killed message.
- 9For a whole compose stack, prefer docker compose -f "$COMPOSE_FILE" stop --timeout 120, which stops in dependency order and does not remove anything. Expect docker compose ps --all to show every service exited.
- 10Verify no container exited with code 137. Run docker ps -a --format "{{.Names}} {{.Status}}" and expect Exited (0) for every service that handles state.
- 11Confirm the volumes are quiesced: with the containers stopped, no process should hold files open under the volume mountpoints. Expect sudo lsof +D on the mountpoint to return nothing.
- 12Verify the host is serving nothing: ss -ltnp shows no published container ports still bound, and a request to the host from outside fails or is refused. Expect a refused connection, not a slow success.
- 13Confirm the remaining hosts absorbed the traffic before starting the maintenance. Expect the service-level error rate to be unchanged on the dashboard.
- 14Perform the maintenance. Then reverse: start stateful services first, wait for healthy, then the middle tier, then the front door.
- 15Return the host to the load balancer, or run docker node update --availability active "$NODE" on a Swarm manager, only after every container in /var/tmp/docker-predrain.txt is running and healthy.
4 · Verification
Confirm the procedure actually fixed the problem.
- ✓ss -ltnp shows none of the published container ports from the pre-check still bound on this host
- ✓A request from another host to this host on the service port is refused or times out - it does not return a 200
- ✓The load balancer status page lists this host as out of rotation, and its request counter for this host is zero
- ✓docker ps returns no rows, or on a Swarm node docker node ls shows AVAILABILITY Drain for this node
- ✓docker ps -a --format "{{.Names}} {{.Status}}" shows Exited (0) for every stateful service - no Exited (137)
- ✓sudo lsof +D on each named volume mountpoint returns nothing, proving nothing still holds the data open
- ✓The service dashboard shows the same total request rate as before the drain, absorbed by the remaining hosts
- ✓After the maintenance, every name in /var/tmp/docker-predrain.txt appears in docker ps, and docker ps --filter health=unhealthy prints nothing
5 · Rollback
If verification fails, undo the procedure in reverse order.
- ↶Abandoning a drain is safe and is often the right call. Restart in reverse: stateful services first, then the middle tier, then the front door.
- ↶Bring a stack back with docker compose -f "$COMPOSE_FILE" start, which restarts the existing containers rather than recreating them and so preserves anonymous volumes.
- ↶On a Swarm node, undo the drain from a manager with docker node update --availability active "$NODE".
- ↶Wait for health before returning traffic: docker ps --filter health=unhealthy --format "{{.Names}}" must print nothing.
- ↶Re-add the host to the load balancer only after that check passes, and watch the error rate for one full health-check interval.
- ↶If a stateful container was killed at its timeout rather than exiting cleanly, do not simply restart it and return traffic. Start it in isolation, confirm it recovers its own state, and only then re-add the host.
- ↶Restoring a volume from backup is not part of this rollback. If a volume is damaged, stop and use the restore runbook with the data owner present.
6 · Escalation
When the runbook isn't enough, contact:
- · The established connection count never falls after removing the host from the load balancer: escalate to the network or load-balancer owner - something is still routing to this host.
- · A stateful container refuses to exit within its timeout and is killed with SIGKILL: escalate to the service owner before restarting it, and capture docker logs first.
- · lsof shows a process outside Docker holding files open under a volume mountpoint: escalate to the host owner; a bind mount is being used by something you are not managing.
- · On a Swarm node, tasks do not reschedule after the drain: escalate to the Swarm owner and return the node to active rather than proceeding with maintenance.
- · The remaining hosts cannot absorb the traffic and the error rate rises: abort the drain immediately, return the host to rotation, and escalate to capacity planning.
Draining a host is four things in a fixed order: stop receiving traffic, let what you have finish, shut down in dependency order, then prove you are serving nothing. Doing them out of order is what turns planned maintenance into an incident, and the usual mistake is stopping containers before the load balancer has been told.
Step 1: Load balancer first, always
docker ps --format '{{.Names}}\t{{.Image}}\t{{.Status}}\t{{.Ports}}'
docker ps --format '{{.Names}}' > /var/tmp/docker-predrain.txt
# Every named volume that will need to be quiesced
docker volume ls --format '{{.Name}}\t{{.Driver}}'
# Client connections this host currently holds
ss -tn state established | wc -lRemove the host from the load balancer, service registry or DNS pool now, using whatever mechanism you own. Then watch the established connection count: it should fall, and it should fall to something close to zero.
Step 2: Understand what SIGTERM means to each container
docker stop sends SIGTERM to the main process inside the
container, waits, and then sends SIGKILL. The waiting period
is what --timeout controls, and the default is 10 seconds.
docker inspect \
--format '{{.Name}} signal={{.Config.StopSignal}} timeout={{.Config.StopTimeout}}' \
$(docker ps -q)Three facts decide whether a graceful stop is actually graceful:
- The signal can be changed. The first signal is
SIGTERMunless the image setsSTOPSIGNALin its Dockerfile or the container was created with--stop-signal. - The timeout can be per-container.
docker run --stop-timeoutbakes it in;docker stop --timeoutsets it for one invocation. In Compose the equivalent isstop_grace_period. - PID 1 must actually forward the signal. A container
whose entrypoint is a shell script that ends in
commandrather thanexec commandhas the shell as PID 1, and many shells do not forwardSIGTERMto their children. The application never hears it and is killed at the timeout.
FRONTEND=web
APPTIER=api
DATABASE=db
# Front door first: nothing new can be enqueued after this
docker stop --timeout 30 "$FRONTEND"
# Then the stateless middle tier
docker stop --timeout 30 "$APPTIER"
# Stateful last, with room to flush and checkpoint
docker stop --timeout 120 "$DATABASE"COMPOSE_FILE=/srv/app/compose.yaml
docker compose -f "$COMPOSE_FILE" config --services
docker compose -f "$COMPOSE_FILE" stop --timeout 120
docker compose -f "$COMPOSE_FILE" ps --allStep 3: Prove the shutdown was graceful
The exit code is the evidence, and it is cheap to read.
docker ps -a --format '{{.Names}}\t{{.Status}}'
# 0 = clean exit. 137 = killed with SIGKILL after the timeout expired.
docker inspect --format '{{.Name}} exit={{.State.ExitCode}} oom={{.State.OOMKilled}}' \
$(docker ps -aq)
# What the application said on the way out
docker logs --tail 40 "$DATABASE"Exited (0) means the process chose to exit. Exited (137)
means it was still running when the grace period ran out and
the kernel killed it. For a stateless web tier that is
usually harmless. For anything holding state it means the
shutdown was cut short, and the next start may have to
recover.
Step 4: Confirm the volumes are quiesced
for V in $(docker volume ls --format '{{.Name}}'); do
MP=$(docker volume inspect --format '{{ .Mountpoint }}' "$V")
printf '%s -> %s\n' "$V" "$MP"
sudo lsof +D "$MP" 2>/dev/null | head -5
doneEmpty lsof output for every mountpoint is the check. A
non-empty result means something still has the data open -
either a container you did not account for, or a process on
the host using a bind mount. Snapshotting or unmounting the
filesystem underneath an open writer is how a “clean”
maintenance produces a corrupt database.
Step 5: Prove nothing is still serving
# No published container ports should still be bound
sudo ss -ltnp
# No containers at all
docker ps
# From ANOTHER host - a refused connection is the pass condition
HOST=192.0.2.10
curl -sS --max-time 5 -o /dev/null -w '%{http_code}\n' "http://$HOST/healthz" || \
echo "refused or timed out - correct"The test that matters is run from somewhere else. curl localhost on the host under maintenance proves nothing about
what the network can still reach.
Common patterns
| Symptom | Likely cause | Resolution |
|---|---|---|
| Established connections stay flat after LB removal | A second route: stale DNS, pinned client IP, undocumented balancer | Find the route before stopping anything |
| Every container exits with 137 at exactly the timeout | PID 1 is a shell that does not forward SIGTERM | Use exec in the entrypoint, or set STOPSIGNAL |
| Database exits 137 at 10 seconds | Default timeout, not the one the engine needs | docker stop --timeout 120, or stop_grace_period in Compose |
lsof still shows writers with no containers running | A host process is using a bind mount | Identify the owner before touching the filesystem |
| Stack comes back with the app crash-looping | Restart policies started everything at once | Start stateful first, wait for healthy, then the rest |
| Volumes gone after the maintenance | docker compose down -v was used instead of stop | Restore from backup; this is not recoverable locally |
Swarm tasks still on the node after a local docker stop | A Swarm node is drained from a manager, not locally | docker node update --availability drain |
Knowledge check
Knowledge check · 4 questions
Q1. What is the correct first action when draining a Docker host for maintenance?
Q2. `docker compose down` and `docker compose stop` are interchangeable for taking a host out of service.
Q3. A database container consistently exits with code 137 during the drain. Which explanations are worth checking? Select all that apply.
Q4. All containers are stopped, but `sudo lsof +D` on a volume mountpoint still lists an open file handle. What does that mean?
Passing score: 75%. Answers are checked in this browser.