Skip to main content
RunBook Academy

← All runbooks in Docker & Containers

medium riskservice affecting~45 min

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.

  1. 1Record the inventory. Write docker ps --format "{{.Names}}" to /var/tmp/docker-predrain.txt. Expect a file listing every container you must bring back.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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.
  7. 7Stop the stateless middle tier next, in reverse dependency order. Expect each docker stop to return within its timeout.
  8. 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.
  9. 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.
  10. 10Verify no container exited with code 137. Run docker ps -a --format "{{.Names}} {{.Status}}" and expect Exited (0) for every service that handles state.
  11. 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.
  12. 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.
  13. 13Confirm the remaining hosts absorbed the traffic before starting the maintenance. Expect the service-level error rate to be unchanged on the dashboard.
  14. 14Perform the maintenance. Then reverse: start stateful services first, wait for healthy, then the middle tier, then the front door.
  15. 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

Read-only / Safewhat is this host serving?
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 -l

Remove 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.

Read-only / Safeshutdown contract
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 SIGTERM unless the image sets STOPSIGNAL in its Dockerfile or the container was created with --stop-signal.
  • The timeout can be per-container. docker run --stop-timeout bakes it in; docker stop --timeout sets it for one invocation. In Compose the equivalent is stop_grace_period.
  • PID 1 must actually forward the signal. A container whose entrypoint is a shell script that ends in command rather than exec command has the shell as PID 1, and many shells do not forward SIGTERM to their children. The application never hears it and is killed at the timeout.
Service impact possiblegraceful stop, in order
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"
Service impact possiblewhole compose stack
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 --all

Step 3: Prove the shutdown was graceful

The exit code is the evidence, and it is cheap to read.

Read-only / Safeexit codes
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

Read-only / Safevolume quiesce
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
done

Empty 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

Read-only / Safeserving nothing
# 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

SymptomLikely causeResolution
Established connections stay flat after LB removalA second route: stale DNS, pinned client IP, undocumented balancerFind the route before stopping anything
Every container exits with 137 at exactly the timeoutPID 1 is a shell that does not forward SIGTERMUse exec in the entrypoint, or set STOPSIGNAL
Database exits 137 at 10 secondsDefault timeout, not the one the engine needsdocker stop --timeout 120, or stop_grace_period in Compose
lsof still shows writers with no containers runningA host process is using a bind mountIdentify the owner before touching the filesystem
Stack comes back with the app crash-loopingRestart policies started everything at onceStart stateful first, wait for healthy, then the rest
Volumes gone after the maintenancedocker compose down -v was used instead of stopRestore from backup; this is not recoverable locally
Swarm tasks still on the node after a local docker stopA Swarm node is drained from a manager, not locallydocker node update --availability drain

Knowledge check

Knowledge check · 4 questions

  1. Q1. What is the correct first action when draining a Docker host for maintenance?

  2. Q2. `docker compose down` and `docker compose stop` are interchangeable for taking a host out of service.

  3. Q3. A database container consistently exits with code 137 during the drain. Which explanations are worth checking? Select all that apply.

  4. 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.

References

  1. docker stop - signal and timeout behaviour
  2. docker ps - status and health filters
  3. Compose file reference - stop_grace_period, stop_signal, depends_on
  4. Drain a node on the swarm
  5. Start containers automatically (restart policies)