Une synchronisation échoue sur un CRD, et le message qui remonte ne parle pas du contenu du manifeste :

metadata.annotations: Too long: may not be more than 262144 bytes

Le CRD n’a pas d’annotations. Celle qui déborde a été écrite par kubectl apply lui-même, et c’est le moment où le server-side apply cesse d’être une curiosité de release notes.

L’annotation que le client-side apply traîne derrière lui

Par défaut, Argo CD applique les manifestes avec un kubectl apply classique, côté client, qui s’appuie sur l’annotation kubectl.kubernetes.io/last-applied-configuration pour mémoriser l’état précédent de la ressource. Cette annotation contient le manifeste complet, sérialisé. Sur un Deployment de trente lignes, personne ne la remarque. Sur un CRD dont le schéma OpenAPI fait plusieurs centaines de kilo-octets, elle fait exploser la limite de taille des annotations, fixée dans Kubernetes à 256 Kio cumulés sur l’objet — 262 144 octets, la valeur exacte qui apparaît dans le message d’erreur.

Le mécanisme de fusion à trois voies qui repose sur cette annotation est décrit ailleurs, avec le bug qu’il produit quand on modifie un objet à la main : le merge que personne ne regarde vraiment. Ce qui compte ici, c’est qu’il ne passe pas à l’échelle d’un objet volumineux, et que le contournement change complètement le modèle de propriété.

Ce que le serveur retient à la place

Avec --server-side, la fusion est calculée par l’API server, et l’état de référence n’est plus une annotation mais metadata.managedFields : la liste des gestionnaires, avec pour chacun l’ensemble des champs qu’il revendique.

# managedFields is omitted by default: ask for it explicitly
kubectl get deployment my-app -o yaml --show-managed-fields
managedFields:
  - manager: argocd-controller
    operation: Apply          # "Apply" for server-side apply, "Update" otherwise
    apiVersion: apps/v1
    fieldsType: FieldsV1
    fieldsV1:
      f:spec:
        f:template:
          f:spec:
            f:containers: {}
  - manager: kube-controller-manager
    operation: Update
    fieldsV1:
      f:spec:
        f:replicas: {}

Trois règles en découlent, et elles sont la vraie différence avec le mode client :

  • Un champ qu’un autre gestionnaire possède et dont la valeur diffère produit un conflit, pas un écrasement silencieux.
  • Retirer un champ de son manifeste ne le supprime de l’objet que si aucun autre gestionnaire ne le revendique. Sinon on abandonne simplement sa propre revendication.
  • Une écriture qui n’est pas un apply — kubectl edit, kubectl scale, un contrôleur qui fait un update — enregistre elle aussi une propriété, avec operation: Update, et ne peut jamais échouer sur un conflit. Seul l’apply s’arrête.

Deux appliers qui posent la même valeur sur un champ le co-possèdent. À partir de là, le premier qui veut la changer déclenche un conflit.

Argo CD ne demande jamais la permission

L’option se pose au niveau de l’Application :

apiVersion: argoproj.io/v1alpha1
kind: Application
spec:
  syncPolicy:
    syncOptions:
      - ServerSideApply=true

ou par ressource, avec l’annotation argocd.argoproj.io/sync-options: ServerSideApply=true. L’inverse fonctionne aussi : ServerSideApply=false sur une ressource précise la sort du mode serveur alors qu’il est actif sur toute l’Application. Et Replace=true prend le pas sur ServerSideApply=true — les combiner ne fait pas ce qu’on imagine.

Le détail qui mérite qu’on s’y arrête est ailleurs. Quand l’option est active, Argo CD exécute kubectl apply --server-side --force-conflicts. Le forçage n’est pas configurable : c’est la commande. Le gestionnaire s’appelle argocd-controller, et il gagne tous les conflits, à chaque synchronisation.

Ce choix est cohérent avec la recommandation de Kubernetes, qui conseille aux contrôleurs de forcer les conflits sur les objets qu’ils possèdent, précisément parce qu’un contrôleur n’a aucun moyen de résoudre un conflit ni d’agir dessus. Mais il faut en tirer la conséquence : le mécanisme de conflit ne protège personne contre Argo CD. La frontière entre Argo CD et un autre contrôleur ne se trace pas dans managedFields, elle se trace dans le dépôt, en ne déclarant pas le champ.

Le cas d’école est spec.replicas face à un HorizontalPodAutoscaler. La documentation Kubernetes décrit le transfert propre : les deux acteurs posent d’abord la même valeur, puis celui qui abandonne retire le champ de sa configuration. Côté GitOps, ça veut dire supprimer replicas du manifeste versionné. Un ignoreDifferences seul ne suffit pas : il n’agit que sur le calcul du diff, l’état désiré est appliqué tel quel au moment de la synchronisation. Pour qu’il agisse aussi pendant l’apply, il faut l’option RespectIgnoreDifferences=true, et elle n’a d’effet que si la ressource existe déjà.

Appliquer un manifeste qui n’en est pas un

Le server-side apply accepte une intention partielle : un objet qui ne contient que les champs sur lesquels on a un avis. Argo CD s’en sert pour patcher une ressource qu’il ne gère pas entièrement.

# Not a valid Deployment: no selector, no template.
# Valid as a server-side apply intent.
apiVersion: apps/v1
kind: Deployment
metadata:
  name: my-deployment
spec:
  replicas: 3

Ce fichier ne passe pas la validation de schéma côté client, il faut donc désactiver celle-ci en plus :

spec:
  syncPolicy:
    syncOptions:
      - ServerSideApply=true
      - Validate=false

Basculer un objet qui existe déjà

C’est la question que tout le monde pose au moment d’activer l’option sur un parc en place : que deviennent les propriétaires actuels ?

Côté Kubernetes, la migration depuis le client est prévue et se fait sans conflit — à condition que l’annotation last-applied-configuration soit à jour. Les champs qu’elle ne couvre pas ne sont pas considérés comme possédés par le client, et ceux-là provoquent un conflit. Un kubectl scale passé après le dernier apply suffit à créer ce cas.

Côté Argo CD, un mécanisme dédié absorbe cette transition, et il est actif par défaut : à la synchronisation, les entrées de managedFields portant operation: Update et appartenant au gestionnaire kubectl-client-side-apply sont transférées à argocd-controller, puis l’entrée d’origine est supprimée. On peut le désactiver avec ClientSideApplyMigration=false, ou viser un autre gestionnaire :

metadata:
  annotations:
    argocd.argoproj.io/client-side-apply-migration-manager: 'my-custom-manager'

Ce dernier point est le plus utile en pratique : c’est ce qui permet de récupérer les champs d’un opérateur qu’on a retiré du cluster et dont les revendications traînent encore dans les objets. Puisque Argo CD force les conflits de toute façon, la migration ne sert pas à éviter un échec — elle sert à ne pas laisser un managedFields qui ment sur qui écrit quoi.

Le diff bascule en même temps que l’apply

Activer le server-side apply change aussi la façon dont Argo CD décide qu’une Application est OutOfSync. La stratégie historique compare l’état vivant, l’état désiré et l’annotation last-applied-configuration — qui n’est plus alimentée. La stratégie qui prenait le relais automatiquement, dite structured-merge diff, est abandonnée au profit du Server-Side Diff, stable depuis Argo CD 3.1.

Elle exécute un server-side apply en dry-run pour chaque ressource et compare la réponse à l’état vivant. Elle s’active globalement dans la ConfigMap argocd-cmd-params-cm :

data:
  controller.diff.server.side: "true"

ou par Application avec argocd.argoproj.io/compare-options: ServerSideDiff=true.

Deux conséquences concrètes. La bonne : les webhooks d’admission participent au calcul du diff, donc un manifeste qu’une validation refusera est signalé au moment du diff, pas au moment de la synchronisation. La moins évidente : les webhooks de mutation ne sont pas pris en compte par défaut, il faut ajouter IncludeMutationWebhook=true — sans quoi un objet muté à l’admission peut rester en écart permanent. Et le dry-run n’a pas lieu à la création d’une ressource qui n’existe pas encore : sur un premier déploiement, on ne bénéficie ni de l’un ni de l’autre.

Le résultat est un modèle plus honnête que l’annotation, où l’on peut enfin lire qui a écrit quoi. Il ne rend pas la cohabitation sûre pour autant : tant que le contrôleur force, managedFields est un journal, pas un garde-fou. Ce qui décide vraiment reste ce qui est déclaré dans le dépôt, ce que le déploiement déclaratif avec Argo CD suppose depuis le début, et que le reste de la chaîne CI/CD doit tenir.