Skip to main content
RunBook Academy

← All break/fix scenarios in Ansible

advancedtemplating~35 min

Break/Fix: the config passed validation, the reload succeeded, and the site returns 502

Reported symptoms

  • Every request to the site returns 502 immediately after a routine configuration deploy
  • The Ansible run was completely green, including a task explicitly named as validating the configuration
  • The service reloaded successfully and is running; this is not a crash
  • The change that triggered it added one upstream server to a list and looked trivial in review
  • Rolling back the change fixes the site, which was read as confirming the change was bad
  • Nobody can see anything wrong with the change itself

Evidence

  • · `nginx -T` on an affected host shows an upstream block whose server entries have a port but no address, or an address with an empty port
  • · `nginx -t` run by hand on the host after the deploy reports the configuration as invalid
  • · The Ansible task that runs the validator reported `ok` during the deploy
  • · The `validate` string in the task has no `%s` placeholder
  • · The template renders one field through a `default` filter that hides a key name typo
  • · `ansible-playbook --syntax-check` passes, and always would have
  • · `--check --diff` on a clean host shows the malformed line, in a diff nobody reads closely because the task is green
Diagnosis and resolutionclick to reveal

Root cause

Two independent defects line up so that neither is caught. The template references a dictionary key that does not exist, guarded by a `default` filter that supplies an empty string; the intent was to make an optional field optional, and the effect is that a mistyped key name renders as nothing instead of raising an undefined-variable error. The result is a syntactically invalid upstream entry, produced silently, by a task that reports success. The second defect is the safety net. The `validate` option on `template` and `copy` runs a command against a temporary copy of the newly rendered file, and the path of that temporary file is substituted into the command wherever `%s` appears. The task was written without `%s`, so the validator ran with no file argument and checked the configuration already installed on the host - which was the previous, valid one. It passed, every time, for a year, on every host, while validating nothing about the file being deployed. The change under review was correct; it simply added the second entry to a list whose rendering had been broken since the typo was introduced, and one broken entry in an upstream block is enough to make the whole file unusable.

Remediation

Fix the validator first, because until it works nothing else can be trusted: add the `%s` placeholder so the command runs against the rendered candidate rather than against whatever is already installed. With that in place, the next deploy of the current template will fail loudly, which is the correct behaviour and confirms the fix. Then correct the template: use the right key name, and remove the `default` filter from any field that is not genuinely optional so a missing value raises an error instead of rendering as empty. Restore service on the affected hosts by rolling forward with the corrected template rather than by leaving the rollback in place, since the rollback hides an unvalidated template that will be redeployed by the next converge.

Verification

Prove the validator can fail. Deliberately render a broken configuration on one test host and confirm the task fails and the live file is untouched - a validation step that has never rejected anything has not been shown to work, and in this incident it never had. Confirm `nginx -T` on a repaired host shows every upstream entry with both an address and a port. Confirm the site returns 200 from a client rather than confirming the service is running, and add an assertion to the play that fetches a real endpoint and checks the status code so the next occurrence fails the run instead of the customer.

Prevention

Treat `default` as a decision, not as a safety measure. A default on a genuinely optional field is correct; a default on a required field converts a loud undefined-variable error into a silently malformed file, and it is the same three characters either way. Always include `%s` in `validate`, and test every validator by feeding it something broken on purpose before trusting it. Assert the outcome rather than the step: a template task can be green, a reload can succeed, and the service can still be serving errors, so every service-affecting play should end with a request that has to succeed. Where a template drives a structured file, validate the structure in CI by rendering it against representative variable sets, so a malformed render is caught before any host sees it.

Reported symptoms

At 11:04 a routine change adds one backend server to an upstream pool. At 11:06 the site returns 502 for every request.

The deploy looked textbook:

TASK [nginx : Render the upstream configuration] *******************************
changed: [web01]

TASK [nginx : Validate and reload] *********************************************
ok: [web01]

RUNNING HANDLER [nginx : reload nginx] *****************************************
changed: [web01]

PLAY RECAP *********************************************************************
web01                      : ok=22   changed=2    unreachable=0    failed=0

The service is running. It did not crash. It reloaded cleanly. And a task named “Validate and reload” reported success.

The change was reverted at 11:11 and the site recovered, which everybody read as proof that the change was at fault. Three engineers then spent an hour looking at a two-line diff that adds one entry to a YAML list, and could not find anything wrong with it.

They were right. There is nothing wrong with it.

Evidence provided

Read-only / Safethe rendered file - note the missing port numbers
$ ansible web01 -i inventory -b -m ansible.builtin.command -a 'nginx -T' | grep -A5 'upstream app'
upstream app_backend {
server 192.0.2.31:;
server 192.0.2.32:;
}
Read-only / Saferun by hand, it fails immediately
$ ansible web01 -i inventory -b -m ansible.builtin.command -a 'nginx -t'
nginx: [emerg] invalid port in upstream "192.0.2.31:" in /etc/nginx/conf.d/upstream.conf:2
nginx: configuration file /etc/nginx/nginx.conf test failed
non-zero return code
Read-only / Safethe validator - compare this with the module documentation
$ grep -n -A7 'Validate and reload' roles/nginx/tasks/main.yml
31:- name: Validate and reload
32:  ansible.builtin.template:
33:    src: upstream.conf.j2
34:    dest: /etc/nginx/conf.d/upstream.conf
35:    validate: /usr/sbin/nginx -t
36:  notify: reload nginx
Read-only / Safethe template
$ cat roles/nginx/templates/upstream.conf.j2
upstream app_backend {
{% for u in app_upstreams %}
  server {{ u.host }}:{{ u.pot | default('') }};
{% endfor %}
}
Read-only / Safethe change everyone blamed, and the change that actually did it, eleven months apart
$ git log --oneline -3 -- roles/nginx/templates/upstream.conf.j2 group_vars/web.yml
a91f2b7 web: add app-02 to the upstream pool
6d0c115 nginx: make the upstream port optional for the health checker
2f88e41 nginx: initial upstream template

Work the evidence before reading on

The rendered file is wrong in a way that nginx -t catches instantly when a human runs it. A task whose entire purpose is to run nginx -t reported success.

  1. Read the template’s port expression character by character. Compare the key name against the key name in group_vars.
  2. Read the validate string, then read what the module documentation says about how the candidate file is passed to the validation command.
  3. The upstream pool had one entry until yesterday and the site was fine. If the port has been rendering as empty for eleven months, why did the site work?

Before continuing: which file did nginx -t actually examine when Ansible ran it?

Root cause

1. default turned a typo into an empty string

The template renders {{ u.pot | default('') }}. The key in group_vars is port. There is no pot.

Without the filter, Jinja would raise an undefined error and the task would fail:

fatal: [web01]: FAILED! => {"msg": "Task failed: ... 'dict object' has no attribute 'pot'"}

That failure is exactly what you want. It is loud, it names the expression, and nothing is written.

With | default(''), the missing key renders as nothing. The line becomes server 192.0.2.31:; - syntactically wrong, semantically meaningless, and produced by a task that reports changed and moves on.

The filter was added eleven months ago in a commit whose message says “make the upstream port optional for the health checker”. That is a reasonable requirement. The implementation applied a default to a field that is not optional at all, and did so on the same line as a typo that the default then concealed.

2. validate without %s checked the wrong file

This is the defect that made the first one survivable for eleven months and then fatal.

validate on template and copy runs a command against a temporary copy of the newly rendered content, before the file is moved into place. The temporary path is substituted wherever %s appears in the command string. The module documentation states that %s must be present.

The task says:

validate: /usr/sbin/nginx -t

No %s. So nginx -t ran with no file argument, which makes it test the configuration currently installed on the host - the previous, working file. It passed. It would have passed no matter what the template rendered, because the candidate was never shown to it.

For eleven months this task reported ok while checking a file it was not deploying.

3. Why the site worked until yesterday

With a single upstream entry, nginx tolerated the malformed line well enough for the pool to function in this configuration - the fault was latent, not absent. Adding the second entry crossed the threshold where the block could no longer be parsed at all, and the reload brought up a configuration that could not route.

That is why the change looked guilty. It was the trigger, not the cause, and reverting it restored service without touching either defect. The next converge would have redeployed the same broken template.

Resolution

  1. Restore service, but understand what the rollback did. Reverting the upstream addition removed the trigger and left both defects in place; the template on disk in the repository still renders a broken file.
  2. Fix the validator first. Add the %s placeholder so the command runs against the rendered candidate: validate: /usr/sbin/nginx -t -c %s or the form appropriate to how your configuration is included. Until this works, no other fix can be verified.
  3. Confirm the fixed validator rejects the current template. Run the play against one scratch host with the uncorrected template and require the task to fail. A validator that now catches the known-bad case is a validator.
  4. Correct the template. Use the right key name, and remove the default filter from the port entirely, since a required field should fail loudly when it is missing.
  5. Audit the rest of the repository for the same two patterns: validate without %s, and default applied to a field that is not optional. Both were introduced by ordinary, well-intentioned commits and both are likely to appear more than once.
  6. Roll forward on a canary host with the corrected template, confirm nginx -T shows complete upstream entries, and fetch a real endpoint from a client before proceeding.
  7. Add an assertion to the play that requests a real endpoint and requires a successful status code, so a future broken configuration fails the run rather than the customer.
  8. Re-apply the original upstream change once the canary is proven, since that change was always correct and is still wanted.

Verification

  1. The validator can reject. Render a deliberately invalid configuration on a scratch host and confirm the task fails. This is the single most important check here and it had never been performed.
  2. A rejected configuration does not reach the host. In that same test, confirm the live file is byte-identical to what it was before, and that the service was not reloaded.
  3. The rendered configuration is complete. nginx -T on a repaired host shows every upstream entry with both an address and a port; grep for a colon followed by a semicolon and require no matches.
  4. The service parses its own configuration. nginx -t run by hand on a repaired host succeeds.
  5. A client gets a real response. Fetch an endpoint and require a successful status code from outside the host. The service running and the service working are different claims, and this incident is the difference between them.
  6. The end-of-play assertion can fail. Point it at a deliberately wrong port on a test host and confirm the run fails.
  7. The repository is clean of both patterns. A grep for validate: lines lacking %s returns nothing, and every remaining default filter has been reviewed against whether the field is genuinely optional.

Prevention

  • Use default only where the field is genuinely optional, and say so in a comment. On a required field it converts a loud, precise, harmless error into a silently malformed file.
  • Consider failing fast on undefined variables in templates for configuration you own, so a typo is an error rather than an empty string. The strictness is the feature.
  • Always include %s in validate, and read the module documentation when you write one rather than copying a command line that works in a shell. The two contexts pass files differently.
  • Test every guard by breaking something. A validator, assertion or health check that has never rejected anything is an unverified claim.
  • Assert the outcome, not the step. Green tasks, a successful reload and a running process are all compatible with a service that returns 502 to every request.
  • Render templates in CI against representative variable sets and check the structure of the output, so a malformed render is caught before a host sees it.
  • When a small change appears to cause a large failure, check whether it is the trigger rather than the cause. Reverting a trigger restores service and leaves the cause in place for the next converge.