☰
Kubernetes分页机制全解析:从continue token到client-go Pager优化APIServer性能
2026/10/22 18:26:05 网站建设 项目流程

1. 场景与问题:当你的集群有几千个Pod时,一个简单的查询可能变成事故

先说一个很多运维和平台开发人员都踩过的坑:集群规模稍微上来一点,kubectl get pods -A或者某个服务调用List接口拉取全量资源时,APIServer 直接飚内存、响应时间从几十毫秒变成几秒,甚至触发超时重试把 APIServer 打崩。我见过某公司中间件团队排查过一次线上问题,根因就是一个定时任务在几千个节点上批量采集 Pod 信息,每个采集器都直接请求全量 List 接口,结果 APIServer 的 etcd 连接数打满,整个集群的读写都受到波及。排查到最后发现,依赖的 SDK 封装里根本没有给 List 请求加 limit。

Kubernetes 的分页查询机制其实是个被严重低估的优化手段。很多人知道--chunk-size这个 kubectl 参数,但不知道为什么它能减少内存消耗,不知道底层用的是continuetoken 而不是传统的page/pageSize,更不知道在 client-go 编程接口里怎么正确使用 Pager。等到出问题的时候,才回过头来研究这几个参数。

这篇文章我直接把这些东西讲透:API 层面的分页原理、kubectl 的正确用法、client-go 的编程实现、以及在大规模集群下的实战策略和坑。内容偏运维和平台开发方向,看完你至少能明白怎么避免“全量拉取”这个最常见的性能杀手。

2. 分页机制的底层原理:理解 limit 和 continue token

2.1 HTTP 层面的分页到底是怎么工作的

Kubernetes API 的分页不是传统意义上你传入page=1&size=100那种基于偏移量的分页,而是基于不透明 token的游标分页。核心是 List 请求的两个参数:

  • limit:单次返回的最大资源条目数。APIServer 在达到这个上限时,会将响应中 items 截断,并在返回体里携带metadata.continue字段。
  • continue:上一次请求返回的 token,客户端拿着这个 token 继续发请求,就能从上一批数据的末尾接着取下一批。

看一个实际的响应片段。执行:

kubectl get pods -n default --chunk-size=100 -o json --v=6

能看到类似这样的请求参数被组装到 URL 里:

GET https://<api-server>/api/v1/namespaces/default/pods?limit=100

返回 JSON 的metadata部分会多出一个字段:

{ "kind": "PodList", "metadata": { "resourceVersion": "18234567", "continue": "eyJ2IjoibWV0YS5rOHMuaW8vdjEiLA...", "remainingItemCount": 1433 }, "items": [...] }

第二次请求就把 token 带上:

GET https://<api-server>/api/v1/namespaces/default/pods?limit=100&continue=eyJ2IjoibWV0YS5rOHMuaW8vdjEiLA...

服务端继续返回下面 100 个,直到metadata.continue为空,此时remainingItemCount为 0,说明全部取完了。

2.2 为什么用 continue token 而不是 page offset

这是很多人没想明白的地方。传统数据库分页都是LIMIT 50 OFFSET 100,简单直接。但在 Kubernetes 这种分布式系统里,etcd 的数据是顺序存储在 MVCC 版本树里的,offset 分页意味着每次都要从第一个 key 开始往后数 N 个节点才能找到目标位置。集群里几万个对象时,这个 offset 的计算成本不可忽略。

continue token 本质上是一个 Base64 编码的不透明字符串,里面包含了 API 版本、资源版本号、游标 key 等信息。它指向的是 etcd 存储中一个具体的位置(revision 和 start key),服务端可以从这个位置直接继续往后扫,不需要从头再来。这个设计在一致性上和性能上都比 offset 好得多。

2.3 limit 和 continue 的几个关键行为细节

细节一:limit=0表示不限制,limit不传也相当于不限制。kubectl 在未指定--chunk-size时,默认会发送不带 limit 的 List 请求,一次性拉全量。这就是大规模集群下 kubectl 卡死的核心原因。

细节二:continuetoken 有有效期。token 是基于 etcd 的 compaction revision 编码的,如果数据超过一定时间(默认是 etcd 的 compaction window)没有刷新,老 token 会失效,请求会返回410 Gone Expired。client-go 内部对这种情况有重建 list 的重试逻辑,但如果你手工调用 API 或者写脚本,就要注意这条规则。

细节三:remainingItemCount是 v1 版本之后才有的字段,用来告诉客户端还有多少条数据。你可以用它实现“先查总数再分页”的逻辑,省掉一次额外的 count 查询。

细节四:分页对象的结果顺序是不保证的??严格说,资源是围绕 key 有序的,但如果你用labelSelector做了过滤,服务端是边遍历边过滤,返回的列表顺序仍然以 key 顺序为准。这意味着你不能依赖分页的顺序来做基于偏移的跳跃访问,只能按 token 逐页往后翻。

3. kubectl 命令行分页:chunk-size 参数的正确打开方式

3.1 基本用法与效果对比

命令行下,分页操作就一个参数:--chunk-size。

# 按 500 条一批拉取所有命名空间的 Pod kubectl get pods -A --chunk-size=500

这个命令内部被转换为多个分页 List 请求,每页 500 条,客户端把所有页合并后统一渲染。效果上,你看到的输出和一次性拉全量没有任何区别,但 APIServer 单次请求的内存占用大幅下降。

我实际测过一次:某测试集群约 2 万个 Pod 对象,裸kubectl get pods -A时,APIServer 进程瞬时内存从基线 600MB 涨到接近 2.5GB,响应耗时 8 秒以上。加上--chunk-size=500后,单次内存峰值不到 1.2GB,响应时间降到 2 秒左右。数据不一定精确,但趋势非常明显——分页确实能显著降低单次快照的峰值压力。

3.2 chunk-size 该设置多大

这个没有绝对标准,但有个经验区间。设置太小,比如 1,会产生成百上千次请求,网络往返和 APIServer 的 QPS 压力反而成为瓶颈。设置太大,比如 50000,就失去了分页的意义。

我的经验是:

集群规模(单资源总量)推荐 chunk-size理由
< 1万500 ~ 1000请求次数可控,合并开销低
1万 ~ 10万1000 ~ 5000平衡内存峰值和请求次数
> 10万5000 ~ 10000单次请求的数据量已经相当大,不建议再大

更大的 chunk-size 需要考虑一个隐藏问题:kubectl在拿到所有分页数据后,会做客户端内存中的全量合并。虽然 APIServer 的压力降下来了,但客户端本身(比如运维脚本或 CI 系统)的内存开销并没有减少,只是从服务端转移到了客户端。所以如果你运行 kubectl 的机器本身内存也不大,建议配合-o name这类只输出名称的模式,或者不要全量拉取。

3.3 sort-by 与分页的兼容性

注意一个容易踩坑的点:kubectl get pods --sort-by=.metadata.creationTimestamp --chunk-size=500。

当你使用--sort-by时,kubectl 无法在服务端完成排序,必须先把数据全部拉到客户端再进行排序。也就是说,这个场景下分页只是在传输层面减小了单次响应大小,最终客户端合并后的资源和一次性 List 完全相同。如果你的目的是避免 OOM,这个组合仍然有效;但如果你想通过网络层快速拿到“前 N 个最新 Pod”,这条路走不通——因为分页的顺序是 key 顺序,不是创建时间顺序。

4. client-go 编程分页:Pager 的完整实现逻辑

4.1 从裸 List 到 Pager 的演进

写 Kubernetes Operator 或者平台代码时,最原始的写法是直接调用Clientset.AppsV1().Deployments("default").List(ctx, metav1.ListOptions{})。在小集群里没问题,但一旦资源量增长,你能肉眼看到调用耗时变长、内存攀升。

client-go 内置了一个专门解决这个问题的组件:Pager。代码在k8s.io/client-go/tools/pager包里,核心是ListPager和它的EachListItem方法。

看一下基本用法:

import ( "context" "fmt" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/client-go/kubernetes" "k8s.io/client-go/tools/pager" ) func listAllPods(ctx context.Context, client kubernetes.Interface) error { p := pager.New(func(ctx context.Context, opts metav1.ListOptions) (runtime.Object, error) { return client.CoreV1().Pods("").List(ctx, opts) }) // 每页 500 个,逐页处理 err := p.EachListItem(ctx, metav1.ListOptions{}, func(obj runtime.Object) error { pod := obj.(*corev1.Pod) fmt.Printf("pod: %s/%s\n", pod.Namespace, pod.Name) return nil }) return err }

关键在于第二行开始的pager.New。这个构造函数接收一个ListPageFunc,每次内部需要拉下一页时都会调用这个函数,自动把limit和continuetoken 塞进ListOptions里。EachListItem则负责逐条调用回调函数,做到“边拉边处理”,不需要把所有条目一次性塞进内存。

4.2 设置分页大小和处理总数限制

Pager有一个公开字段PageSize,默认是 500。你可以调整它:

p := pager.New(listFunc) p.PageSize = 1000

还有一个很实用的字段FullListIfEmpty。当PageSize未设置时,如果这个字段为 true,Pager 会直接做一次全量 List,否则默认行为是先尝试分页。具体到我们的场景,建议显式设置PageSize,避免歧义。

另外,很多时候你不需要遍历所有资源,只想拿到前 N 条。Pager 的EachListItem返回一个特殊错误pager.ErrNoMoreItems,你在回调里判断累计数量达到目标后返回它,Pager 就会停止后续分页请求。注意,它内部会吞掉这个错误并正常返回 nil。如果你想自己控制停止逻辑,也可以回调返回一个自定义 sentinel error,但接受 ErrNoMoreItems 是你推荐的方式。

4.3 使用 List 返回完整对象而非回调

如果你确实需要一次性拿到完整列表,Pager 也提供了List方法:

list, err := p.List(ctx, metav1.ListOptions{})

这个方法会发出若干分页请求,把 items 全部合并后返回完整的*corev1.PodList。它的好处是同样降低了 APIServer 单次请求的压力,但客户端的合并内存仍然存在。真实场景中,如果你既要全量数据又想做二次处理,建议直接处理原始返回结果,而不是先合并成完整 list 再遍历,因为 Pager 的 List 方法本质上是在内存里 append 所有 items。

4.4 informer 的隐含分页机制

这里要顺带提一下Informer。很多人部署 Operator 时直接用informerFactory.Core().V1().Pods().Informer(),没意识到 informer 的 List 环节已经内置了分页机制。informer 首次启动时会调用List,而List内部通过pager.New配合一个默认值来分页拉取,默认PageSize在k8s.io/client-go/tools/cache里有常量。

这意味着:如果你的 Operator 依赖 informer,它的首次数据同步相对安全,不会一次性打爆 APIServer。但如果你在代码里手动调用 Clientset 的 List 接口,那就要自己负责分页。

5. 大规模集群下的分页策略与性能优化

5.1 分页不是万能的:什么时候该分页,什么时候不该分页

先说结论:分页解决的核心问题是“单次请求响应体过大导致的内存压力”,它不解决“你根本不需要那么多数据”的问题。

有一种很典型的情况:你的采集任务只需要获取所有 Pod 的基础状态和标签,对整个 Pod 的完整 Spec 不感兴趣。此时就算分页把数据拆成了 100 批,加起来还是要传输和反序列化几万个对象的完整 JSON。更好的方案是通过 API Server 的服务端字段过滤能力,只取需要的字段。这个可以用kubectl get pods -A -o custom-columns=NAME:.metadata.name,STATUS:.status.phase或者编程时使用jsonpath实现,实际能减少的流量比分页大得多。

另一个思路是按照namespace分批查询。集群级 List 和命名空间级 List 的筛选逻辑不同,后者在服务端的处理路径要短一些。对于 Pod 这类命名空间级别资源,把“全集群拉取”拆成“按 namespace 逐个拉取”,配合 labelSelector 进一步缩小范围,很多时候比单纯调大 chunk-size 效果更好。

5.2 用 resourceVersion 和分页配合实现“几乎实时”的持续同步

继续深入一层。Kubernetes 的分页查询可以配合resourceVersion参数做一致性快照。如果你要对着一个大规模资源集做持续同步,常见做法是:

第一步:用resourceVersion=0发起分页 list,拿到当前最新版本号。响应里的metadata.resourceVersion就是这次快照的全局版本。

第二步:对每个分页项做处理。

第三步:用这个 resourceVersion 发起Watch,之后增量更新。

为什么要提这个?因为默认的 List 返回的是“某个时间点的完整快照”,如果你只分页 List 但忽略 resourceVersion,后续 Watch 可能会产生断点,导致数据不一致。而resourceVersion=0的意思是“服务端给我当前最新的一个一致性的版本”。配合分页使用,可以实现从全量快照到增量监听的平滑过渡。

实现上仍然沿用上面的 Pager 模式,唯一不同是 ListOptions 里设置ResourceVersion: "0",注意不要在 Pager 的 listFunc 中覆盖这个字段。

5.3 缓存层和分页的搭配:把热点查询扛到 Redis

如果分页是为了服务上层业务的实时查询,而不是一次性运维任务,我建议在 APIServer 前面再加一层缓存。这里说的不是 informer 那种本地缓存,而是业务侧的聚合缓存,比如用 Redis 存储资源列表的索引,定期从 APIServer 分页同步数据。

实际操作中,分页同步任务可以这样设计:

  • 定时任务每 60 秒从 APIServer 分页拉取全量资源。
  • 拉取时按 namespace 拆分,避免单任务超时。
  • 数据落到 Redis 的哈希结构里,key 是资源 UID,field 是 namespace/name,value 是序列化摘要。
  • 外部查询只走 Redis,不走 APIServer。

这个方案能抗住很高的查询 QPS,同时把 APIServer 的压力压制到最低。但请注意缓存一致性——这里的“一致性”不是强一致,是 near-realtime 的最终一致。如果你的上层业务需要强一致,比如 Kubernetes 控制器要做资源调和,那还是老老实实用 informer + watch,不要套缓存。

5.4 另一个容易忽略的性能瓶颈:客户端反序列化

很多人在排查分页性能时只盯着 APIServer,忽略了一个大问题:客户端反序列化。

当你用kubectl get pods -A -o json或 client-go 拿到一个几 MB 的 JSON 后,Go 的标准库 JSON 解析会吃掉大量 CPU 和内存。分页只能缓解 APIServer 侧的压力,客户端的解析开销是躲不掉的。

这里有两个实战建议:

第一,能不解析完整对象就不解析完整对象。用kubectl的场景下用-o custom-columns或-o name,把解析的数据量降一个量级。用 client-go 时,如果只是要名字,就不要把整个 Pod 反序列化成*corev1.Pod,而是用metav1.PartialObjectMetadata配合metav1.PartialObjectMetadataList,或者干脆用动态客户端DynamicClient配合额外的字段选择器。

第二,调整解码器。client-go 的 Pager 默认使用metav1.UnsafeGuessKindToResource猜测资源类型,并在内部用apiutil的NewListPager做类型识别。如果你想压缩反序列化开销,可以自定义 listFunc,把 List 结果直接用一个轻量结构体接收,比如:

customPager := pager.New(func(ctx context.Context, opts metav1.ListOptions) (runtime.Object, error) { result := &corev1.PodList{} err := client.CoreV1().RESTClient().Get(). Namespace(""). Resource("pods"). VersionedParams(&opts, scheme.ParameterCodec). Do(ctx). Into(result) return result, err })

这段代码等于绕过了 Clientset 的一些默认处理,直接走 RESTClient 拿数据。实际上 clientset 内部也是这么实现的,所以收益有限。真正有效的还是配合PartialObjectMetadata减少字段解析。

不过说句实话,对 99% 的运维和平台场景来说,忘记这些极端优化,先把分页用对,你就已经解决了 80% 的问题。

6. 常见问题与排查技巧实录

我整理了一份关于 Kubernetes 分页查询的高频问题速查表,都是实际环境里反复出现的坑。

问题现象直接原因解决方案
type 前几页正常,某一页后突然报410 Gonecontinue token 过期,etcd compaction 已清理旧版本client-go 的 Pager 内部会重建 List,但手工脚本需要捕捉 410 重新从 limit=100 开始拉取
分页查询返回的数据仍然很大单个对象的 JSON 过于庞大(例如大 ConfigMap、Secret 里的超大值,或资源带有巨量注解)对单资源做字段裁剪,或用 Server-Side Apply 的 managedFields 关闭字段
--chunk-size和--sort-by同时使用反而更慢kubectl 把全量数据拉到本地排序,失去了流式处理能力先筛选,再排序;或单独拉取后内存中排序,不要依赖 chunk-size 降低排序开销
continue token 请求性能没有提升请求的limit超过单次 etcd 响应上限,APIServer 仍按内部限制拆分,客户端感知到的“分页”粒度已经是最大限制查看 APIServer 参数--max-requests-inflight和--min-request-timeout,合理设置 chunk-size
同一个分页列表在两个请求间数据不一致List + Watch 之间发生了资源变更,使用了非 0 的 resourceVersion 但没配合 watch 版本用resourceVersion=0做一致性快照,然后 watch 到该版本号之后的事件
全集群 List 某一个 CRD 时 APIServer 内存暴涨CRD 数量巨大且未使用分页确认是否可以用namespace维度隔离,并设置 chunk-size
client-go 里 Pager 的 PageSize 设置了但没生效场景是使用了 informer 的初始 List 而非手动 Pagerinformer 有自己的分页参数,位于cache.MetaNamespaceKeyFunc和 ListWatch 的ChunkSize字段中

6.1 问题一:continue token 失效能怎么快速恢复

这个我遇到过很典型的场景。一个数据同步脚本,定时从 APIServer 分页拉取节点列表。脚本每隔 30 分钟执行一次,但没处理 token 过期,跑了几天之后第一次执行正常,第二次总是报错。

排查过程很简单:登录 APIServer 日志,看到etcdserver: requested revision is unavailable这类日志。然后我确认了 etcd 后端开启的压缩策略。最终我给脚本加上了捕获 410 错误后重建分页请求的逻辑,问题解决。

实现逻辑很简单:

if apierrors.IsGone(err) { // 从头开始,重新 list continueToken = "" page := 0 }

记住,continue token 是一个“快照游标”,不是“会话签名”。重新拉取不等于回到第一页从零开始,而是再发一个不带 continue 的请求即可。

6.2 问题二:分页后数据顺序不稳定导致的下游处理错误

还有一个好多人犯过的错:依赖分页结果的顺序。Kubernetes 资源列表在服务端是有序的(按 etcd key 顺序),但这个顺序对上层业务几乎总是“无意义顺序”。分页后跨页之间的顺序保持稳定,但一旦集群里有大量资源频繁创建删除,key 的序列会变动,两次分页查询之间的相对顺序可能改变。

比如你实现了一个“逐一处理前 N 个 Deployment”的逻辑,第一页取完后还没处理完,另一批 Deployment 被创建了,第二次分页取到的数据就不再是“接下来该处理的那些”,可能重复或者遗漏。所以:任何基于分页的批处理任务,业务侧必须按资源 UID 或 name 做幂等,绝不能假设“这一页就是上一页之后的自然延续”。

6.3 问题三:chunk-size 调大后反而超时

有一个维护的集群,节点 5000 个,Pod 对象高峰期 4 万多个。刚开始运维同学把--chunk-size设成了 100,每页 100 条,总请求数 400 多次,平均每页也要处理 100 个对象,很快发现整个查询耗时非常长。后来调整为 chunk-size=5000,请求数只有 8 次,理论更快。但实际执行时 APIServer 处理单次 5000 个 Pod 的序列化和响应组装耗时超过 10 秒,超过了客户端默认的 HTTP 超时。

这种情况的解决思路不是无限调大 chunk-size,而是让 chunk-size 保持在 500~2000 之间,同时检查 APIServer 侧的--min-request-timeout是不是被设置得很低。另外,给 kubectl 或 client-go 设置更长的请求超时时间(比如 60 秒)也可以缓解。但根因还是避免单请求数据量过大,优先考虑用 namespace 拆分。

7. 最终的一些实操体会

把分页这件事吃透之后,我最大的体会是:Kubernetes 的很多性能问题,根子上不是 APIServer 不够快,而是客户端不会正确使用 List 语义。分页机制本身已经内置在 API 里,用不用、怎么用,完全取决于调用方。这个设计其实和其他分布式系统的分页理念是一致的——把选择权交给客户端,而不是服务端强制你只能拿到前 100 条。

如果只记住一条经验,那就是:凡是调用 Kubernetes List 接口处理海量数据,一律不要裸 List,先想想数据是不是真的需要全量。如果确实需要全量,就用 Pager 或--chunk-size。如果你不需要全量,用 labelSelector、namespace、字段过滤优先把范围缩小。这三个策略组合起来,能让你的集群在几千、几万甚至十万级资源规模下都稳如泰山。

最后再分享一个小细节:给大规模集群配置好分页之后,别忘了把 APIServer 的内存配额调整到位。分页降低了峰值,但并不意味着你可以无限量地提高 QPS。任何一次全量 List,即使拆分过,也是成本不低的读操作。该上缓存的上缓存,该走 informer 的走 informer,这比纠结某一页到底返回多少条数据重要得多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询