作者:bigdavidwong
首次编辑:2025-02-28
1. 背景
排查起始于一次某集群的资源巡查,我们发现在某个特定的master节点上执行kubectl
时,第一次获得最终结果的速度非常慢(大概有半分钟左右,偶尔会到1分钟),但在同集群的其它master节点执行相同命令,基本都是立即返回。本着SRE对于海恩法则的坚信不疑,我们决定势必要查出其具体原因,以规避可能存在的风险。
海恩法则,是航空界关于飞行安全的法则。海恩法则指出: 每一起严重事故的背后,必然有29次轻微事故和300起未遂先兆以及1000起事故隐患。
2. 排查
一般来说,当这类成熟的工具使用中出现问题时,SRE优先怀疑的是自身环境问题而不是工具本身,仅从已有的现象来看,可以推出以下几个结论:
- apiserver本身没有问题(因为其它节点完全正常);
- 非
kubectl
版本问题(因为所有节点的版本完全一致); - 大概率是节点本身的问题;
所以,我们打算从一些基础的性能指标入手逐步排查;
异常节点为0号控制平面节点,所以我们后续用
master0
来代称它。
2.1 资源检查
2.1.1 CPU&内存
卡、慢,第一件事儿看下CPU、内存、负载:
- CPU
- 内存
- 负载
umm...都不高,看来跟资源压力关系不大;
2.1.2 网络&磁盘
-
网络
-
网络没啥问题,再看下磁盘
-
嘶...
IOPS
和BPS
倒还好,sdb的IO写延迟偏高了些,正常应该在几毫秒上下,不过问题应该不大?几十毫秒也不会导致几十秒的延迟吧,何况kubectl
应该不至于有非常高频的本地IO读写。
2.2 Debug日志
2.2.1 日志检查
基础指标都看完了,没看出啥问题,行吧,那就开下debug看下到底kubectl都在哪里慢了,执行命令
kubectl get nodes -v 9
检查日志,发现明显的异常点:
-
kubectl
在无缓存获取资源信息时,会先并发发送数十个http
请求,以获取已注册的所有资源信息,我们能看到,这些request都在0.1s内发送出去了,是预期内的: -
但这些请求的响应,仅从日志时间点来看,速度极慢,最后一个甚至等待了接近10秒钟;
好吧,看起来似乎跟网络还是有关系,咱们再回到网络侧入手;
2.2.2 回查网络
-
首先,因为所有的请求在发出时基本立即返回,ping也是微秒级,所以基本排除3、4层故障;
-
同时应该有同学注意到了,我们的
apiserver
端口是8443,这是因为用到了经典的KeepAlived
+HAProxy
架构,所以这里先贴一张架构简图: -
所有的
APIServer
请求,都是先经过VIP
落到活跃节点,然后通过节点的HAProxy
再轮询到后面的APIServer
集群,因此为了减少影响因子,我们直接修改master0和master2的kubeconfig
文件跳过KeepAlived
和HAProxy
,将所有访问指向指定的另一台APIServer
后,再次执行命令; -
再次请求,速度仍然很慢(测速对比,异常节点master0|正常节点master2)
- 可以看到,在首次获取的时候,master0仍然需要20秒,而正常节点只需要1秒,同时我们观察到异常节点的Debug日志行为与之前基本一致(返回的响应很慢);
2.2.3 抓包 初现端倪
既然直接访问也很慢,那就排除了所有中间件的问题,异常点一定在两端之间的某个行为上,那就不得不上抓包大法了,我们直接看一下大致的结果图;
-
TCP重传率,0%
-
正常请求交互请求,初始的TCP握手OK,TLS协商正常;
-
我们往下翻,当找到响应体返回阶段的数据包日志时,发现了端倪:在整个交互过程中,虽然数据包传输正常,但经常出现 master0 收到或发送数据包后,隔了2-3秒,才会发送下一个数据包:
同时,我们对照了对端的抓包数据,发现基本所有数据包的Seq和Ack时间戳基本吻合,并没有出现延迟的情况;
那,大概率就是kubelet
本地的逻辑出现了什么问题...
2.2.4 意外发现
讲真,到这种程度了,基本就要查源码了,但笔者实在不想干这事儿,毕竟这世界上最痛苦的事情就是看别人的代码~
所以我们决定先尝试下重装或者更新下kubectl
的版本看下,毕竟重装能解决99%的问题!执行命令:
sudo apt-get update
sudo apt install kubectl
PS: kubectl只是一个命令行工具,所以临时升个版本没啥,切勿用相同方式升级kubelet等核心组件;
此时发现,问题居然消失了!
难道是版本问题?还是说之前的kubectl
有什么损坏?为了验证,笔者又将kubectl
装回之前的版本,结果发现问题再次出现:
好吧,看来确实和版本有关系,重装了一次也异常的话,那就属于kubectl
自身的问题,那接下来,就要查kubectl了。
2.3 问题解决
2.3.1 发现issue
既然是kubectl本身的问题,那么肯定不会是只有我们遇见,因此我们尝试去社区内查了查关键字,果然找到了一篇相关的issue:
- issue中提到,之所以会非常慢,是因为kubectl每次获取缓存后,都会将其写入到磁盘并fsync,如果将其缓存目录link到/dev/shm下,就能解决该问题
- 我们将信将疑,也测试了下,果然快多了!
这时我们正好想起来,之前检查基础指标时,确实发现磁盘的io延迟较高(接近100ms),只不过当时认为其不会造成数十秒的延迟;
所以我们再次去看下,如果当速度非常慢的时候,节点的IO表现是怎样的:
增量的io粗略估计300,按照每次60ms来算,一共大概18000ms=18s,基本吻合了20s的延迟;
而之所以link
到/dev/shm
就正常了,原因是/dev/shm
是linux下基于内存的一个临时目录,所以基本也就不存在fsync
导致的io延迟困扰;
2.3.2 什么是fsync
问题根因算是找到了,那么在上面issue中提到的fsync
到底是什么?为什么它在高IO延迟下表现如此之差?
fsync
是一种系统调用,用于将数据从内存缓冲区(比如操作系统的文件系统缓存)写入磁盘。这个操作确保了在调用 fsync
后,文件的内容已经持久化到硬盘中,不会因为系统崩溃或电力中断而丢失。它和常规的write
有以下主要区别:
-
写入操作的目的不同:
-
write
:它将数据写入操作系统的缓存中,而不一定会立刻写入磁盘。换句话说,数据可能会留在内存中,直到操作系统决定将其刷新到磁盘。这种方式提高了性能,但也意味着数据可能会在突然断电时丢失。 -
fsync
:它强制操作系统将文件的内容以及相关的元数据(如文件大小、修改时间等)同步写入磁盘。无论操作系统如何安排缓存数据的刷新,fsync
都会确保数据持久化到物理存储中。
-
-
持久化保障的层次不同:
-
write
:数据只保证在操作系统的缓存中存在,可能还未写入磁盘。 -
fsync
:通过强制将数据写入磁盘,fsync
保证数据被持久化,这对于需要数据一致性和持久性的场景(比如数据库)至关重要。
-
-
性能差异:
-
write
:由于数据不立即写入磁盘,它通常比fsync
快。 -
fsync
:fsync
会阻塞当前进程,直到所有的数据被确认写入磁盘,因此性能较差,尤其是在大文件或频繁同步的情况下。
-
IO缓冲流程(图来自网络)
假设你正在开发一个数据库应用,你执行了一个数据写入操作。这时,数据可能会先写入操作系统的缓存而未立即持久化。如果系统突然崩溃,数据可能会丢失。为了确保数据可靠保存,你可以在每次写入后调用 fsync
,强制操作系统将数据写入磁盘,避免崩溃导致数据丢失,这也是我们通常所说的刷盘。
由此可知,kubectl
在缓存k8s对象和http结果时,高频的io和fsync
会放大io延迟的影响,而切换为内存目录会直接去除磁盘本身的io瓶颈,从而带来提升。
2.4 深入源码
2.4.1 新的疑问
问题本身是解决了,但笔者心里始终有一个疑问,为什么升级kubectl
到最新的版本就没有问题了?难道是禁用了缓存文件吗?所以我们在删除本地缓存后,用新版本kubectl
测试了下:
发现缓存文件依然存在,并没有禁用缓存;
同时我们更换了多个版本,发现在1.24.00
的时候问题存在,但是1.25.00
就解决了,且表象均一致;
好吧,看来为了搞明白这个问题,还是只能去扒源码了,逃不掉呀~
2.4.2 旧版本源码走读
-
kubectl的入口还算还找,可以明确看到它使用了经典的pflag库作为命令行管理,这也是kubectl的入口
-
kubectl主要使用
client-go
下的rest
包来进行APIServer
资源获取和处理的,这一点从上面的Debug日志内也能知晓,图中展示了它创建DiscoverClient
的位置,这也是kubectl
向APIServer
请求发现资源的核心模块; -
ToDiscoverClient
是一个接口方法,我们找到其具体传入的实现,就发现了缓存目录的源头;
-
继续追踪,在
NewCachedDiscoveryClientForConfig
方法中,会根据http缓存路径是否存在,来启用http缓存逻辑:
-
newCacheRoundTripper
使用httpcache
包的NewTransport
方法来初始化缓存后端介质,以此来实现缓存的写入和读取:
-
NewTransport
方法的入参是一个Cache
接口,而图中使用了diskcache
的NewWithDiskv
方法来创建一个内置的符合条件的Cache对象-
diskcache
内置的Cache
接口实现
-
-
如果深入到
Set
方法内,就会发现,默认情况下,每一次缓存写入,都会执行*os.File.Sync
方法(类似于fsync
系统调用)
看到这,基本就能确定旧版本的基本缓存逻辑了,同时结合之前我们在源码内找到,API资源的信息是维护在一个map内,所以每次的资源读写会加互斥锁避免竞争,这也是前面我们发现有不少请求是都会等待2-3秒才会发出的原因(因为上一个请求会将结果fsync
后才会释放锁,而并发的请求只能等待,基本失去了并发的意义)。
2.4.3 新版本源码改进
在1.25.00
版本的代码中,我们主要去观察了httpcache
缓存相关的代码,发现它的基本逻辑并没有修改,但在创建缓存介质时,并没有再使用diskcache
内置的缓存对象:
而在独立实现的新的缓存对象中,它的缓存写入通过调用*diskv.Diskv
的Write
方法来实现:
-
而这里的
Write
方法虽然一样调用了WriteStream
,但是传入的sync
参数为fasle
,代表不执行fsync
同步;同时,你也能看到在它的缓存实现中,加入了
sha256
校验和的写入,便能解释为什么新版本能做到保留了缓存文件的逻辑,去除了fsync
的可靠性依赖,还能快速而准确地实现数据读取和写入。
3. 结语
在本次排查中,我们找到了导致kubectl
获取缓慢的根本原因,同时顺便了解了从执行命令到获得结果中大致发生了一些什么事情。对于我们的问题,最终的解决方案有两种:
- 一是可以将新缓存的逻辑加入到1.21版本后重新编译
kubectl
; - 二是使用issue中提到的方案将
cache
目录link
到内存存储/dev/shm
内。
我们最终选择的是后者,因为缓存时间只有10min,总体文件并不会很大。同时,虽然从结果上来看,这次的根因并没有太大的安全隐患,但从SRE的视角出发, 我们更了解了当下系统瓶颈的一些可能的影响因子,并且解决了kubectl
操作的长延迟问题,对于日常工作也算提效,这些看似微小的改善正是稳定性建设道路上稳定的基石。