Ansible Architecture and Agentless Operation
Ansible is agentless, push-based and runs Python over SSH. Those three words are repeated everywhere and rarely demonstrated, so this checks each of them on the machines themselves: no service, no listening port, no package - and then the literal ssh command line Ansible constructs, the temporary directory it uses on the far side, and the SSH logins counted from the managed node's own journal.
Control Node and Managed Nodes Guide 1 of 45 Beginner
- OSUbuntu 26.04 LTS
- ansible-core2.20.1
- Python3.14.4
- TimeAbout 13 min
- Reviewed23 August 2026
Written against the versions above. `ansible-core` 2.20 connects with OpenSSH and reuses connections with `ControlMaster`/`ControlPersist`, which is why a play of twenty tasks does not make twenty TCP connections. The managed node needs only Python and an SSH server; here that is Python 3.14.4, which nobody installed for Ansible.
| Server Name | IP Address | OS | Roles | CPU | RAM | HDD |
|---|---|---|---|---|---|---|
| ANS-CTL01 | 192.168.0.36 | Ubuntu 26.04 LTS | Ansible Control Node | 2 Core | 3 GB | 50 GB |
| ANS-A01 | 192.168.0.37 | Ubuntu 26.04 LTS | Managed Node (group: web) | 2 Core | 3 GB | 50 GB |
| ANS-B01 | 192.168.0.38 | Ubuntu 26.04 LTS | Managed Node (group: db) | 2 Core | 3 GB | 50 GB |
Before you start
- Three machines that can reach each other over SSH. Ansible on one of them.
- Nothing is installed or changed on the managed nodes by this guide - every command on them is a read.
-
No agent on the managed nodes
Three checks on
ANS-A01, which Ansible manages:no ansible service runningnothing listening for ansiblels: cannot access '/etc/ansible': No such file or directoryNo process, no port, no package, no configuration. This is the difference from Puppet, Chef and Salt in their usual deployments, and it is why Ansible can be pointed at a machine you have never touched: if you can SSH to it, you can manage it.
It also means there is nothing to keep patched on the fleet, nothing to break during an upgrade, and no port to firewall - the control node is the only place Ansible exists.
bash Example session systemctl list-units --type=service --state=running --no-pager --no-legend | awk '{print $1}' | grep -i ansible || echo "no ansible service running"no ansible service runningss -ltnp 2>/dev/null | grep -i ansible || echo "nothing listening for ansible"nothing listening for ansiblels /etc/ansible 2>&1ls: cannot access '/etc/ansible': No such file or directory[exit 2]Expected resultThree ways of finding nothing.
Success conditionYou can show a colleague that 'agentless' is literal.
-
The transport is just SSH
-vvvprints the command Ansible actually runs:<ans-a01> ESTABLISH SSH CONNECTION FOR USER: sysadmin <ans-a01> SSH: EXEC ssh -C -o ControlMaster=auto -o ControlPersist=60s -o StrictHostKeyChecking=no ... -o 'ControlPath="/home/sysadmin/.ansible/cp/1b2bc630e1"' ans-a01 '/bin/sh -c ...'That is ordinary OpenSSH, with flags you could type yourself. Two of them matter:
ControlMaster=autoandControlPersist=60s- the first task opens a connection and the rest reuse it for a minute. Without this, Ansible would be painfully slow, and it is the first thing to check when it is.StrictHostKeyChecking=no- becausehost_key_checking = Falseis set in this lab'sansible.cfg. On a real fleet you would leave it on.
The
pingmodule, incidentally, is not ICMP:ans-a01 | SUCCESS => { "changed": false, "ping": "pong" }It logs in, runs Python, and gets an answer back. It tests the whole path - SSH, authentication, and a usable Python - which is why it is the first thing to run against a new host.
bash Example session ansible ans-a01 -m ping -vvv 2>&1 | grep -E "ESTABLISH SSH CONNECTION|SSH: EXEC ssh" | head -2<ans-a01> ESTABLISH SSH CONNECTION FOR USER: sysadmin<ans-a01> SSH: EXEC ssh -C -o ControlMaster=auto -o ControlPersist=60s -o StrictHostKeyChecking=no -o KbdInteractiveAuthentication=no -o PreferredAuthentications=gssapi-with-mic,gssapi-keyex,hostbased,publickey -o PasswordAuthentication=no -o 'User="sysadmin"' -o ConnectTimeout=10 -o 'ControlPath="/home/sysadmin/.ansible/cp/1b2bc630e1"' -o NumberOfPasswordPrompts=1 ans-a01 '/bin/sh -c '"'"'echo ~sysadmin && sleep 0'"'"''ansible ans-a01 -m ping -vvv 2>&1 | grep -E "ESTABLISH SSH CONNECTION|SSH: EXEC ssh" | head -2<ans-a01> ESTABLISH SSH CONNECTION FOR USER: sysadmin<ans-a01> SSH: EXEC ssh -C -o ControlMaster=auto -o ControlPersist=60s -o StrictHostKeyChecking=no -o KbdInteractiveAuthentication=no -o PreferredAuthentications=gssapi-with-mic,gssapi-keyex,hostbased,publickey -o PasswordAuthentication=no -o 'User="sysadmin"' -o ConnectTimeout=10 -o 'ControlPath="/home/sysadmin/.ansible/cp/1b2bc630e1"' -o NumberOfPasswordPrompts=1 ans-a01 '/bin/sh -c '"'"'echo ~sysadmin && sleep 0'"'"''Expected resultThe raw ssh invocation, and
pong.Success conditionYou know exactly what Ansible does to reach a host.
-
What it actually runs over there
Ansible does not run commands remotely so much as ship a Python program and execute it:
/home/sysadmin/.ansible/tmp/ansible-tmp-1787469151.4365723-8675-160474021110340That directory is created on the managed node, the module is written into it, run, and cleaned up. It exists on both machines:
ans-a01 | CHANGED | rc=0 >> /home/sysadmin/.ansible/tmpAnd the only real requirement on the far side is a Python interpreter:
"ansible_python_version": "3.14.4"which Ubuntu ships anyway. This is why a module is not a shell script: it is Python, it returns JSON, and that JSON is what you see in the output.
bash Example session ansible ans-a01 -m ping -vvv 2>&1 | grep -oE "/home/sysadmin/\.ansible/tmp/[^ \"]*" | head -2/home/sysadmin/.ansible/tmp/ansible-tmp-1787469151.4365723-8675-160474021110340/home/sysadmin/.ansible/tmp/ansible-tmp-1787469151.4365723-8675-160474021110340ansible all -m command -a "ls -d /home/sysadmin/.ansible/tmp" 2>&1 | grep -E "rc=0|tmp"ans-a01 | CHANGED | rc=0 >>/home/sysadmin/.ansible/tmpans-b01 | CHANGED | rc=0 >>/home/sysadmin/.ansible/tmpansible all -m setup -a "filter=ansible_python_version"ans-a01 | SUCCESS => { "ansible_facts": { "ansible_python_version": "3.14.4" }, "changed": false}ans-b01 | SUCCESS => { "ansible_facts": { "ansible_python_version": "3.14.4" }, "changed": false}Expected resultA temp directory on the target and a Python version.
Success conditionYou can explain why a managed node needs Python and nothing else.
-
Push, not pull
ans-b01 | CHANGED | rc=0 >> up 1 hour, 14 minutes ans-a01 | CHANGED | rc=0 >> up 1 hour, 14 minutesNote
ans-b01answered first. Ansible runs hosts in parallel and prints them as they finish, so output order is not inventory order - which trips people up when they start reading results carefully.And from the managed node's own journal, counting SSH logins in the last three minutes:
8Eight. Nothing was scheduled on
ANS-A01; nothing polled a server. The control node connected, eight times, because that is how many tasks this guide has run against it. Work happens when you run a command and at no other time.That is the trade against a pull-based tool: no drift correction while you are not looking, and equally, nothing changes on your machines unless somebody deliberately runs something.
bash Example session ansible all -m command -a "uptime -p"ans-b01 | CHANGED | rc=0 >>up 1 hour, 14 minutesans-a01 | CHANGED | rc=0 >>up 1 hour, 14 minutessudo journalctl -u ssh --since "-3 min" --no-pager 2>/dev/null | grep -c "Accepted publickey" || echo 08Expected resultOut-of-order results, and a count of real SSH logins.
Success conditionYou can describe the execution model from evidence on both ends.
Troubleshooting
Ansible is extremely slow, a second or more per task.
Why: Connection reuse is off, so every task makes a new SSH connection.
Fix:Check
ControlPersistappears in-vvvoutput.ssh_connectionsettings inansible.cfgcontrol it;pipelining = Truehelps further./usr/bin/python3: not foundfrom a managed node.Why: No Python interpreter, or not where Ansible looked.
Fix:Set
ansible_python_interpreterfor that host, or useraw- the one module that needs no Python - to install it.Host key verification failures on a new machine.
Why: The host is not in
known_hosts.Fix:Add it properly, or set
host_key_checking = Falseinansible.cfgas this lab does. The second is convenient and weakens the connection's guarantees.