Skip to main content
RunBook Academy

AnsibleII · Ansible ArchitectureThe execution model

How a module gets to the target: AnsiballZ and pipelining

Intermediate⏱ ~20 minansiblesshpython3

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

Not yet marked complete on this device.

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.

Read-only / Safethe wrapper left behind by a single ping task
$ wc -c ~/.ansible/tmp/ansible-tmp-*/AnsiballZ_ping.py
166844 /home/ops/.ansible/tmp/ansible-tmp-1786481678.42-4004527-44275714459835/AnsiballZ_ping.py

163 kilobytes, for ping.

The wrapper is a Python script with a large base64 string in it. Decoded, that string is a zip archive:

Read-only / Safewhat is inside the payload
$ 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)
PY
zip 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.py

Fifty-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.

Read-only / Safekeep the wrapper on the node
$ 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:

Read-only / Safethe same task with pipelining enabled
$ 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.14

Compare 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 noexec mounts, 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:

Read-only / Safepipelining is off by default
$ ansible-config dump | grep -E '^(ANSIBLE_PIPELINING|DEFAULT_KEEP_REMOTE_FILES)\('
ANSIBLE_PIPELINING(default) = False
DEFAULT_KEEP_REMOTE_FILES(default) = False

The 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

BehaviourExplanation from this lesson
A trivial task transfers over 100 KBThe module travels with all its module_utils
Task count matters more than it looksEach task builds and ships its own payload
A task fails with MODULE FAILURE on a full diskThe payload could not be written to the temp directory
A noexec home directory breaks everythingThe wrapper is written there and then executed
Module arguments do not appear in ps on the nodeThey are embedded in the wrapper, not passed as argv
Pipelining conflicts with becomeNo TTY is allocated, and requiretty sudoers refuse
Keeping remote files leaves secrets on hostsBase64 is an encoding, not encryption

Knowledge check

Knowledge check · 4 questions

  1. Q1. Why does a single ansible.builtin.ping task transfer roughly 160 KB to the managed node?

  2. 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.

  3. Q3. What does enabling pipelining remove from the per-task cycle? Select all that apply.

  4. 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.