Chapter 10: Orchestration & Scale
Topic 62

Multi-tier Orchestration

OrchestrationDeploy

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 zero-downtime rolling deploy, one host per wave
serial: 1 wave
drain at lb1
deploy + health-check host
re-add to HAProxy, next host

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.

pre_tasks — delegate the drain to lb1 before touching the host
- 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.

deploy, then gate on a real health check before moving on
  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.

Common Mistakes
  • Restarting gunicorn before draining the host from HAProxy — live traffic hits a restarting app and users get 502s; the drain in pre_tasks must 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 uri health gate is exactly what blocks that.
  • Running the deploy at serial: 2 or 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; size serial to 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/block or 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.
Best Practices
  • Drive the rollout with serial: 1 and max_fail_percentage: 0 on 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.io drain in pre_tasks and a re-add in post_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 — uri with retries/until against the app's health endpoint — not on the deploy task merely reporting ok.
  • Order the tiers explicitly — configure lb1, migrate db, then roll web — as separate plays in sequence, leaning on linear's per-task barrier so a later tier never runs against an unfinished earlier one.
  • Size serial to the spare capacity of the tier, so the hosts still in rotation can carry production traffic while one is being deployed.
Comparable tools Kubernetes rolling updates the closest analog — drain, replace, readiness-probe, re-add, one batch at a time Spinnaker · Argo Rollouts the same blue-green and canary patterns at the pipeline layer Terraform no rolling-deploy concept; Puppet · Chef can't sequence a drain at the LB across hosts

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 uri poll of the app's endpoint in a retries/until loop — not the deploy merely reporting ok
  • 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/block or 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_backend pool 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