Delegation and Become¶
What You'll Learn¶
- How
delegate_toruns a task somewhere else while keeping the original host's context - How
run_onceanddelegate_factsbehave, including underserial - How to run a task on the control node itself
- How
become,become_user, andbecome_methodwork, and the non-root pitfall
Why This Exists¶
Not every task should run on the host it's nominally "for" — the classic case is updating a load balancer's config on behalf of a web server being taken in or out of rotation. delegate_to and run_once handle this; become handles privilege escalation, a related but distinct concept covered in depth in SSH and Connectivity.
Mental Model¶
delegate_tochanges where the action executes. It does not change whose task it is. Variables likeinventory_hostnameandansible_factsstill describe the original host; only the connection goes to the delegate.
flowchart LR
P["Play targeting web02"] --> T["Task: disable web02 in the pool"]
T -->|"delegate_to: lb01"| LB["Action runs on lb01"]
T -.->|"inventory_hostname is still"| W["web02"]
delegate_to: A Load Balancer Drain¶
- name: Rolling update behind HAProxy
hosts: web
serial: 1
become: true
tasks:
- name: Drain this web server from the pool
community.general.haproxy:
state: disabled
host: "{{ inventory_hostname }}" # web02 — the original host
backend: web_pool
socket: /run/haproxy/admin.sock
wait: true
delegate_to: "{{ item }}"
loop: "{{ groups['lb'] }}"
- name: Deploy the new release
ansible.builtin.include_role:
name: checkout
- name: Put this web server back in the pool
community.general.haproxy:
state: enabled
host: "{{ inventory_hostname }}"
backend: web_pool
socket: /run/haproxy/admin.sock
delegate_to: "{{ item }}"
loop: "{{ groups['lb'] }}"
Delegation isn't free:
- Ansible connects to the delegate using the delegate's connection variables (
ansible_host,ansible_userfrom inventory), so the control node needs working SSH tolb01. become: truefrom the play applies on the delegate too, so the remote user needs sudo there.- The original host's variables are still in scope. Use
hostvars[item]when you need the delegate's own values.
Running on the Control Node¶
- name: Wait for the service to answer through the public load balancer
ansible.builtin.uri:
url: "https://shop.example.com/healthz"
status_code: 200
delegate_to: localhost
become: false # don't try to sudo on your laptop or CI runner
delegate_to: localhost is the everyday form. local_action: is an older shorthand for the same thing. If localhost isn't in your inventory, Ansible uses an implicit local connection.
run_once: Exactly Once¶
- name: Run database migrations once for the whole deployment
ansible.builtin.command: /opt/checkout/bin/migrate
run_once: true
delegate_to: "{{ groups['web'] | first }}"
- Without
delegate_to,run_onceexecutes on the first host in the current batch. Pin the host explicitly when it matters. - Under
serial, "once" means once per batch. Withserial: 1across 10 hosts, arun_oncetask runs 10 times. Put one-time work in its own play withoutserial, or guard it:when: inventory_hostname == ansible_play_hosts_all | first. - The result registered by a
run_oncetask is copied to every host in the play, so later tasks on any host can read it.
delegate_facts: Where Gathered Facts Land¶
- name: Gather facts for the database hosts from the web play
ansible.builtin.setup:
delegate_to: "{{ item }}"
delegate_facts: true
loop: "{{ groups['db'] }}"
run_once: true
Without delegate_facts: true, facts gathered by a delegated setup are assigned to the original host, overwriting its own facts. With it, they're stored under the delegate in hostvars. See Magic Variables and Hostvars.
become: Privilege Escalation¶
- name: Manage the application
hosts: app
become: true # escalate for the whole play (to root by default)
tasks:
- name: Install system packages (as root)
ansible.builtin.package:
name: python3-venv
state: present
- name: Create the virtualenv as the application user
ansible.builtin.command: python3 -m venv /opt/checkout/venv
args:
creates: /opt/checkout/venv/bin/python
become_user: checkout
- name: Read a user-owned file without escalating
ansible.builtin.slurp:
src: /home/deploy/.ssh/known_hosts
become: false
| Keyword / variable | Purpose | Default |
|---|---|---|
become |
Turn escalation on or off | false |
become_user |
User to become | root |
become_method |
How: sudo, su, doas, pbrun, ... |
sudo |
ansible_become_password |
Password, usually from Vault | none |
These resolve through normal variable precedence, so ansible_become_method: su in group_vars/legacy.yml changes the method for that group only.
The non-root become_user pitfall¶
Becoming an unprivileged user (connect as deploy, become checkout) means the module file uploaded by deploy must be readable by checkout. On many systems this fails with an error about temporary files and permissions. Fixes, in order of preference:
- Enable pipelining so no temporary module file is written.
- Install
aclon the managed node so Ansible can grant access withsetfacl. - As a last resort,
allow_world_readable_tmpfiles = True, which makes module files readable by everyone on the host.
Deeper diagnosis is in Become and Permission Problems.
Common Mistakes¶
- Forgetting
delegate_tochanges where a task's action runs but not which host's variables are in scope by default — a common source of "wrong value used" bugs. - Using
run_oncewithout also pinning which host runs it, when the "any host" default happens to pick one with different facts than expected. - Using
run_onceunderserialand being surprised it runs once per batch. - Delegating to
localhostwith the play'sbecome: truestill active, so the control node or CI runner asks for a sudo password. - Delegating
setupwithoutdelegate_facts: trueand overwriting the original host's facts.
Interview Questions¶
- What does
delegate_tochange about a task's execution, and what does it not change? - When would you use
run_once, and how doesserialchange its behavior? - What does
delegate_facts: truedo? - Why can becoming a non-root user fail when becoming root works fine?
Next¶
Continue to Async and Poll.