<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[CodeWithNiji]]></title><description><![CDATA[CodeWithNiji]]></description><link>https://codewithniji.hashnode.dev</link><generator>RSS for Node</generator><lastBuildDate>Wed, 28 Oct 2026 06:39:40 GMT</lastBuildDate><atom:link href="https://codewithniji.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[How I audit 50 servers with one Ansible command]]></title><description><![CDATA[How I audited 50 simulated servers with one Ansible command, the code behind it, and the bugs that taught me the most]]></description><link>https://codewithniji.hashnode.dev/how-i-audit-50-servers-with-one-ansible-command</link><guid isPermaLink="true">https://codewithniji.hashnode.dev/how-i-audit-50-servers-with-one-ansible-command</guid><category><![CDATA[ansible]]></category><category><![CDATA[Devops]]></category><category><![CDATA[SRE]]></category><category><![CDATA[automation]]></category><category><![CDATA[Linux]]></category><dc:creator><![CDATA[Opeyemi Adeniji]]></dc:creator><pubDate>Mon, 19 Oct 2026 12:49:06 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/62cebf43fe5b55d5da4273f9/ca678a95-37fe-4b4a-9dce-4e66626693a1.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Imagine you run 50 servers and you need an answer to one question: which of them need attention right now?</p>
<p>The manual way is to SSH into each one, run <code>df -h</code>, <code>free -m</code>, <code>ps</code> and <code>ss -ltn</code>, and copy the results into a spreadsheet. It is slow, you will skip steps on server 37, and nobody does it twice. What I wanted instead was one command, one report, the same checks every time, with the worst servers at the top.</p>
<p>So I built that with Ansible, and tested it against a fleet of 50 simulated servers with five problems hidden in them. This post explains how it works, shows the code that matters, and covers the things that went wrong, because those taught me the most.</p>
<img src="https://cdn.hashnode.com/uploads/covers/62cebf43fe5b55d5da4273f9/dbfab133-3480-4e89-b83c-b5900f9ef4b5.png" alt="" style="display:block;margin:0 auto" />

<h2>The ideas behind it</h2>
<p>A few Ansible concepts carry the whole project.</p>
<p><strong>Agentless.</strong> Ansible needs only SSH and Python on the servers, which nearly every Linux machine already has. Nothing to install on 50 machines first. It runs from one control machine and reaches out.</p>
<p><strong>Inventory.</strong> The list of servers and how they are grouped. A static file goes stale, so I wrote a small Python script that asks Docker which containers are running and hands Ansible the list. For cloud servers the same idea works with a cloud API, and Ansible ships ready-made plugins for that. (<a href="https://docs.ansible.com/ansible/latest/inventory_guide/intro_dynamic_inventory.html">Docs: dynamic inventory</a>)</p>
<p><strong>Playbooks, plays, tasks.</strong> A playbook is a YAML file with one or more plays. Each play says "on these hosts, run these tasks", and each task calls a module such as <code>ping</code>, <code>command</code> or <code>template</code>.</p>
<p><strong>Roles.</strong> A role is a folder with a fixed layout (tasks, defaults, templates, meta) that packages one job, so a playbook can simply say "apply this role". The audit is two roles, which keeps each piece small and reusable. (<a href="https://docs.ansible.com/ansible/latest/playbook_guide/playbooks_reuse_roles.html">Docs: roles</a>)</p>
<p><strong>Read-only by design.</strong> The audit only reads: <code>/proc</code>, <code>df</code>, <code>ps</code>, <code>ss</code>. It changes nothing, so it is safe on production. Every command task sets <code>changed_when: false</code>, which keeps Ansible's output honest: a command ran, but nothing changed.</p>
<p><strong>An unreachable server is a result, not a crash.</strong> With <code>ignore_unreachable: true</code>, a dead server does not stop the run. It becomes a row in the report, at the top, marked UNREACHABLE. That is exactly what you want from an audit: the server that does not answer is the first thing you need to know about. (<a href="https://docs.ansible.com/ansible/latest/playbook_guide/playbooks_error_handling.html">Docs: error handling</a>)</p>
<h2>The design</h2>
<p>I do not have 50 servers, so I built 50 lookalikes: Docker containers running a real SSH server and real processes. One image plays every role. An environment variable picks the service: nginx for web, a small Python server on 8080 for app, and a listener on 5432 for db. Each container carries labels (<code>fleet.audit=true</code>, <code>fleet.role=web</code>), and a script generates the Docker Compose file so nobody types 50 service blocks.</p>
<p>The fleet has 20 web, 20 app and 10 db hosts, with five problems seeded on purpose:</p>
<table>
<thead>
<tr>
<th>Host</th>
<th>Seeded fault</th>
<th>Expected result</th>
</tr>
</thead>
<tbody><tr>
<td>web-07</td>
<td><code>/data</code> volume filled to 92%</td>
<td>CRIT</td>
</tr>
<tr>
<td>app-03</td>
<td><code>/data</code> volume filled to 84%</td>
<td>WARN</td>
</tr>
<tr>
<td>web-12</td>
<td>nginx not started</td>
<td>CRIT</td>
</tr>
<tr>
<td>app-11</td>
<td>app server not started</td>
<td>CRIT</td>
</tr>
<tr>
<td>db-02</td>
<td>sshd not started</td>
<td>UNREACHABLE</td>
</tr>
</tbody></table>
<p>If the audit finds exactly these five and calls everything else OK, I know it works.</p>
<p>The playbook has two plays, and each one is a role (audit_host and audit_report), a pattern I call <strong>collect, then aggregate</strong>:</p>
<ol>
<li><p><strong>Collect.</strong> Runs on every host, 25 at a time. Each host reads its own numbers, compares them with thresholds and builds one small record: status, disk, memory, ports and a list of issues.</p>
</li>
<li><p><strong>Aggregate.</strong> Runs once on my own machine. It reads every host's record, adds a placeholder for hosts that returned nothing, sorts worst first and writes the HTML, CSV and JSON reports.</p>
</li>
</ol>
<p>Keeping the two apart means the hosts never need to know about each other, and the report logic lives in one place.</p>
<h2>The code that matters</h2>
<p><strong>Survive a dead host.</strong> The first task of the audit_host role pings, and everything else is skipped for hosts that did not answer:</p>
<pre><code class="language-yaml">- name: Check the host is reachable
  ansible.builtin.ping:
  register: audit_host_reach
  ignore_unreachable: true

- name: Audit the host
  when: not (audit_host_reach.unreachable | default(false))
  block:
    # ...all the checks...
</code></pre>
<p><strong>Collect raw numbers.</strong> Each check is a read-only command, saved with <code>register</code>:</p>
<pre><code class="language-yaml">- name: Read filesystem usage (mount point and use%)
  ansible.builtin.shell: |
    set -o pipefail
    df -P -x squashfs -x iso9660 -x erofs | awk 'NR &gt; 1 &amp;&amp; $6 !~ /^\/(proc|sys|dev|run|etc)(\/|$)/ {print $6, $5}'
  args:
    executable: /bin/bash
  register: audit_host_disk_out
  changed_when: false
</code></pre>
<p><strong>Turn text into numbers.</strong> A <code>set_fact</code> task with Jinja filters parses the output:</p>
<pre><code class="language-yaml">audit_host_disk_pcts: "{{ audit_host_disk_out.stdout_lines | map('regex_search', '[0-9]+(?=%$)') | select | map('int') | list }}"
</code></pre>
<p><strong>Keep the policy out of the logic.</strong> Thresholds and what each role should run live in <code>group_vars</code>, so web servers are expected to run nginx on port 80 and db servers are not. The role ships sensible defaults, and group_vars overrides them:</p>
<pre><code class="language-yaml"># playbooks/group_vars/all.yml
audit_host_disk_warn_pct: 80
audit_host_disk_crit_pct: 90

# playbooks/group_vars/web.yml
audit_host_expected_processes: [nginx]
audit_host_expected_ports: [80]
</code></pre>
<p><strong>Decide.</strong> Each check adds a message to a list, or an empty list. Joined together, they give the host's status:</p>
<pre><code class="language-yaml">audit_host_crit: &gt;-
  {{
    (['disk ' ~ audit_host_disk_max ~ '% full (critical at ' ~ audit_host_disk_crit_pct ~ '%)']
      if (audit_host_disk_max | int) &gt;= (audit_host_disk_crit_pct | int)
      else [])
    + (['process not running: ' ~ (audit_host_missing_procs | join(', '))]
      if (audit_host_missing_procs | length) &gt; 0
      else [])
    + (['port not listening: ' ~ (audit_host_missing_ports | join(', '))]
      if (audit_host_missing_ports | length) &gt; 0
      else [])
  }}
</code></pre>
<p>The <code>status</code> is CRIT if that list has anything in it, otherwise WARN if the warning list does, otherwise OK. The second play then collects those records through Ansible's <code>hostvars</code>, sorts by severity and fills the report templates. Both plays live in roles, so audit.yml itself is only a few lines.</p>
<h2>What went wrong</h2>
<p>The first real run did not match my expectations, and these are the problems worth sharing.</p>
<p><strong>1. Every host was CRIT, with all numbers at zero.</strong> The audit said 49 of 49 reachable hosts were critical, with 0% disk and open ports <code>[0]</code>. But running <code>df</code> and <code>ss</code> by hand on one host showed perfectly normal output. So the hosts were fine, and my parsing was wrong. I had used <code>regex_replace</code> with a <code>'\\1'</code> backreference to pull out the numbers, and the backreference was not being substituted, so every value turned into 0. I printed the <code>issues</code>, <code>disks</code> and <code>ports</code> fields from the JSON report, compared them with the raw command output, and rewrote the parsing with <code>regex_search</code> and lookahead and lookbehind, which need no backreference. The lesson: look at the raw data before blaming the system, and a test that only checks the report's shape (right columns, right host count) will happily pass when every number is wrong.</p>
<p><strong>2. A healthy machine reported a full disk.</strong> My local test host came out CRIT at 100% disk. The culprit was a read-only image mount that is always 100% full (<code>iso9660</code> here; snap packages show up as <code>squashfs</code> on Ubuntu). The fix was <code>df -x squashfs -x iso9660 -x erofs</code>. An audit has to know what counts as a real filesystem.</p>
<p><strong>3. The playbook worked, but</strong> <code>ansible ping</code> <strong>did not.</strong> The ad hoc ping failed with "Permission denied (publickey)" because it tried to log in as my own user. I had put <code>remote_user</code> and the key path in <code>playbooks/group_vars</code>, and variables in a folder beside the playbook are loaded only when you run that playbook, not for ad hoc commands. Moving them into <code>ansible.cfg</code> fixed both.</p>
<p><strong>4. Every container looked busy.</strong> Containers share the host's kernel, so <code>/proc/loadavg</code> shows my PC's load on all 50 hosts. That is a limit of simulating servers with containers. I raised the threshold for the simulation, wrote it down in the README, and noted that real servers should use about 1.0 load per CPU.</p>
<p>Smaller ones: a callback plugin (<code>timer</code>) that lives in a separate collection and could not load, ansible-lint rejecting lines over 160 characters, and Python refusing <code>pip install --user</code> on a modern system (a virtual environment solves it).</p>
<h2>Forks: a result that surprised me</h2>
<p><code>forks</code> is how many hosts Ansible works on at the same time. The default is 5, so 50 hosts take 10 rounds. I expected 25 forks to be several times faster. I timed two runs of each:</p>
<table>
<thead>
<tr>
<th>Forks</th>
<th>Run 1</th>
<th>Run 2</th>
</tr>
</thead>
<tbody><tr>
<td>5</td>
<td>67.5 s</td>
<td>70.5 s</td>
</tr>
<tr>
<td>25</td>
<td>64.3 s</td>
<td>61.9 s</td>
</tr>
</tbody></table>
<p>Only about 8% faster. The reason is in the <code>user</code> and <code>sys</code> times: Ansible used between 1.5 and 2 CPU cores the whole run. My laptop was running all 50 containers, their SSH servers and Ansible's worker processes at once, so extra forks just queued for CPU. With real servers, most of each task is network waiting, the control machine sits idle, and more forks pay off much more.</p>
<p>The lesson: parallelism helps when you are waiting, not when you are computing. And a simulation can flatter or hide a setting, so say what it cannot show. The same run also does about 700 task executions (50 hosts times 14 tasks), each a separate SSH exchange. Collecting everything with one script per host would cut that sharply, and is the first thing I would try next.</p>
<h2>What a reviewer suggested</h2>
<p>After I shared the project, a friend with cloud experience gave me two pieces of feedback. Both were fair, and both changed the project in v0.2.0.</p>
<p><strong>Use roles.</strong> My playbook was one long file holding every check and all the report logic. Now it is two roles, and <code>playbooks/audit.yml</code> only calls them:</p>
<pre><code class="language-text">playbooks/
  audit.yml                 two plays, each one calls a role
  group_vars/               thresholds and per-role expectations
  roles/
    audit_host/             collects and judges one host (runs on every host)
    audit_report/           builds the CSV, HTML and JSON (runs once)
</code></pre>
<p>I treated this as a refactor: same behaviour, new structure. The acceptance test was the same as before. The audit still had to find exactly the five seeded faults, which means 50 hosts with 1 UNREACHABLE, 3 CRIT, 1 WARN and 45 OK, and it did. One detail worth knowing: ansible-lint wants role variables prefixed with the role name (<code>audit_host_disk_warn_pct</code>, not <code>disk_warn_pct</code>), so two roles can never overwrite each other's settings.</p>
<p><strong>Never let Ansible hold cloud keys.</strong> The second suggestion was to reach AWS, Azure and GCP through roles and short-lived identity instead of secret keys. The reasoning is sound: a stored access key can leak from a laptop, a repository or a CI log, and nobody remembers to rotate it. An assumed role, a managed identity or an attached service account gives Ansible credentials that expire within about an hour, so there is nothing long-lived to steal. I recorded the decision as ADR 0004 and added example inventories for the <a href="https://docs.ansible.com/ansible/latest/collections/amazon/aws/aws_ec2_inventory.html">AWS</a>, <a href="https://docs.ansible.com/ansible/latest/collections/azure/azcollection/azure_rm_inventory.html">Azure</a> and <a href="https://docs.ansible.com/ansible/latest/collections/google/cloud/gcp_compute_inventory.html">GCP</a> inventory plugins.</p>
<p>I want to be clear about what is and is not done. Those example inventories <strong>have not been run yet</strong>, because the project has no cloud accounts attached, and the repo says so. Real runs will come in stages, each as its own release: AWS first, then Azure, then GCP.</p>
<h2>Engineering around it</h2>
<p>The automation is only half of the project. The rest is what makes it safe to change:</p>
<ul>
<li><p><strong>Tests.</strong> Ten unit tests for the inventory script (using fake Docker data), <code>ansible-lint</code>, a syntax check, and an end-to-end run of the real playbook against the local machine as a one-host fleet.</p>
</li>
<li><p><strong>CI.</strong> GitHub Actions runs three jobs on every pull request: lint, a secret scan over the full git history, and the Ansible tests.</p>
</li>
<li><p><strong>A protected</strong> <code>main</code><strong>.</strong> A GitHub ruleset blocks direct pushes and requires a pull request and all three checks. Merges are squashed, so history reads as one line per change.</p>
</li>
<li><p><strong>Signed commits.</strong> Each commit is signed with an SSH key, so GitHub shows Verified.</p>
</li>
<li><p><strong>Decision records and a runbook.</strong> Short ADRs explain why the fleet is simulated with containers, why the inventory is a script and why the design is collect-then-aggregate, and why cloud access should use short-lived roles instead of stored keys. The runbook says what to do about each finding.</p>
</li>
</ul>
<p><strong>What I would do differently next time:</strong></p>
<ul>
<li><p>Run it against real VMs, starting with AWS, and compare the numbers with the simulation.</p>
</li>
<li><p>Add <code>service_facts</code> to check systemd services properly.</p>
</li>
<li><p>Collect everything in one script per host to cut SSH round trips.</p>
</li>
<li><p>Keep reports over time to see trends, and run <code>make audit-strict</code> on a schedule so a CRIT or UNREACHABLE host fails the job and alerts someone.</p>
</li>
<li><p>Turn host key checking back on. I disabled it only because containers get new keys on every rebuild.</p>
</li>
</ul>
<h2>Try it yourself</h2>
<p>The whole project is on GitHub: <a href="https://github.com/OpeyemiAdeniji/ansible-fleet-audit">OpeyemiAdeniji/ansible-fleet-audit</a>. You need WSL2 or Linux, Docker and Python. Then:</p>
<pre><code class="language-bash">git clone git@github.com:OpeyemiAdeniji/ansible-fleet-audit.git
cd ansible-fleet-audit
python3 -m venv .venv &amp;&amp; source .venv/bin/activate
make setup
make fleet-up     # builds and starts the 50 containers
make audit        # writes reports/latest.html
</code></pre>
<p>The repo has a runbook for acting on each finding, and decision records for the main design choices.</p>
<p>If you take one thing from this post, let it be this: when your tool reports that everything is broken, check the raw data before you blame the system. Mine was a regex. Yours will be something else, and the habit is the same.</p>
<p><em>This is the first of a series of DevOps and SRE projects I am building and writing up. Next: a golden machine image built with Packer and hardened to a CIS benchmark.</em></p>
<p><strong>Further reading:</strong></p>
<ul>
<li><p><a href="https://docs.ansible.com/ansible/latest/inventory_guide/intro_dynamic_inventory.html">Ansible dynamic inventory</a></p>
</li>
<li><p><a href="https://docs.ansible.com/ansible/latest/playbook_guide/playbooks_error_handling.html">Ansible error handling</a></p>
</li>
<li><p><a href="https://docs.ansible.com/ansible/latest/reference_appendices/config.html#default-forks">Ansible forks setting</a></p>
</li>
<li><p><a href="https://docs.ansible.com/projects/lint/">ansible-lint</a></p>
</li>
<li><p><a href="https://docs.ansible.com/ansible/latest/playbook_guide/playbooks_reuse_roles.html">Ansible roles</a></p>
</li>
<li><p><a href="https://www.conventionalcommits.org/">Conventional Commits</a></p>
</li>
</ul>
]]></content:encoded></item></channel></rss>