Reported symptoms
A capacity review of the central log platform finds that one application tier is sending roughly two hundred times more log volume than any comparable tier. The application has not changed.
Pulling the thread produces a second observation nobody had connected
to it: the nightly Ansible converge restarts rsyslog on those hosts
every single night at 02:10. It always has.
And a third: that converge has never once reported changed=0.
Nobody raised any of this because nothing ever failed. The dashboard counts failed hosts, the number has been zero for eight months, and a green run at 02:15 is not something anybody investigates.
Evidence provided
$ ansible app012 -i inventory -m ansible.builtin.command -a 'wc -l /etc/rsyslog.d/50-forward.conf'214 /etc/rsyslog.d/50-forward.conf$ ansible app012 -i inventory -m ansible.builtin.command -a 'tail -3 /etc/rsyslog.d/50-forward.conf' *.* @@logs.example.com:6514
*.* @@logs.example.com:6514
*.* @@logs.example.com:6514$ ansible-playbook -i inventory site.yml --limit app012 --check --diff --tags loggingTASK [logging : Forward syslog to the central collector] ***********************
--- before: /etc/rsyslog.d/50-forward.conf (content)
+++ after: /etc/rsyslog.d/50-forward.conf (content)
@@ -212,3 +212,4 @@
*.* @@logs.example.com:6514
*.* @@logs.example.com:6514
*.* @@logs.example.com:6514
+ *.* @@logs.example.com:6514
changed: [app012]$ grep -n -A6 'Forward syslog' roles/logging/tasks/main.yml12:- name: Forward syslog to the central collector
13: ansible.builtin.lineinfile:
14: path: /etc/rsyslog.d/50-forward.conf
15: regexp: '^\*\.\* @@logs\.example\.com'
16: line: ' *.* @@logs.example.com:6514'
17: create: true
18: notify: restart rsyslog$ ansible app012 -i inventory -m ansible.builtin.command -a 'journalctl -u rsyslog --since -7d | grep -c Stopped'7Work the evidence before reading on
Everything here is behaving correctly and reporting honestly. The task
changed the file, so it said changed. The handler was notified, so it
ran. The dry run predicted the change, so it printed it.
- Read
regexpandlinecharacter by character, starting at the first character of each. What does^anchor to, and what is the first character of the string being written? - If the pattern never matches, what does
lineinfiledo withstate: present? --checkagreed with the real run. Given that, what could a dry run possibly have told you?
Before continuing: what is the smallest run you could perform that would distinguish “this task did work” from “this task cannot stop working”?
Root cause
1. The pattern cannot match the line the task writes
For state: present, lineinfile behaves in two stages. It searches
every line of the file for regexp; if it finds a match it replaces the
last matching line with line, and if it finds none it inserts line.
There is no third case, and no complaint.
The pattern is anchored:
^\*\.\* @@logs\.example\.com
The line begins with two spaces:
*.* @@logs.example.com:6514
^ anchors at the start of the line, so an anchored pattern cannot
match a line that starts with whitespace. The task searches, fails,
appends, and reports changed - accurately.
The indentation was added in a tidy-up commit six days after the role was written, to make the file match the surrounding style. The regexp was not touched, because from the diff there was no reason to touch it.
2. changed is a statement about the run, not about correctness
Ansible’s changed means “this task modified the target”. It does not
mean “this task needed to”. Nothing in the model can tell the difference,
because the module reports what it did and the engine believes it.
A task that appends a duplicate every run reports changed every run,
truthfully, forever. The converge never converges, and the only visible
trace is a number in a summary line that nobody reads when the failure
count is zero.
3. The handler restart is downstream of the same fault
notify: restart rsyslog fires when the task reports changed. Since
the task reports changed every night, the handler runs every night.
The service restart is not a separate bug. It is what an inaccurate
changed does to everything built on top of it: the handler mechanism
is only as good as the change detection feeding it, and once a restart
happens every night it stops carrying any information about whether
something happened.
Resolution
- Stop the nightly restart before anything else. Disable the schedule, or add a temporary condition, so the fleet stops accumulating another copy while you work.
- Fix the role, not the hosts. The pattern must match what the task writes. The better fix is to stop using
lineinfilefor a file that Ansible owns entirely: replace it withtemplaterendering the whole file, so content and comparison come from one source. - Measure the damage before cleaning it up. Record the line count per host, because the duplicate forwarding rules mean each host has been sending every message once per copy, and the log platform team needs that number.
- Clean up with a backup and a validation, not with an in-place
sed. Render the corrected file to a new path, confirm the service accepts it withrsyslogd -N1 -f <path>, then move it into place. - Restart the service once, deliberately, and confirm forwarding still works from the collector side rather than from the absence of errors on the host.
- Converge twice on one host and require
changed=0on the second run before touching the rest of the fleet. - Roll out, then re-run the two-converge check on a random sample across roles and build ages, including at least one host built this week and one built a year ago.
- Add the converge-converge gate to CI in the same change, so the class is closed rather than this instance.
Verification
- The second converge reports no change. Run the play twice against the same host with nothing else altered; the second run must report
changed=0for the whole play. This is the only check that distinguishes a task that did work from a task that cannot stop. - The rule appears once.
grep -c "logs.example.com" /etc/rsyslog.d/50-forward.confreturns exactly 1 on every repaired host. - The handler did not fire on the second run. The run output shows no
RUNNING HANDLERsection, andjournalctl -u rsyslogrecords no restart at that timestamp. - The check can fail. On a test host, delete the line and converge: the run must report
changed=1, and the run after that must reportchanged=0. A check that reports success on a host you deliberately broke is not measuring anything. - The service parses its configuration.
rsyslogd -N1returns success on a repaired host; a file that is correct and unparseable is a different incident starting. - Forwarding is confirmed from the collector. Messages from a repaired host arrive once, not 214 times, measured at the destination rather than at the source.
- The CI gate rejects a regression. Reintroduce the anchored-pattern task on a branch and confirm the pipeline fails on the second converge.
Prevention
- Run every role twice in CI and require
changed=0on the second pass. Idempotence is a property of the second run and there is no other way to observe it. - Alert on persistent
changedin production, not only on failures. A host that never reacheschanged=0is either drifting or fighting itself, and both are worth a page eventually. - Use
templatefor files you own. One description of the whole file, compared as a whole, cannot drift apart from its own matching rule. - Reserve
lineinfilefor a line inside a file another system owns, and when you use it, write the regexp against the exact string the task writes. If thelineis later reformatted, the regexp is part of the same change. - Prefer
search_stringtoregexpwhen you are matching a literal. It removes anchoring and escaping from the problem entirely. - Do not rely on
--checkto catch idempotence faults. Check mode validates prediction against action, and a task with wrong logic predicts its wrong action perfectly. - Look at old hosts, not new ones. Accumulation faults are invisible on a machine built yesterday, and the oldest host in the fleet is the best detector you have.