Hands-On Labs · Guided Drills

Drill — Diagnose a Stuck Argo CD Sync

You ship a canary release. Argo CD says Synced — the cluster matches git, exactly what you asked for. And yet nothing is actually working: traffic never shifts, the rollout never promotes, and the Application card sits there glowing an angry Degraded red underneath that reassuring "Synced" label. This drill hands you that exact contradiction, self-inflicted by one missing block in one YAML file, and asks you to find it the way an on-call engineer would — from the evidence the tools already give you, not from re-reading the manifests until something looks wrong. It's fully self-contained: a throwaway kind cluster, Argo CD, Argo Rollouts, and Istio, no dependency on the capstone. Budget 25-40 minutes before you open the walkthrough below.

☺ Explain it like I'm 10

Imagine a mail sorting robot whose only job is to check that every letter in the inbox tray matches the list on the wall — if the tray matches the list, it lights a green "sorted!" light, no matter what's actually written on the letters inside. That green light is Argo CD's Synced status: it only promises the cluster matches git, not that anything is working. Now imagine one of those letters is an instruction that says "deliver 20% of the mail to House B" — except House B was torn down last week and nobody updated the delivery map. The tray still matches the list perfectly. The green light still shows. But not one letter successfully reaches House B, forever, until someone actually reads the delivery map and notices the house is missing. That's today's bug: a green light telling the truth about one thing while something else entirely is stuck.

🤖🦉Your hosts for this drill: Recon the Robot & Professor Owl — Recon owns the sync loop and will be the first to tell you "Synced" was never a promise that anything works; Owl owns the mesh underneath it, and this bug lives exactly on the seam between the two.
⚠ Before you start

You need Docker, kind, kubectl, git, the GitHub CLI (gh, authenticated), istioctl, the argocd CLI, and the kubectl argo rollouts plugin (kubectl krew install argo-rollouts, or download the binary from the Argo Rollouts releases page). Everything else — the cluster, the repo, the app — is brand-new and throwaway; tear it all down when you're done (kind delete cluster --name stuck-sync-drill, gh repo delete --yes). Install commands, chart URLs and CLI flags drift over time — if a command below errors, check that tool's own current docs and adapt; that's a small rep of the same diagnostic instinct this drill is teaching.

What "stuck" actually means here

☺ Like you're 10: Argo CD keeps two separate scoreboards, not one — and today only one of them is red.

Every Argo CD Application carries two independent status fields, and conflating them is the single most common reason a "stuck sync" takes longer to find than it should. Sync Status answers one question only: does the live state of every resource in the cluster match what's declared in git, right now? It's a diff, nothing more — it has no opinion on whether the resources it's comparing are actually functioning. Health Status answers a completely different question: is each resource, once applied, actually doing its job? For a Deployment, health means the right number of replicas are ready. For an Argo Rollouts Rollout — which Argo CD ships a built-in health check for — health means the rollout's own status.phase, which can be Healthy, Progressing, Paused, or Degraded. Today you'll watch an Application sit at Synced and Degraded at the same time, and that combination is not a contradiction in Argo CD's model — it's two honest, independent answers to two different questions.

◆ Key idea

"Synced" only ever means git and cluster agree. It says nothing about whether what you asked for actually works — Kubernetes' API server and Istio's admission webhook will both happily accept a VirtualService that routes to a subset no DestinationRule defines, because nothing at apply time cross-checks that reference. The failure shows up one layer later, in whichever controller actually tries to use that subset.

Build the scratch world

☺ Like you're 10: One cluster, four control planes, one tiny throwaway git repo — set up once, used for the whole drill.

Stand up a fresh kind cluster and the three pieces this drill needs on top of plain Kubernetes: Argo CD (the reconciler), Argo Rollouts (the canary controller), and Istio (the mesh that actually moves traffic).

kind create cluster --name stuck-sync-drill

kubectl create namespace argocd
kubectl apply -n argocd -f https://raw.githubusercontent.com/argoproj/argo-cd/stable/manifests/install.yaml
kubectl -n argocd rollout status deploy/argocd-server --timeout=180s

kubectl create namespace argo-rollouts
kubectl apply -n argo-rollouts -f https://github.com/argoproj/argo-rollouts/releases/latest/download/install.yaml

istioctl install --set profile=demo -y
kubectl label namespace default istio-injection=enabled --overwrite

Log in to the Argo CD CLI against the in-cluster server:

kubectl -n argocd port-forward svc/argocd-server 8080:443 >/tmp/argocd-pf.log 2>&1 &
ARGO_PWD=$(kubectl -n argocd get secret argocd-initial-admin-secret -o jsonpath='{.data.password}' | base64 -d)
argocd login localhost:8080 --username admin --password "$ARGO_PWD" --insecure

Now the scratch repo. This is a real, tiny GitHub repo — Argo CD's repo-server runs inside the cluster and can't reach a plain local filesystem path on your laptop, so a throwaway remote is the fastest thing that actually works:

mkdir stuck-sync-drill && cd stuck-sync-drill
git init -b main
mkdir manifests
gh repo create stuck-sync-drill --private --source=. --remote=origin

Ship the broken canary

☺ Like you're 10: Four files, three of them perfectly correct, one of them missing exactly one paragraph.

You're rolling out checkout as an Argo Rollouts canary, traffic-shifted through Istio instead of raw pod counts. Four manifests, all in manifests/. The Service is unremarkable:

# manifests/service.yaml
apiVersion: v1
kind: Service
metadata:
  name: checkout
spec:
  ports:
    - port: 80
      targetPort: 8080
  selector:
    app: checkout

The Rollout replaces what would otherwise be a Deployment, and hands its traffic shifting to Istio by naming a VirtualService and a DestinationRule it expects to already exist:

# manifests/rollout.yaml
apiVersion: argoproj.io/v1alpha1
kind: Rollout
metadata:
  name: checkout
spec:
  replicas: 3
  selector:
    matchLabels: { app: checkout }
  template:
    metadata:
      labels: { app: checkout }
    spec:
      containers:
        - name: checkout
          image: ghcr.io/example/checkout:v2
          ports:
            - containerPort: 8080
  strategy:
    canary:
      steps:
        - setWeight: 20
        - pause: { duration: 2m }
        - setWeight: 50
        - pause: { duration: 2m }
        - setWeight: 100
      trafficRouting:
        istio:
          virtualService:
            name: checkout-vs
            routes:
              - primary
          destinationRule:
            name: checkout-destrule
            canarySubsetName: canary
            stableSubsetName: stable

The VirtualService splits traffic between a stable subset and a canary subset by weight — Argo Rollouts will own that weight going forward, stepping it through 0 → 20 → 50 → 100 as the canary steps advance:

# manifests/virtualservice.yaml
apiVersion: networking.istio.io/v1
kind: VirtualService
metadata:
  name: checkout-vs
spec:
  hosts: ["checkout"]
  http:
    - name: primary
      route:
        - destination: { host: checkout, subset: stable }
          weight: 100
        - destination: { host: checkout, subset: canary }
          weight: 0

And here's the file that shipped incomplete. Someone copied the DestinationRule from before checkout ever had a canary path, added the traffic-routing block to the Rollout above, and never came back to give this file its second subset:

# manifests/destinationrule.yaml — BROKEN, as shipped
apiVersion: networking.istio.io/v1
kind: DestinationRule
metadata:
  name: checkout-destrule
spec:
  host: checkout
  subsets:
    - name: stable
      labels: { rollouts-pod-template-hash: placeholder-stable }
    # canary subset — missing

Commit, push, and create the Argo CD Application pointing at this repo, automated and self-healing like any real GitOps setup would be:

git add -A && git commit -m "canary release for checkout, take one"
git push -u origin main

GH_USER=$(gh api user -q .login)
argocd app create checkout \
  --repo "https://github.com/$GH_USER/stuck-sync-drill.git" \
  --path manifests \
  --revision main \
  --dest-server https://kubernetes.default.svc \
  --dest-namespace default \
  --sync-policy automated \
  --self-heal \
  --auto-prune

argocd app sync checkout

Give it a minute, then look:

$ argocd app get checkout
Name:               argocd/checkout
Sync Status:        Synced to main (a1b2c3d)
Health Status:      Degraded

GROUP                 KIND             NAMESPACE  NAME               STATUS  HEALTH    MESSAGE
                       Service          default    checkout           Synced  Healthy   service/checkout created
networking.istio.io    VirtualService   default    checkout-vs        Synced  Healthy
networking.istio.io    DestinationRule  default    checkout-destrule  Synced  Healthy
argoproj.io            Rollout          default    checkout           Synced  Degraded  rollout is invalid: see kubectl describe

Every single resource is Synced — git and cluster agree on all four objects, no drift anywhere. And the Rollout is Degraded, which is why the Application as a whole shows Degraded too: an Application's health is the aggregate of every child resource's health, and one Degraded child is enough to sink the whole thing.

Sync axis — does git match cluster? git repo manifests/ · 4 files argocd sync cluster resources Service, VirtualService, DestinationRule, Rollout diff = none Sync Status: Synced Health axis — does it actually work? Rollout controller wants to set canary weight needs subset "canary" DestinationRule subsets: only "stable" defined blocked Degraded Same Application object. Two independent verdicts — Sync Status and Health Status don't move together.

Diagnose it like an on-call, not a re-read

☺ Like you're 10: Don't reread the file hunting for the typo. Ask the Rollout itself what it's stuck on — it already knows.

Start with the tool that watches the rollout specifically:

kubectl argo rollouts get rollout checkout --watch
Name:            checkout
Namespace:       default
Status:          ✖ Degraded
Message:         InvalidSpec: The Rollout "checkout" is invalid
Strategy:        Canary
  Step:          0/5
  SetWeight:     20
  ActualWeight:  0
Images:          ghcr.io/example/checkout:v2 (stable)

NAME                              KIND        STATUS        AGE  INFO
⟳ checkout                        Rollout     ✖ Degraded    3m
└──# revision:1
   └──⧉ checkout-58f7d9c8b4       ReplicaSet  ✔ Healthy     3m   stable
      ├──□ checkout-...-abc12     Pod         ✔ Running     3m
      ├──□ checkout-...-def34     Pod         ✔ Running     3m
      └──□ checkout-...-ghi56     Pod         ✔ Running     3m

ActualWeight: 0 — three healthy pods, zero traffic ever shifted, Step 0/5 never even started. That's your "stuck sync" from the outside: a canary that looks alive but is frozen at the very first step. Now ask why, from the object's own conditions, not a guess:

kubectl describe rollout checkout
Conditions:
  Type          Status  Reason
  ----          ------  ------
  InvalidSpec   True    InvalidSpec
Events:
  Type     Reason        Age   From                Message
  ----     ------        ----  ----                -------
  Warning  InvalidSpec   4m    rollout-controller  The Rollout "checkout" is invalid:
                                spec.strategy.canary.trafficRouting.istio.destinationRule:
                                Invalid value: "checkout-destrule": the DestinationRule
                                "checkout-destrule" does not have the subset(s) "canary"

There's the exact claim, in one line, from the one controller that actually tried to use the missing subset: the Rollout named canarySubsetName: canary, went looking for a subset called canary inside the checkout-destrule DestinationRule, and didn't find one. Confirm it directly against the object itself, not the controller's report of it:

kubectl get destinationrule checkout-destrule -o yaml
spec:
  host: checkout
  subsets:
  - labels:
      rollouts-pod-template-hash: placeholder-stable
    name: stable
# that's it — no second entry

One subset. Exactly what kubectl describe already told you, now confirmed straight from the resource's own spec.

◆ Key idea

Argo Rollouts' Istio integration manages the label on an existing DestinationRule subset entry — swapping rollouts-pod-template-hash to match whichever ReplicaSet is currently canary or stable — but it does not create missing subset entries. Both names in canarySubsetName / stableSubsetName have to already exist in the file you own. This is the same "self-inflicted 503" the ICA blueprint calls out for plain Istio traffic management — a VirtualService naming a subset no DestinationRule defines — just discovered here by a rollout controller instead of a curl. istioctl analyze -n default is worth running too; it's a genuinely good linter for exactly this class of mismatch, and would have caught it before you ever ran argocd app sync.

Fix it, and prove it holds

☺ Like you're 10: Add the missing paragraph, push it, and watch the frozen robot start moving on its own.

The fix is exactly the one missing block — add the canary subset the Rollout was already asking for:

# manifests/destinationrule.yaml — the fix
apiVersion: networking.istio.io/v1
kind: DestinationRule
metadata:
  name: checkout-destrule
spec:
  host: checkout
  subsets:
    - name: stable
      labels: { rollouts-pod-template-hash: placeholder-stable }
    - name: canary
      labels: { rollouts-pod-template-hash: placeholder-canary }

Commit and push — selfHeal: true means Argo CD will pick this up on its own within its next poll interval, or force it immediately:

git add -A && git commit -m "fix: add missing canary subset to checkout DestinationRule"
git push
argocd app sync checkout

Watch the rollout directly — the InvalidSpec condition should clear and the canary should actually start stepping:

kubectl describe rollout checkout | grep -A2 Conditions
kubectl argo rollouts get rollout checkout --watch

ActualWeight should now be climbing off zero, and Step should read 1/5 or further, no longer parked at 0/5. Each pause: { duration: 2m } step really does wait two minutes by design — fine for a real release, slow for a 25-40 minute drill, so promote past both pauses by hand to see the whole thing land:

kubectl argo rollouts promote checkout
# wait for the next pause, then:
kubectl argo rollouts promote checkout --full

Done when: argocd app get checkout shows Sync Status: Synced and Health Status: Healthy together, kubectl describe rollout checkout has no InvalidSpec condition left, and kubectl argo rollouts get rollout checkout shows Step 5/5, ActualWeight: 100, and the old stable ReplicaSet scaled to zero.

The transferable habit

☺ Like you're 10: When something's "stuck," ask which scoreboard is actually red before you start reading code.

Nothing about today's fix required deep Istio expertise — it was one missing YAML block, four lines. The skill worth keeping is the sequence you used to find it: notice that Sync and Health disagreed, trust that disagreement instead of assuming "Synced" means "fine," ask the one controller that actually owns the broken behavior what it's stuck on (kubectl describe, not a guess), and confirm its claim directly against the resource it's pointing at. That sequence generalizes past Istio subsets completely — a Certificate that's Synced but never issues, a HorizontalPodAutoscaler that's Synced but can't read a metric, an Ingress that's Synced but 404s. Every one of those is the same shape: the git-to-cluster diff is clean, and the actual failure is one layer beneath it, in a controller with its own opinion about whether things are working.

🤖 Recon's-eye view

"People treat my green 'Synced' badge like a health check. It was never one — I only ever promise that what's running matches what's in git, byte for byte. If you write something broken into git, I will sync it faithfully and tell you, cheerfully, that I did. Read the Health column. That one's not mine — it belongs to whatever controller actually has to make your resource work."

🦉 Owl's challenge · going further

Push past the minimum fix. Delete the canary subset again on purpose and run istioctl analyze -n default before you touch kubectl describe rollout at all — compare what the linter tells you to what the rollout controller told you, and notice which one would have caught this before you ever synced. Then break a second, subtler version of the same bug: leave both subsets defined, but typo canarySubsetName: canery in the Rollout itself — same failure shape, wrong side of the reference. Finally, add a Kyverno ClusterPolicy in audit mode that flags any Rollout whose destinationRule.canarySubsetName doesn't appear among the subset names of the DestinationRule it names — you won't be able to fully validate the cross-object reference declaratively, but the attempt is a real rep for KCA.

0 / 7 steps complete
1Stand up the cluster, Argo CD, Argo Rollouts, and Istio
Done when: pods in argocd, argo-rollouts, and istio-system are all Running, and argocd login succeeds.
2Push the four manifests and create the Argo CD Application
Done when: argocd app sync checkout completes and argocd app get checkout returns a status for all four resources.
3Catch the contradiction: Synced, but Degraded
Done when: you can read both status fields off argocd app get checkout and correctly say why they disagree, before diagnosing further.
4Find the exact InvalidSpec condition and its message
Done when: you can point at the one line in kubectl describe rollout checkout that names the missing subset.
5Confirm it against the DestinationRule itself
Done when: kubectl get destinationrule checkout-destrule -o yaml shows exactly one subset, stable.
6Add the canary subset, commit, push, resync
Done when: the InvalidSpec condition is gone from kubectl describe rollout checkout.
7Promote through to 100% and confirm Healthy
Done when: argocd app get checkout shows Synced and Healthy, and the rollout reads Step 5/5, ActualWeight: 100.
🎬 At Mission Control
🦊

Foxy: Wait, it says "Synced" right there in green. How is a synced app also broken? Isn't synced supposed to mean it worked?

🤖

Recon the Robot: Synced means I copied your instructions faithfully, Foxy. Nothing more. If the instructions themselves reference something that doesn't exist, I'll sync that too, cheerfully, and tell you I did.

👺

Gizmo the Gremlin: Boooring. Just kubectl edit the DestinationRule directly on the cluster, add the subset by hand, and move on. Green light, day saved. 🤑

🦉

Professor Owl: And the moment anyone touches that repo again, Gizmo? Argo CD's selfHeal reverts your hand-edit right back to the broken version, because git still says one subset. You didn't fix anything — you hid it for one sync interval.

🐢

Timmy the Turtle: The fix belongs in the file that's wrong, in the repo, committed. Anywhere else and it's not fixed — it's just not caught yet.

🦊

Foxy: So the real lesson isn't Istio at all. It's: don't trust one green light to mean the whole system is fine.

🤖

Recon the Robot: Read both columns. Every time.

🐢 Timmy's checkpoint

1. Why did argocd app get checkout show Synced even though the canary release was completely broken? 2. What exactly made the Rollout's health Degraded, and which command showed you the precise message? 3. Why didn't Kubernetes or Istio reject the broken manifests at apply time, before any of this ever reached Argo Rollouts? 4. What's the one sentence that separates Sync Status from Health Status in Argo CD's model, and why does an Application's overall health depend on its children? 5. Why couldn't Argo Rollouts just create the missing canary subset itself instead of failing?

Check your answers
  1. Sync Status only compares git to the live cluster state. Every one of the four manifests applied cleanly and matched git exactly — there was zero drift — so Argo CD correctly reported Synced. The brokenness lived one layer deeper, in whether the applied resources actually functioned together, which Sync Status was never designed to check.
  2. The Argo Rollouts controller tried to manage the canary subset named in the Rollout's trafficRouting.istio.destinationRule.canarySubsetName field and couldn't find it in the checkout-destrule DestinationRule, so it set an InvalidSpec condition and parked the rollout's status.phase at Degraded. kubectl describe rollout checkout showed the exact message, naming both the missing subset and the DestinationRule it was missing from.
  3. Kubernetes' API server validates each object against its own CRD schema, not against other objects — a VirtualService and a DestinationRule are each independently well-formed YAML. Nothing at admission time cross-checks that a subset name mentioned in one object actually exists in another; that reference is only meaningful to whichever controller (Istio's proxy config, or here, the Argo Rollouts controller) tries to resolve it at runtime.
  4. Sync Status asks "does the cluster match git?"; Health Status asks "is what's running actually working?" — they are independent axes that can move separately, exactly as seen here. An Application's overall health is the aggregate of every child resource's health because an app is only as healthy as its least healthy piece; one Degraded Rollout is enough to mark the whole Application Degraded even with three other resources reporting Healthy.
  5. Argo Rollouts' Istio integration only ever relabels an existing subset entry — swapping the rollouts-pod-template-hash value to point at whichever ReplicaSet is currently canary or stable. It deliberately doesn't create new subset entries, because a DestinationRule's subsets can carry trafficPolicy and other settings a human owns; silently inventing one would mean guessing configuration nobody asked the controller to guess.

Fixed and holding? Good — that's the whole drill. For the concepts underneath it, see GitOps Philosophy, Service Mesh Architecture, and The Argo Ecosystem; for the certifications this exact bug class shows up on, see CGOA, CAPA, and ICA — whose own blueprint calls a missing-subset reference "the single most common self-inflicted 503" on that exam. Tool references: Argo CD and Argo Rollouts. Ready for a different single skill? Try Drill — Lock Down a Mesh Namespace, or go build the full loop in Build Your Cert Tracker — Start Here.