灰度发布
本文范围
先说一个线上发布最常见的场景:你手里有一个新版本 order-api:v2,测试环境已经测过,老板也催着上线,但你心里知道,只要 100% 流量一下子打过去,出了问题就只能全员救火。
灰度发布要解决的就是这个问题:新版本先接一点点真实流量,能看日志、能看指标、能继续放量,也能马上切回去。这里不讲漂亮的大平台,先把架构师和运维能落地的打法讲清楚。
这篇文章会带着你做这些事:
- 先分清蓝绿、滚动、金丝雀、按比例灰度、按用户/租户/区域灰度。
- 再用 Nginx 做一个最小灰度,把 1% 流量打到新版本。
- 再看 K8S 里 Deployment 能做什么,为什么它不等于完整金丝雀。
- 接着看 Ingress、Gateway、业务网关、配置开关怎么控制流量和功能。
- 最后补上数据库兼容、监控指标、回滚触发条件、发布记录和止血流程。
本文不展开完整 DevOps 平台建设,不展开服务网格深水区,也不把 K8S、Nginx、Jenkins、Prometheus、数据库迁移工具写成完整教程。需要记住的边界是:这里讲发布策略和落地动作,不讲一个公司从零搭建研发平台。
示例默认三类环境:
- 普通 Linux 服务器:Nginx 或 OpenResty 作为入口,后端服务运行在物理机、虚拟机或容器中。
- K8S 环境:使用 Deployment、Service、Ingress、Gateway API 或渐进发布控制器。
- 中国大陆公网、企业内网、离线环境:不能默认 Docker Hub、GitHub、国外镜像仓库、云产品、外部 SaaS 监控都可访问,要准备私有镜像仓库、离线包、配置仓库、制品仓库和自建监控。
本文示例里的服务名统一用 order-api,稳定版本用 v1,灰度版本用 v2。如果你照着做,把下面几个占位符先换成自己的:
order.example.com # 你的业务域名
10.0.10.11:8080 # 旧版本实例
10.0.10.21:8080 # 新版本实例
harbor.internal/app/order-api:v2 # 你的镜像地址
prod # 你的 K8S namespace前置条件
做灰度发布前,建议先确认这些底座已经具备:
- 每个发布制品都有不可变版本号,例如镜像 tag、jar 包校验值、Git commit 和配置版本。
- 稳定版本和灰度版本能同时运行,入口层能把流量切到不同后端。
- 服务至少有健康检查、版本响应头或日志字段,能区分请求命中了
v1还是v2。 - 监控能按 release、实例、租户、接口维度看错误率、延迟、流量和核心业务指标。
- 回滚动作已经演练过:切流、关闭功能开关、回滚配置、回滚 Deployment 或恢复旧制品。
- 数据库、缓存、MQ、第三方接口的兼容边界已经评审;破坏性数据变更不能和应用灰度绑成一步。
先把几种发布策略分清楚
先别急着写配置。发布策略如果选错,命令写得再漂亮也容易翻车。
一般可以这么理解:
| 策略 | 做法 | 优点 | 风险和边界 |
|---|---|---|---|
| 滚动发布 | 一批机器或 Pod 逐步替换成新版本 | 成本低,不需要双倍资源 | 全量滚完后再发现问题,回滚也要逐步滚,速度慢 |
| 蓝绿发布 | 保留蓝、绿两套环境,流量一次或分批切换 | 回滚快,只要把流量切回旧环境 | 资源成本高,数据库和外部依赖必须兼容 |
| 金丝雀发布 | 先让极少量真实流量进入新版本,再逐步扩大 | 能在真实环境提前暴露问题 | 需要流量切分、监控对比和回滚机制 |
| 按比例灰度 | 1%、5%、10%、50%、100% 逐步放量 | 适合接口、服务、小功能上线 | 如果分桶不稳定,同一用户可能来回跳版本 |
| 按用户/租户/区域灰度 | 按 UserID、租户、城市、设备、版本等规则命中 | 适合 B 端租户、区域试点、白名单验证 | 需要全链路透传灰度标记 |
| 配置开关 | 新逻辑已上线,但通过配置控制是否启用 | 回滚快,不一定需要重新发布 | 只解决业务逻辑开关,不等同于流量切换 |
| A/B 测试 | 多个方案并行对比业务指标 | 用数据比较哪个方案更好 | 它更偏业务实验,不是单纯发布稳定性验证 |
这里要注意,很多团队嘴上说“灰度”,实际做的是“滚动发布”。比如 10 台机器先发 1 台,看起来像 10% 灰度,但它不是按用户或租户稳定命中,只是实例数量上的粗略控制。
选型时可以按下面这个经验来:
- 小改动、低状态变更:先金丝雀或按比例灰度,验证通过后滚动全量。
- 大架构改造、存储改造、强状态变更:优先蓝绿或影子验证,保留快速切回旧环境的能力。
- 新功能不确定是否启用:用配置开关,不要把所有风险都压在重新部署上。
- 需要用户长期稳定命中同一版本:用用户、租户或设备做稳定分桶,不要只靠随机数。
灰度发布的最小闭环
先搭一个最小闭环,不要一上来就想做平台。一个能用的灰度发布,至少要做到下面几件事:
- 制品版本:每次发布都有明确镜像 tag、jar 包版本、Git commit、配置版本。
- 新旧版本并存:旧版本还在服务,新版本先承接小流量。
- 流量规则:能按比例、Header、Cookie、用户、租户、区域或白名单切分。
- 健康检查:新版本未就绪时不能接流量,异常时能摘除。
- 监控对比:新旧版本的错误率、延迟、资源、业务指标能分开看。
- 回滚入口:能快速把灰度流量切回旧版本,必要时回滚代码或配置。
- 发布记录:记录谁、何时、发布什么、放量多少、验证什么、是否回滚。
发布前先做一件小事:把版本号打进响应头、日志和指标标签。这个动作不复杂,但线上排障时非常有用。否则你看到 500 错误,只知道 order-api 报错,却不知道是旧版本还是新版本在报错。
灰度真正要管住的是“放量还是回退”的连续决策,而不是只把新版本挂上去。
每次放量都要能说明依据:看了哪些指标、达到什么阈值、谁确认继续或回滚。
如果把灰度落到入口拓扑上,它应该长这样:入口层负责分流,稳定版和灰度版都要把版本标记打进日志、指标和 Trace,下游依赖要么兼容两版,要么接收并透传灰度标记。回滚优先切流,不要先删实例。
验证拓扑时,不要只看 Nginx 或 K8S 配置,要从入口连续请求并按版本统计:
for i in $(seq 1 200); do
curl -sI http://order.example.com/api/orders \
| awk -F': ' 'tolower($1)=="x-release-version"{print $2}'
done | sort | uniq -c
curl -sI -H 'X-Canary: 1' http://order.example.com/api/orders | grep -i 'x-release-version'常见坑是入口已经命中新版本,但下游 RPC、MQ 消息或异步任务没有透传灰度标记,导致请求半路又回到旧版本;还有一种是监控只有服务总量,没有 release 维度,1% 灰度的问题被全站平均值淹没。生产上必须把主分流入口、版本标签、回滚动作和观察指标写进同一张发布单。
以 Spring Boot 服务为例,可以加一个简单的 Filter。它的作用就是每次请求返回时,把当前发布版本带出来:
@Component
public class ReleaseVersionHeaderFilter implements Filter {
@Value("${release.version:unknown}")
private String releaseVersion;
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
if (response instanceof HttpServletResponse httpResponse) {
httpResponse.setHeader("X-Release-Version", releaseVersion);
}
chain.doFilter(request, response);
}
}启动服务时把版本号传进去:
java -jar order-api.jar \
--server.port=8080 \
--release.version=order-api-v2-20260709-01这条命令解决什么问题?它让同一份 jar 包可以用不同的 release.version 启动。灰度排查时,你不用猜当前实例到底跑的是哪一版。
启动后马上验证:
curl -sI http://127.0.0.1:8080/actuator/health | grep -i 'x-release-version'正常应该看到类似结果:
X-Release-Version: order-api-v2-20260709-01如果没有看到,先查三件事:
- 服务是不是已经重启到新参数。
- Filter 有没有被 Spring 扫描到。
- 反向代理或网关有没有把响应头过滤掉。
这里要注意,版本号不要只写在发布单里,要让请求、日志、指标都能带出来。线上真正出问题时,大家看的不是发布单,而是日志、监控和网关访问记录。
中国大陆、内网和离线环境先说清
很多发布文章默认你有云厂商的灰度产品,点几个按钮就能放量。但真实项目里经常不是这样:机器在内网,镜像仓库是私有的,Prometheus 自己搭,Nginx 配置还要走审批。灰度发布要落地,先把依赖闭环准备好。
中国大陆公网
在中国大陆公网环境落地时,先确认下面几件事。这里不要等发布当天再查,否则一个镜像拉不下来就能卡住整场发布。
- 镜像是否已经同步到国内可访问的私有仓库或企业仓库。
- 发布机是否能访问 Git、制品仓库、镜像仓库和目标机器。
- Nginx、Ingress Controller、APISIX、Argo Rollouts、Prometheus 等镜像不要运行时临时从海外拉取。
- 域名、备案、安全组、系统防火墙、证书链是否已经完成验证。
- 外部监控、短信、企业微信、邮件告警不可达时是否有备用告警链路。
这里要注意,不要把某个第三方镜像加速地址写成唯一方案。镜像加速可用性变化很快,生产上更稳的方式是把镜像同步到自己的 Harbor 或企业镜像仓库。
企业内网
内网里一般需要自己准备一套发布链路,常见组合如下:
GitLab / Gitea -> Jenkins / GitLab CI -> Nexus -> Harbor
-> Nginx / APISIX / Spring Cloud Gateway
-> Prometheus / Grafana / Alertmanager
-> Loki / Elasticsearch / SkyWalking这套链路解决的是“发布时不用临时访问外网”的问题。一般需要准备:
- 私有镜像仓库:保存业务镜像、Ingress、Gateway、监控组件和回滚版本。
- 私有制品仓库:保存 Maven、npm、通用制品包、离线依赖。
- 配置中心:Nacos、Apollo、数据库、Redis 或 GitOps 配置仓库。
- 发布凭证:不要把 SSH 私钥、K8S kubeconfig、仓库密码散落在脚本里。
- 时间同步和证书:离线环境里 TLS 失败、token 过期、证书不信任经常会拖垮发布。
离线环境
离线环境最怕“文档写得很清楚,现场包不齐”。如果要在离线环境部署 Ingress、网关或 Rollouts 控制器,建议提前把镜像保存出来:
# 保存镜像,这一步在有外网或有仓库访问权限的机器上做
docker pull registry.example.com/platform/ingress-nginx-controller:vX.Y.Z
docker save registry.example.com/platform/ingress-nginx-controller:vX.Y.Z \
-o ingress-nginx-controller-vX.Y.Z.tar
# 记录校验值,交付到内网后用来确认文件没有损坏
sha256sum ingress-nginx-controller-vX.Y.Z.tar > SHA256SUMS
# 目标环境导入,并推送到内网 Harbor
docker load -i ingress-nginx-controller-vX.Y.Z.tar
docker tag registry.example.com/platform/ingress-nginx-controller:vX.Y.Z \
harbor.internal/platform/ingress-nginx-controller:vX.Y.Z
docker push harbor.internal/platform/ingress-nginx-controller:vX.Y.Z到了内网机器后,先看 docker load 是否成功,再看 Harbor 上是否能看到这个镜像。失败时先查三个地方:镜像 tar 是否完整、目标机器 Docker/containerd 是否可用、Harbor 项目权限是否允许 push。
如果使用 Helm 或 CRD 控制器,也要离线保存 Chart、CRD、values 文件和镜像列表:
helm pull argo/argo-rollouts --version 2.40.0
helm show values argo/argo-rollouts --version 2.40.0 > argo-rollouts-values.yaml版本号只是示例,落地前以你当前验证过的版本为准。每个组件都要有安装包、镜像、配置、校验值和回退版本。没有这些东西,就不要把灰度发布流程设计得太复杂。
Nginx 灰度
先从最容易落地的 Nginx 讲起。很多公司没有完整网关平台,但基本都有 Nginx。用 Nginx 做灰度,能先把“少量流量进新版本、出问题马上切回旧版本”这条链路跑通。
这里我们假设:
旧版本 order-api v1: 10.0.10.11:8080
新版本 order-api v2: 10.0.10.21:8080
入口域名: order.example.com最简单的权重灰度
先来一个最简单的:99% 流量走旧版本,1% 流量走新版本。这个适合刚上线时试探一下新版本有没有明显异常。
upstream order_api {
server 10.0.10.11:8080 weight=99 max_fails=3 fail_timeout=10s;
server 10.0.10.21:8080 weight=1 max_fails=3 fail_timeout=10s;
}
server {
listen 80;
server_name order.example.com;
location / {
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_pass http://order_api;
}
}这段配置里,weight=99 和 weight=1 就是在做权重分配。注意它不是严格数学意义上的每 100 个请求一定 99 个旧版本、1 个新版本,而是 Nginx 负载均衡层面的近似分配。
配置写完不要直接重启,先校验:
nginx -t看到类似下面结果,说明语法没问题:
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful再平滑加载:
nginx -s reloadreload 的作用是让 Nginx 重新加载配置,但不粗暴杀掉正在处理请求的 worker。失败时先看 nginx -t 输出,再看 error log:
tail -n 100 /var/log/nginx/error.log接下来验证分流。这里最好让后端返回 X-Release-Version,然后连续请求 200 次:
for i in $(seq 1 200); do
curl -sI http://order.example.com/api/orders \
| awk -F': ' 'tolower($1)=="x-release-version"{print $2}'
done | sort | uniq -c正常应该看到两类版本号,旧版本数量明显更多,新版本数量很少。比如:
197 order-api-v1
3 order-api-v2如果只看到旧版本,先查新版本服务是不是可访问:
curl -sI http://10.0.10.21:8080/actuator/health如果直接访问新版本正常,再查 Nginx 是否加载了你改的配置:
nginx -T | grep -n "10.0.10.21"这个方案还有几个坑:
- 它是请求级比例,不保证同一用户始终命中同一版本。
- 如果
ip_hash、连接复用、客户端长连接、缓存或上游 keepalive 参与进来,实际比例可能和预期有偏差。 - 如果新版本只有 1 个实例,1% 流量也可能压垮它,要先核算容量。
按 Header 强制灰度
灰度发布时,经常需要让测试人员或自动化探针强制进入新版本。这个时候不要靠“多刷新几次碰运气”,可以用 Header。
下面这段配置表示:默认走稳定版;如果请求头里带 X-Canary: 1,就走灰度版。
upstream order_api_stable {
server 10.0.10.11:8080;
server 10.0.10.12:8080;
}
upstream order_api_canary {
server 10.0.10.21:8080;
}
map $http_x_canary $order_api_upstream {
default order_api_stable;
"1" order_api_canary;
}
server {
listen 80;
server_name order.example.com;
location / {
proxy_set_header X-Canary $http_x_canary;
proxy_set_header X-Real-IP $remote_addr;
proxy_pass http://$order_api_upstream;
}
}配置加载后,用下面两条命令验证:
curl -sI http://order.example.com/api/orders | grep -i 'x-release-version'
curl -sI -H 'X-Canary: 1' http://order.example.com/api/orders | grep -i 'x-release-version'第一条应该返回旧版本,第二条应该返回新版本。如果第二条没有进入新版本,按这个顺序排查:
- 请求头是不是写错了,Nginx 变量
$http_x_canary对应的是X-Canary。 nginx -T里是否能看到这段map配置。- 新版本 upstream 是否可访问。
- 中间是否还有一层代理把 Header 丢掉了。
这里要注意,如果你使用变量形式的 proxy_pass,生产前一定要在目标 Nginx 版本上验证解析行为。遇到 upstream 变量解析、DNS resolver、reload 后不生效等问题时,优先降低复杂度:固定 upstream、拆 server/location,或者把动态路由放到 OpenResty、APISIX、Spring Cloud Gateway。
按用户稳定分桶
真实项目里,最怕一个用户这次请求进新版本,下次请求又回旧版本。比如用户刚用新版本写了一份数据,下一次旧版本读不懂,这就麻烦了。所以用户灰度一般需要稳定分桶。
Nginx 可以用 split_clients 做一个基础版本。下面例子用 uid Cookie 分桶,1% 用户进入灰度:
split_clients "${cookie_uid}" $release_bucket {
1% canary;
* stable;
}
map $release_bucket $order_api_upstream {
default order_api_stable;
canary order_api_canary;
}
server {
listen 80;
server_name order.example.com;
location / {
proxy_set_header X-Release-Bucket $release_bucket;
proxy_pass http://$order_api_upstream;
}
}验证时可以给同一个 Cookie 连续请求:
for i in $(seq 1 20); do
curl -sI --cookie "uid=10001" http://order.example.com/api/orders \
| grep -i 'x-release-version'
done正常情况下,同一个 uid 应该稳定命中同一个版本。如果一会儿新、一会儿旧,说明你的分桶依据不稳定,或者中间还有其他代理层做了二次分流。
如果没有稳定的 uid Cookie,可以在网关或业务登录层生成。不要用纯 $remote_addr 当唯一依据,企业出口 NAT、移动网络、代理和 IPv6 环境都会让比例和稳定性变差。
Nginx 灰度的回滚
Nginx 灰度出问题时,第一优先级不是回滚代码,也不是重启机器,而是先把流量切回旧版本。
upstream order_api {
server 10.0.10.11:8080 weight=100 max_fails=3 fail_timeout=10s;
server 10.0.10.21:8080 weight=0 max_fails=3 fail_timeout=10s;
}改完配置后执行:
nginx -t
nginx -s reload
for i in $(seq 1 50); do
curl -sI http://order.example.com/api/orders \
| grep -i 'x-release-version'
done连续请求应该只看到旧版本。如果还能看到新版本,先查这几个地方:
- 你改的是不是当前 Nginx 正在加载的配置文件。
- 是否还有其他
server或location覆盖了这条规则。 - 上游是否还有一层网关或负载均衡在分流。
- 客户端或 CDN 是否有缓存。
如果配置校验失败,Nginx 不会使用坏配置替换当前运行配置。发布脚本必须先 nginx -t,再 reload,不能直接覆盖配置后重启。
K8S 滚动发布和金丝雀边界
到了 K8S 里,很多人会说:“Deployment 本来就支持滚动发布,是不是就等于灰度了?”这里要分清楚:Deployment 能帮你逐步替换 Pod,也能回滚 ReplicaSet,但它默认不能按用户、租户、Header 做精确流量灰度。
先看一个典型 Deployment。这里有几个关键配置:maxUnavailable: 0 表示发布过程中不要主动减少可用副本,maxSurge: 1 表示最多额外拉起 1 个新 Pod,readinessProbe 控制 Pod 什么时候能接流量。
apiVersion: apps/v1
kind: Deployment
metadata:
name: order-api
namespace: prod
labels:
app: order-api
spec:
replicas: 6
revisionHistoryLimit: 10
minReadySeconds: 10
progressDeadlineSeconds: 600
strategy:
type: RollingUpdate
rollingUpdate:
maxUnavailable: 0
maxSurge: 1
selector:
matchLabels:
app: order-api
template:
metadata:
labels:
app: order-api
release: stable
spec:
containers:
- name: order-api
image: harbor.internal/app/order-api:v1
ports:
- containerPort: 8080
readinessProbe:
httpGet:
path: /actuator/health/readiness
port: 8080
initialDelaySeconds: 10
periodSeconds: 5
failureThreshold: 3
livenessProbe:
httpGet:
path: /actuator/health/liveness
port: 8080
initialDelaySeconds: 30
periodSeconds: 10
failureThreshold: 3发布新镜像时,一般按下面几步走。
第一步,先写变更原因,后面排查 rollout 历史会用到:
kubectl -n prod annotate deployment/order-api \
kubernetes.io/change-cause="order-api v2 2026-07-09 add payment channel" \
--overwrite第二步,替换镜像:
kubectl -n prod set image deployment/order-api \
order-api=harbor.internal/app/order-api:v2第三步,观察发布是否完成:
kubectl -n prod rollout status deployment/order-api --timeout=10m正常会看到类似:
deployment "order-api" successfully rolled out第四步,看历史版本:
kubectl -n prod rollout history deployment/order-api如果 rollout status 卡住,不要急着回滚,先看 Pod 为什么没有 Ready:
kubectl -n prod get pod -l app=order-api -o wide
kubectl -n prod describe pod <pod-name>常见原因是镜像拉取失败、探针失败、资源不足、环境变量缺失。describe pod 里重点看 Events,里面通常会直接告诉你 ImagePullBackOff、Readiness probe failed 或调度失败原因。
需要回滚时执行:
kubectl -n prod rollout undo deployment/order-api
kubectl -n prod rollout status deployment/order-api --timeout=10m如果要回到指定版本,先看 revision:
kubectl -n prod rollout history deployment/order-api
kubectl -n prod rollout undo deployment/order-api --to-revision=12这里要注意,rollout undo 回滚的是 Pod 模板,也就是镜像、环境变量、探针这类 Deployment 模板内容。它不能自动回滚数据库数据,也不能撤回已经发出去的 MQ 消息。
K8S 原生滚动发布不等于金丝雀
如果你只是想低成本试一下新版本,可以用两个 Deployment 加一个 Service 做粗粒度金丝雀。它的原理很简单:稳定版 9 个 Pod,灰度版 1 个 Pod,Service 同时选中它们,流量大概会有一部分进灰度版。
示例配置如下:
apiVersion: apps/v1
kind: Deployment
metadata:
name: order-api-stable
namespace: prod
spec:
replicas: 9
selector:
matchLabels:
app: order-api
track: stable
template:
metadata:
labels:
app: order-api
track: stable
spec:
containers:
- name: order-api
image: harbor.internal/app/order-api:v1
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: order-api-canary
namespace: prod
spec:
replicas: 1
selector:
matchLabels:
app: order-api
track: canary
template:
metadata:
labels:
app: order-api
track: canary
spec:
containers:
- name: order-api
image: harbor.internal/app/order-api:v2
---
apiVersion: v1
kind: Service
metadata:
name: order-api
namespace: prod
spec:
selector:
app: order-api
ports:
- port: 80
targetPort: 8080应用后先看 Service 后面挂了哪些 Pod:
kubectl -n prod get endpoints order-api -o wide
kubectl -n prod get pod -l app=order-api --show-labels如果 endpoints 里既有 stable Pod,又有 canary Pod,说明流量入口已经能看到两个版本。
这个方案要谨慎使用,它只是“按 Pod 数量近似放量”。它的问题是:
- 比例不够精确,和连接、负载均衡、Pod 性能有关。
- 不能按用户或租户稳定命中。
- 不能直接表达 1%、5%、10% 这种精细放量。
- 不能基于指标自动暂停或回滚。
所以它适合小团队低成本验证,不适合复杂金丝雀治理。生产上如果要 1%、5%、按 Header、按用户稳定命中,就应该把流量控制放到 Ingress、Gateway、业务网关或渐进发布控制器里。
Ingress-NGINX 金丝雀
如果入口是 Ingress-NGINX,可以用 canary annotation。它比“Pod 数量近似灰度”更像真正的入口流量控制。
先准备稳定版 Ingress:
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: order-api
namespace: prod
spec:
ingressClassName: nginx
rules:
- host: order.example.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: order-api-stable
port:
number: 80再准备金丝雀 Ingress。这里 canary-weight: "5" 表示把 5% 流量打到 order-api-canary:
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: order-api-canary
namespace: prod
annotations:
nginx.ingress.kubernetes.io/canary: "true"
nginx.ingress.kubernetes.io/canary-weight: "5"
spec:
ingressClassName: nginx
rules:
- host: order.example.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: order-api-canary
port:
number: 80如果上线后发现错误率升高,先把灰度流量降为 0:
kubectl -n prod annotate ingress/order-api-canary \
nginx.ingress.kubernetes.io/canary-weight="0" \
--overwrite这条命令的好处是不用重新部署业务 Pod,只改入口规则。执行后马上验证:
kubectl -n prod describe ingress order-api-canary
curl -sI http://order.example.com/api/orders | grep -i 'x-release-version'如果还看到灰度版本,先查 Ingress Controller 是否同步了配置,再查是否还有其他入口绕过了 Ingress。
测试人员需要强制命中新版本时,可以按 Header:
metadata:
annotations:
nginx.ingress.kubernetes.io/canary: "true"
nginx.ingress.kubernetes.io/canary-by-header: "X-Canary"
nginx.ingress.kubernetes.io/canary-by-header-value: "always"验证时带上 Header:
curl -sI http://order.example.com/api/orders | grep -i 'x-release-version'
curl -sI -H 'X-Canary: always' http://order.example.com/api/orders | grep -i 'x-release-version'第一条应该大概率走稳定版,第二条应该稳定走灰度版。
Gateway API 权重切流
新集群如果已经使用 Gateway API,可以用 HTTPRoute 的 backendRefs.weight 做权重分流。它的写法比 Ingress annotation 更标准一些,但前提是你的 Gateway Controller 支持对应能力。
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: order-api
namespace: prod
spec:
parentRefs:
- name: prod-gateway
hostnames:
- order.example.com
rules:
- matches:
- path:
type: PathPrefix
value: /
backendRefs:
- name: order-api-stable
port: 80
weight: 95
- name: order-api-canary
port: 80
weight: 5应用后不要只看 YAML 是否提交成功,还要看对象是否被控制器接受:
kubectl -n prod get httproute order-api -o yaml
kubectl -n prod describe httproute order-api如果 Status 里出现未接受、未绑定、找不到 Gateway、backend 不可用之类信息,先修对象关系,不要直接怀疑业务服务。Gateway API 定义了对象模型和字段,真正行为还要看你使用的 Gateway Controller 是否完整支持相关能力。
Argo Rollouts 适合什么场景
如果团队已经在 K8S 里做规模化发布,可以评估 Argo Rollouts 这类渐进发布控制器。它能把“先 1%、暂停、看指标、再 5%、再 20%、失败自动回滚”写成发布对象。
适合:
- 多服务频繁发布。
- 希望把放量步骤写进 YAML。
- 需要 Prometheus 等指标自动判断是否继续。
- 发布过程要可审计、可重复、可回滚。
不适合:
- 集群规模很小,发布频率很低。
- 团队还没有基本的监控指标和健康检查。
- 离线环境里 CRD、镜像、Chart、控制器版本都无法稳定维护。
这里要注意,没有监控和回滚流程时,先不要急着上复杂控制器。工具会把流程自动化,也会把错误自动化。先把 Nginx、Ingress 或 Gateway 的手工灰度跑通,再考虑把流程交给控制器。
网关灰度
微服务系统里,只在入口 Nginx 切流通常不够。一个请求进来后,可能还会调用用户服务、支付服务、库存服务,甚至发 MQ 消息。如果入口进了灰度版,下游又跑回旧版本,这就叫灰度链路漂移。
网关灰度要做三件事:入口识别规则、给请求打标记、下游继续传这个标记。
网关灰度的基本模型
Client
-> API Gateway
-> rule: user/tenant/region/header/percent
-> set X-Gray-Version: v2
-> order-api v2
-> payment-api v2
-> MQ message header gray_version=v2灰度标记至少要覆盖三类链路:
- HTTP:使用 Header,例如
X-Gray-Version: v2。 - RPC:使用 Metadata、Attachment、Context。
- MQ:使用消息 Header、Property 或自定义属性。
只在网关层灰度,不做下游透传,会出现“入口命中新版本,服务间调用又漂回旧版本”的问题。排查这种问题时,日志里一定要打印 traceId 和 grayVersion。
Spring Cloud Gateway 权重路由
如果稳定版和灰度版注册为不同服务 ID,可以先用 Weight Predicate 做一个最小比例路由。下面配置的意思是:同一个 Path 下,95 份流量走稳定服务,5 份流量走灰度服务。
spring:
cloud:
gateway:
routes:
- id: order-api-stable
uri: lb://order-api-stable
predicates:
- Path=/api/orders/**
- Weight=order-api,95
filters:
- AddRequestHeader=X-Gray-Version,stable
- id: order-api-canary
uri: lb://order-api-canary
predicates:
- Path=/api/orders/**
- Weight=order-api,5
filters:
- AddRequestHeader=X-Gray-Version,canary启动网关后,用请求验证:
for i in $(seq 1 100); do
curl -sI http://gateway.example.com/api/orders \
| grep -i 'x-gray-version'
done | sort | uniq -c如果一直只有 stable,先查路由是否加载;如果 404,先查 Path 是否和真实路径匹配;如果路由到了服务但服务不可用,查注册中心里 order-api-canary 是否有实例。
这种方式简单,但服务 ID 会增加。更常见的企业方案是服务 ID 不变,实例元数据带版本:
spring:
cloud:
nacos:
discovery:
metadata:
release: canary
version: v2然后在 Gateway 或 LoadBalancer 中根据 Header、用户规则和实例 metadata 选择实例。这通常需要自定义过滤器或负载均衡策略。这里要注意,不要以为注册中心里加一个 metadata,灰度就自动完成了;入口、负载均衡、RPC、日志和监控都要配合。
APISIX traffic-split
APISIX 适合内网自建网关,也能通过插件做权重切流。下面这个配置表达的是:同一个 URI,95% 到旧实例,5% 到新实例。
{
"uri": "/api/orders/*",
"plugins": {
"traffic-split": {
"rules": [
{
"weighted_upstreams": [
{
"upstream": {
"type": "roundrobin",
"nodes": {
"10.0.10.11:8080": 1
}
},
"weight": 95
},
{
"upstream": {
"type": "roundrobin",
"nodes": {
"10.0.10.21:8080": 1
}
},
"weight": 5
}
]
}
]
}
}
}配置下发后,先用 Admin API 或控制台确认规则已经生效,再用 curl 连续请求验证版本比例。失败时先查 APISIX route 是否命中,再查 upstream 节点是否健康。
生产落地时要额外确认:
- APISIX 和 etcd 的高可用。
- Admin API 权限和审计。
- 配置变更是否有版本记录。
- 插件规则是否能被发布系统回滚。
Dubbo 标签路由
如果服务间调用走 Dubbo,可以用标签路由表达蓝绿或金丝雀分组。基本思路是:
- 新版本 Provider 带
tag=canary或类似元数据。 - Consumer 从请求上下文里读取
X-Gray-Version。 - Consumer 调用时把 tag 放进 Dubbo 上下文。
- Dubbo 路由规则把灰度请求路由到灰度 Provider。
入口请求进来时,先把 Header 里的灰度标记放到上下文。伪代码如下:
public class GrayTagInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
String grayVersion = request.getHeader("X-Gray-Version");
if ("canary".equals(grayVersion)) {
GrayContext.set("canary");
}
return true;
}
}RPC 调用前,再把这个标记写入 Dubbo 调用上下文:
String tag = GrayContext.get();
if (tag != null) {
RpcContext.getClientAttachment().setAttachment("dubbo.tag", tag);
}验证时可以在 Provider 侧日志打印 dubbo.tag 或灰度版本号。如果 Consumer 日志有灰度标记,Provider 日志没有,说明透传断在 RPC 调用层。不同 Dubbo 版本 API 会有差异,正式写代码前要按你项目使用的 Dubbo 版本校准。
配置开关和用户维度灰度
有些功能不一定要靠切流解决。比如新优惠券引擎已经随 v2 发布了,但你不想一上线就启用它,这时候就用配置开关。
配置开关控制的是“功能开不开”,流量灰度控制的是“请求进哪个版本”。真实项目里,两者经常一起用:先让少量用户进新版本,再用配置开关控制新功能是否打开。
例如新版本已经部署,但新功能默认关闭:
features:
newCouponEngine:
enabled: false
percent: 0
allowTenants:
- tenant-a
denyUsers:
- 10086如果没有配置中心,也可以先用数据库表做一个最小版本。表结构可以这样设计:
create table feature_flag (
id bigint primary key auto_increment,
feature_key varchar(128) not null,
enabled tinyint not null default 0,
percent int not null default 0,
allow_tenants text null,
allow_users text null,
deny_users text null,
rule_version bigint not null default 1,
updated_by varchar(64) not null,
updated_at timestamp not null default current_timestamp,
unique key uk_feature_key (feature_key)
);这张表解决的是三个问题:
enabled控制总开关。percent控制放量比例。allow_tenants、allow_users、deny_users控制白名单和黑名单。
按用户放量时,不要每次随机。随机会导致同一个用户一会儿进入新逻辑,一会儿回到旧逻辑。一般要做稳定分桶,伪代码如下:
public boolean hitGray(String userId, String featureKey, int percent) {
if (percent <= 0) {
return false;
}
if (percent >= 100) {
return true;
}
int bucket = stableHash(featureKey + ":" + userId) % 10000;
return bucket < percent * 100;
}这里 featureKey + ":" + userId 是为了让同一个用户在同一个功能上稳定落到同一个桶里。比如 percent=5,就是命中 0 到 499 这些桶的用户进入灰度。
规则优先级建议:
黑名单 -> 强制白名单 -> 租户白名单 -> 区域/设备/App版本 -> 百分比分桶 -> 默认关闭不要在每个业务方法里散落 if (gray)。这种代码后面很难清理。至少封装成统一客户端:
if (featureClient.enabled("newCouponEngine", userContext)) {
return newCouponEngine.calculate(request);
}
return oldCouponEngine.calculate(request);上线时先把 enabled=false,验证新版本进程、接口和依赖都正常,再把 percent 从 1、5、10 慢慢调上去。紧急回滚时,配置中心直接把 enabled=false 或 percent=0。这比重新部署快很多。
如果配置修改后不生效,先查三件事:
- 应用是否有本地缓存,刷新间隔是多少。
- 配置中心推送是否成功,客户端日志有没有收到变更。
- 当前请求有没有带正确的用户、租户、区域等上下文。
数据库变更兼容
灰度发布最容易低估数据库风险。代码回滚很快,但已经写入的新数据不一定能回滚;DDL 更不是每次都能安全撤回。
所以数据库变更不要和应用代码绑成“一把梭”。一般采用“扩展、迁移、收缩”。
这条链路要和应用灰度分开看。应用可以 1%、5%、10% 放量,但数据库结构要先兼容旧版本,再让新版本双写和回填,最后才切读和收缩。
每一步都要有验证命令。字段扩展看结构,双写看新旧字段一致性,回填看剩余缺口和慢 SQL:
show columns from orders like 'channel_v2';
select count(*) as diff_count
from orders
where channel_v2 is not null
and channel_v2 <> channel;
select count(*) as missing_channel_v2
from orders
where channel_v2 is null;常见坑是把“新增字段、改读路径、删旧字段”塞进同一个发布窗口。这样应用能回滚,数据却回不去。生产约束是:破坏性 DDL 延后到收缩阶段,回填任务可暂停,读路径有配置开关,回滚时优先保留新增字段而不是急着清理。
第一步:扩展
第一步只做兼容性变更,简单说就是只新增,不破坏。比如旧字段是 channel,新版本想用 channel_v2,先加字段:
alter table orders add column channel_v2 varchar(32) null comment 'new channel code';执行后先验证字段是否存在:
show columns from orders like 'channel_v2';应该能看到 channel_v2 这一行。这里要注意:
- 老版本可以忽略新字段。
- 新版本能同时读旧字段和新字段。
- 新字段允许空值或有安全默认值。
- 不在同一次发布里删除字段、改字段语义、强制非空。
第二步:双写和回填
第二步,新版本写入时同时写旧字段和新字段。这样即使你回滚到旧版本,旧版本还能读旧字段:
order.setChannel(oldChannel);
order.setChannelV2(newChannel);
orderRepository.save(order);历史数据单独回填。不要上来就全表更新,先小批量跑:
update orders
set channel_v2 = channel
where channel_v2 is null
limit 1000;执行后验证还剩多少没回填:
select count(*) as missing_channel_v2
from orders
where channel_v2 is null;回填不要一次性全表猛跑。要按主键分页、限速、记录进度,并监控慢 SQL、锁等待、复制延迟和业务延迟。如果数据库已经开始出现锁等待,先停回填任务,不要继续放大灰度流量。
第三步:切读
确认新字段覆盖率足够后,再用配置开关切换读路径:
if (featureClient.enabled("readChannelV2", userContext)) {
return order.getChannelV2();
}
return order.getChannel();切读前再查一次:
select count(*) as missing_channel_v2
from orders
where channel_v2 is null;如果还有大量空值,不要切全量读新字段。可以先对白名单租户切读,确认没有问题后再扩大。
第四步:收缩
旧字段删除、索引删除、约束收紧,要等多个版本周期之后再做。不要把“上线新代码”和“删除旧结构”塞在同一个发布窗口。现场经验是:删字段永远比加字段危险,宁可晚删,不要抢在同一晚删。
数据库变更的回滚判断:
| 变更类型 | 能否简单代码回滚 | 处理方式 |
|---|---|---|
| 只加字段 | 通常可以 | 老版本忽略新字段 |
| 新老字段双写 | 可以,但要确认数据一致 | 回滚代码后继续保留新字段 |
| 字段改名 | 风险高 | 用新增字段替代,不直接 rename |
| 删除字段 | 不能简单回滚 | 延迟到收缩阶段 |
| 数据重算 | 不能简单回滚 | 需要备份、补偿和审计 |
| 大表 DDL | 风险高 | 低峰执行,评估在线 DDL 和回退方案 |
监控指标和回滚触发条件
灰度放量不能靠一句“看着还行”。发布前就要把观察指标和回滚线写好。否则出了问题,大家会在群里争论“要不要再观察一下”,时间就这样被浪费掉了。
技术指标
先看技术指标:
- 请求量:灰度版本是否真的接到预期比例流量。
- 错误率:HTTP 5xx、业务异常、RPC 异常、网关 502/504。
- 延迟:P95、P99、最大值。
- 容器状态:Pod 重启、CrashLoopBackOff、OOMKilled。
- 资源:CPU、内存、连接数、线程池、GC。
- 依赖:数据库慢 SQL、连接池耗尽、Redis 超时、MQ 积压。
- 日志:错误日志是否集中在新版本。
PromQL 示例需要按你的指标名调整。比如先看灰度版本 5xx 错误率:
sum(rate(http_server_requests_seconds_count{release="canary",status=~"5.."}[5m]))
/
sum(rate(http_server_requests_seconds_count{release="canary"}[5m]))
> 0.01如果结果大于 0.01,表示最近 5 分钟灰度版本 5xx 超过 1%。这时候不要继续放量,先看日志和依赖。
再看 P95 延迟:
histogram_quantile(
0.95,
sum by (le, release) (
rate(http_server_requests_seconds_bucket{release=~"stable|canary"}[5m])
)
)如果灰度版 P95 明显高于稳定版,先查数据库、Redis、外部接口、线程池,不要只盯着 CPU。
Pod 重启也要单独看:
increase(kube_pod_container_status_restarts_total{namespace="prod",pod=~"order-api.*"}[10m]) > 0这条命中后,先执行:
kubectl -n prod describe pod <pod-name>
kubectl -n prod logs <pod-name> --previous --tail=200--previous 用来查看上一次崩溃前的日志,对排查 OOM、启动异常很有用。
业务指标
- 下单成功率。
- 支付成功率。
- 登录成功率。
- 核心页面转化率。
- 订单金额、库存扣减、优惠券核销。
- 客服投诉、人工工单、风控拒绝率。
业务指标要按新旧版本拆开看。如果只看全站平均值,1% 灰度造成的问题可能被淹没。比如支付成功率全站看没掉,但灰度用户里已经掉了 5%,这就已经该停。
回滚触发条件
建议把触发条件写进发布单,不要临时拍脑袋:
| 指标 | 触发条件示例 | 动作 |
|---|---|---|
| 5xx 错误率 | 灰度版本连续 5 分钟超过 1%,且高于稳定版 2 倍 | 暂停放量,切回旧版本 |
| P95 延迟 | 灰度版本连续 10 分钟高于稳定版 50% | 暂停放量,排查依赖 |
| Pod 重启 | 10 分钟内出现 2 次以上重启 | 停止灰度,保留现场 |
| 业务成功率 | 下单或支付成功率下降超过 0.5% | 立即切流,通知业务负责人 |
| MQ 积压 | 灰度消费者所在 topic/group 积压持续增长 | 降低流量或下线灰度消费者 |
| DB 慢 SQL | 新版本慢 SQL 激增或锁等待 | 切回旧版本,停止回填任务 |
不要等“用户大面积反馈”才回滚。灰度发布的价值就在于让机器指标先报警,人只是确认和执行。
发布流程模板
发布前
发布前不要只问“包打好了吗”。一般要把下面这些项确认完:
1. 确认 Git commit、镜像 tag、配置版本。
2. 确认旧版本仍可用,回滚镜像和配置还在。
3. 确认数据库变更是兼容变更,破坏性 DDL 已延后。
4. 确认健康检查接口真实覆盖依赖状态。
5. 确认监控面板能按 release/version 区分新旧版本。
6. 确认 Nginx、Ingress、Gateway 或配置中心有一键切回路径。
7. 确认发布窗口、负责人、验证人、业务确认人。
8. 确认发布失败时是否需要数据修复脚本。灰度 1%
第一阶段建议从 1% 或白名单开始。这里的目标不是证明新版本性能有多好,而是先证明它不会一接流量就报错。
# Nginx 场景
nginx -t && nginx -s reload
# K8S 场景
kubectl -n prod get deploy,svc,ingress | grep order-api
kubectl -n prod rollout status deploy/order-api-canary --timeout=5m
# 验证入口版本
curl -sI http://order.example.com/api/orders | grep -i 'x-release-version'访问后应该看到新旧版本都存在,并且灰度版本流量很小。然后观察 10 到 30 分钟:
1. 灰度版本请求量是否符合 1%。
2. 错误率是否高于稳定版。
3. P95/P99 是否高于稳定版。
4. 是否有新增异常日志。
5. 是否有数据库慢 SQL、锁等待或连接池耗尽。
6. 是否有 MQ 积压。
7. 核心业务链路是否验证通过。灰度 5% 到 10%
只在 1% 验证通过后放量。每次放量都要留下记录,别只在群里说一句“已放 5%”:
2026-07-09 21:00 order-api v2 1% -> 5% 操作人: zhangsan
验证: 错误率 0.03%,P95 120ms,支付成功率正常
结论: 继续观察 30 分钟全量
全量不是“删掉旧版本就完事”。至少保留一个回滚窗口:
1. 新版本承接 100% 流量。
2. 旧版本实例保留但不接流量,至少观察一个完整业务周期。
3. 数据库旧字段和旧逻辑继续保留到下一个或多个版本。
4. 监控面板继续按版本保留对比。
5. 发布单记录最终结论。回滚和止血流程
故障发生时,先止血,再定位。不要一上来就开始翻代码、查线程栈、争论是不是数据库问题。用户还在报错时,先把新流量切掉。
灰度事故的回滚顺序要比普通发布更细:先让灰度流量归零,再关闭功能开关,再回滚应用或配置,最后才处理数据和补偿。下面这张图适合贴到发布单里,避免现场把“删新版本实例”当成第一动作。
这里的生产约束是:入口切流和功能开关必须能独立执行,不能依赖重新构建;数据库变更和消息副作用要单独确认,不能用 rollout undo 这类应用回滚命令假装已经全部恢复。
第一优先级:切流
Nginx 场景,把 canary 权重改成 0 后执行:
nginx -t && nginx -s reloadIngress-NGINX:
kubectl -n prod annotate ingress/order-api-canary \
nginx.ingress.kubernetes.io/canary-weight="0" \
--overwriteGateway API:
backendRefs:
- name: order-api-stable
port: 80
weight: 100
- name: order-api-canary
port: 80
weight: 0配置开关:
update feature_flag
set enabled = 0, percent = 0, rule_version = rule_version + 1
where feature_key = 'newCouponEngine';K8S Deployment 场景,如果问题来自新镜像本身,可以回滚 Deployment:
kubectl -n prod rollout undo deployment/order-api
kubectl -n prod rollout status deployment/order-api --timeout=10m第二优先级:验证恢复
切流后不要马上宣布恢复,先验证:
curl -sI http://order.example.com/api/orders | grep -i 'x-release-version'
kubectl -n prod get pod -l app=order-api -o wide
kubectl -n prod logs deploy/order-api --tail=200监控上也要确认:
- 请求量回到稳定版。
- 灰度版本请求量归零或降到预期。
- 5xx 错误率下降。
- P95/P99 恢复。
- MQ 积压停止增长。
- 业务成功率恢复。
第三优先级:保留现场
不要刚切回旧版本就立刻删 Pod、清日志、删配置。至少保留现场:
kubectl -n prod get deploy order-api -o yaml > order-api-deploy-incident.yaml
kubectl -n prod get pod -l app=order-api -o wide > order-api-pods-incident.txt
kubectl -n prod describe pod <bad-pod> > bad-pod-describe.txt
kubectl -n prod logs <bad-pod> -c order-api --since=1h > bad-pod.log如果涉及数据写错,要立即冻结自动修复脚本,先导出影响范围:
select count(*) as affected_orders
from orders
where updated_at >= '2026-07-09 21:00:00'
and channel_v2 = 'unexpected';常见发布事故排查
| 现象 | 常见原因 | 验证方式 | 止血动作 |
|---|---|---|---|
| 灰度版本流量远高于预期 | 权重配置错、Ingress 规则覆盖、Service 选择器选中全部 Pod | 查看 Nginx 配置、Ingress annotation、Service endpoints | 权重改 0,修 selector |
| Header 强制灰度无效 | 代理层丢 Header、大小写或值不匹配、网关规则没 reload | curl -H 验证,查网关 access log | 临时按 IP 或白名单切流 |
| 同一用户来回命中新旧版本 | 用随机分流,没有稳定 hash 或 Cookie | 日志按 userId 查 release | 改为 userId/tenantId 稳定分桶 |
| 网关命中新版本,下游回旧版本 | 灰度标记未透传到 HTTP/RPC/MQ | 查链路日志和 Header/Metadata | 停灰度,补透传拦截器 |
| 新版本启动成功但接流量就报错 | readiness 检查太浅,只检查进程不检查依赖 | describe pod、应用日志、依赖状态 | 修 readiness,摘除实例 |
| 1% 没问题,50% 出问题 | 并发、连接池、缓存、DB 热点在小流量下未暴露 | 看连接池、慢 SQL、CPU、锁等待 | 回到上一个比例,压测复现 |
| 回滚代码后仍异常 | 数据库已写入不兼容数据、缓存污染、消息已发出 | 查变更窗口内数据和消息 | 数据修复,清理灰度缓存 |
| Nginx reload 后不符合预期 | 配置文件不是实际加载文件,include 顺序覆盖 | nginx -T 查看完整配置 | 回滚配置仓库版本 |
| K8S rollout 卡住 | 镜像拉取失败、探针失败、资源不足、调度失败 | kubectl describe pod | 修镜像/资源/探针,必要时 undo |
| MQ 消费者灰度异常 | 新旧消费者混用同一 group,消息属性未透传 | 查 consumer group、消息 header | 暂停新消费者,恢复旧消费者 |
发布记录模板
每次灰度发布至少记录这些字段:
服务名称: order-api
发布版本: harbor.internal/app/order-api:v2
Git commit: abcdef1
配置版本: nacos order-api-prod.yaml v35
数据库变更: add orders.channel_v2, compatible
发布时间: 2026-07-09 21:00
操作人: zhangsan
验证人: lisi
灰度规则: user hash 1% -> 5% -> 10% -> 50% -> 100%
监控面板: order-api release dashboard
回滚方式:
1. Ingress canary-weight=0
2. feature newCouponEngine=false
3. kubectl rollout undo deploy/order-api
回滚触发条件:
1. 5xx > 1% for 5m
2. P95 > stable * 1.5 for 10m
3. payment success rate drop > 0.5%
发布结论: success / rollback / partial
复盘链接: incident-20260709-order-api不要只在聊天群里说“已发”。发布记录是以后排障、审计和复盘的入口。
落地工程深水区
灰度发布真正难的不是“分 10% 流量”,而是让这 10% 能稳定命中、能被观测、能被止血,并且不会把不可逆数据写坏。
| 深水区 | 现场表现 | 落地做法 |
|---|---|---|
| 分流维度选错 | 同一用户一会儿新版本一会儿旧版本 | 按 userId、tenantId、设备号做稳定 hash;不要用纯随机分流承载有状态业务 |
| 灰度标记断链 | 网关进新版本,下游 RPC/MQ 又回旧版本 | HTTP Header、RPC Metadata、MQ Header 统一灰度字段;日志和指标同时带 release |
| 数据变更不可逆 | 回滚代码后仍然报错或账务不一致 | 数据库按“扩字段 -> 双写/兼容读 -> 切读 -> 清理”分阶段,不把破坏性 DDL 和发布绑在一起 |
| readiness 太浅 | Pod 启动成功但一接流量就 5xx | 探针覆盖配置、依赖、线程池基本可用性;启动慢的服务配置 startupProbe |
| 监控没有版本维度 | 5xx 升高但分不清新旧版本 | 指标、日志、Trace、access log 加 release / version 标签 |
| 自动扩缩容干扰灰度比例 | 流量比例和实例数量同时变化,现象难解释 | 灰度窗口内冻结非必要变更;HPA、扩容、配置变更写进发布记录 |
| 回滚只回应用 | feature flag、配置中心、缓存、任务调度仍是新逻辑 | 回滚剧本列出应用、网关、配置、开关、缓存、任务、数据修复顺序 |
| 多入口规则不一致 | Nginx、Ingress、API 网关灰度比例互相覆盖 | 只指定一个主分流入口;其他入口只透传灰度标记 |
| 离线/内网制品不可追溯 | 回滚时找不到旧镜像、旧配置、旧脚本 | 镜像仓库、配置仓库、脚本仓库保存版本;发布记录绑定 digest 和配置版本 |
| 团队协同靠聊天 | 出事时没人知道谁能拍板回滚 | 发布单写清操作人、验证人、业务 owner、回滚触发阈值和联系电话 |
生产里判断灰度是否合格,可以问三个问题:能不能精准让一批用户命中新版本,能不能只看这批用户的错误和延迟,能不能在 5 分钟内把这批用户切回稳定路径。
生产落地建议
- 所有服务都要有版本标识,写进响应头、日志和指标标签。
- 所有发布都要先有回滚方案,再有发布动作。
- 灰度比例不要凭感觉跳,建议
1% -> 5% -> 10% -> 25% -> 50% -> 100%,高风险变更更慢。 - 大促、结算、财务、库存、支付、清算链路不要临时上复杂灰度逻辑。
- 数据库破坏性变更必须跨版本完成,不能和应用发布绑死。
- 网关、RPC、MQ 都要能透传灰度标记,否则微服务灰度会漂移。
- 内网和离线环境要优先自建制品仓库、镜像仓库、监控和配置中心。
- 发布脚本必须校验配置,不要直接 reload 或 restart。
- 回滚不是失败,不能回滚才是事故。
- 每次事故后都要沉淀成规则:能自动检查的不要靠人记。
灰度验证检查清单
发布前:
[ ] 镜像 tag、Git commit、配置版本已确认
[ ] 旧版本镜像和配置可回滚
[ ] 数据库变更已确认兼容
[ ] Nginx/Ingress/Gateway 规则已备份
[ ] 健康检查真实有效
[ ] 监控能按 release 区分
[ ] 告警规则已启用
[ ] 业务验证账号和白名单已准备
[ ] 相关负责人在线灰度中:
[ ] 灰度流量比例符合预期
[ ] 错误率没有高于稳定版
[ ] P95/P99 没有明显劣化
[ ] 应用日志无新增集中异常
[ ] DB 慢 SQL、锁等待、连接池正常
[ ] Redis、MQ、第三方依赖正常
[ ] 核心业务链路验证通过
[ ] 发布记录已更新回滚后:
[ ] 流量已切回稳定版
[ ] 灰度版本请求量归零或符合预期
[ ] 错误率和延迟恢复
[ ] 业务成功率恢复
[ ] 异常数据影响范围已确认
[ ] 日志、Pod、配置、监控截图已留存
[ ] 复盘和修复项已创建官方文档入口
- Kubernetes Deployment:https://kubernetes.io/docs/concepts/workloads/controllers/deployment/
- Kubernetes 探针:https://kubernetes.io/docs/tasks/configure-pod-container/configure-liveness-readiness-startup-probes/
- Nginx upstream:https://nginx.org/en/docs/http/ngx_http_upstream_module.html
- Nginx split_clients:https://nginx.org/en/docs/http/ngx_http_split_clients_module.html
- Nginx 控制命令:https://nginx.org/en/docs/control.html
- Ingress-NGINX Canary:https://kubernetes.github.io/ingress-nginx/examples/canary/
- Gateway API Traffic Splitting:https://gateway-api.sigs.k8s.io/guides/user-guides/traffic-splitting/
- Argo Rollouts:https://argoproj.github.io/rollouts/
- Spring Cloud Gateway:https://docs.spring.io/spring-cloud-gateway/reference/
- Spring Cloud LoadBalancer:https://docs.spring.io/spring-cloud-commons/reference/spring-cloud-commons/loadbalancer.html
- Nacos OpenAPI:https://nacos.io/en-us/docs/v2/guide/user/open-api.html
- Dubbo Tag Routing:https://dubbo.apache.org/en/overview/what/core-features/traffic/tag-rule/
- APISIX traffic-split:https://docs.api7.ai/hub/traffic-split
- OpenFeature:https://openfeature.dev/docs/reference/intro/
- Prometheus 告警规则:https://prometheus.io/docs/prometheus/latest/configuration/alerting_rules/
- Liquibase rollback:https://docs.liquibase.com/secure/reference-guide-5-2/init-update-and-rollback-commands/rollback
- Flyway Undo:https://documentation.red-gate.com/fd/undo-migrations-273973334.html
