LinuxLVII · Linux Load BalancingLoad balancing
Connection draining - taking a backend out without dropping requests
What you'll learn
- Distinguish the drain, maint and ready server states and choose the right one
- Drain a HAProxy backend through the runtime API and confirm it is empty before acting
- Remove a real server from IPVS and from an nginx upstream without dropping connections
- Plan a rolling maintenance that keeps enough capacity to carry the traffic
Prerequisites
Verified against Ubuntu 24.04 LTS · Debian 12 (Bookworm) · RHEL 9.x · Rocky Linux 9.x · AlmaLinux 9.x · Linux kernel 6.1 LTS / 6.6 LTS · systemd 255+ · OpenSSH 8.7p1 (RHEL 9) / 9.6p1 (Ubuntu 24.04) · nftables 1.0.x · chrony 4.x · Pacemaker 2.1.x · Corosync 3.1.x · 2026-08-11
Every deployment, every kernel upgrade and every hardware replacement requires taking a backend out of service. It is the most frequent operation a load balancer performs, and the one most often done by stopping the service and letting the health check notice - which drops every request in flight and every request that arrives during the detection window.
Draining is the alternative: stop sending new work to a backend, let the work it already has finish, then act.
Three states, and choosing between them
HAProxy names the states explicitly, and the vocabulary transfers to other load balancers even where the mechanism differs.
| State | New sessions | Existing sessions | Health checks | Use for |
|---|---|---|---|---|
ready | Accepted | Continue | Running | Normal service |
drain | Refused, except clients holding a persistence cookie for this server | Continue to completion | Still running | Planned removal |
maint | Refused, all clients | Kept unless configured otherwise | Stopped | Work on the backend itself |
The distinction that matters operationally is the health check.
A drained server is still being checked, so you can see whether
it is healthy while it empties. A server in maint is not
checked at all, which is what you want while it is rebooting -
otherwise the check failures raise alerts about a server you
deliberately took out.
Draining a HAProxy backend
The runtime API is reached through the stats socket. Configure it once, with a socket that is not world-writable:
global
stats socket /run/haproxy/admin.sock mode 660 level admin expose-fd listeners
stats timeout 30s
hard-stop-after 5m
expose-fd listeners is what allows a reload to hand its
listening sockets to the new process rather than closing and
reopening them, which is the difference between a reload that
is invisible and one that refuses connections for a few
milliseconds.
# echo "set server be_app/app2 state drain" | socat stdio /run/haproxy/admin.sock; echo "show servers state be_app" | socat stdio /run/haproxy/admin.sock1
# be_id be_name srv_id srv_name srv_addr srv_op_state srv_admin_state srv_uweight
3 be_app 1 app1 192.0.2.21 2 0 100
3 be_app 2 app2 192.0.2.22 2 8 100Illustrative output
Then watch it empty. This is the step people skip, and it is the only one that proves the drain worked.
# echo "show stat" | socat stdio /run/haproxy/admin.sock | awk -F, '$1=="be_app" {print $2, $5, $6, $18}'app1 41 512 UP
app2 6 512 DRAIN
BACKEND 47 1024 UPIllustrative output
- Confirm the remaining servers can carry the full load before you remove anything
- Set the target server to drain, and record the time
- Poll the current session count until it reaches zero, or until it stops falling
- Set the server to maint, so its health checks stop and it does not alert while you work
- Do the work - restart, upgrade, reboot
- Set the server back to ready and watch the health checks pass before declaring it in service
- Confirm the session count on it is rising again, which proves traffic is genuinely returning to it
nginx: down and a graceful reload
Open-source nginx has no runtime API for upstream membership. Removal is a configuration change plus a reload - which is graceful, and which is the whole mechanism.
upstream app {
zone app 64k;
server 192.0.2.21:8080 max_fails=3 fail_timeout=10s;
server 192.0.2.22:8080 down;
}
# nginx -t && systemctl reload nginx; ss -tn state established '( dport = :8080 )' | wc -lnginx: configuration file /etc/nginx/nginx.conf test is successful
38Illustrative output
Two properties of that reload are worth stating explicitly, because they are where the graceful part comes from and where it stops:
- Old worker processes keep running until their current
requests complete. They are visible as processes in
shutting downstate, andworker_shutdown_timeoutbounds how long they are allowed to linger before being terminated. - Without that timeout, a worker holding a websocket can stay alive indefinitely, so a busy server accumulates shutting-down workers across successive reloads until memory becomes a problem.
The drain parameter and the runtime upstream API - which
would let you do this without editing a file - are part of the
commercial nginx subscription, not the open-source build. On
open-source nginx, down plus a reload is the supported path.
IPVS: weight zero
IPVS has no drain state, but weight zero produces the same behaviour: the scheduler stops selecting the real server for new connections while existing entries in the connection table continue to be forwarded.
# ipvsadm -e -t 192.0.2.10:443 -r 192.0.2.22:443 -w 0; ipvsadm -L -n; ipvsadm -L -n -c | grep -c 192.0.2.22Prot LocalAddress:Port Scheduler Flags
-> RemoteAddress:Port Forward Weight ActiveConn InActConn
TCP 192.0.2.10:443 wlc
-> 192.0.2.21:443 Route 100 412 88
-> 192.0.2.22:443 Route 0 27 12
27Illustrative output
The IPVS equivalent of the persistence trap is the persistence
template: with -p set on the virtual service, a client that
already has a persistence entry continues to be sent to the
weight-zero server until that entry expires. Same behaviour,
same planning requirement.
Under keepalived, set inhibit_on_failure on the real server
so that a failed health check sets its weight to zero rather
than removing it from the table outright - the same graceful
behaviour, applied automatically.
Make the application drain itself
The most portable mechanism needs no runtime API at all: have the application fail its own health check while continuing to serve real traffic.
- The deployment tooling touches a flag file, or calls an admin endpoint on the application
- The health endpoint begins returning 503 while the service endpoints keep working normally
- The load balancer marks the backend down after its configured checks fail, and stops sending new requests
- The application finishes its in-flight work, then exits
This works identically behind HAProxy, nginx, IPVS, a hardware load balancer or a cloud one, and it puts the decision in the process that actually knows whether it has finished. The cost is the detection delay: the backend keeps receiving new requests until the check fails enough times to count.
$ curl -sS -o /dev/null -w '%{http_code}\n' http://192.0.2.22:8080/health; sleep 6; curl -sS -o /dev/null -w '%{http_code}\n' http://192.0.2.22:8080/health503
503Illustrative output
Capacity is the precondition
None of this is graceful if the servers that remain cannot carry the traffic. Before draining anything, do the arithmetic: if three servers at 70% utilisation become two, those two go to 105% and the drain converts a maintenance window into an outage.
The rule for rolling maintenance is to remove at most as many backends as leaves the remainder below the utilisation you are willing to run at during a further unplanned failure. On a three-node pool, that usually means one at a time, and it means checking the current load rather than the load at planning time.
Knowledge check
Knowledge check · 5 questions
Q1. You set a HAProxy server to drain and its session count falls from 200 to 40 and then stops. What is the most likely explanation?
Q2. What is the operational difference between drain and maint that matters while you work on the backend?
Q3. The drain parameter on an nginx upstream server is available in the open-source build.
Q4. Which steps belong in a graceful backend removal? Select all that apply.
Q5. An application signals a drain by returning 503 from its health endpoint, then exits immediately. Requests still fail during deployments. Why?
Passing score: 75%. Answers are checked in this browser.