Files
home-network-k3s-impl/decisions/2026-10-17-oauth2-proxy-oidc-startup-race.md
jakman85andClaude Opus 5 c150327a30
gate / gate (pull_request) Successful in 1m53s
gate / conformance (pull_request) Has been skipped
fix(oauth2-proxy): gate startup on Keycloak discovery, or a cold boot CrashLoops the proxy
Exposed by the 2026-10-17 whole-cluster shutdown. oauth2-proxy performs OIDC
discovery ONCE at startup and exits 1 if it fails. On a cold boot Keycloak is
not serving yet, the shared Gateway answers 503, and the proxy dies:

  [provider.go:55] Performing OIDC Discovery...
  [main.go:73] ERROR: Failed to initialise OAuth2 Proxy: ... failed to discover
               OIDC configuration: unexpected status "503": no healthy upstream

It then sits in exponential CrashLoopBackOff long after Keycloak is healthy --
one replica was still down minutes later and only a manual pod delete cleared
it. There is no retry flag, a readinessProbe cannot help (the container is
already dead), and CrashLoopBackOff backoff is not tunable per-pod.

Add an extraInitContainers gate (wait-for-idp) that polls the discovery doc
until it answers. A pod in Init accrues no CrashLoopBackOff backoff and retries
promptly. BOUNDED and FAILS OPEN: after ~5m it starts the proxy anyway, so the
worst case is exactly today's behaviour and never something new.

extraInitContainers is the chart's OWN hook for this -- its commented example is
literally `- name: wait-for-idp` -- and the key was CONFIRMED against the pinned
chart (helm show values, 10.7.1) rather than assumed, because a wrong Helm values
key is silently ignored. No new base image: the chart already runs an alpine init
container for its built-in waitForRedis gate; pinned to 3.22 (verified upstream)
rather than the chart's `latest`.

Rejected: Flux postRenderers (no precedent in this tree, and the chart has a
hook); --skip-oidc-discovery (removes the fetch entirely, but needs four
hardcoded endpoints and I could not verify lazy-JWKS offline -- this component
gates SSO for seven apps, so the additive option wins).

Shell vars are written BARE with no braced form: platform declares
postBuild.substituteFrom, and envsubst would blank a braced shell variable and
silently break the loop. Same convention as the gitea/nextcloud OIDC hooks.

UNAPPLIED; the init container has never run, and the race was not re-created to
prove the fix. Check the init container's log on first apply. See the handoff.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-10-17 22:47:30 -05:00

6.6 KiB

date, branch, title
date branch title
2026-10-17 fix/oauth2-proxy-oidc-startup-race oauth2-proxy dies on a cold start because OIDC discovery runs once, before Keycloak serves

What changed

Exposed by the 2026-10-17 whole-cluster shutdown for the UPS move. oauth2-proxy performs OIDC discovery once, at startup, and os.Exit(1)s if it fails. On a cold boot Keycloak is not serving yet, so the shared Gateway answers 503 and the proxy dies:

[provider.go:55] Performing OIDC Discovery...
[main.go:73] ERROR: Failed to initialise OAuth2 Proxy: error initialising provider: ...
             failed to discover OIDC configuration: unexpected status "503": no healthy upstream

It then sits in exponential CrashLoopBackOff long after Keycloak is healthy — one of the two replicas was still down minutes later and only a manual kubectl delete pod cleared it. There is no retry flag; the process does not survive a cold start. A readinessProbe cannot help (the container is already dead), and CrashLoopBackOff backoff is not tunable per-pod.

platform/oauth2-proxy/helmrelease.yaml gains an extraInitContainers gate (wait-for-idp) that polls Keycloak's discovery doc until it answers, then lets the proxy start. A pod in Init does not accrue CrashLoopBackOff backoff and retries promptly, which is the point.

Bounded, and fails open. After ~5 minutes it gives up and starts the proxy anyway, so the worst case is exactly today's behaviour and never something new. An unbounded wait would trade a CrashLoop for a pod wedged in Init forever, which is not an improvement.

Why this shape and not the alternatives

  • extraInitContainers is the chart's own documented hook for precisely this. Its commented example in values.yaml is literally - name: wait-for-idp. The key was confirmed against the pinned chart (helm show values oauth2-proxy/oauth2-proxy --version 10.7.1 → extraInitContainers: [] at line 428), not assumed — a wrong Helm values key is silently ignored, which is the "reviewed, merged, apparently done, still broken" shape this repo keeps getting bitten by.
  • No new base image. The chart already runs an alpine init container for its built-in initContainers.waitForRedis gate, so alpine is a base this chart uses regardless. Pinned to 3.22 (verified to exist upstream) rather than the chart's own latest.
  • Flux postRenderers rejected: it would work independently of the chart schema, but nothing in this tree uses postRenderers, so it would introduce a mechanism with no precedent for a problem the chart already has a hook for.
  • --skip-oidc-discovery rejected: it removes the startup fetch entirely, which is arguably more elegant, but it means hardcoding four Keycloak endpoints and I could not verify offline that oauth2-proxy then fetches JWKS lazily rather than at startup. oauth2-proxy gates SSO for PiHole, the *arr apps, qBittorrent, Homepage, Prometheus, Alertmanager and Hubble — a wrong guess locks the operator out of all of them, so the reversible, additive option wins.

The $$-free convention matters here: shell variables are written bare ($i, $url, $(( ))) with no braced form anywhere, because platform declares postBuild.substituteFrom cluster-vars and envsubst consumes braced expansions — a braced shell variable renders to the empty string and silently breaks the loop. ${BASE_DOMAIN} is braced on purpose. Same hazard and same convention as the gitea and nextcloud OIDC bootstrap hooks, and ci: flux-postbuild-substitution enforces it.

What I verified

  • The failure is measured, not theorised. The log above is from the live pod (oauth2-proxy-5d6c74876b-bzb2p, lastState.terminated: {reason: Error, exitCode: 1}), captured with --previous after the cold boot. The surviving replica's log shows the same startup succeeding once Keycloak was up, which is what identifies this as a race rather than a config fault.
  • Deleting the pod is a sufficient workaround — done on prod 2026-10-17, the replacement came up clean and both replicas have been healthy since.
  • The chart key is real, at the pinned version range: helm search repo resolves >=10.0.0 <11.0.0 to chart 10.7.1 (app 7.15.5), whose values carry extraInitContainers: [], extraContainers: [] and the built-in initContainers.waitForRedis (which uses alpine).
  • docker.io/library/alpine:3.22 exists — confirmed against the registry tag list rather than assumed from memory.
  • The edited HelmRelease parses as YAML.

What I did NOT verify

  • UNAPPLIED. The init container has never run. I did not reconcile the HelmRelease, so I have not seen wait-for-idp execute, exit 0, or gate the proxy. Applying it rolls both oauth2-proxy replicas, which is a mutating action (CLAUDE.md §5) — and it rolls the component that gates SSO for seven apps, so do it when you can watch it, not unattended.
  • I did not re-create the cold-start race to prove the fix. That needs Keycloak down while oauth2-proxy starts; the honest test is the next whole-cluster boot. Until then this is reasoned from the measured failure, not demonstrated against it.
  • busybox wget's TLS behaviour here is assumed, not tested. The claim is that alpine's ca-certificates-bundle lets it validate the Gateway's LE-prod certificate, so a genuine TLS failure exits non-zero rather than passing falsely. If that is wrong in either direction the gate degrades safely — a never-succeeding probe just burns the 5-minute budget and then starts the proxy anyway — but the fast path would be silently dead and this would be doing nothing. Check the init container's log on first apply: it should print "Keycloak discovery reachable" within seconds on a warm cluster, not the give-up message.
  • Whether a pod can reach the Gateway hostname at all from this namespace is inherited, not re-proven. The main container does exactly this fetch today and succeeds, so the path is known good — but note the standing trap that an arbitrary pod gets 403 from every Gateway hostname (decisions/2026-10-04-plex-advertise-plexdirect.md); oauth2-proxy is exempt only because Keycloak's ingress policy admits it. An init container in the same pod inherits that identity, so this should hold — unverified.
  • 60 attempts x 5s is not tuned. It is "comfortably longer than Keycloak's observed cold start", not a measured bound.
  • No runtime conformance. vars.CLUSTER_AVAILABLE is unset, so CI proves the L0/L1 offline subset only.
  • I did not re-measure branch protection or runner state (CLAUDE.md §2b).