线上经常遇到这样的场景:监控面板显示 Pod 内存 2.3GiB,而 requests 只给了 512Mi,limits 是 1Gi,于是怀疑监控出错或容器逃逸;另一种情况是扩容时 kubectl top 的值远低于 requests,HPA 一直不缩容,节点资源被白白占着。这两个数据来自完全不同的链路——kubectl top 读的是 metrics-server 从 kubelet 抓来的 cgroup 采样快照,requests/limits 是调度器和 cgroup 控制器使用的静态契约——把它们放在一张表里比较时,单位换算、采样窗口、容器口径、initContainer 累加、Pod 与容器层级混淆,任何一处对不上都会产生「数值不符」的错觉。本文给出一个可以直接落地的排查脚本,把三个数据源(Pod spec、metrics API、节点 cgroup 实际值)拉到同一个口径下对比,并列出每一类偏差的判定方法与处置建议。
先把三个数据源的语义分清楚
排查之前必须明确:所谓「不符」,到底是哪两个值在比。常见的对比有四种组合,结论完全不同。
| 数据源 | 获取方式 | 口径 | 典型误差来源 |
|---|---|---|---|
| Pod spec | kubectl get pod -o json |
单容器或 Pod 级 requests/limits 声明值 | 未声明、多容器需累加、initContainer 取 max |
| metrics API | kubectl top pod --containers |
metrics-server 抓取的 cgroup 累计值 | 采样间隔(默认 15s)、聚合窗口、单位进位 |
| 节点 cgroup | node 上读 /sys/fs/cgroup |
内核实时值,含 page cache | workingSet 与 usage 的差、buff/cache 计入 |
| 调度视图 | kubectl describe node |
Allocated resources 汇总 | 只统计已绑定节点上的 Pod |
关键差异在于:metrics-server 上报的 memory 是 workingSet,即 usage 减去 inactive_file(可回收的 page cache),而 cgroup 的 memory.usage_in_bytes(cgroup v1)或 memory.current(cgroup v2)包含全部 page cache。所以你会看到节点上 cat memory.current 得到 3.1GiB,kubectl top 只有 800MiB,这不是 bug。反过来,如果 kubectl top 比 limits 还大却没被 OOMKill,那多半是 limits 根本没生效(往下看第 4 节)。
CPU 侧则要注意 kubectl top 输出的是 millicores 瞬时速率,由两次累计 CPU 时间的差值除以采样间隔得到,它和 requests.cpu(调度配额)不是同一维度,不能直接说「超了」。真正能说明超额的是 container_cpu_cfs_throttled_periods_total 之类的 cgroup 指标。
脚本设计:一个命令输出四列对比表
下面这个脚本(bash + jq,单文件,无需额外依赖)以 namespace 为粒度,把 Pod spec 中的 requests/limits、kubectl top 的实测值、以及按容器累加后的口径对齐,输出一张人眼可读的对齐表,并对每一行打上判定标记。
#!/usr/bin/env bash
# ktop-audit.sh —— 对齐 kubectl top 与 requests/limits 的口径
# 依赖: kubectl, jq (>=1.6), bc
set -uo pipefail
NS="${1:-default}"
TOP_JSON="$(mktemp)"
POD_JSON="$(mktemp)"
trap 'rm -f "$TOP_JSON" "$POD_JSON"' EXIT
kubectl top pod -n "$NS" --containers --no-headers > "$TOP_JSON" 2>/dev/null || {
echo "ERROR: kubectl top 失败,检查 metrics-server: kubectl get apiservice v1beta1.metrics.k8s.io" >&2
exit 1
}
kubectl get pod -n "$NS" -o json > "$POD_JSON"
# 把 kubectl top 的 "512m" / "1024Mi" 统一成 Mi
to_mi() {
local v="${1:-0}"
case "$v" in
*Gi) echo "${v%Gi} * 1024" | bc ;;
*Mi) echo "${v%Mi}" ;;
*Ki) echo "scale=2; ${v%Ki} / 1024" | bc ;;
*m) echo "scale=2; ${v%m} / 1024 / 1024 * 1024 * 1024" | bc ;; # CPU 的 m 不该走这里
*) echo "$v" ;;
esac
}
printf "%-42s %-14s %10s %10s %10s %10s %s\n" \
"POD/CONTAINER" "RESOURCE" "REQ" "LIMIT" "TOP" "TOP/REQ" "VERDICT"
jq -r '.items[] | .metadata.name as $p
| .spec.containers[] as $c
| [$p, $c.name,
($c.resources.requests.memory // "none"),
($c.resources.limits.memory // "none"),
($c.resources.requests.cpu // "none"),
($c.resources.limits.cpu // "none")]
| @tsv' "$POD_JSON" | while IFS=$'\t' read -r pod ctn req_m lim_m req_c lim_c; do
top_m=$(awk -v p="$pod" -v c="$ctn" '$1==p && $2==c {print $4}' "$TOP_JSON")
top_c=$(awk -v p="$pod" -v c="$ctn" '$1==p && $2==c {print $3}' "$TOP_JSON")
top_m="${top_m:-NA}"; top_c="${top_c:-NA}"
ratio="NA"
if [[ "$top_m" != "NA" && "$req_m" != "none" ]]; then
t=$(to_mi "$top_m"); r=$(to_mi "$req_m")
ratio=$(echo "scale=2; $t / $r" | bc 2>/dev/null || echo "NA")
fi
verdict="OK"
if [[ "$lim_m" == "none" ]]; then
verdict="NO_LIMIT"
elif [[ "$ratio" != "NA" ]] && (( $(echo "$ratio > 1.0" | bc -l) )); then
verdict="TOP>REQ(正常,受limit约束)"
fi
if [[ "$top_m" == "NA" ]]; then verdict="NO_METRICS"; fi
printf "%-42s %-14s %10s %10s %10s %10s %s\n" \
"$pod/$ctn" "memory" "$req_m" "$lim_m" "$top_m" "$ratio" "$verdict"
printf "%-42s %-14s %10s %10s %10s %10s\n" \
"$pod/$ctn" "cpu" "$req_c" "$lim_c" "$top_c" "-"
done
运行方式与输出片段:
$ ./ktop-audit.sh prod
POD/CONTAINER RESOURCE REQ LIMIT TOP TOP/REQ VERDICT
api-7d9f8c6b5-x2k4n/app memory 512Mi 1Gi 723Mi 1.41 TOP>REQ(正常,受limit约束)
api-7d9f8c6b5-x2k4n/app cpu 250m 500m 31m -
worker-5b6c7d8f9-q8m2p/worker memory 256Mi none 41Mi 0.16 NO_LIMIT
worker-5b6c7d8f9-q8m2p/worker cpu 100m none 2m -
这张表的价值在于把「数值不等」直接翻译成结论:比值大于 1 不等于异常,只要 limits 存在且 OOMKill 计数为 0,就是健康状态;NO_LIMIT 才是需要关注的(可能吃掉整节点);NO_METRICS 则是采集链路问题,与资源配置无关。
第 1 类偏差:单位换算与聚合口径
最高频的「不符」其实是单位误读。kubectl top 输出形如 723Mi,而 manifest 里写 0.5Gi 或 536870912 字节,肉眼一比就觉得差很多。脚本里的 to_mi 函数已经处理了 Gi/Mi/Ki,但要注意两个坑:
- Decimal 与 Binary 混用:
500M(SI,5×10⁸ 字节≈476.8Mi)和500Mi(≈524.3MB)相差 5%,写 YAML 时混用会让 request 与 limit 的比例失真。 - CPU 的
m不是内存单位:512m在 memory 上下文中是 0.512 字节,几乎必然被 API Server 拒绝或产生荒谬的 request。若你的脚本里看到TOP/REQ是个天文数字,先检查是不是把 CPU 的 millicores 当成了内存。
另一个口径问题是多容器累加。kubectl top pod 不带 --containers 时输出的是 Pod 内所有容器之和(含 POD 沙箱容器,其内存通常只有几百 KiB 到 2MiB),而 kubectl top pod --containers 会额外打印一行 POD 伪容器。排查时必须用 --containers 逐容器对齐,否则 app(500Mi)+ sidecar(300Mi)合计 800Mi 对上单容器的 512Mi request,自然「不符」。
还有一种情况:initContainer 的 requests 计入 Pod 有效请求。Pod 的 requests 有效值取 max(sum(container.requests), max(initContainer.requests)),而不是简单相加。用 kubectl describe pod 能看到 Requests: cpu: 1, memory: 1Gi 却算不出 1Gi 时,去检查 initContainer——这个值不在 kubectl top 里体现(init 容器已退出,cgroup 可能已销毁),是典型的「理论值有、实测值无」。
# 快速看某个 Pod 的有效 requests(含 init 容器逻辑)
kubectl get pod api-7d9f8c6b5-x2k4n -n prod \
-o jsonpath='{range .spec.containers[*]}{.name}{"\t"}{.resources.requests}{"\n"}{end}'
kubectl get pod api-7d9f8c6b5-x2k4n -n prod \
-o jsonpath='{range .spec.initContainers[*]}{.name}{"\t"}{.resources.requests}{"\n"}{end}'
第 2 类偏差:metrics-server 数据陈旧或缺失
当 kubectl top 报 error: Metrics API not available,或者数值长时间不变、明显滞后于实际负载,问题在采集链路而不在资源配置。按下面顺序查:
- APIService 是否可用:
kubectl get apiservice v1beta1.metrics.k8s.io -o yaml,看status.conditions里是Available=True还是False。 - metrics-server Pod 日志:
kubectl logs -n kube-system deploy/metrics-server,重点关注unable to fully scrape metrics from node与x509: certificate signed by unknown authority。前者通常是 kubelet 10250 端口不通或 TLS 校验失败,后者在自签证书集群中需要加--kubelet-insecure-tls。 - kubelet 是否暴露 Summary API:
curl -k https://<node-ip>:10250/stats/summary需要 token,更简单的验证是在节点上curl -s localhost:10255/stats/summary(只读端口关闭时用kubectl get --raw "/api/v1/nodes/<node>/proxy/stats/summary")。 - 采样窗口:metrics-server 默认
--metric-resolution=15s,kubectl top返回的是最近一次抓取结果,没有平均值平滑。短命 Pod(存活 < 15s)可能完全拿不到数据,表现为NO_METRICS。
# 直接读 metrics API 原始值,绕过 kubectl top 的展示层
kubectl get --raw "/apis/metrics.k8s.io/v1beta1/namespaces/prod/pods" \
| jq -r '.items[] | [.metadata.name,
(.containers[] | "\(.name)=\(.usage.memory // "nil")")] | @tsv'
# 检查 APIService 状态
kubectl get apiservice v1beta1.metrics.k8s.io \
-o jsonpath='{.status.conditions[?(@.type=="Available")].status}{"\n"}'
如果原始 API 有值而 kubectl top 显示不出来,检查 kubectl 与服务端版本是否跨度过大(例如 1.19 客户端对 1.27 服务端),可以显式指定 --request-timeout=30s 排除超时截断。
第 3 类偏差:limits 未生效或已被改写
最危险的一种「不符」是:kubectl top 长期高于 limits,但 Pod 没有被 OOMKilled。这说明 limits 在运行时并不存在,常见原因有三:
| 现象 | 根因 | 验证命令 |
|---|---|---|
| top 远超 limit 但无 OOM | LimitRange 未覆盖到该命名空间,或 Pod 已创建、改动的 LimitRange 只对新 Pod 生效 |
kubectl get limitrange -n prod |
| spec 里有 limit,容器内 cgroup 无限制 | kubelet 使用了 --enforce-node-allocatable 之外的路径,或 runtime 为 containerd 且 SystemdCgroup 配置异常 |
节点上 cat /sys/fs/cgroup/.../memory.max |
Pod 显示 BestEffort |
所有容器既无 request 也无 limit,QoS 降级,节点内存压力时最先被驱逐 | kubectl get pod -o jsonpath='{.status.qosClass}' |
验证 cgroup 实际限制的方法(以 cgroup v2、containerd 为例):
# 在 Pod 所在节点执行;containerID 从 kubectl describe pod 的 Container ID 获取
CID=$(kubectl get pod api-7d9f8c6b5-x2k4n -n prod \
-o jsonpath='{.status.containerStatuses[0].containerID}' | sed 's|containerd://||')
SLICE=$(systemctl status containerd 2>/dev/null | head -1 >/dev/null; \
find /sys/fs/cgroup -type d -name "cri-containerd-${CID}*" 2>/dev/null | head -1)
echo "cgroup path: $SLICE"
cat "$SLICE/memory.max" # 对应 limits.memory,单位字节;max 表示无限制
cat "$SLICE/memory.current" # 当前用量(含 page cache)
cat "$SLICE/cpu.max" # 形如 "50000 100000" 表示 0.5 核
memory.max 若是 max 而 YAML 里写了 limit,说明写入失败或 Pod 尚未重建(改了 Deployment 的 resources 但没滚动更新)。cpu.max 的第二列 100000 是 period,第一列是 quota,50000 100000 即 500m,可直接与 limits.cpu 对照。
注意:给容器设置
limits.memory后,workingSet 超过限制才会触发 cgroup OOM。page cache 会在逼近 limit 时被内核回收,所以短暂看到memory.current贴着 limit 是正常的,不代表即将 OOM。判断依据应是container_oom_events_total或kubectl describe pod中的Last State: OOMKilled。
第 4 类偏差:Pod 级与容器级、workingSet 与 usage 混用
如果你是从 Prometheus 侧对比,问题会更隐蔽。常用查询有两个,口径完全不同:
# A. cAdvisor 的 usage(含 page cache),会明显偏大
container_memory_usage_bytes{namespace="prod",pod="api-7d9f8c6b5-x2k4n",container!="POD"}
# B. workingSet(剔除 inactive_file),与 kubectl top 一致
container_memory_working_set_bytes{namespace="prod",pod="api-7d9f8c6b5-x2k4n",container!="POD"}
# 与 requests/limits 对比应使用 kube_pod_container_resource_requests / _limits
kube_pod_container_resource_requests{namespace="prod",resource="memory",container="app"}
把 A 当作 top 值去和 limits 比,你会稳定地多出几百 MiB,进而误判为超限。同理,sum(container_memory_working_set_bytes) 若不按 container!="POD" 过滤,会把 pause 容器的少量内存也计入,导致小数级别偏差;不按 pod!="" 过滤则会混入节点级 cgroup(container=""),数值直接跳到几十 GiB。
另外要确认 kube-state-metrics 的 requests 指标是否按 resource 与 unit 拆分:kube_pod_container_resource_requests 的数值单位是「核」和「字节」,250m 会被导出为 0.25,与 CPU 使用率 rate(container_cpu_usage_seconds_total[5m]) 单位一致,可以直接相除;而内存侧 kubectl top 给的是 Mi,直接比会差 1048576 倍。
脚本化验证这一步,可以加一段可选逻辑,把 Prometheus 的 workingSet 与 kubectl top 做交叉校验,偏差超过 10% 就打警告,用来发现 metrics-server 数据陈旧:
PROM="${PROM:-http://prometheus.monitoring:9090}"
check_prom() {
local pod="$1" ns="$2"
local q="container_memory_working_set_bytes{namespace=\"$ns\",pod=\"$pod\",container!=\"POD\"}"
curl -sG "$PROM/api/v1/query" --data-urlencode "query=$q" \
| jq -r '.data.result[] | "\(.metric.container) \(.value[1])"' \
| awk -v p="$pod" '{printf "%s %s %.1fMi\n", p, $1, $2/1048576}'
}
check_prom api-7d9f8c6b5-x2k4n prod
把结论固化成巡检任务
一次性排查只能救急。建议把上述判定做成 CronJob,每天输出一份「配置风险清单」,只保留三类高信号条目:
- 存在容器
limits.memory为空且 7 天内 workingSet 峰值 > 1GiB —— 有挤占节点风险。 limits.memory与requests.memory相等(Guaranteed 之外的隐性做法)且 OOM 次数 > 0 —— 说明限额偏紧,需要上调或排查内存泄漏。- TOP/REQ 长期 < 0.2 且持续 14 天 —— 典型的 requests 虚高,缩容可释放调度配额。
把这些规则落成 PromQL 比落成 shell 更稳,因为历史峰值需要时间序列。例如找出 7 天内存峰值超过 limit 80% 的容器:
max_over_time(container_memory_working_set_bytes{container!="POD"}[7d])
/ on(namespace,pod,container)
max by (namespace,pod,container) (kube_pod_container_resource_limits{resource="memory"})
> 0.8
结果交给告警通道,就形成了「配置—实测—历史峰值」的闭环,后面再遇到「kubectl top 和 requests 不符」,先看这张表是不是已经提前报出来了。
小结
「kubectl top 与 requests/limits 不符」绝大多数不是故障,而是四个口径问题:单位(Mi vs M vs 字节)、层级(容器 vs Pod vs initContainer)、指标定义(usage vs workingSet)、生命周期(metrics 采样快照 vs spec 静态声明)。排查顺序建议固定下来:先用脚本对齐到容器粒度并统一单位,再看 APIService 与 kubelet 采集链路,然后用节点 cgroup 的 memory.max / cpu.max 确认 limits 真的生效,最后才去怀疑是否存在内存泄漏或配置本身不合理。真正需要动手改配置的只有两种情况:limits 缺失导致的节点风险,以及 requests 长期虚高导致的调度浪费。
FAQ
Q1:为什么 kubectl top 显示的内存比容器内 free -m 看到的小很多?
容器内 free 读的是 /proc/meminfo,在 cgroup 命名空间下可能受 lxcfs 或 cgroup v2 的 memory.stat 呈现方式影响,且 free 展示的是 usage 口径(含 page cache)。kubectl top 展示 workingSet(剔除 inactive_file)。要对照就用 cat /sys/fs/cgroup/memory.stat(v1)或 memory.current(v2),不要用 free。
Q2:kubectl top 的 CPU 单位 31m 和 requests 的 250m 是什么关系?
两者都是 millicores,同单位可直接比较,但含义不同:31m 是最近一个采样窗口内的平均使用速率(由累计 CPU 时间差分得出),250m 是调度器预留的配额。使用率低于 request 完全正常,只有在节点 CPU 紧张时 request 才转化为实际保障。判断是否被限流看 container_cpu_cfs_throttled_periods_total / container_cpu_cfs_periods_total,超过 0.3 才值得关注。
Q3:报错 error: Metrics API not available 怎么快速定位?
三步:kubectl get apiservice v1beta1.metrics.k8s.io 看 Available 是否为 True;kubectl -n kube-system logs deploy/metrics-server 找 unable to scrape 或 TLS 报错;kubectl get --raw "/api/v1/nodes/<node>/proxy/stats/summary" | head -c 200 验证 kubelet 侧。自签证书集群加 --kubelet-insecure-tls;网络策略阻断 10250 则放通 kube-system 到节点的入站。
Q4:Pod 是 Burstable,top 值超过 requests 但没到 limits,会被驱逐吗?
不会因为超过 requests 被驱逐。驱逐由节点 memory.available 触及 eviction-hard 阈值时按 QoS 排序触发:BestEffort 先于 Burstable,Burstable 内部按「用量超过 requests 的程度」排序。所以 requests 设得过低会显著提升被驱逐概率,这是把 requests 调到接近实际 P95 用量的主要理由。
Q5:为什么 Pod 里没有 initContainer,requests 却比所有容器之和还大?
检查是否有 LimitRange 注入了 defaultRequest,或者该 Pod 由 Operator(如 Istio 注入 sidecar、Argo Rollouts)在 admission 阶段追加了容器。kubectl get pod -o yaml 看到的是落盘后的最终 spec,与原始 Deployment 模板可能不同;用 kubectl get deploy -o yaml 对比模板即可确认差异来源。
Q6:脚本在生产集群跑安全吗?
脚本只执行 kubectl get、kubectl top 与本地 awk/jq/bc,不做任何写操作。唯一需要注意的是大命名空间下 kubectl get pod -o json 返回体积可能达到几十 MB(数千 Pod),建议加 --chunk-size=500 或限定 -l app=xxx 标签。若是跨节点读 cgroup 的那部分,需要节点 shell 权限,请放在临时调试容器或堡垒机上执行,不要做成常驻 DaemonSet。