上周二晚上,我们的Kubernetes生产环境出了一个诡异的Bug。

那天晚上八点,我刚吃完饭,正准备休息一下,手机上的告警突然响了。打开一看,是生产环境的告警:部分Pod频繁重启,服务间歇性不可用,错误率飙升。

我心里一紧,赶紧打开电脑,登录K8s集群,开始排查。本以为是个小问题,很快就能解决,没想到这个Bug非常诡异,排查过程异常曲折,我从晚上八点一直查到第二天早上六点,花了整整一夜,终于找到了问题的根源。

今天就来记录这次K8s生产环境Bug的排查过程,希望这次经历能给正在使用K8s的朋友一些参考和提醒。

一、问题现象

先说说问题的现象。

我们的生产环境是一个Kubernetes 1.9集群,有大约20个节点,运行着50多个微服务,每天的请求量大概在几百万级别。集群已经稳定运行了半年多,一直没出什么大问题。

那天晚上八点左右,告警系统开始报警:

  • 部分服务的Pod频繁重启,重启次数在短时间内达到了几十次
  • 服务间歇性不可用,错误率从正常的0.1%飙升到了10%-20%
  • 部分节点的CPU和内存使用率异常升高
  • 集群的API Server响应变慢,有时候甚至超时

奇怪的是,不是所有的服务都出问题,只有一部分服务受影响,而且受影响的服务还在不断变化。有时候是A服务出问题,有时候是B服务出问题,没有明显的规律。

而且,问题是间歇性的,不是一直出问题。有时候一切正常,告警消失;有时候突然又出问题,告警响个不停。这种间歇性的问题,排查起来特别麻烦,因为你不知道它什么时候会复现。

二、初步排查

看到告警之后,我第一反应是:是不是某个服务发布了新版本,引入了Bug?

因为那天下午,我们的开发团队刚发布了几个服务的新版本。我首先怀疑是新版本有问题,导致了Pod重启和服务不可用。

我赶紧查了一下当天的发布记录,发现下午确实发布了三个服务:订单服务、用户服务、支付服务。我先把这三个服务的新版本回滚到了旧版本,看看问题是否解决。

回滚之后,我观察了一会儿,发现问题并没有解决,Pod还是在频繁重启,服务还是间歇性不可用。看来不是新版本的问题。

然后我想,是不是节点出了问题?我查了一下节点的状态,发现有两个节点的状态是NotReady,已经被集群标记为不可调度,上面的Pod也被驱逐到了其他节点。

我以为是这两个节点的硬件出了问题,比如内存故障、磁盘故障等。我登录到这两个节点上,查看了系统日志和硬件状态,发现硬件并没有问题,CPU、内存、磁盘都正常。但是这两个节点的kubelet进程异常,有时候会卡住,导致节点状态变成NotReady。

我重启了这两个节点的kubelet,节点状态恢复了Ready。但是好景不长,过了一会儿,这两个节点又变成了NotReady,而且其他节点也开始出现类似的问题。

看来不是单个节点的问题,而是集群层面的问题。

三、深入排查

初步排查没有找到问题,我开始深入排查。

第一步:查看Pod日志

我首先查看了频繁重启的Pod的日志,看看有没有什么错误信息。

但是Pod的日志里没有明显的错误信息,服务的应用日志都是正常的,没有报错。Pod重启的原因是OOMKilled(内存溢出被杀掉),但是这些Pod的内存配置明明是足够的,正常情况下不应该OOM。

我觉得很奇怪,为什么内存足够的Pod会OOM呢?我查看了Pod的内存使用情况,发现Pod的内存使用率确实在不断升高,最后超过了限制,被OOM杀掉了。但是这些服务平时的内存使用率都很稳定,不会突然升高。

这说明,不是服务本身的问题,而是有什么外部因素导致了Pod的内存使用率异常升高。

第二步:查看节点资源使用情况

我查看了各个节点的资源使用情况,发现一个奇怪的现象:有些节点的内存使用率非常高,已经达到了90%以上,但是上面运行的Pod的内存请求加起来并没有那么多。

也就是说,节点的内存被什么东西占用了,但是不是Pod占用的。

我登录到一个内存使用率高的节点上,用free命令查看内存使用情况,发现可用内存很少,但是用top命令查看进程,又没有发现哪个进程占用了大量内存。

这时候我想到,是不是内核的缓存占用了大量内存?我用cat /proc/meminfo查看了详细的内存信息,发现Slab内存占用非常高,达到了几个GB。Slab是内核用来管理内核对象的内存,正常情况下不会占用这么多。

我进一步查看Slab的详细信息,用cat /proc/slabinfo,发现dentry和inode这两个缓存占用了大量内存。dentry是目录项缓存,inode是索引节点缓存,这两个都是文件系统的缓存,正常情况下会占用一定内存,但是不会占用这么多。

这说明,节点上有大量的文件操作,导致dentry和inode缓存不断增长,占用了大量内存。

第三步:查看文件系统使用情况

既然dentry和inode缓存占用了大量内存,我开始怀疑是文件系统的问题。

我查看了节点上的磁盘使用情况,发现/var/lib/docker这个目录的磁盘使用率非常高,已经达到了80%以上。而且,这个目录的文件数量非常多,有几十万个文件。

/var/lib/docker是Docker存储镜像和容器数据的目录,文件多是正常的,但是几十万个文件还是有点多。

我进一步查看,发现/var/lib/docker/overlay2这个目录下有大量的临时文件和残留文件,很多是已经删除的容器残留下来的,没有被清理掉。

这时候我想到,是不是Docker的镜像和容器没有及时清理,导致了大量的残留文件,占用了磁盘和内存?

我运行了docker system prune命令,清理了无用的镜像、容器和网络。清理之后,磁盘使用率降下来了,文件数量也减少了很多。

我以为问题解决了,但是观察了一会儿,发现问题还是没有解决,Pod还是在频繁重启,节点的内存使用率还是在不断升高。

看来不是Docker残留文件的问题。

第四步:查看kubelet日志

Docker残留文件的问题解决了,但是核心问题还在。我开始查看kubelet的日志,看看有没有什么线索。

kubelet的日志里有大量的错误信息,主要是:

  • "Failed to list pods":无法列出Pod
  • "Failed to sync pod":无法同步Pod状态
  • "PLEG is not healthy":PLEG(Pod Lifecycle Event Generator)不健康
  • "image pull failed":镜像拉取失败

特别是"PLEG is not healthy"这个错误,出现的频率很高。PLEG是kubelet中负责Pod生命周期事件管理的组件,如果PLEG不健康,kubelet就无法正常管理Pod,会导致Pod状态异常,频繁重启。

PLEG不健康的原因,通常是因为kubelet无法及时获取Pod的状态,比如容器运行时(Docker)响应慢,或者节点负载太高,导致kubelet处理不过来。

我查看了Docker的状态,发现Docker的响应确实很慢,有时候执行docker ps命令都要等好几秒。而且Docker的日志里有大量的错误,主要是"context deadline exceeded"(上下文超时),说明Docker处理请求超时了。

这时候我意识到,问题可能出在Docker上。Docker响应慢,导致kubelet无法正常获取Pod状态,PLEG不健康,进而导致Pod频繁重启,服务不可用。

但是为什么Docker会响应慢呢?我之前清理了Docker的残留文件,磁盘使用率也降下来了,为什么还是慢?

第五步:查看Docker存储驱动

我开始怀疑是Docker的存储驱动出了问题。

我们的Docker用的是overlay2存储驱动,这是目前推荐的存储驱动,性能和稳定性都不错。但是overlay2对内核版本有要求,如果内核版本太低,可能会有问题。

我查看了节点的内核版本,发现是3.10.0,是CentOS 7默认的内核版本。overlay2存储驱动在3.10.0内核上虽然能用,但是有一些已知的问题,比如内存泄漏、性能下降等。

我进一步查看了内核的日志(dmesg),发现了大量的overlay文件系统错误,主要是"overlay: maximum number of layers exceeded"(超过了最大层数限制)和"overlay: filesystem is too large"(文件系统太大)。

这时候我终于找到了问题的线索:overlay2存储驱动在内核3.10.0上有bug,当镜像层数太多或者文件系统太大的时候,会导致内核内存泄漏,dentry和inode缓存不断增长,占用大量内存,进而导致节点内存不足,Pod OOM重启,Docker响应慢,kubelet PLEG不健康,一系列连锁反应。

四、问题确认

找到了问题的线索之后,我开始确认这个问题。

我在一个内存使用率高的节点上,执行了以下操作:

  1. 用cat /proc/meminfo查看Slab内存使用情况,确认dentry和inode缓存占用了大量内存
  2. 用dmesg查看内核日志,确认有overlay文件系统错误
  3. 用docker info查看Docker存储驱动,确认是overlay2
  4. 用uname -r查看内核版本,确认是3.10.0

确认之后,我又在其他几个出问题的节点上做了同样的检查,发现都有同样的问题。而没有出问题的节点,要么是内核版本较高(4.x),要么是Docker用的是devicemapper存储驱动(没有overlay的问题)。

这就确认了问题的根源:overlay2存储驱动在3.10.0内核上存在内存泄漏bug,导致dentry和inode缓存不断增长,占用大量内存,进而引发一系列连锁反应。

这个bug是已知的,在Docker和内核的issue里都有记录。主要原因是3.10.0内核的overlay文件系统实现不完善,在某些场景下会导致dentry和inode缓存无法回收,造成内存泄漏。

五、解决方案

确认了问题之后,我开始寻找解决方案。

临时解决方案

首先,我需要一个临时解决方案,快速恢复集群的正常运行。

临时解决方案是:定期清理节点的内存缓存,释放被dentry和inode占用的内存。可以通过执行以下命令来清理缓存:

sync && echo 3 > /proc/sys/vm/drop_caches

这个命令会清理页缓存、dentry和inode缓存,释放被占用的内存。但是这只是临时的,过一段时间缓存又会增长,需要定期执行。

我写了一个定时任务,每隔一小时执行一次缓存清理,临时缓解了内存问题。执行之后,节点的内存使用率降下来了,Pod重启也减少了,服务恢复了正常。

但是这只是临时方案,不是长久之计。定期清理缓存会影响性能,而且如果缓存增长太快,一小时清理一次可能都不够。

长期解决方案

长期解决方案有以下几个:

  1. 升级内核版本:把内核从3.10.0升级到4.x或者更高版本,高版本内核的overlay文件系统实现更完善,没有这个内存泄漏bug。这是最根本的解决方案。
  1. 更换Docker存储驱动:把Docker的存储驱动从overlay2换成devicemapper或者其他存储驱动。devicemapper虽然性能稍差,但是没有overlay的内存泄漏问题。不过devicemapper配置比较复杂,而且Docker官方已经不推荐使用devicemapper了。
  1. 限制节点上的Pod数量和镜像数量:减少每个节点上的Pod数量和镜像数量,减少overlay文件系统的大小和层数,缓解内存泄漏的问题。但是这会降低资源利用率,不是理想的解决方案。
  1. 升级Docker版本:新版本的Docker对overlay2有一些优化,可能能缓解这个问题。但是根本原因在内核,升级Docker不能完全解决问题。

我们最终选择的长期解决方案是升级内核版本。我们把所有节点的内核从3.10.0升级到了4.4.x(CentOS 7的elrepo内核),升级之后,overlay2的内存泄漏问题就彻底解决了,节点的内存使用率恢复正常,Pod不再频繁重启,服务稳定运行。

升级内核的过程比较顺利,我们是逐个节点滚动升级的,先把节点上的Pod驱逐到其他节点,然后升级内核,重启节点,确认节点正常之后再处理下一个节点。整个过程用了两天时间,没有影响服务的正常运行。

六、经验总结

这次排查花了整整一夜,过程非常曲折,但是也让我学到了很多。下面总结一些经验和教训。

经验一:生产环境的内核版本很重要

很多人不重视内核版本,觉得只要能跑就行。但是实际上,内核版本对容器运行时、存储驱动、网络等都有很大影响。低版本内核可能有各种bug和性能问题,特别是对于Docker和K8s这种比较新的技术,对内核版本的要求更高。

建议生产环境使用较新的稳定版内核,比如4.x或者更高版本。CentOS 7默认的3.10.0内核虽然还能用,但是对于Docker和K8s来说,已经有点老了,有一些已知的问题。

经验二:存储驱动的选择要谨慎

Docker的存储驱动有很多种,overlay2、devicemapper、aufs、btrfs等,每种都有优缺点。选择存储驱动的时候,要考虑内核版本、文件系统、性能需求、稳定性等因素,不能盲目跟风。

overlay2是目前推荐的存储驱动,性能好,稳定性高,但是对内核版本有要求,需要3.18以上内核,而且3.10.0内核上有已知的bug。如果内核版本较低,建议先升级内核,再用overlay2,或者考虑其他存储驱动。

经验三:排查问题要从现象到本质,层层深入

这次排查的过程,就是一个从现象到本质、层层深入的过程。从Pod重启,到节点内存高,到Slab内存高,到dentry和inode缓存高,到overlay文件系统错误,最后到内核bug。每一步都需要仔细观察、大胆假设、小心验证。

排查问题的时候,不要急于下结论,要多观察、多思考、多验证。有时候表面现象很迷惑人,比如一开始我以为是新版本发布的问题,后来以为是节点硬件的问题,再后来以为是Docker残留文件的问题,最后才发现是内核的问题。

经验四:监控要全面,不能只看应用层指标

我们的监控系统,之前主要监控应用层的指标,比如服务的响应时间、错误率、吞吐量等,对节点层的指标监控不够全面,特别是内核层面的指标,比如Slab内存使用、内核日志等。

这次问题,如果我们提前监控了Slab内存使用情况和内核日志,可能就能更早发现问题,不用花一整夜排查。

建议监控系统要全面,不仅要监控应用层,还要监控节点层、内核层、容器运行时层等各个层面的指标。特别是对于K8s这种复杂的系统,全面的监控非常重要。

经验五:要有应急预案,定期演练

这次问题发生的时候,我们没有现成的应急预案,只能临时想办法,手忙脚乱的。如果有应急预案,可能就能更快地恢复服务,减少影响。

建议针对常见的故障场景,制定应急预案,并且定期演练。比如节点故障、网络故障、存储故障、K8s组件故障等,都应该有相应的应急预案。这样,当故障发生的时候,就能按照预案快速处理,减少故障影响时间。

经验六:滚动升级,避免影响服务

最后解决问题的时候,我们需要升级所有节点的内核。这个过程我们是滚动升级的,逐个节点处理,先驱逐Pod,再升级,再验证,确保不影响服务的正常运行。

在生产环境做任何变更,都应该采用滚动的方式,避免一次性变更所有节点,导致服务不可用。而且变更之前要做好备份和回滚方案,万一出问题能快速回滚。

七、写在最后

这次K8s生产环境的Bug排查,花了我整整一夜,过程非常曲折,但是也让我学到了很多。

Kubernetes是一个非常强大但是也非常复杂的系统,它涉及到容器、网络、存储、调度、操作系统等方方面面,任何一个环节出问题,都可能导致整个集群异常。作为运维人员,需要有扎实的基础知识,全面的监控体系,丰富的排查经验,才能应对各种复杂的故障。

这次问题的根源,是一个内核层面的bug,非常隐蔽,排查起来特别困难。但是只要我们耐心观察、层层深入、大胆假设、小心验证,最终一定能找到问题的根源。

最后,希望这篇文章能给正在使用K8s的朋友一些参考和提醒。如果你也在用K8s,而且内核版本还是3.10.0,Docker存储驱动是overlay2,建议你检查一下节点的Slab内存使用情况和内核日志,看看有没有类似的问题。如果有,建议尽早升级内核,避免像我们一样,半夜被告警叫醒,花一整夜排查问题。

愿每一个运维人员,都能少加班,多睡觉,生产环境永远稳定运行。