AI时代的Kubernetes教程36招-第34招-Kubernetes性能到底该从API Server、Scheduler、etcd、网络还是存储调?

Kubernetes 性能调优:从 etcd fsync 到应用 P99 的分层证据基线

集群一慢就猜组件,是最贵的调优方式

[读者] 负责 Kubernetes 控制面与应用端性能的平台 / SRE 工程师
[痛点] 集群一慢先加副本加节点,指标看了一圈仍说不清瓶颈在哪一层
[现在读] 要做容量评估与性能基线,需要一套可复测、可归因的采集口径
[读完] 能按层建基线、按证据判越界,并写出可回归的对比脚本
[旧方案] 看总览大盘 + 按经验拍板扩容
    |
    v
[新需求] 规模上量、多租户混跑,延迟抖动必须定位到具体层
    |
    v
[冲突] 没有分层基线,同一份“变慢”在各层的表现互相矛盾
    |
    v
[后果] 扩容烧钱不解决问题,变更互相掩盖,复盘始终收敛不了

我是老李,在一家做多租户托管平台的公司负责集群侧工程。集群从几百节点扩到两千节点量级,上面跑着几十个团队、上千个命名空间的负载。

规模上来之后,“变慢”变成一件很难描述的事。应用团队说接口 P99 抖,调度团队说 Pod 起不来,平台侧看到 API Server 写入延迟上升、etcd 磁盘告警、Scheduler 队列堆积。

每个团队拿出来的证据都对,但拼不到一起。于是每次性能问题最后都变成“谁先动手谁有理”。

我后来把这件事压成一句话:调优不是找“最慢的组件”,而是找“证据越界的那一层”。而要判断越界,前提是先有分层基线。

集群变慢时,第一刀该切在哪一层?

是继续加 API Server 副本、加节点,还是先停下来把各层的基线和口径补齐?

如果继续凭经验猜,最坏的结果是什么?

01、故事

现场是一个自研多租户托管平台:3 台控制面、3 台 etcd,工作节点两千量级,机器跨机房、磁盘型号也不完全一致。控制面只由平台团队维护,但上面跑的负载来自几十个团队。

这次的任务是把“集群变慢”的定位路径固化成一个可复测的流程,而不是每次出事现搭仪表盘。

当时我们手上的方案很朴素:一块总览大盘,加上“出问题就加副本、加节点”的经验。大盘上有 CPU、内存、请求总量、错误率,看起来该有的都有。

变化出现在两个方向:一是租户数量翻倍,命名空间和 CRD 数量跟着涨;二是运维方式变了,批量发布、批量打标、批量重启这类脚本化操作变多,一次脚本跑起来,控制面就“迟钝”十几分钟。

于是同一周里出现了三类报障:

  1. 应用侧接口 P99 抖动,但应用自己的服务耗时指标很稳;
  2. 大版本发布时,Pod 从 Pending 到 Running 的时间明显拉长;
  3. 批量运维脚本一跑,所有团队都感觉控制面变慢,包括 kubectl 都开始卡。

三类现象指向三个不同的组件:API Server、Scheduler、kubelet 与运行时。但变更窗口只有一个,谁先改,谁就会“看起来有效”。

这就是本篇的核心冲突:在缺少分层证据的情况下,所有跨层结论都是猜的,而猜错的方向一旦落地,就会变成长期的技术债。

规模翻十倍
延迟四处冒
谁都在喊冤
证据未分层

02、问题

旧方案失效的地方很具体:大盘做的是高聚合平均,把租户、verb、resource、profile 全混在一起。它能告诉你“整体变慢了”,但回答不了“谁在排队、排在哪一层、排了多久”。

业务影响有三条:

  • 发布窗口被拉长,滚动发布从“分钟级等待”变成“需要人工盯着看”;
  • 批量运维不敢做,变更排期越积越多,反过来又让单次变更范围更大、风险更集中;
  • 对外 SLO 无法承诺,因为没有可复测的容量结论,只能给一个很保守的数字。

技术表现(定性描述,具体数值必须由你们自己的基线给出):

  • 写路径:API Server 写入分位抬升、apiserver_current_inflight_requests 逼近上限、APF 出现排队甚至拒绝;
  • 存储层:etcd 的 WAL fsync 与 backend commit 出现毛刺,DB 体积只增不减;
  • 调度层:Scheduler 的 pending 队列堆积,调度时长分位抬升;
  • 控制器层:workqueue 深度上涨、队列等待时间拉长;
  • 节点层:kubelet PLEG relist 变慢,节点上所有 Pod 的状态推进一起延迟;
  • 启动链路:containerd、CNI、CSI 任一环变慢都会被放大成“Pod 慢”;
  • 名称解析:CoreDNS 查询量与 search path 放大倍数相关,而不只和 Pod 数相关。

把这些现象放在一起,问题的本质不是“某个组件性能差”,而是“我们没有一个统一口径去判断哪一层越界”。

所以本篇的完成标准写成四条可验证的:

  1. 每一层都有一份基线快照,快照里明确记录采集口径(PromQL、时间窗口、单位、阈值来源);
  2. 任一层的越界能给出“哪一层、哪个量、什么时间窗”的结论;
  3. 变更前后能用同一口径对比,差异可量化、可回归;
  4. 整个流程可以用一个脚本重复执行,不依赖某个人记得怎么查。

旧法靠直觉
扩容不见效
判定无标准
复测不可比

03、原理

本篇只讲支撑后面所有排查与优化的那部分原理。

第一,控制面是“一条共享写路径 + 多条相对独立的读路径”。写路径大致是:API Server 鉴权准入 → etcd 的 Raft 提案 → WAL 落盘 fsync → apply 到状态机 → backend commit。读路径则大量命中 watch cache,或者从 etcd 读取已序列化的对象。

反直觉判断:API Server 本身是无状态的,但写请求的延迟主项通常不在 API Server 内部,而在 etcd 的等待与 fsync 上。因此,当写路径成为瓶颈时,横向扩容 API Server 副本既不会让单个请求更快,也不会消除那个串行点——它只会把更多并发压进同一个 fsync 队列。这也解释了为什么“加了副本反而更抖”。

第二,APF(API Priority and Fairness)把公平性从进程级挪到了请求级。它用 FlowSchema 定义“哪些请求算一类”,用 PriorityLevelConfiguration 定义“这类请求有多少并发份额(seat)”。没有分层时,一个批量脚本的 list/update 会和 kubelet 的心跳、控制器的写回抢同一池并发;有了分层,关键流量可以保住自己的座位,非关键流量可以被排队甚至被拒。

第三,Scheduler 的成本模型是“单位调度成本 ≈ 参与过滤打分的节点数 × 打分插件成本”。percentageOfNodesToScore 控制的是扫描比例:当它被显式设为 0 时,调度器会按集群规模自动推算一个比例,集群越大比例越小;它必须大于 0 且小于 100。

反直觉判断:调度变慢,往往不是因为节点太少,而是因为每个 Pod“看得太多”——扫描比例被写死,或者 profile 里保留了成本很高的打分插件,导致单位调度成本随节点数一起上涨。

第四,Pod 启动链路是串行的:Scheduler 绑定 → kubelet admit → CRI/containerd 创建沙箱与容器 → CNI 配置网络 →(有卷时)CSI attach/mount → 容器真正启动。链路上任意一环变慢,端到端都慢。而在节点内部,PLEG 的 relist 是单线程的:任何一个慢的运行时调用都会阻塞整个节点的状态推进。

反直觉判断:节点上“业务不重”,不等于节点不会成为瓶颈。PLEG 被拖住时的表现是“这个节点上所有 Pod 都慢”,而不是“某个 Pod 慢”,这很容易被误判成网络或调度问题。

第五,DNS 的放大来自调用模式而不是 Pod 数量。默认的 ndots 与 search path 会让一次短域名解析先尝试若干后缀,一次应用调用可能放大成多次查询;CoreDNS 的 QPS 因此和应用的调用习惯强相关。

最后是贯穿全文的分层证据原则:做跨层归因时,先比较“层内的排队延迟”和“层内的服务时间”。排队主导,说明瓶颈就在这一层;服务时间主导,说明瓶颈在更下一层。

说明:本文涉及的组件指标名与配置项在不同版本间存在改名与增删,凡不能确认的部分按当前官方文档核验,见文末作业中的核验方法。

写路径串行
读路径并发
排队在层内
归因看证据

04、架构

[输入] 各层 /metrics、etcd endpoint 状态、控制面与工作节点事件
    |
    v
[模块] 分层采集器(分层清单 SPEC + PromQL 查询器 + 快照器)
    |
    v
[数据/状态] 版本化基线快照(layer → metric → value / threshold / unit)
    |
    v
[处理] 差异对比 + 阈值判定 + 归因(哪一层的哪个量越界)
    |
    v
[输出] 结论:瓶颈层 + 待验证假设 + 复测口径

这套结构的边界很明确:

  • 它只做“证据归层与判定”,不做自动修复,也不做根因推断——根因仍然要人去验证;
  • 它的输入必须是已经被采集到的指标,采集链本身(Prometheus、node-exporter、CoreDNS metrics 插件)不在本篇范围内;
  • 它的输出是一份可对比的快照,而不是一张好看的图。

收益是排查路径的收敛:以前“集群慢”要逐个组件翻,现在先看哪一层越界,再决定往哪一层深挖。代价也很直接:需要维护一份分层清单,需要为每个量标注单位和阈值来源,并且在 PromQL 里用窗口和分位替代瞬时值,查询成本会上升。

适用条件是:控制面与应用在同一个可观测体系内,且你能拿到各层的关键指标。如果是托管控制面,你拿不到 etcd 与 Scheduler 的内部指标,那么这套清单要按可获得的指标裁剪,判定门槛也要相应放宽。

分层清单的骨架如下(这是本篇的核心资产):

层 关键量 采集来源
API Server 请求延迟分位(按 verb/resource/scope)、inflight、watch 流数、存储体积 /metrics
APF 排队数、执行数、被拒数、等待时长分位 /metrics
etcd WAL fsync、backend commit、DB 体积、leader 变化次数、peer RTT /metrics 与 etcdctl endpoint status
Scheduler pending 队列、调度尝试结果分布、调度时长分位(按 profile) /metrics
Controller workqueue 深度、队列等待时长、处理时长、重试次数 /metrics
kubelet / PLEG relist 时长、relist 间隔、Pod worker 时长、runtime operation 分位 /metrics
containerd CRI/运行时操作延迟(按 operation_type 拆分) kubelet 指标与 containerd 指标
CNI 网络插件 add/del 时长 运行时与 kubelet 侧观测(指标名按当前版本核验)
CoreDNS 查询延迟分位、rcode 分布、缓存命中 CoreDNS metrics 插件
CSI attach/detach、mount/unmount 时长 CSI sidecar 指标与 kubelet 卷相关指标
Node CPU 饱和度、CFS 节流比例、内存工作集、磁盘 IO node-exporter / cAdvisor
应用 自身 RED 指标与依赖调用耗时 应用 /metrics 或链路追踪

大规模集群再额外关注三件事:API Priority and Fairness 的分层是否合理、watch/list 的压力是否被放大、以及 Scheduler profiles 是否按负载类型做了隔离。

清单定层次
采集成快照
对比见越界
结论可复测

05、实战一次

先做一个最小但完整的 V1:把上面的清单跑通一次,落一份快照。

环境与版本:控制面与各组件指标已通过 Prometheus 采集;脚本按 Kubernetes 文档中公开的通用指标名编写,具体版本差异需按当前官方文档核验。

依赖:curl、jq、一个可访问的 Prometheus 查询入口;交叉验证时还需要 kubectl 与 etcdctl。

核心实现(V1:瞬时采样):

#!/usr/bin/env bash
# baseline-v1.sh —— Kubernetes 分层基线快照(V1:瞬时采样)
set -euo pipefail

PROM="${PROM:-http://127.0.0.1:9090}"   # Prometheus 查询入口
OUT="${OUT:-baseline-v1.json}"

query() {                                # $1=PromQL $2=layer $3=name
  curl -sG "${PROM}/api/v1/query" --data-urlencode "query=$1" \
  | jq -c --arg l "$2" --arg n "$3" \
      '{layer:$l, name:$n,
        series:[.data.result[] | {labels:.metric, value:(.value[1]|tonumber)}]}'
}

emit() { query "$3" "$1" "$2"; }         # $1=layer $2=name $3=PromQL

{
  # ---- API Server:写/读延迟与并发 ----
  emit apiserver write_p99_seconds \
    'histogram_quantile(0.99, sum by (le) (rate(apiserver_request_duration_seconds_bucket{verb=~"POST|PUT|PATCH|DELETE"}[5m])))'
  emit apiserver read_p99_seconds \
    'histogram_quantile(0.99, sum by (le) (rate(apiserver_request_duration_seconds_bucket{verb="GET"}[5m])))'
  emit apiserver inflight_mutating 'apiserver_current_inflight_requests{request_kind="mutating"}'
  emit apiserver inflight_readonly 'apiserver_current_inflight_requests{request_kind="readOnly"}'
  emit apiserver watch_streams 'sum(apiserver_longrunning_requests{verb="WATCH"})'
  emit apiserver storage_bytes 'sum(apiserver_storage_size_bytes)'

  # ---- APF:排队与被拒 ----
  emit apf rejected_rate 'sum(rate(apiserver_flowcontrol_rejected_requests_total[5m]))'
  emit apf wait_p99_seconds \
    'histogram_quantile(0.99, sum by (le) (rate(apiserver_flowcontrol_request_wait_duration_seconds_bucket[5m])))'
  emit apf executing_requests 'sum(apiserver_flowcontrol_current_executing_requests)'
  emit apf inqueue_requests 'sum(apiserver_flowcontrol_current_inqueue_requests)'

  # ---- etcd:磁盘与体积 ----
  emit etcd wal_fsync_p99_seconds \
    'histogram_quantile(0.99, sum by (le) (rate(etcd_disk_wal_fsync_duration_seconds_bucket[5m])))'
  emit etcd backend_commit_p99_seconds \
    'histogram_quantile(0.99, sum by (le) (rate(etcd_disk_backend_commit_duration_seconds_bucket[5m])))'
  emit etcd db_size_bytes 'etcd_mvcc_db_total_size_in_bytes'
  emit etcd leader_changes 'increase(etcd_server_leader_changes_seen_total[1h])'

  # ---- Scheduler:队列与调度时长 ----
  emit scheduler pending_pods 'sum by (queue) (scheduler_pending_pods)'
  emit scheduler schedule_attempts 'sum by (result) (rate(scheduler_schedule_attempts_total[5m]))'
  emit scheduler scheduling_p99_seconds \
    'histogram_quantile(0.99, sum by (le) (rate(scheduler_scheduling_attempt_duration_seconds_bucket[5m])))'

  # ---- Controller:workqueue ----
  emit controller queue_depth_top5 'topk(5, workqueue_depth)'
  emit controller queue_wait_p99_seconds \
    'histogram_quantile(0.99, sum by (le) (rate(workqueue_queue_duration_seconds_bucket[5m])))'
  emit controller retries_rate 'sum by (name) (rate(workqueue_retries_total[5m]))'

  # ---- kubelet:PLEG 与运行时 ----
  emit kubelet pleg_relist_p99_seconds \
    'histogram_quantile(0.99, sum by (le) (rate(kubelet_pleg_relist_duration_seconds_bucket[5m])))'
  emit kubelet runtime_op_p99_seconds \
    'histogram_quantile(0.99, sum by (le, operation_type) (rate(kubelet_runtime_operations_duration_seconds_bucket[5m])))'

  # ---- 节点:CPU 节流 ----
  emit node cpu_throttle_top5 \
    'topk(5, rate(container_cpu_cfs_throttled_periods_total[5m]) / rate(container_cpu_cfs_periods_total[5m]))'

  # ---- CoreDNS:解析延迟与响应码 ----
  emit coredns dns_p99_seconds \
    'histogram_quantile(0.99, sum by (le) (rate(coredns_dns_request_duration_seconds_bucket[5m])))'
  emit coredns responses_rate 'sum by (rcode) (rate(coredns_dns_responses_total[5m]))'
} | jq -s --arg ts "$(date -u +%Y-%m-%dT%H:%M:%SZ)" '{ts:$ts, samples:.}' | tee "$OUT"

执行:

chmod +x baseline-v1.sh
PROM=http://prometheus.monitoring:9090 OUT=baseline-v1.json ./baseline-v1.sh
jq empty baseline-v1.json && echo "快照是合法 JSON"

未在当前环境实测,以下为预期结果。示例输出(节选,仅展示结构):

{
  "ts": "2024-01-01T00:00:00Z",
  "samples": [
    {"layer":"etcd","name":"wal_fsync_p99_seconds","series":[{"labels":{},"value":0.0082}]},
    {"layer":"apiserver","name":"inflight_mutating","series":[{"labels":{},"value":41}]},
    {"layer":"scheduler","name":"pending_pods","series":[{"labels":{"queue":"active"},"value":0}]}
  ]
}

验证点四条:

  1. jq '.samples | length' baseline-v1.json 的数量应等于脚本里 emit 的条数,任何一条缺失都说明 PromQL 与当前版本指标名不匹配;
  2. etcd db_size_bytes 的数量级应与 etcdctl endpoint status --write-out=table 里的 DB SIZE 一致;
  3. watch_streams 应与 kubectl get --raw /metrics | grep apiserver_longrunning_requests 的结果一致;
  4. 换一个时间点再跑一次,同一批量的取值应处于同一数量级,否则说明采集口径本身噪声过大。

V1 跑通了,但它留下的问题也很清楚:瞬时采样抓不到毛刺;快照里没有单位和阈值,判不了“越界”;两份快照之间没法自动对比。这三点正好是第 06 章要从证据里挖出来的东西。

先跑通一层
再补齐各层
快照落了盘
基线才算立

06、排查

下面的四条诊断链都遵循同一个结构:现象 → 怀疑 → 检查 → 证据 → 根因 → 修复。这里给出的是方法与判定口径,具体数值必须在你们自己的环境里采集后确认。

诊断链一:应用 P99 抖动,根因却在 APF 排队

  • 现象:租户反馈接口 P99 抖动,出现间歇性 5xx;应用自身的服务耗时指标平稳。
  • 怀疑:应用依赖、Sidecar、DNS。
  • 检查:先看应用侧 RED 指标,再看 API Server 写延迟与 inflight,最后看 APF 的排队与拒绝。
  • 证据:apiserver_flowcontrol_current_inqueue_requests 与 apiserver_current_inflight_requests{request_kind="mutating"} 同时抬升;apiserver_flowcontrol_request_wait_duration_seconds 的分位同步抬升;apiserver_flowcontrol_rejected_requests_total 出现增长;把应用写入请求与批量运维脚本的 FlowSchema 归属对上后,发现它们落在同一层级。
  • 根因:没有为“关键租户 + 关键 verb”建立独立的 FlowSchema 与 PriorityLevelConfiguration,批量脚本的 list/update 与业务写入共享同一池并发份额,业务写入在 APF 里排队甚至被拒。
  • 修复:按发起者、verb、resource 拆分 FlowSchema,给业务写入与 kubelet 心跳留出稳定的并发份额,把批量运维单独降到低优先级层,允许它排队。

错误尝试: 第一反应是“API Server CPU 不够,先扩到 5 副本”。这个动作不解决问题:证据显示时间主要花在 queue wait(APF 排队)和 etcd 侧,而不是 API Server 自身的 CPU。加副本只会把更多并发压进同一段共享写路径,apiserver_flowcontrol_current_inqueue_requests 不会因为副本数增加而下降。

诊断链二:Scheduler 吞吐下降,是“看得太多”而不是“节点太少”

  • 现象:发布窗口内 Pod 从 Pending 到 Running 的时间明显拉长,pending 队列堆积。
  • 怀疑:节点资源不足,集群该扩容了。
  • 检查:scheduler_pending_pods{queue} 按队列拆分、scheduler_schedule_attempts_total{result} 看失败类型分布、scheduler_scheduling_attempt_duration_seconds 看单位调度时长。
  • 证据:堆积主要集中在 active 队列;失败结果里 Unschedulable 并不占主导,主导的是调度时长本身变长;同时配置里 percentageOfNodesToScore 被显式设成了一个较大的固定比例。
  • 根因:每个 Pod 参与过滤打分的节点比例过高,单位调度成本随集群规模一起上涨,调度器吞吐被自身扫描成本吃掉。
  • 修复:把 percentageOfNodesToScore 恢复成调度器按规模自动推算(或设置为与当前规模匹配的小比例,取值须大于 0 且小于 100);同时用 Scheduler profiles 按负载类型隔离插件配置,把批处理场景里不必要的高成本打分插件禁用。

诊断链三:etcd 的 fsync 毛刺与 DB 膨胀

  • 现象:控制面在“没什么事”的时段也会抖,etcd 磁盘告警间歇触发,DB 体积只增不减。
  • 怀疑:API Server 请求量太大。
  • 检查:etcd_disk_wal_fsync_duration_seconds、etcd_disk_backend_commit_duration_seconds、etcd_mvcc_db_total_size_in_bytes、etcd_server_leader_changes_seen_total,并与磁盘密集任务、备份与快照窗口做时间对齐。
  • 证据:fsync 分位的抬升与 leader 变化、快照任务、同盘高 IO 负载在时间上重合;DB 体积持续增长,而 in-use 体积与总量差距扩大。
  • 根因:etcd 存储是“只追加 + 周期性压实”的模型。未配置合适的自动压实保留策略时,历史版本持续堆积,DB 膨胀后又拉长了快照与 defrag 的耗时;同时 fsync 对同盘 IO 非常敏感,混跑会直接体现在分位上。
  • 修复:配置压缩保留策略与周期性的 defrag(注意 defrag 是阻塞操作,需要单独窗口),把 etcd 数据盘从高 IO 负载中隔离出来,并对 DB 配额与告警设上限。etcd 的容量与请求上限属于官方给出的硬约束,具体数值请按当前官方文档核验。

诊断链四:节点上“Pod 全慢”——PLEG、运行时、CNI、CSI、DNS 的串联

  • 现象:节点上已运行 Pod 的状态更新延迟,新 Pod 启动慢;但节点 CPU 与内存看起来并不紧张。
  • 怀疑:节点资源不足、或者网络有问题。
  • 检查:kubelet_pleg_relist_duration_seconds 与 kubelet_pleg_relist_interval_seconds;kubelet_runtime_operations_duration_seconds 按 operation_type 拆分;网络插件 add/del 的耗时;有卷 Pod 的 attach/mount 耗时;CoreDNS 的查询量与延迟。
  • 证据:PLEG relist 时长抬升,且与某个具体的 operation_type(例如沙箱或容器创建)同步抬升;带卷 Pod 卡在 attach/mount 阶段;DNS 侧查询量远高于 Pod 数所对应的水平,说明存在 search path 放大。
  • 根因:PLEG 在节点内是单线程 relist,任何一次慢的运行时调用都会阻塞整节点的状态推进;启动链路本身串行,containerd、CNI、CSI 任一环变慢都会被放大成“Pod 慢”;DNS 的放大倍数由调用模式与 ndots 配置共同决定。
  • 修复:从最慢的那个 operation_type 入手,分别定位 containerd、CNI 与 CSI 侧;对 DNS,通过调整 ndots、使用 FQDN 调用、以及依赖 CoreDNS 缓存来降低放大倍数。

四条链的共同点是:结论不是从“哪个组件最重要”推出来的,而是从某一层的具体量越界反推出来的。

现象先归层
怀疑成假设
证据定根因
修复留口径

07、优化

把第 06 章的证据归位,V1 的根因有三个:

  1. 瞬时采样抓不到毛刺,所以 APF 排队与 etcd fsync 的短时抬升经常被漏掉;
  2. 快照里没有单位与阈值,无法把“波动”升级成“越界”;
  3. 两次采集之间没有可对比结构,变更前后只能靠人眼看图。

对应的修改:采样从瞬时值改为窗口统计(分位与最大值);每条样本带上 layer、unit、threshold;快照带时间戳与窗口信息落盘;新增 diff 模式,直接输出哪一层哪个量越界。

V2 实现:

#!/usr/bin/env bash
# baseline-v2.sh —— 分层基线:窗口采样 + 阈值判定 + 快照对比
# 用法:./baseline-v2.sh snapshot [name]
#       ./baseline-v2.sh diff <old> <new>
set -euo pipefail

PROM="${PROM:-http://127.0.0.1:9090}"
WIN="${WIN:-5m}"
SNAP_DIR="${SNAP_DIR:-./baselines}"
mkdir -p "$SNAP_DIR"

# layer|name|unit|threshold|PromQL
# threshold 为示例门槛,落地时必须替换为你们自己的 SLO 或官方建议值
SPEC=$(cat <<SPEC
apiserver|write_p99|s|0.5|histogram_quantile(0.99, sum by (le) (rate(apiserver_request_duration_seconds_bucket{verb=~"POST|PUT|PATCH|DELETE"}[${WIN}])))
apiserver|inflight_mutating|count|300|max_over_time(apiserver_current_inflight_requests{request_kind="mutating"}[${WIN}])
apiserver|watch_streams|count|20000|max_over_time(sum(apiserver_longrunning_requests{verb="WATCH"})[${WIN}])
apf|rejected_rate|rps|0|sum(rate(apiserver_flowcontrol_rejected_requests_total[${WIN}]))
apf|wait_p99|s|0.1|histogram_quantile(0.99, sum by (le) (rate(apiserver_flowcontrol_request_wait_duration_seconds_bucket[${WIN}])))
etcd|wal_fsync_p99|s|0.01|histogram_quantile(0.99, sum by (le) (rate(etcd_disk_wal_fsync_duration_seconds_bucket[${WIN}])))
etcd|backend_commit_p99|s|0.025|histogram_quantile(0.99, sum by (le) (rate(etcd_disk_backend_commit_duration_seconds_bucket[${WIN}])))
etcd|db_size|bytes|8589934592|etcd_mvcc_db_total_size_in_bytes
scheduler|pending_active|count|100|max_over_time(sum(scheduler_pending_pods{queue="active"})[${WIN}])
scheduler|scheduling_p99|s|0.5|histogram_quantile(0.99, sum by (le) (rate(scheduler_scheduling_attempt_duration_seconds_bucket[${WIN}])))
controller|queue_depth_max|count|10000|max_over_time(max(workqueue_depth)[${WIN}:15s])
kubelet|pleg_relist_p99|s|3|max_over_time(histogram_quantile(0.99, sum by (le) (rate(kubelet_pleg_relist_duration_seconds_bucket[${WIN}])))[${WIN}:15s])
node|cpu_throttle_ratio|ratio|0.25|max_over_time((rate(container_cpu_cfs_throttled_periods_total[${WIN}]) / rate(container_cpu_cfs_periods_total[${WIN}]))[${WIN}:15s])
coredns|dns_p99|s|0.1|histogram_quantile(0.99, sum by (le) (rate(coredns_dns_request_duration_seconds_bucket[${WIN}])))
SPEC
)

run_snapshot() {
  local name="${1:-$(date -u +%Y%m%dT%H%M%SZ)}" out="$SNAP_DIR/$name.json" tmp
  tmp="$(mktemp)"
  while IFS='|' read -r layer met unit thr q; do
    [ -z "${layer:-}" ] && continue
    curl -sG "$PROM/api/v1/query" --data-urlencode "query=$q" \
    | jq -c --arg l "$layer" --arg n "$met" --arg u "$unit" --argjson t "$thr" \
        '{layer:$l, name:$n, unit:$u, threshold:$t,
          value:([.data.result[]? | (.value[1] | tonumber)] | max)}
         | .exceeded = ((.value != null) and (.value > .threshold))' >> "$tmp"
  done <<< "$SPEC"
  jq -s --arg ts "$(date -u +%Y-%m-%dT%H:%M:%SZ)" --arg win "$WIN" \
     '{ts:$ts, window:$win, samples:.}' "$tmp" > "$out"
  rm -f "$tmp"
  echo "$out"
}

run_diff() {
  local old="$SNAP_DIR/$1.json" new="$SNAP_DIR/$2.json"
  jq -n --slurpfile a "$old" --slurpfile b "$new" '
    ($a[0].samples | map({key:(.layer + "/" + .name), value:.value}) | from_entries) as $o
    | $b[0].samples
    | map(. + {before: ($o[.layer + "/" + .name])})
    | map(. + {delta: (if (.before != null and .value != null)
                       then (.value - .before) else null end)})'
}

case "${1:-snapshot}" in
  snapshot) run_snapshot "${2:-}" ;;
  diff)
    run_diff "$2" "$3" | tee "$SNAP_DIR/diff.json" \
      | jq -r '.[] | [.layer, .name, (.before|tostring), (.value|tostring), (.exceeded|tostring)] | @tsv'
    n=$(jq '[.[] | select(.exceeded)] | length' "$SNAP_DIR/diff.json")
    echo "越界项: $n"
    [ "$n" -eq 0 ] || exit 3
    ;;
  *) echo "usage: $0 {snapshot [name]|diff <old> <new>}"; exit 2 ;;
esac

每处修改都能对应到第 06 章的一条证据:窗口统计对应“瞬时采样抓不到毛刺”;layer/unit/threshold 对应“无法判定越界”;diff 模式对应“变更前后不可比”。

执行与验证:

WIN=5m ./baseline-v2.sh snapshot pre-change
# 做一次变更
WIN=5m ./baseline-v2.sh snapshot post-change
./baseline-v2.sh diff pre-change post-change; echo "exit=$?"

示例输出(结构示例,非真实数据):

{"layer":"etcd","name":"wal_fsync_p99","unit":"s","threshold":0.01,"value":0.0132,"before":0.0071,"delta":0.0061,"exceeded":true}
{"layer":"scheduler","name":"pending_active","unit":"count","threshold":100,"value":260,"before":12,"delta":248,"exceeded":true}
{"layer":"apiserver","name":"write_p99","unit":"s","threshold":0.5,"value":0.22,"before":0.18,"delta":0.04,"exceeded":false}

验证新行为的三条自检:

  1. jq '.samples | length' baselines/pre-change.json 应等于 SPEC 的行数,缺失行说明 PromQL 与该版本指标名不匹配;
  2. 同一窗口连续跑两次 snapshot,然后 diff 两个快照,越界项应为 0,否则说明阈值定得比采集噪声还紧;
  3. 用 etcdctl endpoint status 与 kubectl top node 交叉验证 etcd DB 体积与节点资源量,确保采集层没有取错目标。

需要强调的是:上面的阈值只是示例口径。真正的门槛要么来自官方文档给出的建议,要么来自你们自己的 SLO;在没有证据之前,任何“优化了百分之多少”的说法都不成立。

采样改窗口
阈值入库房
对比自动化
复测可回归

08、演进

[同一输入] 各层 /metrics 与 etcd endpoint 状态
    |
    +--[V1] 瞬时采样:一条命令拿到当期值
    |       代价:抓不到毛刺、无归属、无阈值、不可比
    |
    +--[V2] 窗口统计 + 分层归属 + 阈值判定 + 差异报告
    |       代价:清单需要维护、阈值需要标定、子查询抬高采集成本
    |
[Trade-off]
得到:任一层越界可判定、变更前后可对比、结论可复测
失去:一次采集的“轻量”和“零配置”
适用边界:控制面指标可获取;托管控制面需按可获得的指标裁剪清单并放宽门槛

逐项比较:

维度 V1 V2
正确性 只能反映采样瞬间,容易误判 反映窗口内的分位与峰值,能表达毛刺
稳定性 受单次采集抖动影响大 阈值过滤噪声,复测两次可自检
复杂度 一个脚本,清单写死在 PromQL 里 需要维护 SPEC 与阈值来源
成本 查询轻 子查询与多分位查询更贵,需要对采集频率做约束
适用范围 临时看一眼 变更前后的常规对比与容量评估
遗留问题 无归属、无对比 阈值标定依赖经验;跨层自动归因仍未实现

Trade-off 的核心是:V2 用“清单与阈值的维护成本”换“结论的可复测性”。在只有几台机器的测试集群里,V2 是过度设计;但在两千节点、多租户、变更频繁的环境里,没有 V2 就意味着每次性能问题都要重新发明一次排查方法。

同源不同解
得此必失彼
边界写清楚
演进不回头

09、洞见

9.1 没有分层口径,跨层对比是伪证据

同一个“变慢”,在应用侧是 P99 抬升,在 API Server 侧是 inflight 抬升,在 etcd 侧是 fsync 分位抬升。这三份证据的时间窗口、采样频率、单位都不一样,直接放在一起比较只会得到噪声。反直觉判断:最先被怀疑的组件,通常不是真正的瓶颈层——因为最显眼的指标往往来自聚合度最高、离用户最远的那一层。

9.2 反直觉判断:加副本救不了写路径

API Server 是无状态的,副本数解决的是“并发处理能力”;但写路径的串行点在 etcd 的 Raft 日志与 fsync。当 apiserver_flowcontrol_current_inqueue_requests 抬升、apiserver_flowcontrol_rejected_requests_total 开始增长时,真正需要动的是 FlowSchema 与 PriorityLevelConfiguration 的分层,以及 etcd 的磁盘与压实策略,而不是继续加副本。

9.3 反直觉判断:调度变慢常常是“看得太多”,不是“节点太少”

调度成本与参与扫描的节点数、打分插件成本直接相关。percentageOfNodesToScore 被写死成较大比例,或者在 profile 里保留了与当前负载无关的高成本插件,都会让吞吐下降,而这个时候集群资源可能还有富余。扩容能缓解现象,但会掩盖真正的成本模型问题。

9.4 基线的价值在于收敛排查路径,而不是指标齐全

三十个指标不如十二层、每层四个关键量。分层的本质是把无限多的指标压成有限个判断,让结论从“感觉哪里有问题”变成“这一层的这个量越界了/没越界”。阈值标定得粗糙没关系,口径一致、可复测才是关键。

直觉常错位
证据会说话
分层即收敛
判断可复用

10、系统落地

原来有什么:一块总览大盘、一组按组件拆的告警、以及一套只存在于人脑里的排查经验。能发现“整体变慢”,但说不清“慢在哪一层”。

本篇新增了什么:一份分层清单(十二层、每层四个左右的量)、一个带阈值的采集脚本、一份版本化快照、一个能判定越界的 diff 命令。加上第 06 章那四条诊断链,把“怀疑”变成了“可验证的假设”。

现在能做什么:在做变更之前,先跑一次 snapshot 看有没有层已经接近边界;变更之后再跑一次,用 diff 明确说清这次变更影响了哪一层、越界了几项。发布窗口里的判断从“感觉快了”变成“哪一层的哪个量变了”。

还缺什么:一是应用侧与层间的关联,现在只能证明“控制面在排队”,还不能证明“就是这个排队导致了那个接口的抖动”,需要把 trace 或应用侧指标与层指标在时间轴上关联起来;二是长期趋势,基线会随版本与负载漂移,需要定期重跑并保留历史;三是自动化,目前还需要人手动触发。

下一步的演进方向:把这套快照纳入压测流程与发布门禁——压测时按固定负载跑一遍基线,发布前对比一次,越界就阻断

posted @ 2026-10-12 09:30  李福春  阅读(2)  评论(0)    收藏  举报