上一章结束时,TaskBoard 镜像已经进入 kind 节点,Namespace 仍然存在,但那个名为 taskboard-pod 的裸 Pod 已经被删除,而且没有回来。这不是 Kubernetes 失灵了,恰恰是 API 按声明做事:当 Pod 对象本身都不存在时,系统里也就没有“它应该继续存在”这条声明。
这会暴露裸 Pod 的根本限制。它能表达“一组容器怎样一起运行”,却不能表达“这个应用至少要有两个可用实例”“某个实例消失后要补一个”“版本变化时应逐步替换,不能同时停掉全部实例”。如果我们继续手工创建 Pod,值班人员就必须一直盯着实例数量,并在节点维护、进程故障或误删之后手工补建。这种做法把可用性寄托在人的反应速度上。
本章要解决的问题因此很具体:把 TaskBoard 从一次性实例提升为一个被持续维护的工作负载。我们会创建两个由 Deployment 管理的副本,沿着 Deployment、ReplicaSet、Pod 三层对象读取控制关系,再故意删除其中一个 Pod,验证控制器是否真的把实际数量恢复到期望数量。
完成本章时,项目应处于下面的状态:Deployment 的期望副本数为 2,当前有两个 Ready Pod;其中任意一个 Pod 被删除后都会出现一个新 UID 的替代实例;Service、ConfigMap 和 Redis 还没有创建。下一章会建立稳定网络入口,因此本章只解决“实例怎样被持续维护”,暂时不解决“调用方怎样找到这些实例”。
章节之间可能隔了一段时间,也可能已经打开了新的终端。Shell 变量和当前工作目录不会随课程文字自动恢复:PATH 决定调用哪一个 kubectl,KUBECONFIG 决定连接哪一个 API Server,相对路径 k8s/... 则从当前工作目录解析。三者只要有一个错误,后面的命令就可能找不到文件,或者发往错误的 context。
下面的恢复块只校验课程目录归属、设置当前 Shell、核对 context 并进入 TaskBoard 项目根目录,不会创建或修改 Kubernetes 对象。cmp -s 要求课程标记逐字节一致;case 避免重复把同一个 bin 目录加入 PATH;pwd -P 打印解析后的项目路径。预期 context 精确为 kind-welearn-course,项目路径以 welearn-kubernetes-course/taskboard 结尾。理解这四个边界后再执行:
COURSE_ROOT="$HOME/welearn-kubernetes-course"
if ! { [ -d "$COURSE_ROOT" ] && \
printf 'welearn-kubernetes-course\n' | cmp -s - "$COURSE_ROOT/.course-owned"; }; then
echo '停止:课程目录不存在或归属标记不匹配' >&2
exit 1
fi
case ":$PATH:" in
*":$COURSE_ROOT/bin:"*) ;;
*) export PATH="$COURSE_ROOT/bin:$PATH" ;;
esac
export KUBECONFIG="$COURSE_ROOT/welearn-course.kubeconfig"
COURSE_CONTEXT="$(kubectl config current-context 2>/dev/null)" || { echo '停止:无法读取课程 context' >&2; exit 1; }
if [ "$COURSE_CONTEXT" != 'kind-welearn-course' ]; then
echo "停止:当前 context 不是 kind-welearn-course,而是 $COURSE_CONTEXT" >&2
exit 1
fi
CLUSTER_NAME='welearn-course'
NODE_NAME='welearn-course-control-plane'
CLUSTER_LABEL='io.x-k8s.kind.cluster=welearn-course'
CLUSTER_MARKER="$COURSE_ROOT/.kind-cluster-owned"
KIND_CLUSTERS="$(kind get clusters 2>&1)" || { echo '停止:无法读取 kind 集群列表' >&2; exit 1; }
printf '%s\n' "$KIND_CLUSTERS" | grep --fixed-strings --line-regexp --quiet "$CLUSTER_NAME" || { echo '停止:课程 kind 集群不存在' >&2; exit 1; }
LABEL_IDS="$(docker ps -aq --no-trunc --filter "label=$CLUSTER_LABEL" 2>&1)" || { echo '停止:无法按 kind 标签读取节点容器' >&2; exit 1; }
NAME_IDS="$(docker ps -aq --no-trunc --filter "name=$NODE_NAME" 2>&1)" || { echo '停止:无法按名称读取节点容器' >&2; exit 1; }
LABEL_COUNT="$(printf '%s\n' "$LABEL_IDS" | awk 'NF { count += 1 } END { print count + 0 }')"
NAME_COUNT="$(printf '%s\n' "$NAME_IDS" | awk 'NF { count += 1 } END { print count + 0 }')"
if [ "$LABEL_COUNT" -ne 1 ] || [ "$NAME_COUNT" -ne 1 ] || [ "$LABEL_IDS" != "$NAME_IDS" ]; then
echo '停止:课程节点容器的名称、标签或数量不一致' >&2
exit 1
fi
COURSE_NODE_ID="$LABEL_IDS"
case "$COURSE_NODE_ID" in
*[!0-9a-f]*|'') echo '停止:课程节点容器 ID 格式无效' >&2; exit 1 ;;
esac
if [ "${#COURSE_NODE_ID}" -ne 64 ]; then
echo '停止:课程节点容器 ID 不是完整 64 位' >&2
exit 1
fi
printf 'cluster=%s\nnode=%s\nnode_id=%s\nlabel=%s\n' \
"$CLUSTER_NAME" "$NODE_NAME" "$COURSE_NODE_ID" "$CLUSTER_LABEL" | \
cmp -s - "$CLUSTER_MARKER" || { echo '停止:当前节点与集群归属账本不匹配' >&2; exit 1; }
NODE_FACTS="$(docker inspect --format '{{.Id}}|{{.Name}}|{{index .Config.Labels "io.x-k8s.kind.cluster"}}' "$COURSE_NODE_ID" 2>&1)" || { echo '停止:无法检查课程节点身份' >&2; exit 1; }
if [ "$NODE_FACTS" != "$COURSE_NODE_ID|/$NODE_NAME|$CLUSTER_NAME" ]; then
echo '停止:课程节点不可变身份校验失败' >&2
exit 1
fi
cd "$COURSE_ROOT/taskboard" || { echo '停止:TaskBoard 项目目录不存在' >&2; exit 1; }
printf 'context=%s\nproject=%s\n' "$COURSE_CONTEXT" "$(pwd -P)"
printf 'cluster_identity=verified node_id_prefix=%.12s\n' "$COURSE_NODE_ID"context=kind-welearn-course
project=<用户目录>/welearn-kubernetes-course/taskboard
cluster_identity=verified node_id_prefix=<动态 12 位>用户目录和 ID 前缀是动态值;稳定条件是 context 完全匹配、物理路径落在课程项目目录,并显示 cluster_identity=verified。这一结果只有在 kind 名称、Docker 标签、容器名、完整 ID、inspect 事实和四行账本全部一致后才出现。若失败,不要用 kubectl config use-context 或改 marker 猜测修复,应回到第 2 章核对独立 kubeconfig 与集群归属。
先把时间线理清楚,会更容易理解为什么 Kubernetes 不直接把所有能力都塞进 Pod。
最早需要解决的是“某个容器实例消失后怎么办”。Kubernetes 的旧 API ReplicationController 会持续保证指定数量的 Pod 存在。官方文档把它类比为跨节点的进程监督器:节点上的 kubelet 负责单个容器的重启,ReplicationController 负责跨 Pod、跨节点维护副本数量。即使只运行一个实例,控制器也比裸 Pod 多了一层长期承诺。
但“保持 N 个相同副本”和“把应用从版本 A 安全更新到版本 B”其实是两个不同问题。前者只需比较当前副本数与期望副本数;后者还要同时管理新旧两组副本、更新节奏、暂停、失败判断与回滚历史。早期 ReplicationController 的滚动更新做法,需要建立新旧两个控制器,再一边给新控制器加一个副本、一边给旧控制器减一个副本。这个过程可以工作,却把发布编排暴露给了客户端。
ReplicaSet 是 ReplicationController 的后继者。它仍然负责“保持一组副本”,但支持集合式标签选择器。Deployment 又位于 ReplicaSet 之上,把版本更新变成服务端的声明式控制过程。Kubernetes 官方在 2018 年介绍核心工作负载 API 稳定化时,已经明确把 Deployment 定位为管理无状态应用的推荐方式;今天的官方文档也继续建议:通常不要直接操作 ReplicaSet,而是让 Deployment 自动创建和管理它。
所以这不是为了增加对象数量而人为分层。每一层只回答一个问题:
这种分层还解释了一个常见疑问:为什么 Deployment 不在 Pod 消失后“复活同一个 Pod”?Kubernetes 并不尝试修复已经消失的对象身份,而是创建满足同一模板的新实例。应用角色保持不变,Pod 名称、UID、IP 和启动时间都会变化。对无状态应用来说,这种可替换性正是控制器能够工作的前提。
相关演化可以在 Kubernetes 官方的 ReplicationController 文档、ReplicaSet 文档 和 核心工作负载 API GA 说明 中核对。
如果把 Deployment 只理解成“能创建多个 Pod 的 YAML”,后面的自愈、发布和扩缩容都会显得像黑箱。更准确的模型是:我们把期望状态写入 API Server,控制器持续观察实际状态,发现差异后发出操作,直到差异缩小。
以本章的两个副本为例,控制循环可以拆成下面几步:
这里没有一个中心脚本从头阻塞执行到尾。对象通过 API 状态互相衔接,各控制器以最终一致的方式推进。因此,kubectl apply 返回成功只表示对象被 API Server 接收,不表示后续的调度、拉取镜像、启动应用和通过 readiness 已经全部完成。我们必须继续读取 status、condition 和事件,才能知道控制循环走到了哪里。
“最终一致”也意味着你可能看到短暂的中间态。例如删除一个 Pod 后,列表中可能同时出现一个 Terminating Pod 和一个 ContainerCreating Pod;Deployment 的 READY 可能先从 2/2 变成 1/2,再恢复为 2/2。这些不是异常,而是控制器正在消除差异留下的证据。真正需要警惕的是中间态长时间不收敛。
判断不收敛时,可以按责任边界排查。没有 ReplicaSet,先看 Deployment 的 condition 和事件;ReplicaSet 已存在但 Pod 数不足,检查配额、准入策略和控制器事件;Pod 为 Pending,检查调度事件;Pod 已调度但不 Ready,再检查容器状态、日志和探针。不要一看到 READY 不是目标值就立刻删除所有对象,那会连同证据一起删除。
Deployment 不直接逐个维护 Pod。它根据 Pod 模板创建 ReplicaSet,ReplicaSet 再保证匹配选择器的 Pod 数量。对象关系可以读成:
Deployment/taskboard
└── ReplicaSet/taskboard-<模板哈希>
├── Pod/taskboard-<模板哈希>-<随机后缀>
└── Pod/taskboard-<模板哈希>-<随机后缀>
模板内容变化时,Deployment 会创建带新哈希的 ReplicaSet,并按更新策略在新旧 ReplicaSet 之间迁移副本。Pod 名称后缀、UID 和 IP 都属于动态身份,不应写进调用方配置。
Deployment 的 spec.selector 定义控制器要匹配的目标 Pod 集合,不代表对象所有权,创建后也不可随意更改。selector.matchLabels 必须与 template.metadata.labels 匹配,否则 API Server 会拒绝清单,或让控制器无法把模板创建的 Pod 纳入目标集合。真正的所有权由 metadata.ownerReferences 记录。
第一次接触控制器时,很容易把“标签匹配”和“对象归属”当成同一个概念。它们有关联,却承担不同职责。
标签是对象上的普通键值元数据。本章给 Pod 模板添加 app=taskboard,ReplicaSet 用选择器找到满足这个条件的 Pod。选择器回答的是“哪些对象属于我的候选集合”。它不会自动保证标签在整个 Namespace 中唯一,也不会表达创建顺序。
真正的控制器归属记录在 metadata.ownerReferences。Deployment 创建 ReplicaSet 后,ReplicaSet 的 ownerReferences 会指向 Deployment;ReplicaSet 创建 Pod 后,Pod 的 ownerReferences 会指向 ReplicaSet。垃圾回收器会读取这些引用,在删除父对象时按传播策略处理子对象。你在 kubectl describe 输出里看到的 Controlled By,就是这条关系的人类可读形式。
ReplicaSet 还存在“收养”行为:一个没有控制器 ownerReference、但标签恰好匹配选择器的 Pod,可能被 ReplicaSet 纳入管理。这也是为什么生产清单应使用足够具体、不会重叠的标签组合。如果两个控制器的选择范围混在一起,后果不只是查询结果难看,还可能发生副本计数与归属混乱。
本课程为了保持主线清晰,只用 app=taskboard。真实项目通常会组合 app.kubernetes.io/name、app.kubernetes.io/instance、app.kubernetes.io/component 等标签,让“应用名称”“某次部署实例”“组件角色”分别可筛选。设计标签时要先问清楚谁会拿它做选择器,因为控制器选择器一旦创建通常不能随意变更。
我们稍后会用三种证据确认控制关系:
创建完成后,我们还会读取 ownerReferences。输出中的直接所有者应是 ReplicaSet,而不是 Deployment;这会把上面的概念模型变成可查询证据。

请在项目根目录新建 k8s/deployment.yaml,保存下面的完整内容。本章先把配置直接写进 env,专注理解 Deployment;配置拆分会在后续章节处理。
apiVersion: apps/v1
kind: Deployment
metadata:
name: taskboard
namespace: taskboard
labels:
app: taskboard
spec:
replicas: 2
revisionHistoryLimit: 3
strategy:
type: RollingUpdate
rollingUpdate:
maxUnavailable: 0
maxSurge
这份清单同时处理了五类运行约束。
replicas: 2 是控制器持续维护的期望数量。maxUnavailable: 0 表示更新期间不主动减少可用副本,maxSurge: 1 允许临时多创建一个新副本。它们需要结合资源余量理解:若节点没有空间容纳额外 Pod,更新也会等待。
调度器依据 requests 判断节点是否放得下 Pod;运行时用 limits 约束容器最多可用的资源。CPU 的 50m 表示 0.05 个 CPU 核心,内存的 32Mi 表示 32 MiB。请求不是预留一台独占机器,而是调度与资源治理的输入。
startup probe 在慢启动阶段保护另外两类探针,成功后才交棒。readiness probe 失败会让 Pod 退出可接收流量的集合,但不会重启容器。liveness probe 连续失败才会触发 kubelet 重启容器。把三者全部指向同一个含义不清的接口,会掩盖故障语义;这里分别使用 /healthz 与 /readyz。
Pod 级 runAsUser 和 runAsGroup 与镜像中的 10001 对齐。容器不能提升权限、默认根文件系统只读,并丢弃所有 Linux capabilities。应用仍需要 /tmp 放置就绪标记,因此用 emptyDir 提供唯一可写目录;它随 Pod 消失,不是持久化存储。
一份长 YAML 最容易让人产生“先复制,能跑再说”的冲动。这样虽然可能得到 Running,却很难在失败时知道哪一层负责。更稳妥的读法是按 API 对象、控制策略、Pod 模板、容器约束四层向内收缩。
最外层的 apiVersion 和 kind 共同确定 API 结构。apps/v1 的 Deployment 是稳定 API;metadata.name 与 namespace 确定对象地址,也就是后续命令里的 deployment/taskboard 和 taskboard Namespace。metadata.labels 只是在 Deployment 对象自身上打标签,不负责选择 Pod。spec.selector 定义要匹配的目标集合,metadata.ownerReferences 才记录 Deployment、ReplicaSet 与 Pod 之间的所有权链。
spec.replicas 写的是期望数量,不是“创建命令要循环两次”。控制器每次调谐都会重新对账。revisionHistoryLimit 为 3,表示控制器在清理历史时最多保留若干旧 ReplicaSet,供后续回滚使用。它不等于保留三份正在运行的应用,也不等于保存镜像;镜像仍需要仓库或节点镜像存储提供。
spec.strategy 描述模板改变时怎样迁移。maxUnavailable 和 maxSurge 可以写整数,也可以写百分比。本章用整数是为了让两个副本时的行为容易观察:更新期间允许最多出现 3 个 Pod,但可用副本不能因为发布动作主动低于 2。这里的“可用”不是单看 Running,而与 readiness 和 minReadySeconds 等状态有关。第八章会把这条约束放进真实发布中验证。
spec.template 是一份嵌套 Pod 模板。它没有自己的 apiVersion 和 kind,因为它不是现在就独立提交的 Pod 对象,而是 ReplicaSet 以后创建 Pod 时使用的蓝图。只要模板中的字段变化,Deployment 就会把它视为一个新 revision。修改 Deployment 自己的普通标签不一定触发发布;修改 template.metadata.labels、镜像、环境变量、探针或安全上下文都会改变模板。
容器的 imagePullPolicy 为 IfNotPresent,与前面把镜像加载进 kind 节点的方式配套。它表示节点已有同名镜像时直接使用,没有时才尝试拉取。这个策略不适合把同一个可变标签反复覆盖成不同内容,因为节点可能继续使用缓存。生产发布应优先使用不可变版本标签,要求更强时再使用镜像摘要。
resources.requests 与 limits 也不是装饰字段。调度器主要根据 request 做放置决策;HPA 的 CPU 利用率同样会用 CPU request 做分母。limit 则约束运行期上限:CPU 超出后通常被节流,内存超过限制可能触发 OOM 终止。第八章会看到,错误设置 request 会直接改变 HPA 对“50% 利用率”的解释。
三个探针的时序要从 kubelet 的角度理解。startupProbe 成功前,kubelet 不会执行 liveness 和 readiness 的失败判定;它给慢启动应用一个单独预算。本清单每 2 秒检查一次,最多允许 12 次失败,粗略上给出约 24 秒启动窗口。startup 成功后,readiness 决定端点是否可接流量,liveness 才负责判断是否重启。探针路径返回什么状态码,由应用定义;Kubernetes 不知道“TaskBoard 业务正常”具体是什么意思。
securityContext 分 Pod 级和容器级。Pod 级字段提供默认运行身份和 seccomp;容器级字段进一步关闭提权、丢弃 capabilities 并把根文件系统设为只读。volumeMounts 只负责把名为 tmp 的卷挂到 /tmp,volumes 才定义这个卷是 emptyDir。两处名称必须对应,否则 API 校验会拒绝或 Pod 无法创建。
读完后,我们可以先做一次静态预判:创建成功应出现一个 Deployment、一个 ReplicaSet、两个 Pod;Pod 应以 UID 10001 运行;根文件系统只读但 /tmp 可写;容器 Ready 之前不会成为将来 Service 的就绪后端。后续每条命令都在验证这些推论,而不是只追求一行 successfully rolled out。
应用清单前,taskboard Namespace 和节点内的 taskboard:1.0.0 镜像应继续存在。kubectl apply 会创建 Deployment,随后控制器异步创建 ReplicaSet 与 Pod。
下面提交清单。预期 API Server 返回 created。
kubectl apply --filename k8s/deployment.yamldeployment.apps/taskboard createdcreated 只证明对象已保存,还不能证明两个副本都通过探针。下面等待 Deployment 完成当前 rollout,预期两个新副本都变为 Available 后返回成功。
kubectl rollout status \
deployment/taskboard \
--namespace taskboard \
--timeout=120sWaiting for deployment "taskboard" rollout to finish: 0 of 2 updated replicas are available...
Waiting for deployment "taskboard" rollout to finish: 1 of 2 updated replicas are available...
deployment "taskboard" successfully rolled out中间提示的次数取决于启动速度,最后一行才是完成条件。
三行输出对应三个状态切片。第一行的 0 of 2 表示新模板已经被控制器接受,但还没有新副本满足 Available;第二行说明其中一个副本已完成启动并通过就绪判断;最后一行才说明当前 revision 的两个副本都满足发布条件。等待过程只是在观察 Deployment condition,它没有替控制器执行创建动作。
如果命令超时,先保留现场并依次读取三层证据:
不要通过重复 apply 来“催促”控制器。相同清单不会让一个拉取失败的镜像变正确,也不会增加节点容量。重复提交只会让时间线更难读。
一次列出三层对象,可以避免只看 Pod 而忽略它由谁管理。预期 Deployment 为 2/2,只有一个当前 ReplicaSet,且它的 DESIRED、CURRENT、READY 都是 2;两个 Pod 均为 1/1 Running。
kubectl get deployment,replicaset,pod \
--namespace taskboard \
--selector app=taskboard \
--output wideNAME READY UP-TO-DATE AVAILABLE AGE CONTAINERS IMAGES
deployment.apps/taskboard 2/2 2 2 <动态时长> api taskboard:1.0.0
NAME DESIRED CURRENT READY AGE CONTAINERS IMAGES
replicaset.apps/taskboard-<模板哈希> 2 2 2 <动态时长> api taskboard:1.0.0
NAME READY STATUS RESTARTS AGE IP NODE
pod/taskboard-<模板哈希>-<随机后缀 A> 1/1 Running 0 <动态时长> <动态 Pod IP> welearn-course-control-plane
pod/taskboard-<模板哈希>-<随机后缀 B> 1/1 Running 0 <动态时长> <动态 Pod IP> welearn-course-control-planeReplicaSet 的哈希由完整 Pod 模板计算;哪怕只改变一个环境变量或安全字段,哈希也可能变化。Pod 随机后缀、AGE 和 IP 同样是动态值。稳定结论是三层对象的归属关系和副本统计一致。
这里每一列都回答不同问题。Deployment 的 READY 2/2 表示就绪副本与期望副本相等;UP-TO-DATE 为 2 表示两个 Pod 都来自当前模板;AVAILABLE 为 2 表示它们满足可用条件。ReplicaSet 的 DESIRED、CURRENT、READY 分别表示控制器想要多少、已经观察到多少、其中多少通过就绪。Pod 的 1/1 是容器就绪数,不是副本数;STATUS Running 也不能替代 READY,因为进程可以运行却暂时不适合接流量。
如果 Deployment 为 2/2,而 Pod 列表出现多于两个对象,不必立刻判定泄漏。滚动发布或 Pod 终止阶段可能让旧对象短暂保留。结合 ownerReferences、deletionTimestamp、ReplicaSet 的 DESIRED 与 Deployment strategy 才能判断它是否属于正常中间态。
现在对象已经存在,可以进一步核对 ownerReferences。下面的只读查询不会改变资源;jsonpath 依次读取 Pod 名、直接所有者 kind 和所有者名:
kubectl get pods --namespace taskboard --selector app=taskboard --output jsonpath='{range .items[*]}{.metadata.name}{"\t"}{.metadata.ownerReferences[0].kind}{"/"}{.metadata.ownerReferences[0].name}{"\n"}{end}'taskboard-<模板哈希>-<随机后缀 A> ReplicaSet/taskboard-<模板哈希>
taskboard-<模板哈希>-<随机后缀 B> ReplicaSet/taskboard-<模板哈希>第一列是动态 Pod 名。第二列稳定地显示 ReplicaSet/,说明 Pod 的直接控制者是 ReplicaSet。若这里为空,说明对象没有控制器所有者;若所有者不是预期 ReplicaSet,应暂停后续删除实验,先用 kubectl describe pod 查看 Controlled By 与 Events,避免对不属于本 Deployment 的 Pod 做判断。
名称适合人类查询,UID 才是 API Server 为每个对象实例分配的唯一身份。ReplicaSet 补建的是承担同一角色的新 Pod,不是让被删对象复活,因此新实例必须有不同 UID。
这一实验必须在同一个终端、同一个 Shell 会话中从头执行到尾。OLD_POD 和 OLD_UID 只是当前 Shell 的变量;如果中途换终端,变量会变成空值。空字符串传给 grep 时可能得到与预期无关的结果,所以脚本在删除前用 test -n 明确拒绝空变量,避免把“没有保存旧 UID”误判成“旧 UID 已消失”。
下面先列出删除前的两个实例,再选取列表中的第一个 Pod,读取并校验名称与 UID;随后删除它,等待 Deployment 恢复,最后在同一块中验证副本数、旧 UID 消失和新实例列表。所有命令共享同一组 Shell 变量:
kubectl get pods --namespace taskboard --selector app=taskboard --output custom-columns='NAME:.metadata.name,UID:.metadata.uid,READY:.status.containerStatuses[0].ready'
OLD_POD="$(kubectl get pods --namespace taskboard --selector app=taskboard --output jsonpath='{.items[0].metadata.name}')"
test -n "$OLD_POD" || { echo '没有选到待删除 Pod' >&2; exit 1; }
OLD_UID="$(kubectl get pod "$OLD_POD
NAME UID READY
taskboard-<模板哈希>-<随机后缀 A> <Pod UID A> true
taskboard-<模板哈希>-<随机后缀 B> <Pod UID B> true
deleting=taskboard-<模板哈希>-<随机后缀 A> uid=<Pod UID A>
pod "taskboard-<模板哈希>-<随机后缀 A>" deleted
Waiting for deployment "taskboard" rollout to finish: 1 of 2 updated replicas are available...
deployment "taskboard" successfully rolled out
NAME READY DESIRED
taskboard 2 2
old UID is gone
replacement UID differs=true
NAME UID READY
taskboard-<模板哈希>-<新随机后缀> <新 Pod UID> true
taskboard-<模板哈希>-<随机后缀 B> <Pod UID B> true第一张表是删除前快照。test -n 没有输出,说明 OLD_POD 与 OLD_UID 都是非空值;任意一项为空时脚本会在删除前退出。deleted 只表示 API 删除已完成,补建动作来自 ReplicaSet。rollout 最后一行表示两个副本重新 Available;中间等待行可能因恢复速度而不出现。
Deployment 表中的 READY=2、DESIRED=2 是数量收敛证据。CURRENT_UIDS 的赋值先检查 kubectl 是否成功,空列表也会显式失败,避免查询失败被误判为“不存在”。grep 的 --fixed-strings 把 UID 当普通文本而不是正则表达式,--line-regexp 要求整行完全相等,--quiet 只用退出码表达是否找到;如果旧 UID 仍在,脚本现在会以非零状态停止,而不是只打印一句提示。随后又要求恢复后的 UID 非空并与 OLD_UID 不同,所以两条布尔输出都是真正通过断言后的结果。最后一张表显示一个幸存 UID 与一个全新 UID,证明角色恢复但对象身份发生变化。
如果看到 old UID still exists,先确认删除是否仍在终止阶段,再读取 deletionTimestamp;不要重新给 OLD_UID 赋值,否则会失去前后比较基准。如果 rollout 超时,保留当前变量和资源,按 Pod Events、镜像、调度与探针顺序排查。
下面是一组完成同类自愈动作时留下的真实执行记录。那次执行的 Pod 模板已包含后续章节的配置引用,因此哈希 68b79995f8 不会与本章直接写 env 的模板相同;UID 对比展示的是同一条控制器机制。
删除前:
taskboard-68b79995f8-qjzf4 53f2e5f1-7091-4d32-80b6-28c0892a4b5f
taskboard-68b79995f8-rm9tv 553c1c31-fcb8-49bb-ab86-a9eb7d54bd6a
恢复后:
taskboard-68b79995f8-52tvv 184e339d-776d-45a8-ae25-8d74f6480bff
taskboard-68b79995f8-rm9tv 553c1c31-fcb8-49bb-ab86-a9eb7d54bd6a记录中,幸存的 rm9tv 保留原 UID;被删的 qjzf4 则由新名称 52tvv 和新 UID 取代。不要把这些动态名称复制进命令,实际验收应始终使用副本数量、旧 UID 消失和新 UID 出现三条标准。

这里保留 Deployment、ReplicaSet、两个 Pod、Namespace、镜像和集群,供后续 Service、配置与存储章节继续使用。
看到 Pod 被补回来后,最容易得出过大的结论:“Deployment 保证应用永远可用。”本章实验真正证明的范围要窄得多:当 ReplicaSet 观察到匹配的活动 Pod 数量低于期望值时,它会创建新 Pod;当新 Pod 能被调度、镜像可用且探针通过时,副本状态会重新收敛。
这套机制不会自动修复以下问题:
还要区分容器重启与 Pod 替换。容器进程崩溃时,通常由同一个 Pod 所在节点上的 kubelet 按 restartPolicy 重启容器,Pod UID 不变,RESTARTS 增加。我们本章删除了整个 Pod 对象,ReplicaSet 创建的是新 Pod,UID 必然变化,RESTARTS 往往从 0 重新开始。诊断时先看 UID 和容器 restartCount,才能判断是哪一级自愈发生了。
在生产系统里,两个副本也不自动等于高可用。可用性还取决于节点和可用区分布、PodDisruptionBudget、依赖服务、入口层、发布策略与容量。PodDisruptionBudget(PDB)用于限制节点维护等自愿中断期间允许同时不可用的 Pod 数量;SLO 是团队对一段时间内可用性或延迟等服务水平目标的量化约定。Deployment 提供的是工作负载控制基础,不是对业务 SLO 的完整承诺。
下一章的 Service 会用标签 app=taskboard 选择这两个 Pod。进入下一章前,请确认下面四条同时成立:
下面的命令把最关键的状态集中到一次只读检查里。第一条看 Deployment,第二条看标签、就绪与所有者。它们不要求动态名称相同,只要求数量和关系一致。
kubectl get deployment taskboard -n taskboard
kubectl get pods -n taskboard -l app=taskboard --output custom-columns='NAME:.metadata.name,READY:.status.containerStatuses[0].ready,OWNER:.metadata.ownerReferences[0].name'NAME READY UP-TO-DATE AVAILABLE AGE
taskboard 2/2 2 2 <动态时间>
NAME READY OWNER
taskboard-<模板哈希>-<随机后缀 A> true taskboard-<模板哈希>
taskboard-<模板哈希>-<随机后缀 B> true taskboard-<模板哈希>动态时间、哈希和后缀不需要与示例相同。若 READY 不是 2/2,先不要创建 Service,否则网络层的实验会混入工作负载故障。若 Pod 标签缺少 app=taskboard,Service 选择器将找不到后端;若所有者为空,则这些 Pod 不是本章预期的受控副本。
到这里,本章问题已经闭环:我们从“裸 Pod 删除后无人负责”走到“控制器持续维护两个可替换实例”。下一章将面对随之而来的新问题——实例越是可替换,名称和 IP 越不稳定,调用方越不能直接依赖它们。