灰度发布

背景和价值

在 Spring Cloud 架构中,灰度发布(又称金丝雀发布)的核心目标是将新版本服务仅对部分用户 / 流量开放,验证稳定性后再全量上线,以降低发布风险。实现需结合服务注册发现、负载均衡、网关路由等组件,通过 “服务标识 + 规则路由” 实现流量隔离。以下是具体实现方案和关键技术点:

image

版本标识传递:
动态负载均衡:
配置中心动态调整

组件 职责
网关(Gateway) 1. 初始化版本标识(X-Version);2. 接收外部系统请求并识别灰度规则;3. 路由时触发负载均衡
业务服务 1. 通过Feign拦截器传递版本标识;2. 注册时携带版本元数据(metadata.version)
负载均衡器 扩展Spring Cloud LoadBalancer,根据X-Version筛选匹配版本的服务实例
配置中心 存储灰度规则(如网关过滤规则)、服务版本映射,支持动态刷新。

流程

准备工作:

应用服务配置 nacos元数据版本

Nacos 配置灰度规则

  • 灰度总开关
  • 单一的灰度规则(比如仅按 1% 流量)过于粗糙,无法应对复杂的业务和运维场景。使用多条规则,基于priority 优先匹配,
    如:需要确保某些最重要的用户或内部测试团队能强制使用新的灰度版本,而不会被随机流量规则打乱。

http header

x-gray-version:
enable-gray-flag: 灰度全局开关

业务网关

灰度规则计算并传递

  • 集中决策,所有的灰度路由规则(用户、流量比例等)只在业务网关(Gateway)集中计算一次。,保证灰度决策的一致性和原子性,避免了在BFF和业务服务器上重复实现复杂的路由逻辑。
  • 标签透传,网关将决策结果(例如:X-Gray-Version: v2-gray)写入HTTP Header,并沿着调用链向下传递。,简化下游服务的逻辑,下游服务无需关心决策依据,只关心标签。 分布式执行:BFF和业务服务器的负载均衡器(如Ribbon/Spring Cloud LoadBalancer),根据请求Header中的标签,从服务注册中心(Nacos)筛选出匹配版本的实例进行调用。,解耦了路由决策和实例选择,提高了调用链的效率和可维护性。
  • 支持复杂场景的灰度规则配置。比如 需求: 将 VIP 用户导向 v3 (蓝绿) 版本,将普通测试用户导向 v2 (金丝雀) 版本,剩余 1% 流量导向 v1 (基线) 版本进行小流量测试。
    灰度发布配置。 为了支持这个需求,在rules下增加target-version配置

业务网关处理算法

  • 当任何一个灰度规则都没有路由到,要如何处理。答案是: 不设置默认版本。您必须确保您的负载均衡器扩展点选择所有不带任何版本元数据或带有基线版本元数据的实例。
    这会引入新的问题:

如果默认引入基线版本会导致耦合: Gateway 耦合了基线版本的具体标签 (V1)。如果基线版本标签未来改为 Base 或 Stable,您需要同时修改 Gateway。
不必要的开销: 所有的基线流量(90%以上的流量)都带着一个冗余的 Header 传输,增加了网络开销和下游服务的处理负担。


gray:
  # 1. 灰度发布总开关和版本定义
  enabled: true       # gray.enabled
  version: "v2-gray"  # 灰度版本标签,通常写到Header中

  # 2. 统一的全局路由规则清单 (SCG只需要加载和执行这里的规则)
  # 现在 rules 是 gray 的直接子属性
  rules: 
    # 规则 1: 用户ID灰度
    - id: "rule-1-user"
      type: "header" # 匹配请求头规则
      match-header: "X-User-User-ID"
      match-value: "user001,user002"
      target-version: "v2-gray"
      priority: 1
    
    # 规则 2: Cookie灰度 (示例)
    - id: "rule-2-cookie"
      type: "cookie"
      match-cookie: "X-GRAY-FLAG"
      match-value: "true"
      target-version: "v2-gray"
      priority: 2
      
    # 规则 3: 流量百分比(放在最后)
    - id: "rule-3-traffic-percentage"
      type: "percentage"
      percentage: 1
      target-version: "v2-gray"
      priority: 99
      
  # 3. (可选)服务特定的元数据,用于服务侧检查
  services: 
    order-service:
      enabled: true
    item-service:
      enabled: false

灰度版本请求传递

package com.example.orderservice.config;

import com.example.gateway.common.GrayContstants; // 假设常量已共享或复制
import feign.RequestInterceptor;
import feign.RequestTemplate;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;

@Configuration
public class FeignClientConfiguration {

    @Bean
    public RequestInterceptor requestInterceptor() {
        return new RequestInterceptor() {
            @Override
            public void apply(RequestTemplate template) {
                ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
                
                if (attributes != null) {
                    // 从当前请求的 Header 中获取灰度版本
                    String grayVersion = attributes.getRequest().getHeader(GrayContstants.GRAY_VERSION_HEADER);
                    
                    if (grayVersion != null) {
                        // 将灰度版本添加到 Feign 请求的 Header 中进行透传
                        template.header(GrayContstants.GRAY_VERSION_HEADER, grayVersion);
                    }
                }
            }
        };
    }
}

服务路由(客户端)负载均衡器

package com.example.gateway.config;

import org.springframework.cloud.loadbalancer.core.ServiceInstanceListSupplier;
import org.springframework.context.ConfigurableApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

/**
 * 针对所有服务应用的 LoadBalancer 配置,使用自定义的灰度规则
 */
@Configuration
public class GrayLoadBalancerConfiguration {

    @Bean
    public ServiceInstanceListSupplier discoveryClientServiceInstanceListSupplier(
            ConfigurableApplicationContext context) {

        // 确保使用 Spring Cloud LoadBalancer 默认的发现机制
        ServiceInstanceListSupplier delegate = ServiceInstanceListSupplier.builder()
                .withDiscoveryClient() // 使用 DiscoveryClient 获取所有实例
                .withCaching()         // 启用缓存
                .build(context);

        // 包装默认的 Supplier,使用我们自定义的灰度过滤逻辑
        return new GrayServiceInstanceListSupplier(delegate);
    }
}


package com.example.gateway.config;


import com.example.gateway.constants.GrayContstants;
import org.springframework.cloud.client.ServiceInstance;
import org.springframework.cloud.client.loadbalancer.Request;
import org.springframework.cloud.loadbalancer.core.ServiceInstanceListSupplier;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Flux;
import java.util.List;
import java.util.stream.Collectors;

/**
 * 基于请求 Header 过滤服务实例列表的 ServiceInstanceListSupplier
 */
public class GrayServiceInstanceListSupplier implements ServiceInstanceListSupplier {

    private final ServiceInstanceListSupplier delegate;

    public GrayServiceInstanceListSupplier(ServiceInstanceListSupplier delegate) {
        this.delegate = delegate;
    }

    @Override
    public String getServiceId() {
        return delegate.getServiceId();
    }

    @Override
    public Flux<List<ServiceInstance>> get() {
        return delegate.get();
    }

    /**
     * 核心方法:在获取实例列表后进行过滤
     */
    @Override
    public Flux<List<ServiceInstance>> get(Request request) {
        return delegate.get(request)
                .map(instances -> filterGrayInstances(request, instances));
    }

    /**
     * 过滤逻辑
     */
    private List<ServiceInstance> filterGrayInstances(Request request, List<ServiceInstance> instances) {
        // 1. 获取请求上下文,提取目标版本
        if (!(request.getContext() instanceof ServerWebExchange)) {
            // 如果不是 Gateway 请求,不处理(例如来自 Feign Client)
            return instances;
        }

        ServerWebExchange exchange = (ServerWebExchange) request.getContext();
        String targetVersion = exchange.getRequest().getHeaders().getFirst(GrayContstants.GRAY_HEADER);

        // 2. 基线流量处理:如果 Header 为空,返回所有没有版本标签的实例
        if (targetVersion == null || targetVersion.isEmpty()) {
            return instances.stream()
                    .filter(instance -> !instance.getMetadata().containsKey("version")) // 过滤掉所有带版本标签的实例
                    .collect(Collectors.toList());
        }

        // 3. 灰度流量处理:如果 Header 不为空,返回匹配目标版本的实例
        List<ServiceInstance> grayInstances = instances.stream()
                .filter(instance -> targetVersion.equals(instance.getMetadata().get("version")))
                .collect(Collectors.toList());

        // 4. 容错处理:如果灰度实例为空,降级到基线实例(可选的安全策略)
        if (grayInstances.isEmpty()) {
            // 注意:这里需要根据业务决定是否降级。
            // 降级:返回所有基线实例
            // 严格:返回空列表(导致路由失败,更安全)
            System.err.println("WARN: Gray version " + targetVersion + " not found. Falling back to base instances.");
            return instances.stream()
                    .filter(instance -> !instance.getMetadata().containsKey("version"))
                    .collect(Collectors.toList());
        }

        return grayInstances;
    }
}

参考资料

posted @ 2025-11-07 20:34  向着朝阳  阅读(13)  评论(0)    收藏  举报