Kubernetes 蓝绿部署:Service Selector 切换的细节比想象多

发布时间:2026/7/8 14:46:54
Kubernetes 蓝绿部署:Service Selector 切换的细节比想象多 Kubernetes 蓝绿部署Service Selector 切换的细节比想象多一、一次已经切过去了的线上事故某个周三下午团队按计划做模型推理服务的版本更新。部署脚本跑完新版本的 Pod 已经 Runningkubectl 里看着一切正常。就在准备点外卖的时候监控告警响了。400 和 500 交替出现持续了大概三分钟。不是全量报错大概有 12% 的请求失败。那三分钟里Service 的 Endpoints 里同时挂了两组 Pod——旧版本和新版本。问题的核心我们把蓝绿部署理解成了先建新 Deployment 再删旧的但忽略了 Service Selector 切换那个窗口里发生的事情。基础设施不需要漂亮话它需要把每一毫秒的状态转换想清楚。二、Service Selector 切换的四种时序蓝绿部署的本质操作就两步一是让 Service 选中新 Pod二是让 Service 放弃旧 Pod。但这两步的顺序决定了中间状态的形状。sequenceDiagram participant U as 用户流量 participant S as Service participant B as 蓝版Pod(旧) participant G as 绿版Pod(新) Note over B,G: 阶段1: 仅蓝版运行 U-S: 请求 S-B: 转发 Note over B,G: 阶段2: 蓝绿并存(危险窗口) U-S: 请求 S-B: 可能转发到旧版 S-G: 可能转发到新版 Note over B,G: 阶段3: 仅绿版运行 U-S: 请求 S-G: 转发四种时序对应的策略如下策略一先改 Selector 再扩容绿版。这是最危险的。绿版 Pod 还没就绪Service 已经找不到任何 Pod所有请求 503。策略二先扩容绿版再改 Selector。这是常见方案。绿版 Pod 就绪后修改 Service Selector 指向绿版。问题是Selector 修改是原子操作吗答案是从 Kubernetes 1.22 以后Service 的 Endpoints 更新依赖 EndpointSlice 控制器。控制器监控 Pod 标签变化再更新 Endpoints 列表。这个过程中约 0.5 到 2 秒Service 的 Endpoints 列表是空的——旧 Pod 标签已不匹配新 Pod 的 EndpointSlice 还没刷出来。策略三同时修改蓝版和绿版标签使其共存后逐步切换。这引入了一个更危险的场景——ELB 或 NodePort 里同时有新旧两版 Pod请求会被随机分发。需要应用层确保向后兼容。策略四基于 Service Name 切换。创建两个独立的 Servicemodel-svc-blue和model-svc-green通过 Ingress 或 API Gateway 的 upstream 切换来指向不同 Service。这避免了 Selector 原子性问题但需要在 Ingress 层做流量切换编排。三、生产级蓝绿部署编排下面的实现使用策略四两套 Service 互备通过批量 Pod 标签切换实现原子操作。package deploy import ( context fmt time metav1 k8s.io/apimachinery/pkg/apis/meta/v1 k8s.io/client-go/kubernetes ) const ( labelVersion app.kubernetes.io/version labelColor deploy.myteam.io/color ) // BlueGreenDeploy 管理蓝绿部署状态 type BlueGreenDeploy struct { clientset *kubernetes.Clientset namespace string appName string blueSvc string // 如 model-svc-blue greenSvc string // 如 model-svc-green } // SwitchTraffic 执行流量切换 // 返回旧的 active color用于回滚 func (d *BlueGreenDeploy) SwitchTraffic(ctx context.Context, targetColor string) (string, error) { // 1. 确认目标颜色的 Pod 所有副本都已就绪 targetPods, err : d.clientset.CoreV1().Pods(d.namespace).List(ctx, metav1.ListOptions{ LabelSelector: fmt.Sprintf(app%s,%s%s, d.appName, labelColor, targetColor), }) if err ! nil { return , fmt.Errorf(list target pods: %w, err) } readyCount : 0 for _, pod : range targetPods.Items { for _, cond : range pod.Status.Conditions { if cond.Type Ready cond.Status True { readyCount } } } if readyCount ! len(targetPods.Items) { return , fmt.Errorf(not all target pods ready: %d/%d, readyCount, len(targetPods.Items)) } // 2. 找出当前 active 的 color currentColor, err : d.getActiveColor(ctx) if err ! nil { return , fmt.Errorf(get active color: %w, err) } // 3. 原子更新 Ingress backend 指向目标 Service activeSvcName : d.appName -svc- targetColor if err : d.updateIngressBackend(ctx, activeSvcName); err ! nil { return , fmt.Errorf(update ingress backend: %w, err) } // 4. 等待旧 Pod 的存量连接排空 time.Sleep(5 * time.Second) // 5. 旧版本 Pod 保持 Running 状态不立即删除 // 保留 10 分钟用于回滚回滚只需重新指向旧 Service return currentColor, nil } // getActiveColor 通过 Ingress backend 判断当前 active 的 Service func (d *BlueGreenDeploy) getActiveColor(ctx context.Context) (string, error) { ing, err : d.clientset.NetworkingV1().Ingresses(d.namespace).Get(ctx, d.appName, metav1.GetOptions{}) if err ! nil { return , fmt.Errorf(get ingress: %w, err) } for _, rule : range ing.Spec.Rules { for _, path : range rule.HTTP.Paths { svcName : path.Backend.Service.Name if svcName d.blueSvc { return blue, nil } if svcName d.greenSvc { return green, nil } } } return , fmt.Errorf(cannot determine active color from ingress) } // Rollback 回滚到指定 color 的 Service func (d *BlueGreenDeploy) Rollback(ctx context.Context, color string) error { svcName : d.appName -svc- color return d.updateIngressBackend(ctx, svcName) } func (d *BlueGreenDeploy) updateIngressBackend(ctx context.Context, svcName string) error { ing, err : d.clientset.NetworkingV1().Ingresses(d.namespace).Get(ctx, d.appName, metav1.GetOptions{}) if err ! nil { return fmt.Errorf(get ingress: %w, err) } for i : range ing.Spec.Rules { for j : range ing.Spec.Rules[i].HTTP.Paths { ing.Spec.Rules[i].HTTP.Paths[j].Backend.Service.Name svcName } } _, err d.clientset.NetworkingV1().Ingresses(d.namespace).Update(ctx, ing, metav1.UpdateOptions{}) if err ! nil { return fmt.Errorf(update ingress: %w, err) } return nil }关键设计决策解释用两套独立的 Service 而不是一套 Service 改 Selector。避免了 Selector 切换时的 EndpointSlice 更新延迟。Ingress 的 backend 切换是原子更新不存在中间态。旧版本 Pod 不立即删除。保留 10 分钟的缓冲期回滚只需重新指向旧 Service比重新拉镜像快得多。通过 Pod Condition 而非 Phase 判断就绪。Phase 为 Running 不代表 Pod 可以接收流量必须检查 Readiness Probe 的结果。四、边界场景与注意事项零停机的时间窗口计算。Ingress backend 切换是原子操作但 Nginx Ingress 的配置重载有 1-3 秒延迟。加上旧 Pod 的 TCP 连接需要排空实际切换过程需要 5-10 秒的观察期。灰度场景的变体。如果需要按比例切流量如 10% 到新版本可以引入两套 Ingress 规则通过 weight annotation 控制流量比例。这比单 Service 多 Selector 的方式更可控。长连接与 WebSocket。如果服务使用 WebSocket旧 Pod 的存量连接不能通过简单的 sleep 等待排空。需要引入preStophook 加terminationGracePeriodSeconds的机制让 Pod 在收到 SIGTERM 后主动通知客户端重连。蓝绿 vs 金丝雀的选择。蓝绿部署适合一次性切换灰度发布适合逐步放量。如果业务不允许同时存在两个版本比如数据库 schema 不兼容蓝绿部署时需要先禁写、执行迁移、再切换流量、最后恢复写。这个过程比单纯的 Service 切换复杂一个数量级。StatefulSet 不适合这套方案。StatefulSet 的 Pod 名称是固定的如model-0不会因为 Deployment 重建而改变。有状态服务建议使用 Operator 方案管理蓝绿切换。五、总结Kubernetes 蓝绿部署的核心陷阱在于 Service Selector 切换不是原子操作。两套独立 Service 配合 Ingress backend 切换的方案可以避开这个坑。生产落地时需要关注目标 Pod 必须通过 Readiness Probe 验证后才切换旧 Pod 保留缓冲期不立即删除WebSocket 长连接需要 preStop hook 处理有状态服务需要额外的 schema 迁移编排。蓝绿部署适合全量切换场景如需逐步放量应考虑金丝雀发布。