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
dockerconfigjsonSecret enimagePullSecretspatch opdefaultSA + alle custom Helm-chart-SAs (*-redis-master,*-redis-replica,*-mariadb, etc.). - 89 pods in
ImagePullBackOff→ 1 (de resterende 1 isopenanonymiser-acc, buiten scope van vandaag — zit in 11-extras voor follow-up). - ArgoCD-compatibility annotaties (
Prune=false,compare-options=IgnoreExtraneous) enmanaged-by=cluster-config-spoor1label op elke aangemaakte Secret. - Greenfield repo
cluster-configmet 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):
- Oude pod krijgt delete-signaal, container stopt
- Kubelet op de oude node moet de volume detachen — async, kost seconden tot minuten afhankelijk van CSI-driver state
- StatefulSet-controller spawnt direct vervangende pod
- Scheduler plaatst nieuwe pod op een (mogelijk) andere node
- Nieuwe node probeert PV te attachen → faalt met
Multi-Attach erroromdat oude attachment nog niet is opgeruimd - Pod stuck in
StartErrortot 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
-
unblock_namespacedeed directkubectl deleteop stuck pods, in plaats van via de controller (rolling restart). Eenkubectl rollout restart deployment/Xzou de Deployment-strategy respecteren (maxUnavailable, pod-disruption-budgets) en pods één-voor-één wisselen, zodat PVs gracieus detachen voor de volgende pod claimt. -
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 5prompt tussen batches was de enige throttle, maar binnen een batch werden pods seriëel maar in snel tempo gedeleted. -
Eerste
apply(vóór--all-sas) had alleendefaultSA gepatcht — wat tot een tweede mass-restart leidde toen we--all-sastoevoegden en alle non-default SAs alsnog patchten. Een vooraf-discovery van welke SAs er bestaan per ns had die hercyclus voorkomen. -
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. -
fix-cccerror.shwas er nog niet toen we begonnen. Bij CYSO-cluster restarts blijven sommige pods inCreateContainerConfigErrorhangen 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-acceptvingen 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
defaultSA was gepatcht en--all-sastoegevoegd moest worden, kon het script gewoon opnieuw draaien zonder cleanup; bestaande Secrets en SA-patches werden alsunchangedherkend. -
Audit-trail. Elke run van
apply-pullsecret.shschreef naarreports/apply-pullsecret-<TS>.logmet 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 injectedimagePullSecretop en pulden authenticated. -
PAT-credentials-hygiene. Token in
~/.dockerhub-patmetchmod 600(en óók in Passworks voor rotation/audit). Script gebruikt het enunset'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 -egotcha in eerste versie vandiscover.sh([[ ]] && cmdals 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 -aappending naar hetzelfde report-bestand met bash-trace pollution. Gefixt door seconden in TS toe te voegen.
Lessons learned
-
Pod-restart via controller, niet via direct delete. Voor pods met ownerReference op Deployment/StatefulSet/DaemonSet, prefereer
kubectl rollout restartbovenkubectl delete pod. De controller respecteertmaxUnavailableen eviction-policies en geeft PVs tijd om gracieus te detachen. -
Discovery is rijker dan tellen.
discover.shrapporteerde aantallen namespaces en images, maar inventariseerde géén ServiceAccounts. Eendiscover-sas.sh(of uitbreiding) had de Bitnami custom-SAs vooraf blootgelegd. -
Health-gate tussen batches. Tijd-prompts (
Doorgaan? [y/n]) zijn onvoldoende; een rollout moet wachten tot pods van de vorige batchReadyzijn voor de volgende batch start. -
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.
-
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)