Kubernetes Deployment 配置:副本、探针与资源限制
从探针分工、资源配额到滚动更新与优雅退出,把 Deployment 配成生产可用的状态。
这篇文章要解决的问题是:为什么照着模板写出来的 Deployment,会在发布时把流量打到不健康的 Pod 上导致接口大面积超时、Pod 反复重启、滚动更新卡死——以及怎么从机制上避开这些坑。背后最基础的事实是:Kubernetes 并不"执行"你的 YAML,它只把你的清单当作期望状态,交给控制平面里的控制器不断比对"期望"与"现实"并收敛;而真正起容器、探活、杀进程、限资源的,是每个节点上的 kubelet 和内核 cgroup。先想清楚"谁在什么时机对什么对象做什么",Deployment 里每个字段就不再是玄学。
全文按三条因果链展开。第一,Deployment 如何通过 ReplicaSet 把 Pod 副本收敛到期望数;第二,kubelet 的探针如何周期性把"健康结论"转成摘流量、杀容器、延迟就绪三类动作;第三,requests/limits 如何分别从调度阶段和运行时阶段约束资源。这三条链在滚动更新这个场景里交汇:探针决定新 Pod 何时可以被放行,资源决定新 Pod 能否被调度,ReplicaSet 决定新旧 Pod 如何交接。后面每一节的命令,都能回到这三条链上找到自己的位置。
#Deployment 的收敛机制:Deployment → ReplicaSet → Pod
#一切都是"期望状态 + 控制器循环"
Kubernetes 几乎所有对象都遵循同一个模式:你提交一份声明(期望状态,Desired State),控制器(Controller)持续读取集群当前状态(Current State),发现不一致就动手收敛。Deployment、ReplicaSet 是这套模式,Service、Ingress、HPA 也是。把这个模式刻在脑子里,后面所有行为都可以推导出来。
Deployment 本身不直接管理 Pod,它管理的是 ReplicaSet(副本集)。责任链条是三层:
- Deployment 控制器(kube-controller-manager 中的 deployment-controller)把 Deployment 声明翻译成对 ReplicaSet 的创建、扩容、缩容操作;
- ReplicaSet 控制器(replicaset-controller)把实际 Pod 数量收敛到 spec.replicas——多了就删,少了就建;
- kubelet 把调度到本节点的 Pod 真正跑起来:拉镜像、起容器、周期性探活、终止时回收容器。
所以 kubectl get deployment 里看到的 3 个副本,底层其实是"某个 ReplicaSet 管理的 3 个 Pod 是否满足期望"。这也是为什么排障时先看 kubectl get rs:Deployment 健康不健康,最终都体现在 ReplicaSet 的期望/当前/就绪三个数字上。
"声明式"还有一个容易被忽略的推论:你随时可以用 kubectl scale deployment demo-api --replicas=5 改期望值,控制器只负责收敛,不负责阻止你改。改完它仍然会持续把实际副本数拉向新期望。生产里常见的"手动 scale 之后又被弹回去"的困惑,往往是另一个控制器(比如 HPA)也在写同一个字段——两个写入方抢一个值,这在后面讲资源时还会遇到。
#Pod 模板一改,就诞生新的 ReplicaSet
Deployment 的 .spec.template 是"发版指纹":只要 template 内容(镜像、启动参数、环境变量、探针、资源声明)发生变化,Deployment 控制器就创建一个新的 ReplicaSet。新旧两个 ReplicaSet 并存,控制器按 strategy 字段把副本数从旧 RS 迁到新 RS。kubectl rollout history 记录的每个版本,本质上就是一次 ReplicaSet 的变更。
由此可以推导出两个重要行为:
- 滚动更新不是"把旧 Pod 原地改成新的",而是"新 RS 先长出来、旧 RS 再缩下去";
- 任何只改容器内容、不改 template 的动作都不会触发新 RS。
kubectl rollout restart之所以能触发滚动,是因为它会给 template 打上一个 restartedAt 注解,人为制造"template 变了";直接改镜像 tag 指向的远端镜像内容则不会触发,因为 template 文本没有变化。
发布时观察:
kubectl get rs -l app=demo-api
kubectl rollout history deployment/demo-api你会看到两个 ReplicaSet:旧 RS 的期望副本数在减少、新 RS 在增加。滚动更新卡住时,问题也能从这里定位:旧 RS 一直被保着副本,新 RS 的 ready 永远上不去。
#selector 与 pod-template-hash:Deployment 怎么认领和区分版本
Deployment 靠 label selector 认领它创建的 ReplicaSet,ReplicaSet 又靠同样的 selector 认领 Pod。两个硬约束值得记牢:
- Deployment 的 .spec.selector.matchLabels 必须能匹配 template 里的 labels,否则 apply 时 API Server 直接拒绝(报
selector does not match template labels); - selector 一旦创建就不可修改(报
field is immutable)。原因很直接:selector 是"这个 ReplicaSet 管哪些 Pod"的认领契约,改了它等于重新划定地盘,正在运行的 Pod 归属会瞬间错乱。
另一个自动注入的细节是 pod-template-hash:Deployment 创建 ReplicaSet 时,会给 RS 的 template labels 自动加上 pod-template-hash(模板内容哈希),不同版本的 Pod 会带上不同的 hash 标签。这解释了三个常见现象:
kubectl get pods --show-labels里每个 Pod 都多了一个 pod-template-hash 标签;- ReplicaSet 名字后面跟着一串短哈希(形如 demo-api-7b9c8f6d4),就是这个标签的体现;
- 滚动更新卡住时,可以用
kubectl get pods -l pod-template-hash=<新RS的hash>只看新版本 Pod 的状态。
旧版本 ReplicaSet 也不会永远保留:revisionHistoryLimit(默认 10)控制保留多少个历史版本用于回滚,超过后最老的会被清理。还要纠正一个常见误解:回滚不是"把旧 RS 恢复上线",而是"把 Deployment 的 template 恢复成某一历史 revision 的 template",恢复动作本身又触发一次滚动——所以回滚也要走完相同的 readiness 门槛,不是瞬时的。
#运行中的 Pod 也可能"存在但不可用"
这里引出一个关键区分:Pod 处于 Running 只代表容器进程活着,不代表能接流量。容器进程活着、业务可用的判定,交给节点上的探针(Probe)。
#探针:kubelet 的三类健康检查与对应处置
探针是节点级行为:由 kubelet 周期性执行,与 kube-controller-manager 无关。kubelet 通过 CRI 与容器运行时交互,只能知道"进程在不在";至于"容器业务上能不能用",必须由探针回答。两类信息分开记录:ContainersReady 表示容器进程起来了,Ready 表示可以接流量——readiness 失败时,常见现象就是这两个条件一真一假。
三类探针分别回答三个不同的问题:
- readinessProbe(就绪探针):答"能不能接流量"。失败时 Pod 不健康,但不会被杀掉,只是从 Service 的 Endpoints 里摘掉。就绪。
- livenessProbe(存活探针):答"是不是死透了"。连续失败会被 kubelet 杀掉并重启。存活。
- startupProbe(启动探针):给启动慢的应用用的。它成功之前,liveness 和 readiness 都不参与判定,防止启动 30 秒的应用因为 liveness 超时被反复杀。启动门禁。
#四种探测方式
kubelet 支持四种探测动作,前三种最常用:
- httpGet:kubelet 从节点侧直接对 Pod IP 发起 HTTP GET 请求,响应码在 200~399 之间记为成功。注意这个请求不经过 Service、不经过负载均衡,是"节点 → Pod IP"直连,因此探针路径必须对节点网络可达,port 必须是容器内实际监听的端口。
- exec:在容器内执行一条命令,退出码为 0 记为成功。适合"端口活着但业务逻辑要单独检查"的场景,也适合没有 HTTP 服务的容器。
- tcpSocket:kubelet 尝试与容器端口建立 TCP 连接,能连上即为成功,只验证"端口在监听"。
- grpc:通过 gRPC 健康检查协议探测,需要应用实现健康服务且集群版本与运行时支持,使用前先确认。
选择依据很简单:HTTP 服务优先 httpGet;只监听 TCP 的中间件用 tcpSocket;需要精细断言(比如检查某个缓存是否热好)用 exec 跑脚本。执行位置也值得分清:exec 在容器内跑,httpGet/tcpSocket 由 kubelet 从宿主机侧发起——httpGet 看到的网络视角和容器内不完全一样,任何"只允许容器内访问"的路径都不会被探针访问到。
#探针参数如何决定"多久判死"
每个探针都有一组公共参数:
initialDelaySeconds: 5 # 容器启动后等多久才开始第一次探测
periodSeconds: 10 # 两次探测的间隔
timeoutSeconds: 2 # 单次探测超时
successThreshold: 1 # 连续成功多少次才算健康
failureThreshold: 3 # 连续失败多少次才算不健康从故障到动作的时延是可以估算的。以 periodSeconds: 10, timeoutSeconds: 2, failureThreshold: 3 为例:从第一次探测失败算起,大约要经历 (failureThreshold - 1) × periodSeconds + timeoutSeconds ≈ 22 秒才会被判为失败;liveness 判死后 kubelet 立即按 restartPolicy 杀容器并重启。探针的真实执行时序以 kubelet 实现为准,这个算式用来估算"从故障到处置"的时延,判断阈值设得合不合理——把 failureThreshold 调大,等于延长容忍抖动的时间;把 periodSeconds 调小,等于提高检测灵敏度,代价是探测请求更频繁。两个上限别忘:timeoutSeconds 默认只有 1 秒,对慢接口是假阳性高发区;timeoutSeconds 也不该大于 periodSeconds,否则探测节奏会乱。
#kubelet 根据结果做什么:摘流量、杀容器、延迟就绪
- readiness 失败 → kubelet 把 Pod 的 Ready 条件置为 False;kube-controller-manager 里的 EndpointSlice 控制器观察到变化,把该 Pod 的 IP 从 Service 的 EndpointSlice/Endpoints 摘除,Service 不再把流量转发给它。容器本身继续运行,恢复健康后重新加回。摘流量是异步的,不是一瞬间完成。
- liveness 失败 → kubelet 杀容器,并按 restartPolicy(Pod 默认 Always)重启。注意:重启的是容器,Pod 还在原地;连续失败会进入指数退避(重启间隔 10 秒起,翻倍到 20、40、80、160,封顶约 300 秒),表现为 CrashLoopBackOff。如果 restartPolicy 是 Never,容器被杀后不会重启,Pod 会一直处于 Failed。
- startup 失败 → 同样导致容器被杀。startupProbe 的语义是"我最长容忍这么多时间完成启动",超了就是起不来,杀。
把三条因果链连起来理解:startup 没通过,readiness/liveness 都不执行,容器不会被误杀,但也不会被放行接流量;startup 通过后,liveness 接管"要不要重启",readiness 接管"能不能接流量"。startup 成功之后它就不再执行,所以它只适合当"启动门禁",不适合当周期性健康检查。
#从 Conditions 到 Endpoints:就绪的完整路径
把"ready 与否"落到流量上,中间还有一段异步链路,值得完整走一遍:
- kubelet 周期性执行探针,把结果写进 Pod 的 status.conditions:readiness 失败 → Ready=False,而 ContainersReady 可能仍为 True;
- kube-controller-manager 里的 EndpointSlice 控制器监听 Pod 变化,把 Ready=False 的 Pod 地址从对应 Service 的 EndpointSlice 中移除;
- 节点上的 kube-proxy 监听 EndpointSlice 变化,重新生成 iptables/ipvs 转发规则;
- 之后的新连接才不再被转发到该 Pod;已建立的存量连接不受影响,由应用层面的优雅退出负责关闭。
这正是 kubectl get endpoints 和 kubectl get pod 的 READY 列会短暂不一致的原因,也是 preStop 需要留时间的机制根源。排障时记住:探针是 kubelet 写的(节点层),摘流量是 EndpointSlice 控制器做的(控制面),转发规则是 kube-proxy 更新的(节点层)——三层异步推进,别指望"探针一失败,流量立刻消失"。相应地,kubectl describe pod 里的 Conditions 段(PodScheduled、Initialized、ContainersReady、Ready)是判断"卡在哪一层"的第一现场。
#资源声明:requests 给调度器,limits 给运行时
资源声明是两套不同动作的输入:requests 主要影响"能不能调度、被谁欺负",limits 主要影响"运行时能用到什么程度、超了会怎样"。它们作用的位置完全不同,混在一起写就容易出事故。
#先看懂单位:100m 与 128Mi
资源声明里最常见的两个单位:
- CPU 的 m:1 核 = 1000m,100m 就是 0.1 核。m 后缀只用于 CPU;CPU 是时间片资源,100m 意味着"平均占用 0.1 个核的调度时间",可以短时爆发(受 limits 约束)。
- 内存的 Mi:128Mi = 128 × 2^20 字节(Mebibyte),不是 128 MB(10^6)。内存声明习惯上用 Mi/Gi 这类二进制单位,避免舍入误差。
另一个容易踩的 API 校验:每个资源的 limits 不能小于 requests,违背会在 apply 时被拒绝(报 requests must be lower than or equal to limits)。反过来,"只写 limits 不写 requests"时,requests 会被默认成与 limits 相同——你以为没设 requests,实际把调度账单也顶到了上限,Pod 可能因此调度不上去。想表达"允许爆发上限、但不占太多调度账本",是把 requests 设小、limits 设大(两者都显式写),代价是节点超卖:同节点的邻居可能在爆发时被打扰。
#requests:调度器的账本与 kubelet 的保证
调度器(kube-scheduler)为 Pod 选节点时,走的是"筛选 + 打分":先检查节点剩余资源是否满足 requests(已分配 requests 之和 + 本 Pod 的 requests ≤ 节点可分配容量),不满足的直接排除;然后再按空闲资源等因素打分选最优。Pod 只会被调度到"剩余资源 ≥ requests"的节点。这就是为什么 requests 设得越小,可选节点越多、调度越快;也是为什么 kubectl describe pod 里出现 0/N nodes are available: ... Insufficient cpu / Insufficient memory 时,第一反应应该是去查 requests,而不是怪集群没资源。
要注意 requests 是"账单"不是"用量":节点账本记录的是所有 Pod 的 requests 之和,而不是它们实际吃了多少。kubectl describe node 里 Allocated resources 一节,就是你每个节点账本的账面(节点可分配容量 = 物理容量 − 系统预留 kube-reserved/system-reserved)。
requests 也是 kubelet 对容器的"保证承诺":kubelet 会按 requests 为容器配置 cgroup 的保底参数(cgroup v2 下对应 memory.min、cpu.weight 等),同节点资源争抢时,用量没超过 requests 的容器拥有优先权。HPA(HorizontalPodAutoscaler,水平 Pod 自动扩缩器)也以 requests 为分母计算利用率:利用率 = 当前用量 / requests,requests 设得离谱,HPA 的扩缩容决策就会失真。
#limits:CPU 限流是"慢",内存超限是"死"
limits 被 kubelet 写进容器 cgroup 的上限。两种资源对待超限的方式完全不同:
- CPU 是压缩型资源(compressible):超过 limit 会被 CFS 限流(throttle),进程变慢、等待调度,但不会死。cgroup v2 下对应 cpu.max 里的 quota/period,比如写了 500m,quota 就是半个 CPU 核的配额。
- 内存是不可压缩资源(incompressible):超过 limit 直接触发该 cgroup 的 OOM killer 杀进程,容器被标记 OOMKilled,Pod 的容器状态里 terminated reason=OOMKilled,随后按 restartPolicy 重启——表现为"起来就死、死了再起"。
一个常被忽略的点:CPU 的 limits 总和可以超过节点容量(超卖),因为超卖的最坏结果是限流不是杀人;内存超卖则是危险的,超卖部分没有保障。
排查命令:
kubectl top pod demo-api-xxxxx # 看实际占用(需要集群装有 metrics-server)
kubectl describe pod demo-api-xxxxx # 看 Events 里有没有 OOMKilled一个典型事故:开发随手写 limits: {memory: 256Mi},Java 应用实际要 512Mi,上线后 Pod 反复 OOMKilled,kubectl describe 的 Events 里全是 OOMKilled,业务却以为"K8s 不稳定"。limits 必须先压测再定,内存类应用尤其如此;宁可先只设 requests 不设 limits(让节点级 cgroup 和驱逐机制兜底),也不要拍脑袋设一个偏小的 limits。
#QoS 等级:由 requests/limits 组合推导,决定被杀顺序
Kubernetes 根据每个容器的 requests/limits 组合,把 Pod 归入三个 QoS(服务质量)等级,这个等级直接决定资源紧张时的"被牺牲顺序":
- Guaranteed:每个容器都对 CPU 和内存设置了 requests,且 requests == limits(只设 limits 时 requests 会被默认成相同值,也算);
- Burstable:至少一个容器设置了 requests 或 limits,但不满足 Guaranteed(例如只设 requests 不设 limits,或 requests < limits);
- BestEffort:所有容器都不设置任何 requests/limits。
等级不是给人看的标签,它被映射到内核的 oom_score_adj 和 kubelet 的驱逐排序:节点内存压力下触发驱逐(eviction)时,kubelet 优先动那些用量已经超过 requests 的 Pod,在这一前提下 BestEffort 最容易被驱逐,Burstable 次之,Guaranteed 很少被整体驱逐;容器级 OOM 时,超过 requests 越多、等级越低,越容易被内核选中牺牲。一个常被误解的点:Guaranteed 不等于"永不 OOMKilled"——它只是"被同节点其他 Pod 挤死的概率最低",自己把内存冲到 limit 之上一样被 cgroup OOM killer 杀掉。
用一条命令就能看到等级:
kubectl get pod demo-api-xxxxx -o jsonpath='{.status.qosClass}'#两条资源死亡路径:容器级 OOM 与节点级驱逐
"内存超了会怎样"其实有两条不同的死亡路径,别混为一谈:
- 容器级 OOM:容器自身用量超过自己的 memory limit,触发的是该容器 cgroup 的 OOM killer,表现形式是 OOMKilled + 重启,只影响这个容器;
- 节点级驱逐:节点整体内存不足(kubelet 按 memory.available 等阈值判断),kubelet 的 eviction manager 挑选 Pod 整体驱逐,表现形式是 Pod 被删除、由 ReplicaSet 在别处重建,可能连累同节点多个 Pod。
"宁可先只设 requests 不设 limits"的兜底,兜的是第二条路:没有 limits 时容器不会被自己的 cgroup 判死,节点内存紧张时由 kubelet 按 QoS 和用量排序驱逐,驱逐事件会在事件里留下清晰记录,比"起来就死"的 OOMKilled 循环好定位得多。但要注意:不设 limits 不代表安全,内存杀手只是从"容器自己"换成了"整节点",高压下依然会出事。
两条路径分别观察:容器状态里的 terminated.reason=OOMKilled,以及事件里的 Evicted。这两类记录在 kubectl describe pod 里都能看到。
#滚动更新与优雅退出
#maxUnavailable 与 maxSurge 的推进方式
Deployment 默认的更新策略是 RollingUpdate,两个关键参数:
- maxSurge:更新期间允许超出期望副本数的最多数量(绝对数或百分比,默认 25%);
- maxUnavailable:更新期间允许不可用的旧副本最多数量(默认 25%)。
maxUnavailable: 0 + maxSurge: 1 的意思是:先多起一个新的,确认就绪后再停一个旧的。任意时刻至少有一个旧 Pod 在服务,更新过程中容量不下降。代价是高峰时期会短暂占用双倍资源(多一倍的 Pod 同时跑),对资源有硬约束的集群要评估。注意百分比会做向上取整:maxSurge: 25% 在 replicas=3 时实际步长是 1。
发布时观察:
kubectl rollout status deployment/demo-api --timeout=120s
kubectl rollout history deployment/demo-api
kubectl get pods -w -l app=demo-api滚动更新的推进节奏由新 Pod 的 readiness 决定:只有前一个 Pod readiness 通过后,Deployment 控制器才会继续下一个。所以探针配错,滚动更新就会卡死——新版 Pod 永远不就绪,旧版被 maxUnavailable: 0 保着,更新挂起但线上没挂。这种状态极具迷惑性:页面正常、流量正常,只有 rollout status 一直转圈。超过 progressDeadlineSeconds(默认 600 秒)后,Deployment 会把 Progressing 条件标记为 ProgressDeadlineExceeded,此时 kubectl rollout status 会明确报错,而不是永远转下去。kubectl describe deployment 的 Conditions 一栏(Available、Progressing、ReplicaFailure)能告诉你它当前卡在哪个状态。
#暂停、灰度与回滚
滚动更新还有几个高频操作:
kubectl rollout pause deployment/demo-api # 暂停推进,适合先观察新版本
kubectl rollout resume deployment/demo-api # 继续推进
kubectl rollout undo deployment/demo-api # 回滚到上一版本
kubectl rollout undo deployment/demo-api --to-revision=2 # 回滚到指定版本
kubectl rollout history deployment/demo-api --revision=2 # 查看某版本细节pause 的语义是"控制器暂停对新旧 RS 副本数的调整",已经起来的 Pod 照常运行、探针照常执行,readiness 依然会维护端点。最常见的一种灰度用法:pause 后手动把新版本拉一个副本起来观察(集群允许时 kubectl scale deployment/demo-api --replicas=4),确认没问题再 resume 让控制器继续收敛。
undo 的语义要澄清:它把 Deployment 的 template 恢复成目标 revision 的 template,因此会再生成一次滚动,不是"瞬间切回旧 RS"。如果 template 在发布过程中被人手工改过,undo 会覆盖这些手工修改——回滚前先 kubectl rollout history 看清楚再动手。
删除 Pod 侧的覆盖项也提一句:kubectl delete pod <pod> --grace-period=N 可以临时覆盖对象的 terminationGracePeriodSeconds;--grace-period=0 --force 直接跳过优雅退出立即 SIGKILL,只在节点僵死、容器无法响应时作为最后手段,正常发布不要用。
#优雅退出:preStop → SIGTERM → grace period → SIGKILL
Pod 被删除或缩容时(滚动更新缩旧 RS 也走这条路),终止流程是:
- API Server 给 Pod 打上 deletionTimestamp,Pod 进入 Terminating;
- EndpointSlice 控制器把该 Pod 从 Service 端点中摘除(异步进行,期间新请求仍可能被转发过来);
- kubelet 先执行 preStop 钩子(如果配了);
- kubelet 向容器主进程发送 SIGTERM;
- 等待 terminationGracePeriodSeconds(默认 30 秒);
- 超时后发送 SIGKILL,强制结束。
两个容易忽略的细节:preStop 和 SIGTERM 共用同一个 grace period——preStop 里 sleep 5,SIGTERM 的等待就只剩 25 秒;preStop 一直不返回,时间一到照样 SIGKILL;应用必须监听 SIGTERM 做优雅退出(关连接、刷缓冲),否则等于强制断电。建议在容器里加 preStop 钩子做最后的流量摘除和延迟:
lifecycle:
preStop:
exec:
command: ["/bin/sh", "-c", "sleep 5"]sleep 5 给 Service 端点的摘除留时间。摘除是异步链路:Endpoints/EndpointSlice → kube-proxy → iptables/ipvs 规则更新,这期间新请求仍可能被转发到正在终止的 Pod;preStop 的延迟让应用在"还有可能在收请求"的阶段保持监听,避免"Pod 已经退出,请求还在往里打"。这个 5 秒 + 应用自身的优雅退出,是生产发布"零抖动"最简单的保障。
#完整可复现的部署清单(操作层)
#前置条件
以下清单与命令以 Kubernetes v1.28+(kubectl v1.28+)为验证环境:所有字段基于稳定 API apps/v1(自 v1.9 起 GA);startupProbe 自 v1.20 起 GA,可直接使用。需要一个可用集群(本地 kind/minikube 或远程集群均可)、kubectl 已配置好 kubeconfig、集群装有 metrics-server(否则 kubectl top 会报 Error from server (NotFound): the server could not find the requested resource)。命令在 bash/zsh 下验证。
#完整 Deployment manifest(含三类探针与资源声明)
把下面的内容保存为 deploy.yaml:
apiVersion: apps/v1
kind: Deployment
metadata:
name: demo-api
spec:
replicas: 3
strategy:
type: RollingUpdate
rollingUpdate:
maxUnavailable: 0
maxSurge: 1
selector:
matchLabels:
app: demo-api
template:
metadata:
labels:
app: demo-api
spec:
terminationGracePeriodSeconds: 30
containers:
- name: api
image: ghcr.io/acme/demo-api:1.2.0
ports:
- containerPort: 8080
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: 500m
memory: 512Mi
startupProbe:
httpGet:
path: /healthz
port: 8080
periodSeconds: 5
timeoutSeconds: 2
failureThreshold: 30
readinessProbe:
httpGet:
path: /healthz
port: 8080
initialDelaySeconds: 5
periodSeconds: 10
timeoutSeconds: 2
failureThreshold: 3
livenessProbe:
httpGet:
path: /healthz
port: 8080
periodSeconds: 10
timeoutSeconds: 2
failureThreshold: 3设计意图拆开看:
- replicas: 3 + maxUnavailable: 0 + maxSurge: 1:更新期间容量不降、最多瞬时多 1 个副本;
- startupProbe:给慢启动留窗口。periodSeconds 5 × failureThreshold 30,估算约 145 秒的启动容忍期;在它成功前,readiness 和 liveness 都不执行,Java 冷启动 40 秒的应用不会被 liveness 误杀;
- readinessProbe:initialDelaySeconds 5、period 10、timeout 2、failureThreshold 3,约 22 秒判定失败,用于摘流量;
- livenessProbe:同一阈值,用于判死重启;
- terminationGracePeriodSeconds: 30:优雅退出窗口,配合 preStop 钩子使用(见上文"优雅退出"一节,可以直接合入这份清单)。
为了让"摘流量"这一步可验证,还需要一个指向它的 Service,保存为 service.yaml:
apiVersion: v1
kind: Service
metadata:
name: demo-api
spec:
selector:
app: demo-api
ports:
- port: 80
targetPort: 8080#发布与回滚命令
# 1. 服务器端校验:拦下语法与 schema 错误(不会实际创建)
kubectl apply --dry-run=server -f deploy.yaml
# 2. 应用
kubectl apply -f deploy.yaml
kubectl apply -f service.yaml
# 3. 等待滚动完成
kubectl rollout status deployment/demo-api --timeout=120s
# 4. 模拟一次发版:更新镜像,触发新的 ReplicaSet
kubectl set image deployment/demo-api api=ghcr.io/acme/demo-api:1.3.0
kubectl rollout status deployment/demo-api --timeout=120s
# 5. 给版本加注释,让 rollout history 可读
kubectl annotate deployment/demo-api kubernetes.io/change-cause="upgrade to 1.3.0"
kubectl rollout history deployment/demo-api --revision=2
# 6. 回滚(默认上一个版本;也可 --to-revision=N 指定版本)
kubectl rollout undo deployment/demo-api
kubectl rollout status deployment/demo-api --timeout=120s#验证层:怎么确认我做对了
#验证副本收敛与就绪状态
kubectl get deployment demo-api
kubectl get rs -l app=demo-api
kubectl get pods -l app=demo-api -o wide
kubectl get endpoints demo-api预期:Deployment 显示 3/3;尚未发版时只有一个 ReplicaSet,desired/current/ready 都是 3;3 个 Pod 都是 Running,READY 列都是 1/1;endpoints 里有 3 个地址。Pod 状态列里的 READY 是"就绪容器数/总容器数",它直接反映 readiness 是否通过。
#验证探针行为:就绪失败只摘流量、不被杀
用 busybox 拉起一个最小演示对象(探针实验专用,验完删除),把 readiness 指向一个"由文件存在性决定的检查",就能在不触发滚动更新的前提下精确观察"摘流量"动作。保存为 probe-lab.yaml:
apiVersion: apps/v1
kind: Deployment
metadata:
name: probe-lab
spec:
replicas: 1
selector:
matchLabels:
app: probe-lab
template:
metadata:
labels:
app: probe-lab
spec:
containers:
- name: app
image: busybox:1.36
command: ["/bin/sh", "-c", "sleep 3600"]
readinessProbe:
exec:
command: ["/bin/sh", "-c", "test -f /tmp/ready"]
initialDelaySeconds: 1
periodSeconds: 2
failureThreshold: 2
---
apiVersion: v1
kind: Service
metadata:
name: probe-lab
spec:
selector:
app: probe-lab
ports:
- port: 80
targetPort: 80然后依次执行:
kubectl apply -f probe-lab.yaml
# 初始:/tmp/ready 不存在,Pod 不就绪,endpoints 为空
kubectl get endpoints probe-lab
# 让就绪条件成立:endpoints 几秒后出现地址,READY 变 1/1
kubectl exec deploy/probe-lab -- touch /tmp/ready
kubectl get endpoints probe-lab
kubectl get pod -l app=probe-lab
# 破坏就绪条件:endpoints 几秒后地址消失,但容器仍是 Running、restartCount 为 0
kubectl exec deploy/probe-lab -- rm /tmp/ready
kubectl get endpoints probe-lab
kubectl get pod -l app=probe-lab
# 恢复就绪:地址重新出现
kubectl exec deploy/probe-lab -- touch /tmp/ready
# 验完清理
kubectl delete -f probe-lab.yaml观察要点:无论 /tmp/ready 是否存在,容器进程始终活着(restartCount 不增加)——这就是"readiness 失败只摘流量、不杀容器"的直接证据。想在真实应用上复现,把 exec 探针换成你应用的 /healthz,再用业务手段让它失败。
#验证 liveness 与重启
liveness 的"杀容器再重启"用"杀掉主进程"来触发(只在测试环境做):
kubectl exec deploy/demo-api -- kill 1
kubectl get pods -l app=demo-api -w主进程退出后,kubelet 按 restartPolicy 重启容器:观察 restartCount 增加、Pod 的 READY 短暂变 0/1 再恢复。如果应用起不来,kubectl get pods 会看到 CrashLoopBackOff,restartCount 按退避节奏(10 秒、20 秒、40 秒……封顶约 300 秒)增长;kubectl logs --previous 能看到容器被杀前的输出。想从事件侧观察探针判定过程,可以持续跟踪事件:
kubectl get events --sort-by=.metadata.creationTimestamp | tail -20
# 重点看 Readiness probe failed / Liveness probe failed / Killing / Started 等事件#验证资源与 OOM
kubectl describe node | sed -n '/Allocated resources/,/Events/p' # 节点账本:Requests/Limits 汇总
kubectl top pod -l app=demo-api # 实际用量
kubectl get pod demo-api-xxxxx -o jsonpath='{.status.qosClass}\n' # QoS 等级,预期 GuaranteedOOM 的复现路径:在测试环境把 limits.memory 调小(比如 128Mi)再 apply,压一点流量或 exec 进容器申请内存,然后观察:
kubectl describe pod demo-api-xxxxx # Events 出现 OOMKilled,restartCount 上升
kubectl get pod -o jsonpath='{.status.containerStatuses[*].lastState.terminated.reason}\n'
kubectl logs deployment/demo-api --previous # 看被杀前的日志想从 cgroup 层面确认限额(cgroup v2;v1 的路径与字段名不同):
kubectl exec deploy/demo-api -- cat /sys/fs/cgroup/cpu.max # 形如 50000 100000,即 500m
kubectl exec deploy/demo-api -- cat /sys/fs/cgroup/memory.max # 512Mi 对应的字节数
kubectl exec deploy/demo-api -- cat /sys/fs/cgroup/memory.events # oom_kill 计数
kubectl exec deploy/demo-api -- cat /sys/fs/cgroup/cpu.stat # nr_throttled 反映限流次数CPU 限流的特征与 OOM 完全不同:进程还活着,只是 cpu.stat 里的 nr_throttled 在增长。
#验证调度与事件
requests 太大导致调度不上去,是高频事故。用 kubectl run 单独验证(临时对象,验完删除):
kubectl run sched-lab --image=busybox:1.36 --restart=Never --requests='cpu=8' -- sleep 3600
kubectl describe pod sched-lab # Events: 0/N nodes are available ... Insufficient cpu
kubectl delete pod sched-lab如果集群里确实没有 8 核空闲的节点,这个 Pod 会一直 Pending,事件里给出明确的拒绝理由。这就是"requests 是调度账本"的最直观演示:不是集群没资源,是你的账单太大。
#验证滚动更新
kubectl get rs -l app=demo-api
kubectl rollout history deployment/demo-api
kubectl get pods -w -l app=demo-api
kubectl describe deployment demo-api | sed -n '/Conditions:/,$p'发版期间应看到:新 RS 的期望副本从 0 升到 1,第一个新 Pod readiness 通过后,旧 RS 期望副本才从 3 降到 2,随后新 RS 再升到 2……直到新 RS 3/3、旧 RS 0/3。如果新 Pod 一直不就绪,滚动会停在原地,rollout status 转圈,超过 progressDeadlineSeconds 后报超时——这就是"探针配错导致发布卡死"的标准现场。Conditions 一栏的 Type/Status/Reason(Available、Progressing、ReplicaFailure)能告诉你 Deployment 当前卡在哪个状态。
#边界与坑
下面这些坑都是从实际事故里来的,每一条都能在前面机制里找到解释。
- 探针路径用业务接口当健康检查。健康检查应该只依赖应用自身状态。去查数据库的 /healthz 在 DB 抖动时会连带把 Pod 全摘掉,形成雪崩;把 /orders 当探针更糟糕:压测时探针超时 → Pod 重启 → 流量再打进新 Pod → 再次超时,无限循环,接口全线超时。
- liveness 和 readiness 用同一个 /healthz 且不区分场景。慢启动的应用(比如 Java 冷启动 40 秒),如果没配 startupProbe,liveness 会在就绪前就把 Pod 杀掉。要么用 startupProbe 兜底,要么把 liveness 的 failureThreshold 放宽。
- readiness 探针依赖外部组件(数据库、下游服务)。启动时 DB 未通就永远不就绪,滚动更新卡死;运行中 DB 抖动就全摘流量。健康检查只回答"我自己还行不行"。
- liveness 探针指向外部依赖更危险。readiness 指向依赖,坏了最多摘流量;liveness 指向依赖,依赖一抖,无关的 Pod 全被杀光重启,比雪崩还狠。liveness 只应检查"进程是否死透"这种本容器内的问题。
- httpGet 的 port 必须是容器内实际监听端口。探针由 kubelet 直连 Pod IP,端口写错、路径写错,readiness 永远失败,现象与"应用没起来"完全一样。
- 镜像版本写错或 tag 不存在。Pod 卡在 ImagePullBackOff,永远不就绪,maxUnavailable: 0 保着旧副本,滚动更新挂起但线上不挂;
kubectl describe pod的 Events 里能看到拉镜像失败的原因。 - 只写 limits 不写 requests。requests 被默认成 limits,调度账本被顶满,Pod 可能调度不上去;这不是"不设 requests",而是"把 requests 设成了上限"。
- limits 拍脑袋。内存类应用尤其危险:limits 设小了反复 OOMKilled,设大了节点超卖风险高。正确做法是先压测拿到真实峰值,再给 limits 留 20%~30% 余量;没有把握时先只设 requests(QoS 降为 Burstable,节点压力下可能被驱逐,但驱逐有明确事件可查,比 OOMKilled 循环好定位得多)。
- requests 设太大。Pod 会一直 Pending,describe 的 Events 里是
0/N nodes are available: ... Insufficient cpu / Insufficient memory;用kubectl describe node看 Allocated resources,就知道账本还剩多少。 - HPA 与手动改 replicas 混用。两个写入方抢 Deployment 的 replicas 字段,会导致"我刚 scale 完又被改回去"。要扩缩容的服务,replicas 交给 HPA 管,别再手动动。
- 容器内看到的资源不是自己的配额。容器里
nproc、free看到的往往是宿主机整机容量;老版本 JDK 甚至会按节点核数初始化线程池、按整机内存估算堆。现代 JDK(8u191+ / 10+)默认开启容器感知,但前提是你把 limits 设对,否则它感知到的上限同样失真。 - 探针的 timeoutSeconds 默认只有 1 秒。对慢接口或冷连接,1 秒超时会造成大量假阳性失败;但也不要把它当业务超时调得很大,超过 periodSeconds 会影响探测节奏。
- maxSurge/maxUnavailable 用百分比时注意舍入:期望副本数越大,单位步长越大(25% 的 4 副本是 1,8 副本是 2)。
- startupProbe 成功后就退出舞台,它不适合当周期性健康检查用。
- preStop 里的 sleep 别超过 terminationGracePeriodSeconds,否则 SIGTERM 没机会执行,应用等于被 SIGKILL 强杀。
- 发布前先服务器端校验:
kubectl apply --dry-run=server -f deploy.yaml能拦住绝大部分语法错误、资源类型错误和 schema 不匹配,成本极低。 - 出问题先看这两个地方:
kubectl describe pod的 Events(OOMKilled、FailedScheduling、探针失败的记录都在这里)和kubectl logs --previous(重启前容器最后的输出)。一半的 Deployment 事故都能从这里定位。
#什么时候不该照抄
探针、资源、优雅退出这三样配齐,Deployment 才算"生产可用",缺一个都迟早出事。但也要知道边界:一次性任务(Job/CronJob)不需要 readinessProbe;无状态多副本服务才适合这种"杀一个换一个"的滚动模式,有状态应用(数据库、有主从关系的中间件)的滚动要配合 StatefulSet 的有序更新;只设 requests 不设 limits 适合"还不知道上限"的早期阶段,进入容量规划后仍要补 limits,并把压测当成发布流程的一部分。这份清单是一个可靠的起点,不是终点——每上一个新应用,都值得把三条因果链重新走一遍:谁在收敛副本、谁在探活、谁在限资源。
相关阅读
Related留言讨论
0 条还没有留言,来说点什么吧。