runbook: ArgoCD synced-but-stale, and how to tell a wedged controller from a slow poll
ci / lint (push) Successful in 27s
ci / types (push) Successful in 49s
ci / unit (push) Successful in 1m51s
ci / chart (push) Successful in 10s
ci / integration (push) Successful in 1m15s
ci / dockerfile (push) Failing after 10m28s
ci / security (push) Failing after 11m1s
ci / image (api) (push) Has been skipped
ci / image (reconciler) (push) Has been skipped
ci / image (worker) (push) Has been skipped
ci / bump (push) Has been skipped

Cost 11 hours yesterday and looked like nothing was wrong, because the
Application reported Synced the whole time — at a revision five commits
behind. Records the diagnosis that actually works (log-lines-per-hour, and
a flat goroutine count meaning blocked rather than idle), the Kyverno
failurePolicy root cause, and the two escalating fixes. Folds in the
hook-finalizer deadlock, which has now happened four times and was only
written down in chat.
This commit is contained in:
2026-07-22 01:23:20 +00:00
parent b2cdcecdc1
commit a0215ef63f
+68
View File
@@ -482,3 +482,71 @@ remembering.
**Escalate** if size is growing with `tasks` already pruned — that means `instances` is **Escalate** if size is growing with `tasks` already pruned — that means `instances` is
growing, i.e. tenants are real, i.e. the free tier is the wrong tier. growing, i.e. tenants are real, i.e. the free tier is the wrong tier.
---
## Deploy stuck: ArgoCD says Synced at an old commit
**Symptom:** CI is green and the bump commit is on `master`, but the running pods are on the
previous digest. `kubectl -n argocd get application svcforge` says **`Synced`** — at a
revision several commits behind. Nothing looks broken, which is what makes this expensive.
**Diagnose.** Compare what ArgoCD thinks it synced against what `master` actually is:
```bash
kubectl -n argocd get application svcforge \
-o jsonpath='{.status.sync.revision}{" reconciledAt="}{.status.reconciledAt}{"\n"}'
git -C ~/workspace/svcforge-reference log --oneline origin/master -1
```
If the revision is stale, ask whether the controller is doing *anything*:
```bash
# Healthy: a few hundred lines an hour. Stalled: exactly 6 — the 10-minute memory heartbeat.
kubectl -n argocd logs statefulset/argocd-application-controller --tail=8000 \
| grep -oE 'time="[0-9-]+T[0-9]{2}' | sort | uniq -c | tail
```
A flat `Goroutines=NNN` across hours in those heartbeat lines means blocked goroutines, not
an idle controller.
**Cause seen here (2026-07-21).** Not ArgoCD config — `timeout.reconciliation` was 120s the
whole time. The controller's server-side dry-run applies go through the cluster's admission
webhooks, and Kyverno's mutate webhook was `failurePolicy: Fail`. Kyverno restarts under
this cluster's memory pressure, and each restart is a window where that webhook is
unreachable, so the applies blocked and the controller wedged for **11 hours** — reconciling
zero apps while still reporting `Synced`. Fixed in `oci-k8s` by setting `failurePolicy:
Ignore` on both ClusterPolicies; see the comment there.
**Unstick it now:**
```bash
# 1. Force a re-poll. If the revision advances, polling was the only problem.
kubectl -n argocd annotate application svcforge argocd.argoproj.io/refresh=hard --overwrite
# 2. If it does not advance within ~60s, the controller is wedged. Restart it —
# ArgoCD holds no state of its own; everything is in the cluster and in git.
kubectl -n argocd rollout restart statefulset/argocd-application-controller
kubectl -n argocd rollout status statefulset/argocd-application-controller --timeout=180s
```
**Then check the actual chain, because `Synced` is not the same as `deployed`:**
```bash
git show origin/master:deploy/chart/values.yaml | grep -A2 -E '^\s+(api|worker|reconciler):'
kubectl -n svcforge get pods \
-o jsonpath='{range .items[*]}{.spec.containers[0].image}{"\n"}{end}' | sort -u
```
The digests must match. If the sync stalls with a Job stuck `Complete` but never deleted,
it is holding `argocd.argoproj.io/hook-finalizer` — see below.
**Related: the migrate Job deadlock.** A PreSync hook Job that finished but keeps the
finalizer blocks the sync forever:
```bash
kubectl -n svcforge get job svcforge-migrate -o jsonpath='{.metadata.finalizers}{"\n"}'
kubectl -n svcforge patch job svcforge-migrate --type=merge -p '{"metadata":{"finalizers":null}}'
```
The Application goes `Synced` within seconds of the patch.