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):
# 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:
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:
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.