K8S测试环境微服务优雅停机重启---思路1

场景

测试环境,采用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会停止旧的启动新的,整个重启过程需要时间,在这个过程中会出现服务不可用的情况。
这也是测试同学经常反馈"抱怨"的:每当开发修改代码重启服务时,会出现频繁报错,影响测试工作的正常进行。

注:这里调用报错是前端或者其他项目组调用本项目组网关,网关路由到组内微服务报错;组内微服务间的调用(如feign)以后单独写文章分析讨论。

分析

由于测试环境只部署了1个节点,微服务重启,这个过程导致服务有一定时间不可用。

如果每个部署多个节点,在服务停止、重启过程中,由于Ribbon负载均衡、Eureka客户端等缓存原因,也会造成一定数量的接口调用失败。

之前写过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);
它本身设计了多级缓存(readWriteCacheMapreadOnlyCacheMap);
eureka-client是从缓存中获取数据,有可能服务停止过程,而获取的实例(InstanceInfo)的状态(status)是UP

ribbon里也设计了缓存,参考DynamicServerListLoadBalancerPollingServerListUpdater等;

因此当服务重启,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());
}

这里的loadBalancerLoadBalancerClient类型,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默认使用ZoneAwareLoadBalancerZoneAvoidanceRule,限于篇幅这里不在赘述。

借助arthas去看一看ILoadBalancer里缓存的实例列表:

  1. 在rancher里进入网关服务的控制台安装arthas
mkdir arthas
cd arthas
curl -O https://alibaba.github.io/arthas/arthas-boot.jar
  1. 通过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("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执行

参考

posted @ 2024-03-15 22:29  cdfive  阅读(127)  评论(0编辑  收藏  举报