[微服务] 微服务之web容器优雅升级方案

什么是优雅升级?

优雅升级即在对业务和用户无感知的情况下,对系统进行升级

如今互联网基于微服务架构部署越来越流行。在需要对线上应用做升级或者版本更新时,我们一般要对应用实例做到有计划而且平滑的切换,即对业务无感,不产生任何业务上的中断。更具体的, 是应用实例在收到重启/停机信号后, 马上对调用端隐藏, 同时处理完所有已经收到的请求后, 再重启.

web容器优雅升级的现状和痛点

在微服务的模式下,我们常常将相同的服务部署到多个节点,这样做到了负载均衡和高可用,避免单点故障。

微服务集群对外暴露服务, 一般通过http网关的方式。例如我司目前部署了多个web容器作为服务网关,给部门前端项目、POS收银系统、PHP端项目、微信、H5等诸多应用提供后台服务。而在网关的前面, 是阿里的slb + nginx. 上游项目会通过nginx,再路由到web网关。

升级服务的痛点

目前的几种web容器都没有现成的优雅升级方案. 意味着重启过程中总会存在部分请求失败的情况. 我们只能在升级的时候避开业务高峰时段, 尽可能减少失败的业务.
如果结合nginx, 理论上可以做到无感升级, 但步骤繁琐:

  • 1.把准备停机的节点从nginx上摘除, 这样新的请求就不会再发往待停机节点

  • 2.在web容器停机之前,等几秒钟, 让系统处理完已有请求并成功返回给客户端后再彻底停机。

    1. 升级成功后,再把该节点挂载到nginx上.

如上述总结,我们发现对业务无感的web容器升级过程略复杂,环节略多,存风险。

优雅升级你的web容器

技术要点:web容器基于Springboot,并使用内嵌tomcat形式打成jar包,使用docker进行部署和运行,网络网关层使用淘宝的tengine代替现有nginx

基于内嵌tomcat形式的web容器优雅关闭

web容器正在运行时进行关闭,如果有请求没有处理完,会出现中断的异常,那么就有可能对业务造成影响。所以,优雅停机非常有必要性,目前tomcat官方是没有提供很好的策略来实现优雅关机。

于是,我们通过以下方案来实现容器的优雅关闭,整理一下关键技术点:

  • 1.基于springboot的web容器应用
  • 2.使用内嵌tomcat形式,直接java -jar提供服务
  • 3.打成docker镜像,以docker形式提供服务

原理:

更新:Spring Boot 2.3.0.RELEASE 之后,支持配置优雅关闭,详见文档

当我们在kill springboot进程时(不是强制kill),Springboot在接收到停机信号以后,会有一个优雅关机的处理过程,容器在关闭之前会发布一个ContextClosedEvent事件。
我们可以自定义Bean去监听该事件,在容器即将销毁关闭之后,JVM退出之前,执行我们的优雅关机逻辑。

定义一个组件GracefulShutdown实现TomcatConnectorCustomizerApplicationListener,监听ContextClosedEvent事件。

实现TomcatConnectorCustomizer可以让我们管理内嵌的tomcat容器。

@Component
public class GracefulShutdown implements TomcatConnectorCustomizer, ApplicationListener<ContextClosedEvent> {
    private Logger logger = LoggerFactory.getLogger(getClass());

    private volatile Connector connector;

    private final int waitTime = 60;

    @Override
    public void customize(Connector connector) {
        this.connector = connector;
    }

    @Override
    public void onApplicationEvent(ContextClosedEvent contextClosedEvent) {
        logger.info("准备关闭容器,先关闭线程!");
        this.connector.pause();
        Executor executor = this.connector.getProtocolHandler().getExecutor();

        if (executor instanceof ThreadPoolExecutor) {
            try {
                ThreadPoolExecutor threadPoolExecutor = (ThreadPoolExecutor) executor;
                threadPoolExecutor.shutdown();

                if (!threadPoolExecutor.awaitTermination(waitTime, TimeUnit.SECONDS)) {

                    logger.warn("Tomcat thread pool did not shut down gracefully within " + waitTime
                            + " seconds. Proceeding with forceful shutdown");
                }
            } catch (InterruptedException ex) {
                Thread.currentThread().interrupt();
            }
        }
    }
}

当springboot容器即将关闭时,首先获取到tomcat连接器connector,然后通过连接器拿到tomcat业务线程池。拿到线程池后,对线程池执行shutdown()方法。

docker 模式 优雅关闭

以docker环境部署web容器,将springboot打成的jar包,然后构建docker镜像

1.构建镜像

我们使用的是自己打的基于openJdk-jre的基础镜像,在其中植入了fluent-bit日志收集器,以及jmxdapeng-cli等dapeng定制的工具(想了解更多?请关注dapeng开源)

  • Dockerfile
FROM docker.today36524.com.cn:5000/base/openjdk:server-jre8

RUN mkdir /gateway-conf &&  mkdir -p /opt/gateway

COPY  ./apps/dapeng-api-gateway.jar /opt/gateway/
COPY  ./startup.sh /opt/gateway/
COPY  ./apps/service-whitelist.xml /gateway-conf/
WORKDIR /opt/gateway
ENTRYPOINT exec  /opt/gateway/startup.sh

2. startup.sh 脚本

#!/bin/sh
echo "begin"
export JVM_HOME='opt/oracle-server-jre'
export PATH=$JVM_HOME/bin:$PATH

PRGNAME=api-gate-way
ADATE=`date +%Y%m%d%H%M%S`
PRGDIR=`pwd`
dirname $0|grep "^/" >/dev/null
if [ $? -eq 0 ];then
   PRGDIR=`dirname $0`
else
    dirname $0|grep "^\." >/dev/null
    retval=$?
    if [ $retval -eq 0 ];then
        PRGDIR=`dirname $0|sed "s#^.#$PRGDIR#"`
    else
        PRGDIR=`dirname $0|sed "s#^#$PRGDIR/#"`
    fi
fi

LOGDIR=$PRGDIR/logs
if [ ! -d "$LOGDIR" ]; then
        mkdir "$LOGDIR"
fi


#DEBUG="-Xdebug -Xnoagent -Djava.compiler=NONE -Xrunjdwp:transport=dt_socket,server=y,suspend=n,address=9997"
JMX="-Dcom.sun.management.jmxremote -Dcom.sun.management.jmxremote.port=1091 -Dcom.sun.management.jmxremote.ssl=false -Dcom.sun.management.jmxremote.authenticate=false"
JVM_OPTS="-Dfile.encoding=UTF-8 -Dsun.jun.encoding=UTF-8 -Dname=$PRGNAME -Dio.netty.leakDetectionLevel=advanced -Xms512M -Xmx1024M -XX:+HeapDumpOnOutOfMemoryError -XX:+PrintGCDateStamps -Xloggc:$LOGDIR/gc-$PRGNAME-$ADATE.log -XX:+PrintGCDetails -XX:NewRatio=1 -XX:SurvivorRatio=30 -XX:+UseParallelGC -XX:+UseParallelOldGC -Dlog.dir=$PRGDIR/.."
SOA_BASE="-Dsoa.base=$PRGDIR/../ -Dsoa.run.mode=native"

# SIGTERM  graceful-shutdown
pid=0
process_exit() {
 if [ $pid -ne 0 ]; then
  echo "graceful shutdown pid: $pid" > $LOGDIR/pid.txt
  kill -SIGTERM "$pid"
  wait "$pid"
 fi
 exit 143; # 128 + 15 -- SIGTERM
}


trap 'kill ${!};process_exit' SIGTERM

nohup java -server $JVM_OPTS $SOA_BASE $DEBUG_OPTS $USER_OPTS  $E_JAVA_OPTS -jar $PRGDIR/dapeng-api-gateway.jar >> $LOGDIR/console.log 2>&1 &
#nohup java  -jar dapeng-api-gateway.jar >> $LOGDIR/console.log 2>&1 &
#java  -jar $PRGDIR/dapeng-api-gateway.jar
pid="$!"
echo "start pid: $pid" > $LOGDIR/pid.txt

nohup sh /opt/fluent-bit/fluent-bit.sh >> $LOGDIR/fluent-bit.log 2>&1 &

wait $pid

我们重点看 #SIGTERM graceful-shutdown下面一行
首先定义一个脚本方法process_exit(),使用trap 'kill ${!};process_exit' SIGTERM去监听我们即将启动的进程。
当docker容器被关闭时,这一行会收到来自于docker的kill信号,然后会执行定义的方法,去kill java进程,也就是我们的api网关进程。
这样就能够做到在docker stop时,容器内的java线程能够收到kill信号,执行优雅关闭。

web层总结

我们知道,该方法的作用是阻止新来的任务提交,对已提交的任务不会产生影响,直到所有的线程执行完任务以后,彻底关闭线程池。
我们这样做的目的是希望tomcat继续处理已经接收的请求,停止处理新请求,当所有的请求处理完成后,即彻底关闭tomcat,springboot容器也退出,随即JVM退出。

这样,api网关层面的优雅关闭就可以做到了,但是会存在如下问题:

  • 即使tomcat线程池shutdown以后,不再处理新的请求,但是tomcat的端口并没有关闭,外部请求仍然可以路由进来,但是只是一个一直等待的模式(pending),直到请求超时。
    对于我们的无缝优雅关闭和切换网关来说还是差了点什么?

使用tengine负载均衡无缝优雅关闭

Tengine是由淘宝网发起的Web服务器项目。它在Nginx的基础上,针对大访问量网站的需求,添加了很多高级功能和特性。我们利用tengine的ngx_http_upstream_check_module功能,对负载的服务进行健康检查
现在,启动两个基于springboot的api网关,使用tengine对网关做负载均衡(可以做到网关服务的多节点,高可用)
配置如下:

apiGateWay.conf

upstream maple {
    # simple round-robin
    server 192.168.10.8:9101;
    server 192.168.10.8:9102;

    check interval=3000 rise=2 fall=3 timeout=1000 type=http;
    check_http_send "HEAD /health/check  HTTP/1.0\r\n\r\n";
    check_http_expect_alive http_2xx http_3xx;
}

server {
    listen 80;
    server_name www.maple.td;
    
    access_log  /var/log/nginx/gateway.log;
    error_log /var/log/nginx/gateway-error.log;

    keepalive_timeout 300;
    send_timeout 300; 
    proxy_read_timeout 300;

    location / {
        proxy_set_header   Host             $host;
        proxy_set_header   X-Real-IP        $remote_addr;
        proxy_set_header   X-Forwarded-For  $proxy_add_x_forwarded_for;
        
        proxy_pass http://maple;
    }
}
  • 1.所有请求www.maple.td的请求都会路由到upstream中的两个server对应的网关服务
  • 2.下面三行check开头的是对服务的健康检查机制,包括如下:

check interval=3000 rise=2 fall=3 timeout=1000 type=http;

该指令可以打开后端服务器的健康检查功能。指令后面的参数意义是:

interval:向后端发送的健康检查包的间隔。
fall(fall_count): 如果连续失败次数达到fall_count,服务器就被认为是down。
rise(rise_count): 如果连续成功次数达到rise_count,服务器就被认为是up。
timeout: 后端健康请求的超时时间。
type:健康检查包的类型,现在支持以下多种类型

check_http_send "HEAD /health/check HTTP/1.0\r\n\r\n";

该指令可以配置http健康检查包发送的请求内容。为了减少传输数据量,推荐采用"HEAD"方法。

当采用长连接进行健康检查时,需在该指令中添加keep-alive请求头,如:"HEAD / HTTP/1.1\r\nConnection: keep-alive\r\n\r\n"。
同时,在采用"GET"方法的情况下,请求uri的size不宜过大,确保可以在1个interval内传输完成,否则会被健康检查模块视为后端服务器或网络异常。
我们配置让nginx 请求/health/check url对服务网关进行健康检查

check_http_expect_alive http_2xx http_3xx;

当返回http状态码为 2xx 3xx 认为服务是健康的,当连续返回多次非此类状态码时,就认为该服务挂了,接下来的请求转发不会路由到该服务。
但是tengine在后台仍然会定期检测该服务的健康状况,如果之后重启了服务节点后,连续两次检测重新返回2xx 3xx时。tengine会认为该服务节点重新恢复正常,
接下来请求会重新路由过来。

tengine配合和web容器配合进行优雅升级

web容器优雅停机代码需要在原有代码的基础上稍微作一些修改,暴露一个检测服务的端点(endpoint)给外部

@Controller
public class HealthCheckController {
    private Logger logger = LoggerFactory.getLogger(getClass());
    /***
     * Gateway 容器状态,即将关闭时显示 GREEN
     */
    public static ContainerStatus status = ContainerStatus.GREEN;

    @RequestMapping(value = "/health/check", method = RequestMethod.HEAD)
    public ResponseEntity healthCheck() {
        logger.debug("health check,container status: " + status);
        ResponseEntity<String> response;
        if (status == ContainerStatus.YELLOW) {
            response = ResponseEntity
                    .status(HttpStatus.INTERNAL_SERVER_ERROR)
                    .body("container maybe shutdown soon");
        } else {
            response = ResponseEntity
                    .status(HttpStatus.OK)
                    .body("container is  running");
        }
        return response;
    }
}

提供给tengine一个健康检查的controller,请求当前的url时,如果容器状态为正常时(GREEN),会返回200,如果容器状态为即将关闭时(YELLOW)时,返回500

如何让网关关闭前改变容器状态,让tengine检测并剔除该服务

在容器销毁之前,停止tomcat线程池之前,改变容器状态,代码逻辑如下:

public void onApplicationEvent(ContextClosedEvent contextClosedEvent) {
        HealthCheckController.status = ContainerStatus.YELLOW;
        logger.info("睡眠10s,等待tengine踢出当前web服务");
        try {
            TimeUnit.SECONDS.sleep(10);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        logger.info("准备关闭容器,先关闭线程!");
        this.connector.pause();
        Executor executor = this.connector.getProtocolHandler().getExecutor();

        if (executor instanceof ThreadPoolExecutor) {
            try {
                ThreadPoolExecutor threadPoolExecutor = (ThreadPoolExecutor) executor;
                threadPoolExecutor.shutdown();

                if (!threadPoolExecutor.awaitTermination(waitTime, TimeUnit.SECONDS)) {

                    logger.warn("Tomcat thread pool did not shut down gracefully within " + waitTime
                            + " seconds. Proceeding with forceful shutdown");
                }
            } catch (InterruptedException ex) {
                Thread.currentThread().interrupt();
            }
        }
    }
}

上面代码对比之前的内嵌tomcat优雅关闭的代码新增了改变容器状态的代码,增加主线程睡眠时间以提供给tengine足够的健康检测的时间。
当tengine正式摘除停机的web节点后,web容器主线程睡醒后继续向下执行,正式进行优雅关闭。

以后,我们只需要执行docker stop apiGateWay --time 60,去停掉web容器,并设置docker关闭等待超时时间为60s,
有充足的时间给tengine剔除该容器,并且web容器能够处理完已有请求后,再关闭容器。

这样就做到了无缝的优雅关闭

测试

  • 1.启动两个经过优雅关闭代码改造后的docker web容器,分别为 api1 api2


    web容器.png
  • 2.启动tengine docker 容器,配置如下:
upstream maple {
    # simple round-robin
    server 192.168.10.8:9101;
    server 192.168.10.8:9102;

    check interval=3000 rise=2 fall=3 timeout=1000 type=http;
    check_http_send "HEAD /health/check  HTTP/1.0\r\n\r\n";
    check_http_expect_alive http_2xx http_3xx;
}

配置健康检查规则为间隔3s,连续3次返回5xx就剔除该server,当连续两次检测到返回2xx时,即重新路由请求到该server

  • 3.本地启动一个简易的程序,对web容器某个url连续发起请求
public class Test {
    private static Logger logger = LoggerFactory.getLogger(Test.class);
    private static String url = "http://www.maple.td/check";

    public static void main(String[] args) {
        System.setProperty("java.net.preferIPv4Stack", "true");
        ExecutorService service = Executors.newFixedThreadPool(10);
        for (int i = 0; i < 2; i++) {
            service.execute(() -> {
                try {
                    execute();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            });
        }
    }

    private static void execute() throws InterruptedException {
        int i = 0;
        while (true) {
            Thread.sleep(10);
            i++;
            try {
                List<NameValuePair> pairs = new ArrayList<>(4);
                ResponseResult postResult = null;
                postResult = poolPost(url, pairs);
                String response = postResult.getContent();
                //利于展示,根据路由到的web服务ip对response以不同颜色区分打印
                String sub = response.substring(response.indexOf("[") + 1, response.indexOf("]"));
                //api1
                if (s.equals("172.22.0.2")) {
                    System.err.println("response: " + response);
                } else {
                    logger.info("response: " + response);
                }
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
    }

private static ResponseResult poolPost(String uri, List<NameValuePair> arguments) throws Exception {
      // 通过 httpclient 或者其他方式请求指定url,这里省略代码
}

该测试类的主要作用是请求tengine的域名,会路由到两个web节点,我们通过观察返回的数据进行比照

测试过程

  • 1.启动测试类,我们看到请求均衡的路由到了两个web容器。


    均衡路由.png
  • 2.现在关闭api1(控制台打印红色节点),并指定docker stop的超时时间为30s(如果不设置,10s内docker容器会强制关闭)


    关闭.png
  • 3.查看api1的日志


    api日志.png
  • 4.同时查看请求类的日志


    测试类.png

我们可以观察到,当执行命令docker stop api1 时,web容器马上收到了来自docker的SIGTERM信号,web容器准备停机,于是改变了container状态,tengine的健康检查将返回YELLOW.

仔细观察,当第三次返回YELLOW时,即20:06:28秒,测试类的控制台已经没有请求api1了(没有了红色的日志)

    1. 重新启动api1
api1日志.png

重新启动api1后,tengine继续对api1进行健康检查,当第二次green返回时,测试类的请求马上就路由过来了(看红色日志和时间)


image.png

总结

到这一步,我们的优雅升级web容器方案已经完全成功了,以后相对某一个节点停机,无需改变nginx配置,无需担心web容器已有请求会因为关闭而中断。这样的策略在企业应用无缝升级起到了很大的作用。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 159,219评论 4 362
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,363评论 1 293
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,933评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,020评论 0 206
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,400评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,640评论 1 219
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,896评论 2 313
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,597评论 0 199
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,327评论 1 244
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,581评论 2 246
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,072评论 1 261
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,399评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,054评论 3 236
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,083评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,849评论 0 195
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,672评论 2 274
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,585评论 2 270

推荐阅读更多精彩内容