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 :
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]) :
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.
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-pushLe 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 :
make gitops-revertCe 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 canaryLa 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.
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ôme | Cause | Parade |
|---|---|---|
| Une version défaillante est promue sans erreur mesurée | aucun trafic pendant l'analyse : la requête renvoie un résultat vide, et len(result) == 0 vaut succès | trafic synthétique pendant le canary, ou exiger un volume minimal (sum(increase(...)) > 50) avant de juger |
| Analyse toujours en succès malgré des erreurs | requête sur tous les pods, ou étiquette de hash absente des séries | podTargetLabels 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 fausse | prometheusAddress par environnement (sur GKE : frontend de Managed Prometheus) |
| Poids demandé 10 %, observé 33 % | sans routeur de trafic, le poids est arrondi au pod | routeur de trafic, ou plus de répliques |
L'application reste Degraded après l'annulation | Git décrit toujours la version refusée | git 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 ?
C'est l'incident reproduit dans cette leçon : un ConfigMap partagé mis à jour par le commit du canary, lu par un pod stable recréé. Le seuil concerne la décision d'annuler, qui a bien eu lieu. La version d'Argo Rollouts et NGINX ne fabriquent pas d'erreurs applicatives.
Un service interne reçoit 30 requêtes par heure. L'équipe veut le même canary que l'assistant. Quel risque principal ?
À 30 requêtes par heure, une fenêtre d'une minute est vide la plupart du temps. La condition de ce chart traite le vide comme un succès pour ne pas bloquer un déploiement sans trafic : la protection devient fictive. Pour ce service, préférez un rolling update suivi d'un test de fumée, ou générez du trafic synthétique pendant le canary.
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 67859d19786ae4105be8e1cd9ee85373540ec8c4Retour à 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
AnalysisRunen échec et finir l'annulation par ungit revert - Repérer une configuration partagée entre deux versions qui cohabitent
- Dire quand un canary ne protège rien (trafic trop faible)