容器化改造完成之后,很多团队会发现一个尴尬的问题:以前在Nginx配置文件里写死 upstream 权重就能完成的A/B测试,现在完全行不通了。Kubernetes集群里Pod的IP随时变化,副本数量随着弹性伸缩不断波动,请求到达哪个实例根本不受你控制。流量分发这个看似简单的问题,在容器化架构下需要重新设计。本文将围绕几种主流方案展开,从原理到配置逐一拆解。

一、为什么容器环境下的流量分发需要专门设计
在传统部署模式中,一台服务器就是一个固定的分流单元,运维人员在负载均衡器上给不同机器配置不同权重,就能实现按比例分流。而容器化架构彻底改变了这个前提。Pod是有生命周期的,一次滚动更新、一次HPA自动扩容,都会让原有的实例集合发生变化。
这带来两个直接后果。第一,分流规则不能绑定到具体IP或实例,只能依赖请求本身的属性,比如用户ID、Cookie、Header或者URL参数。第二,分流必须与发布体系解耦,因为实验组的代码版本和对照组可能相同也可能不同,如果分流逻辑耦合在部署脚本里,实验配置的每次调整都要触发一次发布,效率和风险都不可接受。
另外还要考虑一致性问题。同一位用户在实验周期内应该始终落入同一个分组,否则用户体验会割裂,实验数据也会被污染。这就要求分流算法具备确定性——相同输入永远得到相同输出,这正是后文反复出现的哈希方案的出发点。
二、基于Ingress注解的轻量分流方案
如果你的集群使用Nginx Ingress Controller,最简单的做法是利用其原生的Canary能力。Nginx Ingress提供了nginx.ingress.kubernetes.io/canary系列注解,可以在入口层按权重、按Header、按Cookie三个维度把流量切分到不同的Service。
按Header分流的典型配置如下:
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: ab-test-header
annotations:
nginx.ingress.kubernetes.io/canary: "true"
# 命中 x-env=beta 这个Header的请求进入实验组
nginx.ingress.kubernetes.io/canary-by-header: "x-env"
nginx.ingress.kubernetes.io/canary-by-header-value: "beta"
spec:
rules:
- host: shop.ippipp.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: app-v2
port:
number: 80
如果需要按比例随机分流,把canary-by-header换成canary-weight: "10"即可,表示10%的流量进入实验组。这种方式的优点是零代码侵入,配置即生效,非常适合快速验证性的实验。缺点也比较明显:权重分配依赖Nginx内部的随机算法,不做会话保持,同一用户可能一会儿看到新版一会儿看到旧版;而且Header分流要求上游有办法注入Header,通常还需要配合网关或前端SDK来标记用户。
因此在实践中,纯Ingress方案适合发布前的灰度验证,而不适合严格的科学实验。真正的A/B测试需要把分流决策权掌握在自己手里。
三、服务网格Istio的精细流量控制
当实验需求复杂起来,比如需要按用户地域、设备类型、会员等级等多个维度组合分流,服务网格就显示出优势了。Istio通过VirtualService和DestinationRule两个资源,把流量规则从应用代码中彻底剥离出来。
看一个按用户ID尾号分流的例子:
apiVersion: networking.istio.io/v1beta1
kind: VirtualService
metadata:
name: app-ab-split
spec:
hosts:
- app.prod.svc.cluster.local
http:
- match:
# 用户ID以3结尾的进入实验组
- headers:
x-user-id:
regex: ".*3"
route:
- destination:
host: app-v2.prod.svc.cluster.local
- route:
- destination:
host: app-v1.prod.svc.cluster.local
这段配置把用户ID以特定字符结尾的请求全部路由到v2版本,其余走v1。因为判断依据是用户ID这种稳定属性,天然具备会话一致性,不需要额外粘滞处理。此外Istio还支持按百分比权重分流、故障注入、镜像流量等高级能力,可以满足绝大多数实验场景。
不过Istio的代价不小:引入Sidecar会带来一定的请求延迟和资源开销,运维复杂度也上了一个台阶。中小团队如果只是做常规实验,上全家桶可能得不偿失。折中做法是只在入口网关层启用Istio的流量能力,内部服务保持普通Kubernetes Service通信。
四、应用层实验平台与分流SDK
第三条路线是把分流逻辑下沉到业务代码内部,通过统一的实验平台SDK完成。其核心原理很简单:对用户唯一标识做哈希运算,将结果映射到0到9999的桶区间,再根据实验配置判断该桶属于哪个分组。
public String getExperimentGroup(String userId, String experimentId) {
// 对实验ID和用户ID拼接后取哈希,保证不同实验之间互不影响
String key = experimentId + "_" + userId;
int bucket = Math.abs(key.hashCode()) % 10000;
// 配置:0-8999为对照组,9000-9999为实验组,即10%放量
if (bucket >= 9000) {
return "treatment";
}
return "control";
}
这种方案的好处是分流粒度极细,可以精确到接口甚至页面组件级别,实验指标(点击率、转化率、停留时长)的采集也天然与应用埋点体系融合。而且分流发生请求到达之前,不依赖任何基础设施组件,容器怎么扩缩容都无所谓。
需要注意两点。一是hashCode在Java中对同一字符串的结果是稳定的,但如果实验ID拼写有出入,哈希结果会完全改变,导致用户分组漂移,所以实验ID必须纳入严格管理。二是样本比偏差问题:哈希分桶后各分组比例可能偏离理论值,放量前应该用线上流量做回放验证,确认实际比例误差在可接受范围内,必要时对桶分配做微调。
五、几个必须提前想清楚的坑
第一个坑是缓存污染。如果实验页面被CDN或网关缓存,不同分组的用户可能拿到同一份缓存内容。解决思路是把分组标识写入缓存Key,或者在实验路径上显式禁用缓存。
第二个坑是日志归因。无论采用哪种方案,务必把分组结果写到访问日志或埋点事件里,下游分析时才能按分组聚合。最好的做法是分流组件统一输出一个标准字段,例如x-ab-group,贯穿整条数据链路。
第三个坑是实验叠加。多个实验同时运行时,如果都直接对用户ID取模,实验之间会互相干扰。标准做法是分层正交实验设计:不同层的实验使用不同的哈希盐值,保证层与层之间的分组相互独立,这也是Google多年前公开的Overlapping Experiment Infrastructure的核心思想。
综合来看,Ingress注解适合轻量灰度,Istio适合多维度复杂分流,应用层SDK适合精细实验与指标分析,三者并不互斥,完全可以按需组合。选型的关键不是哪个技术更新,而是你的实验频率、团队规模和指标体系处在什么阶段,从简单方案起步、随业务增长逐步演进,往往是更稳妥的路径。