AnsibleXX · SSH Architecture and ConnectivitySSH architecture and connectivity
Pipelining and its preconditions
What you'll learn
- Explain the -tt decision the ssh connection plugin makes and why pipelining changes it
- Identify use_tty as a second, independent route to the same requiretty failure
- Establish whether requiretty is set across a fleet before enabling pipelining, not after
- Roll pipelining out per group with ansible_ssh_pipelining and a reversible plan
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 architecture part of this course established what pipelining does:
instead of writing the module wrapper to a file on the target and then
executing that file, Ansible feeds the wrapper to the remote Python
interpreter on standard input. The mkdir, PUT, chmod and rm
disappear, and one task becomes one round trip.
This lesson is about the precondition — because “pipelining conflicts with privilege escalation” is repeated everywhere and is, as usually stated, slightly wrong in a way that makes it hard to act on.
What the documentation actually says
$ ansible-doc -t connection ansible.builtin.ssh pipelining Pipelining reduces the number of connection operations
required to execute a module on the remote server, by
executing many Ansible modules without actual file
transfers.
This can result in a very significant performance
improvement when enabled.
However this can conflict with privilege escalation
('become'). For example, when using sudo operations you
must first disable 'requiretty' in the sudoers file for
the target hosts, which is why this feature is disabled
by default.
default: falseTwo things to take from that. The default is false because of the
requiretty interaction, not because pipelining is risky in itself. And
the words “for example” are doing real work — sudo is an instance of the
problem, not the problem.
The actual mechanism: one line in the connection plugin
The conflict is not between pipelining and sudo. It is between pipelining
and a pseudo-terminal, and the decision is made explicitly, here, in
ansible/plugins/connection/ssh.py on 2.21.3:
# we can only use tty when we are not pipelining the modules. piping
# data into /usr/bin/python inside a tty automatically invokes the
# python interactive-mode but the modules are not compatible with the
# interactive-mode ("unexpected indent" mainly because of empty lines)
# -tt can cause various issues in some environments so allow the user
# to disable it as a troubleshooting method.
use_tty = self.get_option('use_tty')
args: tuple[str, ...]
if not in_data and sudoable and use_tty:
args = ('-tt', self.host, cmd)
else:
args = (self.host, cmd)
in_data is the pipelined payload. So the condition reads: allocate a
pseudo-terminal only when there is no payload on stdin, the command is
one that might need to escalate, and use_tty is on.
Follow the causal chain, because the order matters:
- By default, Ansible passes
-tttossh, which forces allocation of a pseudo-terminal on the target. sudowithDefaults requirettyrefuses to run without a TTY. With-ttpresent, there is one, so it runs.- Pipelining puts the module on stdin, so
in_datais set, so the condition is false, so-ttis not passed. sudonow has no TTY and refuses.
The comment explains why step 3 has to work that way, and it has nothing
to do with sudo at all: piping a script into an interactive Python inside
a TTY makes Python treat it as interactive input, and the module source —
which contains blank lines — gets rejected with unexpected indent. The
two features are mutually exclusive for a reason internal to Python, and
sudo is a bystander that happens to depend on the flag that gets dropped.
Establishing the precondition before you change anything
The architecture part described what happens when a team enables pipelining globally and a third of a mixed-age fleet stops working. The reason that scenario is so common is that people establish the precondition retrospectively, from the failures.
You can establish it in advance, read-only, in one run.
# playbooks/preflight-requiretty.yml
# READ-ONLY: reads sudo policy and reports it. Changes nothing.
# Uses become because sudoers is only readable with privilege.
- name: Survey sudo TTY policy before enabling pipelining
hosts: all
gather_facts: false
become: true
tasks:
- name: Ask sudo for the effective defaults
ansible.builtin.command:
argv: [sudo, -n, sudo, -V]
register: sudo_v
changed_when: false
failed_when: false
- name: Report hosts whose sudo policy requires a tty
ansible.builtin.debug:
msg: "requiretty is in effect"
when: "'requiretty' in sudo_v.stdout"That is one approach and it has a weakness worth naming: sudo -V reports
the compiled defaults and the settings it can see, and a requiretty that
applies only to a specific user or command via a Defaults: specification
may not show up. The more reliable survey reads the policy files:
- name: Search sudoers and its drop-in directory
ansible.builtin.shell:
cmd: grep -RIl '^[[:space:]]*Defaults.*requiretty' /etc/sudoers /etc/sudoers.d/ || true
register: rt
changed_when: false
- name: Hosts that will break under pipelining
ansible.builtin.debug:
var: rt.stdout_lines
when: rt.stdout_lines | length > 0|| true because grep exits non-zero when it finds nothing, and “nothing
found” is the answer you are hoping for rather than a failure. The result
is a list of hosts, which turns “a third of the fleet broke” into “these
47 hosts need attention first” — the same information, obtained before the
outage instead of during it.
Rolling it out per group
pipelining is settable per host and per group, which makes a staged
rollout possible without touching sudo policy under time pressure:
$ ansible-doc -t connection ansible.builtin.ssh pipelining
set_via:
env:
- name: ANSIBLE_PIPELINING
- name: ANSIBLE_SSH_PIPELINING
ini:
- key: pipelining
section: defaults
- key: pipelining
section: connection
- key: pipelining
section: ssh_connection
vars:
- name: ansible_pipelining
- name: ansible_ssh_pipelining
default: false# inventory/production/group_vars/all.yml
ansible_ssh_pipelining: true
# inventory/production/group_vars/legacy_sudoers.yml
# 47 hosts carrying Defaults requiretty from the 2019 hardening baseline.
# Surveyed 2026-08-11. Remove this group as hosts are rebuilt; the
# replacement images do not set it. Tracked as OPS-4471.
ansible_ssh_pipelining: falseThe exception group carries what every exception in this course carries: a count, a reason, a date, an owner, and the condition under which it disappears. An exception with an expiry becomes smaller over time. An exception without one becomes permanent and eventually nobody remembers which hosts depended on it.
Note the direction of the default. Pipelining on for the fleet and off for the known exceptions is better than the reverse, because it makes the exception list visible and shrinking. The reverse — off globally, on for a few groups — hides the fact that most of your fleet is paying for a constraint that applies to 47 hosts.
Knowledge check
Knowledge check · 4 questions
Q1. Why does enabling pipelining break sudo on hosts that set Defaults requiretty?
Q2. A fleet reports the sudo TTY error, but ansible-config dump shows pipelining is off. What should you check next?
Q3. Which are sound reasons to survey requiretty across the fleet before enabling pipelining, rather than enabling it and seeing what breaks? Select all that apply.
Q4. Enabling pipelining removes the module wrapper file from the managed node, which eliminates one of the places a credential passed as a module argument can be left behind.
Passing score: 75%. Answers are checked in this browser.