Standalone · Cluster

The trigger is not the cause: a GitOps outage autopsy

39 public endpoints returned 404 for 42 minutes. The change that "caused" it was additive and correct. The real defect had been latent in eight templates for weeks. Here's the anatomy of a YAML separator bug.

This is the story of a P0 that wasn't caused by the change that triggered it. It's the cleanest example I've seen of the difference between a trigger and a cause, and it produced a guard rail that should be in every GitOps pipeline.

The outage

At 19:08 UTC, a parallel WAF-authenticated public ALB was added to 45 Argo Applications. The change was additive by design: it rendered a second ALB Ingress alongside the existing public one, behind a WAF that requires a bearer token.

By 19:08, 39 public Ingresses had been deleted from the cluster. Argo's `prune: true` turned "desired state lost an object" into "delete it from production". The `inference-public` listener rules dropped from 63 to 23. ~39 endpoints returned 404 for 42 minutes.

The trigger was not the cause

Reverting the auth feature would have restored service and fixed nothing. The defect had been sitting in eight serving archetypes for weeks, armed for the next time any app rendered two ALB Ingresses from one template file.

The mechanism

Each archetype's `_alb-ingress.tpl` ended its final variable assignment with `-}}`. In Helm, `-}}` trims all following whitespace, including the newline. The `---` on the next line therefore landed at the end of the previous document's last rendered line:

                  number: 8000---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: qwen3-reranker-alb-auth

A YAML parser sees one document. Duplicate keys resolve last-wins, so `metadata.name` becomes `qwen3-reranker-alb-auth` and the public Ingress ceases to exist as an object.

Why it stayed hidden

The bug requires two or more ALB Ingresses rendered from one template file. The first document's `---` sits at the start of the output, where gluing is harmless. Before the auth feature, virtually every app rendered exactly one public ALB Ingress, so the bug had no trigger.

The auth feature added a second render to 45 apps. It did not touch the separator logic. Its only template changes were the `access_logs.s3.prefix` attribute and the `wafv2-acl-arn` annotation. Nothing in code review could have caught it, because the code was correct. The violation happened one layer down, in YAML serialisation.

The invariant

The fix was 8 files, one character each: drop the trailing `-` so the newline survives. But the real deliverable was the assertion that catches this class forever:

raw    = len(re.findall(r'(?m)^kind: ', out))
parsed = sum(1 for d in yaml.safe_load_all(out) if d)
assert raw == parsed, "glued separator?"

Render all 65 models with the exact value files the ApplicationSet injects, assert `count(^kind:)` equals the parsed document count, fail the build on mismatch. Before the fix: 45 models lost at least one Ingress. After: 0. Ingress objects in desired state went from 60 to 109.

Mental model

A successful `helm template` is not a valid render. Helm exits 0 on output that silently contains fewer objects than intended. Document count is the invariant, and nothing was asserting it.

What made it an outage

Two properties turned a template bug into a 42-minute P0:

Reading the error's provenance

The detail that ruled out the backend in one step: `Server: awselb/2.0` with an empty body. That's the ALB's own default action, meaning no listener rule matched. The backend was `8/8` Running the whole time. The ALB answered; the routes were gone.

This is the discipline that makes RCAs fast: read where the error came from before you chase what it says.

The takeaway

Reverting the trigger restores service and fixes nothing. The cause was latent for weeks; the trigger was just the first change to render two documents from one template. Guard the invariant, not the incident.

Sources

Back to the blog