Chapter 10: Orchestration & Scale
Topic 59

Delegation

Orchestration

Sometimes a task aimed at one host must actually run somewhere else. To drain web3 from the pool you run an HAProxy command on lb1.larkspur.io, not on web3 itself. delegate_to does exactly that: the play targets web, but a task with delegate_to: lb1.larkspur.io executes on the load balancer while still carrying the current web host's variables. This is the piece that makes a load-balancer-aware rolling deploy possible from a single play.

Delegation is one mechanism viewed from a few angles. The same machinery that runs a task on the load balancer also runs a task once instead of per host, runs it on the control node, and decides whose facts a delegated task updates. This topic takes each angle in turn, all anchored to the Larkspur drain-and-re-add pattern the chapter is building.

delegate_to — Run Here, Act There

A task with delegate_to: lb1.larkspur.io runs on lb1, but it keeps the inventory_hostname and variables of the host the play is currently iterating over. That combination is the whole point: the action lands on the load balancer, but it knows the name of the web host the play is working on, so you can tell HAProxy to drain "this specific web host" by name. The task's location moves to lb1; the task's context stays with web3.

The example below disables the current web host in HAProxy by delegating the command to lb1. The inventory_hostname in the command is still web3 — the host the play is on — even though the command itself runs on the load balancer.

delegate_to — drain the current web host from HAProxy on lb1
- name: Drain {{ inventory_hostname }} from the HAProxy backend
  ansible.builtin.command:
    cmd: "echo 'disable server web_backend/{{ inventory_hostname }}' | socat stdio /run/haproxy/admin.sock"
  delegate_to: lb1.larkspur.io     # runs on lb1, names the web host being deployed

The Drain and Re-add Pattern

This is the shape every load-balancer-aware deploy takes. Before you touch web3, you delegate a "disable server web3" command to lb1 so traffic stops flowing to it. After the deploy lands and a health check passes, you delegate an "enable server web3" command to lb1 to put it back in rotation. The web host is the subject the whole time — it is what is being deployed — while the load balancer is merely where the drain and re-add actions execute. One play, two delegated commands bracketing the real work.

Delegated drain-and-re-add for one web host
task targets web1, delegate_to lb1
drain web1 from HAProxy
deploy web1
re-add at lb1

run_once — Once Per Play, Not Per Host

run_once: true makes a task execute a single time, on the first targeted host, instead of once per host. It is for the genuinely fleet-singular step in a play that otherwise runs across many hosts — a single database migration that must happen once, a single "announce the deploy" notification, a one-time cache priming. Without run_once that step fires once for every host in the batch; with it, the step runs exactly once no matter how many hosts the play targets.

local_action and delegate_to localhost

Some tasks belong on the control node itself, not on any managed host — hitting an external API, posting a Slack message, templating a file locally. delegate_to: localhost runs the task on the control node without touching a managed node at all. The older local_action: syntax does the same thing and still works, but it obscures what is really happening: local_action is just delegation to the control node wearing a different name. Prefer the explicit delegate_to: localhost, which makes the mechanism plain.

delegate_facts — Whose Facts Get Set

By default, facts gathered or set during a delegated task attach to the original host the play is iterating over, not to the delegation target. That is usually what you want, but not always. delegate_facts: true flips it, attaching the gathered facts to the delegated host instead. This matters the moment you delegate a setup to read the load balancer's own facts: without delegate_facts, those facts land on web3 and you read them off the wrong host later; with it, they land on lb1 where they belong.

The trap is silent. The play does not error — it just attaches facts to the host you did not mean, and a later task reads a value that looks plausible and is wrong. Set delegate_facts deliberately whenever a delegated task gathers or sets facts you intend to read back from the target.

Common Mistakes
  • Putting the HAProxy drain command directly in the web play without delegate_to — it runs the haproxy control on the web host, which is not the load balancer, and the drain silently does nothing or errors.
  • Expecting delegate_to to also move the task's variables to the target host — it keeps the original host's inventory_hostname and vars, which is the whole point; reading the wrong host's facts here is a common surprise.
  • Using a delegated drain without thinking about run_once at larger batches — it is fine at serial: 1, but a single delegated command in a multi-host wave must name each host in the batch, not assume there is only one.
  • Forgetting delegate_facts: true when delegating a fact-gathering task, then reading those facts off the original host later and acting on values that belong to the wrong machine.
  • Reaching for local_action when delegate_to: localhost is the current, clearer form — the older syntax still works but hides that it is simply delegation to the control node.
Best Practices
  • Use delegate_to: lb1.larkspur.io for every load-balancer drain and re-add, so the play targets web but acts on the load balancer, keeping the rolling deploy in one readable play.
  • Pair each drain with a guaranteed re-add — put the re-add in a block/always or a post_task — so a failed deploy never strands a host drained out of the pool.
  • Use run_once for genuinely fleet-singular steps — one migration, one notification — instead of letting them fire once per host in the batch.
  • Delegate external-system calls (Slack, monitoring, an API) to localhost so they run on the control node, and set delegate_facts deliberately when you actually need the target's facts.
  • Name the host explicitly in delegated drain and re-add commands with inventory_hostname, so the action on lb1 is unambiguous even as the play iterates across the tier.
Comparable tools No direct equivalent acting on a sibling host mid-play is push-model orchestration Puppet · Chef each node converges alone, with no "run this step on another host" Kubernetes rolling updates the nearest analog — the controller drains a pod, not a sibling node from a play

Knowledge Check

What does delegate_to keep, and what does it move?

  • It moves where the task runs to the target, while keeping the original host's inventory_hostname and variables
  • It moves both the execution and the full variable context across to the delegation target host
  • It keeps the task running on the original host and only changes which remote user account executes it under become
  • It moves the variables over to the target host but still runs the task on the original host

Why must the HAProxy drain be delegated to lb1?

  • The drain command has to run on the load balancer that holds the backend pool, not on the web host being deployed
  • The web host is frozen and cannot run any command at all while it is being deployed to
  • Delegation is the only way to escalate to root for the disable command on the load balancer
  • HAProxy admin commands can only ever be issued from the control node itself, never from any managed host in the inventory

What does run_once: true change in a multi-host play?

  • The task executes a single time on the first targeted host rather than once for every host
  • The task runs on every host in the batch, but only during the play's very first execution ever
  • The task runs exactly once per batch, with the batch size defined by the play's serial setting
  • The task is skipped on every host except the last one the play targets in the batch

When you delegate a fact-gathering task, whose facts does delegate_facts control?

  • By default the facts attach to the original host; delegate_facts: true attaches them to the delegation target instead
  • Facts always attach to the delegation target by default, and delegate_facts only changes the moment within the run at which they are gathered and cached
  • delegate_facts discards the gathered facts entirely, so neither the original nor the target host keeps them
  • The facts attach to the control node itself regardless of how the delegate_facts flag is set

You got correct