The previous lesson established that standalone Docker cannot fail over, because
nothing outside the host observes it. This lesson builds the thing that observes
it: a proxy in front of two or more Compose hosts, which polls each one and
routes around the ones that stop answering.
flowchart LR Internet((Clients)) --> LB[Proxy with health checks] LB -->|checked every 2s| Host1[host-01: Compose stack] LB -->|checked every 2s| Host2[host-02: Compose stack] Host1 --> DB[(Managed or replicated DB)] Host2 --> DB Host1 --> Obj[(Object storage)] Host2 --> Obj
The picture is easy. Everything difficult is in two places: what the health
check tests, and what happens to connections when a backend leaves the pool.
Prerequisite: the state has to move first
Before any of this is worth doing, deal with the fact that a named volume is
local to one host. app_pgdata on host-01 is not visible from host-02 and never
will be.
State
Where it must go
Relational database
A managed service, or a replicated cluster with a defined promotion procedure
User uploads
Object storage (S3-compatible), or a shared filesystem both hosts mount
Sessions
Redis or the database β never in-process memory
Cache
Shared Redis, or genuinely per-host and cold-start tolerant
Certificates
Terminated at the proxy, or a shared store both hosts read
Skipping this produces the failure named in the previous lesson: two hosts, one
database on host A, and a load balancer that confidently routes to a survivor
that cannot serve a request. Adding a second host improves availability only
after the single-instance state is gone from both.
Sessions deserve a specific mention because they fail subtly rather than
loudly. In-process sessions plus round-robin load balancing means every second
request lands on a host that has never seen the user, and the symptom is
βrandomly logged outβ rather than an outage. The usual workaround β sticky
sessions at the proxy β makes it work and quietly recreates the problem: a host
failure now logs out every user pinned to it, and it prevents clean draining.
Move sessions to shared storage; do not paper over them with stickiness.
The health check is the whole design
The proxyβs entire model of reality is one endpointβs response. If that endpoint
returns 200 while the service is unusable, the proxy will keep routing traffic
into it, and no amount of good configuration elsewhere helps.
Read-only / Safethe two kinds of endpointβ One tells you the process is alive. The other tells you it can serve a request.
HOST=127.0.0.1:8080
# Liveness: is the process running? Cheap, no dependencies.
# Used by the container health check and by process supervision.
curl -sS "http://$HOST/healthz"
# Readiness: can this instance serve a real request right now?
# Checks the database connection, the cache, the disk it writes to.
# This is what the LOAD BALANCER must poll.
curl -sS "http://$HOST/readyz"
Read-only / Safereadiness that means somethingβ The database is unreachable. Liveness is green; readiness is not.
Configuration changehaproxy.cfgβ Active checks with an explicit expectation, plus a drain-capable runtime socket.
global
stats socket /run/haproxy/admin.sock mode 660 level admin
# Bound wait for in-flight sessions on reload, so old workers cannot linger
hard-stop-after 120s
defaults
mode http
timeout connect 5s
timeout client 60s
timeout server 60s
backend app_backend
balance roundrobin
option httpchk GET /readyz
http-check expect status 200
# inter: check every 2s. fall: 3 failed checks removes it.
# rise: 2 good checks return it. slowstart ramps traffic back gradually.
server app-01 192.0.2.11:8080 check inter 2s fall 3 rise 2 slowstart 30s
server app-02 192.0.2.12:8080 check inter 2s fall 3 rise 2 slowstart 30s
The four numbers on the server line are the availability policy, and they are
a trade-off in both directions:
inter 2s fall 3 means a dead backend is detected in about six seconds.
Lowering fall to 1 detects faster and makes a single dropped packet eject a
healthy host. Raising it makes the pool stable and slow to react.
rise 2 stops a flapping backend from being readmitted on one lucky
check.
slowstart 30s ramps a returning backendβs weight up over 30 seconds. This
is the one people omit and then wonder why a recovered host immediately falls
over: a host that has just started has cold caches and an empty connection
pool, and sending it half of production instantly is how you get it ejected
again. HAProxy has this natively; nginx open source offers slow_start as a
server parameter, though its interaction with active checks is a commercial
feature.
nginx, and an important limitation
Configuration changenginx upstreamβ Open source nginx has PASSIVE checks only. Read the caveat below before relying on this.
upstream app_backend {
# max_fails failures within fail_timeout marks the server unavailable
# for fail_timeout. This is PASSIVE - it only reacts to real traffic.
server 192.0.2.11:8080 max_fails=3 fail_timeout=30s;
server 192.0.2.12:8080 max_fails=3 fail_timeout=30s;
keepalive 32;
}
server {
listen 443 ssl;
server_name app.example.com;
location / {
proxy_pass http://app_backend;
proxy_next_upstream error timeout http_502 http_503 http_504;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
Draining, not cutting
Removing a backend from the pool has two meanings and they are not the same
operation.
Drain β stop sending new requests; let existing ones finish.
Disable / down β cut the backend out immediately, including in-flight
requests.
Going straight to the second for a planned change drops every request currently
being served on that host. For a 30-second report or a file upload, that is a
user-visible error caused entirely by the maintenance procedure.
Service impact possibledrain a backendβ Drain, wait for quiet, then act. Never straight to maint for a planned change.
SOCK=/run/haproxy/admin.sock
BACKEND=app_backend
SERVER=app-01
# 1. Drain: no new sessions, existing ones continue
echo "set server $BACKEND/$SERVER state drain" | sudo socat stdio "$SOCK"
# 2. Wait for the current session count to reach zero
for _ in $(seq 1 60); do
n=$(echo "show stat" | sudo socat stdio "$SOCK" | awk -F, -v b="$BACKEND" -v s="$SERVER" '$1==b && $2==s {print $5}')
echo "sessions still open: $n"
[ "$n" -eq 0 ] && break
sleep 5
done
# 3. Only now is it safe to stop the stack on that host
# docker compose -f /srv/app/compose.yaml stop
# 4. After maintenance, return it - slowstart ramps the traffic
echo "set server $BACKEND/$SERVER state ready" | sudo socat stdio "$SOCK"
Read-only / Safedraining in progressβ Nineteen requests were still in flight when the drain started.
$ show stat | socat stdio /run/haproxy/admin.sock, sampled every 5s
sessions still open: 19
sessions still open: 11
sessions still open: 4
sessions still open: 1
sessions still open: 0
Illustrative output
Those nineteen requests are the ones a state maint would have destroyed. The
whole drain took twenty seconds.
Rolling a deploy across two hosts
With draining available, the downtime that docker compose up imposes β it
stops and recreates a changed service, with no overlap β becomes invisible to
users, because the host is out of the pool while it happens.
Service impact possiblerolling deployβ One host at a time. Verify before returning each to the pool.
SOCK=/run/haproxy/admin.sock
STACK=/srv/app/compose.yaml
for srv in app-01 app-02; do
echo "set server app_backend/$srv state drain" | sudo socat stdio "$SOCK"
sleep 30 # replace with the session-count wait loop above
ssh "$srv" "docker compose -f $STACK pull && docker compose -f $STACK up -d --wait --wait-timeout 120"
# Verify locally on that host, bypassing the proxy, BEFORE returning it
ssh "$srv" 'curl -fsS http://127.0.0.1:8080/readyz > /dev/null' || {
echo "FAIL: $srv did not come back ready - stopping the rollout" >&2
exit 1
}
echo "set server app_backend/$srv state ready" | sudo socat stdio "$SOCK"
sleep 60 # watch one host in the new version before touching the next
done
Two properties make this a deploy procedure rather than a script. --wait
returns non-zero if any service fails to reach healthy, so a broken image stops
the rollout at host one instead of taking out both. And the local curl before
returning the host to the pool means a host that started but is not serving
correctly never receives traffic β the same βverify before repointingβ rule as
the DR rebuild.
Knowledge check
Knowledge check Β· 6 questions
Q1. What does open source nginx provide for upstream health checking?
Q2. A load balancer polls `GET /` and gets 200 from both hosts. The database is down and every real request returns 500. What is wrong?
Q3. What is the difference between setting an HAProxy server to `drain` and to `maint`?
Q4. Which must be true before a second Compose host provides availability rather than just capacity? Select all that apply.
Q5. What does `slowstart 30s` on an HAProxy server line do, and why does it matter?
Q6. Putting a single HAProxy instance in front of two application hosts removes the single point of failure from the architecture.
Passing score: 75%. Answers are checked in this browser.