Skip to main content
RunBook Academy

AnsibleXXXII · Rolling DeploymentsRolling Deployments

Draining a host from the load balancer

Advanced⏱ ~24 minansible-playbook

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

Not yet marked complete on this device.

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.

Service impact possibledraining via a control command on the balancer
    - 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: true

For a balancer with an HTTP API, uri is the better instrument, and it runs from wherever you delegate it:

Service impact possibledraining via the balancer API
    - 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: true

Two 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:

Service impact possiblethe anti-pattern
    - 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: 10

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

Read-only / Safewaiting for the connections to actually go away
    - 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 /healthz every 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.

Read-only / Safeexcluding the pollers
# 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 poller

Getting 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:

Read-only / Safefinding out who is connected before you write the exclusion list
    - 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.

Read-only / Safeasking the balancer instead of counting sockets
    - 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: false

Note 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

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

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

  3. Q3. Running the rolling playbook with --check will exercise the drain step and prove the wait_for condition is correct.

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