灰度发布

灰度发布是通过切换线上并存版本之间的路由权重,逐步从一个版本切换为另一个版本的过程。虽然有很多人包括专业大牛认为灰度发布与金丝雀发布是等同的,但是在具体的操作和目的上面个还是有些许差别的。金丝雀发布更倾向于获取快速的反馈,而灰度发布更倾向于从一个版本到另一个版本平稳的切换。

环境支持:

  • Spring Boot 2.x
  • Maven 3.x
  • Java 8
  • Lombok
  • transmittable-thread-local
  • spring-cloud-starter-netflix-zuul
  1. 微服务客户端添加配置
eureka.instance.metadata-map.version=v1 #当前微服务版本
  1. zuul添加配置
zuul.ribbon.canary.enabled=true
  1. 自定义ribbon负载均衡server选择
import com.frdscm.gateway.SecurityConstants;
import com.frdscm.gateway.util.RibbonVersionHolder;
import com.google.common.base.Optional;
import com.netflix.loadbalancer.Server;
import com.netflix.loadbalancer.ZoneAvoidanceRule;
import com.netflix.niws.loadbalancer.DiscoveryEnabledServer;
import com.xiaoleilu.hutool.util.StrUtil;
import lombok.extern.slf4j.Slf4j;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;

@Slf4j
public class MetadataCanaryRuleHandler extends ZoneAvoidanceRule {
    @Override
    public Server choose(Object key) {
        List<Server> eligibleServers = this.getPredicate().getEligibleServers(this.getLoadBalancer().getAllServers(), key);
        if (eligibleServers == null || eligibleServers.size() < 1) {
            return null;
        }
        String targetVersion = RibbonVersionHolder.getContext();
        if (StrUtil.isBlank(targetVersion)) {
            log.info("Client Not config version");
            List<Server> roundRobinNotVersionServer = eligibleServers.stream().filter(server -> {
                Map<String, String> metadata = ((DiscoveryEnabledServer) server).getInstanceInfo().getMetadata();
                String metaVersion = metadata.get("version");
                return StrUtil.isBlank(metaVersion);
            }).collect(Collectors.toList());
            if (roundRobinNotVersionServer.size() < 1) {
                return eligibleServers.get(0);
            }
            if (roundRobinNotVersionServer.size() == 1) {
                return roundRobinNotVersionServer.get(0);
            }
            Optional<Server> server = this.getPredicate().chooseRoundRobinAfterFiltering(roundRobinNotVersionServer, key);
            return server.isPresent() ? server.get() : null;
        } else {
            List<Server> roundRobinVersionServer = eligibleServers.stream().filter(server -> {
                Map<String, String> metadata = ((DiscoveryEnabledServer) server).getInstanceInfo().getMetadata();
                String metaVersion = metadata.get("version");
                return targetVersion.equals(metaVersion);
            }).collect(Collectors.toList());
            if (roundRobinVersionServer.size() < 1) {
                return null;
            }
            if (roundRobinVersionServer.size() == 1) {
                return roundRobinVersionServer.get(0);
            }
            Optional<Server> server = this.getPredicate().chooseRoundRobinAfterFiltering(roundRobinVersionServer, key);
            return server.isPresent() ? server.get() : null;
        }
    }
}
  1. 版本上下文Alibaba TTL
import com.alibaba.ttl.TransmittableThreadLocal;

public class RibbonVersionHolder {

    private static final ThreadLocal<String> context = new TransmittableThreadLocal<>();

    public static String getContext() {
        return context.get();
    }

    public static void setContext(String value) {
        context.set(value);
    }

    public static void clearContext() {
        context.remove();
    }
}
  1. 初始化灰度发布
import com.frdscm.gateway.handler.MetadataCanaryRuleHandler;
import com.netflix.loadbalancer.ZoneAvoidanceRule;
import com.netflix.niws.loadbalancer.DiscoveryEnabledNIWSServerList;
import org.springframework.beans.factory.config.ConfigurableBeanFactory;
import org.springframework.boot.autoconfigure.AutoConfigureBefore;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.cloud.netflix.ribbon.RibbonClientConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Scope;

@Configuration
@ConditionalOnClass(DiscoveryEnabledNIWSServerList.class)
@AutoConfigureBefore(RibbonClientConfiguration.class)
@ConditionalOnProperty(value = "zuul.ribbon.canary.enabled")
public class RibbonMetaFilterAutoConfiguration {

    @Bean
    @ConditionalOnMissingBean
    @Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE)
    public ZoneAvoidanceRule metadataAwareRule() {
        return new MetadataCanaryRuleHandler();
    }

}
  1. zuul中添加AccessFilter
import com.netflix.zuul.ZuulFilter;
import com.netflix.zuul.context.RequestContext;
import com.smartcomma.scaffolding.gateway.util.RibbonVersionHolder;
import com.xiaoleilu.hutool.util.StrUtil;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.cloud.netflix.zuul.filters.support.FilterConstants;
import org.springframework.stereotype.Component;
import static org.springframework.cloud.netflix.zuul.filters.support.FilterConstants.FORM_BODY_WRAPPER_FILTER_ORDER;

@Component
public class AccessFilter extends ZuulFilter {

    @Value("${zuul.ribbon.canary.enabled:false}")
    private boolean canary;

    @Override
    public String filterType() {
        return FilterConstants.PRE_TYPE;
    }

    @Override
    public int filterOrder() {
        return FORM_BODY_WRAPPER_FILTER_ORDER - 1;
    }

    @Override
    public boolean shouldFilter() {
        return true;
    }

    @Override
    public Object run() {
        RequestContext requestContext = RequestContext.getCurrentContext();
        String version = requestContext.getRequest().getHeader("version");
        if (canary && StrUtil.isNotBlank(version)) {
            RibbonVersionHolder.setContext(version);
        } else {
            RibbonVersionHolder.clearContext();
        }
        return null;
    }
}
  1. 客户端调用时header传入版本,以axios为例
axios.defaults.headers.common['Authorization'] = AUTH_TOKEN;
axios.defaults.headers.common['version'] = 'v1';  #对应eureka metadata 配置