Kubernetes 交付与状态边界:对象就绪不等于业务可用
前面的 Linux 容器页讲清了 cgroup 配额与容器内视角,Actuator 页讲清了应用怎样报告 readiness/liveness 状态。这一页把两者放进 Kubernetes 的交付对象里:一份 Deployment 从清单到流量,中间经过调度、探针、Endpoints、滚动替换和优雅停机——每一步都有"看起来成功但业务不可用"的失败面。
本页是 U66(#103)的 E04 权威正文。先说清证据等级:本机有 kubectl v1.33.9 客户端,但没有任何 Kubernetes 集群(无 Docker daemon、kind/minikube、kubeconfig,6443 未监听)。因此本页的可运行证据分两层:
- 本地可执行验证:真实
kubectl kustomize渲染(离线 Kustomize 引擎)+ SnakeYAML 2.5(Boot 4.0.9 BOM 同版本)清单契约断言,以及滚动发布/探针预算/停机时序的状态机推演(labs/k8s-delivery的 10 个测试;推演不是集群观察)。 - 受阻:真实集群的 apply、rollout、探针判定、驱逐顺序、PVC 绑定——票面允许"本机无集群时受阻仍算待验证",本页不冒充集群观察。
应用侧探针语义的权威讲解在 Actuator:端点权限、健康就绪与观测(U24 已在本机 macOS 实跑 readiness 503→200 时间线);本页只讲平台侧:这些端点在 Kubernetes 里由谁调用、判定结果流向哪里、与 Endpoints 和滚动发布怎样衔接。cgroup 机制见 Linux cgroup 与容器诊断;信号语义见 Linux 信号权限与优雅停机。
一条发布请求的生命周期
| 阶段 | 谁在做 | 失败会怎样 |
|---|---|---|
| 清单渲染 | 你的 CI / Kustomize | 渲染错误在本地失败,不进入集群 |
| API 接受与调度 | API server / scheduler | 资源不足 Pending;requests 决定能否调度 |
| 容器启动 | kubelet | startup 探针失败则重启,永远不进入 ready |
| 就绪判定 | kubelet 探针 | readiness 失败 → NotReady → 从 Endpoints 摘除 |
| 流量接入 | EndpointSlice 控制器 + kube-proxy | Ready 的 Pod 才收到流量;"就绪≠业务正确" |
| 滚动替换 | Deployment 控制器 | maxSurge/maxUnavailable 决定可用下界与资源峰值 |
| 退出 | kubelet + 应用 | preStop → SIGTERM → 宽限期 → SIGKILL |
一句话主线:Kubernetes 只负责"对象状态";"业务可用"必须由应用用自己的证据(真实请求、队列水位、依赖状态)证明,再由探针与发布门禁接入。
版本、工程与观察入口
固定 kubernetes/kubernetes v1.33.9(与本机 kubectl 同线;源码摘录均为该 tag)、kubectl v1.33.9 客户端、SnakeYAML 2.5(Boot 4.0.9 BOM 管理)、JDK 21(Liberica 21.0.11+11)、Maven 3.9.9、macOS arm64。
公开入口在仓库根目录:
python3 labs/k8s-delivery/run.py入口执行 clean test、核对 1 份 Surefire 报告(10 个测试)、校验 10 条 U66 观察行,把环境、kubectl 版本、集群探测结果(kubeconfig/docker/6443)与受阻登记写入运行证据。清单在 labs/k8s-delivery/src/main/resources/manifests/(base + prod overlay)。
本页框架原码均为 v1.33.9 固定 tag 的连续片段,元数据含整文件 SHA-256 与行段,由 scripts/check_source_excerpts.py 在线复核。源码、清单、渲染、推演是四种证据,不能互相替代。
交付对象:Deployment、StatefulSet 与它们的差别
三种工作负载
- Deployment:无状态替换。Pod 是"可丢弃的副本",滚动时新旧 Pod 并存、身份不重要。
- StatefulSet:稳定身份(
name-0/1/2)+ 每 Pod 独立 PVC + 有序或并行启动。删除 Pod 不删 PVC:这就是"有状态"与"数据生命周期"的分界(清单volumeClaimTemplates生成data-ledger-store-0/1/2三个 PVC)。 - DaemonSet:每节点一个,用于节点级代理。本页不展开。
本实验清单同时给 Deployment(3 副本,滚动策略)与 StatefulSet(3 副本,PVC 模板,headless Service)。真实渲染结果:base 7 个对象、overlay 仍是 7 个(U66 kustomizeRender=PASS kubectl=v1.33.9 baseObjects=7)。
overlay 只改差异
prod overlay 不复制清单正文:namePrefix: prod-、replicas 覆盖(3→6)、patch 改 resources(250m/256Mi → 500m/512Mi)。真实渲染断言:U66 overlayRender=PASS prefix=prod- replicas=6 cpuRequest=500m。这个做法把"环境差异"收敛到少量字段,避免两份 Deployment 漂移。
Service 与流量:选择器到 Endpoints
选择器、Endpoints 与流量路径
Service 的 selector 匹配 Pod 标签;EndpointSlice 收录匹配 selector 的 Pod,未就绪的 Pod 也会出现,但 conditions.ready=false、不接收新流量(publishNotReadyAddresses 例外)。三种类型:
- ClusterIP(本实验默认):集群内虚拟 IP。iptables 模式下集群内访问通常保留客户端源 IP;kube-proxy 仅在
masqueradeAll等条件下做源地址伪装,不能概括为"ClusterIP 一律 SNAT"。 - NodePort:每节点开端口;默认对集群外来源做 SNAT,
externalTrafficPolicy: Local才保留。 - LoadBalancer:云厂商 LB + NodePort;源 IP 是否保留取决于 LB 实现与
externalTrafficPolicy。
externalTrafficPolicy: Local 保留客户端源 IP,代价是"本节点无 Pod 即丢流"——这是"看到真实客户端 IP"与"不丢流"之间的取舍,不是免费开关。
就绪探针到 endpoints 的链路
kubelet 只报告容器状态;把 Pod 从 Endpoints 摘除的是 EndpointSlice 控制器——它按 Pod 的 Ready 条件,而不是按你的业务健康语义。这条链路的三个"断点"必须分清:
- 探针失败 → 容器
Ready=false:kubelet 的判定,取决于 readinessProbe 配置。 Ready=false→ EndpointSlice 的conditions.ready翻转:控制器异步,有传播延迟(kube-proxy/ingress 各自刷新)。- ready 翻转生效 → 新请求不再到达该 Pod:客户端连接池里已建立的连接仍可能继续。
第 3 点是运维最容易误判的:摘流不是"立刻没有请求",而是"新连接不再进来";已在途的请求要靠优雅停机处理(见后文)。
"对象就绪不等于业务可用"
U24 的实跑已经给出反例:进程活着(liveness 200)但 readiness 503 OUT_OF_SERVICE——此时 Pod 在 Kubernetes 里是 NotReady、被摘流,这是正确行为。反过来,如果应用把重初始化放在首个请求里做(懒加载),readiness 会在"还没准备好"时返回 200,LB 放流量就会打出慢请求。探针测的是你让它测的东西,不是业务真相。
探针:三类判定与时间预算
三类探针的失败后果
| 探针 | 失败后发生什么 | 典型用途 |
|---|---|---|
| startupProbe | 容器重启(在失败阈值内未成功) | 兜首次启动的长初始化;成功后不再运行 |
| readinessProbe | Pod NotReady → 摘流,不重启 | 依赖未就绪、队列过载时暂停接流 |
| livenessProbe | 容器重启 | 死锁/假死检测;误报会导致雪崩 |
本实验的清单给出显式预算:startup 2s × 30 = 60s、readiness 5s × 3 = 15s、liveness 10s × 3 = 30s(U66 probeBudget=PASS startup=60s readiness=15s liveness=30s)。
判定逻辑的固定源码
探针的"连续失败达阈值才改变状态"由 kubelet 的 worker 实现。固定源码 pkg/kubelet/prober/worker.go v1.33.9,331–349 行:
if (result == results.Failure && w.resultRun < int(w.spec.FailureThreshold)) ||
(result == results.Success && w.resultRun < int(w.spec.SuccessThreshold)) {
// Success or failure is below threshold - leave the probe state unchanged.
return true
}
w.resultsManager.Set(w.containerID, result, w.pod)
if (w.probeType == liveness && result == results.Failure) || w.probeType == startup {
// The container fails a liveness/startup check, it will need to be restarted.
// Stop probing until we see a new container ID. This is to reduce the
// chance of hitting #21751, where running `docker exec` when a
// container is being stopped may lead to corrupted container state.
// In addition, if the threshold for each result of a startup probe is exceeded, we should stop probing
// until the container is restarted.
// This is to prevent extra Probe executions #117153.
w.onHold = true
w.resultRun = 0
}四个工程含义:
- 阈值是"连续"的:
resultRun在结果变化时重置为 1;failureThreshold > 1时单次抖动不会改变状态。注意它是结果连续性计数,不是"窗口内失败次数"——间歇失败永远不会达到阈值。failureThreshold=1时第一次失败即生效。 - liveness 失败达阈值、或 startup 达阈值(无论成功失败)都会置
onHold:探针停止,直到新容器出现。startup 成功后停止探测正是靠这个分支。 - readiness 失败不触发重启(分支里没有 readiness):这是"摘流不是重启"的源码依据。
- SuccessThreshold 对 liveness/startup 强制为 1(API 校验);readiness 可配多次成功才恢复。
时间预算的计算
periodSeconds × failureThreshold 是近似上界(不含 initialDelaySeconds、timeoutSeconds,且探针执行错误被丢弃不累计),不是"多久失败"。三个预算要一起算:
- startup 预算必须覆盖最慢冷启动(本实验 60s);
- readiness 预算决定"坏 Pod 被摘流的最坏延迟"(15s:其间仍可能收到请求);
- liveness 预算决定"假死被重启的最坏延迟"(30s)。
反例:把 liveness 的 failureThreshold 设为 1、periodSeconds 设为 1,网络抖动一次就重启——重启会放大依赖压力,形成雪崩。liveness 应比 readiness 更宽松,且应检测"不可恢复的假死"而不是"依赖暂时不可用"。
资源与 QoS
requests、limits 与调度
- requests 是调度依据:scheduler 按 requests 找节点,不是 limits。
- limits 是 cgroup 上限:CPU 超限被节流(throttle),内存超限被 OOMKill。
- requests 远小于 limits 时,节点可能超卖——高峰期争抢,延迟抖动。
QoS 类别与驱逐顺序
| QoS | 条件 | 驱逐优先级 |
|---|---|---|
| Guaranteed | 所有容器 requests == limits | 最后被驱逐 |
| Burstable | 有 requests 或 limits 但不等 | 中间 |
| BestEffort | 完全未设 | 最先被驱逐 |
本实验 Deployment 的 requests 250m/256Mi 与 limits 1 CPU/512Mi 不相等,所以是 Burstable(U66 qosClass=PASS class=Burstable)。这不是缺陷,而是"允许突发但接受在节点压力下先于 Guaranteed 被驱逐"的取舍——要 Guaranteed 就得把 requests 抬到等于 limits,付出调度灵活性的代价。
配置与凭据
ConfigMap 与 Secret
- ConfigMap:非敏感配置。以 env 注入时启动时读取一次,更新不会热生效;以 volume 挂载时文件会异步更新,但应用要自己实现"重读"。
- Secret:
data只是 base64,不是加密。真正的边界在 etcd 加密、RBAC(谁能 get secrets)与准入策略。本实验的清单只放合成占位值(U66 secretHygiene=PASS placeholder=true),仓库不出现真实凭据。
凭据卫生
三条硬规则:清单里不放明文密码;Secret 的值不进日志;env 注入的 Secret 在同容器任意进程可见(/proc/self/environ)。默认 Pod 不共享 PID namespace,sidecar 读不到别的容器环境;只有显式 shareProcessNamespace: true 时才可能跨容器读取。敏感度高的凭据应走文件挂载 + 最小权限。
滚动发布:maxSurge 与 maxUnavailable
两个参数的语义
- maxSurge:滚动过程中允许多于期望副本的 Pod 数(资源峰值)。
- maxUnavailable:允许少于期望的可用副本数(业务容量下界)。
两者不能同时为 0——Kubernetes API 校验(apps/validation)会拒绝,本实验的模型也显式拒绝(rolloutModelRespectsSurgeAndUnavailableBounds 里 assertThrows(IllegalArgumentException))。
控制器语义的固定源码
固定源码 pkg/controller/deployment/rolling.go v1.33.9,195–232 行(scaleDownOldReplicaSetsForRollingUpdate):
maxUnavailable := deploymentutil.MaxUnavailable(*deployment)
// Check if we can scale down.
minAvailable := *(deployment.Spec.Replicas) - maxUnavailable
// Find the number of available pods.
availablePodCount := deploymentutil.GetAvailableReplicaCountForReplicaSets(allRSs)
if availablePodCount <= minAvailable {
// Cannot scale down.
return 0, nil
}
logger.V(4).Info("Found available pods in deployment, scaling down old RSes", "deployment", klog.KObj(deployment), "availableReplicas", availablePodCount)
sort.Sort(controller.ReplicaSetsByCreationTimestamp(oldRSs))
totalScaledDown := int32(0)
totalScaleDownCount := availablePodCount - minAvailable
for _, targetRS := range oldRSs {
if totalScaledDown >= totalScaleDownCount {
// No further scaling required.
break
}
if *(targetRS.Spec.Replicas) == 0 {
// cannot scale down this ReplicaSet.
continue
}
// Scale down.
scaleDownCount := min(*(targetRS.Spec.Replicas), totalScaleDownCount-totalScaledDown)
newReplicasCount := *(targetRS.Spec.Replicas) - scaleDownCount
if newReplicasCount > *(targetRS.Spec.Replicas) {
return 0, fmt.Errorf("when scaling down old RS, got invalid request to scale down %s/%s %d -> %d", targetRS.Namespace, targetRS.Name, *(targetRS.Spec.Replicas), newReplicasCount)
}
_, _, err := dc.scaleReplicaSetAndRecordEvent(ctx, targetRS, newReplicasCount, deployment)
if err != nil {
return totalScaledDown, err
}
totalScaledDown += scaleDownCount
}三个要点:
- 缩旧只看"可用数量":
availablePodCount来自各 ReplicaSet 的Status.AvailableReplicas——要求 Ready 且持续满足minReadySeconds,不是创建数量;所以新 Pod 没 Available 之前,旧 Pod 不会被缩。本实验清单设了minReadySeconds: 10,推演模型未建模这 10 秒。 - 可用下界是硬约束:
availablePodCount <= minAvailable时直接不缩。这就是"maxUnavailable 保护业务容量"的实现。 - 按创建时间排序:多个旧 ReplicaSet 场景(连续发布)从最老的开始缩。
状态机推演
本实验的 RolloutModel 按上述语义推演逐 tick 状态(简化状态机,不是集群实跑)。简化点:每 tick 最多创建一个 Pod、不建模 minReadySeconds、不建模多旧 ReplicaSet 比例缩放与百分比参数取整,因此 Step 是模型 tick,不是控制器 sync 次数:
- 3 副本、maxSurge=1、maxUnavailable=1:可用下界 2、峰值 4(
U66 rolloutModel=PASS minAvailable=2 maxTotal=4)。 - maxSurge=0、maxUnavailable=1:不允许超额,峰值 3,可用最低仍 2(
U66 rolloutTradeoff=PASS zeroSurgeMax=3 ...)。 - maxSurge=1、maxUnavailable=0:可用始终满 3,代价是峰值 4。
- 新 Pod Ready 延迟从 0 加到 3 tick:滚动窗口变长(推演步骤 5→9,含初始状态),但可用下界不变(
U66 rolloutLatency=PASS fastSteps=5 slowSteps=9 floor=2)。
结论:maxUnavailable 决定最差业务容量,maxSurge 决定最差资源占用,Ready 延迟只影响发布时长,不影响下界。
rollout status 与"对象就绪≠业务可用"
kubectl rollout status 跟踪的是 Deployment 的 Progressing 条件:它看新 ReplicaSet 的副本是否达到期望、是否在 progressDeadlineSeconds 内完成。它不发送业务请求——一个 Pod 可以 Ready、Deployment 可以 Available,而业务依赖(数据库、下游 API)仍不可用。反过来,rollout status 卡住时 kubectl describe deployment 的 Progressing=False 与 conditions 里的原因(如 ProgressDeadlineExceeded)是排障入口。业务可用必须用真实请求或业务指标验证,见 Actuator 健康就绪 中"懒加载会让 readiness 说谎"的反例。
PDB 与滚动发布的区别
PodDisruptionBudget 约束的是自愿中断(节点排空、集群升级时 eviction API),Deployment 滚动由控制器驱动、不受 PDB 约束。本实验 3 副本 + minAvailable: 2:排空时最多一次驱逐 1 个。两个数字要一起算——否则会出现"滚动发满速、排空卡住"或"排空放得比滚动还宽"。
有状态与存储边界
StatefulSet 的稳定身份与 PVC
volumeClaimTemplates 给每个 Pod 一个独立 PVC。Pod 删除不删 PVC——重启后仍绑定同一份数据。因此:
- 滚动更新 StatefulSet 是逐个替换(默认 OrderedReady),不是同时;
- 删除 StatefulSet 默认也不删 PVC(除非显式配置 retention policy);
- 数据备份是独立责任:PVC 存在 ≠ 有备份,PVC 绑定 ≠ 数据可恢复。
本实验用 headless Service(clusterIP: None)给每个 Pod 稳定 DNS:ledger-store-0.ledger-store-headless(渲染断言:U66 objects=PASS ... headless=None)。
访问模式与绑定
PVC 的 accessModes 是能力声明,不是互斥锁:ReadWriteOnce(RWO)表示"单节点读写",同一节点上的多个 Pod 可以同时挂载;ReadOnlyMany/ReadWriteMany 是否可用取决于存储插件(块存储通常只支持 RWO,NFS 类才支持 RWX)。PV 的绑定有两种:静态预建 PV 匹配 PVC,或 StorageClass 动态供应。绑定成功 ≠ 数据可恢复:PV 只是存储句柄,备份仍是独立责任(见下文故障域)。StatefulSet 删除时 PVC 默认保留;persistentVolumeClaimRetentionPolicy 可以改成随 StatefulSet 删除(本实验未启用)。
故障域与恢复
每个带独立 PVC 的 Pod 是一个独立故障单元,但共享存储后端(同一 StorageClass/后端阵列)时,后端就是共同故障域。这与 [故障域、RTO/RPO 和切换](../../架构与工程实践/质量与恢复/故障域、RTORPO 和切换.md) 的"共享依赖要覆盖数据面之外"一致——PVC 的可用性取决于后端,不是 StatefulSet 本身。
优雅停机:preStop、SIGTERM 与宽限期
时间线
Pod 进入 Terminating 时:
- EndpointSlice 控制器异步摘除(传播需要时间 t_propagation);
- kubelet 执行 preStop hook(本实验
sleep 5); - preStop 结束后发 SIGTERM;
- 应用自行 drain(处理在途请求、关闭连接池);
- 宽限期(
terminationGracePeriodSeconds,本实验 30s)到期发 SIGKILL——不可捕获。
预算算术
- preStop 5s + 应用 drain 25s = 30s 宽限期(
U66 gracefulDrain=PASS grace=30s preStop=5s drainBudget=25s)。 - preStop 必须覆盖摘除传播窗口:
preStopCoversPropagation(5, 3)为真;传播窗口 10s 时 5s preStop 覆盖不住(assertFalse)。
反例:preStop 时间 > 宽限期时,preStop 本身被打断——应用连收到 SIGTERM 的机会都没有。另一个反例:preStop 太短而传播窗口长,新请求仍在到达,SIGTERM 后应用立即退出,在途请求 RST。
观测与排障
三类证据
- events:
kubectl describe pod底部事件——调度失败、拉镜像失败、探针失败都在这里。 - conditions:
PodScheduled/Initialized/ContainersReady/Ready四个条件,逐层排。 - 日志:容器 stdout/stderr;探针失败的 HTTP 状态码也会进 kubelet 日志。
常见误读
Running≠Ready:容器跑着但探针失败。Ready≠ 业务可用:见前文懒加载反例。Available(Deployment 条件)基于minReadySeconds+ Ready;它只说明"稳定了就绪"这段时间,不说明依赖健康。- CrashLoopBackOff 的
BackOff是指数退避,不是"重启间隔等于 backoff"。
本页证明边界与受阻
| 已验证(本地实跑) | 受阻(无集群) | 明确不纳入 |
|---|---|---|
| kubectl kustomize 渲染(真实客户端)、YAML 契约断言、探针时间预算算术、滚动发布状态机推演、停机时序推演、QoS 分类 | apply/rollout/probe 真实判定/eviction/PVC 绑定 | Ingress Controller、Service Mesh、NetworkPolicy 运行时 |
| 清单卫生(无真实凭据) | Secret 的 etcd 加密与 RBAC 实际效果 | KMS/External Secrets 集成 |
| — | 集群级故障(节点宕机、控制面不可用) | Helm 等模板体系 |
本页能证明"清单结构与配置算术正确、控制器语义理解正确";不能证明"在你的集群里发布不会出问题"。 集群层验证需要真实集群(本票受阻,登记于证据 JSON)。
实验读法
python3 labs/k8s-delivery/run.pyManifestContractTest(10):kubectl 渲染、overlay 覆盖、探针预算、停机预算、QoS、对象集合、Secret 卫生、滚动推演三组。- 清单:
manifests/base/(Deployment/Service/ConfigMap/Secret/PDB/StatefulSet/headless Service)、manifests/overlays/prod/(namePrefix/replicas/patch)。 - 断言优先看"渲染后的对象结构与推演数字",不看 YAML 文本——YAML 是输入,渲染结果才是集群会看到的东西。
闭卷验收
本页 27 道闭卷题收进了下方自测面板:先回忆,再展开参考答案与评分点,最后给三档自评。