Docker & ContainersXXX Β· Host MaintenanceDraining
Draining a Docker host before maintenance
What you'll learn
- Define a drain for a standalone Docker host and its four phases
- Remove a host from upstream traffic and prove it is receiving none
- Distinguish draining a stateless service from draining a stateful one
- Stop a Compose stack in an order the application can survive
Prerequisites
Verified against Docker Engine 29.x Β· Docker Engine 28.x Β· Docker Compose 2.x Β· containerd 2.x Β· runc 1.2.x Β· BuildKit 0.20+ Β· Linux kernel 5.15+ Β· Ubuntu 24.04 LTS Β· Debian 12 (Bookworm) Β· 2026-08-11
An orchestrator drains a node for you: it stops scheduling new work there, evicts what is running, waits for it to move, and tells you when the node is empty. Standalone Docker has none of that. The host is either taking traffic or it is not, and nothing but you decides which.
Draining is the procedure that gets a host from βserving productionβ to βsafe to rebootβ without dropping requests. It has four phases and each one has a check that must pass before the next begins.
The four phases
| Phase | Goal | Proof it is done |
|---|---|---|
| 1. Stop new traffic | Upstream stops sending requests here | Connection count at the proxy reaches zero for this backend |
| 2. Let in-flight work finish | Requests and jobs already accepted complete | No active connections; queue consumers idle |
| 3. Quiesce stateful services | Data on disk is consistent | Checkpoint or flush completed |
| 4. Stop containers | Nothing is running | docker ps is empty for the stack |
Phase 1 without phase 2 drops in-flight requests. Phase 4 without phase 3 is how databases end up doing crash recovery. Skipping to phase 4 β which is what βstop the containers and rebootβ does β does both.
Phase 1: stop new traffic
How you do this depends on what is in front of the host, and the mechanism matters less than the verification.
# HAProxy runtime API: set the backend server to drain
echo "set server app_backend/app-01 state drain" | sudo socat stdio /run/haproxy/admin.sock
# nginx upstream: mark the server down and reload
sudo sed -i 's|^\(\s*server 192.0.2.11:8080\)\(.*\)$|\1 down;|' /etc/nginx/conf.d/upstream.conf
sudo nginx -t && sudo systemctl reload nginx
# A health-check-driven pool: fail the health endpoint deliberately
docker exec web touch /run/drainThe third form is the most robust and the least common. If your application exposes a health endpoint that the load balancer polls, and that endpoint can be made to fail on demand, draining becomes a single flag inside the container and the load balancer removes the host on its own schedule with no configuration change anywhere else.
Proving no traffic is arriving
This is the phase that gets asserted rather than verified, and asserting it is how requests get dropped.
# 1. Established connections to the published port
ss -tn state established '( sport = :8080 )' | tail -n +2 | wc -l
# 2. The application's own request log, over the last 60 seconds
docker logs --since 60s web 2>&1 | wc -l
# 3. Container network counters, sampled twice
docker exec web cat /proc/net/dev | awk '/eth0/ {print $2, $10}'
sleep 10
docker exec web cat /proc/net/dev | awk '/eth0/ {print $2, $10}'All three should stop changing. The connection count is the primary signal; the log and the counters catch the cases where the load balancer stopped sending but something else β a health checker, a cron job on another host, a message queue consumer β is still driving work through the container.
Phase 2: let in-flight work finish
There is no command for this. It is a wait, and the length of the wait is a property of your application: the longest request it serves, plus the longest job it processes.
#!/usr/bin/env bash
set -euo pipefail
PORT=8080
QUIET_FOR=30
DEADLINE=$(( $(date +%s) + 300 ))
quiet=0
while [ "$(date +%s)" -lt "$DEADLINE" ]; do
n=$(ss -tn state established "( sport = :$PORT )" | tail -n +2 | wc -l)
if [ "$n" -eq 0 ]; then
quiet=$(( quiet + 5 ))
echo "quiet for ${quiet}s"
[ "$quiet" -ge "$QUIET_FOR" ] && { echo 'DRAINED'; exit 0; }
else
quiet=0
echo "$n connection(s) still established"
fi
sleep 5
done
echo 'TIMEOUT: connections still arriving after 300s' >&2
exit 1The deadline matters as much as the wait. A drain that never completes is telling you something β usually that the load balancer change did not take effect, or that a client is connecting to the host directly, bypassing the proxy entirely. Both are worth knowing before you take the host down.
Phase 3: quiesce stateful services
For stateless containers, phase 3 is empty. For anything holding data, it is the phase that decides whether the next boot is clean.
# PostgreSQL: force a checkpoint so recovery on next start is short
docker exec postgres psql -U postgres -c 'CHECKPOINT;'
# Redis: write the dataset to disk synchronously
docker exec redis redis-cli SAVE
# Then stop it with a timeout it can actually use
docker stop --timeout 60 postgresThis is the same quiesce step the application-consistent backup lesson describes, applied for a different reason. The principle is identical: ask the application to reach a consistent on-disk state while it is still running, rather than hoping it manages it in the seconds between SIGTERM and SIGKILL.
Phase 4: stop the containers
docker compose -f /srv/app/compose.yaml stop --timeout 60
# Confirm nothing is left running for this project
docker compose -f /srv/app/compose.yaml ps
docker ps --format '{{.Names}}'stop, not down. down removes containers and networks, which is a
different operation with different consequences β and down -v removes the
named volumes. For maintenance you want the containers to exist and be
stopped, so the reboot brings them back exactly as they were.
The reverse: undraining
Undraining is the drain in reverse, and phase 1 is last rather than first.
- Start the stack.
docker compose up -d. - Wait for health. Every container
healthy, not merelyrunning. - Verify locally, bypassing the load balancer β curl the published port on the host itself.
- Only then return the host to the load balancer pool.
- Watch one full check interval before declaring the window closed.
Step 3 is the one that saves you. Returning a host to the pool and then discovering the application does not work means you have just sent production traffic to a broken backend, which is worse than the maintenance you were doing.
Knowledge check
Knowledge check Β· 4 questions
Q1. Why use `docker compose stop` rather than letting the daemon stop containers during a reboot?
Q2. You have set the backend to drain at the load balancer and `ss` shows zero established connections on the published port. What can still be driving work through the container?
Q3. Which are correct about the four-phase drain? Select all that apply.
Q4. Draining makes maintenance a zero-downtime operation regardless of how many instances of the service exist.
Passing score: 75%. Answers are checked in this browser.