k8s list请求优化以及合理使用list以维护集群稳定性
apiserver/etcd List 示例分析
-
1、LIST apis/cilium.io/v2/ciliumendpoints?limit=500&resourceVersion = 0
这里同时传了两个参数,但 resourceVersion=0 会导致 apiserver 忽略 limit=500, 所以客户端拿到的是全量 ciliumendpoints 数据
此时不会查etcd,因为有resourceVersion = 0,且resourceVersion = 0会忽略limit,因为limit一定要查etcd
一种资源的全量数据可能是比较大的,需要考虑清楚是否真的需要全量数据 -
2、LIST api/v1/pods?filedSelector=spec.nodeName%3Dnode1
这个请求是获取 node1 上的所有 pods(%3D 是 = 的转义)。
根据 nodename 做过滤,给人的感觉可能是数据量不太大,但其实背后要比看上去复杂:
这种行为是要避免的,除非对数据准确性有极高要求,特意要绕过 apiserver 缓存。
首先,这里没有指定 resourceVersion=0,导致 apiserver 跳过缓存,直接去etcd读数据;
其次,etcd 只是 KV 存储,没有按 label/field 过滤功能(只处理 limit/continue),
所以,apiserver 是从 etcd 拉全量数据,然后在内存做过滤,开销也是很大的,后文有代码分析。 -
3、LISTapi/v1/pods?filedSelector=spec.nodeName%3Dnode1&resourceVersion = 0
跟 2 的区别是加上了 resourceVersion=0,因此 apiserver 会从缓存读数据,性能会有量级的提升。
但要注意,虽然实际上返回给客户端的可能只有几百 KB 到上百 MB(取决于 node 上 pod 的数量、pod 上 label 的多少等因素), 但 apiserver 需要处理的数据量可能是几个 GB。后面会有定量分析。
以上可以看到,不同的 LIST 操作产生的影响是不一样的,而客户端看到数据还有可能只 是 apiserver/etcd 处理数据的很小一部分。如果基础服务大规模启动或重启, 就极有可能把控制平面打爆。
判断是否必须从 etcd 读数据:shouldDelegateList()
func shouldDelegateList(opts storage.ListOptions) bool {
resourceVersion := opts.ResourceVersion
pred := opts.Predicate
pagingEnabled := DefaultFeatureGate.Enabled(features.APIListChunking) // 默认是启用的
hasContinuation := pagingEnabled && len(pred.Continue) > 0 // Continue 是个 token
hasLimit := pagingEnabled && pred.Limit > 0 && resourceVersion != "0" // 只有在 resourceVersion != "0" 的情况下,hasLimit 才有可能为 true
// 1. 如果未指定 resourceVersion,从底层存储(etcd)拉去数据;
// 2. 如果有 continuation,也从底层存储拉数据;
// 3. 只有 resourceVersion != "0" 时,才会将 limit 传给底层存储(etcd),因为 watch cache 不支持 continuation
return resourceVersion == "" || hasContinuation || hasLimit || opts.ResourceVersionMatch == metav1.ResourceVersionMatchExact
}
-
问:客户端未设置 ListOption{} 中的 ResourceVersion 字段,是否对应到这里的 resourceVersion == ""?
答:是的,所以第一节的 例子 会导致从 etcd 拉全量数据。
-
问:客户端设置了 limit=500&resourceVersion=0 是否会导致下次 hasContinuation==true?
答:不会,resourceVersion=0 将导致 limit 被忽略(hasLimit 那一行代码),也就是说, 虽然指定了 limit=500,但这个请求会返回全量数据。
-
问:还有其他情况需要去etcd拉取吗?
答:apiserver cache未建立完成的时候
apiserver/etcd List 开销分析
List 请求可以分为两种:
-
1、List 全量数据:开销主要花在数据传输;
-
2、指定用 label 或字段(field)过滤,只需要匹配的数据。
大部分情况下,apiserver 会用自己的缓存做过滤,这个很快,因此耗时主要花在数据传输;
需要将请求转给 etcd 的情况,
前面已经提到,etcd 只是 KV 存储,并不理解 label/field 信息,因此它无法处理过滤请求。实际的过程是:apiserver 从 etcd 拉全量数据,然后在内存做过滤,再返回给客户端。
因此除了数据传输开销(网络带宽),这种情况下还会占用大量apiserver CPU 和内存
大规模部署时潜在的问题
用 k8s client-go 根据 nodename 过滤 pod:
podList, err := Client().CoreV1().Pods("").List(ctx(), ListOptions{FieldSelector: "spec.nodeName=node1"})
来实际看一下它背后的数据量。以一个 4000 node,10w pod 的集群为例,全量 pod 数据量:
- etcd 中:紧凑的非结构化 KV 存储,在 1GB 量级;
- apiserver 缓存中:已经是结构化的 golang objects,在 2GB 量级
- apiserver 返回:client 一般选择默认的 json 格式接收, 也已经是结构化数据。全量 pod 的 json 也在 2GB 量级。
可以看到,某些请求看起来很简单,只是客户端一行代码的事情,但背后的数据量是惊人的。指定按 nodeName 过滤 pod 可能只返回了 500KB 数据,但 apiserver 却需要过滤 2GB 数据 ------ 最坏的情况,etcd 也要跟着处理 1GB 数据 (以上参数配置确实命中了最坏情况,见下文代码分析)。
集群规模比较小的时候,这个问题可能看不出来(etcd 在 LIST 响应延迟超过某个阈值 后才开始打印 warning 日志);规模大了之后,如果这样的请求比较多,apiserver/etcd 肯定是扛不住的。
LIST example
为了避免客户端库(例如 client-go)自动帮我们设置一些参数,我们直接用 curl 来测试,指定证书就行了:
$ cat curl-k8s-apiserver.sh
curl -s --cert /etc/kubernetes/pki/admin.crt --key /etc/kubernetes/pki/admin.key --cacert /etc/kubernetes/pki/ca.crt $@
使用方式:
$ ./curl-k8s-apiserver.sh "https://localhost:6443/api/v1/pods?limit=2"
{
"kind": "PodList",
"metadata": {
"resourceVersion": "2127852936",
"continue": "eyJ2IjoibWV0YS5rOHMuaW8vdjEiLCJ...",
},
"items": [ {pod1 data }, {pod2 data}]
}
指定 limit=2:response 将返回分页信息(continue)
$ ./curl-k8s-apiserver.sh "https://localhost:6443/api/v1/pods?limit=2"
{
"kind": "PodList",
"metadata": {
"resourceVersion": "2127852936",
"continue": "eyJ2IjoibWV0YS5rOHMuaW8vdjEiLCJ...",
},
"items": [ {pod1 data }, {pod2 data}]
}
可以看到:
- 确实返回了两个 pod 信息,在 items[] 字段中;
- 另外在 metadata 中返回了一个 continue 字段,客户端下次带上这个参数,apiserver 将继续返回剩下的内容,直到 apiserver 不再返回 continue。
指定 limit=2&resourceVersion=0:limit=2 将被忽略,返回全量数据:
$ ./curl-k8s-apiserver.sh "https://localhost:6443/api/v1/pods?limit=2&resourceVersion=0"
{
"kind": "PodList",
"metadata": {
"resourceVersion": "2127852936",
"continue": "eyJ2IjoibWV0YS5rOHMuaW8vdjEiLCJ...",
},
"items": [ {pod1 data }, {pod2 data}, ...]
}
items[] 里面是全量 pod 信息。
指定 spec.nodeName=node1&resourceVersion=0 vs. spec.nodeName=node1
$ ./curl-k8s-apiserver.sh "https://localhost:6443/api/v1/namespaces/default/pods?fieldSelector=spec.nodeName%3Dnode1" | jq '.items[].spec.nodeName'
"node1"
"node1"
"node1"
...
$ ./curl-k8s-apiserver.sh "https://localhost:6443/api/v1/namespaces/default/pods?fieldSelector=spec.nodeName%3Dnode1&resourceVersion=0" | jq '.items[].spec.nodeName'
"node1"
"node1"
"node1"
...
速度差异很大,用 time 测量以上两种情况下的耗时,会发现对于大一些的集群,这两种请求的响应时间就会有明显差异。
对于 4K nodes, 100K pods 规模的集群,以下数据供参考:
- 不带 resourceVersion=0(读 etcd 并在 apiserver 过滤): 耗时 10s
- 带 resourceVersion=0(读 apiserver 缓存): 耗时 0.05s
部署和调优建议
1、List 请求默认设置 ResourceVersion=0
不设置这个参数将导致 apiserver 从 etcd 拉全量数据再过滤,导致很慢,规模大了 etcd 扛不住
因此,除非对数据准确性要求极高,必须从 etcd 拉数据,否则应该在 LIST 请求时设置 ResourceVersion=0 参数, 让 apiserver 用缓存提供服务。
如果你使用的是 client-go 的 ListWatch/informer 接口, 那它默认已经设置了 ResourceVersion=0。
2、优先使用 namespaced API
如果要 LIST 的资源在单个或少数几个 namespace,考虑使用 namespaced API:
Namespaced API: /api/v1/namespaces//pods?query=xxx
Un-namespaced API: /api/v1/pods?query=xxx
3、Restart backoff
对于 per-node 部署的基础服务,例如 kubelet、cilium-agent、daemonsets,需要 通过有效的 restart backoff 降低大面积重启时对控制平面的压力。
例如,同时挂掉后,每分钟重启的 agent 数量不超过集群规模的 10%(可配置,或可自动计算)。
4、优先通过 label/field selector 在服务端做过滤
如果需要缓存某些资源并监听变动,那需要使用 ListWatch 机制,将数据拉到本地,业务逻辑根据需要自己从 local cache 过滤。这是 client-go 的 ListWatch/informer 机制。
但如果只是一次性的 LIST 操作,并且有筛选条件,例如前面提到的根据 nodename 过滤 pod 的例子, 那显然应该通过设置 label 或字段过滤器,让 apiserver 帮我们把数据过滤出来。LIST 10w pods 需要几十秒(大部分时间花在数据传输上,同时也占用 apiserver 大量 CPU/BW/IO), 而如果只需要本机上的 pod,那设置 nodeName=node1 之后,LIST 可能只需要 0.05s 就能返回结果。
另外非常重要的一点时,不要忘记在请求中同时带上resourceVersion=0。
-
Label selector
在 apiserver 内存过滤。
-
Field selector
在 apiserver 内存过滤。
-
Namespace selector
etcd 中 namespace 是前缀的一部分,因此能指定 namespace 过滤资源,速度比不是前缀的 selector 快很多。
5、配套基础设施(监控、告警等)
以上分析可以看成,client 的单个请求可能只返回几百 KB 的数据,但 apiserver(更糟糕的情况,etcd)需要处理上 GB 的数据。因此,应该极力避免基础服务的大规模重启,为此需要在监控、告警上做的尽量完善。
-
使用独立 ServiceAccount
每个基础服务(例如 kubelet、cilium-agent 等),以及对 apiserver 有大量 LIST 操作的各种 operator, 都使用各自独立的 SA, 这样便于 apiserver 区分请求来源,对监控、排障和服务端限流都非常有用。
-
Liveness 监控告警
基础服务必须覆盖到 liveness 监控。
6、Get 请求:GetOptions{}
基本原理与 ListOption{} 一样,不设置 ResourceVersion=0 会导致 apiserver 去 etcd 拿数据,应该尽量避免。