Leçon 3 — Livraison progressive et observabilité

Livraison progressive avec Argo Rollouts

10 min de lecture

Livraison progressive avec Argo Rollouts

La version qui renvoyait des réponses vides chez Ferrand Médical passait toutes les sondes. Le rolling update l'a donc installée partout en 40 secondes, et l'équipe l'a apprise par les tickets. Une sonde vérifie qu'un pod est vivant et prêt, pas qu'il répond juste. Pour cela, il faut exposer la nouvelle version à une fraction du trafic réel, mesurer, et décider automatiquement. C'est le canary avec analyse, et c'est ce que fait Argo Rollouts.

Le mécanisme

Le chart remplace le Deployment par un Rollout (même gabarit de pod, stratégie différente) dès que rollout.enabled=true :

files/gitops-source/chart/inference/values.yaml
rollout:
  enabled: true
  steps:
    - setWeight: 34
    - pause: { duration: 45s }
    - analysis:
        templates: [{ templateName: inference-success-rate }]
        args: [{ name: canary-hash, valueFrom: { podTemplateHashValue: Latest } }]
    - setWeight: 67
    - pause: { duration: 30s }
stateDiagram-v2
  [*] --> W34: "commit : nouvelle image ou nouvelle config"
  W34: "34 % : 1 pod canary sur 3"
  W34 --> P45: "pause 45 s (le trafic s'accumule)"
  P45 --> AN: "AnalysisRun"
  AN: "taux de succès des SEULS pods canary, 3 mesures / 20 s"
  AN --> W67: "mesures >= 95 %"
  AN --> ABORT: "2 mesures < 95 % (failureLimit 1)"
  W67: "67 % : 2 pods canary"
  W67 --> FULL: "pause 30 s"
  FULL: "100 % : la version devient stable"
  ABORT: "Degraded : retour immédiat aux pods stables"
  FULL --> [*]
  ABORT --> [*]

Sans maillage de services ni contrôleur d'entrée compatible, Argo Rollouts règle le poids par le nombre de pods : 34 % de 3 pods, c'est 1 pod canary et 2 stables derrière le même Service. La mesure le confirme (2 384 réponses du canary contre 5 128 du stable sur 15 s). Pour des poids fins (5 %, 1 %), il faut un routeur de trafic : Gateway API, Istio, ou les annotations canary de NGINX.

L'analyse ne regarde que les pods canary, grâce au hash du gabarit de pod que le ServiceMonitor recopie dans chaque série (podTargetLabels: [rollouts-pod-template-hash]) :

files/gitops-source/chart/inference/templates/analysistemplate.yaml
successCondition: "len(result) == 0 || isNaN(result[0]) || result[0] >= {{ .Values.rollout.analysis.minSuccessRate }}"
query: |
  sum(rate(inference_requests_total{namespace="assistant",rollouts_pod_template_hash="{{args.canary-hash}}",status="200"}[1m]))
  /
  sum(rate(inference_requests_total{namespace="assistant",rollouts_pod_template_hash="{{args.canary-hash}}",status=~"200|5.."}[1m]))

Un taux calculé sur tous les pods diluerait le canary : 30 % d'erreurs sur un pod sur trois donnent 10 % globalement, et un seuil global plus lâche laisserait passer la version.

La mise en œuvre

Deux démonstrations, chacune sous une charge continue de 8 clients (loadtest.py), en mode simulé : aucun appel modèle.

terminal
make image TAG=0.2.1 && make image TAG=0.2.2
make load CONC=8 DUR=240 &                       # imprime toutes les 15 s : étape du Rollout, réponses par version
# canary sain
sed -i 's/^  tag: "0.2.0"/  tag: "0.2.1"/' gitops-repo/environments/kind/values.yaml
git -C gitops-repo commit -qam "assistant 0.2.1" && make gitops-push
# canary défaillant : 30 % de 502 injectés
sed -i 's/^  tag: "0.2.1"/  tag: "0.2.2"/; s/^  FAULT_RATE: "0"/  FAULT_RATE: "0.3"/' gitops-repo/environments/kind/values.yaml
git -C gitops-repo commit -qam "assistant 0.2.2" && make gitops-push

Le canary sain franchit les étapes en 2 min 30 et ne produit aucune erreur. Le canary défaillant est annulé seul après deux mesures à 71 %. Ensuite, Git dit toujours 0.2.2 : l'application est Synced (le cluster correspond à Git) et Degraded (le Rollout a refusé la version). Il reste à corriger la source :

terminal
make gitops-revert
Argo CD après l'annulation automatique du canary 0.2.2 : application Synced mais Degraded, AnalysisRun en échec, pods stables seuls en service (capture réelle du 2026-10-16)

Ce qui casse en production

L'incident reproduit : un ConfigMap partagé entre stable et canary

Au premier essai du canary défaillant, l'annulation a eu lieu à 106 s. Pourtant, la charge a continué de recevoir 502 et des réponses étiquetées 0.2.2 jusqu'à la fin, pendant plus de deux minutes, sur une application que tout affichait comme revenue à l'état stable :

  106s  Degraded    étape=0 ... RolloutAborted   réponses/15s={'0.2.2': 2604, '0.2.1': 7574, 'erreur': 1179}
  242s  Degraded    étape=0 ... RolloutAborted   réponses/15s={'0.2.1': 6795, '0.2.2': 2363, 'erreur': 1035}
requêtes : 172261  codes : {200: 156763, 502: 15498}

$ for p in $(kubectl -n assistant get pods -o name); do ... printenv APP_VERSION FAULT_RATE; done
pod/inference-8476bcfd4-5s6zp 0.2.1 0
pod/inference-8476bcfd4-bxbzf 0.2.1 0
pod/inference-8476bcfd4-st2s9 0.2.2 0.3        <- pod STABLE, configuration du canary

La version 0.2.0 du chart mettait la configuration dans un seul ConfigMap, lu au démarrage par envFrom. Argo CD avait mis ce ConfigMap à jour avec les valeurs de 0.2.2 dès le commit. Pendant le canary, un pod stable a été recréé (montée à 3 pods stables après l'annulation) : il a démarré avec l'image stable et la configuration du canary. Pire, après make gitops-revert, le ConfigMap est revenu à 0.2.1 mais ce pod, lui, a gardé sa configuration : tout était Synced/Healthy et un pod sur trois renvoyait 30 % d'erreurs. Le tableau de bord de la leçon 4 montre la trace : le taux de succès du hash stable 8476bcfd4 tombe à 90 %.

La parade est dans le chart 0.3.0 : la configuration est écrite dans le gabarit du pod, donc chaque ReplicaSet, stable ou canary, porte la sienne.

files/gitops-source/chart/inference/templates/workload.yaml
          env:
            {{- range $k, $v := .Values.config }}
            - { name: {{ $k }}, value: {{ $v | quote }} }
            {{- end }}
            - { name: APP_VERSION, value: {{ .Values.image.tag | default .Chart.AppVersion | quote }} }

Même scénario rejoué avec le chart corrigé : les erreurs s'arrêtent avec l'annulation (93 s), 5 249 erreurs au lieu de 15 498, et tous les pods restants sont en 0.2.1 avec FAULT_RATE=0. Un test de rendu verrouille la règle (test_config_lives_in_pod_template_not_shared_configmap dans test_chart.py).

Les autres défaillances

SymptômeCauseParade
Une version défaillante est promue sans erreur mesuréeaucun trafic pendant l'analyse : la requête renvoie un résultat vide, et len(result) == 0 vaut succèstrafic synthétique pendant le canary, ou exiger un volume minimal (sum(increase(...)) > 50) avant de juger
Analyse toujours en succès malgré des erreursrequête sur tous les pods, ou étiquette de hash absente des sériespodTargetLabels dans le ServiceMonitor ; tester la requête dans Prometheus avant de l'écrire dans le chart
AnalysisRun en Error, Rollout bloquéPrometheus injoignable depuis le namespace d'Argo Rollouts, ou adresse fausseprometheusAddress par environnement (sur GKE : frontend de Managed Prometheus)
Poids demandé 10 %, observé 33 %sans routeur de trafic, le poids est arrondi au podrouteur de trafic, ou plus de répliques
L'application reste Degraded après l'annulationGit décrit toujours la version refuséegit revert : Git doit décrire ce qui tourne

La règle de décision

  • Canary avec analyse dès qu'un service a assez de trafic pour qu'une minute de mesures signifie quelque chose (quelques requêtes par seconde au moins). En dessous, un rolling update et des tests de fumée après déploiement.
  • Le critère d'analyse mesure ce que voit l'utilisateur : taux de succès, et pour un service IA, la part de réponses vides ou refusées si l'application l'exporte. La latence seule ne suffit pas.
  • Jamais de configuration partagée et mutable entre deux versions qui cohabitent : variables dans le gabarit, ou ConfigMap dont le nom contient un hash de son contenu.
  • À surveiller : la durée totale d'un canary (ici 2 min 30) multipliée par le nombre de déploiements par jour, et le nombre d'annulations par semaine.

Pratique

Un canary est annulé automatiquement. Dix minutes plus tard, l'astreinte voit encore 8 % d'erreurs sur le service, alors qu'Argo Rollouts affiche uniquement des pods stables. Que vérifiez-vous en premier ?

Un service interne reçoit 30 requêtes par heure. L'équipe veut le même canary que l'assistant. Quel risque principal ?

Exercice

Modifiez l'analyse pour qu'une fenêtre sans trafic suffisant bloque la promotion au lieu de l'autoriser : la mesure doit échouer si le canary a reçu moins de 50 requêtes sur la minute. Écrivez la requête et la condition.

Voir la solution

Ajoutez une seconde métrique à l'AnalysisTemplate :

    - name: canary-traffic
      interval: 20s
      count: 3
      failureLimit: 1
      successCondition: "len(result) > 0 && result[0] >= 50"
      provider:
        prometheus:
          address: {{ .Values.rollout.analysis.prometheusAddress }}
          query: |
            sum(increase(inference_requests_total{namespace="{{ .Release.Namespace }}",rollouts_pod_template_hash="{{ "{{args.canary-hash}}" }}"}[1m]))

Toutes les métriques d'un AnalysisRun doivent réussir : un canary sans trafic échoue désormais. Vérifiez avec helm template puis testez : commit d'une nouvelle étiquette sans lancer make load. L'analyse doit échouer et le Rollout passer Degraded. Le prix : un déploiement la nuit, sans trafic, est refusé ; c'est voulu, ou c'est le signal qu'il faut du trafic synthétique.

Résultat obtenu

Exécution du 2026-10-16, 8 clients en continu, mode simulé.

Canary sain (0.2.0 vers 0.2.1) :

   30s  Paused      étape=1 prêts=3 CanaryPauseStep  réponses/15s={'0.2.0': 8266}
   45s  Paused      étape=1 prêts=3 CanaryPauseStep  réponses/15s={'0.2.0': 5128, '0.2.1': 2384}
   75s  Progressing étape=2 prêts=3 more replicas need to be updated  réponses/15s={'0.2.0': 6606, '0.2.1': 3303}
  121s  Paused      étape=4 prêts=3 CanaryPauseStep  réponses/15s={'0.2.0': 4138, '0.2.1': 2896}
  136s  Paused      étape=4 prêts=3 CanaryPauseStep  réponses/15s={'0.2.0': 3121, '0.2.1': 6239}
  167s  Healthy     étape=5 prêts=3   réponses/15s={'0.2.1': 11058}
requêtes : 133334  codes : {200: 133334}
inference-8476bcfd4-2-2 success-rate Successful [('Successful', '[1]'), ('Successful', '[1]'), ('Successful', '[1]')]

Canary défaillant (0.2.2, FAULT_RATE=0.3), chart 0.3.0 :

   30s  Paused      étape=1 prêts=3 CanaryPauseStep  réponses/15s={'0.2.1': 10562, '0.2.2': 1452, 'erreur': 687}
   78s  Progressing étape=2 prêts=3 more replicas need to be updated  réponses/15s={'0.2.1': 9335, '0.2.2': 3236, 'erreur': 1434}
   93s  Degraded    étape=0 prêts=3 RolloutAborted: Rollout aborted update to revision  réponses/15s={'0.2.1': 10024, 'erreur': 1288, '0.2.2': 2904}
  108s  Degraded    étape=0 prêts=3 RolloutAborted: Rollout aborted update to revision  réponses/15s={'0.2.1': 9818}
requêtes : 161486  codes : {200: 156237, 502: 5249}

Degraded RolloutAborted: Rollout aborted update to revision 3: Step-based analysis phase error/failed:
Metric "success-rate" assessed Failed due to failed (2) > failureLimit (1)
inference-7cb69f66b5-3-2 success-rate Failed [('Failed', '[0.7121661217389]'), ('Failed', '[0.707860184528034]')]

$ make gitops-revert
Poussé : 67859d1 Revert "assistant 0.2.2"
assistant   Synced   Healthy   67859d19786ae4105be8e1cd9ee85373540ec8c4

Retour à l'état stable après le revert : 5 s, sans nouveau canary (Argo Rollouts reconnaît le gabarit de la révision stable).

Je sais faire

  • Écrire les étapes d'un canary et l'analyse qui ne mesure que les pods canary
  • Expliquer pourquoi le poids est arrondi au pod sans routeur de trafic
  • Lire un AnalysisRun en échec et finir l'annulation par un git revert
  • Repérer une configuration partagée entre deux versions qui cohabitent
  • Dire quand un canary ne protège rien (trafic trop faible)
Suivant