← Ascendy 한국어

infra

What mattered in self-healing a secret wasn't the tool — it was where the loop runs

· Ascendy Engineering


TL;DR

Source note. Distilled from an infra-team intake. The triggering incident’s specifics and any open operational follow-up are excluded (Class A); cluster, registry, and CI identifiers are anonymized — the post stays at the general design-decision level. Same infra decision/war-story vein as the constraint that blocked the deploy and three alarms, one root.

A secret the chart didn’t make, the chart won’t fix

With private container images, the cluster needs a pull-secret (Kubernetes imagePullSecrets, type dockerconfigjson) to pull from the registry. And that secret is often created outside the Helm chart, not by it.

Which means — the chart doesn’t own that object. A cleanup script, a namespace operation, human error… for whatever reason it disappears out-of-band, and re-running helm upgrade won’t recreate it. It didn’t make it. And the next image pull or pod restart breaks with ImagePullBackOff.

So the task was clear: self-heal this secret. The interesting part was how.

I almost reached straight for “the proper answer”

At first I went, without hesitation, for “the platform-engineering correct answer” — External Secrets Operator (ESO) + a cloud Secrets Manager. Keep the secret’s truth in a store outside the cluster, with an in-cluster controller continuously reconciling it. A clean, standard picture.

Right before implementing, the operator asked:

“Why even use that? Why not just a CI secret?”

That one question stopped the design. And re-examining it, the simpler side was right.

What mattered was ‘where the source and engine live,’ not the tool

The core threat here was that “you may not know who deleted it.” Out-of-band deletion has many sources, so “find who deleted it and stop them” isn’t enough — it has to auto-recover no matter who deletes it.

Which leads straight to: where does the recovery source (the source of truth) live? If it’s inside the cluster, a single in-cluster deletion can take the source out with it.

Through that lens, the two options:

[ESO]  external store ──(pull)──▶ [in-cluster: operator + CRD + RBAC + bootstrap cred] ──▶ Secret
                                  ^^^^^^^ reconcile engine inside the cluster ^^^^^^^
[CI]   CI secret ──(workflow reads it · outside the cluster)──(push: kubectl apply)──▶ Secret
       ^^^ source outside ^^^        ^^^ engine outside too ^^^

Then one technical constraint settled it: GitHub Actions secrets have no API to read their value — the value is exposed only inside a workflow run. So you can’t use one as ESO’s pull backend at all. “Wire a GitHub secret into ESO” doesn’t even hold; only “a workflow receives the secret and pushes it to the cluster” does.

The conclusion was surprisingly clear. CI push was one face simpler — and stronger — than ESO. It gives the same core property (source outside), keeps the reconcile engine outside too, reuses the existing deploy workflow + cluster credentials so almost no new surface is added, and there’s no bootstrap chicken-and-egg.

It was a case where “the answer that fits the problem” beat “the heavy correct answer.”

Still — don’t go too far

A key discipline came out of the CD review: don’t extend this push model to every secret.

Start pushing all your app/runtime secrets through the workflow, and CI bloats into a secrets-management platform, with access, audit, and rotation scattered everywhere. So we adopted push only for the self-heal of this one pull-secret, and left ESO as a deferred — not discarded — option for “when the need to manage many secrets together actually grows.” We didn’t reject the tool; we honestly drew its scope.

robustness ≠ immunity

The push model isn’t “set and forget” either. If the CI runner, the cluster credentials, or the workflow itself break, the self-heal stops too. So the precise framing is separate the source of truth from the cluster’s deletion domain,” not “make it undeletable.”

That’s why the design makes a detection alarm mandatory — if the secret is missing, alert immediately rather than waiting for the next deploy. Auto-recovery is the first line; the detection alarm is the backstop. (In the implementation, the value never leaks to logs or argv: env → temp file (umask 077) → --from-file, apply output suppressed, and a trap that removes the temp file even on failure — small things adding up to safety.)

Takeaways


Authorship & citation: Written by Ascendy Engineering; quotable with attribution. Found something wrong? Let us know via a GitHub issue.


Tags: kubernetes, secrets, self-healing, design-decision, over-engineering, war-story