Een standaard Kubernetes Deployment heeft me lang prima gediend. Push een nieuwe image tag, kijk hoe de pods rollen, klaar. Simpel, declaratief, en meestal ging er niks mis. De rolling update gaf me zelfs een warm gevoel van veiligheid: oude pods verdwijnen pas als de nieuwe ready zijn.

Dat gevoel is een leugen. Een rolling update beschermt je tegen pods die niet opstarten. Hij doet niks tegen pods die perfect opstarten en vervolgens kapotte responses serveren. De container is gezond, de readiness probe is groen, en je nieuwe code stuurt stilletjes 500’s naar elke gebruiker. Binnen seconden raakt 100% van je traffic code die niemand onder echte load heeft gevalideerd.

De vraag waar ik steeds op uitkwam: hoe ship je een nieuwe versie zonder al je gebruikers in te zetten op het feit dat hij correct is? Ik wil begrijpen wat een release daadwerkelijk doet voordat hij iedereen raakt, en ik wil dat het systeem zelf de stekker eruit trekt als ik niet zit te kijken.

Argo Rollouts is mijn antwoord. Het is een drop-in vervanging voor een Deployment die traffic in stappen naar de nieuwe versie verschuift, onderweg metrics checkt, en automatisch terugrolt als de cijfers slecht worden. Dezelfde declaratieve geest, een veel kleinere blast radius.

Waarom progressive delivery beter is dan een rolling update

Kijk naar wat een standaard Deployment je kost als er een bug ship:

Tijd 0:00 - Deploy nieuwe versie
Tijd 0:02 - Alle pods draaien nieuwe versie
Tijd 0:05 - Errors beginnen te verschijnen
Tijd 0:08 - Alerts gaan af
Tijd 0:15 - Engineer onderzoekt
Tijd 0:25 - Rollback gestart
Tijd 0:27 - Alle pods terug naar oude versie

Blast radius: 100% van gebruikers voor ~25 minuten

Met progressive delivery:

Tijd 0:00 - Deploy nieuwe versie (5% traffic)
Tijd 0:05 - Geautomatiseerde analyse detecteert errors
Tijd 0:06 - Automatische rollback

Blast radius: 5% van gebruikers voor ~6 minuten

Dat gat is het hele punt. Ik probeer geen failures te voorkomen, want dat kan ik niet. Bugs komen er door, hoe goed de tests ook zijn. Wat ik wel kan doen: zorgen dat een bug die er doorheen glipt vijf mensen raakt voor zes minuten in plaats van iedereen voor een half uur. Dat is resilience by design: ga ervan uit dat dingen breken, en beperk hoeveel schade ze kunnen aanrichten.

Twee strategieën: canary vs blue-green

Argo Rollouts ondersteunt een paar strategieën. Twee dekken bijna alles wat ik doe.

Canary

Traffic verschuift geleidelijk van oude naar nieuwe versie:

Stap 1:  5% nieuw, 95% oud   (test the waters)
Stap 2: 20% nieuw, 80% oud   (uitbreiden als gezond)
Stap 3: 50% nieuw, 50% oud   (halverwege)
Stap 4: 100% nieuw, 0% oud   (volledige rollout)

Best voor stateless services en alles met genoeg traffic dat een kleine slice je echt signaal geeft. Dit is waar ik standaard naar grijp.

Blue-green

Twee complete omgevingen, directe switch:

Voor:    100% blue (oud)     0% green (nieuw)
Deploy:  100% blue           green klaar, ontvangt geen traffic
Switch:    0% blue         100% green

Best voor services die een directe flip terug nodig hebben, voor het testen van de nieuwe versie tegen productiedata voordat hij live gaat, en voor gevallen waar je beide versies tegelijk wilt draaien om ze te vergelijken. De prijs is dubbele resources tijdens de switch, want je draait twee complete kopieën.

Argo Rollouts installeren

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

Dat werkt, maar een release manifest met de hand applyen is precies het soort ding dat ik vergeet consistent te doen. Voor GitOps met ArgoCD laat je de controller het in plaats daarvan beheren als Application:

apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: argo-rollouts
  namespace: argocd
spec:
  project: default
  source:
    repoURL: https://argoproj.github.io/argo-helm
    chart: argo-rollouts
    targetRevision: 2.35.1
    helm:
      values: |
        dashboard:
          enabled: true
  destination:
    server: https://kubernetes.default.svc
    namespace: argo-rollouts
  syncPolicy:
    automated:
      prune: true
      selfHeal: true

Canary rollout voorbeeld

Het mooie is hoe weinig er verandert. Een Rollout ziet er bijna identiek uit aan een Deployment. Dezelfde replicas, dezelfde selector, dezelfde pod template. Je wisselt de kind en voegt een strategy block toe:

apiVersion: argoproj.io/v1alpha1
kind: Rollout
metadata:
  name: my-app
spec:
  replicas: 10
  selector:
    matchLabels:
      app: my-app
  template:
    metadata:
      labels:
        app: my-app
    spec:
      containers:
        - name: app
          image: my-app:v2.0.0
          ports:
            - containerPort: 8080
  strategy:
    canary:
      steps:
        - setWeight: 5
        - pause: { duration: 5m }
        - setWeight: 20
        - pause: { duration: 5m }
        - setWeight: 50
        - pause: { duration: 5m }
        - setWeight: 100

Dit creëert een geleidelijke rollout van 20 minuten:

  1. Stuur 5% van traffic naar nieuwe versie, wacht 5 minuten
  2. Als gezond, verhoog naar 20%, wacht 5 minuten
  3. Als gezond, verhoog naar 50%, wacht 5 minuten
  4. Voltooi rollout naar 100%

Breek je op welk moment dan ook af, dan klapt traffic terug naar de oude versie. De oude ReplicaSet is nooit weggegaan, dus er valt niks te herbouwen.

Traffic management

Hier is een detail waar mensen over struikelen. Standaard benadert Argo Rollouts de traffic weight met replica count. Vraag om 5% met 10 replicas en hij kan je geen 5% geven, hij geeft je het dichtstbijzijnde dat met hele pods lukt. Voor de meeste apps is die benadering prima. Wanneer je wilt dat de weight echt 5% betekent, knoop je de Rollout vast aan je ingress zodat de proxy traffic precies splitst.

Ik draai Traefik, dus dat is degene die ik in productie gebruik. De Nginx- en Istio-configs staan er ook bij voor de volledigheid.

Met Traefik

apiVersion: argoproj.io/v1alpha1
kind: Rollout
metadata:
  name: my-app
spec:
  strategy:
    canary:
      canaryService: my-app-canary
      stableService: my-app-stable
      trafficRouting:
        traefik:
          weightedTraefikServiceName: my-app-weighted
      steps:
        - setWeight: 10
        - pause: { duration: 2m }
        - setWeight: 50
        - pause: { duration: 2m }

Met ondersteunende TraefikService:

apiVersion: traefik.io/v1alpha1
kind: TraefikService
metadata:
  name: my-app-weighted
spec:
  weighted:
    services:
      - name: my-app-stable
        port: 80
        weight: 100  # Beheerd door Argo Rollouts
      - name: my-app-canary
        port: 80
        weight: 0    # Beheerd door Argo Rollouts

Met Nginx ingress

apiVersion: argoproj.io/v1alpha1
kind: Rollout
spec:
  strategy:
    canary:
      canaryService: my-app-canary
      stableService: my-app-stable
      trafficRouting:
        nginx:
          stableIngress: my-app-ingress

Met Istio

apiVersion: argoproj.io/v1alpha1
kind: Rollout
spec:
  strategy:
    canary:
      trafficRouting:
        istio:
          virtualService:
            name: my-app-vsvc
            routes:
              - primary

Blue-green rollout

apiVersion: argoproj.io/v1alpha1
kind: Rollout
metadata:
  name: my-app
spec:
  replicas: 5
  selector:
    matchLabels:
      app: my-app
  template:
    metadata:
      labels:
        app: my-app
    spec:
      containers:
        - name: app
          image: my-app:v2.0.0
  strategy:
    blueGreen:
      activeService: my-app-active
      previewService: my-app-preview
      autoPromotionEnabled: false
      prePromotionAnalysis:
        templates:
          - templateName: smoke-tests
      postPromotionAnalysis:
        templates:
          - templateName: load-test

Dit creëert:

  • my-app-active: Wijst naar huidige productie versie
  • my-app-preview: Wijst naar nieuwe versie voor testen

De nieuwe versie draait volledig maar ziet geen productie traffic totdat je promoot. Met autoPromotionEnabled: false is die promotie een bewuste beslissing die je neemt nadat je zelf aan de preview service hebt gevoeld.

Geautomatiseerde analyse

Alles tot nu toe leunt nog op een mens die naar een dashboard kijkt en beslist wanneer door te gaan. Daar verlies ik mijn vertrouwen in het proces, want ik kijk niet altijd, en om 2 uur ’s nachts al helemaal niet. Het deel van Argo Rollouts waar het mij echt om gaat is geautomatiseerde analyse: de controller queryt je metrics tijdens de rollout en breekt zelf af wanneer ze slecht worden.

Analysis template

apiVersion: argoproj.io/v1alpha1
kind: AnalysisTemplate
metadata:
  name: success-rate
spec:
  args:
    - name: service-name
  metrics:
    - name: success-rate
      interval: 1m
      successCondition: result[0] >= 0.95
      failureLimit: 3
      provider:
        prometheus:
          address: http://prometheus.monitoring:9090
          query: |
            sum(rate(http_requests_total{service="{{args.service-name}}",status=~"2.."}[5m])) /
            sum(rate(http_requests_total{service="{{args.service-name}}"}[5m]))

Deze template queryt Prometheus elke minuut voor de HTTP success rate, behandelt alles op of boven 95% als gezond, en faalt de rollout na 3 opeenvolgende slechte metingen. Die failureLimit is belangrijk: een enkele scrape-hapering moet geen deploy killen, maar drie op rij is een echt signaal.

Analysis gebruiken in een Rollout

apiVersion: argoproj.io/v1alpha1
kind: Rollout
spec:
  strategy:
    canary:
      steps:
        - setWeight: 5
        - pause: { duration: 2m }
        - analysis:
            templates:
              - templateName: success-rate
            args:
              - name: service-name
                value: my-app
        - setWeight: 50
        - pause: { duration: 5m }
        - analysis:
            templates:
              - templateName: success-rate
            args:
              - name: service-name
                value: my-app

Nu verschuift de rollout 5% van de traffic, wacht twee minuten, en draait dan de analyse. Is de success rate drie metingen lang onder de 95%, dan rolt hij terug zonder dat ik iets aanraak. Slaagt hij, dan gaat traffic naar 50% en draait dezelfde check opnieuw voordat de volledige rollout afrondt. De deploy verdedigt zichzelf nu.

Meerdere analysis metrics

Success rate alleen mist dingen. Een versie kan 200’s teruggeven en er drie seconden over doen, wat een eigen soort kapot is. Dus check ik meerdere signalen tegelijk, en zodra er één faalt breekt de rollout af:

apiVersion: argoproj.io/v1alpha1
kind: AnalysisTemplate
metadata:
  name: comprehensive-check
spec:
  args:
    - name: service-name
  metrics:
    # HTTP success rate
    - name: success-rate
      interval: 1m
      successCondition: result[0] >= 0.95
      failureLimit: 3
      provider:
        prometheus:
          address: http://prometheus.monitoring:9090
          query: |
            sum(rate(http_requests_total{service="{{args.service-name}}",status=~"2.."}[5m])) /
            sum(rate(http_requests_total{service="{{args.service-name}}"}[5m]))

    # P99 latency
    - name: latency-p99
      interval: 1m
      successCondition: result[0] < 0.5
      failureLimit: 3
      provider:
        prometheus:
          address: http://prometheus.monitoring:9090
          query: |
            histogram_quantile(0.99, sum(rate(http_request_duration_seconds_bucket{service="{{args.service-name}}"}[5m])) by (le))

    # Error rate
    - name: error-rate
      interval: 1m
      successCondition: result[0] < 0.01
      failureLimit: 2
      provider:
        prometheus:
          address: http://prometheus.monitoring:9090
          query: |
            sum(rate(http_requests_total{service="{{args.service-name}}",status=~"5.."}[5m])) /
            sum(rate(http_requests_total{service="{{args.service-name}}"}[5m]))

Web-based job analyse

Niet elke check past in een Prometheus query. Soms wil je gewoon een paar endpoints raken en bevestigen dat ze antwoorden. Argo Rollouts kan een Kubernetes Job draaien als analysis stap, zodat een gefaalde curl de rollout net zo afbreekt als een slechte metric:

apiVersion: argoproj.io/v1alpha1
kind: AnalysisTemplate
metadata:
  name: smoke-test
spec:
  metrics:
    - name: smoke-test
      provider:
        job:
          spec:
            backoffLimit: 1
            template:
              spec:
                containers:
                  - name: smoke
                    image: curlimages/curl
                    command:
                      - /bin/sh
                      - -c
                      - |
                        curl -f http://my-app-canary/health || exit 1
                        curl -f http://my-app-canary/api/status || exit 1
                restartPolicy: Never

Dashboard en CLI

Monitor rollouts met de Argo Rollouts kubectl-plugin:

# Installeer plugin
curl -LO https://github.com/argoproj/argo-rollouts/releases/latest/download/kubectl-argo-rollouts-darwin-amd64
chmod +x kubectl-argo-rollouts-darwin-amd64
mv kubectl-argo-rollouts-darwin-amd64 /usr/local/bin/kubectl-argo-rollouts

# Watch rollout progress
kubectl argo rollouts get rollout my-app -w

# Handmatig promoten (als autoPromotion disabled is)
kubectl argo rollouts promote my-app

# Afbreken en rollback
kubectl argo rollouts abort my-app

# View dashboard
kubectl argo rollouts dashboard

De -w watch view is wat ik openhoud tijdens een release. Hij toont de huidige weight, welke analyses slagen, en hoe ver elke stap is, allemaal in de terminal waar ik toch al woon. Het web-dashboard doet hetzelfde met grafieken als je dat liever hebt.

Integratie met GitOps

Hier klikt het vast in de rest van mijn workflow. Tijdens een normale deploy draai ik geen van die kubectl-commando’s. De image tag leeft in Git, ArgoCD synct hem, en de progressive rollout start vanzelf.

# In je GitOps repo
apiVersion: argoproj.io/v1alpha1
kind: Rollout
metadata:
  name: my-app
spec:
  template:
    spec:
      containers:
        - name: app
          image: my-app:v2.1.0  # Update deze regel

De semantic versioning pipeline maakt de tag, die bumpt de image in de GitOps repo, ArgoCD synct de wijziging, en de Rollout neemt het daarna over. Git is het enige wat ik aanraak, en de controller doet het zorgvuldige werk.

Notificaties

Omdat ik bewust niet elke rollout zit te bekijken, wil ik een ping als er één afrondt of afbreekt:

apiVersion: argoproj.io/v1alpha1
kind: Rollout
metadata:
  name: my-app
  annotations:
    notifications.argoproj.io/subscribe.on-rollout-completed.slack: my-channel
    notifications.argoproj.io/subscribe.on-rollout-aborted.slack: my-channel

Je configureert de notification controller apart, en hij deelt zijn config met ArgoCD notifications als je die toch al draait.

Mijn productie setup

Hier is de Rollout die ik daadwerkelijk draai voor mijn API, met alles hierboven samengebracht:

apiVersion: argoproj.io/v1alpha1
kind: Rollout
metadata:
  name: api
spec:
  replicas: 5
  revisionHistoryLimit: 3
  selector:
    matchLabels:
      app: api
  template:
    metadata:
      labels:
        app: api
    spec:
      containers:
        - name: api
          image: registry.example.com/api:v1.0.0
          ports:
            - containerPort: 8080
          readinessProbe:
            httpGet:
              path: /health
              port: 8080
            initialDelaySeconds: 5
            periodSeconds: 5
          resources:
            requests:
              cpu: 100m
              memory: 128Mi
            limits:
              cpu: 500m
              memory: 512Mi
  strategy:
    canary:
      canaryService: api-canary
      stableService: api-stable
      trafficRouting:
        traefik:
          weightedTraefikServiceName: api-weighted
      steps:
        # Fase 1: Smoke test
        - setWeight: 5
        - pause: { duration: 2m }
        - analysis:
            templates:
              - templateName: smoke-test

        # Fase 2: Beperkte blootstelling
        - setWeight: 25
        - pause: { duration: 5m }
        - analysis:
            templates:
              - templateName: success-rate
            args:
              - name: service-name
                value: api

        # Fase 3: Meerderheid traffic
        - setWeight: 75
        - pause: { duration: 10m }
        - analysis:
            templates:
              - templateName: success-rate
            args:
              - name: service-name
                value: api

        # Fase 4: Volledige rollout
        - setWeight: 100
      rollbackWindow:
        revisions: 2

Een paar keuzes daarin zijn bewust. De smoke test draait eerst op 5%, zodat een overduidelijk kapotte build in twee minuten sterft voordat hij echte gebruikers bereikt. De pauzes worden langer naarmate de weight klimt, want meer traffic betekent dat ik meer signaal wil voordat ik de volgende sprong vertrouw. En rollbackWindow houdt de laatste twee revisies warm zodat een revert direct is, geen herbouw.

Wanneer geen progressive delivery

Ik moet eerlijk zijn over de kosten. Progressive delivery voegt bewegende delen toe: extra services, traffic routing config, analysis templates, metrics die echt moeten bestaan. Die complexiteit levert je in een paar gevallen niks op, en hem er dan toch in forceren voegt alleen maar friction toe:

  • Breaking database schema changes. Als de nieuwe code niet kan samenleven met het oude schema, dan is beide versies tegelijk draaien een bug, geen veiligheidsfeature. Je hebt de hele app op één versie nodig.
  • Single-user of interne tools. Er is geen zinvolle traffic om te splitsen, dus een canary vertelt je niks. Een gewone Deployment is de eerlijke keuze.
  • Strak gekoppelde services die in lockstep moeten upgraden. Eén ervan geleidelijk verschuiven breekt het contract met de andere.

Voor een productie service die echte gebruikers over HTTP bedient verdient de complexiteit zichzelf terug bij de eerste slechte deploy die hij vangt.

Waarom dit ertoe doet

Elke deploy is een gok dat je nieuwe code zich gedraagt in productie. Tests verkleinen de kans, maar beslissen de gok niet, want de staging omgeving is nooit helemaal de echte.

Wat progressive delivery verandert is de grootte van de inzet. Een slechte release bereikt een slice gebruikers voor een paar minuten, de controller spot het in de metrics, en traffic stroomt terug naar de versie waarvan je weet dat hij werkt, allemaal zonder iemand wakker te maken. Ik kan begrijpen wat een release doet terwijl het gebeurt, en het systeem beschermt zichzelf als ik niet kijk. Voor mij is dat de hele reden om mijn eigen platform te draaien: niet jagen op deploys die nooit falen, maar er een bouwen die klein faalt en zichzelf herstelt.