Skip to main content
RunBook Academy

AnsibleXLI · Service and Application DeploymentService and Application Deployment

Rolling a deployment through a load balancer

Advanced⏱ ~26 min🧪 Lab requiredansible-playbook

What you'll learn

  • Assemble a rolling application deploy from serial, block/always and delegate_to
  • Apply throttle to a step that contends on a rate-limited API and know why it cannot raise parallelism
  • Place a one-time step so it runs once per run rather than once per batch
  • Reason about which host supplies variables and facts in a delegated task

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.

Part XXXII built the rolling loop: drain, change, health-check, return, soak. This lesson is about composing that loop with the deployment content of this part, and about two keywords whose semantics are per batch rather than per run — which is the source of the two most expensive mistakes in a rolling deploy.

The composition

Service impact possiblea rolling application deploy, whole
- name: Roll the application through the fleet
hosts: appservers
become: true
serial: '{{ deploy_serial | default([1, 2, "25%"]) }}'
max_fail_percentage: 0
tasks:
  - name: Deploy this host, returning it to the pool whatever happens
    block:
      - name: Drain this backend from the load balancer
        ansible.builtin.uri:
          url: >-
            https://lb.example.com/api/v1/pools/{{ app_pool }}/members/{{ inventory_hostname }}
          method: PATCH
          body_format: json
          body:
            state: drain
          headers:
            Authorization: 'Bearer {{ lb_api_token }}'
          status_code: [200, 204]
        delegate_to: localhost
        throttle: 2
        changed_when: true

      - name: Wait for in-flight requests to complete
        ansible.builtin.wait_for:
          host: '{{ ansible_host | default(inventory_hostname) }}'
          port: 8080
          state: drained
          timeout: 120

      - name: Converge package, configuration and service
        ansible.builtin.include_role:
          name: myapp

      - name: Prove this host serves the version we deployed
        ansible.builtin.include_tasks: health-gate.yml

    always:
      - name: Return healthy hosts, announce the rest
        ansible.builtin.include_tasks: undrain.yml

  - name: Soak before the next batch
    ansible.builtin.pause:
      seconds: '{{ soak_seconds | default(120) }}'
    run_once: true

Three structural decisions in there are worth naming.

The drain and the undrain are delegate_to: localhost. They are API calls to the load balancer, made from the controller, about the current host. The host being deployed may be unreachable at that moment, and it certainly has no business talking to the balancer’s control plane.

The whole per-host sequence is a block with an always. The drain creates an obligation; the always discharges it however the block ended, including when a failure policy is about to stop the play.

The soak is run_once — correct here, because one pause per batch is exactly the intent.

throttle: fewer workers for one task

throttle limits the number of workers for a single task or block. Upstream describes the use case as restricting “tasks that may be CPU-intensive or interact with a rate-limiting API”, which is precisely what a load-balancer control plane is.

The constraint is stated just as plainly: “If you have already restricted the number of forks or the number of machines to execute against in parallel, you can reduce the number of workers with throttle, but you cannot increase it.”

So throttle is a one-way valve. It can narrow, never widen.

SettingEffect on a serial: 10 batch with forks: 5
throttle: 22 hosts run the task at a time
throttle: 5no change; already capped at 5 by forks
throttle: 20no change; cannot exceed forks or the batch

run_once is per batch, and the migration is the casualty

Upstream: “When used together with serial, tasks marked as run_once will be run on one host in each serial batch.”

This was verified on ansible-core 2.21.3, with serial: [1, 3, 6] over ten hosts and a run_once debug task:

Read-only / Saferun_once under serial, executed on 2.21.3
$ ansible-playbook -i inv10.ini batchstop.yml
PLAY [Validation failure stops the next batch] **********************************
"msg": "BATCH h01 of 10 total"

PLAY [Validation failure stops the next batch] **********************************
"msg": "BATCH h02,h03,h04 of 10 total"

PLAY [Validation failure stops the next batch] **********************************
"msg": "BATCH h05,h06,h07,h08,h09,h10 of 10 total"

For a soak, three executions is the intent. For a schema migration it is a production incident, and the mechanism produces it silently: the migration runs on batch one, succeeds; runs again on batch two against an already-migrated database, and either errors — stopping the deployment mid-fleet — or, worse, succeeds and applies something twice.

Read-only / Safethe two idioms
# Once per batch. Correct for a soak or a per-batch banner.
- name: Soak before the next batch
ansible.builtin.pause:
  seconds: 120
run_once: true

# Once per run. Correct for anything fleet-wide.
- name: Announce the deployment start
ansible.builtin.uri:
  url: 'https://chat.example.com/api/messages'
  method: POST
  body_format: json
  body:
    text: 'Deploying {{ myapp_version }} to {{ app_pool }}'
  headers:
    Authorization: 'Bearer {{ chat_token }}'
delegate_to: localhost
when: inventory_hostname == ansible_play_hosts_all[0]

ansible_play_hosts_all is documented as every host the play targets, unaffected by batching, so testing inventory_hostname against its first element is true once.

There is one more trap in the idiom itself: if the first host of the play has failed and left the active set, the condition is true for nobody and the task silently never runs. For an announcement that is an annoyance. For “release the deployment lock” it is a lock nobody holds and nobody released.

Delegation: whose variables, whose facts

delegate_to changes where the task executes, not which host the task is about. That distinction produces the two recurring confusions.

Variables come from the original host. In the drain task above, inventory_hostname, app_pool and ansible_host all resolve from the host being deployed, even though the task runs on the controller. This is what makes the task expressible at all — it is a statement about web07 executed from localhost.

Facts, by default, do not transfer. A task delegated to the load balancer host that gathers facts assigns them to the original host unless delegate_facts: true is set, which is the source of a memorable bug: ansible_facts.hostname on web07 becomes the load balancer’s hostname, and it stays that way for the rest of the play.

Read-only / Safedelegation without contaminating the original host's facts
- name: Gather facts about the load balancer itself
ansible.builtin.setup:
  gather_subset: ['!all', '!min', 'network']
delegate_to: '{{ lb_host }}'
delegate_facts: true
run_once: true

delegate_to: localhost also means the task uses the controller’s Python, the controller’s installed libraries and the controller’s network position. A uri task delegated to localhost needs the controller to be able to reach the load balancer API, which is an assumption worth checking before the change window rather than during it.

Knowledge check

Knowledge check · 4 questions

  1. Q1. A play has forks: 5 and serial: 10. A drain task carries throttle: 20. How many hosts run that task concurrently?

  2. Q2. A rolling deploy with serial: [1, 2, "25%"] includes a schema migration marked run_once: true. What happens?

  3. Q3. A drain task uses delegate_to: localhost. Which statements are correct? Select all that apply.

  4. Q4. Putting throttle: 2 on a single rate-limited API call is generally better than lowering serial, because it slows only the contended task rather than the entire deployment.

Passing score: 75%. Answers are checked in this browser.