作者:bigdavidwong
首次编辑:2025-02-28
1. 背景
排查起始于一次某集群的资源巡查,我们发现在某个特定的 master 节点上执行 kubectl 时,第一次获得最终结果的速度非常慢(大概有半分钟左右,偶尔会到 1 分钟),但在同集群的其它 master 节点执行相同命令,基本都是立即返回。本着 SRE 对于海恩法则的坚信不疑,我们决定势必要查出其具体原因,以规避可能存在的风险。
海恩法则,是航空界关于飞行安全的法则。海恩法则指出: 每一起严重事故的背后,必然有 29 次轻微事故和 300 起未遂先兆以及 1000 起事故隐患。
2. 排查
一般来说,当这类成熟的工具使用中出现问题时,SRE 优先怀疑的是自身环境问题而不是工具本身,仅从已有的现象来看,可以推出以下几个结论:
- apiserver 本身没有问题(因为其它节点完全正常);
- 非
kubectl
版本问题(因为所有节点的版本完全一致); - 大概率是节点本身的问题;
所以,我们打算从一些基础的性能指标入手逐步排查;
异常节点为master0,所以我们后续用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
操作的长延迟问题,对于日常工作也算提效,这些看似微小的改善正是稳定性建设道路上稳定的基石。
欢迎来到这里!
我们正在构建一个小众社区,大家在这里相互信任,以平等 • 自由 • 奔放的价值观进行分享交流。最终,希望大家能够找到与自己志同道合的伙伴,共同成长。
注册 关于