Pular para conteúdo

5. Canary com Argo Rollouts

Um rolling update substitui todo pod pela nova versão assim que ele fica ready — mas "ready" só significa que o processo subiu. Uma versão que responde 20% das requisições com 500 também fica ready. Um canary manda primeiro uma pequena fatia do tráfego para a nova versão, a mede contra o SLO e só continua enquanto os números estão bons; caso contrário, aborta e a versão antiga continua atendendo. Com o Argo Rollouts a decisão é automática.

As peças

Objeto Arquivo Papel
Rollout catalog catalog-rollout.yaml substitui o controlador do Deployment; workloadRef reaproveita o template de pod de base/catalog.yaml
Service catalog-stable / catalog-canary catalog-services.yaml o Argo Rollouts aponta cada um para os pods da sua versão (ele adiciona o hash do template de pod aos seletores)
AnalysisTemplate catalog-slo catalog-analysis.yaml a cada 30 s, duas consultas PromQL sobre o canary: taxa de erro < 1%, p99 < 500 ms
Prometheus k8s/monitoring/prometheus.yaml faz scrape dos Services anotados e rotula toda série com service="<nome do Service>"
HPA modificado pelo patch do overlay agora escala o Rollout

O Prometheus deste lab é mínimo de propósito (um Deployment, um job de scrape):

k8s/monitoring/prometheus.yaml
# A minimal in-cluster Prometheus for the canary analysis (lab 11).
# It discovers the Services of namespace boxoffice annotated with prometheus.io/scrape: "true"
# and labels every series with service="<Service name>" (catalog-stable, catalog-canary, ...).
apiVersion: v1
kind: Namespace
metadata:
  name: monitoring
---
apiVersion: v1
kind: ServiceAccount
metadata:
  name: prometheus
  namespace: monitoring
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
  name: prometheus
rules:
  - apiGroups: [""]
    resources: ["services", "pods"]
    verbs: ["get", "list", "watch"]
  - apiGroups: ["discovery.k8s.io"]
    resources: ["endpointslices"]
    verbs: ["get", "list", "watch"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
  name: prometheus
roleRef:
  apiGroup: rbac.authorization.k8s.io
  kind: ClusterRole
  name: prometheus
subjects:
  - kind: ServiceAccount
    name: prometheus
    namespace: monitoring
---
apiVersion: v1
kind: ConfigMap
metadata:
  name: prometheus-config
  namespace: monitoring
data:
  prometheus.yml: |
    global:
      scrape_interval: 15s
    scrape_configs:
      - job_name: boxoffice
        metrics_path: /actuator/prometheus
        kubernetes_sd_configs:
          - role: endpointslice
            namespaces:
              names: [boxoffice]
        relabel_configs:
          - source_labels: [__meta_kubernetes_service_annotation_prometheus_io_scrape]
            regex: "true"
            action: keep
          - source_labels: [__meta_kubernetes_endpointslice_port_name]
            regex: http
            action: keep
          - source_labels: [__meta_kubernetes_service_name]
            target_label: service
          - source_labels: [__meta_kubernetes_pod_name]
            target_label: pod
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: prometheus
  namespace: monitoring
spec:
  replicas: 1
  selector:
    matchLabels:
      app: prometheus
  template:
    metadata:
      labels:
        app: prometheus
    spec:
      serviceAccountName: prometheus
      containers:
        - name: prometheus
          image: prom/prometheus:v3.13.4
          args:
            - --config.file=/etc/prometheus/prometheus.yml
            - --storage.tsdb.retention.time=2d
          ports:
            - name: http
              containerPort: 9090
          readinessProbe:
            httpGet: { path: /-/ready, port: http }
          resources:
            requests: { cpu: 100m, memory: 256Mi }
            limits: { cpu: "1", memory: 1Gi }
          volumeMounts:
            - name: config
              mountPath: /etc/prometheus
      volumes:
        - name: config
          configMap:
            name: prometheus-config
---
apiVersion: v1
kind: Service
metadata:
  name: prometheus
  namespace: monitoring
spec:
  selector:
    app: prometheus
  ports:
    - name: http
      port: 9090
      targetPort: http

A métrica é a do próprio Spring Boot (Micrometer), http_server_requests_seconds, exposta em /actuator/prometheus pela dependência adicionada no passo 1 e pelo patch MANAGEMENT_ENDPOINTS_WEB_EXPOSURE_INCLUDE do overlay. Por padrão o Micrometer publica só _count e _sum; o patch MANAGEMENT_METRICS_DISTRIBUTION_PERCENTILESHISTOGRAM_HTTP_SERVER_REQUESTS do overlay adiciona as séries _bucket, sem as quais a consulta do p99 não retorna nada e essa métrica passaria sempre. Com o label service, a mesma consulta compara service="catalog-canary" com service="catalog-stable".

As etapas do Rollout (veja catalog-rollout.yaml no passo 4): 25% → espera 2 min → 50% → espera 2 min → 100%, com a análise rodando em segundo plano desde a primeira etapa. Sem um roteador de tráfego, o Argo Rollouts chega a um peso pela contagem de réplicas (com 2 réplicas, "25%" é 1 pod canary ao lado dos estáveis), então a fatia real é aproximada; a aula de entrega mostra pesos exatos com o plugin da Gateway API.

Acompanhe um rollout

Instale o plugin kubectl do Argo Rollouts:

brew install argoproj/tap/kubectl-argo-rollouts
curl -fsSLo kubectl-argo-rollouts https://github.com/argoproj/argo-rollouts/releases/download/v1.10.1/kubectl-argo-rollouts-linux-amd64
chmod +x kubectl-argo-rollouts && sudo mv kubectl-argo-rollouts /usr/local/bin/

Mantenha carga sobre o serviço enquanto uma nova versão é liberada — a análise precisa de requisições para medir — usando o script k6 do lab 10 a uma taxa modesta:

k6 run -e PEAK=40 load/catalog-hpa.js &          # about 11 minutes of traffic through the Ingress
kubectl argo rollouts -n boxoffice get rollout catalog --watch

Qualquer commit em services/catalog na main produz uma nova imagem e um novo rollout; um rápido é um comentário em ChaosFilter.java. Você deve ver as etapas avançarem (SetWeight: 25 → 50 → 100), um AnalysisRun com medições ✔ e, no fim, o novo ReplicaSet marcado como stable.

Uma release ruim

Agora entregue uma release que quebra 30% das requisições, pelo Git. Em k8s/overlays/gitops/kustomization.yaml, adicione mais uma operação ao patch do Deployment (depois da MANAGEMENT_METRICS_DISTRIBUTION_PERCENTILESHISTOGRAM_HTTP_SERVER_REQUESTS), com o seu - op: exatamente embaixo das outras linhas - op: desse patch. Os commits do Deploy reformataram o arquivo (4 espaços, como abaixo); outra indentação quebra o patch, e o Argo CD reporta um ComparisonError em vez de iniciar um canary:

    - op: add                      # lab 11 experiment: a bad release (remove it afterwards)
      path: /spec/template/spec/containers/0/env/-
      value: { name: CHAOS_ERROR_RATE, value: "0.3" }

CHAOS_ERROR_RATE é lida pelo ChaosFilter, que existe só para este experimento:

services/catalog/src/main/java/boxoffice/catalog/ChaosFilter.java
package boxoffice.catalog;

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.concurrent.ThreadLocalRandom;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;

/** Lab 11 only: CHAOS_ERROR_RATE=0.2 makes 20% of the API requests fail, to play a bad release. */
@Component
public class ChaosFilter extends OncePerRequestFilter {

    private final double errorRate;

    public ChaosFilter(@Value("${CHAOS_ERROR_RATE:0}") double errorRate) {
        this.errorRate = errorRate;
    }

    @Override
    protected boolean shouldNotFilter(HttpServletRequest request) {
        return errorRate <= 0 || request.getRequestURI().startsWith("/actuator");
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
            throws ServletException, IOException {
        if (ThreadLocalRandom.current().nextDouble() < errorRate) {
            response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "injected failure");
            return;
        }
        chain.doFilter(request, response);
    }
}

Faça commit, push e acompanhe (mantenha a carga do k6 rodando):

git commit -am "experiment: bad release" && git pull --rebase && git push
kubectl argo rollouts -n boxoffice get rollout catalog --watch

Você deve ver, cerca de um minuto depois do sync do Argo CD, o rollout abortado e o canary reduzido a zero. De uma execução de teste:

Name:            catalog
Namespace:       boxoffice
Status:          ✖ Degraded
Message:         RolloutAborted: Rollout aborted update to revision 2: Background analysis phase error/failed: Metric "error-rate" assessed Failed due to failed (2) > failureLimit (1)
Strategy:        Canary
  Step:          0/4
  SetWeight:     0
  ActualWeight:  0
Images:          boxoffice/catalog:v1 (stable)

(Essa execução de teste usou um boxoffice/catalog:v1 carregado localmente; a sua mostra ghcr.io/<você>/boxoffice-catalog:sha-… como a imagem estável.)

e as medições da métrica error-rate foram [] (ainda sem tráfego no canary: contado como sucesso por len(result) == 0), 0.068 e 0.285 — duas falhas, acima de failureLimit: 1. Os fãs que caíram no canary viram erros por cerca de um minuto; todos os outros nem perceberam.

Degraded é o sinal para corrigir para a frente no Git: reverta o experimento (git revert HEAD && git push), o Argo CD sincroniza, e o próximo rollout começa de um estado limpo.

O que um canary não consegue pegar

  • Migrações de banco rodam uma vez, para as duas versões: precisam ser compatíveis com a versão anterior (expand/contract), porque a versão antiga continua rodando durante um canary — e depois de um canary abortado.
  • Erros raros precisam de muitas requisições no canary para aparecer; com pouco tráfego, alongue as pausas.
  • Efeitos colaterais (pagamentos, e-mails) das requisições do canary são reais.

Próximo: Meça