Atlantis
GitHub Toggle Dark/Light/Auto mode Toggle Dark/Light/Auto mode Toggle Dark/Light/Auto mode Back to homepage

5. 大规模集群优化  

1. 前言

当我想了解下多大规模的集群算是大规模集群时,网上搜索到的内容往往都比较惊人,投入大量人力维护的集群节点数量似乎上不封顶。

组长曾经提到过,对于交付给客户使用的容器集群产品,超过 500 个节点的已经算较大的规模,需要重点关注,但在我看来需要走工单才能继续扩容的规模就算大规模了,从网上搜索到的信息如下:

UCloud

1000 节点以上,Master 配置推荐

alt text

腾讯云

5000 节点以上,TKE 配额限制

alt text

百度云

200 节点以上,CCE集群配额限制

alt text

华为云

2000 节点以上,CCE集群配额限制

alt text

阿里云

5000 节点以上,集群配额

相比之下,这篇 wiki 要记录的集群规模就小了很多,因为我们的容器集群产品运行在私有云上,限制比较多,在前司的工作时间中,我参与过两次大规模集群测试:

  1. 1949 节点,5 万 Pod
  2. 3000 节点,10 万 Pod

集群使用的 K8s 版本还是比较老的 1.18.18,Master 和 etcd 分开部署,都托管在底座 K8s 集群上,Worker 使用小规格的 ECS(2C4G),Pod 里运行着基于 Pause 镜像制作的专用容器,启动后一直运行。

从结果上来说,达到了几个项目的投标需求,从实际上来说,其实只做到了最大管理 M 个节点和 N 个容器的最低要求,距离实际业务压力有很大差距,不过更实际的情况是,客户创建过的最大规模集群节点数量也没有超过 500 个(32C64G 规格节点)。

能实打实地经历大规模集群建设并踩坑是一种可遇不可求的体验,这里就记录下两次大规模集群测试和优化点。

2. 第一次大规模测试

2.1 背景

公司的年度重点项目进入交付阶段,我当时也在其中一个现场驻场支持,不过老板们似乎对于当前容器产品的能力没有信心,于是由质量部牵头进行一次大规模集群测试,希望能满 2000 节点和 5 万 Pod 的要求,并对私有云整体能力摸一个底,于是我也调遣到支持质量部测试的工作中。

2.2 部署过程

质量部的测试团队投入了大量人力,整合了多个测试环境的资源搭建出一个支持大规模测试的私有云环境,但由于计算资源不足,部分底座节点都使用虚拟机部署,做了计算资源超分。

测试负责人的计划是先创建一个小规模集群试水,然后部署 nginx 容器验证,模拟每个节点运行 100 个业务 Pod,结果 Pod 数量超过 8000 个的时候遭遇 kube-apiserver 异常,频繁重启无法工作,我排查后确认是出现了 OOM,原来控制面容器只设置了 limits,限制为 0.5C 和 750MiB,这时产生了第一个优化任务:Master 规格分档。因为时间问题,只能做最小化调整:默认使用最大规格的 etcd(三副本 4C8G + 本地盘),最大规格 Master 容器配置(request 与原先一致,limits 设置为较大,kube-apiserver、kube-controller-mamager、kube-scheduler统一为 16C32G,其余按需设置)。

手动调整 Master 规格后,测试团队继续加量,紧接着遇到下一个问题:节点列表卡顿报错,排查一番发现节点列表接口中存在调用 ECS 和 Prometheus 的操作,总是先获取资源列表和监控数据后,在进行分页,当节点数量过多时,就会出现调用超时,其他的资源如 deployment、statefulset、pod 等等,都是相同的逻辑,估计是为了复用函数导致的,这时产生了第二个优化任务:后台服务接口优化。这个任务工作量较多,由组长组织人手修复。

解决好控制面的问题后,还剩下节点与镜像仓库。节点内核参数未经优化,基本只设置了开启 IPv4 和 IPv6 转发的参数,客户的业务场景是在集群中部署大量网络探测和爬虫应用,会产生大量的访问公网请求,导致内核报错:kernel: nf_conntrack: table full, dropping packet,第三个任务:Worker 节点内核参数优化。镜像仓库的优化问题与大规模集群一样,需要通过压力测试摸底并调整组件规格。

当测试同学添加了 2000 多个节点后,测试环境内计算资源耗尽,我登录集群查看,有 1949 个节点处于 Ready 状态,于是利用 daemonset 测试并发拉取镜像,执行第四个任务:容器镜像仓库优化,测试使用的镜像 Dockerfile 如下:

FROM pause:3.15
RUN dd if=/dev/urandom of=/data bs=1M count=256
CMD ["/pause"]

利用随机数发生器生成指定大小的二进制写入同一位置,从而每次产生不同的镜像分层,让客户端每次都能从镜像仓库拉取分层,避免利用本地 blob 缓存影响测试。在不断提高并发拉取镜像的节点数量同时调整镜像仓库组件的规格,在第十轮中测中,完成容器镜像仓库的优化,harbor-core、harbor-registry、harbor-database 等调整了规格及连接数限制,在 1949 个节点并发拉取镜像时,kubelet 未产生 Image 相关错误,此时 harbor-core 服务 CPU 满载,registry 服务正常运行,存储系统读延迟 3.53s,写延迟 9.5s,继续加压后存储集群挂掉,也算是摸到了镜像仓库的上限了。

3. 第二次大规模测试

3.1 背景

某国内航空公司招标演示中容器相关的内容占比较多,记得我是在周一晚上 11 点多接到主管电话,随后就做准备飞往演示现场,参与第二天的容器技术演示。

招标文件中有一个硬性要求:单集群能够管理 3000 节点和 10 万 Pod,我们在两天多的时间内准备了一个环境,最后顺利通过测试。

3.2 部署过程

上一次的大规模测试是由质量部的测试负责人牵头,调集了几个开发环境的机器拼凑出的资源池,而这次由于时间问题已经来不及,老板们一顿讨论后,确定使用集团内部的一个大型私有云作为演示环境。

这个环境原本就是为集团内部各个子公司上云做准备的,资源比较充足,也运行了一部分业务,老板们纷纷拍板,感觉这是一次检验私有云稳定性的好机会(立 flag 了),于是我和两位测试同学动手开干了。

10 万 Pod 平摊到 3000 个节点上,每个节点运行 34 个 Pod,压力理论上不会很大,2C4G 的节点足矣。

我通过控制台创建了一个 K8s 集群,然后进入私有云后台,调整 Master 容器组中每个容器的 requests 和 limits,kube-apiserver:32C64G、kube-controller-manager:16C32G、kube-scheduler:16C32G,其余容器调整为 8C16G,etcd 使用数据库团队提供的最高规格:三副本 4C8G,存储是 NVME 本地盘做的 RAID。

接下来是最艰巨的工作,我们需要先创建出 3000 台 ECS,然后添加到集群中,最后部署出 10 万 Pod。

ECS 的控制台具备批量创建虚拟机的功能,在前一千个节点中没有遇到太大问题,每次批量创建 100 个虚拟机,然后通过容器集群产品页将其添加到集群中。接近两千节点时,x86 资源消耗完毕,我们临时创建了一个低规格 ARM 机型来添加 ARM 节点,接下来到三千个节点的过程是巨大的煎熬:批量创建虚拟机功能频发异常、云盘创建失败、添加到集群内的节点 NotReady,最后降到每次批量创建 5 个虚拟机才能确保实例孵化成功。

达到 3000 节点的目标时,实际创建了 3200 多个虚拟机,其中 200 多个异常,虚拟机的云盘归属的存储集群出现波动,影响了部分用户的业务。

除了上述问题外,集群内的监控服务 Prometheus 也挂了,而且无法恢复,这个监控服务只能单副本运行,当节点和 Pod 数量骤增时,就遭遇了 OOM,即使单独部署到一台 96C128G 的虚拟机上,也无法正常运行。

在添加节点的期间,我也在断断续续部署 Pod,开始使用 daemonset 部署,随着规模增加发现对于新增节点会产生较大压力(IO 性能不足导致系统卡死),于是改为 deployment,每次等待一批节点运行正常后才部署新的工作负载,最终达到了超过 10 万 Pod 的目标。

4. 优化总结

4.1 后台服务

大规模集群的节点数量、工作负载数量(deploy、sts、ds、 pod 等)都会超出正常集群的数倍,我们需要避免直接 List 全量资源,或者在列表接口中执行太多网络请求。

以节点为例,可以 利用 List 请求的 Limit 和 Continue 字段分页获取全量数据,或者 启动一个 Informer 同步数据到本地 cache 中再执行 List 资源(以空间换时间),而节点 ECS 状态、CPU与内存使用率等,可以异步缓存到 DB 或者 Redis,这样在节点列表中可以实现最少量的接口调用。

4.2 etcd

我在 etcd 这块还没有累积太多经验,只有两个办法:独立部署、定时备份。

老架构的集群数据使用数据库团队的 etcd 产品提供,以三副本的形式独立部署于 K8s 集群外,使用私有云节点的 NVME SSD 存储数据,可以有力支撑大规模集群。之前压测时我记录了集群存在 3000个节点,10万个Pod 的情况,稳定状态下:

  1. 每个 kube-apiserver 的内存占用 6G 左右
  2. 每个 etcd 的内存占用 1G 左右

alt text

alt text

alt text

alt text

etcd 实质上是一个 KV 数据库,存储了 K8s 的所有资源,当 K8s 集群规模扩大时,etcd 承载着 KV 数据也会剧增,私有云云盘的 IOPS 往往支撑不住 etcd 的大量读写导致集群异常,这一点在新架构中让我们吃了不少亏,一般来说 7200RPM 机械硬盘 IOPS 介于 75-100 之间,SATA SSD 硬盘的 IOPS 能达到四位数(千级),主流 NVMe SSD 的IOPS普遍达到了六位数(10 万级),etcd 存储的性能越高越好。

关于 etcd 的高可用可以参考:蚂蚁集团万级规模 K8s 集群 etcd 高可用建设之路

4.3 控制面组件

老架构中的控制面组件已经具备高可用,架构如下:

flowchart LR
    subgraph Master
        A1[kube-apiserver\nkube-scheduler\nkube-controller-manager]
        A2[kube-apiserver\nkube-scheduler\nkube-controller-manager]
        A3[kube-apiserver\nkube-scheduler\nkube-controller-manager]
    end
    subgraph etcd
        E1[etcd-member1]
        E2[etcd-member2]
        E3[etcd-member3]
        E1 <-.-> E2
        E2 <-.-> E3
        E3 <-.-> E1
    end
    L1[主 L4 LB]
    L2[备 L4 LB]
    L1 <-.-> L2
    L1 ---> A1
    L1 ---> A2
    L1 ---> A3
    A1 ---> etcd
    A2 ---> etcd
    A3 ---> etcd
    C[Worker]
    C -->|VIP| L1

Master 组件使用容器化的方式部署在底座集群上,每个副本中都包含完整控制面组件,客户端通过 VIP 访问 Master,请求会经过四层负载均衡到达后端的 kube-apiserver。

集群的高可用实际上就是 etcd 的高可用,当 etcd 独立部署后,即使只有一个 Master 运行,集群也可以正常工作。

当规模上升遇到性能瓶颈时,直接调整容器的规格即可,为了兼顾宿主机使用率和大规模集群性能,我先设定了集群规格分档:

规格 最大节点数量 最大Pod数量 控制面容器规格 (limits)
20 2200 4C8G
100 11000 8C16G
500 55000 32C64G

不过由于工期问题,只能先选择一个规格最为默认值,于是调整控制面容器的 requests 固定为 0.5C750MiB(与之前的 limits 保持一致确保调度不产生问题),再将 kube-apiserver、kube-controller-manager、kube-shceduler 等 limits 调整为 8C16G,有大规模场景时手动修改容器规格。

4.4 节点组件

节点组件主要包括:kubelet、kube-proxy、CNI 插件,这里主要针对前两者做优化。kubelet 启用 lease 机制,使用体积更小的 lease 代替上报 status 作为心跳,降低 etcd 的写入压力,kube-proxy 的 Proxy modes 修改为 ipvs,提升大规模场景下转发性能,CNI 插件可选 host-gw 模式的 Flannel(要求所有节点处于同一个二层网络下)或者 Calico 来提高网络性能。

4.5 节点内核优化

原先内核参数的配置过于随意,存在 k8s.conf、conf.conf 之类的名称,我统一改为 k8s-optimize.conf,下面是几个内核参数调整。

调节文件描述符限制

文件描述符的上限提升到 200 万。

文件位置:/etc/security/limits.d/k8s-optimize.conf

* soft     nproc          2097152
* hard     nproc          2097152
* soft     nofile         2097152
* hard     nofile         2097152
root soft     nproc          2097152
root hard     nproc          2097152
root soft     nofile         2097152
root hard     nofile         2097152

开机加载指定内核模块

/etc/modules-load.d/k8s-optimize.conf

ip_vs
ip_vs_rr
ip_vs_wrr
ip_vs_sh
nf_conntrack
ip_tables
iptable_filter
ipt_state
br_netfilter

这里还遇到了一个模块名问题,Linux kernel 4.18 后,nf_conntrack_ipv4 被重命名为 nf_conntrack,为了兼容性,需先检查模块是否存在再加载:

# load nf_conntrack_ipv4 if it exists
nf_conntrack_ipv4_exist=$(modinfo nf_conntrack_ipv4 2>&1|grep filename|wc -l)
if [ $nf_conntrack_ipv4_exist -gt 0 ];then
  echo -ne "nf_conntrack_ipv4
  " >> /etc/modules-load.d/k8s-optimize.conf
  modprobe nf_conntrack_ipv4
fi

其余内核参数优化

主要内容如下:

  1. 文件描述符:
    1. inotify 实例的最大数量为 16384
    2. 每个 inotify 实例最多可以监控的文件数量 2097152
    3. 系统可以打开的文件描述符的最大数量 2097152
    4. 单个进程可以打开的文件描述符的最大数量 2097152
  2. nf_conntrack:这些配置参数主要是为了调整系统处理大量 TCP 连接的能力
    1. 设置可跟踪的最大连接数为 2097152
    2. 设置连接跟踪表的哈希表大小为 524,288 个桶
    3. 设置连接的通用超时时间为 120 秒
    4. 开启 TCP 连接跟踪的 宽松模式,当设置为 1 时,内核会更加宽松地处理一些可能不完全符合标准的 TCP 流量,从而减少连接被意外丢弃的情况
    5. 开启松散的 TCP 状态跟踪模式,当设置为 1 时,连接跟踪会更宽松地匹配 TCP 数据包,即使在数据包丢失或顺序混乱的情况下,也不会立即丢弃连接
    6. 设置 TCP 最大重传次数为 3 次。如果在这之后还未收到确认,连接会被认为失败并丢弃
    7. 设置 TCP 连接在 CLOSE 状态下的超时时间为 10 秒
    8. 设置 TCP 连接在 CLOSE_WAIT 状态下的超时时间为 30 秒
    9. 设置 TCP 连接在 ESTABLISHED 状态下的超时时间为 1800 秒(30 分钟)。这个状态表示连接是活动的且已经建立,如果连接在这个时间段内没有任何活动,则会被清除。
    10. 设置 TCP 连接在 FIN_WAIT 状态下的超时时间为 30 秒
    11. 设置 TCP 连接在 LAST_ACK 状态下的超时时间为 30 秒
    12. 设置 TCP 连接在最大重传状态下的超时时间为 300 秒(5 分钟)。这用于处理重传失败的连接。
    13. 设置 TCP 连接在 SYN_RECV 状态下的超时时间为 30 秒。这个状态下,系统已收到 SYN 数据包,并发送了 SYN-ACK 响应,等待对方的 ACK 确认。
    14. 设置 TCP 连接在 SYN_SENT 状态下的超时时间为 30 秒。这个状态表示连接请求已发送,但尚未收到对方的确认。
    15. 设置 TCP 连接在 TIME_WAIT 状态下的超时时间为 30 秒。这个状态表示连接已经关闭,但系统等待确保对方接收到最后的 ACK。
    16. 设置 TCP 连接在未被确认(Unacknowledged)状态下的超时时间为 300 秒(5 分钟)。这个状态表示数据已经发送但未收到确认,用于处理等待确认的连接。
  3. TCP 优化
    1. 内核允许将处于 TIME_WAIT 状态的 TCP 连接重用于新的连接请求
    2. 启用 TCP 时间戳选项,避免序列号在连接重用时发生冲突
    3. 设置 TCP 连接发送第一次 keepalive 探测包之前的空闲时间 为 900 秒
    4. 设置在发送 keepalive 探测包后的时间间隔为 75 秒
    5. 设置发送 keepalive 探测包的最大次数为 9 次
  4. ARP(Address Resolution Protocol)和 NDP(Neighbor Discovery Protocol)缓存优化
    1. 设置第一阈值为 8192,当邻居表中的条目数达到此阈值时,内核会开始更积极地清理不常用的条目
    2. 设置第二阈值为 16384,当邻居表中的条目数达到此阈值时,内核将更频繁地尝试清理旧条目,以腾出空间
    3. 设置第三阈值为 32768,这是邻居表可以容纳的最大条目数。如果超过此值,新条目将无法添加,可能会导致网络连接问题,因为无法解析新的 IP 地址到 MAC 地址
  5. 开启IPv4 和 IPv6 的转发功能
# file descriptor
fs.inotify.max_user_instances=16384
fs.inotify.max_user_watches=2097152
fs.file-max=2097152
fs.nr_open=2097152
# nf_conntrack
# max:buckets -> 4:1
net.netfilter.nf_conntrack_max=2097152
net.netfilter.nf_conntrack_buckets=524288
## linux/k8s: 600 cks: 120
net.netfilter.nf_conntrack_generic_timeout=120
# nf_conntrack_tcp
## linux: 0 cks: 1
net.netfilter.nf_conntrack_tcp_be_liberal=1
net.netfilter.nf_conntrack_tcp_loose=1
net.netfilter.nf_conntrack_tcp_max_retrans=3
net.netfilter.nf_conntrack_tcp_timeout_close=10
## linux: 60 k8s: 3600 cks: 30
net.netfilter.nf_conntrack_tcp_timeout_close_wait=30
## linux: 43200 k8s: 86400, cks: 1800
net.netfilter.nf_conntrack_tcp_timeout_established=1800
## linux/k8s: 120 cks: 30
net.netfilter.nf_conntrack_tcp_timeout_fin_wait=30
net.netfilter.nf_conntrack_tcp_timeout_last_ack=30
net.netfilter.nf_conntrack_tcp_timeout_max_retrans=300
## linux/k8s: 60 cks: 30
net.netfilter.nf_conntrack_tcp_timeout_syn_recv=30
## linux/k8s: 120 cks: 30
net.netfilter.nf_conntrack_tcp_timeout_syn_sent=30
## linux/k8s: 120 cks: 30
net.netfilter.nf_conntrack_tcp_timeout_time_wait=30
net.netfilter.nf_conntrack_tcp_timeout_unacknowledged=300
# tcp-ipv4
## linux/k8s: 2 cks: 1
net.ipv4.tcp_tw_reuse=1
net.ipv4.tcp_timestamps=1
## tcp_keepalive
net.ipv4.tcp_keepalive_time=900
net.ipv4.tcp_keepalive_intvl=75
net.ipv4.tcp_keepalive_probes=9
# arp-ipv4
## linux/k8s: 128,512,1024 cks: 8192,16384,32768
net.ipv4.neigh.default.gc_thresh1=8192
net.ipv4.neigh.default.gc_thresh2=16384
net.ipv4.neigh.default.gc_thresh3=32768
# arp-ipv6
## linux/k8s: 128,512,1024 cks: 8192,16384,32768
net.ipv6.neigh.default.gc_thresh1=8192
net.ipv6.neigh.default.gc_thresh2=16384
net.ipv6.neigh.default.gc_thresh3=32768
# forward
net.ipv4.ip_forward=1
net.ipv6.conf.all.forwarding=1

4.6 容器镜像仓库

容器镜像的二进制文件实际存储在 S3 上,由于网络问题,在 ECS 上拉取一个容器镜像,请求会经过以下的组件:

flowchart LR
    docker --->|HTTPS| public-registry-proxy
    public-registry-proxy --->|HTTP|harbor-core
    harbor-core --->|HTTP|harbor-registry
    harbor-registry --->|TCP|s3multisandbox
    s3multisandbox -->|TCP-socat-socat-TCP|s3存储

当并发拉取镜像的节点数剧增时,整个链路上的服务都需要提升规格,总的来说做了以下优化:

  1. public-registry-proxy:其实就是一个 nginx,limits 改为 8C8G
  2. harbor-core:limits 改为 8C8G,PG 连接池改为 1000
  3. harbor-registry:CPU 占用较高,limits 改为 16C8G,Redis 连接池改为 1000
  4. harbor-database:limits 改为 8C8G,max_connections 由 100 改为 2048,该数值需要大于 harbor-core 的连接池大小,否则可能出现 PG 的健康失败导致数据库重启
  5. s3multisandbox:其实就是两个 socat 容器,利用本地 unix 套接字在经典网和管理网之间转发数据,limits 改为 8C8G

5. 补充测试

启动一个 Informer 同步数据到本地 cache 中再执行 List 资源(以空间换时间) 的想法一直在我脑海里,这次也做了一回测试,效果是比较好的,可惜资源占用较大,下面记录下测试过程。

5.1 实验环境

我在一台搭载 E5-2676 V4 的 PVE 主机上创建了多个 LXC 容器和 KVM 虚拟机测试,配置如下:

  1. etcd:三节点 etcd,使用 LXC 容器,规格为 4C8G
  2. k3s:单节点 k3s 集群,版本为 v1.25.16+k3s4,使用 KVM 虚拟机,规格为 8C16G
  3. 测试机:使用 LXC 容器,规格为 4C8G,运行 kubectl 和测试程序

5.2 测试方法

创建一个 user 命名空间,然后在里面使用 deployment 部署 10 万个 Pod,通过配置 nodeSelector、requests 和 limits 来让 Pod 处于 Pending 状态,deployment 配置如下:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: pending-pods
  namespace: user
spec:
  replicas: 100000  # 设置所需的 Pod 数量
  selector:
    matchLabels:
      app: pending-app
  template:
    metadata:
      labels:
        app: pending-app
    spec:
      nodeSelector:
        disktype: ssd  # 选择节点的标签
      containers:
      - name: pending-container
        image: image.wbuntu.com/library/nginx:1.20-alpine  # 使用轻量级镜像
        resources:
          requests:
            memory: "2Gi"  # 请求较高的资源
            cpu: "1000m"   # 请求较高的 CPU
          limits:
            memory: "2Gi"
            cpu: "1000m"

然后使用 Go 编写一个程序 podcache,主要功能如下:

  1. 启动一个 informer 来 watch 所有的 Pod,调谐函数中将 Pod 同步到内部缓存,缓存结构体为 Item,只保存 namespace、name 和 timestamp
  2. 内部缓存是一个 map + slice 的结构体,map 用于去重,slice 中按创建时间逆序保存 Item
  3. 同时启动一个 HTTP 服务器,提供一个查询 Pod 列表接口,支持使用命名空间、关键字过滤容器组

完整代码如下:

go.mod 文件

module github.com/wbuntu/podcache

go 1.23.0

require (
	k8s.io/api v0.31.0
	k8s.io/apimachinery v0.31.0
	k8s.io/client-go v0.31.0
	sigs.k8s.io/controller-runtime v0.19.0
)

require (
	github.com/beorn7/perks v1.0.1 // indirect
	github.com/cespare/xxhash/v2 v2.3.0 // indirect
	github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
	github.com/emicklei/go-restful/v3 v3.11.0 // indirect
	github.com/evanphx/json-patch/v5 v5.9.0 // indirect
	github.com/fsnotify/fsnotify v1.7.0 // indirect
	github.com/fxamacker/cbor/v2 v2.7.0 // indirect
	github.com/go-logr/logr v1.4.2 // indirect
	github.com/go-logr/zapr v1.3.0 // indirect
	github.com/go-openapi/jsonpointer v0.19.6 // indirect
	github.com/go-openapi/jsonreference v0.20.2 // indirect
	github.com/go-openapi/swag v0.22.4 // indirect
	github.com/gogo/protobuf v1.3.2 // indirect
	github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
	github.com/golang/protobuf v1.5.4 // indirect
	github.com/google/gnostic-models v0.6.8 // indirect
	github.com/google/go-cmp v0.6.0 // indirect
	github.com/google/gofuzz v1.2.0 // indirect
	github.com/google/uuid v1.6.0 // indirect
	github.com/imdario/mergo v0.3.6 // indirect
	github.com/josharian/intern v1.0.0 // indirect
	github.com/json-iterator/go v1.1.12 // indirect
	github.com/mailru/easyjson v0.7.7 // indirect
	github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
	github.com/modern-go/reflect2 v1.0.2 // indirect
	github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
	github.com/pkg/errors v0.9.1 // indirect
	github.com/prometheus/client_golang v1.19.1 // indirect
	github.com/prometheus/client_model v0.6.1 // indirect
	github.com/prometheus/common v0.55.0 // indirect
	github.com/prometheus/procfs v0.15.1 // indirect
	github.com/spf13/pflag v1.0.5 // indirect
	github.com/x448/float16 v0.8.4 // indirect
	go.uber.org/multierr v1.11.0 // indirect
	go.uber.org/zap v1.26.0 // indirect
	golang.org/x/exp v0.0.0-20230515195305-f3d0a9c9a5cc // indirect
	golang.org/x/net v0.26.0 // indirect
	golang.org/x/oauth2 v0.21.0 // indirect
	golang.org/x/sys v0.21.0 // indirect
	golang.org/x/term v0.21.0 // indirect
	golang.org/x/text v0.16.0 // indirect
	golang.org/x/time v0.3.0 // indirect
	gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect
	google.golang.org/protobuf v1.34.2 // indirect
	gopkg.in/inf.v0 v0.9.1 // indirect
	gopkg.in/yaml.v2 v2.4.0 // indirect
	gopkg.in/yaml.v3 v3.0.1 // indirect
	k8s.io/apiextensions-apiserver v0.31.0 // indirect
	k8s.io/klog/v2 v2.130.1 // indirect
	k8s.io/kube-openapi v0.0.0-20240228011516-70dd3763d340 // indirect
	k8s.io/utils v0.0.0-20240711033017-18e509b52bc8 // indirect
	sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd // indirect
	sigs.k8s.io/structured-merge-diff/v4 v4.4.1 // indirect
	sigs.k8s.io/yaml v1.4.0 // indirect
)

main.go 文件

package main

import (
	"context"
	"encoding/json"
	"net/http"
	"os"
	"strconv"
	"strings"
	"sync"
	"time"

	corev1 "k8s.io/api/core/v1"
	apierrors "k8s.io/apimachinery/pkg/api/errors"
	"k8s.io/apimachinery/pkg/runtime"
	"k8s.io/client-go/tools/clientcmd/api"
	ctrl "sigs.k8s.io/controller-runtime"
	"sigs.k8s.io/controller-runtime/pkg/cache"
	"sigs.k8s.io/controller-runtime/pkg/client"
	"sigs.k8s.io/controller-runtime/pkg/log"
	"sigs.k8s.io/controller-runtime/pkg/log/zap"
)

type reconciler struct {
	client.Client
	scheme *runtime.Scheme
	cache  *Cache
	store  cache.Cache
}

func (r *reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
	log := log.FromContext(ctx).WithValues("pod", req.NamespacedName)

	var pod corev1.Pod
	if err := r.Get(ctx, req.NamespacedName, &pod); err != nil {
		if !apierrors.IsNotFound(err) {
			log.Error(err, "unable to get pod")
			return ctrl.Result{}, err
		}
		r.cache.Delete(req.Namespace, req.Name)
		log.V(1).Info("Delete pod")
	} else {
		item := Item{
			Namespace: pod.Namespace,
			Name:      pod.Name,
			Timestamp: pod.CreationTimestamp.Time,
		}
		r.cache.Add(item)
		log.V(1).Info("Add pod")
	}
	return ctrl.Result{}, nil
}

// PodResponse 用于响应 Pod 列表
type PodResponse struct {
	Pods  []corev1.Pod `json:"pods"`
	Total int          `json:"total"`
}

// httpHandler 处理 HTTP 请求
func (r *reconciler) httpHandler(w http.ResponseWriter, req *http.Request) {
	namespace := req.URL.Query().Get("namespace")
	pageStr := req.URL.Query().Get("page")
	pageSizeStr := req.URL.Query().Get("pageSize")
	search := req.URL.Query().Get("search")

	page := 1
	pageSize := 10

	if p, err := strconv.Atoi(pageStr); err == nil {
		page = p
	}
	if ps, err := strconv.Atoi(pageSizeStr); err == nil {
		pageSize = ps
	}

	var items []*Item
	var total int
	if namespace != "" {
		items, total = r.cache.GetPagedByNamespace(namespace, search, page, pageSize)
	} else {
		items, total = r.cache.GetPaged(search, page, pageSize)
	}

	pods := make([]corev1.Pod, 0, len(items))
	for _, item := range items {
		var pod corev1.Pod
		err := r.store.Get(context.TODO(), client.ObjectKey{Namespace: item.Namespace, Name: item.Name}, &pod)
		if err == nil {
			pods = append(pods, pod)
		}
	}

	response := PodResponse{
		Pods:  pods,
		Total: total,
	}

	w.Header().Set("Content-Type", "application/json")
	json.NewEncoder(w).Encode(response)
}

func main() {
	ctrl.SetLogger(zap.New())
	setupLog := ctrl.Log.WithName("setup")
	mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{})
	if err != nil {
		setupLog.Error(err, "unable to start manager")
		os.Exit(1)
	}

	// in a real controller, we'd create a new scheme for this
	if err := api.AddToScheme(mgr.GetScheme()); err != nil {
		setupLog.Error(err, "unable to add scheme")
		os.Exit(1)
	}
	reconciler := &reconciler{
		Client: mgr.GetClient(),
		scheme: mgr.GetScheme(),
		cache:  NewCache(),
		store:  mgr.GetCache(),
	}
	if err := ctrl.NewControllerManagedBy(mgr).For(&corev1.Pod{}).Complete(reconciler); err != nil {
		setupLog.Error(err, "unable to create controller")
		os.Exit(1)
	}

	// 添加 HTTP 处理程序
	http.HandleFunc("/pods", reconciler.httpHandler)

	go func() {
		setupLog.Info("starting http server")
		if err := http.ListenAndServe(":9090", nil); err != nil {
			setupLog.Error(err, "unable to start HTTP server")
			os.Exit(1)
		}
	}()

	setupLog.Info("starting manager")
	if err := mgr.Start(ctrl.SetupSignalHandler()); err != nil {
		setupLog.Error(err, "problem running manager")
		os.Exit(1)
	}
}

// Item 表示缓存中的项目
type Item struct {
	Namespace string
	Name      string
	Timestamp time.Time
}

// Cache 用于存储 Pod 项目
type Cache struct {
	mu    sync.RWMutex
	items map[string]struct{} // 用于去重
	order []*Item             // 用于保存顺序
}

// NewCache 创建新的 Cache 实例
func NewCache() *Cache {
	return &Cache{
		items: make(map[string]struct{}),
		order: make([]*Item, 0),
	}
}

// Add 添加项到缓存
func (c *Cache) Add(item Item) {
	c.mu.Lock()
	defer c.mu.Unlock()

	key := item.Namespace + "/" + item.Name
	if _, exists := c.items[key]; !exists {
		c.items[key] = struct{}{}
		c.order = append(c.order, &item) // 添加到顺序列表
	}
}

// Delete 从缓存中删除项
func (c *Cache) Delete(namespace, name string) {
	c.mu.Lock()
	defer c.mu.Unlock()

	key := namespace + "/" + name
	if _, exists := c.items[key]; exists {
		delete(c.items, key)

		// 从顺序列表中删除
		for i, item := range c.order {
			if item.Namespace == namespace && item.Name == name {
				c.order = append(c.order[:i], c.order[i+1:]...) // 删除项
				break
			}
		}
	}
}

// GetPaged 获取分页数据
func (c *Cache) GetPaged(search string, page int, pageSize int) ([]*Item, int) {
	c.mu.RLock()
	defer c.mu.RUnlock()

	var filteredItems []*Item
	for _, item := range c.order {
		if contains(item.Name, search) {
			filteredItems = append(filteredItems, item)
		}
	}

	total := len(filteredItems)
	start := (page - 1) * pageSize
	end := start + pageSize

	if start > total {
		return []*Item{}, total
	}
	if end > total {
		end = total
	}

	return filteredItems[start:end], total
}

// GetPagedByNamespace 根据命名空间获取分页数据
func (c *Cache) GetPagedByNamespace(namespace string, search string, page int, pageSize int) ([]*Item, int) {
	c.mu.RLock()
	defer c.mu.RUnlock()

	var filteredItems []*Item
	for _, item := range c.order {
		if item.Namespace == namespace && contains(item.Name, search) {
			filteredItems = append(filteredItems, item)
		}
	}

	total := len(filteredItems)
	start := (page - 1) * pageSize
	end := start + pageSize

	if start > total {
		return []*Item{}, total
	}
	if end > total {
		end = total
	}

	return filteredItems[start:end], total
}

// contains 检查字符串是否包含子字符串
func contains(str, substr string) bool {
	return substr == "" || strings.Contains(str, substr)
}

5.3 测试结果

创建 Pod 及同步缓存过程中资源占用会有剧烈变化,这里以服务运行稳定后的结果为准:

k3s

CPU 占用稳定在 2~3 核,内存占用约 10GB

etcd

CPU 占用约 2 核,内存占用 1523MB,endpoint 状态输出如下:

➜  ~ etcdctl endpoint status -w table
+----------------------+------------------+---------+---------+-----------+------------+-----------+------------+--------------------+--------+
|       ENDPOINT       |        ID        | VERSION | DB SIZE | IS LEADER | IS LEARNER | RAFT TERM | RAFT INDEX | RAFT APPLIED INDEX | ERRORS |
+----------------------+------------------+---------+---------+-----------+------------+-----------+------------+--------------------+--------+
| 192.168.123.101:2379 |  d32a4b16a0f9191 |  3.5.15 |  832 MB |      true |      false |         3 |    3712326 |            3712325 |        |
| 192.168.123.102:2379 | 47c5ecfae8fccdb1 |  3.5.15 |  832 MB |     false |      false |         3 |    3712326 |            3712326 |        |
| 192.168.123.103:2379 |  96224218d3d7de4 |  3.5.15 |  832 MB |     false |      false |         3 |    3712326 |            3712326 |        |
+----------------------+------------------+---------+---------+-----------+------------+-----------+------------+--------------------+--------+

kubectl

这里使用简单粗暴的方式,获取全部 Pod 计算时间:

➜  ~ /usr/bin/time -v kubectl get pod -A > /dev/null
	Command being timed: "kubectl get pod -A"
	User time (seconds): 26.44
	System time (seconds): 1.80
	Percent of CPU this job got: 55%
	Elapsed (wall clock) time (h:mm:ss or m:ss): 0:50.98
	Average shared text size (kbytes): 0
	Average unshared data size (kbytes): 0
	Average stack size (kbytes): 0
	Average total size (kbytes): 0
	Maximum resident set size (kbytes): 2438748
	Average resident set size (kbytes): 0
	Major (requiring I/O) page faults: 0
	Minor (reclaiming a frame) page faults: 706168
	Voluntary context switches: 21425
	Involuntary context switches: 11041
	Swaps: 0
	File system inputs: 0
	File system outputs: 0
	Socket messages sent: 0
	Socket messages received: 0
	Signals delivered: 0
	Page size (bytes): 4096
	Exit status: 0

10 万个 Pod大约需要 50 秒才能拉取完成,最大占用约 2.4GB 内存,一般实现分页浏览 Pod 都是这么操作,全量获取、过滤、分页,这种做法也是导致 V3 架构中大规模场景下网页卡顿、请求超时频发的原因。

podcache

首先使用 curl 下载全量的 Pod,可以看到只花了 6 秒左右。

➜  ~ date && curl "http://192.168.123.3:9090/pods?page=1&pageSize=100007" -o pod.json && date
Wed Sep  4 09:59:39 PM CST 2024
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
100  317M    0  317M    0     0  60.6M      0 --:--:--  0:00:05 --:--:-- 78.8M
Wed Sep  4 09:59:45 PM CST 2024

然后使用 curl 获取一个分页,从 time 命令输出可以看到只花了 14ms,

➜  ~ time curl -s "http://192.168.123.3:9090/pods?page=10000&pageSize=10" -o /dev/null
curl -s "http://192.168.123.3:9090/pods?page=10000&pageSize=10" -o /dev/null  0.01s user 0.01s system 77% cpu 0.014 total

alt text

代价是峰状态情况下程序会跑满四核,10 万 Pod 内存占用 1691MB,informer 缓存同步完成,运行一段时间后,程序内存占用下降到 975MB。

如果要兼容原来设计的后台接口,我们需要使用 informer 监听多种常用资源,如 deployment、statefulset、replicaset、cronjob、job、pod、service、node、configmap…对于大规模集群来说,内存占用预计需要几十 GB,而 CPU 占用取决于资源变化频繁程度。