我的 GitOps 金丝雀发布实践:Argo Rollouts 与 Gateway API
2025-11-27

还记得我之前写的 k8s-gitops 项目吗?那个项目帮我把 Homelab 的基础设施代码化了,ArgoCD 一跑,集群状态就和 Git 仓库保持一致。"怎么部署"这个问题算是解决了。

但是用了几个月之后,一个新的焦虑冒出来了:怎么安全地更新?

每次改了代码推上去,ArgoCD 自动同步,新版本直接全量上线。我的心情可以用四个字概括——听天由命。万一代码有 Bug?万一配置写错了?所有用户(虽然主要是我的家人和朋友)瞬间受到影响,想回滚都要手忙脚乱操作一番。

这让我开始想:能不能像大厂那样,先给一小部分流量看新版本,确认没问题了再全量推广?

于是就有了这个项目:canary-deployment

全量发布的问题

先说说"大爆炸"发布为什么让人不踏实。

传统的 Kubernetes Deployment 滚动更新,虽然不是瞬间切换,但新 Pod 一 Ready 就开始接流量,整个过程只有几十秒。如果新版本有问题,你发现的时候,可能已经有大量请求打到了有问题的 Pod 上。

更要命的是回滚得靠你自己去敲 kubectl rollout undo,或者 ArgoCD 手动 Sync 回上一个版本。真出事手忙脚乱的时候,谁能保证不敲错?

我想要的是这样一个流程:

graph LR
    A[Git Push] --> B[ArgoCD Sync]
    B --> C[创建 Canary Pod]
    C --> D[切 20% 流量]
    D --> E{指标分析}
    E -->|正常| F[切 50% 流量]
    F --> G{指标分析}
    G -->|正常| H[全量上线]
    E -->|异常| I[自动回滚]
    G -->|异常| I

    style A fill:#DBEAFE,stroke:#2563EB,color:#1E40AF
    style B fill:#DBEAFE,stroke:#2563EB,color:#1E40AF
    style C fill:#D1FAE5,stroke:#059669,color:#065F46
    style D fill:#D1FAE5,stroke:#059669,color:#065F46
    style F fill:#D1FAE5,stroke:#059669,color:#065F46
    style H fill:#D1FAE5,stroke:#059669,color:#065F46
    style E fill:#FEF3C7,stroke:#D97706,color:#92400E
    style G fill:#FEF3C7,stroke:#D97706,color:#92400E
    style I fill:#FEE2E2,stroke:#DC2626,color:#991B1B

代码推上去,系统自己走完试探、观察、推进或止损这一圈,不用人盯着。这才是 GitOps 该有的样子。

技术选型

要实现上面的流程,得有三个核心组件配合:

Argo Rollouts,渐进式交付控制器。它扩展了 Kubernetes 原生的 Deployment,提供了 Rollout 资源,让你可以定义金丝雀或蓝绿发布策略。说白了,它就是那个指挥"先切多少流量、什么时候推进、什么时候回滚"的大脑。

Gateway API + Traefik,流量切分的执行者。Gateway API 是 Kubernetes 网络层的新标准(你可以理解为 Ingress 的继任者),它的 HTTPRoute 资源原生支持 weight 字段来做流量分配。Traefik 作为 Gateway Controller 负责实际执行。和以前 Ingress 时代各家靠私有注解实现流量切分不同,Gateway API 是标准化的,换控制器也不用改配置。

Prometheus,指标采集和查询。它要回答的就一个问题:"新版本到底行不行?" Argo Rollouts 会定期查 Prometheus,根据数据决定继续推进还是回滚。

说白了,Rollouts 拿主意,Gateway API 干活,Prometheus 提供判断依据。

核心实现

Rollout:重新定义"发布"

在 Argo Rollouts 里,不再用 Deployment,改用 Rollout 资源。它长得和 Deployment 几乎一样,关键区别是多了个 strategy 字段:

apiVersion: argoproj.io/v1alpha1
kind: Rollout
metadata:
  name: demo-app
spec:
  replicas: 3
  selector:
    matchLabels:
      app: demo-app
  strategy:
    canary:
      stableService: demo-app-stable
      canaryService: demo-app-canary
      trafficRouting:
        plugins:
          argoproj-labs/gatewayapi:
            httpRoute: demo-app-route
            namespace: default
      steps:
        - setWeight: 20
        - pause: {duration: 30s}
        - setWeight: 50
        - pause: {duration: 30s}
        - setWeight: 100
  template:
    metadata:
      labels:
        app: demo-app
    spec:
      containers:
        - name: demo-app
          image: demo-app:v2
          ports:
            - containerPort: 8080

这段配置定义了一个三步走的发布策略:

  1. 镜像更新后,先创建新版本 Pod,把 20% 的流量切过去
  2. 暂停 30 秒,这段时间 Prometheus 在持续采集指标
  3. 指标正常的话,流量升到 50%,再观察 30 秒
  4. 最后全量上线

注意这里有两个 Service:demo-app-stable 指向旧版本 Pod,demo-app-canary 指向新版本 Pod。Argo Rollouts 通过修改 HTTPRoute 里的 weight 来控制流量在两者之间的分配比例。

AnalysisTemplate:让数据说话

光有流量切分只是第一步。真正的关键是:谁来决定"继续"还是"回滚"?

靠人一直盯着 Grafana?不现实,人总要睡觉,也总有看走眼的时候。

AnalysisTemplate 就是 Argo Rollouts 的自动裁判。我定义了一个基于成功率的检查:

apiVersion: argoproj.io/v1alpha1
kind: AnalysisTemplate
metadata:
  name: success-rate
spec:
  metrics:
    - name: success-rate
      interval: 1m
      count: 3
      successCondition: result[0] >= 0.99
      failureLimit: 1
      provider:
        prometheus:
          address: http://prometheus-server.prometheus-system.svc.cluster.local:80
          query: |
            sum(rate(http_requests_total{status!~"5.*"}[1m]))
            /
            sum(rate(http_requests_total[1m]))

这个模板告诉 Argo Rollouts:

然后在 Rollout 的 steps 里引用它:

steps:
  - setWeight: 20
  - analysis:
      templates:
        - templateName: success-rate
  - setWeight: 50
  - pause: {duration: 30s}
  - setWeight: 100

这样,流量切到 20% 之后,Argo Rollouts 会自动运行分析。分析通过才继续推进,失败就自动回滚。全程不需要人工干预。

Gateway API:流量切分的标准化

以前用 Ingress 做金丝雀,Nginx 要写 nginx.ingress.kubernetes.io/canary-weight 注解,Traefik 又是另一套写法。换个 Ingress Controller 就得改一圈配置,痛苦不堪。

Gateway API 把流量切分变成了一等公民。看看 HTTPRoute 的配置,简洁明了:

apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
  name: demo-app-route
spec:
  parentRefs:
    - name: traefik-gateway
  hostnames:
    - "demo.homelab.local"
  rules:
    - matches:
        - path:
            type: PathPrefix
            value: /
      backendRefs:
        - name: demo-app-stable
          port: 80
          weight: 100
        - name: demo-app-canary
          port: 80
          weight: 0

初始状态 stable 100%、canary 0%。发布时 Argo Rollouts 自动修改 weight 值,Traefik 实时生效。这是标准 API,不是注解黑魔法。

演示:看系统自己干活

为了验证这套系统真的能自动止损,我在 Demo App 里加了个故障注入功能。Helm Values 里设置 errorRate: 50,模拟新版本有 50% 概率返回 500 错误。

发布过程是这样的:

  1. Git Push:修改镜像 tag,推送到仓库
  2. ArgoCD Sync:ArgoCD 检测到 Git 变化,同步新的 Rollout 配置到集群
  3. 金丝雀启动:Argo Rollouts 创建新版本 Pod,修改 HTTPRoute 将 20% 流量导向新 Pod
  4. 分析运行:AnalysisTemplate 开始查询 Prometheus,发现成功率只有 50% 左右
  5. 自动回滚:分析判定失败,Argo Rollouts 立即将 HTTPRoute weight 切回 stable 100%,新版本标记为 Degraded

整个过程大概两三分钟,不需要任何人工操作。影响范围也被控制在了 20% 的流量内,而且持续时间很短。

对比一下全量发布:100% 的用户受影响,持续到你手动回滚为止。差距一目了然。

踩过的坑

实话说,搭建过程并不顺利。记录几个让我折腾了比较久的问题:

Gateway API CRD 版本对齐

Traefik 对 Gateway API 的支持在快速迭代中,不同版本的 Traefik 支持的 Gateway API CRD 版本不一样。我一开始装了最新的 CRD,结果 Traefik 不认识,报了一堆看不懂的错。

解决方法:查 Traefik 的 Release Notes,确认它支持的 Gateway API 版本,然后安装对应版本的 CRD。别贪新。

PromQL 空值陷阱

这个坑最隐蔽。当新版本刚启动、还没有流量的时候,PromQL 查询返回的是空值(不是 0,是真的没有数据)。Argo Rollouts 拿到空值后会判定分析失败,直接回滚。

也就是说,你的新版本还没接到第一个请求,就被回滚了。

解决方法是在 PromQL 里加兜底逻辑:

sum(rate(http_requests_total{status!~"5.*"}[1m]))
/
sum(rate(http_requests_total[1m]))
or vector(1)

or vector(1) 的意思是:如果前面的查询返回空值,就用 1(100% 成功率)代替。

本地资源消耗

在本地 Kind 集群里跑 Traefik + ArgoCD + Argo Rollouts + Prometheus + Demo App,光 idle 状态就要吃掉 4-5GB 内存。建议给 Docker Desktop 分配至少 8GB 内存,否则 Pod 会因为 OOMKill 不停重启,你还以为是配置问题。

最后

搭完这套,我的 Homelab 发布流程从"推代码然后祈祷"变成了"推代码然后喝茶"。

ArgoCD 让集群状态跟着 Git 走,Argo Rollouts 把全量发布换成了渐进式加自动回滚,Prometheus 则让"行不行"这件事由数据来定,而不是靠我拍脑袋。三个凑一起,才是我想要的那套发布方式。

如果你也受够了全量发布的提心吊胆,可以来我的仓库看看:

项目地址canary-deployment