Reported symptoms
The payments team has a recurring complaint that has been open for five weeks. Once a night, at almost exactly 02:07, a handful of in-flight requests to the payment gateway fail with connection resets. Volume at that hour is low, the blip lasts under two seconds, and the error budget absorbs it - which is why it took five weeks to become somebody’s problem.
What the investigation found first:
- No cron job runs at that time on those hosts.
- No systemd timer fires in that window.
- The change-management calendar is empty.
- No deployment pipeline is scheduled overnight.
- The service is not crashing:
journalctlshows a clean stop and a clean start, not a fault.
The nightly Ansible converge runs at 02:00 and is green every night. Nobody looked at it for a fortnight, on the grounds that it had not changed and it was not deploying anything.
Evidence provided
$ ansible pay01 -i inventory -b -m ansible.builtin.command -a 'journalctl -u payment-gw --since -7d -o short-iso | grep -E "Stopped|Started" | tail -6'2026-08-09T02:07:11+0000 systemd[1]: Stopped payment-gw.service
2026-08-09T02:07:12+0000 systemd[1]: Started payment-gw.service
2026-08-10T02:07:04+0000 systemd[1]: Stopped payment-gw.service
2026-08-10T02:07:05+0000 systemd[1]: Started payment-gw.service
2026-08-11T02:07:19+0000 systemd[1]: Stopped payment-gw.service
2026-08-11T02:07:19+0000 systemd[1]: Started payment-gw.service$ grep -E 'TASK|RUNNING HANDLER|changed:' logs/converge-2026-08-11.log | head -8TASK [payment : Render the gateway configuration] ******************************
changed: [pay01]
RUNNING HANDLER [payment : restart payment-gw] *********************************
changed: [pay01]$ ansible-playbook -i inventory site.yml --limit pay01 --check --diff --tags paymentTASK [payment : Render the gateway configuration] ******************************
--- before: /etc/payment-gw/gateway.conf
+++ after: /etc/payment-gw/gateway.conf
@@ -1,4 +1,4 @@
# Managed by Ansible - do not edit by hand
-# Generated 2026-08-11T02:07:03Z
+# Generated 2026-08-11T14:22:41Z
# Role: payment
changed: [pay01]$ head -4 roles/payment/templates/gateway.conf.j2# Managed by Ansible - do not edit by hand
# Generated {{ ansible_date_time.iso8601 }}
# Role: payment
$ git log --oneline -1 -- roles/payment/templates/gateway.conf.j28b1c04e docs: add a provenance header to generated config filesWork the evidence before reading on
Everything in the chain is behaving exactly as designed. The task
reported changed because the file changed. The handler ran because it
was notified. The service restarted because that is what the handler
does.
- Read the
--check --diffoutput and count the lines that differ. Then ask which of them is configuration. ansible_date_timeis a fact. When is it measured, and how often does its value repeat?- The service restarts at 02:07 but the converge starts at 02:00. What accounts for the seven minutes, and does it tell you anything about which task is responsible?
Before continuing: if you rendered this template twice in a row with no other change, would the two outputs be identical? What does that answer imply about every run, forever?
Root cause
1. The rendered content includes the current time
ansible.builtin.template decides whether it has anything to do by
rendering the template with the current variables and comparing the
result against the file on the target. If they match, it reports ok.
If they differ, it writes the new content and reports changed.
The provenance header renders ansible_date_time.iso8601, a fact
gathered at the start of each run. Its value is different on every run
by construction. The rendered content is therefore different on every
run by construction, and the comparison can never come out equal.
The task is not wrong about what it did. It genuinely wrote a different file. It is the file that is wrong, in the specific sense that it contains a value which is not part of the configuration.
2. notify inherits the defect
notify fires when a task reports changed. Once changed is pinned
to true, the handler is pinned to running.
The restart is not a bug in the handler, in systemd, or in the service. It is the accurate downstream consequence of a change signal that has stopped carrying information. Every handler in this role has the same problem for the same reason, and so does every drift report, every compliance snapshot, and every dashboard that counts changed hosts.
3. The header was a good idea implemented in the wrong place
The commit that caused this is entirely sympathetic. Knowing when and by what a file was generated is genuinely useful when you are staring at an unfamiliar host at three in the morning.
The mistake is the specific value. Provenance that identifies what generated the file - the role, the repository, the commit - is stable between runs and changes exactly when the configuration changes. Provenance that records when it was generated changes every time and takes change detection down with it.
Resolution
- Suppress the nightly restart tonight while you work, either by pausing the converge or by temporarily removing the notify. Five weeks of a nightly outage does not need a sixth.
- Remove the timestamp from the template. Keep the provenance if it is wanted, but express it with values that change only when the configuration changes - the role, the repository, the deploying commit.
- Render on a canary host and compare against the existing file to confirm the only difference is the header you just corrected. There should be no surprises hiding underneath five weeks of noise.
- Converge the canary twice. The second run must report
changed=0for the template task and must produce no handler section. - Change the handler from restart to reload if the service supports reloading. A restart drops connections; a reload usually does not, and this handler will still fire on genuine changes.
- Add drain and health-check steps around whatever the handler does, and batch the play so the tier does not lose all its capacity at the same instant.
- Roll out, then confirm across three consecutive nights that no restarts occur. A once-a-night fault needs more than one night of silence to be considered gone.
- Audit the rest of the repository for the same header. It was added by a commit that touched several roles, and every one of them has been reporting changed ever since.
Verification
- The second converge reports no change for that task specifically. Read the task line in the output, not just the play recap - another task in the same play may legitimately be changing something and would mask this.
- No handler runs on the second converge. The output contains no
RUNNING HANDLERsection, andjournalctl -u payment-gwrecords no stop or start at that timestamp. - The check can fail. Edit a genuine configuration value on a test host, converge, and confirm the task reports
changed, notifies, and reloads. A template that never reports changed is the opposite fault and is worse. - Three consecutive nights are clean.
journalctl -u payment-gw --since -3d | grep -c Stoppedreturns 0 on a sample of hosts. - Client-visible errors have stopped. Check the payments error rate in the 02:00 to 02:15 window across those three nights - the service being quiet and the client being happy are different claims.
- The rendered file is stable across hosts and runs. Render on two hosts and diff; anything that differs and is not host-specific configuration is a latent instance of the same fault.
- The audit is complete.
grep -rn ansible_date_time roles/*/templates/returns nothing, and any role that used the same header has been fixed and two-converge tested.
Prevention
- Nothing measured per run belongs in managed content. If the value changes when the configuration has not, it is not configuration.
- Gate the two-converge check in CI. Render, converge, converge again,
and fail the build on any
changedin the second pass. This catches the entire family before deployment and costs one extra run. - Treat an unexplained restart as a broken change signal until proven otherwise. A service that restarts on a schedule rather than in response to a change is telling you that something upstream of the handler cannot settle.
- Prefer reload to restart wherever the service supports it, and put drain and health-check steps around anything that interrupts connections. The handler will fire correctly one day, and that is the day it matters that it fires safely.
- Remember that handlers flush together, so a whole batch restarts at once. Batching the play is what turns a correct handler into a safe one.
- Put provenance in the file if you want it, but make it identify the producer, not the moment. A commit hash is more useful at three in the morning than a timestamp anyway, because it tells you what to go and read.