解密 kubectl 性能瓶颈:获取资源缓慢问题的排查与优化

作者:bigdavidwong

首次编辑:2025-02-28

1. 背景

排查起始于一次某集群的资源巡查,我们发现在某个特定的 master 节点上执行 kubectl 时,第一次获得最终结果的速度非常慢(大概有半分钟左右,偶尔会到 1 分钟),但在同集群的其它 master 节点执行相同命令,基本都是立即返回。本着 SRE 对于海恩法则的坚信不疑,我们决定势必要查出其具体原因,以规避可能存在的风险。

海恩法则,是航空界关于飞行安全的法则。海恩法则指出: 每一起严重事故的背后,必然有 29 次轻微事故和 300 起未遂先兆以及 1000 起事故隐患。

2. 排查

一般来说,当这类成熟的工具使用中出现问题时,SRE 优先怀疑的是自身环境问题而不是工具本身,仅从已有的现象来看,可以推出以下几个结论:

  1. apiserver 本身没有问题(因为其它节点完全正常);
  2. kubectl​版本问题(因为所有节点的版本完全一致);
  3. 大概率是节点本身的问题;

所以,我们打算从一些基础的性能指标入手逐步排查;

异常节点为master0,所以我们后续用master0来代称它。

2.1 资源检查

2.1.1 CPU&内存

卡、慢,第一件事儿看下 CPU、内存、负载:

  • CPU​

    image

  • 内存

    image

  • 负载

    image

umm...都不高,看来跟资源压力关系不大;

2.1.2 网络&磁盘

  • 网络

    imageimageimage

  • 网络没啥问题,再看下磁盘

    image

    image

  • 嘶... IOPS​和 BPS​倒还好,sdb 的 IO 写延迟偏高了些,正常应该在几毫秒上下,不过问题应该不大?几十毫秒也不会导致几十秒的延迟吧,何况 kubectl​应该不至于有非常高频的本地 IO 读写。

2.2 Debug 日志

2.2.1 日志检查

基础指标都看完了,没看出啥问题,行吧,那就开下 debug 看下到底 kubectl 都在哪里慢了,执行命令

kubectl get nodes -v 9

检查日志,发现明显的异常点:

  • kubectl​在无缓存获取资源信息时,会先并发发送数十个 http​请求,以获取已注册的所有资源信息,我们能看到,这些 request 都在 0.1s 内发送出去了,是预期内的:

    image

  • 但这些请求的响应,仅从日志时间点来看,速度极慢,最后一个甚至等待了接近 10 秒钟;

    image

好吧,看起来似乎跟网络还是有关系,咱们再回到网络侧入手;

2.2.2 回查网络

  1. 首先,因为所有的请求在发出时基本立即返回,ping 也是微秒级,所以基本排除 3、4 层故障;

  2. 同时应该有同学注意到了,我们的 apiserver​端口是 8443,这是因为用到了经典的 KeepAlived​+HAProxy​架构,所以这里先贴一张架构图:

    image

  1. 所有的 APIServer​请求,都是先经过 VIP​落到活跃节点,然后通过节点的 HAProxy​再轮询到后面的 APIServer​集群,因此为了减少影响因子,我们直接修改master0master2kubeconfig​文件跳过 KeepAlived​和 HAProxy​,将所有访问指向指定的另一台 APIServer​后,再次执行命令;

    image

  1. 再次请求,速度仍然很慢(测速对比,异常节点master0| 正常节点master2

    imageimage

    • 可以看到,在首次获取的时候,master0仍然需要 20 秒,而正常节点只需要 1 秒,同时我们观察到异常节点的 Debug 日志行为与之前基本一致(返回的响应很慢);

2.2.3 抓包 初现端倪

既然直接访问也很慢,那就排除了所有中间件的问题,异常点一定在两端之间的某个行为上,那就不得不上抓包大法了,我们直接看一下大致的结果图;

  • TCP 重传率,0%

    image

  • 正常请求交互请求,初始的 TCP 握手 OK,TLS 协商正常;

    image

  • 我们往下翻,当找到响应体返回阶段的数据包日志时,发现了端倪:在整个交互过程中,虽然数据包传输正常,但经常出现 master0 收到或发送数据包后,隔了 2-3 秒,才会发送下一个数据包:

    image

    同时,我们对照了对端的抓包数据,发现基本所有数据包的 Seq 和 Ack 时间戳基本吻合,并没有出现延迟的情况;

那,大概率就是 kubelet​本地的逻辑出现了什么问题...

2.2.4 意外发现

讲真,到这种程度了,基本就要查源码了,但笔者实在不想干这事儿,毕竟这世界上最痛苦的事情就是看别人的代码 ~
image

所以我决定先尝试下重装或者更新下 kubectl​的版本看下,毕竟重装能解决 99% 的问题!执行命令:

sudo apt-get update sudo apt install kubectl

PS:kubectl 只是一个命令行工具,所以临时升个版本没啥,切勿用相同方式升级 kubelet 等核心组件;

此时发现,问题居然消失了!

image

难道是版本问题?还是说之前的 kubectl​有什么损坏?为了验证,笔者又将 kubectl​装回之前的版本,结果发现问题再次出现:

image

好吧,看来确实和版本有关系,重装了一次也异常的话,那就属于 kubectl​自身的问题,那接下来,就要查 kubectl 了。

2.3 问题解决

2.3.1 发现 issue

既然是 kubectl 本身的问题,那么肯定不会是只有我们遇见,因此我们尝试去社区内查了查关键字,果然找到了一篇相关的 issue:

github.com/kubernetes/kubernetes/issues/73570

issue 中提到,之所以会非常慢,是因为 kubectl 每次获取缓存后,都会将其写入到磁盘并 fsync,如果将其缓存目录 link 到/dev/shm 下,就能解决该问题

image

我们将信将疑,也测试了下,果然快多了!

image

这时我们正好想起来,之前检查基础指标时,确实发现磁盘的 io 延迟较高(接近 100ms),只不过当时认为其不会造成数十秒的延迟;

所以我们再次去看下,如果当速度非常慢的时候,节点的 IO 表现是怎样的:

image

增量的 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 缓冲流程(图来自网络)

image

假设你正在开发一个数据库应用,你执行了一个数据写入操作。这时,数据可能会先写入操作系统的缓存而未立即持久化。如果系统突然崩溃,数据可能会丢失。为了确保数据可靠保存,你可以在每次写入后调用 fsync​,强制操作系统将数据写入磁盘,避免崩溃导致数据丢失,这也是我们通常所说的刷盘。

​​

由此可知,kubectl​在缓存 k8s 对象http 结果时,高频的 io 会让 fsync​放大 io 延迟的影响,而切换为内存目录会直接去除磁盘本身的 io 瓶颈,从而带来提升。

2.4 深入源码

2.4.1 新的疑问

问题本身是解决了,但笔者心里始终有一个疑问,为什么升级到 kubectl 到最新的版本就没有问题了?难道是禁用了缓存文件吗?所以我们在删除本地缓存后,用新版本 kubectl 测试了下:

image

发现缓存文件依然存在,并没有禁用缓存;

同时我们更换了多个版本,发现在 1.24.00​的时候问题存在,但是 1.25.00​就解决了,且表象均一致;

好吧,看来为了搞明白这个问题,还是只能去扒源码了,逃不掉呀 ~

2.4.2 旧版本源码走读

  1. kubectl 的入口还算还找,可以明确看到它使用了经典的 pflag 库作为命令行管理,这也是 kubectl 的入口​

    image

  1. kubectl 主要适用 client-go​下的 rest​包来进行 APIServer​资源获取和处理的,这一点从上面的 Debug 日志内也能知晓,图中展示了它创建 DiscoverClient​的位置,这也是 kubectl​向 APIServer​请求发现资源的核心模块;

    image

  1. ToDiscoverClient​是一个接口方法,我们找到其具体传入的实现,就发现了缓存目录的源头;

    image

  1. 继续追踪,在 NewCachedDiscoveryClientForConfig​方法中,会根据 http 缓存路径是否存在,来启用 http 缓存逻辑:

    image

  1. newCacheRoundTripper​使用 httpcache​包的 NewTransport​方法来初始化缓存后端介质,以此来实现缓存的写入和读取:​

    image

  1. NewTransport​方法的入参是一个 Cache​接口,而图中使用了 diskcache​的 NewWithDiskv​方法来创建一个内置的符合条件的 Cache 对象​

    imageimage

  • diskcache​内置的 Cache​接口实现​

    image

  1. 如果深入到 Set​方法内,就会发现,默认情况下,每一次缓存写入,都会执行 *os.File.Sync​方法(类似于 fsync​系统调用)

    image

看到这,基本就能确定旧版本的基本缓存逻辑了,同时结合之前我们在源码内找到,API 资源的信息是维护在一个 map 内,所以每次的资源读写会加互斥锁避免竞争,这也是前面我们发现有不少请求是都会等待 2-3 秒才会发出的原因(因为上一个请求会将结果 fsync​后才会释放锁,而并发的请求只能等待,基本失去了并发的意义)。

2.4.3 新版本源码改进

1.25.00​版本的代码中,我们主要去观察了 httpcache​缓存相关的代码,发现它的基本逻辑并没有修改,但在创建缓存介质时,并没有再使用 diskcache​内置的缓存对象:

image​​image

而在独立实现的新的缓存对象中,它的缓存写入通过调用 *diskv.Diskv​的 Write​方法来实现:

image

  • 而这里的 Write​方法虽然一样调用了 WriteStream​,但是传入的 sync​参数为 fasle​,代表不执行 fsync​同步;

image

同时,你也能看到在它的缓存实现中,加入了 sha256​ 校验和的写入,便能解释为什么新版本能做到保留了缓存文件的逻辑,去除了 fsync​的可靠性依赖,还能快速而准确地实现数据读取和写入。

3. 结语

在本次排查中,我们找到了导致 kubectl​获取缓慢的根本原因,同时顺便了解了从执行命令到获得结果中大致发生了一些什么事情。对于我们的问题,最终的解决方案有两种:一是可以将新缓存的逻辑加入到 1.21 版本后重新编译 kubectl​;二是使用 issue 中提到的方案将 cache​目录 link​到内存存储 /dev/shm​内。我们最终选择的是后者,因为缓存时间只有 10min,总体文件并不会很大。另外,虽然从结果上来看,这次的根因并没有太大的安全隐患,但从 SRE 的视角出发, 我们更了解了当下系统瓶颈的一些可能的影响因子,并且解决了 kubectl​操作的长延迟问题,对于日常工作也算提效,这些看似微小的改善正是稳定性建设道路上稳定的基石。

  • Kubernetes

    Kubernetes 是 Google 开源的一个容器编排引擎,它支持自动化部署、大规模可伸缩、应用容器化管理。

    118 引用 • 54 回帖 • 5 关注

相关帖子

欢迎来到这里!

我们正在构建一个小众社区,大家在这里相互信任,以平等 • 自由 • 奔放的价值观进行分享交流。最终,希望大家能够找到与自己志同道合的伙伴,共同成长。

注册 关于
请输入回帖内容 ...