API 版本、灰度与请求变换:让每组流量可证明地命中目标版本
订单 API 的候选版本只分到少量流量,监控里的成功率也很平稳,发布者却收到两种互相矛盾的反馈:一部分用户始终看不到新版本,另一部分用户在刷新后跨版本跳转。更危险的是,同一个写请求第一次到达旧版本,网关重试时却进入新版本,两个上游各执行了一次副作用。
这不是把权重从 0 改成 10 就能解决的问题。版本选择同时受到 Host、Path、Header、消费者身份、连接复用、缓存、Cookie 和重试影响;请求变换还可能改变签名输入、重定向地址与授权所依据的资源。灰度真正需要交付的是一份版本合同,以及“每组请求为何命中这个版本”的连续证据。
先把版本合同固定在路由之前
v1 和 v2 不是两个 Deployment 名称,而是调用者能观察到的合同。请求方法、路径、Header、身份与签名规则构成输入合同;状态码、响应字段、错误模型、幂等语义和缓存指令构成输出合同。网关可以选择上游、改写路径或补充 Header,却不能把删除字段、改变金额精度、重解释错误码这类破坏性变化伪装成兼容。
上线前应把每个候选变化分成三类:
| 变化 | 网关可承担的动作 | 版本合同要求 |
|---|---|---|
| 新增可选字段、兼容默认值 | 分流后直接透传 | 旧客户端忽略未知字段,新服务接受旧请求 |
| 路径前缀或内部服务名变化 | 受控 Path/Header 变换 | 外部资源身份、签名输入和错误语义不变 |
| 删除字段、类型变化、幂等语义变化 | 发布显式新版本 | 消费者迁移、双版本窗口与退出条件明确 |
正样本是“旧消费者发旧合同,请求落到任一灰度版本都得到兼容结果”;反样本是“靠网关把 v2 响应删字段后冒充 v1”。后者只能遮住表面差异,无法补回字段含义、事务边界或副作用语义。契约测试应在流量发布前完成,网关 smoke test 负责证明路由执行了已批准合同。
版本合同还要写进可审计的发布制品,而不是停留在版本号。每个 revision 至少固定外部资源标识、认证与授权输入、签名 canonicalization、请求/响应 Schema、错误码、幂等键作用域、缓存规则、长连接恢复语义和允许重试的方法;同时记录兼容方向、迁移窗口、退出条件与责任人。只有 v1 请求分别直达 v1、v2 都通过,再让 v2 请求分别直达两端并得到预期的“通过或明确拒绝”,才能说明双向兼容边界已经被测过。
五种分流机制解决不同问题
Host、Path、Header、权重和消费者分组不是五种等价语法。它们决定谁控制版本、选择能否缓存、是否有粘性,以及回滚要清理多少状态。
| 机制 | 适合场景 | 主要代价 |
|---|---|---|
Host,如 v2.api.example.test | 独立域名、证书、客户端配置可控 | DNS、证书、CORS、Cookie Domain 与跳转都要双版本治理 |
Path,如 /v2/orders | 公开且长期并存的 API 版本 | URL 成为合同,SDK、签名、文档和缓存键都要包含版本 |
Header,如 X-API-Version: v2 | 内部试用、自动化回归、短期候选 | 浏览器可见性差,中间缓存必须正确执行 Vary 或纳入自定义键 |
| 权重 | 兼容版本的随机抽样和容量爬坡 | 单个用户不粘,有限样本不能证明精确比例 |
| 消费者分组 | 合作方、租户、员工或设备的稳定 cohort | 必须先建立可信身份,组成员与策略缓存要可撤销 |
消费者分组不能直接相信客户端传来的 X-Canary-Cohort。安全顺序应是:规范化请求,完成认证,把可信消费者映射为内部 cohort,再执行路由选择;进入上游前删除外部同名 Header。若产品会在授权后修改路由输入、清除 route cache 或重新匹配,还要重新验证授权结论仍绑定同一资源,避免“以旧路径授权、按新路径访问”。
权重适合兼容流量,不提供会话粘性。需要稳定 cohort 时,优先使用认证后的消费者 ID 做确定性哈希;只有匿名浏览器场景才考虑短期 Cookie。Cookie 必须签名或使用不可伪造的服务端映射,并限定 Secure、HttpOnly、SameSite、Domain、Path 和有效期。回滚时若只改权重而不使 Cookie 失效,候选流量仍可能持续进入旧目标。
在隔离 Gateway 上运行四类路由
下面实验假设已有兼容 gateway.networking.k8s.io/v1 的 Gateway API 控制器和一个测试 Gateway。操作账号需要在实验命名空间创建 Deployment、Service、HTTPRoute,并获准把 Route 附着到指定 listener;不要在共享生产 Gateway 上直接照抄。先填入实际父级,再创建两个能返回版本标识的上游:
export LAB_NS='gw-canary-lab'
export GATEWAY_NAME='<isolated-gateway-name>'
export GATEWAY_NS='<gateway-namespace>'
export LISTENER_NAME='<http-listener-name>'
kubectl create namespace "${LAB_NS}"
kubectl auth can-i create httproutes.gateway.networking.k8s.io -n "${LAB_NS}"
kubectl get gateway -n "${GATEWAY_NS}" "${GATEWAY_NAME}" -o yamlkubectl apply -f - <<'YAML'
apiVersion: apps/v1
kind: Deployment
metadata: {name: orders-v1, namespace: gw-canary-lab}
spec:
replicas: 2
selector: {matchLabels: {app: orders-v1}}
template:
metadata: {labels: {app: orders-v1}}
spec:
containers:
- name: http
image: busybox:1.36
command: [sh, -c]
args: ["mkdir -p /www/orders /www/v2/orders; echo version=v1 > /www/index.html; echo version=v1 > /www/orders/index.html; echo version=v1 > /www/v2/orders/index.html; exec httpd -f -p 8080 -h /www"]
ports: [{name: http, containerPort: 8080}]
readinessProbe: {httpGet: {path: /, port: http}}
resources:
requests: {cpu: 10m, memory: 16Mi}
limits: {memory: 64Mi}
---
apiVersion: v1
kind: Service
metadata: {name: orders-v1, namespace: gw-canary-lab}
spec:
selector: {app: orders-v1}
ports: [{name: http, port: 80, targetPort: http}]
---
apiVersion: apps/v1
kind: Deployment
metadata: {name: orders-v2, namespace: gw-canary-lab}
spec:
replicas: 2
selector: {matchLabels: {app: orders-v2}}
template:
metadata: {labels: {app: orders-v2}}
spec:
containers:
- name: http
image: busybox:1.36
command: [sh, -c]
args: ["mkdir -p /www/orders /www/v2/orders; echo version=v2 > /www/index.html; echo version=v2 > /www/orders/index.html; echo version=v2 > /www/v2/orders/index.html; exec httpd -f -p 8080 -h /www"]
ports: [{name: http, containerPort: 8080}]
readinessProbe: {httpGet: {path: /, port: http}}
resources:
requests: {cpu: 10m, memory: 16Mi}
limits: {memory: 64Mi}
---
apiVersion: v1
kind: Service
metadata: {name: orders-v2, namespace: gw-canary-lab}
spec:
selector: {app: orders-v2}
ports: [{name: http, port: 80, targetPort: http}]
YAML
kubectl rollout status -n "${LAB_NS}" deploy/orders-v1
kubectl rollout status -n "${LAB_NS}" deploy/orders-v2
kubectl run -n "${LAB_NS}" direct-check --rm -i --restart=Never \
--image=curlimages/curl:8.12.1 -- \
sh -c 'curl -fsS http://orders-v1/; curl -fsS http://orders-v2/'直接访问应分别输出 version=v1、version=v2。两个容器也创建了 /orders/ 与 /v2/orders/ 的静态响应,避免后续真实 Route 请求被静态服务器自己的 404 干扰。实验镜像标签让步骤可执行,团队模板应换成已批准仓库中的固定摘要,并保存镜像扫描证据。
接着发布三条规则:默认 Host 上 /orders 按 9:1 分流;带候选 Header 时稳定进入 v2;显式 /v2/orders 进入 v2。另一个 Host 全量进入 v2。Header 规则同时包含 Path 与 Header,比只含 Path 的默认规则更具体;不要依赖 YAML 排列顺序猜测冲突结果。
cat > /tmp/canary-routes.yaml <<YAML
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: orders
namespace: ${LAB_NS}
annotations:
lab.example/config-revision: canary-r1
spec:
parentRefs:
- name: ${GATEWAY_NAME}
namespace: ${GATEWAY_NS}
sectionName: ${LISTENER_NAME}
hostnames: [api.example.test]
rules:
- matches:
- path: {type: PathPrefix, value: /orders}
headers:
- {name: X-API-Version, type: Exact, value: v2}
backendRefs:
- {name: orders-v2, port: 80}
- matches:
- path: {type: PathPrefix, value: /v2/orders}
backendRefs:
- {name: orders-v2, port: 80}
- matches:
- path: {type: PathPrefix, value: /orders}
backendRefs:
- {name: orders-v1, port: 80, weight: 9}
- {name: orders-v2, port: 80, weight: 1}
---
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: orders-v2-host
namespace: ${LAB_NS}
annotations:
lab.example/config-revision: canary-r1
spec:
parentRefs:
- name: ${GATEWAY_NAME}
namespace: ${GATEWAY_NS}
sectionName: ${LISTENER_NAME}
hostnames: [v2.api.example.test]
rules:
- matches:
- path: {type: PathPrefix, value: /orders}
backendRefs:
- {name: orders-v2, port: 80}
YAML
kubectl apply --server-side --dry-run=server -f /tmp/canary-routes.yaml
kubectl apply -f /tmp/canary-routes.yaml
kubectl get httproute -n "${LAB_NS}" -o yamlkubectl apply 成功只说明 API Server 接收了对象。等待两个 Route 在目标 parent 下对当前 generation 报告 Accepted=True、ResolvedRefs=True,同时确认 Gateway listener 已编程且真实入口可达。Gateway API 不定义统一的数据面 revision 字段;实现若提供配置 hash、代理已加载版本或 xDS dump,应把它与注解中的候选 revision 一起保存。
kubectl get httproute -n "${LAB_NS}" \
-o jsonpath='{range .items[*]}{.metadata.name}{" generation="}{.metadata.generation}{" revision="}{.metadata.annotations.lab\.example/config-revision}{"\n"}{range .status.parents[*].conditions[*]}{" "}{.type}{"="}{.status}{" reason="}{.reason}{" observed="}{.observedGeneration}{"\n"}{end}{end}'若任一目标 parent 的 observedGeneration 小于对象 generation,或者 Accepted、ResolvedRefs 不是 True,先修复 listener 附着、引用或实现支持问题,不进入流量抽样。condition 新鲜仍只证明控制器状态;数据面加载证据和下面的真实响应必须另取。
取得实际地址后执行真实请求:
export GATEWAY_ADDRESS='<reachable-gateway-address>'
curl -fsS -H 'Host: api.example.test' \
-H 'X-API-Version: v2' "http://${GATEWAY_ADDRESS}/orders"
curl -fsS -H 'Host: api.example.test' \
"http://${GATEWAY_ADDRESS}/v2/orders"
curl -fsS -H 'Host: v2.api.example.test' \
"http://${GATEWAY_ADDRESS}/orders"
for i in $(seq 1 200); do
curl -fsS -H 'Host: api.example.test' \
"http://${GATEWAY_ADDRESS}/orders"
done | sort | uniq -c前三个请求都应返回 version=v2。循环应同时出现 v1 与 v2,但 9:1 是每次选择的相对权重,不承诺有限样本恰好得到 180/20。保存样本数、每版本计数、Route generation、数据面 revision 和上游健康,才能区分随机波动、配置未同步与候选实例不健康。
Gateway API 的流量拆分把 weight 定义为同一规则内后端权重的相对比例:省略时默认为 1,0 表示该后端不应接收流量。它既不是跨时间窗的精确百分比,也不提供消费者粘性。更重要的是,生产证据不能只记 selected_version=v2,还要保存一组不会混淆的流量身份:外部 API 版本、可信 cohort、Route generation、数据面 revision、选中的后端版本、下游 request ID 和上游尝试序号。缺少其中任一项,看到 v2 错误率升高时都无法判断是候选代码、旧配置节点、重试跨池还是伪造 Header。
变换必须保持资源、签名和跳转语义
Path 改写发生在签名校验前还是后,会直接决定请求是否有效。若客户端签名包含 method、原始 path、query、Host 或选定 Header,网关改写后再验签就会得到不同 canonical request;先验签再改写,则必须把已验证的外部资源与内部目标绑定,禁止后续插件重新路由到权限更高的资源。
Gateway API 中,URLRewrite 可以修改送往上游的 hostname 或 path,但路径改写属于 Extended 支持,部署前应先查目标实现的一致性报告。HTTPRoute 过滤器定义还规定同一规则不能同时使用 URLRewrite 与 RequestRedirect;多个过滤器的执行效果也不能脱离实现文档推断。下面的规则只把外部 /orders 前缀替换为上游 /internal/orders,并用 set 覆盖客户端可伪造的版本标记,写入网关选出的内部版本:
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: orders-v2-rewrite
namespace: gw-canary-lab
spec:
parentRefs:
- name: <isolated-gateway-name>
namespace: <gateway-namespace>
sectionName: <http-listener-name>
hostnames: [api.example.test]
rules:
- matches:
- path: {type: PathPrefix, value: /orders}
headers:
- {name: X-API-Version, type: Exact, value: v2}
filters:
- type: RequestHeaderModifier
requestHeaderModifier:
set:
- {name: X-Selected-Version, value: v2}
- type: URLRewrite
urlRewrite:
path:
type: ReplacePrefixMatch
replacePrefixMatch: /internal/orders
backendRefs:
- {name: orders-v2, port: 80}先做服务端 dry-run,再观察当前 generation 的 Accepted、ResolvedRefs 和实现给出的不兼容过滤器 reason。正样本 /orders/42 应在上游观察为 /internal/orders/42 且内部版本为 v2;反样本由客户端发送 X-Selected-Version: v1,上游仍只能看到网关写入的 v2。若控制器拒绝该组合,不能把 Accepted=False 当成暂时抖动后强行发布;若实现接受却得不到预期路径,则保存数据面配置和上游实际 path,确认过滤器能力与执行顺序,而不是继续叠加插件补丁。
可执行的反向检查是固定一个测试密钥,对原始请求生成签名,然后分别发送“不改写”和“开启改写”的请求。预期只有双方约定的 canonicalization 路径通过;若两种都通过,检查是否漏签了 path/Host;若两种都失败,比较客户端与验签端记录的 canonical hash,日志中不要写密钥或完整 Authorization。
先用隔离用假密钥证明“改一个签名输入就应得到不同签名”,再把同一组 canonical request 接到目标产品的验签策略;下面只验证输入合同,不代表任何云厂商的签名格式:
export TEST_SIGNING_KEY='lab-only-discard-after-test'
ORIGINAL_SIG="$({ printf 'GET\n/orders\napi.example.test\nx-api-version:v2\n'; } | openssl dgst -sha256 -hmac "${TEST_SIGNING_KEY}" | awk '{print $NF}')"
REWRITTEN_SIG="$({ printf 'GET\n/internal/orders\norders-v2.svc\nx-api-version:v2\n'; } | openssl dgst -sha256 -hmac "${TEST_SIGNING_KEY}" | awk '{print $NF}')"
printf 'original=%s\nrewritten=%s\n' "${ORIGINAL_SIG}" "${REWRITTEN_SIG}"
test "${ORIGINAL_SIG}" != "${REWRITTEN_SIG}"
unset TEST_SIGNING_KEY ORIGINAL_SIG REWRITTEN_SIGtest 应返回 0。接入网关后的正样本使用双方约定的外部 canonical request 并返回成功;反样本复用原签名却改变已签名 Path、Host 或版本 Header,应在上游调用前被拒绝。若改写后仍放行,先确认验签究竟发生在改写前还是后、授权绑定的是外部资源还是内部目标,再决定执行顺序,不能靠同时接受两套 canonicalization 规避迁移。
重定向也不是普通响应变换。Location 里的 scheme、Host、端口和版本前缀必须从受信入口配置生成,不能盲信客户端 Forwarded 或 X-Forwarded-Host。从 v2 重定向到无版本 Host 会丢失 cohort;把 POST 用错误的重定向状态改成 GET 还会改变语义。验证时关闭 curl 自动跟随,先保存第一跳:
curl -sS -D /tmp/headers.txt -o /dev/null \
-H 'Host: v2.api.example.test' "http://${GATEWAY_ADDRESS}/orders/redirect"
sed -n '/^HTTP\|^[Ll]ocation:/p' /tmp/headers.txt只有确认 Location 仍在批准域名与版本合同内,才用 curl -L 验证完整跳转。Header 变换采用允许列表:删除外部伪造的内部身份、cohort 和 revision Header,再由网关写入可信值;不要把完整 JWT、Cookie 或签名材料复制给不需要它的上游。
缓存键和 Cookie 是隐藏的版本路由
同一 URL 若按 Header 或消费者分组进入不同版本,缓存键却只有 Host + Path,首个候选响应就可能污染所有用户。公共缓存至少要包含会改变表示的 API 版本;按消费者变化的响应通常应禁止共享缓存,或使用经过评审的低基数分组键。使用 HTTP 缓存时,响应 Vary 必须与实际选择输入一致,但也要确认 CDN/网关是否支持该 Header,避免高基数 Cookie 把缓存打碎。
一个可重复的反例是依次用 X-API-Version: v2 和无 Header 请求同一 URL,并在中间缓存启用后比较响应中的版本、Age/cache-status 和网关上游命中计数。若第二个请求得到 v2,说明缓存键串版本;若两个消费者互相读到私有数据,则应立即旁路缓存、清除受影响对象并按数据事件处理,而不是继续调权重。
Cookie 粘性需要额外验证四件事:首次响应何时设置、后续请求是否稳定、候选失效时怎样回退、回滚后 Cookie 如何失效。不要让一个永久 Cookie 把用户绑在已删除 upstream;也不要在每次请求随机刷新 Cookie,造成缓存失效与会话抖动。
目标实现启用候选 Cookie 后,可用同一个 jar 重放而不手工伪造 Cookie:
rm -f /tmp/canary-cookie.jar
curl -sS -c /tmp/canary-cookie.jar -H 'Host: api.example.test' \
"http://${GATEWAY_ADDRESS}/orders" -o /tmp/canary-first.txt
for i in $(seq 1 20); do
curl -fsS -b /tmp/canary-cookie.jar -H 'Host: api.example.test' \
"http://${GATEWAY_ADDRESS}/orders"
done | sort | uniq -c同一 jar 的 20 次请求应只出现一个版本;删除 jar 后重新建会话,才允许按当前策略重新选择。反例是回滚权重后旧 jar 仍命中 v2,此时要撤销服务端映射或让已签名 Cookie 到期,并再次重放旧 jar 证明候选不能到达。Cookie 名称、签名方式和故障回退语义属于产品配置影响,发布记录必须保存实际值,不能从“支持粘性”四个字推断。
WebSocket、SSE 与重试改变了抽样单位
HTTP 权重通常在选路时生效。WebSocket 握手成功后,后续消息留在同一连接;SSE 也可能维持很久,所以请求数占比与连接数、消息数、在线用户占比完全不同。扩权重不会搬迁已有连接,回滚也不会自动关闭候选连接。
长连接灰度要分别记录握手版本、活跃连接、连接持续时间、断开原因和每连接消息量。先建立一批 v1 连接,再提高 v2 权重并创建新连接,预期旧连接仍在 v1、新连接按新策略选择。若协议允许重连,客户端应带幂等订阅位置或事件游标;SSE 的 Last-Event-ID、WebSocket 会话状态和候选版本兼容性必须在断线重连实验中验证。
重试则可能重新做负载均衡。若第一次尝试到 v1,第二次允许选 v2,两个版本必须共享幂等合同和数据可见性,否则读请求可能前后不一致,写请求可能重复执行。稳妥做法是把一次下游请求的所有尝试固定到同一版本池,并记录 attempt、selected_version 与最终上游。只有幂等、兼容且经过故障实验的请求才允许跨实例重试;跨版本重试应默认关闭。
反向实验可让候选上游在处理后主动断开,再观察网关是否重试、第二次命中哪个版本、幂等键是否只产生一个业务结果。只看到最终 200 是危险信号,因为它可能掩盖第一次已经提交的副作用。
项目接入把选择依据变成发布制品
真实项目应同时版本化四类内容:OpenAPI/契约测试中的版本合同,Gateway/产品配置中的匹配与权重,消费者到 cohort 的授权映射,以及观测字段与回滚脚本。发布流水线为每次候选生成不可变 revision,并把 route_revision、selected_version、consumer_cohort 和 upstream_attempts 映射到目标产品的日志或 Trace;字段名可以不同,语义不能丢。
CI 先跑两个版本的直连契约测试,再在隔离数据面执行确定性 Host/Path/Header 样本、权重样本和反例。共享环境推广时,每次只改变一个变量:先 100/0 证明候选可寻址,再让可信 cohort 进入,最后才扩大随机权重。缓存策略、Cookie 和重试配置与路由 revision 一起评审,避免流量文件回滚了,隐藏状态仍停在候选版。
机制选型可以按这条顺序收敛:需要长期公开合同时选 Path 或 Host;需要短期内部验证时选认证后 Header;需要稳定人群时选消费者分组或确定性哈希;只有两个版本双向兼容、单请求不要求粘性时才用纯权重。产品不支持所需粘性、缓存键或长连接证据时,不要用插件拼出不可审计的灰度,改由服务发现、Service Mesh 或应用发布平台承担。
放量由停止线驱动,而不是由时间表驱动
每个放量台阶都应把稳定版和候选版放在同一个业务窗口、同一消费者层级和同一请求类型中比较。候选 5xx 增量、业务失败增量、p95/p99 延迟增量、重试放大倍数、连接池饱和、缓存命中下降和资源余量分别设阈值;窗口还要同时满足最小请求数与最小时长,避免十几个请求全成功就进入下一档。低流量接口改用可信 cohort 的逐笔结果、合成交易和关键不变量,不能照搬高流量接口的百分比。
停止线分两级。出现越权、数据错写、重复副作用、跨租户缓存、签名绕过或回滚入口失效时,不等统计显著性,立即冻结变更并把流量恢复到稳定版。性能与可用性采用预先约定的连续窗口,例如候选 5xx 相对稳定版增加超过 0.5 个百分点且至少新增 20 次失败,或 p99 连续三个五分钟窗口恶化超过 20%;这些数字必须由服务 SLO、基线波动和错误预算推导,示例值不能跨服务照抄。
每次判断都保存分子、分母、稳定版基线、候选结果、窗口起止、Route generation、数据面 revision 和决策动作。若指标只按 route 聚合而没有 selected_version,停止线没有能力区分两个版本;若把消费者 ID 放进指标标签,时序基数又会失控。正确做法是指标使用受控的 version/cohort 枚举,单请求身份留在限期日志或 Trace 中。停止后先禁止自动继续放量,再执行后面的完整回滚;仅把权重改回 100/0,不能证明隐藏状态已经退出。
回滚要撤销所有版本状态
准备回滚时先冻结 cohort 和权重变更,保存当前 Route、策略、缓存配置与数据面 revision。随后把默认流量恢复到已知稳定版本,但保留候选实例用于只读取证;等待目标 generation 的状态新鲜、每个数据面节点加载旧 revision,再用 Host/Path/Header 的正反请求证明候选入口已经关闭。
接着处理隐藏状态:失效或缩短候选 Cookie,清除按错误键生成的缓存,撤销消费者 cohort,停止跨版本重试,检查重定向不再导向候选 Host,等待或受控终止 WebSocket/SSE 连接。只有候选请求计数归零、旧版本容量恢复、业务副作用核对完成,才缩容并删除 v2。
实验环境可精确清理:
kubectl delete -f /tmp/canary-routes.yaml --ignore-not-found
kubectl delete namespace "${LAB_NS}" --wait=true
rm -f /tmp/canary-routes.yaml /tmp/headers.txt
rm -f /tmp/canary-cookie.jar /tmp/canary-first.txt
kubectl get httproute -A | grep gw-canary-lab || true
kubectl get namespace "${LAB_NS}" 2>/dev/null || true共享 Gateway、控制器和 CRD 不属于本实验,不应删除。若测试创建了公网负载均衡器、DNS、证书、缓存对象、日志索引或 Trace 数据,还要从各自资源入口核对删除和费用停止,不能用 namespace 消失代替退出证明。
容量与成本随灰度维度一起增长
双版本并行至少需要两套上游余量;故障时稳定版本还要能接回全部流量。权重样本越少,统计置信越弱;样本越多,日志、Trace 和业务风险越高。容量模型应同时计入 TLS 握手、长连接、缓存碎片、策略调用、请求/响应变换缓冲、重试放大和观测写入,不只计算 RPS。
消费者 ID、request ID 和原始 Cookie 不能做指标标签,否则时序数量会随用户增长。指标使用 route、version、cohort、状态码类别和 failure reason 等受控低基数维度;单请求细节进入采样 Trace 或限期日志。缓存按版本拆键会降低命中率并增加存储,双 Host 会增加证书、DNS 与 CDN 配置,双版本会增加测试、值守和回滚窗口,这些都属于发布成本。
团队至少要明确 API 合同 owner、Gateway 配置 owner、消费者 cohort owner、缓存 owner 和业务回滚 owner。灰度完成不是把候选权重调到全量,而是旧版本消费者已迁移、旧路由和凭证可撤销、缓存与长连接没有残留,并且下一次变更仍能从同一套实验重新证明。
放量前再核对一次证据
Host、Path、Header、权重和消费者分组各有明确控制者与命中证据。v1、v2 的请求、响应、错误和幂等合同已经做双向兼容实验。缓存键、Vary、Cookie、重定向和签名 canonicalization 与版本选择一致。
WebSocket、SSE 的握手版本、活跃连接和重连语义可观察。一次下游请求的重试不会跨版本重复副作用。回滚同时撤销 Route、cohort、Cookie、缓存、重试和候选长连接。
容量预算覆盖稳定版本接回全量、重试放大、观测存储和双版本成本。
