Skip to main content
RunBook Academy

Docker & ContainersXXIII Β· High AvailabilityExternal LB

External load balancing β€” HA without an orchestrator

Intermediate⏱ ~28 mindocker

What you'll learn

  • Write a health endpoint that fails when the service is unusable, not merely when the process is dead
  • Configure active health checks and understand what nginx open source cannot do
  • Drain a backend so in-flight requests complete instead of being cut
  • Identify the shared state that must move off both hosts before two hosts mean anything

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-12

Not yet marked complete on this device.

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.

StateWhere it must go
Relational databaseA managed service, or a replicated cluster with a defined promotion procedure
User uploadsObject storage (S3-compatible), or a shared filesystem both hosts mount
SessionsRedis or the database β€” never in-process memory
CacheShared Redis, or genuinely per-host and cold-start tolerant
CertificatesTerminated 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
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
$ curl -sS -w '\\n%{http_code}\\n' http://127.0.0.1:8080/readyz
{
"status": "unavailable",
"checks": {
  "database": "FAIL: dial tcp 10.0.0.9:5432: connect: connection refused",
  "objectstore": "ok",
  "diskspace": "ok"
}
}
503

Illustrative output

Configuring the proxy

HAProxy

Configuration changehaproxy.cfg
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
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
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
$ 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
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

  1. Q1. What does open source nginx provide for upstream health checking?

  2. 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?

  3. Q3. What is the difference between setting an HAProxy server to `drain` and to `maint`?

  4. Q4. Which must be true before a second Compose host provides availability rather than just capacity? Select all that apply.

  5. Q5. What does `slowstart 30s` on an HAProxy server line do, and why does it matter?

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