AnsibleII · Ansible ArchitectureThe execution model
How a module gets to the target: AnsiballZ and pipelining
What you'll learn
- Describe what is actually transferred to a managed node when a task runs
- Explain what ANSIBLE_KEEP_REMOTE_FILES does, and why it is a debugging tool and an exposure at once
- State what pipelining removes from the transfer cycle and what it requires
- Recognise failures caused by the transport rather than by the module
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
The previous lesson said “the payload is transferred and executed”. This
one opens the payload, because what is inside it explains a set of
behaviours that are otherwise arbitrary: why the simplest possible task
transfers over a hundred kilobytes, why become and pipelining
interact, why keeping remote files is both the best debugging tool
available and a way to leave secrets on a hundred hosts.
What is actually shipped
Take ansible.builtin.ping — the smallest useful module there is. Its
source is a few dozen lines. Here is what arrives on the managed node.
$ wc -c ~/.ansible/tmp/ansible-tmp-*/AnsiballZ_ping.py166844 /home/ops/.ansible/tmp/ansible-tmp-1786481678.42-4004527-44275714459835/AnsiballZ_ping.py163 kilobytes, for ping.
The wrapper is a Python script with a large base64 string in it. Decoded, that string is a zip archive:
$ python3 - <<'PY'
import base64, io, zipfile
blob = None
for line in open('AnsiballZ_ping.py'):
if line.startswith("zip_data='"):
blob = line[len("zip_data='"):].rstrip().rstrip("'")
raw = base64.b64decode(blob)
z = zipfile.ZipFile(io.BytesIO(raw))
print('zip bytes:', len(raw))
print('entries:', len(z.namelist()))
for n in z.namelist()[:6]:
print(' ', n)
PYzip bytes: 115382
entries: 56
ansible/__init__.py
ansible/module_utils/__init__.py
ansible/module_utils/_internal/_ansiballz/_loader.py
ansible/module_utils/_internal/__init__.py
ansible/module_utils/_internal/_ansiballz/__init__.py
ansible/module_utils/_internal/_errors.pyFifty-six files. The module itself — ansible/modules/ping.py — is one
of them. The other fifty-five are module_utils: argument-spec
validation, JSON encoding, text conversion, file helpers, distribution
detection, error handling.
This is the concrete meaning of agentless. The library the module needs is not installed on the node, so it travels with the module, every time, for every task.
The cost this implies
Per task, per host, without pipelining, the sequence from the previous
lesson runs in full: create a temporary directory, transfer a
six-figure-byte file, chmod it, execute it, remove the directory.
At three hundred hosts and sixty tasks that is eighteen thousand
transfers. This is not a reason to avoid tasks — it is why forks,
connection reuse and pipelining exist, and why the performance part of
this course insists on measuring before tuning.
Keeping the remote files
ANSIBLE_KEEP_REMOTE_FILES=1 tells Ansible not to clean up the
temporary directory. The wrapper stays on the node, and you can read it,
decode it, and run it by hand.
$ ANSIBLE_KEEP_REMOTE_FILES=1 ansible node1 -m ansible.builtin.ping -vvv<node1> PUT /home/ops/.ansible/tmp/ansible-local-.../tmpoicft4o4 TO /home/ops/.ansible/tmp/ansible-tmp-1786481678.42-4004527-44275714459835/AnsiballZ_ping.py
<node1> EXEC /bin/sh -c 'chmod u+rwx /home/ops/.ansible/tmp/ansible-tmp-.../AnsiballZ_ping.py'
<node1> EXEC /bin/sh -c '/usr/bin/python3.12 /home/ops/.ansible/tmp/ansible-tmp-.../AnsiballZ_ping.py'
node1 | SUCCESS => {
"ansible_facts": {
"discovered_interpreter_python": "/usr/bin/python3.12"
},
"changed": false,
"ping": "pong"
}Note the absent line: there is no rm -f -r at the end. The directory
survives.
This is genuinely the best debugging tool in Ansible for a class of problem that is otherwise very hard: a module that fails on one host and works on thirty-nine. You can reproduce the exact execution on that host, under that interpreter, with those arguments, without Ansible in the loop at all.
Pipelining
Pipelining removes the write step. Instead of transferring the wrapper to a file and then executing that file, Ansible opens the remote Python interpreter and feeds the wrapper to it on stdin.
The difference is visible immediately in a verbose run. Without
pipelining, one task produces a mkdir, a PUT, a chmod, an EXEC
and an rm. With it, verified on ansible-core 2.21.3:
$ ANSIBLE_PIPELINING=True ansible-playbook -i localhost, local.yml -vvv | grep -E 'PUT|EXEC|ESTABLISH'<localhost> ESTABLISH LOCAL CONNECTION FOR USER: ops
<localhost> EXEC /bin/sh -c 'echo FOUND; command -v '"'"'python3.14'"'"'; ... ; echo ENDFOUND'
<localhost> EXEC /bin/sh -c /usr/bin/python3.14Compare that with the non-pipelined trace in the previous lesson. The
mkdir, the PUT, the chmod and the rm are all gone. What remains
is interpreter discovery and a single EXEC that starts Python with the
payload arriving on its standard input.
The gains are real and they compound over a run:
- Fewer SSH round trips per task. This is the dominant cost on a high-latency link.
- Nothing written to the node’s filesystem. No temporary directory,
which sidesteps
noexecmounts, full home directories and quota problems. - Less residue. There is no file to be left behind by an interrupted run.
Why it is off by default
Pipelining conflicts with sudo configurations that require a TTY. The
default configuration confirms it:
$ ansible-config dump | grep -E '^(ANSIBLE_PIPELINING|DEFAULT_KEEP_REMOTE_FILES)\('ANSIBLE_PIPELINING(default) = False
DEFAULT_KEEP_REMOTE_FILES(default) = FalseThe upstream documentation for the setting states the reason directly:
it conflicts with privilege escalation, and using it with sudo
requires requiretty to be disabled in /etc/sudoers on all managed
hosts — which is why it is disabled by default rather than because it is
risky in itself.
Many modern distributions ship without requiretty for the relevant
entries, which is why enabling pipelining often “just works”. Confirm it
rather than assuming, because the failure mode is a run that breaks on
whichever subset of your fleet has the older sudoers policy — a partial
failure across a mixed-age estate, which is the hardest kind to
interpret.
What this explains
| Behaviour | Explanation from this lesson |
|---|---|
| A trivial task transfers over 100 KB | The module travels with all its module_utils |
| Task count matters more than it looks | Each task builds and ships its own payload |
A task fails with MODULE FAILURE on a full disk | The payload could not be written to the temp directory |
A noexec home directory breaks everything | The wrapper is written there and then executed |
Module arguments do not appear in ps on the node | They are embedded in the wrapper, not passed as argv |
Pipelining conflicts with become | No TTY is allocated, and requiretty sudoers refuse |
| Keeping remote files leaves secrets on hosts | Base64 is an encoding, not encryption |
Knowledge check
Knowledge check · 4 questions
Q1. Why does a single ansible.builtin.ping task transfer roughly 160 KB to the managed node?
Q2. Enabling ANSIBLE_KEEP_REMOTE_FILES turns pipelining off, so you cannot use it to inspect the payload a pipelined run feeds to the interpreter.
Q3. What does enabling pipelining remove from the per-task cycle? Select all that apply.
Q4. After enabling pipelining globally, about a third of a production fleet fails every task using become, with sudo reporting that a tty is required. The staging fleet was unaffected. What is happening?
Passing score: 75%. Answers are checked in this browser.