目前部署在Kubernetes中。Service通过CalicoBGP连接到集群外的网络,在外部nginx中配置Service地址,对外暴露服务。经过一段时间的观察,发现在Deployment滚动更新的过程中和之后,偶尔会出现服务访问502的问题。问题背景及现象目前Kuberntes集群使用Calico作为CNI组件,通过BGP方式将PodIP和ServiceIP与集群外的网络相连。集群外的Nginx作为反向代理对外提供服务。应用程序以Deployment的形式部署。经过一段时间的观察,有应用反映,在应用发布后的一段时间内,服务有一定概率会报502错误。排查问题最直接的猜测是问题是否只发生在滚动更新过程中,即应用没有配置检查检测,所以服务不是真正可用的,但Pod已经处于就绪状态。经过简单的测试,很快就排除了这种可能。对配置有效健康检查探针的Deployment进行滚动更新,使用ab通过Nginx配置的域名进行连续请求(此时无并发),发现应用滚动更新结束后,并手动确认通过podIP服务没有问题,还是有概率出现502错误,而且错误现象会持续几分钟甚至十几分钟,这显然远远超过了滚动更新所需要的时间。以上初步测试的现象排除了应用本身的问题。下一个嫌疑人是Nginx。由于该现象是通过Nginx代理访问产生的,那么直接请求Service有没有问题呢?由于当前集群Service地址是连外网的,测试起来很方便。我准备了如下测试:ab继续请求域名通过Nginx服务访问,并触发滚动更新(ab-r-v2-n50000http://service.domain.com/test)ab继续请求serviceIP:port访问服务,并触发滚动更新(ab-r-v2-n50000http://10.255.10.101/test)经测试,情况1出现502错误,情况2则不会。那么,是Nginx的问题吗?找了一个负责Nginx的同事分析,结论是Nginx好像不会引起类似的问题。那为什么上面的测试只重现了案例1中的问题呢?所以我决定重新测试,这次给ab请求加上并发(-c10)。结果这两种情况都出现了502错误。这样一来,问题似乎又回到了Kubernetes集群本身,而且似乎只有在高请求量的情况下才会出现。此时我开始怀疑是不是因为某些原因,有些请求会被错误的分发到rollingrelease后被kill了一段时间的oldpodIP上。为了验证这个猜测,我进行了如下实验:创建一个副本数为1的测试Deployment,提供一个简单的http服务,接收请求时输出日志,并创建对应的Service。使用ab并发请求服务的Service地址。使用kubectlpatch修改Pod的标签,使其与Deployment不一致,触发Deployment自动拉起新的Pod。跟踪新Pod和旧Pod的日志,观察请求进来。第三步,给pod的label打补丁,保留原来的pod实例,观察请求是否会分发到旧Pod.(修补Pod的label不会导致Pod重启或退出,但是更改label会使Pod脱离原Deployment的控制,从而触发Deployment创建新的Pod)。结果如预期。当新的Pod就绪并且新Pod的IP已经出现在Endpoint上时,请求仍然会转到原来的Pod。基于以上结果,我们通过多次实验观察Kubernetes节点上的IPVS规则,发现在滚动更新后的一段时间内IPVS规则中仍然会出现旧的podIP,但权重为0,并且手动删除后权重为0。rs之后,问题不再出现。此时发现是IPVS的问题,但是为什么会这样呢?搜索了相关文章,大概找到了原因。怪异的Noroutetohost提到了IPVS的一个特性:即IPVS模块处理数据包的主要入口,发现它会先检查数据包在本地连接转发表中是否有对应的连接(匹配五元组),如果有,则表示不是新连接,不进行调度,直接发送给该连接对应的之前调度过的rs(不判断权重);如果没有匹配到,说明这个包是一个新的连接,就会去调度(rr,wrr等调度策略)。即:当五元组(源IP地址、目的IP地址、协议号、源端口、目的端口)一致时,IPVS可以直接将新连接当成已有连接转发给原来的真实服务器(即播客)。理论上,这种情况只有在单个客户端有大量请求的场景下才会触发。这也是怪异的Noroutetohost一文中模拟的场景,即不同请求的源IP始终相同。关键是有没有可能源端口是一样的。由于ServiceA向ServiceB发起大量的短连接,ServiceA所在的节点会有大量的TIME_WAIT状态的连接,需要2分钟(2*MSL)的时间来清理。但是由于连接数较多,每次发起的连接都会占用一个源端口,当源端口不够用时,会重用TIME_WAIT状态连接的源端口。此时数据包进入IPVS模块后,检测到其五元组与本地连接转发表中的一个连接一致(TIME_WAIT状态),我以为是已存在的连接,于是直接将报文转发给对应的此连接之前的rs。但是这个rs对应的Pod已经被销毁了,所以抓包看到的现象是发SYN给老Pod,并且无法收到ACK,同时返回ICMP提示IP不可达,这也被应用程序解释为“没有主机路由”。原因分析这里分析一下为什么前面的测试会出现两个不同的结果。我一共进行了两次对比实验。第一次在不加并发的情况下,通过Nginx和ServiceIP访问对比。在这组实验中,通过Nginx访问重现了问题,没有通过ServiceIP重现。这个结果差点让调查误入歧途。但是现在来分析一下,原因是因为目前Kubernetes服务访问入口的设计是集群外的Nginx是整个Kubernetes集群共享的,所以Nginx的流量非常大,这也导致Nginx主动发起连接到后端upstream(即ServiceIP)理论上源端口复用的概率更高(实际上经过抓包观察,几分钟内会观察到多次端口复用),所以更可能重复五元组。第二次,同样的对比,这次加了并发,两种情况都出现了问题。这样,和上面文章中的场景类似,由于并发的增加,发出ab请求的机器也出现了源端口不足重用的情况,所以问题也复现了。正式环境下的问题反馈和我第一次通过Nginx接入实验是一样的原因。虽然单个应用的请求量远没有达到能够触发五元组重复的程度,但是集群中所有集群的应用请求量加起来就会触发这个问题。解决方案上面引用的文章中也提到了几种解决方案。另外,可以参考isuue81775,关于这个问题和相关解决方案的讨论很多。鉴于我们目前的技术能力和集群规模,暂时不可能也没有必要在linux内核层面修改和验证功能,我们调研过业务应用,大部分都是基于短连接的。我们采用了一种简单直接的方法。一定程度上避免了这个问题。开发自定义进程,以Daemonset的形式部署在各个Kubernetes的各个节点上。进程通过informer机制监听集群Endpoint的变化。一旦检测到事件,就会获取Endpoint及其对应Service的信息,从而找到节点上生成的对应IPVS规则。如果发现VirtualService下有一个权重为0的RealService,立即删除这个RealService。然而,这种解决方案不可避免地牺牲了一些优雅退出的特性。但考虑到业务应用的特性权衡后,这确实是目前可以接受的方案(虽然极其不优雅)。想着要不要这样用Service?总结问题的原因。当我们单项业务的请求量还远远没有达到会触发五元组重复小概率事件的瓶颈时,我们就过早的遇到了这个问题,和我们对Kubernetes服务入口网关的设计有很大的关系.打通Service和虚拟机的网络,使用外部Nginx作为入口网关。这种用法在Kubernetes的实践中应该算是非常特殊(甚至是奇怪)的了,也是由于目前的业务实例,有大量的混合虚拟机和容器。教训是,在推广和构建像Kubernetes这样复杂的系统时,尽量坚持社区和大厂公开的最佳生产实践,减少基于经验或机械扩展的架构设计。否则很容易踩坑,事倍功半。
