Ga naar inhoud

RCA — Pull-secret rollout volume-attach storm

Field Value
Datum 2026-05-02 (zaterdag)
Auteur Mark Westerweel (@MWest2020)
Cluster garden-wh2mnkj--con-prod-external (CYSO Gardener-managed K8s)
Severity Low — transient, self-healing in ~15 min, geen dataverlies
Status Resolved
Driver Docker Hub anonymous rate-limit (429) op prod cluster

Executive summary

Bij het uitrollen van Docker Hub pull-secrets over 84 Nextcloud-tenant-namespaces (apply-pullsecret.sh --all-sas --unblock) zijn ~90 stuck pods cluster-wide nagenoeg gelijktijdig gedeleted. Controllers spawnden onmiddellijk vervangers, die hun PVCs claimden voordat de oude PV-attachments waren losgekomen van de oorspronkelijke nodes. Resultaat: 310 pods kortstondig in StartError met Multi-Attach error op redis/mariadb-PVCs. Self-healing voltooid binnen ~15 minuten, geen handmatige interventie nodig. Het pull-secret-doel is bereikt: 89 pods in ImagePullBackOff → 1 (de overgebleven 1 zit in een namespace die bewust buiten scope was vandaag).

Doel van de operatie

Acute mitigatie van Docker Hub anonymous pull rate-limit (100 pulls/6h/IP) op het prod cluster. ~7 images van docker.io (Nextcloud-fpm, nginx, redis, mariadb, busybox, exporters, postgres) verspreid over ~110 multi-tenant Nextcloud-instances; zonder authenticated pulls liep het cluster vast op ImagePullBackOff na elke node-restart of pod-rescheduling. Conduction Docker Hub Pro abonnement was net aangeschaft; vanavond was de eerste keer dat de credentials uitgerold werden.

Wat is bereikt

  • 84 Nextcloud-tenant-namespaces voorzien van dockerconfigjson Secret en imagePullSecrets patch op default SA + alle custom Helm-chart-SAs (*-redis-master, *-redis-replica, *-mariadb, etc.).
  • 89 pods in ImagePullBackOff → 1 (de resterende 1 is openanonymiser-acc, buiten scope van vandaag — zit in 11-extras voor follow-up).
  • ArgoCD-compatibility annotaties (Prune=false, compare-options=IgnoreExtraneous) en managed-by=cluster-config-spoor1 label op elke aangemaakte Secret.
  • Greenfield repo cluster-config met audit-baar verloop: 6 commits, 4 scripts (discover.sh, apply-pullsecret.sh, fix-cccerror.sh, plus ondersteunende structuur), reports/-mappen met log-output van elke run, twee-sporen-aanpak gedocumenteerd.

Tijdlijn (approximatief)

Tijd Gebeurtenis
21:24 Eerste discover.sh run, faalde halverwege op set -e gotcha
21:26 discover.sh schoongelopen na fix; rapporteerde 171 ns / 85 nextcloud-pods / 96 docker.io-pullers / 89 stuck
21:46 Canary dry-run op nc-example + canary-accept
21:48 Canary apply (Secret + default SA-patch) — 0 errors
21:50 Canary verify: stuck pods in canary-accept handmatig gedeleted, nieuwe pods kwamen Running met succesvolle authenticated pulls
21:59 Broad apply zonder --unblock: 65/84 ns, 0 errors, gestopt na batch 13
22:22 --unblock feature toegevoegd aan apply-pullsecret.sh; dry-run valideerde detectie
22:27 Broad apply met --unblock herstart vanaf 1/84: alleen alkmaar had nog 1 stuck pod (rest was inmiddels self-healed via ArgoCD-reconciliatie); gestopt na 15/84
22:36 Volledige rerun met --unblock: 84 ns verwerkt, alleen default SAs gepatcht (deelresultaat)
22:45 User-melding: 429 unauthenticated pull rate limit op bitnamilegacy/redis — ondanks patch
22:50 RCA op SA-niveau: Bitnami Helm-charts maken eigen SA per release; default-only-patch raakte ze niet
22:55 --all-sas feature toegevoegd; dry-run op beek valideerde dat 4 extra SAs niet gepatcht waren
~23:05 User rerun met --all-sas --unblock over 84 ns
23:09 User-melding: 74 ns in "progressing" met Readiness probe failed: connection refused
23:09 Diagnose: 310 pods in StartError, ~18 actieve Multi-Attach events op PVs, vermoedelijk volume-detach lag
23:21 12 minuten later: 7 StartError, 0 actieve Multi-Attach (events 29-35 min oud)
23:33 Cluster volledig stabiel: 702 Running, 0 StartError, 1 ImagePullBackOff (= openanonymiser-acc, buiten scope)

Root cause

Mass simultane pod-delete cluster-wide veroorzaakte een PV-attach race.

Het --unblock mechanisme detecteerde pods in ImagePullBackOff/ErrImagePull en deed kubectl delete pod voor alle gevonden pods, met --wait=false. Dat fired ~90 deletes binnen enkele minuten. Voor pods met PV-claims (redis-master, redis-replicas, mariadb-0 — allemaal StatefulSet):

  1. Oude pod krijgt delete-signaal, container stopt
  2. Kubelet op de oude node moet de volume detachen — async, kost seconden tot minuten afhankelijk van CSI-driver state
  3. StatefulSet-controller spawnt direct vervangende pod
  4. Scheduler plaatst nieuwe pod op een (mogelijk) andere node
  5. Nieuwe node probeert PV te attachen → faalt met Multi-Attach error omdat oude attachment nog niet is opgeruimd
  6. Pod stuck in StartError tot detach voltooid is en nieuwe attach kan

Bij 90 deletes tegelijk maakt dat een storm van ~60-300 seconden waarin een flink deel van de cluster volume-bound was.

Contributing factors

  1. unblock_namespace deed direct kubectl delete op stuck pods, in plaats van via de controller (rolling restart). Een kubectl rollout restart deployment/X zou de Deployment-strategy respecteren (maxUnavailable, pod-disruption-budgets) en pods één-voor-één wisselen, zodat PVs gracieus detachen voor de volgende pod claimt.

  2. Geen rate-limiting in unblock. Script deleted álle stuck pods van een namespace zonder pauze, en deed dat direct na elkaar voor opeenvolgende namespaces in een batch. Effectief: de --batch-size 5 prompt tussen batches was de enige throttle, maar binnen een batch werden pods seriëel maar in snel tempo gedeleted.

  3. Eerste apply (vóór --all-sas) had alleen default SA gepatcht — wat tot een tweede mass-restart leidde toen we --all-sas toevoegden en alle non-default SAs alsnog patchten. Een vooraf-discovery van welke SAs er bestaan per ns had die hercyclus voorkomen.

  4. Geen health-gate tussen batches. Het script vroeg Doorgaan? [y/n] op tijd-basis, maar checkte niet of de pods van de vorige batch al Running waren. Bij een settle-pauze gemiddelde van een minuut had dat veel cumulatieve druk weggehaald.

  5. fix-cccerror.sh was er nog niet toen we begonnen. Bij CYSO-cluster restarts blijven sommige pods in CreateContainerConfigError hangen op stale projected-token mounts. Een aparte tool om die te fixen ontbrak en maakte het lastig te onderscheiden welke errors door ons waren versus pre-existing.

Wat ging goed

  • Canary-strategie werkte zoals bedoeld. nc-example + canary-accept vingen het feit op dat de pull-secret werkte (succesvolle pulls binnen seconden, pod-spec had de injected reference). Zonder canary hadden we blind 84 namespaces uitgerold.

  • Idempotency van het script. Toen we ontdekten dat alleen default SA was gepatcht en --all-sas toegevoegd moest worden, kon het script gewoon opnieuw draaien zonder cleanup; bestaande Secrets en SA-patches werden als unchanged herkend.

  • Audit-trail. Elke run van apply-pullsecret.sh schreef naar reports/apply-pullsecret-<TS>.log met de volledige verwerking, exit code en timestamps. Dit RCA had veel meer reconstructie nodig zonder die logs.

  • Self-healing eigenschap van de SA-patch. ~70% van de oorspronkelijke 89 stuck pods herstelde zonder --unblock-actie, omdat ArgoCD-reconciliatie pods geleidelijk recreeerde nadat de SA-patch er stond. Die nieuwe pods pikten de injected imagePullSecret op en pulden authenticated.

  • PAT-credentials-hygiene. Token in ~/.dockerhub-pat met chmod 600 (en óók in Passworks voor rotation/audit). Script gebruikt het en unset't de PAT direct na het bouwen van de base64 auth-string. Geen echo/log-pad waar de PAT in plain-text doorheen lekt.

  • Lokaal-only repo state. Greenfield repo niet gepusht (niet DevOps-whitelisted + weekend-freeze) — alle commits lokaal, audit-trail op host, push pas na DevOps-onboarding.

Wat ging fout

  • Mass-restart cascade veroorzaakte de Multi-Attach storm (zie root cause).
  • Eerste rollout-poging miste 80% van Bitnami SAs. Ontdekt door een alerte 429-foutmelding van de gebruiker; zonder die had het rate-limit probleem voor Bitnami-images blijven bestaan.
  • set -e gotcha in eerste versie van discover.sh ([[ ]] && cmd als laatste statement in functie returnt non-zero exit code en killed het script). Triviaal gefixt zodra opgemerkt, maar liet zien dat shellcheck-clean code nog niet runtime-clean is.
  • Timestamp-collision tussen runs binnen dezelfde minuut leidde tot tee -a appending naar hetzelfde report-bestand met bash-trace pollution. Gefixt door seconden in TS toe te voegen.

Lessons learned

  1. Pod-restart via controller, niet via direct delete. Voor pods met ownerReference op Deployment/StatefulSet/DaemonSet, prefereer kubectl rollout restart boven kubectl delete pod. De controller respecteert maxUnavailable en eviction-policies en geeft PVs tijd om gracieus te detachen.

  2. Discovery is rijker dan tellen. discover.sh rapporteerde aantallen namespaces en images, maar inventariseerde géén ServiceAccounts. Een discover-sas.sh (of uitbreiding) had de Bitnami custom-SAs vooraf blootgelegd.

  3. Health-gate tussen batches. Tijd-prompts (Doorgaan? [y/n]) zijn onvoldoende; een rollout moet wachten tot pods van de vorige batch Ready zijn voor de volgende batch start.

  4. Test cross-tabulation tegen werkelijkheid, niet aannames. We hadden 85 nextcloud-tenants vs 96 docker.io-pullers; we kozen scope op de 85. Maar binnen die 85 zaten >5 ServiceAccounts per ns, niet 1. Een "sample one ns and inventory exhaustively" zou dat hebben opgevangen.

  5. Bitnami bitnamilegacy/* is een aparte tech-debt. Bitnami heeft distributiemodel gewijzigd in 2025; "legacy" tags zijn bewust behouden maar kunnen op termijn verdwijnen van docker.io. Aparte mitigatie nodig (Harbor proxy-cache plus eigen mirror van legacy tags).

Action items

ID Actie Owner Wanneer
AI-1 Verbouw unblock_namespace naar kubectl rollout restart-first strategie; fallback naar delete pod alleen voor bare pods Mark / volgende sessie Spoor 1 follow-up
AI-2 Voeg --unblock-rate-limit N/sec aan apply-pullsecret.sh voor de bare-pod fallback Mark Spoor 1 follow-up
AI-3 Voeg health-gate toe aan --batch-size: wacht tot vorige-batch pods Ready zijn voor Doorgaan?-prompt Mark Spoor 1 follow-up
AI-4 Breid discover.sh uit met SA-inventarisatie per nextcloud-namespace, output naar reports/sas-<TS>.txt Mark Spoor 1 follow-up
AI-5 Patch de 11 niet-Nextcloud namespaces (argocd, argo-events, llm, n8n, nextcloud-platform, openanonymiser-{acc,accept,dev,prod}, +1) — kleinere set, geen mass-restart Mark Komende dagen
AI-6 CYSO escaleren: kube-system heeft eigen pods die docker.io pullen — kunnen zij dat zelf afdekken, of accepteren we het rate-limit-risico daar? Mark Komende werkdag
AI-7 Roadmap Spoor 2: ESO (External Secrets Operator) op cluster, dan Ansible-rol die manifests rendert naar ArgoCD-watched repo Mark Na Spoor 1 stabilisatie
AI-8 Roadmap Spoor 3: Harbor proxy-cache uitrollen — structurele oplossing, onafhankelijk van Docker Hub limits Mark Volgens infra-roadmap
AI-9 DevOps-onboarding cluster-config repo (whitelist), dan push naar GitHub Mark Maandag (eerste werkdag na weekend-freeze)
AI-10 PAT-rotatie-procedure documenteren in README en kalender-reminder zetten op token-expiry Mark Bij DevOps-onboarding

Referenties

  • Logs: reports/apply-pullsecret-2026-05-02T*.log (lokaal in repo)
  • Discovery: reports/discover-2026-05-02T*.txt
  • Scripts: scripts/discover.sh, scripts/apply-pullsecret.sh, scripts/fix-cccerror.sh
  • Commits: zie git log --oneline (lokaal, niet gepusht per weekend-freeze)
  • Gerelateerd incident: CanisterSprawl 2026-04-21 (apart traject, scheidingsprincipe credential-domeinen)