场景
测试环境,采用k8s容器化部署,通过rancher在web界面对资源进行管理;
各项目组有独立的网关,多个微服务(根据业务功能、高内聚低耦合划分);
网关是基于spring-cloud-gateway,定制扩展了一些功能,如鉴权、限流等;
微服务是基于spring-cloud各组件,eureka、ribbon、hystrix等;
由于是测试环境使用,网关和各微服务都只部署了1个节点;
持续集成(git+jenkins+docker+harbor+rancher)
当开发修改项目代码、自测检查后,提交git仓库,通过jenkins构建打包生成docker镜像并推送至harbor,
然后通过rancher界面,点击重新部署按钮,重启部署最新的服务。
这是一套测试环境的持续集成流程,从开发到部署。
通常网关不会经常修改,微服务会经常修改,如bug修复、新功能开发、代码优化等。
当在rancher点击重启部署,工作负载的pod会停止旧的启动新的,整个重启过程需要时间,在这个过程中会出现服务不可用的情况。
这也是测试同学经常反馈"抱怨"的:每当开发修改代码重启服务时,会出现频繁报错,影响测试工作的正常进行。
分析
由于测试环境只部署了1个节点,微服务重启,这个过程导致服务有一定时间不可用。
之前写过1篇博客探讨过微服务的优雅停机:Spring-Cloud-Gateway+Ribbon+Eureka微服务优雅停机实践
但这里测试环境只部署了1个节点。单个服务从停止到启动,需要一定耗时。
看看rancher里的缩放/升级策略:
- 滚动: 先启动新 Pod,再停止旧 Pod。
- 滚动: 先停止旧 Pod,再启动新 Pod。
- 删除所有 Pod,然后重新开始。
- 自定义
默认第1个是先启动新Pod,再停止旧Pod。
在重新部署过程中也发现,新Pod是先启动,旧Pod在进行停止和删除的。
注意到界面上缩放/升级策略里还有2个时间配置:
- 最短准备时间:0
- 进度截止时间:600
这里把最短准备时间改为200,即200秒新Pod才被视为可用,这样给服务留了启动时间。
调整这个配置后,当有代码修改重新部署微服务,发现仍然有一定的时间服务不可用,只是比之前的情况好一些。
由于客户端(如web、app、小程序)是调用网关,通过网关路由调用微服务接口的,即客户端 -> spring-cloud-gateway网关服务 -> 微服务。
接口出现不可用,推测是在重启过程中,网关调用到了不可用Pod里的服务。
项目里网关spring-cloud-gateway通过ribbon进行调用方的负载均衡;
网关、各微服务的注册中心是eureka-server;
ribbon获取各服务节点及状态是通过eureka-client来实现的;
由于eureka-server是CAP理论里的AP系统,优先保证可用性(A)和分区容错性(P),不保证强一致性(C);
它本身设计了多级缓存(readWriteCacheMap
、readOnlyCacheMap
);
eureka-client是从缓存中获取数据,有可能服务停止过程,而获取的实例(InstanceInfo
)的状态(status
)是UP
;
ribbon里也设计了缓存,参考DynamicServerListLoadBalancer
、PollingServerListUpdater
等;
因此当服务重启,1个Pod在停止时,可能服务已停止不可用了,但ribbon里还有该节点的Server
,并且状态是存活(isAlive()
方法返回true),
该节点仍然能被负载均衡算法选取到,发起调用造成调用失败。
打开spring-cloud-gateway包的LoadBalancerClientFilter
类,梳理它使用ribbon进行负载均衡选取实例调用,它的choose
方法:
protected ServiceInstance choose(ServerWebExchange exchange) {
return loadBalancer.choose(
((URI) exchange.getAttribute(GATEWAY_REQUEST_URL_ATTR)).getHost());
}
这里的loadBalancer
是LoadBalancerClient
类型,ribbon实现类为RibbonLoadBalancerClient
,查看它的choose
方法:
public ServiceInstance choose(String serviceId, Object hint) {
Server server = getServer(getLoadBalancer(serviceId), hint);
if (server == null) {
return null;
}
return new RibbonServer(serviceId, server, isSecure(server, serviceId),
serverIntrospector(serviceId).getMetadata(server));
}
可以看到,它使用ILoadBalancer
进行实例Server
的选取。
进一步跟踪代码,了解到ribbon默认使用ZoneAwareLoadBalancer
、ZoneAvoidanceRule
,限于篇幅这里不在赘述。
借助arthas去看一看ILoadBalancer
里缓存的实例列表:
- 在rancher里进入网关服务的控制台安装arthas
mkdir arthas
cd arthas
curl -O https://alibaba.github.io/arthas/arthas-boot.jar
- 通过arthas里的
ongl
调用ribbon获取所有实例的方法
java -jar arthas
ognl -c xxx '@com.xxx.utils.SpringContextUtil@getBean(@org.springframework.cloud.netflix.ribbon.SpringClientFactory@class).getLoadBalancer("service-xxx").getAllServers()'
注:
SpringContextUtil
是项目里的工具类,通过实现ApplicationContextAware
接口获取ApplicationContext
实例,提供了便捷获取Spring Bean的方法-c xxx
里的xxx 是hash值,可通过sc -d com.xxx.utils.SpringContextUtil
查看getLoadBalancer("service-xxx")
里的service-xxx是网关调用具体微服务的服务名称
在rancher重新部署微服务,多次执行上面的ognl命令,发现返回的实例列表,从1个变更2个最后变为1个;
当新POD重启成功后,实例列表变为2个,而当旧POD停止时,实例列表仍然是2个,需要等一会儿才变为1个;
这样验证了上面对网关spring-cloud-gateway通过ribbon进行调用方的负载均衡的分析。
解决
有了上述分析和验证,开始思考解决方法。
注意到spring-cloud-gateway的LoadBalancerClientFilter
类,它是一个GlobalFilter
。
考虑对它进行定制,实现一个定制的GlobalFilter
用于使用,重写它的choose()
方法,保证返回的实例是可用的。
原方法里使用了ribbon的RibbonLoadBalancerClient
来选取实例,但ribbon有缓存机制问题。
因为想到直接用eureka-client来获取实例;
eureka-client
也有缓存怎么办?InstanceInfo
里的status
不是准确的;
注意到InstanceInfo
里有个时间戳lastUpdatedTimestamp
字段,表示最后更新时间,
根据缩放/升级策略,新Pod启用,老Pod停止,那么当新Pod里服务启动成功,让每次选取都选取新的实例即可;
这样老Pod在停止服务的过程中,实例不被选取到,即解决了调用失败的问题。
定制的GlobalFilter
代码如下:
import com.netflix.appinfo.InstanceInfo;
import com.netflix.discovery.EurekaClient;
import lombok.Setter;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.springframework.cloud.client.ServiceInstance;
import org.springframework.cloud.client.loadbalancer.LoadBalancerClient;
import org.springframework.cloud.gateway.config.LoadBalancerProperties;
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
import org.springframework.cloud.gateway.filter.GlobalFilter;
import org.springframework.cloud.gateway.filter.LoadBalancerClientFilter;
import org.springframework.cloud.gateway.support.DelegatingServiceInstance;
import org.springframework.cloud.gateway.support.NotFoundException;
import org.springframework.cloud.gateway.support.ServerWebExchangeUtils;
import org.springframework.cloud.netflix.eureka.EurekaDiscoveryClient;
import org.springframework.context.annotation.Profile;
import org.springframework.core.Ordered;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;
import java.net.URI;
import java.util.List;
import static org.springframework.cloud.gateway.support.ServerWebExchangeUtils.*;
/**
* @author cdfive
*/
@Slf4j
@Setter
@Profile(value = {"test"})
public class LatestUpdateTimeLoadBalancerClientFilter implements GlobalFilter, Ordered {
private static final Log log = LogFactory.getLog(LatestUpdateTimeLoadBalancerClientFilter.class);
protected LoadBalancerClient loadBalancer;
private LoadBalancerProperties properties;
private EurekaClient eurekaClient;
public LatestUpdateTimeLoadBalancerClientFilter(LoadBalancerClient loadBalancer, LoadBalancerProperties properties, EurekaClient eurekaClient) {
this.loadBalancer = loadBalancer;
this.properties = properties;
this.eurekaClient = eurekaClient;
}
@Override
public int getOrder() {
return LoadBalancerClientFilter.LOAD_BALANCER_CLIENT_FILTER_ORDER - 1;
}
@Override
@SuppressWarnings("Duplicates")
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
URI url = exchange.getAttribute(GATEWAY_REQUEST_URL_ATTR);
String schemePrefix = exchange.getAttribute(GATEWAY_SCHEME_PREFIX_ATTR);
if (url == null
|| (!"lb".equals(url.getScheme()) && !"lb".equals(schemePrefix))) {
return chain.filter(exchange);
}
// preserve the original url
addOriginalRequestUrl(exchange, url);
if (log.isTraceEnabled()) {
log.trace("LoadBalancerClientFilter url before: " + url);
}
final ServiceInstance instance = choose(exchange);
if (instance == null) {
throw NotFoundException.create(properties.isUse404(),
"Unable to find instance for " + url.getHost());
}
URI uri = exchange.getRequest().getURI();
// if the `lb:<scheme>` mechanism was used, use `<scheme>` as the default,
// if the loadbalancer doesn't provide one.
String overrideScheme = instance.isSecure() ? "https" : "http";
if (schemePrefix != null) {
overrideScheme = url.getScheme();
}
URI requestUrl = loadBalancer.reconstructURI(
new DelegatingServiceInstance(instance, overrideScheme), uri);
if (log.isTraceEnabled()) {
log.trace("LoadBalancerClientFilter url chosen: " + requestUrl);
}
exchange.getAttributes().put(GATEWAY_REQUEST_URL_ATTR, requestUrl);
return chain.filter(exchange);
}
protected ServiceInstance choose(ServerWebExchange exchange) {
URI url = exchange.getAttribute(ServerWebExchangeUtils.GATEWAY_REQUEST_URL_ATTR);
String serviceId = url.getHost();
List<InstanceInfo> instanceInfos = eurekaClient.getInstancesByVipAddress(serviceId, false);
log.info("debugStart=>" + instanceInfos.size());
InstanceInfo info = null;
Long time = null;
for (InstanceInfo instanceInfo : instanceInfos) {
log.debug(instanceInfo.getInstanceId() + "=>" + instanceInfo.getStatus());
if (InstanceInfo.InstanceStatus.UP.equals(instanceInfo.getStatus()) && (time == null || instanceInfo.getLastUpdatedTimestamp() > time)) {
time = instanceInfo.getLastUpdatedTimestamp();
info = instanceInfo;
}
}
if (info != null) {
log.info("debugEnd=>" + info.getInstanceId() + "," + info.getStatus());
return new EurekaDiscoveryClient.EurekaServiceInstance(info);
}
return null;
}
}
注:
-
EurekaClient
在网关项目的Spring容器有,这里通过构造方法注入,用@Autowired
注解也可 -
通过
eurekaClient.getInstancesByVipAddress
获取服务的实例列表,通过比较选取里面lastUpdatedTimestamp
最大的1个实例 -
instanceInfo.getStatus()
判断值为InstanceStatus.UP
,由于缓存问题判断效果是不能确保的,因此还要加上lastUpdatedTimestamp
-
通过
debugStart=>
、debugEnd=>
里打印的日志信息,方便查看每次调用时节点总数和选取的节点信息 -
通过
@Profile(value = {"test"})
注解,标识该Filter仅测试环境使用 -
实现
Ordered
接口的int getOrder()
方法,优先于系统的LoadBalancerClientFilter
执行
参考
-
【云原生&微服务四】SpringCloud之Ribbon和Erueka/服务注册中心的集成细节(获取服务实例列表、动态更新服务实例信息、负载均衡出一个实例、IPing机制判断实例是否存活)
https://blog.csdn.net/Saintmm/article/details/125270880 -
【SpringCloud】通过Redis手动更新Ribbon缓存来解决Eureka微服务架构中服务下线感知的问题
https://blog.csdn.net/weixin_57535055/article/details/134719740 -
kubernetes Deployment 详解 更新/回滚/缩放/暂停/恢复部署操作
https://blog.csdn.net/qq_37377136/article/details/109141843