The problem
An app shows OutOfSync. Before you sync, you need to know what differs and why. Sometimes the answer is "someone kubectl-edited it" (drift, 26.11). But sometimes an app stays OutOfSync even right after a successful sync, forever - and with self-heal on, Argo CD then syncs again and again. This lesson is how Argo CD decides "different", and how to fix the case where something else legitimately changes your objects.
What you need to know already: 26.4 (sync status), 26.9 (self-heal, sync options), 15.40 (kubectl apply, the last-applied annotation, kubectl diff), 17.28 (the HorizontalPodAutoscaler), 17.39 (admission: the API server checking objects before storing them), 7.11 (jq paths).
What "OutOfSync" actually compares
For each object Argo CD tracks, it takes three things: the manifest git renders, the live object, and (for normal client-side apply) the last-applied-configuration annotation kubectl leaves on objects (15.40). It normalises both sides (puts them in one standard form, e.g. "1000m" CPU and "1" become the same) and compares. The practical rule:
- A field you declare in git is compared. If the live value differs, the app is OutOfSync.
- A field you do not declare is not compared: defaults the API server adds (
imagePullPolicy,terminationGracePeriodSeconds,strategy, a Service'sclusterIP) do not make an app OutOfSync. - A field you declared before and removed now is compared through last-applied: kubectl would delete it, so Argo CD shows it.
So an app that is OutOfSync forever, even right after a successful sync, has something in the cluster that changes a field git declares, every time:
mutating admission webhooks a program the API server calls on every create/update
that may REWRITE the object before it is stored - e.g. a
company policy engine (Kyverno, Gatekeeper) forcing
imagePullPolicy, adding resources, injecting labels or
sidecar containers
controllers an HPA owns spec.replicas (17.28); cert-manager (a
controller that issues TLS certificates) fills a
caBundle field; an operator writes back its own spec
the API server normalising "1000m" becomes "1", "0.5Gi" becomes "512Mi"
A mutating admission webhook is registered with a MutatingWebhookConfiguration object (kubectl get mutatingwebhookconfigurations lists them).
With self-heal on, that turns into a loop: sync, mutate, OutOfSync, self-heal sync, mutate... Argo CD waits a little longer between tries, but the app flaps between statuses and the history fills with syncs of the same commit.
Fix it in this order
- Make git match reality. If the platform policy forces
imagePullPolicy: Always, declareAlways(or stop declaring the field at all). If an HPA owns replicas, removereplicasfrom the Deployment in git - that is the documented pattern, not an ignore rule. - Ignore narrowly, when a controller legitimately owns a field you must also declare.
ignoreDifferencesin the Application lists fields Argo CD should not compare:
spec:
ignoreDifferences:
- group: apps
kind: Deployment
name: payments-api # omit to match every Deployment of the app
jsonPointers: # a path like /spec/replicas (RFC 6901, 26.2's patch paths)
- /spec/replicas
- group: admissionregistration.k8s.io
kind: MutatingWebhookConfiguration
jqPathExpressions: # the same, written as a jq path (7.11)
- .webhooks[]?.clientConfig.caBundle
- group: apps
kind: Deployment
managedFieldsManagers: # ignore every field this manager owns
- kube-controller-manager
syncPolicy:
syncOptions:
- RespectIgnoreDifferences=true # and do not overwrite those fields on sync
managedFieldsManagers relies on managed fields: the API server records, in metadata.managedFields, which tool (a "manager") last set each field.
Without RespectIgnoreDifferences=true, the field is ignored for the sync status, but a sync still applies git's value - and the fight continues on every sync.
- System-wide rules go in
argocd-cm(resource.customizations.ignoreDifferences.apps_Deployment) - for things every app hits, like a platform injecting the same annotation everywhere.
Never ignore a whole object or /spec: you would hide exactly the drift GitOps is supposed to catch.
Reading a diff
# after someone kubectl-edited imagePullPolicy (in sync, it prints nothing and exits 0)
argocd app diff payments-prod
===== apps/Deployment pay-prod/payments-api ======
61c61
< imagePullPolicy: Always
---
> imagePullPolicy: IfNotPresent
Read it line by line:
===== apps/Deployment pay-prod/payments-api ======- which object (API group / kind, namespace / name).61c61- classicdiffnotation: line 61 was changed.- The output is
diff LIVE TARGET:<lines are what is live in the cluster,>lines what git wants. Here the cluster hasAlwaysand git saysIfNotPresent.
Argo CD's UI shows the same with colours. Exit code 1 means differences, which makes argocd app diff usable as a CI check on a pull request (render the pull request's commit with --revision).
Server-side diff
Newer Argo CD versions can ask the API server to dry-run the apply (go through all the checks and webhooks but store nothing) and diff the result (argocd.argoproj.io/compare-options: ServerSideDiff=true, or controller.diff.server.side: "true" for all apps). Because the dry run goes through admission, mutations by webhooks appear on both sides and stop causing diffs. It is the modern fix for webhook noise - but it costs an API call per object per reconcile, and it only helps with webhooks, not with controllers that write later.
What you can now do
- Say which fields Argo CD compares and why an app can be OutOfSync right after a sync.
- Read
argocd app diffoutput: which side is live, which is git. - Fix a fight with a webhook or controller: make git match first, ignore narrowly second.