AnsibleXXXII · Rolling DeploymentsRolling Deployments
Draining a host from the load balancer
What you'll learn
- Delegate a drain action to a load balancer that is in inventory
- Verify a drain completed rather than assuming it after a sleep
- Configure wait_for state drained correctly, including exclude_hosts
- Explain why TIME_WAIT connections can make a healthy drain look like a failure
Prerequisites
Verified against ansible-core 2.21.x · ansible (community package) 14.x · Python (controller) 3.12+ · ansible-lint 26.x · Molecule 26.x · Ubuntu 24.04 LTS · Debian 12 (Bookworm) · RHEL / Rocky / AlmaLinux 9.x · 2026-08-11
Draining is two operations that are easy to confuse: telling the load balancer to stop sending new requests, and waiting for the requests already in flight to finish.
The first is instant and the load balancer will confirm it. The second takes as long as your slowest request, and nothing confirms it unless you ask.
Implementations that skip the second step restart a service while it is mid-response to real users. The requests fail, the users see errors, and the run log records a successful deployment — because from Ansible’s point of view the disable call returned zero and the restart returned zero.
Telling the load balancer
The task runs against the load balancer while the play is iterating over
application servers, which is what delegate_to is for.
- name: Remove this host from the load balancer pool
ansible.builtin.command: >-
/usr/local/bin/lb-ctl disable {{ inventory_hostname }}
delegate_to: '{{ load_balancer_host }}'
changed_when: trueFor a balancer with an HTTP API, uri is the better instrument, and it
runs from wherever you delegate it:
- name: Set this backend to draining
ansible.builtin.uri:
url: >-
https://lb.example.com/api/v1/pools/{{ app_pool }}/members/{{ inventory_hostname }}
method: PATCH
body_format: json
body:
state: draining
headers:
Authorization: 'Bearer {{ lb_api_token }}'
status_code:
- 200
- 204
validate_certs: true
delegate_to: localhost
changed_when: trueTwo details in that example are deliberate.
delegate_to: localhost because the API call goes over the network
from wherever it runs, and the controller is a sensible place to run it
from. Delegating an API call to the load balancer host itself is
pointless — you would be connecting to the balancer over SSH so that it
can make an HTTP request to itself.
changed_when: true on both examples. Neither command nor uri
knows whether the state actually changed, and a drain is a change worth
reporting. The alternative — leaving uri to report ok — makes the
run log claim nothing happened.
Why “sleep 10” is not a drain
The pattern that appears in a great many playbooks:
- name: Remove from the pool
ansible.builtin.command: /usr/local/bin/lb-ctl disable {{ inventory_hostname }}
delegate_to: '{{ load_balancer_host }}'
changed_when: true
# Do not do this.
- name: Wait for connections to finish
ansible.builtin.pause:
seconds: 10It is wrong in both directions at once.
Too short, and you restart mid-request. Ten seconds is fine until a report endpoint takes forty, or a client uploads a large file, or the database is slow that night. The failure is intermittent, correlates with load, and looks like a network problem.
Too long, and every deployment costs the padding. Ten seconds per host across 200 hosts in batches of five is a little over six minutes of pure waiting. Somebody will eventually shorten it, under time pressure, without knowing why it was ten.
Above all: it asserts nothing. After the pause you know exactly as much as you did before it. A verification that cannot fail is not a verification.
Verifying the drain
wait_for with state: drained checks for active TCP connections on a
port and returns when there are none.
- name: Wait for in-flight requests to complete
ansible.builtin.wait_for:
host: '{{ ansible_host | default(inventory_hostname) }}'
port: 8080
state: drained
timeout: 90
sleep: 2
exclude_hosts: '{{ drain_exclude_hosts }}'The difference from the pause version is the whole point: this task
can fail. A host that still has connections after 90 seconds fails
the task, which under max_fail_percentage: 0 stops the rollout — and
that is correct, because a host that will not drain is a host you should
not be restarting.
It also finishes early. A host that drains in 300 milliseconds costs 300 milliseconds, not ten seconds.
exclude_hosts is not optional
state: drained counts every active connection to the port. In
production that includes things that will never go away:
- the monitoring system polling
/healthzevery ten seconds - the load balancer own health checks, which usually continue against a draining backend
- a metrics scraper
- another application server holding a keep-alive connection
Any one of these makes the drain never complete, and the task fails at the timeout on a host that drained perfectly well.
# group_vars/appservers.yml
drain_exclude_hosts:
- 192.0.2.10 # load balancer health checks
- 192.0.2.11 # load balancer, second node
- 198.51.100.25 # monitoring pollerGetting this list wrong is the most common reason a correct drain implementation fails on its first production run. Work it out before the change window by looking at what is actually connected:
- name: List everything currently connected to the application port
ansible.builtin.command: ss -Htn state established '( sport = :8080 )'
changed_when: false
register: connections
- name: Show the connected peers
ansible.builtin.debug:
msg: '{{ connections.stdout_lines }}'When the port is the wrong question
wait_for counts TCP connections, which is a proxy for “is this host
serving traffic”. Sometimes it is the wrong proxy.
Persistent connections never drain. An application using long-lived keep-alive or WebSocket connections holds them open for hours. The connection count will not fall to zero because the clients have no reason to disconnect. Draining here means asking the load balancer how many requests it has routed recently, not counting sockets.
The load balancer knows better than the host. A balancer that tracks active sessions per backend can answer “is this backend still serving anyone” authoritatively, and its answer accounts for session affinity that the host cannot see.
- name: Wait until the balancer reports no active sessions for this backend
ansible.builtin.uri:
url: >-
https://lb.example.com/api/v1/pools/{{ app_pool }}/members/{{ inventory_hostname }}
method: GET
headers:
Authorization: 'Bearer {{ lb_api_token }}'
return_content: true
delegate_to: localhost
register: member
retries: 30
delay: 3
until: member.json.active_sessions | default(1) == 0
changed_when: falseNote default(1) rather than default(0). If the field is missing —
because the API changed, or the response was an error — the condition
must not be satisfied. Defaulting an absent value to the one that
means “safe to proceed” is how a broken check becomes an invisible
check.
Knowledge check
Knowledge check · 4 questions
Q1. A rolling play disables a backend in the load balancer and then uses pause: seconds: 10 before restarting the service. What is the fundamental problem?
Q2. A wait_for with state: drained times out on a host that appears to have drained perfectly. What are plausible causes? Select all that apply.
Q3. Running the rolling playbook with --check will exercise the drain step and prove the wait_for condition is correct.
Q4. A drain check reads until: member.json.active_sessions | default(1) == 0. Why default to 1 rather than 0?
Passing score: 75%. Answers are checked in this browser.