CertGrid CertGrid
Concepts·Ansible

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

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.

All three, because the point of the guide is what is present on the control node and absent on the other two.
Server NameIP AddressOSRolesCPURAMHDD
ANS-CTL01192.168.0.36Ubuntu 26.04 LTSAnsible Control Node2 Core3 GB50 GB
ANS-A01192.168.0.37Ubuntu 26.04 LTSManaged Node (group: web)2 Core3 GB50 GB
ANS-B01192.168.0.38Ubuntu 26.04 LTSManaged Node (group: db)2 Core3 GB50 GB

Before you start

  1. No agent on the managed nodes

    Three checks on ANS-A01, which Ansible manages:

    no ansible service running
    nothing listening for ansible
    ls: cannot access '/etc/ansible': No such file or directory

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

  2. The transport is just SSH

    -vvv prints 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=auto and ControlPersist=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 - because host_key_checking = False is set in this lab's ansible.cfg. On a real fleet you would leave it on.

    The ping module, 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.

  3. 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-160474021110340

    That 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/tmp

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

  4. Push, not pull

    ans-b01 | CHANGED | rc=0 >>
    up 1 hour, 14 minutes
    ans-a01 | CHANGED | rc=0 >>
    up 1 hour, 14 minutes

    Note ans-b01 answered 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:

    8

    Eight. 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 08

    Expected resultOut-of-order results, and a count of real SSH logins.

    Success conditionYou can describe the execution model from evidence on both ends.

Troubleshooting

Official sources