@[TOC]记一次K8S平滑发布问题
记一次生产K8S平滑发布问题解决方案
刚到公司不久,发现一个现象。所有正常迭代都会等到晚上10点过后发布,后来跟同事聊天发现,原来是生产环境发布不平滑,每次发布都会产生一些异常,怕影响用户体验。
问题表现:生产环境服务每次发布都会在服务端产生一些BeanCreationNotAllowedException,一小段时间后自动恢复。
问题分析阶段-通过表象看原理
通过生产日志报错以及Spring源码发现BeanCreationNotAllowedException产生条件:在spring容器处于销毁阶段,但仍然尝试从容器中获取Bean。
以下是源码截图,该标识位在容器关闭时会变成true。
容器已经销毁,但是仍然还会获取bean,结合日志,发现该表现的本质是容器关闭了,但是仍然有请求打到已经关闭的实例上。
问题1:为什么容器都关闭了,仍然有请求进来
问题1原理分析
这个问题要从请求的整个网络链路来看:
- 客户端使用HttpClient并使用了连接池
- 服务调用有两种方式work内网域名,serviceName(基于k8s集群的一种虚拟ip)
第一点,连接池本身是一种缓存,做一个假设,如果连接池中的连接是缓存着旧实例的连接,而且客户端一直不知道服务端已经要下线了,这时候一直有请求打到老的实例就可以解释了。
第二点,分为两种请求方式
- 通过.work内网域名调用最终是走到ingress(发布在k8s集群中的nginx服务),有ingress转发到具体实例。nginx是靠监听端口实现反向代理的,客户端肯定是和nginx建立连接,每次请求走nginx转发,K8S在实例下线的时候会通知所有ingress服务在负载均衡中移除实例,所以这种请求方式可以排除
- 通过serviceName调用,这种方式在K8s中有两种常用模式UDP和VXLAN,我们公司采用UDP,所以今天着重讨论这个。UDP模式的负载均衡是很弱的,它是通过维护iptables的出入规则来达到简单的负载,但也仅仅是在建立连接时根据iptables规则选择一个具体的实例建立连接(类似客户端负载均衡),下面是UDP网络架构图:
从网络架构图可以看到,UDP模式iptables和nginx最大区别一个是客户端负载均衡,一个是服务端负载均衡,而TCP连接必须是端到端,要是实体或虚拟的网卡(iptables是没有的,只有到了具体的实例上才有虚拟网卡),该结论线下我是自己搭建了建议k8s,然后进入docker容器查看了TCP连接进行过验证的,但是不是重点就不赘述。
问题1结论
实例下线后仍然有请求打过来,是因为客户端连接池使用serviceName调用导致
问题1解决方案
最简单解决方案,所有服务调用都使用.work域名就好了。但是显然这得靠人为控制,我们还是得从技术上根本解决问题1。所以接着往下分析,还有一个问题没搞定。
问题2:为什么一小段时间后异常就恢复了
按照我们问题1 的分析,连接池缓存了旧实例,那么服务应该会一直调用失败啊,至少也出现点无法连接之类的IOException吧,怎么会自动恢复了。
问题2原理分析
在研究HttpClient连接池过程中发现有这么一个配置:
连接池是支持重试机制的,我们再深入一下源码看下什么情况下会重试,我们从最里层代码往外看:
这是HttpClient核心execute方法,可以看到当发生IOException时,调用了一个abortConnection方法,这个方法的作用很关键,关闭当前连接并回收当前连接,也就是说不会放回池子里了。再来看下重试是怎么做的:
这是HttpClient的重试处理器,可以看到这里也是捕获了IO异常,同时去判断是否重试,结果当然就是重试,所以问题2的结论很明显了。
问题2结论
服务之后报错一小段时间就自动恢复,是因为HttpClient的重试机制。在实例被关闭之后,客户端发起的请求会受到IO异常,这时候就会很快的淘汰掉出现异常的连接,所以看起来服务自动恢复了
问题原因找到了,怎么解决问题
需要解决哪些问题?
- K8S滚动发布基本原理要了解,K8S会做什么?我们在这个过程中可以做些什么?
- 当一个实例要下线时,接收到的请求怎么处理完,同时如何以一个客户端可以感知到的方式拒绝新的请求
- 客户端如何感知服务端的拒绝,同时做出合适的处理
怎么解决上述三个问题?
第一个问题:K8S的发布流程
首先看一下我们生产的k8s配置:
preStop配置是K8S的一个钩子,可以在实例关闭之前让我们做一些自己的事情,同时有一个参数值得关注:terminationGracePeriodSeconds使用默认值30s,该参数表示K8S允许POD优雅关闭的时间周期,什么意思?请看下面的手绘图,表名发布时候实例的状态变更:
图中可以看到我们的配置其实就是让实例被强杀。这样的配置肯定是不合理的,但是为我们解决第二个问题提供了思路,我们是可以在服务关闭前做一些事情的。
第二个问题:服务端如何平滑发布
平滑发布调研
我们生产环境使用的是Undertow应用服务器,搜索一下Undertow平滑发布,发现有不少现成的例子,该服务器是支持平滑发布的,而且支持自定义。请看截图:
该处理器是undertow自带的,我们只需要通过以下代码将该处理器添加到Undertow处理请求的流程中即可,注意UndertowGracefulShutdown需要实现HandlerWrapper:
接下来我们看下GracefulShutdownHandler的核心方法:
- shutdown() 改变了shutdown标识位
- awaitShutdown() 等待所有请求处理完成
- handleRequest() 处理请求的核心方法,可以看到当shutdown=true时,所有请求返回503
结论
从调研情况看Undertow的平滑发布完全满足我们第二个问题的需求,处理完已接收的请求同时新进来的请求返回503(这是一个客户端可以感知的状态)。
但是有一个重要问题,怎么触发这些核心的平滑发布方法?
平滑发布方案
这时候preStop就要发挥它该有的作用了,我们将生产preStop修改成这样:sleep 10 && kill -15 1
sleep 10是干啥的?这是给K8S时间去异步移除集群中所有负载均衡和iptables规则。
kill -15 1又是干啥的?在docker容器中我们的java进程号是1,这句就是通知我们的java进程需要你处理完手上的事情释放资源并关闭。
接下来就是最终奥义,怎么触发这些核心的平滑发布方法?
kill -15 1对于我们的应用程序是可感知的,我们只需要做一件事,在服务端创建一个ApplicationListener,去监听ContextCloseEvent,然后我们就可以去触发平滑发布的核心方法,从而完成对已接收请求的处理,同时拒绝新请求。
第三个问题:客户端收到503怎么办?
前面有一张截图大家可能没太注意,再看一遍:
一开始我以为自己需要重写一个类似RetryExec的重试处理器去处理503,不过显然我多虑了,开发者早已看透了一切。在阅读源码时比较好奇除了RetryExec和MainClientExec,还有哪些实现类实现了ClientExecChain接口,这时候就被我看到了ServiceUnavailableRetryExec,一看就是处理503 Service Unavailable的。
第三个问题解决方案
解决方案就是大家看到的,截图中设置一下503处理策略即可。
总结
一个看似简单的异常,背后却是一系列的问题、原理、技术方案,本人解决问题之后内部整理过一次文档,但是后来自己复盘之后还是觉得思路不够清晰,所以借此机会又重新梳理了一下结题思路。
没有系统的将方案代码贴出来,是因为每家公司情况不同,我们有一些自己的定制化封装,所以将排查问题的思路和关键技术点罗列出来,希望给遇到相似问题的同学一点帮助。
感谢大家来阅读我的第一篇博客!!!