Implementing Gray Release (Canary Deployment) in Spring Cloud with Nacos and Ribbon
This article explains how to implement a gray (canary) release strategy in a Spring Cloud microservice ecosystem using Nacos for service discovery, Spring Cloud Gateway for request routing, Ribbon for load balancing, and custom filters and interceptors to control traffic based on request headers, IP, city or user ID.
Gray release, also known as canary deployment, is a gradual rollout technique that allows a subset of users to use a new version (B) while the rest continue with the current version (A). By monitoring the behavior of the gray group, issues can be identified early before the new version is rolled out to all users.
The demo project uses Spring Boot 2.3.12, Spring Cloud Hoxton.SR12, and Spring Cloud Alibaba 2.2.9. Core components include Nacos as the service registry, Spring Cloud Gateway as the API gateway, Ribbon (or Spring Cloud LoadBalancer) for client‑side load balancing, and OpenFeign for RPC calls.
Project structure :
spring-cloud-gray-example // parent project
├─ kerwin-common // common module
├─ kerwin-gateway // gateway service
├─ kerwin-order // order service
│ └─ order-app // order business service
├─ kerwin-starter // custom starter
│ └─ spring-cloud-starter-kerwin-gray // gray release starter (core code)
├─ kerwin-user // user service
│ ├─ user-app // user business service
│ └─ user-client // Feign client and DTOsThe gray release logic relies on a GrayFlagRequestHolder that stores a GrayStatusEnum (ALL, PROD, GRAY) in a ThreadLocal . The gateway pre‑filter determines the gray status based on a configurable request header, IP whitelist, city list or user ID list, and writes the status into the holder and request header.
public class GrayFlagRequestHolder {
private static final ThreadLocal
grayFlag = new ThreadLocal<>();
public static void setGrayTag(final GrayStatusEnum tag) { grayFlag.set(tag); }
public static GrayStatusEnum getGrayTag() { return grayFlag.get(); }
public static void remove() { grayFlag.remove(); }
}The GrayGatewayBeginFilter (a GlobalFilter ) checks the gray switch, evaluates the request, sets the appropriate GrayStatusEnum , and forwards the request with the gray header.
public class GrayGatewayBeginFilter implements GlobalFilter, Ordered {
@Autowired private GrayGatewayProperties grayGatewayProperties;
@Override public Mono
filter(ServerWebExchange exchange, GatewayFilterChain chain) {
GrayStatusEnum grayStatusEnum = GrayStatusEnum.ALL;
if (grayGatewayProperties.getEnabled()) {
grayStatusEnum = GrayStatusEnum.PROD;
if (checkGray(exchange.getRequest())) { grayStatusEnum = GrayStatusEnum.GRAY; }
}
GrayFlagRequestHolder.setGrayTag(grayStatusEnum);
ServerHttpRequest newRequest = exchange.getRequest().mutate()
.header(GrayConstant.GRAY_HEADER, grayStatusEnum.getVal()).build();
return chain.filter(exchange.mutate().request(newRequest).build());
}
// ...methods checkGrayHeadKey, checkGrayIPList, checkGrayCiryList, checkGrayUserNoList...
@Override public int getOrder() { return Ordered.HIGHEST_PRECEDENCE; }
}A corresponding post‑filter removes the thread‑local value to avoid memory leaks.
public class GrayGatewayAfterFilter implements GlobalFilter, Ordered {
@Override public Mono
filter(ServerWebExchange exchange, GatewayFilterChain chain) {
GrayFlagRequestHolder.remove();
return chain.filter(exchange);
}
@Override public int getOrder() { return Ordered.LOWEST_PRECEDENCE; }
}The global exception handler also clears the holder when an exception occurs.
public class GrayGatewayExceptionHandler implements WebExceptionHandler, Ordered {
@Override public Mono
handle(ServerWebExchange exchange, Throwable ex) {
GrayFlagRequestHolder.remove();
ServerHttpResponse response = exchange.getResponse();
if (ex instanceof ResponseStatusException) {
response.setStatusCode(((ResponseStatusException) ex).getStatus());
} else {
response.setStatusCode(HttpStatus.INTERNAL_SERVER_ERROR);
}
return response.setComplete();
}
@Override public int getOrder() { return Ordered.HIGHEST_PRECEDENCE; }
}For client‑side routing, a custom Ribbon rule AbstractGrayLoadBalancerRule filters the server list according to the gray status stored in the holder and the version metadata registered in Nacos.
public abstract class AbstractGrayLoadBalancerRule extends AbstractLoadBalancerRule {
@Autowired private GrayVersionProperties grayVersionProperties;
@Value("${spring.cloud.nacos.discovery.metadata.version}") private String metaVersion;
public List
getReachableServers() { return getGrayServers(getLoadBalancer().getReachableServers()); }
public List
getAllServers() { return getGrayServers(getLoadBalancer().getAllServers()); }
protected List
getGrayServers(List
servers) {
String currentVersion = metaVersion;
GrayStatusEnum status = GrayFlagRequestHolder.getGrayTag();
if (status != null) {
switch (status) {
case PROD: currentVersion = grayVersionProperties.getProdVersion(); break;
case GRAY: currentVersion = grayVersionProperties.getGrayVersion(); break;
default: return servers; // ALL
}
}
List
result = new ArrayList<>();
for (Server s : servers) {
NacosServer ns = (NacosServer) s;
String version = ns.getMetadata().get("version");
if (version != null && version.equals(currentVersion)) { result.add(s); }
}
return result;
}
}Using the above rule, a custom round‑robin implementation GrayRoundRobinRule can be plugged in via the Ribbon property NFLoadBalancerRuleClassName .
Business services also need to propagate the gray flag. A Spring MVC interceptor reads the gray header and stores it in the holder, while a Feign interceptor adds the header to outbound RPC calls.
@SuppressWarnings("all")
public class GrayMvcHandlerInterceptor implements HandlerInterceptor {
@Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
String grayTag = request.getHeader(GrayConstant.GRAY_HEADER);
if (grayTag != null) { GrayFlagRequestHolder.setGrayTag(GrayStatusEnum.getByVal(grayTag)); }
return true;
}
@Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
GrayFlagRequestHolder.remove();
}
} public class GrayFeignRequestInterceptor implements RequestInterceptor {
@Override public void apply(RequestTemplate template) {
GrayStatusEnum status = GrayFlagRequestHolder.getGrayTag();
if (status != null) { template.header(GrayConstant.GRAY_HEADER, Collections.singleton(status.getVal())); }
}
}Basic configuration classes define the gray header name, enable flag, whitelist IPs, cities and user IDs, as well as the production and gray version identifiers.
public interface GrayConstant { String GRAY_HEADER = "gray"; } public enum GrayStatusEnum {
ALL("ALL", "All versions"),
PROD("PROD", "Production version"),
GRAY("GRAY", "Gray version");
private final String val; private final String desc;
GrayStatusEnum(String v, String d) { this.val = v; this.desc = d; }
public String getVal() { return val; }
public static GrayStatusEnum getByVal(String v) { if (v == null) return null; for (GrayStatusEnum e : values()) if (e.val.equals(v)) return e; return null; }
} @Data @Configuration @RefreshScope @ConfigurationProperties("kerwin.tool.gray.gateway")
public class GrayGatewayProperties {
private Boolean enabled = false;
private String grayHeadKey = "gray";
private String grayHeadValue = "gray-996";
private List
grayIPList = new ArrayList<>();
private List
grayCityList = new ArrayList<>();
private List
grayUserNoList = new ArrayList<>();
} @Data @Configuration @RefreshScope @ConfigurationProperties("kerwin.tool.gray.version")
public class GrayVersionProperties {
private String prodVersion;
private String grayVersion;
}Auto‑configuration classes conditionally register the gateway filters, MVC interceptor and Feign interceptor based on the property kerwin.tool.gray.load .
@Configuration @ConditionalOnProperty(value = "kerwin.tool.gray.load", havingValue = "true")
@EnableConfigurationProperties(GrayVersionProperties.class)
public class GrayAutoConfiguration {
@Configuration(proxyBeanMethods = false)
@ConditionalOnClass(GlobalFilter.class)
@EnableConfigurationProperties(GrayGatewayProperties.class)
static class GrayGatewayFilterAutoConfiguration {
@Bean public GrayGatewayBeginFilter grayGatewayBeginFilter() { return new GrayGatewayBeginFilter(); }
@Bean public GrayGatewayAfterFilter grayGatewayAfterFilter() { return new GrayGatewayAfterFilter(); }
@Bean public GrayGatewayExceptionHandler grayGatewayExceptionHandler() { return new GrayGatewayExceptionHandler(); }
}
@Configuration(proxyBeanMethods = false)
@ConditionalOnClass(WebMvcConfigurer.class)
static class GrayWebMvcAutoConfiguration {
@Bean public WebMvcConfigurer webMvcConfigurer() {
return new WebMvcConfigurer() { @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(new GrayMvcHandlerInterceptor()); } };
}
}
@Configuration @ConditionalOnClass(RequestInterceptor.class)
static class GrayFeignInterceptorAutoConfiguration {
@Bean public GrayFeignRequestInterceptor grayFeignRequestInterceptor() { return new GrayFeignRequestInterceptor(); }
}
}Configuration files (YAML) illustrate how to enable the gray module, set production/gray versions, and configure Ribbon to use the custom rule.
# common-config.yaml
kerwin:
tool:
gray:
load: true
version:
prodVersion: V1
grayVersion: V2
user-app:
ribbon:
NFLoadBalancerRuleClassName: com.kerwin.gray.loadbalancer.GrayRoundRobinRule
order-app:
ribbon:
NFLoadBalancerRuleClassName: com.kerwin.gray.loadbalancer.GrayRoundRobinRule # gateway-app.yaml
kerwin:
tool:
gray:
gateway:
enabled: true
grayHeadKey: gray
grayHeadValue: gray-996
grayIPList:
- '127.0.0.1'
grayCityList:
- 本地Running the demo starts five services: one gateway, two versions of the user service, and two versions of the order service. By toggling the gateway gray switch or providing the special request header gray=gray-996 , traffic can be directed to either the production (V1) or gray (V2) versions, demonstrating the gradual rollout capability.
Source code repository: https://gitee.com/kerwin_code/spring-cloud-gray-example
Finally, the author encourages readers to like, share, and follow for more technical articles.
Code Ape Tech Column
Former Ant Group P8 engineer, pure technologist, sharing full‑stack Java, job interview and career advice through a column. Site: java-family.cn
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.