Kubernetes资源限制与探针实战:避免Pod重启和调度失败
2026/9/9 13:26:41 网站建设 项目流程

生产环境里最常被问到的问题,一个是 Pod 无缘无故重启,另一个是节点负载不高但 Pod 一直调度不上去。这两类问题背后,绝大多数都和两个配置有关系:资源限制(requests/limits)和探针(Probe)。这一篇把这两块拆开讲透。作为系列第八篇,前面我们在部署、网络、存储上都踩过不少坑,今天聊的这两个点,恰恰是线上稳定性最容易翻车的地方,也是面试里出现频率极高的考点。

文章不会讲太虚的概念,尽量基于 kubelet、containerd、cgroup 这一条真实执行链路来说,配合可以直接抄走的 YAML 和排查命令。看完之后你至少能回答三个问题:requests 和 limits 到底谁在起作用?三种探针分别守护什么?线上 Pod 被重启或者被驱逐时,怎么从事件里快速定位。

1. 先搞清楚 requests 和 limits 在 Kubernetes 里到底卡住了谁

很多刚接触 Kubernetes 的同学,以为 requests 和 limits 是同一个作用的两套配置,大不了一个写大一个写小。其实这两个字段的执行位置完全不同,一个管调度,一个管运行,搞混了就会出大问题。

1.1 调度器只看 requests:Pod 能不能落在这个节点上

kube-scheduler 在给 Pod 选节点时,只累加节点上所有 Pod 的 requests 总量,再看这个总量是否超过节点的 Allocatable。Allocatable 不是节点的物理总容量,而是扣掉了系统预留 kube-reserved、system-reserved 以及驱逐阈值之后的可用资源。公式大概是:

Allocatable = Node Capacity - 系统预留 - kubelet 预留 - eviction-thresholds

每个新 Pod 要调度,调度器会先在 Node 上模拟加上这个 Pod 的 requests,如果总和大于 Allocatable,就跳过这个节点。如果所有节点都不满足,Pod 会卡在 Pending,events 里出现Insufficient cpu或者Insufficient memory

这里有个常见的隐蔽点:只配置 limits 而不配置 requests 时,Kubernetes 会把 requests 默认继承为 limits 的值。比如你写limits: cpu: "2",那 requests 自动变成cpu: "2",调度器会按 2 核去算。很多人以为不写 requests 就等于没有资源占用,结果节点上堆积了一堆“虚拟大胖子”。

requests 代表的是这个 Pod 最低要保障的资源。调度器只看 requests,是因为 limits 是运行上限,Pod 不一定真的会用满,调度器无法预知实际用量,按保障值来分配才是最安全的策略。

1.2 运行时靠 limits + cgroup:内存和 CPU 被锁死

Pod 调度到节点之后,kubelet 会把 requests 和 limits 通过 CRI 传给容器运行时。以 containerd 为例,kubelet 调用 containerd 的 CRI 接口创建容器时,containerd 会通过 runc 最终把这些参数落到 cgroup 上。这也是很多运维想了解的调用链:kubelet -> CRI -> containerd -> runc -> cgroup。

CPU 是压缩资源,limits 会转换成 CFS 配额。在 cgroup v2 下,对应的文件是/sys/fs/cgroup/cpu.max,内容类似50000 100000,表示这个容器在 100ms 周期内最多使用 50ms CPU,换算出来就是 0.5 核。如果容器超过了这个配额,内核会强制做 CPU throttle,表现为 CPU 使用率被压平,但进程不会被杀掉。

内存是非压缩资源,limits 对应 cgroup v2 的/sys/fs/cgroup/memory.max。一旦容器内所有进程的内存占用超过这个值,内核 OOM killer 就会介入,优先杀掉容器内占用内存最高的进程。反映到 Pod 上,就是容器状态变成 OOMKilled,退出码 137,Restarts 次数上涨。

我见过很多人对内存超限的认知是“Kubernetes 把容器杀了”,严格说不对。是内核 OOM killer 杀的进程,Kubernetes 只是观察到了退出码并按照 restartPolicy 重启容器。这个细节在排查时很重要,因为日志里不一定有 Kubernetes 的错误信息,反而是内核日志或者容器退出状态能说明问题。

1.3 QoS 等级:从资源配置一眼看出 Pod 的优先级

Kubernetes 会根据资源配置自动给 Pod 打 QoS 等级,一共三种。

QoS 等级触发条件典型特征
Guaranteed每个容器都同时设置了 CPU 和内存的 requests 和 limits,且 requests == limits核心业务常用,最不容易被驱逐
Burstable至少有一个容器设置了 requests 或 limits,但又不满足 Guaranteed大部分中间件、普通业务的默认状态
BestEffort所有容器都没有设置任何 requests 和 limits临时任务、测试 Pod,被驱逐风险最高

QoS 等级直接影响节点内存压力时的驱逐顺序。节点内存不足时,kubelet 按 BestEffort -> Burstable -> Guaranteed 的顺序清理 Pod。同等级内部再按 usage/request 的比值排序,谁的用量超出保障值比例高,谁先被驱逐。

这里要特别提醒,QoS 是 Pod 维度,不是容器维度。只要 Pod 里有一个容器不满足 Guaranteed 条件,整个 Pod 就降级成 Burstable。所以生产环境里想给某个 Pod 最高保障,每个容器都得写满 requests 和 limits,不能漏。

2. 三种探针的分工:liveness、readiness、startup 别搞混

探针这部分,我见过最大的问题不是不会写,而是把三种探针混用。很多人只听说过 liveness,就所有服务都配一个 httpGet,结果启动慢的应用被反复重启。要搞清楚,三种探针是服务于不同阶段的,职责完全不同。

2.1 livenessProbe:容器死了就重启

livenessProbe 解决的是“容器进程还活着,但业务已经不可用”的情况。比如 Java 应用线程池耗尽,进程没退出,但所有请求都超时;或者一个 worker 进程死锁了,不干活也不退出。这时候 liveness 探测失败,kubelet 会杀掉容器,再根据 restartPolicy 决定是否重启。

注意 liveness 失败不一定导致重启。如果 restartPolicy 是 Never,容器被标记为 Failed,但不会被重启。如果 restartPolicy 是 OnFailure,只有当容器退出码非 0 时才会重启。生产环境默认的 restartPolicy 一般是 Always,所以大多数情况下 liveness 失败会触发重启。

liveness 的检测逻辑应该尽量轻量。只检查当前进程是否活着、核心依赖是否还在,不要在 liveness 里去做复杂的业务检查。不要把数据库、外部缓存的状态也塞进 liveness,否则数据库抖动一次,所有 Pod 跟着重启,那就是事故。

2.2 readinessProbe:没准备好就别接流量

readinessProbe 解决的是“这个 Pod 现在能不能接收流量”的问题。它失败时不会重启容器,而是把 Pod 的 Ready 状态置为 False,然后从 Service 的 Endpoints 列表里摘掉。等探针恢复成功后,Pod 会重新被加回 Endpoints,流量恢复。

它最常见的应用场景是滚动更新。新版本 Pod 启动后,如果 readiness 一直不通过,Deployment 的滚动更新会卡住,不会继续把旧 Pod 全部删掉。这其实是保护机制,不是 bug。如果你在发布期间发现“新 Pod 起来了但流量没切过去”,先看 readiness 有没有过。

readiness 里适合做相对完整的业务健康检查,比如检查应用能否连接配置中心、能否连上数据库、能否正常响应健康检查接口。因为它的失败代价是摘流量,而不是重启,所以可以适当严格一点。

2.3 startupProbe:慢启动容器的保护伞

startupProbe 是后来才加入的探针,专门解决慢启动容器被 liveness 误杀的问题。它的行为是:容器启动后,先执行 startupProbe,在 startupProbe 成功之前,liveness 和 readiness 都不会执行;如果 startupProbe 失败达到 failureThreshold,容器会被杀。

举个例子,一个 Java 应用启动要 40 秒,如果 liveness 配置的是initialDelaySeconds: 10,那 10 秒后就开始探测,40 秒时应用还没起来,连续几次失败,容器就会被杀,陷入反复重启。有了 startupProbe 之后,可以把它配成periodSeconds: 5failureThreshold: 30,这样启动阶段最多能容忍 150 秒,等应用真正起来后再交给 liveness 和 readiness 接管。

这个探针对于重内存、有初始化加载、依赖网络拉配置的应用非常实用。如果你的服务启动时间超过 10 秒,强烈建议把 startupProbe 配上,然后让 liveness 和 readiness 的 initialDelaySeconds 可以设得保守一点。

3. 探针的三种检测机制与参数调优,别让探针变成杀手

探针类型决定“什么时候检测”,检测方式决定“怎么检测”。Kubernetes 支持三种检测方式:exec、httpGet、tcpSocket,选型不合适或者参数太激进,探针本身就会变成故障源。

3.1 exec、httpGet、tcpSocket 各自适合什么场景

检测方式工作方式适用场景关键注意点
execkubelet 通过 CRI 在容器内执行命令,退出码为 0 则成功没有 HTTP 服务的进程、CLI 工具、数据库命令本身不能太重,避免产生过多僵尸进程
httpGetkubelet 从节点侧发起 HTTP GET 请求到 Pod IP 的指定端口Web 服务、有健康检查接口的应用应用必须监听在容器网络可达的地址上,监听 127.0.0.1 会探测失败
tcpSocketkubelet 发起 TCP 连接,能建立连接即成功非 HTTP 的 TCP 服务,如 MySQL、Redis只能证明端口通,不能证明业务正常

httpGet 和 tcpSocket 都是从 kubelet 节点侧发起请求的,目标地址是 Pod IP。很多人误以为 httpGet 是在容器内部访问 localhost,其实不是。如果你的应用只监听了 127.0.0.1,kubelet 从节点访问 Pod IP 是连不上的。这种现象在本地开发容器里经常出现,因为本地跑容器时端口映射是通的,但放到 Kubernetes 里 Pod IP 的监听地址不对就失败。

exec 方式有一个隐藏问题:kubelet 每次探测都会通过 CRI 调 containerd 做一次 ExecSync,实质上是在容器里启动一个进程。如果探测脚本特别重,或者调用频率太高,会产生很多一次性进程,容器里会出现僵尸进程。所以 exec 探测的命令要尽量简单,比如用pgreptest -f这类轻量命令,别写复杂的 shell 脚本。

3.2 探针参数逐个拆解:initialDelaySeconds 到 failureThreshold

探针对象里的参数不多,但每个都会影响探测行为。默认值很多人记不准确,建议直接看官方文档,我列一份最常用的表格。

参数默认值作用建议
initialDelaySeconds0容器启动后等待多久才开始第一次探测慢启动应用要调大,避免启动期误判
periodSeconds10每隔多少秒探测一次不要小于 1;对性能敏感的服务建议调大
timeoutSeconds1单次探测的超时时间如果探测超时频繁,先看是不是探测逻辑太重
successThreshold1连续成功多少次才认为健康liveness 和 startup 必须为 1
failureThreshold3连续失败多少次才认为不健康保守一点可以设 5 或更大,避免抖动误杀

一个比较容易踩的坑是 timeoutSeconds 和 periodSeconds 的关系。如果单次探测超过 periodSeconds,探针不会自动重叠执行,而是等上一次完成后再等下一个周期,实际探测频率会低于预期。这个在配置时不用太担心,但要意识到 timeout 不是越大越好,调大 timeout 只是为了给慢接口更多时间,而不是让它一直在跑。

还有一个细节:livenessProbe 和 startupProbe 的 successThreshold 必须为 1,因为这两类探针只执行一次成功判定,不适合连续成功多次的逻辑。readinessProbe 的 successThreshold 可以大于 1,用来避免网络抖动导致 Pod 频繁被摘流量,但一般保持 1 就够。

3.3 这些参数组合起来会产生什么行为

很多人单独看参数能懂,组合到一起就不知道下一次探测是什么时候。我直接算一个例子。假设配置如下:

livenessProbe: httpGet: path: /healthz port: 8080 initialDelaySeconds: 10 periodSeconds: 5 timeoutSeconds: 2 failureThreshold: 3

执行时间线是:容器启动后第 10 秒发起第一次探测(超时上限 2 秒),之后每 5 秒一次。第一次失败没关系,连续失败 3 次才判定不健康。这里有个容易误解的点:failureThreshold 是在“首次探测失败”之后连续计数,不是从容器启动开始算。也就是说,如果第 10 秒失败,第 15 秒失败,第 20 秒失败,第 20 秒之后容器就会被杀。这个时间长度大致是initialDelaySeconds + (failureThreshold - 1) * periodSeconds + timeoutSeconds,但实际还要考虑探针执行时间,不用算得太精确,重点是理解“连续失败”的含义。

探针参数调整时,建议遵循“先放宽,后收紧”的原则。上线初期把 failureThreshold 调大、period 调大,等确认探针稳定后再逐步收紧。不要一开始就追求秒级发现故障,那通常意味着探针本身会成为故障源。

4. 一个同时包含资源限制和探针的完整 YAML 部署实战

光讲参数不落地没有意义。这一节给一个完整的 Deployment 示例,包含资源限制、三种探针、资源字段注释,然后说明怎么验证。

4.1 完整 YAML 代码与字段注释

下面这段 YAML 直接可以用于部署一个模拟 Web 服务的应用。它设置了 Guaranteed 级别的资源限制,同时也配了三种探针。

apiVersion: apps/v1 kind: Deployment metadata: name: demo-web namespace: production spec: replicas: 2 selector: matchLabels: app: demo-web template: metadata: labels: app: demo-web spec: containers: - name: demo-web image: registry.example.com/demo-web:1.2.0 ports: - containerPort: 8080 name: http resources: requests: cpu: 250m memory: 256Mi limits: cpu: 500m memory: 512Mi startupProbe: httpGet: path: /startup port: http initialDelaySeconds: 5 periodSeconds: 5 failureThreshold: 30 readinessProbe: httpGet: path: /ready port: http initialDelaySeconds: 10 periodSeconds: 5 timeoutSeconds: 2 failureThreshold: 3 livenessProbe: httpGet: path: /healthz port: http initialDelaySeconds: 20 periodSeconds: 10 timeoutSeconds: 3 failureThreshold: 3

几点解释:

  • resources 里 requests 和 limits 的 CPU、内存都写了,且值相等,所以这个 Pod 是 Guaranteed QoS,节点资源紧张时它被驱逐的优先级最低。
  • startupProbe 的 failureThreshold 是 30,periodSeconds 是 5,意味着启动阶段最多容忍 150 秒。如果应用在 150 秒内没起来,容器会被杀。
  • readinessProbe 配置了 timeoutSeconds: 2,要求 2 秒内返回的 /ready 接口才能认为就绪,否则就绪状态失败。
  • livenessProbe 的 initialDelaySeconds 是 20,确保 startup 探针成功后,再给应用一点稳定时间才开始判断存活。

4.2 部署后如何验证资源限制真的生效

部署完成后,不要只看 Pod Running 就结束了,要验证资源限制有没有真正落到 cgroup 上。

第一步,看节点资源分配情况:

kubectl describe node <node-name>

输出里有 Allocated resources 部分,能看到当前节点上所有 Pod 的 CPU 和内存 requests 总和。这个值会直接影响后续 Pod 能否调度到这个节点。

第二步,进入容器查看 cgroup 文件:

kubectl exec -it <pod-name> -n production -- cat /sys/fs/cgroup/cpu.max kubectl exec -it <pod-name> -n production -- cat /sys/fs/cgroup/memory.max

在 cgroup v2 的节点上,cpu.max 输出类似50000 100000,memory.max 输出类似536870912,换算过来正好是 512Mi。如果看到的数值和 YAML 里配置的对不上,说明容器运行时或 kubelet 的设置有问题,需要排查。

第三步,用压测工具验证 CPU throttle。比如在容器里跑一个死循环消耗 CPU,然后通过kubectl top pod观察 CPU 使用率,应该被限制在 500m 左右。如果看到 CPU 使用率长时间超过 500m,说明 limits 可能没生效,需要检查节点的 cgroup 驱动配置。

验证内存限制更直接:配合应用的内存压力测试,或者故意触发 OOM,观察容器退出码是否为 137,事件里是否出现 OOMKilled。正常预期是容器会被杀掉并且重启,证明 memory.limit 生效。

4.3 如何验证探针真的在干活

探针验证的核心思路是:人为制造故障,观察 Kubernetes 的反应。

readiness 探针验证方法:找到 /ready 接口,临时让它返回非 200。可以直接进入容器停掉提供 /ready 接口的服务,或者改成直接 kill 应用对应的监听进程,然后看:

kubectl get pod -n production -o wide

观察 READY 列变成 0/1,同时查看 Endpoints:

kubectl get endpoints <service-name> -n production

你会发现这个 Pod 的 IP 已经从 Endpoints 里消失。等接口恢复后,READY 会自动变回 1/1,IP 重新加回来。

liveness 探针验证方法:把 /healthz 接口弄挂,或者直接 kill 掉容器里的主进程。观察:

kubectl get pod -n production

RESTARTS 列会开始增加,describe 里能看到 Last State 的退出码和事件。如果 liveness 配置正确,kubelet 会自动重启容器,这个时间窗口取决于你的 periodSeconds 和 failureThreshold。

startupProbe 的验证相对简单:观察容器启动阶段,在kubectl describe pod的 Events 里能看到Started container以及探针相关的信息,只要应用能正常启动,就不会触发 liveness。如果想测试 startup 失败,可以把镜像换成启动必然失败的版本,看容器是否按 failureThreshold 计数后被重启。

5. 我在生产环境踩过的坑:资源、探针和节点驱逐

最后这部分是真正的运维经验。这些坑单独看都挺小,但叠加在一起,足以让一个业务频繁抖动。

5.1 OOMKilled:limits.memory 设置低于实际需求引发的重启

印象最深的一次是给 Java 应用设置 memory limit 4Gi,JVM 堆参数也是 4G。按道理没问题,但运行一段时间后容器频繁被 OOMKilled。后来才发现,JVM 除了堆内存之外,还有 Metaspace、线程栈、JIT 编译缓存、Direct Memory,这些都不在-Xmx的控制范围内。堆设 4G,整个进程实际占用可能超过 4.5G,limit 一卡就挂。

正确的做法是给 JVM 容器加上-XX:MaxRAMPercentage=70.0这类容器感知参数,让 JVM 自动根据 cgroup 限制计算堆大小,而不是直接指定-Xmx。或者把 memory limit 上调到比 JVM 峰值占用高 20% 到 30%。排查 OOM 时不要一上来就怀疑内存泄漏,先看容器退出码和 cgroup 限制值,很多 OOM 就是“limit 设小了”。

5.2 liveness 探针太激进,导致容器不断被误杀

另一个典型场景是 liveness 探针配置得跟 readiness 一样严格,periodSeconds 设 1 秒,timeout 设 1 秒,failureThreshold 设 2。结果应用一遇到 Full GC 或者 CPU 抖动,探针就超时,连续两次失败后容器被重启,然后重启后又需要预热,又触发探针失败,形成重启风暴。

这里的教训是:liveness 探针一定要“钝”一点。它只负责检测最核心的死锁、进程异常,不要追求秒级发现。如果担心故障恢复速度,可以靠 readiness + Service 摘流量的机制来切走流量,让 liveness 保持相对迟钝。

还有一次是团队把数据库探活脚本写进了 liveness exec,结果数据库主从切换瞬间连接失败,所有依赖数据库的 Pod 全部被 liveness 杀掉重启,实际数据库 30 秒后就恢复了,但 Pod 已经重启了一轮。从那之后我严格规定:liveness 只能检查本地进程状态,不能依赖外部服务。

5.3 节点资源压力与 Pod 驱逐:QoS 和优先级决定的命运

节点内存被打满时,kubelet 会触发驱逐。这个动作不是马上杀容器,而是有一个 eviction 阈值,默认通常是memory.available < 100Mi这一类。一旦触发,kubelet 会优先驱逐 BestEffort 和 Burstable 的 Pod。

我维护的集群里发生过一次误伤:一个离线计算任务的 Pod 以 BestEffort 方式运行,把节点内存吃满,结果同一节点上另一个 Burstable 的业务 Pod 被驱逐了。当时业务方很委屈,说“我明明配了 requests 和 limits,为什么还被驱逐”。原因就是 QoS 是 Burstable,在 BestEffort 被清掉之后,它就是下一个被清理的对象。

如果你的业务核心程度很高,唯一的保障就是把它变成 Guaranteed。也就是每个容器都同时设置 CPU 和内存的 requests == limits。Guaranteed 虽然不能保证绝对不驱逐,但至少在节点资源紧张时,它是最后被考虑的。另外,requests 不要随便填一个小于实际使用的值,因为在驱逐排序时,usage/request 比例越高越容易被优先驱逐。合理评估每个 Pod 的基准使用量,requests 要能兜住常态运行,而不是随便写个 10m。

5.4 排查链路:从 kubectl describe 和 events 看真相

最后给一个标准的排查链路,遇到容器重启、驱逐、流量异常时按这个顺序看。

第一步,获取 Pod 当前状态:

kubectl get pods -n <namespace> -o wide kubectl describe pod <pod-name> -n <namespace>

重点看 describe 输出的 Last State、Exit Code、Reason、Events 末尾段落。如果 Reason 是 OOMKilled,直接往内存 limit 方向查;如果 Reason 是 Unhealthy,再往上翻 Events,能看到具体的探针失败信息。

第二步,看节点状态和资源情况:

kubectl describe node <node-name> kubectl top node

如果节点有 MemoryPressure 或者 DiskPressure 状态,说明已经进入了驱逐流程。这时候要关注哪些 Pod 被驱逐了,事件里能看到。

第三步,查看 kubelet 和容器运行时的日志。如果是 containerd,可以看:

journalctl -u kubelet -n 200 --no-pager journalctl -u containerd -n 200 --no-pager

liveness 探针失败时,kubelet 日志会留痕。exec 探针失败还经常会有 ExecSync 相关的错误信息。

第四步,查看集群事件:

kubectl get events --sort-by=.lastTimestamp -n <namespace>

这个命令能按时间倒序看到整个故障周期内发生的所有事件,尤其是长时间没有探针事件、但容器重启了的情况,往往说明探针没有生效或者配置写错了。

如果让我给新人一个最朴素的建议:所有核心服务先按 Guaranteed 来配,探针先配 readiness,稳定后再加 liveness,参数从宽松到严格慢慢收紧。资源限制和探针是两个独立又相互影响的功能,但线上故障经常是它们一起搞出来的。踩过几次坑之后,我每次上线前都会问自己三句话:这个容器内存会不会超?这个探针失败会怎样?如果节点被打满,谁会先滚蛋?把这三个问题想清楚,大部分稳定性事故都能提前规避。

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

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

立即咨询