Multi-tier Orchestration
This is the worked example the whole chapter builds toward: deploy a new larkspur release to the six-host prod web tier behind HAProxy on lb1.larkspur.io without dropping a single request. The play runs serial: 1 over web, and for each host it delegates a drain to lb1, deploys and health-checks the host, then delegates a re-add — one host out of the pool at a time, never the whole tier.
It is the clearest demonstration in the book that an ordered, gated, cross-host rollout is what a push model does and a pull model cannot. Each primitive from the earlier topics has a job here: serial for the wave, delegate_to for the load-balancer actions, the health gate for correctness, max_fail_percentage for the stop. The sections assemble them into one play.
The Play Shape
The play is hosts: web, serial: 1, become: true — one prod web host per wave, escalating to root for the deploy. The structure that makes it safe is the split between pre_tasks, the main tasks, and post_tasks: the drain runs before the main work and the re-add runs after, so the load-balancer actions cleanly bracket the deploy. Each wave is therefore a tidy unit — drain this host, deploy it, return it — repeated six times down the tier.
pre_tasks — Drain From HAProxy
For the current web host, a pre_tasks step with delegate_to: lb1.larkspur.io disables that server in the HAProxy backend and waits for in-flight connections to drain. The drain runs on the load balancer but names the web host the play is on, so HAProxy stops sending it new traffic and lets existing requests finish. Only once the host is out of rotation does the deploy touch it — no live traffic ever hits a host mid-deploy.
- hosts: web serial: 1 max_fail_percentage: 0 # first failure halts the whole rollout become: true pre_tasks: - name: Drain {{ inventory_hostname }} from HAProxy community.general.haproxy: state: disabled backend: web_backend host: "{{ inventory_hostname }}" wait: true # wait for in-flight connections to drain delegate_to: lb1.larkspur.io
The Deploy and Health Check
With the host drained, the main tasks pull the new release into /opt/larkspur, restart gunicorn, and reload nginx through handlers. Then comes the gate: a uri task polls the host's own health endpoint in a retries/until loop until it returns healthy. This is what refuses to proceed on a host that did not come back up — a deploy that reports ok is not proof the app is serving; only a passing health check is.
tasks: - name: Pull the new release ansible.builtin.git: repo: https://github.com/larkspur-io/app.git dest: /opt/larkspur version: "{{ release_tag }}" notify: Reload nginx - name: Restart gunicorn ansible.builtin.service: name: gunicorn state: restarted - name: Wait for the host to report healthy ansible.builtin.uri: url: "http://{{ inventory_hostname }}:8000/healthz" status_code: 200 register: health until: health.status == 200 retries: 12 delay: 5
post_tasks — Re-add to the Pool
Only after the health check passes does a post_tasks step, again delegate_to: lb1.larkspur.io, re-enable the server in HAProxy. The host rejoins the pool, takes traffic again, and the next wave begins on the next host. The ordering is the safety property: a host is returned to rotation strictly after it has proven it is healthy, never before. Drain, deploy, gate, re-add — and only then advance.
Guaranteeing Re-add on Failure
The dangerous edge case is a deploy that fails after the drain. If the re-add lives only in the happy path, a failed host stays drained out of the pool indefinitely. The fix is to make the re-add unconditional — wrap the drain-deploy-readd in a block with the re-add (or an explicit abort) in an always or rescue, so the host is either returned to service or the rollout stops loudly. Combined with max_fail_percentage: 0, the first broken host halts everything instead of the play draining a second one behind it.
Ordering Across Tiers
The web rollout does not stand alone. In a full release the db tier migrates first in an earlier play, the web tier rolls behind the load balancer second, and lb1 itself is configured before either. That ordering is enforced by running the tiers as separate plays in sequence and by the linear strategy's per-task barrier, which keeps "db before web" honest — a later tier never runs against an unfinished earlier one. The push model sequences the whole fleet from one place; a pull model, where each node converges alone on its own timer, cannot coordinate "drain at the LB, then touch this host," which is exactly why this play is the chapter's closing argument.
- Restarting gunicorn before draining the host from HAProxy — live traffic hits a restarting app and users get 502s; the drain in
pre_tasksmust complete before the deploy touches the service. - Re-adding the host to the pool without a passing health check — a broken release rejoins the load balancer and starts serving errors; the
urihealth gate is exactly what blocks that. - Running the deploy at
serial: 2or higher on a six-host tier without checking capacity — pulling two of six hosts at once may drop the tier below the traffic it must serve; sizeserialto spare capacity. - Putting the re-add only in the happy path — a failed deploy then leaves the host drained out of the pool indefinitely; the re-add must be guaranteed in an
always/blockor the abort must be explicit. - Forgetting
max_fail_percentage: 0— the first host fails, the play moves on and drains the second, and now two hosts are down instead of the rollout stopping cold.
- Drive the rollout with
serial: 1andmax_fail_percentage: 0on the prod web tier, so exactly one host is ever out of the pool and the first failure halts everything. - Bracket every wave with a
delegate_to: lb1.larkspur.iodrain inpre_tasksand a re-add inpost_tasks, and make the re-add path unconditional so a failed deploy can't strand a host out of service. - Gate the re-add on an actual health check —
uriwithretries/untilagainst the app's health endpoint — not on the deploy task merely reportingok. - Order the tiers explicitly — configure
lb1, migratedb, then rollweb— as separate plays in sequence, leaning onlinear's per-task barrier so a later tier never runs against an unfinished earlier one. - Size
serialto the spare capacity of the tier, so the hosts still in rotation can carry production traffic while one is being deployed.
Knowledge Check
Why must the drain precede the service restart?
- If the host is restarted while still in the pool, live traffic hits a restarting app and users get 502s, so the drain comes first
- The service restart simply cannot run until HAProxy over on the load balancer has first released the file locks it holds open on the web host
- Draining the host after the restart would accidentally re-add it to the backend pool twice over
- The order is purely cosmetic; either sequence serves production traffic in exactly the same way
What gates a host's return to the pool?
- A passing health check — a
uripoll of the app's endpoint in aretries/untilloop — not the deploy merely reportingok - A fixed delay after the deploy task, sized to be long enough for the app process to fully settle before live traffic returns to it
- The next host's wave beginning, which automatically re-adds the previous host back into rotation
- HAProxy re-adding the drained host on its own as soon as Ansible's SSH session to it closes
Why is serial: 1 plus max_fail_percentage: 0 the safe prod pairing?
- Exactly one host is ever out of the pool, and the first host to fail halts the rollout before a second is drained
- It deploys to all six web hosts at once but then rolls them all back together the moment any one fails
- It tolerates up to a single host failure within each wave before it stops the rest of the rollout
- It runs the entire play through twice on each host to confirm idempotency before it is allowed to advance to the next one
How is a failed deploy prevented from stranding a drained host?
- The re-add is made unconditional — placed in an
always/blockor paired with an explicit abort — so a failed deploy still returns the host or stops loudly - HAProxy automatically re-enables any backend server in the
web_backendpool that has been left disabled for too long, returning it to rotation once its health checks pass again - The play just retries the failed deploy on that host indefinitely until it eventually succeeds
- The drain step is skipped entirely on that host, so there is never anything left to re-add
You got correct