灰度发布实战

灰度发布的概念我很早就知道,但一直没机会实践。过往项目要么规模不够,要么节奏太紧。直到这个项目条件齐了,终于能把它落地。这篇笔记记录这次实战的过程。

一、功能背景

在微服务架构中实现基于 IP 白名单的灰度发布:让指定 IP 的请求进入新版(灰度)实例,其余流量留在稳定版实例,小范围验证后再全量放量。灰度标记贯穿”网关 → HTTP 服务 → Dubbo RPC”全链路。

二、技术栈与依赖版本

依赖 版本 说明
JDK 1.8 项目编译/运行版本
Spring Boot 2.7.18 基础框架
Spring Cloud 2021.0.8 微服务框架(Gateway 3.1.x、LoadBalancer 3.1.x)
Spring Cloud Alibaba 2021.0.5.0 Nacos 注册/配置、Sentinel
Dubbo(dubbo-spring-boot-starter) 3.2.1 服务间 RPC(Apache Dubbo)
Nacos 由 SCA 2021.0.5.0 管理 注册中心 / 配置中心
Spring Cloud Gateway starter 引入,版本由 2021.0.8 管控 网关

网关 pom.xml 关键依赖:spring-cloud-starter-gateway、spring-cloud-starter-alibaba-nacos-discovery、spring-cloud-starter-alibaba-nacos-config、spring-cloud-loadbalancer(注意排除了 spring-webmvc,网关是 WebFlux 环境)。
common-core 关键依赖:dubbo-spring-boot-starter、spring-cloud-starter-loadbalancer、javax.servlet-api。

三、整体架构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
客户端请求
│
▼
网关 (joylife-gateway)
├─ GrayStainGlobalFilter (order=-50) ← 按 IP 白名单判定灰度,写入 X-Gray-Flag 头
├─ ParseServiceGatewayFilterFactory ← 解析路径 serviceId,构造 lb:// 目标
├─ GrayReactorLoadBalancer ← 按灰度标记选实例(灰度实例 / 普通实例)
├─ DebugGatewayFilter (order=10149) ← 调试日志,打印转发目标
▼
HTTP 服务 (openapi / user 等,依赖 common-core)
├─ GrayFlagFilter (Servlet Filter, order=0) ← 读 X-Gray-Flag,写入 Dubbo RpcContext
▼
Dubbo RPC 链路
├─ GrayDubboConsumerFilter (consumer端) ← 灰度标记透传到 RPC 调用
├─ GrayProviderFilter (provider端) ← 支持 A→B→C 嵌套链路透传
└─ GrayDubboLoadBalance ← 按 provider URL 参数 gray 选提供者

四、核心实现与关键代码

1. 网关层:流量染色(GrayStainGlobalFilter)

全局过滤器,order = -50。获取真实 IP(优先取 X-Forwarded-For 首值),命中白名单即判定灰度,向请求写入 X-Gray-Ip、X-Gray-Flag 头透传下游。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
@Slf4j
@Component
public class GrayStainGlobalFilter implements GlobalFilter, Ordered {

public static final String X_GRAY_IP = "X-Gray-Ip";
public static final String X_GRAY_FLAG = "X-Gray-Flag";
public static final String CTX_KEY_IS_GRAY = "isGrayRequest";

private final GrayConfigProperties grayConfig;

public GrayStainGlobalFilter(GrayConfigProperties grayConfig) {
this.grayConfig = grayConfig;
}

@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
String clientIp = getRealIp(exchange.getRequest());
boolean isGray = Boolean.TRUE.equals(grayConfig.getEnable())
&& grayConfig.getIpWhitelist() != null
&& grayConfig.getIpWhitelist().contains(clientIp);

log.info("clientIp:{}, isGray:{}", clientIp, isGray);
// 透传header给下游http服务
ServerHttpRequest newReq = exchange.getRequest().mutate()
.header(X_GRAY_IP, clientIp)
.header(X_GRAY_FLAG, String.valueOf(isGray))
.build();
ServerWebExchange newExchange = exchange.mutate().request(newReq).build();
newExchange.getAttributes().put(CTX_KEY_IS_GRAY, isGray);
return chain.filter(newExchange);
}

/** 必须实现 Ordered 接口:SCG 3.1.x 的 FilteringWebHandler 只认 Ordered 接口,@Order 注解会被忽略 */
@Override
public int getOrder() {
return -50;
}

/** 获取真实客户端IP,注意Nginx X-Forwarded-For */
private String getRealIp(ServerHttpRequest request) {
String xff = request.getHeaders().getFirst("X-Forwarded-For");
if (StringUtils.isNotBlank(xff)) {
return xff.split(",")[0].trim();
}
if (request.getRemoteAddress() != null) {
return request.getRemoteAddress().getAddress().getHostAddress();
}
return "unknown";
}
}

2. 灰度配置属性(GrayConfigProperties)

1
2
3
4
5
6
7
8
9
@Data
@RefreshScope
@ConfigurationProperties(prefix = "gateway.gray")
public class GrayConfigProperties {
/** 是否开启灰度 */
private Boolean enable = true;
/** 灰度IP白名单 */
private Set<String> ipWhitelist;
}

3. 网关负载均衡(GrayReactorLoadBalancer)

从 RequestDataContext 读请求头 X-Gray-Flag:灰度流量只选元数据 gray=true 实例,普通流量排除灰度实例;无匹配时降级全部实例,避免 503。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
@Slf4j
public class GrayReactorLoadBalancer implements ReactorServiceInstanceLoadBalancer {

private final String serviceId;
private final Supplier<ServiceInstanceListSupplier> supplierProvider;
private final Random random = new Random();

public GrayReactorLoadBalancer(Supplier<ServiceInstanceListSupplier> supplierProvider, String serviceId) {
log.info("[灰度LB] 创建成功, serviceId = {}", serviceId);
this.supplierProvider = supplierProvider;
this.serviceId = serviceId;
}

@Override
public Mono<Response<ServiceInstance>> choose(Request request) {
ServiceInstanceListSupplier supplier = supplierProvider.get();
return supplier.get()
.next()
.flatMap(allInstances -> {
boolean isGrayRequest = resolveIsGray(request);
log.info("[灰度LB] service={}, isGrayRequest={}", serviceId, isGrayRequest);
List<ServiceInstance> candidates;
if (isGrayRequest) {
//灰度流量,只取元数据gray=true实例
candidates = allInstances.stream()
.filter(i -> "true".equals(i.getMetadata().getOrDefault("gray", "false")))
.collect(Collectors.toList());
} else {
//普通流量,排除灰度实例
candidates = allInstances.stream()
.filter(i -> !"true".equals(i.getMetadata().getOrDefault("gray", "false")))
.collect(Collectors.toList());
}
//兜底:无匹配实例,降级全部实例,避免503
if (candidates.isEmpty()) {
log.warn("[灰度LB] service={}, isGray={} 无匹配实例,降级全部实例", serviceId, isGrayRequest);
candidates = allInstances;
}
if (candidates.isEmpty()) {
return Mono.just(new EmptyResponse());
}
int idx = random.nextInt(candidates.size());
return Mono.just(new DefaultResponse(candidates.get(idx)));
});
}

/** 从请求头读取灰度标识(GrayStainGlobalFilter 写入) */
private boolean resolveIsGray(Request request) {
if (request.getContext() instanceof RequestDataContext) {
RequestDataContext ctx = (RequestDataContext) request.getContext();
String flag = ctx.getClientRequest().getHeaders().getFirst(GrayStainGlobalFilter.X_GRAY_FLAG);
return "true".equals(flag);
}
return false;
}
}

注册方式(GrayLoadBalancerConfig)—— 注意把 ObjectProvider 包装为 Supplier:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Configuration
@LoadBalancerClients(defaultConfiguration = GrayLoadBalancerConfig.class)
public class GrayLoadBalancerConfig {

@Bean
@Primary
public ReactorServiceInstanceLoadBalancer grayLoadBalancer(Environment environment,
LoadBalancerClientFactory factory) {
String serviceName = environment.getProperty(LoadBalancerClientFactory.PROPERTY_NAME);
// 重点:把 ObjectProvider 包装为 Supplier
return new GrayReactorLoadBalancer(
() -> factory.getLazyProvider(serviceName, ServiceInstanceListSupplier.class).getIfAvailable(),
serviceName
);
}
}

4. 通用路由与路径解析(ParseServiceGatewayFilterFactory)

配合单条路由 Path={serviceId}/**,动态构造 lb://{serviceId} 转发目标,无需为每个服务配置路由。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
@Component
public class ParseServiceGatewayFilterFactory extends AbstractGatewayFilterFactory<ParseServiceGatewayFilterFactory.Config> {

public ParseServiceGatewayFilterFactory() {
super(Config.class);
}

@Override
public GatewayFilter apply(Config config) {
return (exchange, chain) -> {
Map<String, String> pathVars = ServerWebExchangeUtils.getUriTemplateVariables(exchange);
String serviceId = pathVars.get("serviceId");
if (StringUtils.isBlank(serviceId)) {
exchange.getResponse().setStatusCode(HttpStatus.NOT_FOUND);
return exchange.getResponse().setComplete();
}
URI realLbUri = URI.create("lb://" + serviceId);
// 覆盖网关转发目标URI属性
exchange.getAttributes().put(ServerWebExchangeUtils.GATEWAY_REQUEST_URL_ATTR, realLbUri);
return chain.filter(exchange);
};
}

public static class Config {
}
}

5. 服务层:HTTP 入口标记透传(GrayFlagFilter)

Servlet 过滤器,order = 0,在 AdornRequestBodyFilter(1)、AuthFilter(2) 之前执行。读到 X-Gray-Flag: true 时写入 Dubbo RpcContext,finally 清理防线程池复用泄漏。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class GrayFlagFilter implements Filter {

@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
try {
HttpServletRequest httpServletRequest = (HttpServletRequest) request;
String flag = httpServletRequest.getHeader(GrayDubboConsumerFilter.X_GRAY_FLAG);
if ("true".equals(flag)) {
RpcContext.getClientAttachment().setAttachment(GrayDubboConsumerFilter.ATTACHMENT_GRAY, "true");
}
chain.doFilter(request, response);
} finally {
// 清理,防止线程池/线程复用导致灰度标记泄漏到下一个请求
RpcContext.getClientAttachment().removeAttachment(GrayDubboConsumerFilter.ATTACHMENT_GRAY);
}
}
}

注册(FilterRegisterConfig):

1
2
3
4
5
6
7
8
@Bean
public FilterRegistrationBean<GrayFlagFilter> grayFlagFilter() {
FilterRegistrationBean<GrayFlagFilter> bean = new FilterRegistrationBean<>();
bean.setFilter(new GrayFlagFilter());
bean.addUrlPatterns("/*");
bean.setOrder(0);
return bean;
}

6. Dubbo 灰度透传双 Filter(consumer / provider)

consumer 端(GrayDubboConsumerFilter,@Activate(group = "consumer") 自动激活):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
//注意:Web环境,仅在HTTP入口接收请求后发起dubbo调用生效
@Activate(group = "consumer")
public class GrayDubboConsumerFilter implements Filter {

public static final String X_GRAY_FLAG = "X-Gray-Flag";
public static final String ATTACHMENT_GRAY = "gray"; // 与 provider URL 参数同名

public Result invoke(Invoker<?> invoker, Invocation invocation) {
String gray = invocation.getAttachment(ATTACHMENT_GRAY);
if (gray == null) {
gray = RpcContext.getClientAttachment().getAttachment(ATTACHMENT_GRAY);
}
if ("true".equals(gray)) {
RpcContext.getClientAttachment().setAttachment(ATTACHMENT_GRAY, "true");
} else {
RpcContext.getClientAttachment().removeAttachment(ATTACHMENT_GRAY);
}
return invoker.invoke(invocation);
}
}

provider 端(GrayProviderFilter,@Activate(group = "provider"),支持 A→B→C 嵌套链路):

1
2
3
4
5
6
7
8
9
10
11
12
@Activate(group = "provider")
public class GrayProviderFilter implements Filter {

@Override
public Result invoke(Invoker<?> invoker, Invocation invocation) throws RpcException {
String gray = invocation.getAttachment(GrayDubboConsumerFilter.ATTACHMENT_GRAY);
if ("true".equals(gray)) {
RpcContext.getClientAttachment().setAttachment(GrayDubboConsumerFilter.ATTACHMENT_GRAY, "true");
}
return invoker.invoke(invocation);
}
}

7. Dubbo 灰度负载均衡(GrayDubboLoadBalance)

灰度调用只选 provider URL 参数 gray=true 的 invoker,普通调用排除,无候选时回退全部。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
@Slf4j
public class GrayDubboLoadBalance extends AbstractLoadBalance {

static {
// 诊断日志:确认 dubbo 是否加载了本负载均衡扩展
log.info("[GrayLB] GrayDubboLoadBalance LOADED, classloader={}", GrayDubboLoadBalance.class.getClassLoader());
}

private final Random random = new Random();

@Override
protected <T> Invoker<T> doSelect(List<Invoker<T>> invokers, URL url, Invocation invocation) {
String gray = RpcContext.getClientAttachment().getAttachment(GrayDubboConsumerFilter.ATTACHMENT_GRAY);
if (gray == null) {
gray = invocation.getAttachment(GrayDubboConsumerFilter.ATTACHMENT_GRAY); // 兜底
}
boolean isGray = "true".equals(gray);
List<Invoker<T>> candidates;
if (isGray) {
candidates = invokers.stream()
.filter(i -> "true".equals(i.getUrl().getParameter("gray", "false")))
.collect(Collectors.toList());
} else {
candidates = invokers.stream()
.filter(i -> !"true".equals(i.getUrl().getParameter("gray", "false")))
.collect(Collectors.toList());
}
//兜底
if (candidates.isEmpty()) {
log.info("[GrayLB] candidates empty, fallback to all invokers");
candidates = invokers;
}
Invoker<T> invoker = candidates.get(random.nextInt(candidates.size()));
log.info("[GrayLB] SELECTED gray={} method={} invoker={}",
gray, invocation.getMethodName(), invoker.getUrl().getUrlAddress());
return invoker;
}
}

8. Dubbo SPI 扩展点注册

META-INF/dubbo/org.apache.dubbo.rpc.Filter:

1
2
3
grayConsumerFilter=com.rh.joylife.filter.GrayDubboConsumerFilter
grayProviderFilter=com.rh.joylife.filter.GrayProviderFilter
dubboTrace=com.rh.joylife.filter.DubboTraceFilter

META-INF/dubbo/org.apache.dubbo.rpc.cluster.LoadBalance:

1
gray=com.rh.joylife.loadbalance.GrayDubboLoadBalance

9. 实例标记配置(测试配置,通过启动脚本 3 个 JVM 参数控制)

说明:gray 标记不写死在配置文件,bootstrap.yml 中改为 3 个独立占位符,由启动脚本 JVM 参数控制。3 个参数必须同时传对,否则链路会错乱:

  • METADATA_GRAY:控制 Nacos 注册元数据 gray —— 网关 GrayReactorLoadBalancer 按此筛选转发目标(HTTP 层)
  • PROVIDER_GRAY:控制 Dubbo provider URL 参数 gray —— GrayDubboLoadBalance 按此筛选调用目标(RPC 层)
  • CONSUMER_LB:控制消费方 Dubbo 负载均衡策略 —— 必须为 gray,否则不走 GrayDubboLoadBalance,灰度标记完全失效

为什么缺一不可:若 METADATA_GRAY=true 而 PROVIDER_GRAY=false,网关会把灰度流量转发给该实例,但该实例的 Dubbo URL 上没有 gray=true,下游服务灰度调用时选不到它(或兜底混调),灰白链路错乱;若 CONSUMER_LB 不是 gray,消费方根本不会走灰度负载均衡,灰度分流直接失效。

bootstrap.yml(默认值即稳定实例语义,灰度实例由脚本传参开启):

1
2
3
4
5
6
7
8
9
10
11
12
spring:
cloud:
nacos:
discovery:
metadata: # ① 网关负载均衡按此元数据筛选
gray: ${METADATA_GRAY:false}
dubbo:
provider:
parameters: # ② Dubbo URL 参数,GrayDubboLoadBalance 按此筛选
gray: ${PROVIDER_GRAY:false}
consumer:
loadbalance: ${CONSUMER_LB:gray} # ③ 消费方负载均衡策略

启动脚本示例(灰度实例 vs 稳定实例):

1
2
3
4
5
6
7
8
9
10
# 灰度实例:3 个参数同时传
java \
-Ddubbo.provider.parameters.gray=true \
-Ddubbo.consumer.loadbalance=gray \
-Dspring.cloud.nacos.discovery.metadata.gray=true \
-jar joylife-App-user.jar

# 稳定实例:不传灰度标记
java \
-jar joylife-App-user.jar

10. 网关侧配置(测试配置,gateway.gray 放 Nacos 动态下发)

说明:bootstrap.yml 中只保留通用路由等基础配置;gateway.gray.enable、gateway.gray.ip-whitelist 从本地文件移除,改放到 Nacos 配置中心(data-id:joylife-gateway.yaml,由 spring.cloud.nacos.config 拉取)。配合 GrayConfigProperties 上的 @RefreshScope,白名单增删后自动动态生效,无需重启网关——放量/收回灰度 IP 直接改 Nacos 即可。

bootstrap.yml(仅保留路由基础配置):

1
2
3
4
5
6
7
8
9
10
spring:
cloud:
gateway:
routes:
- id: all-service-route
predicates: [ Path={serviceId}/** ]
filters:
- name: ParseServiceGatewayFilterFactory
- StripPrefix=2
uri: lb://placeholder

Nacos 配置(joylife-gateway.yaml):

1
2
3
4
5
6
gateway:
gray:
enable: true
ip-whitelist:
- 127.0.0.1
- 192.168.1.100

网关启动类需开启配置扫描:@ConfigurationPropertiesScan。

五、灰度发布操作流程

  1. 发布前:新版本实例启动脚本传 -DMETADATA_GRAY=true -DPROVIDER_GRAY=true(无需改配置重新打包),消费方 CONSUMER_LB=gray 默认开启
  2. 放量:新实例注册到 Nacos;在 Nacos joylife-gateway.yaml 中把测试人员 IP 加入 gateway.gray.ip-whitelist,动态生效无需重启网关
  3. 验证:白名单 IP 走灰度实例,普通用户走稳定实例,流量隔离互不影响
  4. 全量:验证通过后,灰度实例去掉 -D...GRAY=true 参数重启(或全量替换),灰度流量自动收敛到稳定版

六、关键技术点与注意事项

点 说明
灰度判定时机 网关在最外层(order=-50)完成判定并染色,保证下游统一读 header
线程安全 Servlet 层 finally 清理 attachment,Dubbo 层 filter 负责传递,防止 ThreadLocal 泄漏
嵌套链路 provider filter 保证 A→B→C 多级 RPC 调用灰度标记不丢失
兜底设计 网关 LB 和 Dubbo LB 都有”无匹配→降级全部”逻辑,避免灰度实例未就绪时 503
双通道一致 Spring Cloud LoadBalancer(HTTP 网关)和 Dubbo LoadBalance(RPC)两套选择逻辑各自实现,需同步维护
三参数联动 实例侧 METADATA_GRAY / PROVIDER_GRAY / CONSUMER_LB 必须一致,防止网关与 Dubbo 灰度判定错位
动态路由 通用路由 + 路径解析,StripPrefix=2 剥掉 /{serviceId} 前缀后再转发
版本坑点 SCG 3.1.x 必须实现 Ordered 接口,@Order 注解会被 FilteringWebHandler 忽略
诊断手段 GrayDubboLoadBalance static 块 + 每次选择打日志;DebugGatewayFilter 打印转发目标 URI

七、涉及文件清单

模块 文件 作用
gateway GrayStainGlobalFilter IP 判定 + 流量染色(全局过滤器)
gateway GrayConfigProperties 灰度配置属性(支持动态刷新)
gateway GrayLoadBalancerConfig 注册自定义网关负载均衡器
gateway GrayReactorLoadBalancer 网关实例筛选负载均衡
gateway ParseServiceGatewayFilterFactory 通用路由 serviceId 解析
gateway DebugGatewayFilter 转发目标调试日志
gateway JoyLifeGatewayApplication 开启 @ConfigurationPropertiesScan
gateway bootstrap.yml 通用路由基础配置
common-core GrayFlagFilter HTTP 入口标记写入 RpcContext
common-core GrayDubboConsumerFilter consumer 端标记透传
common-core GrayProviderFilter provider 端嵌套链路透传
common-core GrayDubboLoadBalance Dubbo 灰度负载均衡
common-core FilterRegisterConfig Servlet 过滤器注册
common-core META-INF/dubbo/* Dubbo SPI 扩展点注册
App-openapi / App-user bootstrap.yml 灰度标记占位符 + 消费端负载均衡策略

(注:内容由 AI 辅助编写)

[越努力,越幸运!]