AnsibleXLI · Service and Application DeploymentService and Application Deployment
Rolling a deployment through a load balancer
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
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
- 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: trueThree 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.
| Setting | Effect on a serial: 10 batch with forks: 5 |
|---|---|
throttle: 2 | 2 hosts run the task at a time |
throttle: 5 | no change; already capped at 5 by forks |
throttle: 20 | no 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:
$ ansible-playbook -i inv10.ini batchstop.ymlPLAY [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.
# 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.
- 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: truedelegate_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
Q1. A play has forks: 5 and serial: 10. A drain task carries throttle: 20. How many hosts run that task concurrently?
Q2. A rolling deploy with serial: [1, 2, "25%"] includes a schema migration marked run_once: true. What happens?
Q3. A drain task uses delegate_to: localhost. Which statements are correct? Select all that apply.
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.