上一章结束时,TaskBoard 的普通配置来自 ConfigMap,口令来自 Secret,两个 Pod 都通过 readiness 并位于 Service 的就绪后端中。Deployment 仍运行 taskboard:1.0.0,期望副本数是 2,资源 request 为每个容器 50m CPU。现在我们有了执行发布和容量控制所需的两个基础信号:哪个副本真正可用,以及每个副本声明了多少基准资源。
本章处理两个会直接影响可用性的动作。第一个是版本变化:怎样从 1.0.0 换到 2.0.0,又不在某一时刻把两个旧副本同时停掉?第二个是负载变化:怎样让控制器根据 CPU 指标调整副本,而不是靠人盯图表手工执行 scale?
我们会先滚动发布 2.0.0,再故意发布不存在的 9.9.9。错误版本会停在 ImagePullBackOff,旧的两个可用副本继续服务;随后通过 Deployment revision 回滚。恢复稳定版本后,我们安装 Metrics Server,创建 autoscaling/v2 HPA,用受控 CPU 负载把副本从 2 扩到上限 5,再观察指标下降和稳定窗口之后回到 2。
完成本章时,TaskBoard 应稳定运行 2.0.0,HPA 保留且副本回到 minReplicas=2,临时 load-generator 已删除。Metrics Server 会留在 kube-system,供后续验收使用。下一章会增加 Redis、StatefulSet、PVC 与 Job,因此本章只处理无状态 API 的发布和横向容量。
本章既会从相对目录 app 构建镜像,也会应用 k8s 下的清单。若从新终端直接执行,错误的工作目录会让 Docker 读取另一份构建上下文,错误的 kubeconfig 则可能把发布发往另一套 API。发布前先恢复会话,本身就是变更管理的一部分。
下面只设置当前 Shell、读取 context 并进入 TaskBoard 项目根目录,不会构建镜像或修改对象。case 让重复执行保持幂等,其他检查失败都会在任何发布动作前停止:
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
[ "${#COURSE_NODE_ID}" -eq 64 ] || { echo '停止:课程节点容器 ID 不是完整 64 位' >&2; exit 1; }
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; }
[ "$NODE_FACTS" = "$COURSE_NODE_ID|/$NODE_NAME|$CLUSTER_NAME" ] || { echo '停止:课程节点不可变身份校验失败' >&2; exit 1; }
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 位>这里没有用 cd app,因为后面既要引用 app 又要引用 k8s;统一停在二者共同的项目根目录,可以让所有相对路径保持一致。第三行还证明当前控制平面容器与第 2 章记录的完整 ID 相同,而不只是复用了同一个集群名。
最直接的版本更新办法是先删除旧实例,再创建新实例。它的问题也最直观:从旧实例停止到新实例真正可用之间存在空窗。即使创建动作很快,镜像拉取、调度、应用预热和依赖检查都需要时间;新版本如果有错误,空窗还可能变成长时间故障。
另一个极端是一次性创建全部新实例,确认后再删除全部旧实例。它减少停机风险,却要求瞬时拥有接近两倍容量,也会在切换时同时改变大量连接与缓存。对于大规模工作负载,这种峰值未必能被调度。
滚动发布把替换过程拆成若干小步。Deployment 为新 Pod 模板创建一个新 ReplicaSet,在新副本达到可用条件后逐步缩小旧 ReplicaSet。maxSurge 限制临时能多出多少实例,maxUnavailable 限制发布动作允许少掉多少可用实例。两者一起决定速度、容量峰值和风险暴露。
Deployment 之所以位于 ReplicaSet 之上,正是因为“维持一组相同副本”和“协调两组不同模板交接”不是同一个控制问题。旧 ReplicationController 时代的滚动更新需要客户端协调两个控制器;Deployment 把这个过程放进服务端控制器,并把历史 revision 保存在它管理的 ReplicaSet 中。回滚不是从备份中恢复某个旧 Pod,而是把 Deployment 的 Pod 模板切回历史 revision,再让控制器重新调谐。
滚动发布不自动保证业务兼容。新旧版本会在一段时间内同时接收流量,因此数据库迁移、消息格式、缓存键和 API 协议必须支持重叠窗口。maxUnavailable=0 也只约束 Deployment 眼里的可用副本;如果 readiness 写得过于宽松,坏版本仍会被计为可用。
这些行为可从 Kubernetes 官方的 Deployment 文档 与 核心工作负载 API GA 说明 中核对。
TaskBoard Deployment 已经配置:
strategy:
type: RollingUpdate
rollingUpdate:
maxUnavailable: 0
maxSurge: 1maxUnavailable: 0 表示发布期间不能主动减少可用副本;maxSurge: 1 允许临时多创建一个新 Pod。对于当前的两个副本,Deployment 会先创建一个新版本 Pod,等它通过 startup 和 readiness probe 后,再缩减旧 ReplicaSet。
滚动发布依赖 readiness 的准确性。如果 /readyz 在应用尚未真正可用时就返回成功,Deployment 仍会把流量交给它。因此,更新策略和健康检查需要一起设计。
把两个参数代入本项目,可以推演出一次典型状态变化。开始时旧 ReplicaSet 为 2、新 ReplicaSet 为 0,总 Pod 数 2、可用数 2。maxSurge=1 允许先把总数增到 3,所以控制器创建第一个新 Pod。它未 Ready 时,可用数仍由两个旧 Pod 提供;它 Ready 后,控制器才可以把一个旧副本缩到 1。随后再创建或推进第二个新副本,最终新 ReplicaSet 为 2、旧 ReplicaSet 为 0。
实际列表可能短暂看到 Terminating Pod,因此对象总数有时看起来超过 desired+maxSurge;终止中的 Pod 对资源占用和统计有自己的时序。我们验收的是控制器最后收敛和可用副本约束,不把某个瞬间必须恰好三行 Pod 当成硬规则。
availableReplicas 也不是单纯等于 Ready Pod 数。Deployment 会结合 readiness 与 minReadySeconds 判断副本是否满足可用条件。本项目没有设置 minReadySeconds,因此新 Pod Ready 后较快进入 Available;生产中若应用容易刚 Ready 就抖动,可以设置最短稳定时间,但它会相应拉长发布。
progressDeadlineSeconds 则用于判断 rollout 是否长期没有进展。它不会自动回滚错误版本;Deployment 会把 Progressing condition 标记为失败,发布系统或操作人员仍要决定暂停、修复或 undo。把“超时提示”和“自动恢复”混为一谈,会在坏版本上留下无人处理的控制循环。

镜像标签是 Pod 模板的一部分。kind 的节点使用独立容器运行时,项目工作目录里构建出的镜像不会自动出现在节点中,所以构建后还要执行 kind load docker-image。
这次仍设置 imagePullPolicy: IfNotPresent。节点已经拥有 taskboard:2.0.0 后,Kubelet 会直接使用它,不会去外部仓库查找同名镜像。
在写入 taskboard:2.0.0 标签前还要确认归属。同名标签只说明 Docker 当前有这个名字,不说明它由课程创建;直接 build 会让标签移动到新镜像,可能覆盖原有制品。本章使用 COURSE_ROOT 下的 .taskboard-2.0.0-owned 保存课程构建镜像的完整 Docker image ID。镜像已存在但 marker 缺失,或 marker 与当前标签解析出的 ID 不同,都立即停止;只有两个 ID 逐字节相同才视为课程的安全恢复。
下面先验证课程根目录和 Docker daemon,再复核第 3 章创建的专属 Buildx builder。八行账本不仅保存名称,还绑定完整 builder 容器 ID、状态卷创建时间与 driver image ID;命令会把 buildx 元数据、Docker 容器、卷和镜像事实全部交叉比较,任何一项变化都停止。只有 builder 身份通过后才检查 taskboard:2.0.0 标签:已有标签用完整 .Id 与 marker 逐字节比较,标签不存在时才通过同一个专属 builder 执行带 --load 的构建。最后把已确认归属的镜像加载进 welearn-course:
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
IMAGE_MARKER="$COURSE_ROOT/.taskboard-2.0.0-owned"
if [ -e "$IMAGE_MARKER"
builder_identity=verified container_id_prefix=<动态 12 位>
[+] Building <动态耗时> FINISHED
=> naming to docker.io/library/taskboard:2.0.0
taskboard:2.0.0 构建完成并写入归属标记 image_id=sha256:<完整镜像 ID>
Image: "taskboard:2.0.0" with ID "<动态镜像 ID>" not yet present on node "welearn-course-control-plane", loading...builder 容器 ID 前缀、镜像 ID 和构建耗时会变化。builder_identity=verified 只会在八行账本、buildx 元数据、完整容器 ID、driver 镜像和状态卷全部匹配后出现;2.0.0 的中间层继续写入课程专属卷,不会进入默认共享 builder。当前隔离练习集群只有一个节点,后续输出表明该节点完成了镜像加载。若迁移到多节点 kind 集群,需要让每个可能承载 Pod 的节点都拥有镜像。
重复执行时不会再次出现构建步骤,而会看到“安全恢复:复用课程镜像”及同一个完整 image ID;随后 kind 可能提示节点已经具有该镜像。第十一章清理镜像时也必须重新解析当前标签 ID,并与 marker 逐字节比较,只有相同才删除;marker 缺失或 ID 不一致时应保留。
FINISHED 和 naming to 说明 Docker 构建成功并创建标签,紧接着的 image_id 是 inspect 读到并写进 marker 的完整值;最后一段明确说镜像此前不在 welearn-course-control-plane,随后执行加载。具体 ID 每次构建可能不同,但“输出 ID、marker 内容和当前标签 ID 三者完全相同”是归属验收条件。
如果 buildx 构建失败,问题仍在制品阶段,不应修改 Deployment;先检查专属 builder、Dockerfile、构建上下文或依赖,不要改回默认 docker build。若构建成功但 kind load 找不到集群,检查 kind get clusters 与 --name。若节点已有同名标签,load 输出可能不同;这也是可变标签容易造成歧义的原因。生产中应把镜像推送到节点可访问的仓库,并优先用不可变标签或 digest,再配合镜像签名和准入策略。
kubectl set image 会修改 Deployment Pod 模板中的镜像字段。模板哈希随之变化,Deployment 创建新 ReplicaSet,并逐步把旧 ReplicaSet 缩到零。已有 Service 不需要修改,它仍按 app=taskboard 选择新旧两个版本的就绪 Pod。
执行发布前,也要把项目中的 k8s/deployment.yaml 镜像更新为 taskboard:2.0.0。这样后续再次应用清单时,不会把线上状态意外改回旧版本。
发起发布后,用 set image 精确修改 deployment/taskboard 中名为 api 的容器镜像。-n taskboard 限定 Namespace;api=taskboard:2.0.0 左侧必须与 containers.name 一致。rollout status 观察当前 revision,超时 90 秒;最后用标签列出新旧 ReplicaSet,验证历史没有被误删。执行命令前同步修改 k8s/deployment.yaml,是为了让文件声明与这次命令式变更保持一致:
kubectl set image deployment/taskboard \
-n taskboard \
api=taskboard:2.0.0
kubectl rollout status deployment/taskboard \
-n taskboard \
--timeout=90s
kubectl get replicaset -n taskboard \
-l app=taskboarddeployment.apps/taskboard image updated
Waiting for deployment "taskboard" rollout to finish: 1 out of 2 new replicas have been updated...
Waiting for deployment "taskboard" rollout to finish: 1 old replicas are pending termination...
deployment "taskboard" successfully rolled out
NAME DESIRED CURRENT READY AGE
taskboard-<旧哈希> 0 0 0 <动态时间>
taskboard-<新哈希> 2 2 2 <动态时间>ReplicaSet 哈希和时间会变化。旧 ReplicaSet 仍被保留,但副本数是零;新 ReplicaSet 承担两个副本。这份历史是快速回滚的基础。
输出中的 image updated 表示 Pod 模板已改变,不表示新版本已经可用。中间两行展示控制器先增加新副本、再等待旧副本终止;最后成功才是 rollout 完成。ReplicaSet 表格中旧哈希的 DESIRED/CURRENT/READY 都归零,新哈希三列都为 2,证明流量后端最终只剩新版本。
如果 rollout 超时,先查看新 ReplicaSet 和 Pod,而不是立即删除旧 ReplicaSet。旧 ReplicaSet 是当前可用性和回滚历史的一部分。Running 但 0/1 通常要检查 readiness;Pending 看调度事件;ImagePullBackOff 看镜像是否加载、名称和仓库凭据。
现在通过 Service 读取应用版本。这样验证的不只是某个 Pod 启动,而是 DNS、Service、EndpointSlice 与新副本的完整用户路径。请求可能命中任意新 Pod,但 rollout 已完成,所以 version 都应为 2.0.0:
kubectl exec -n taskboard toolbox-safe -- \
wget -qO- http://taskboard/api/info{"service":"taskboard","version":"2.0.0","message":"你好,配置已经更新","pod":"<动态 Pod 名>","namespace":"taskboard","redisConfigured":true}Pod 名称是动态值,version 才是本次验收字段。
message 仍来自上一章更新后的 ConfigMap,redisConfigured 仍为 true,说明滚动发布没有丢失外部配置引用;pod 字段变化说明实例被替换;version=2.0.0 才证明流量到达新镜像。若直接访问新 Pod 成功而 Service 仍返回旧版本,应读取所有 EndpointSlice targetRef 与 ReplicaSet 状态,确认旧 Pod 是否仍 Ready 或 rollout 是否真的完成。
现在把镜像改成并不存在的 taskboard:9.9.9。节点找不到镜像,外部仓库也没有这个标签,新 Pod 会进入 ImagePullBackOff。由于 maxUnavailable 是零,Deployment 不会先删除两个可用的 2.0.0 Pod,Service 因而仍能提供稳定版本。
这类失败与应用启动后崩溃不同。ImagePullBackOff 发生在容器启动前,应检查镜像名称、标签、仓库权限和节点可达性,而不是先查应用日志。
状态名也值得拆开。ErrImagePull 表示某次拉取尝试失败;ImagePullBackOff 表示 kubelet 进入退避,在下一次重试前等待。等待时间逐步增加是为了避免持续压垮仓库或网络。因为容器从未启动,kubectl logs 很可能没有 TaskBoard 应用日志,kubectl describe pod 的 Events 才会记录 Failed to pull image、not found 或认证错误。
本项目把 imagePullPolicy 保持为 IfNotPresent。9.9.9 在节点不存在,kubelet 因此尝试拉取并失败。这个实验验证的是错误镜像引用,不是在模拟应用逻辑 bug。若要模拟新进程启动后 readiness 失败,应使用确实存在的镜像并让 /readyz 返回失败,两者的证据路径不同。
发起错误发布,并给状态检查设置 15 秒超时。set image 仍会创建新模板 revision;rollout status 只观察 15 秒,目的是快速取得“未收敛”证据。超时是客户端观察窗口,不会暂停 Deployment,也不会让 kubelet停止重试:
kubectl set image deployment/taskboard \
-n taskboard \
api=taskboard:9.9.9
kubectl rollout status deployment/taskboard \
-n taskboard \
--timeout=15sdeployment.apps/taskboard image updated
Waiting for deployment "taskboard" rollout to finish: 1 out of 2 new replicas have been updated...
error: timed out waiting for the condition超时只说明新版本没有达到期望状态。接下来同时查看 Pod 和 Deployment,确认旧版本是否仍有两个可用副本。
第一行说明错误模板已成为最新期望;第二行表明控制器已创建一个新副本对象;最后的 timed out 是客户端超时。它没有说明旧版本失败,也不能单独证明根因是镜像。下一组 Pod 状态与事件才负责根因定位。
kubectl get pod 展示每个实例的容器状态,get deployment 展示聚合副本数,最后一条仍沿用户路径请求 Service。三条一起执行,是为了同时回答“新版本为何没好”“旧版本还剩多少”“用户还能拿到什么”:
kubectl get pod -n taskboard -l app=taskboard
kubectl get deployment taskboard -n taskboard
kubectl exec -n taskboard toolbox-safe -- \
wget -qO- http://taskboard/api/infoNAME READY STATUS RESTARTS AGE
<稳定版本 Pod A> 1/1 Running 0 <动态时间>
<稳定版本 Pod B> 1/1 Running 0 <动态时间>
<错误版本 Pod> 0/1 ImagePullBackOff 0 <动态时间>
NAME READY UP-TO-DATE AVAILABLE AGE
taskboard 2/2 1 2 <动态时间>
{"service":"taskboard","version":"2.0.0","message":"你好,配置已经更新","pod":"<动态 Pod 名>","namespace":"taskboard","redisConfigured":true}名称和时间会变化。UP-TO-DATE 为 1,表示一个 Pod 使用最新模板;但它没有就绪。AVAILABLE 仍为 2,Service 响应仍是 2.0.0,说明滚动策略保住了旧副本。
Pod 表格中两个 Running 1/1 是稳定 revision,一个 ImagePullBackOff 0/1 是错误 revision。Deployment READY 2/2 和 AVAILABLE 2 说明两个期望可用席位仍由旧副本满足;UP-TO-DATE 1 只统计新模板创建出的一个 Pod,不代表它可用。JSON 的 version=2.0.0 是最终用户路径证据。
这里 total Pod 数为 3,符合 maxSurge=1。控制器不能再创建第二个错误版本 Pod,也不能缩掉旧副本,因为新 Pod 没有达到 Available。若 maxUnavailable 设置得过大,控制器可能先减少旧副本;若 readiness 错误返回成功,坏版本也可能进入 Service。因此,策略参数和探针共同决定保护效果。
错误已经确认,不再让控制器继续拉取不存在的镜像。rollout undo 会把 Deployment 模板恢复到前一个 revision,对应 ReplicaSet 已经保留,无需重新发明 PodSpec。等待错误 ReplicaSet 缩零后,除了查看 Pod,还要读取 Deployment 的实际镜像与副本状态、revision 历史,并通过 Service 请求版本。这样才能证明“对象回滚”“历史可见”和“用户路径恢复”同时成立:
kubectl rollout undo deployment/taskboard -n taskboard
kubectl rollout status deployment/taskboard -n taskboard --timeout=90s
kubectl get pod -n taskboard -l app=taskboard
kubectl get deployment taskboard -n taskboard -o jsonpath='{"image="}{.spec.template.spec.containers[?(@.name=="api")].image}{" updated="}{.status.updatedReplicas}{" ready="}{.status.readyReplicas}{" available="}{.status.availableReplicas}{"\n"}'
kubectl rollout history deployment/taskboard -n taskboard
kubectl exec -n taskboard toolbox-safedeployment.apps/taskboard rolled back
deployment "taskboard" successfully rolled out
NAME READY STATUS RESTARTS AGE
<动态 Pod A> 1/1 Running 0 <动态时间>
<动态 Pod B> 1/1 Running 0 <动态时间>
image=taskboard:2.0.0 updated=2 ready=2 available=2
deployment.apps/taskboard
REVISION CHANGE-CAUSE
<动态 revision> <none>
<回滚后的动态 revision> <none>
{"service":"taskboard","version":"2.0.0","message":"你好,配置已经更新","pod":"<动态 Pod 名>","namespace":"taskboard","redisConfigured":true}不存在的镜像 Pod 已经消失,稳定版本仍是 2.0.0。这次回滚修复了集群状态;项目清单本来就保留 2.0.0,所以声明文件与运行状态仍然一致。
rolled back 表示 Deployment 期望模板已恢复,successfully rolled out 表示控制器完成调谐。两个 1/1 Running Pod 与 updated=2、ready=2、available=2 证明当前模板已经完全接管;image=taskboard:2.0.0 明确排除 9.9.9 仍是期望镜像。rollout history 的编号会随前面重启和发布次数变化,验收的是历史仍可读取。最终 JSON 的 version=2.0.0 才是用户路径恢复证据。
如果错误 set image 之后又进行了其他模板修改,undo 默认回到“上一个 revision”,未必是你心里想的版本。生产回滚前应读取 rollout history,并用 --to-revision 明确目标。数据库和外部状态变更也不会随 Deployment 自动回滚,应用版本回退必须与数据兼容策略一起设计。
HPA 本身不采集 CPU。它通过 metrics.k8s.io API 读取资源指标,这个 API 通常由 Metrics Server 提供。没有指标服务时,HPA 对 CPU 的读取会显示 <unknown>,也不会按 CPU 扩容。
资源指标链路可以从采集端向控制器依次理解:
Metrics Server 是面向自动扩缩容和 kubectl top 的轻量资源指标来源,不是长期监控数据库。它不替代 Prometheus 一类时序系统,也不保存适合容量趋势、告警和审计的长期历史。反过来,安装了 Prometheus 也不会自动让 Resource 类型 HPA 获得 metrics.k8s.io,除非相应适配链路明确配置。
APIService 的 AVAILABLE=True 只说明聚合 API 注册和服务可用。具体 Pod 指标还可能因为 kubelet 连接、证书、地址选择或采样尚未完成而暂时缺失。因此安装后还要执行 kubectl top,把“API 服务存在”和“TaskBoard 已有非空样本”分别验收。
这里固定使用 Metrics Server v0.8.1,避免远程清单随最新版本变化。kind 节点的 kubelet 服务证书不一定包含 Metrics Server 访问所用的地址,因此本节会增加 --kubelet-insecure-tls。
--kubelet-insecure-tls 只用于这个隔离练习集群。生产集群应为 kubelet 配置可验证的服务证书,让 Metrics Server 正常校验身份,不能用跳过校验代替证书治理。
先应用固定版本清单,再给 Metrics Server 容器追加参数。远程 URL 固定到 v0.8.1,不使用随时间变化的 latest 清单。直接在每次重跑时执行 JSON Patch add 会不断追加相同参数,所以命令先读取完整参数列表并做逐行精确匹配:已经存在就保持不变,不存在才追加。追加前还断言第一个容器确实名为 metrics-server,避免上游清单结构变化后修改错误容器。随后等待 kube-system 中的 Deployment 可用,最后读取 v1beta1.metrics.k8s.io APIService:
kubectl apply -f \
https://github.com/kubernetes-sigs/metrics-server/releases/download/v0.8.1/components.yaml
METRICS_CONTAINER="$(kubectl get deployment metrics-server -n kube-system \
-o jsonpath='{.spec.template.spec.containers[0].name}')" || { echo '停止:无法读取 Metrics Server 容器' >&2; exit 1; }
if [ "$METRICS_CONTAINER" != 'metrics-server' ]; then
echo "停止:第一个容器不是 metrics-server,而是 $METRICS_CONTAINER" >&2
exit
serviceaccount/metrics-server created
clusterrole.rbac.authorization.k8s.io/system:metrics-server created
deployment.apps/metrics-server created
apiservice.apiregistration.k8s.io/v1beta1.metrics.k8s.io created
deployment.apps/metrics-server patched
metrics_tls_flag=added
deployment "metrics-server" successfully rolled out
NAME SERVICE AVAILABLE AGE
v1beta1.metrics.k8s.io kube-system/metrics-server True <动态时间>远程清单会创建多种资源,输出行数可能更多。验收点是 APIService 的 AVAILABLE 为 True。
前几行代表 ServiceAccount、RBAC、Service、Deployment 与 APIService 等资源被创建;patched 与 metrics_tls_flag=added 说明参数首次进入 Pod 模板。重跑时 apply 可能输出 unchanged 或 configured,若参数仍在则只显示 metrics_tls_flag=already-present,不会产生第二个相同参数。rollout 成功说明 Metrics Server Pod Ready;最后 AVAILABLE=True 证明 API 聚合层能连接到它。AGE 动态变化,不影响判断。
如果 APIService 为 False,先 kubectl describe apiservice v1beta1.metrics.k8s.io 查看 condition,再检查 metrics-server Service、EndpointSlice、Pod 日志与证书错误。不要直接创建 HPA 后等待它自己变好;没有指标链路时,HPA 只能显示 unknown。
指标有采样周期。APIService 刚变为可用时,TaskBoard 的第一次样本仍可能尚未生成;只执行一次 kubectl top 会把正常冷启动窗口误写成失败。下面最多轮询 60 次、每次间隔 2 秒,总边界约 120 秒。只有命令成功且至少得到两行包含名称、CPU、内存的样本才通过;-l 过滤目标应用,避免把 toolbox 和系统组件混入基线。每次失败都保留最后一次原始输出,超时后用于诊断:
METRICS_READY='no'
LAST_METRICS_OUTPUT='尚未请求'
for attempt in {1..60}; do
if LAST_METRICS_OUTPUT="$(kubectl top pod -n taskboard \
-l app=taskboard --no-headers 2>&1)"; then
SAMPLE_COUNT="$(printf '%s\n' "$LAST_METRICS_OUTPUT" | \
awk '$2 ~ /^[0-9]+m$/ && $3 ~ /^[0-9]+(Ki|Mi|Gi|Ti|Pi|Ei)$/ { count += 1 } END { print count + 0 }')"
NAME CPU(cores) MEMORY(bytes)
<动态 Pod A> <实时值> <实时值>
<动态 Pod B> <实时值> <实时值>Pod 名称和指标会随采样变化。循环退出前已经断言至少有两行三列样本,因此这里的非空 CPU 和内存值不是一次未校验的截图,而是有时间边界的链路验收。
CPU(cores) 通常以 m 显示,1m 是千分之一个 CPU 核;MEMORY(bytes) 常以 Mi 显示。这里不要求特定空闲值,因为采样窗口、请求时机和节点负载都会影响数字。验收是至少两个 TaskBoard Pod 都有非空样本。若超时输出为 Metrics API not available,回到 APIService;若长期只有一行,比较 Deployment Ready 副本与 Pod 列表,并检查另一个 Pod 是否刚启动、是否已被删除以及 Metrics Server 日志能否关联到该节点。
官方的 资源指标流水线文档 对 kubelet、Metrics Server、聚合 API 和 kubectl top 的关系有完整说明。
TaskBoard 每个容器声明了 requests.cpu: 50m。HPA 的 CPU 利用率以 request 为分母:
CPU 利用率 = 实际 CPU 使用量 ÷ CPU request × 100%例如平均使用约 25m,对 50m request 来说就是 50%。若没有 CPU request,HPA 无法计算这个百分比。
HPA 是一个 API 对象,也是 kube-controller-manager 中的控制器。它按控制周期读取目标工作负载的 scale 子资源和指标,计算期望副本,再写回 scale。官方文档给出的核心关系可以写成:
期望副本数 = ceil(当前副本数 × 当前指标值 ÷ 目标指标值)假设当前 2 个副本、平均 CPU 利用率 200%、目标 50%,原始计算是 ceil(2×200/50)=8;但本章 maxReplicas=5,所以最终被上限截到 5。若当前 5 个副本、平均利用率 5%、目标 50%,原始结果是 ceil(5×5/50)=1;minReplicas=2 又会把结果抬到 2。
控制器不会对每个微小抖动立即伸缩。默认情况下,当前值与目标值的比率落在容忍区间附近时会跳过动作;官方机制页描述的默认容忍度是 0.1,也就是约 10%。HPA 控制循环默认周期约 15 秒,具体由控制平面参数决定,所以命令执行后不会瞬时出现新副本。
CPU Utilization 类型要求目标 Pod 中相关容器具有 CPU request。request 是百分比计算基准,不是实际占用上限。request 写成 50m 时,实际用 50m 就约为 100%;如果把 request 改成 500m 而负载不变,利用率读数会大约缩小十倍,HPA 也会做出不同判断。没有 request 的 Pod 对该资源利用率可能未定义,导致控制器无法按预期采取动作。
HPA 还要处理缺失指标和正在启动的 Pod。为了避免启动期 CPU 峰值或 readiness 抖动让扩缩容失真,控制器会对尚未稳定 Ready 的 Pod 和缺失样本做保守处理。控制平面有 CPU 初始化周期与初始 readiness 延迟参数;合理的 startupProbe 和 readinessProbe 能让 HPA 更准确地区分“启动预热”与“稳定负载”。
扩容和缩容也不对称。流量升高时通常希望较快补容量;流量下降可能只是短暂波谷,立即缩容会造成副本来回摆动。本章在 behavior.scaleDown 中把稳定窗口设为 30 秒。控制器会在窗口内保留缩容建议并选择较保守值;这不是从负载停止那一刻精确倒计时 30 秒,因为指标采样、控制周期和 Pod 终止还会叠加。
HPA 只改变 Pod 副本数,不会给节点增加 CPU,也不会修改单 Pod 的 requests/limits。若扩出的 Pod 因节点容量不足一直 Pending,需要节点扩容机制或容量规划;若单个请求无法横向拆分,增加副本也未必有用。Vertical Pod Autoscaler 处理资源建议或单 Pod 资源调整,Cluster Autoscaler/节点自动扩缩处理节点容量,它们与 HPA 解决不同层次的问题。
早期稳定的 autoscaling/v1 HPA schema 主要表达单一 CPU 利用率目标,无法在对象字段中完整描述多个资源、Pod 指标、对象指标、外部指标和独立的扩缩行为。autoscaling/v2 把 metrics 设计成列表,让控制器可以同时评估 CPU、内存或其他指标,并增加 behavior 来分别配置扩容、缩容速率和稳定窗口。本章需要 behavior.scaleDown,也希望把指标来源写清楚,因此使用 autoscaling/v2,而不是把额外能力藏在旧版本注解中。
Kubernetes 的 autoscaling/v2 支持 Resource、Pods、Object、External 等指标类型和可配置 behavior。HPA 的可配置伸缩速度来自官方 KEP-853,动机之一正是不同业务对快速扩容、缓慢缩容与抖动控制有不同要求。完整算法与默认行为可在 Horizontal Pod Autoscaling 官方文档 和 KEP-853 中核对。

在项目工作目录创建 k8s/hpa.yaml。我们允许 2 到 5 个副本,目标利用率为 50%,并把本次练习的缩容稳定窗口设为 30 秒:
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: taskboard
namespace: taskboard
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: taskboard
minReplicas: 2
maxReplicas: 5
behavior:
scaleDown:
stabilizationWindowSeconds
scaleTargetRef 指向 apps/v1 Deployment/taskboard,HPA 通过它的 scale 子资源读取和写入 replicas。minReplicas=2 保留空闲基线,maxReplicas=5 限制本次集群的容量峰值。behavior.scaleDown.stabilizationWindowSeconds=30 只覆盖缩容稳定窗口;没有显式定义的扩容与策略字段使用控制器默认行为。
metrics 中 type=Resource 表示使用 Pod 资源指标。name=cpu 选择 CPU;target.type=Utilization 表示用“实际使用量相对 request 的百分比”聚合;averageUtilization=50 表示所有纳入计算 Pod 的平均目标为 50%。它不是每个 Pod 都必须精确保持 50%,也不是 CPU limit 的 50%。
HPA 与 Deployment 都可能写 spec.replicas。创建 HPA 后,副本数的持续期望应由 HPA 管理;项目文件中的 Deployment replicas:2 只是初始值。后续若部署工具每次都强制 apply replicas:2,就可能和 HPA 的写入互相覆盖。常见做法是让 HPA 专门拥有副本字段,并在声明式工具中忽略或移除冲突管理。
应用 HPA 后先读取一次状态。apply 创建控制对象,get hpa 把目标、当前指标、上下限与当前副本汇总在一行。空闲时利用率通常很低,副本数应保持最小值 2;刚创建时尚未完成首轮采样,因此允许短暂出现 unknown:
kubectl apply -f k8s/hpa.yaml
kubectl get hpa taskboard -n taskboardhorizontalpodautoscaler.autoscaling/taskboard created
NAME REFERENCE TARGETS MINPODS MAXPODS REPLICAS AGE
taskboard Deployment/taskboard <实时值>/50% 2 5 2 <动态时间>刚创建时 TARGETS 可能短暂显示 <unknown>/50%,等下一轮指标到达后会变成百分比。
REFERENCE=Deployment/taskboard 证明目标解析正确;TARGETS 左侧是实时平均值、右侧是 50% 目标;MINPODS 与 MAXPODS 来自清单;REPLICAS 是当前观察到的副本数。AGE 是动态值。若 REFERENCE 显示 unknown 或 condition 出错,检查目标名称和 scale 子资源;若 TARGETS 长期 unknown,执行 kubectl describe hpa 查看 FailedGetResourceMetric 等 condition,并回到 Metrics Server 与 CPU request 链路。
HPA 开始管理副本数后,不要在日常发布中反复把 Deployment 的 spec.replicas 应用回固定值,否则声明式工具和 HPA 会争夺副本字段。保留最小副本应由 HPA 的 minReplicas 表达。
TaskBoard 的 /api/work?ms=1000 会持续计算约一秒,用来产生可控 CPU 消耗。我们创建四个请求循环 Pod。它们同样满足 restricted 安全要求,并设置很小的自身资源边界,避免负载工具无上限占用节点。
在项目工作目录创建 k8s/load-generator.yaml:
apiVersion: apps/v1
kind: Deployment
metadata:
name: load-generator
namespace: taskboard
spec:
replicas: 4
selector:
matchLabels:
app: load-generator
template:
metadata:
labels:
app: load-generator
spec:
securityContext
这个 Deployment 有四个负载 Pod,每个容器在循环中请求 http://taskboard/api/work?ms=1000。请求仍经过 Service,因此压力会分布到当前就绪 TaskBoard 后端。/api/work 的 ms=1000 让服务端持续计算约一秒,制造 CPU 使用,而不是让负载容器自己空转。
负载工具自身也声明 request 与 limit,避免它无限抢占节点。它使用非 root 用户、RuntimeDefault seccomp、只读根文件系统、禁止提权并丢弃 capabilities,与后续 restricted 策略兼容。它没有持久状态,验证结束后删除整个 Deployment 即可停止所有循环。
这不是性能基准测试。BusyBox wget、单节点调度、采样窗口和共享 CPU 都会影响数字;我们的目标只是让指标显著高于 50%,观察 HPA 控制回路。不要把本章的 478% 或扩容时间当作 TaskBoard 的吞吐结论。
创建负载 Deployment,确认四个负载 Pod 就绪后观察 HPA。rollout status 保证请求循环已经运行,再用 --watch 持续订阅 HPA 对象变化。指标先由 kubelet采集,再经 Metrics Server 和 HPA 周期处理,最后 Deployment 创建 Pod,因此通常要等待多个周期:
kubectl apply -f k8s/load-generator.yaml
kubectl rollout status deployment/load-generator -n taskboard --timeout=60s
kubectl get hpa taskboard -n taskboard --watch看到副本达到 5 后,按 Ctrl+C 结束 watch;这只停止观察,不会删除任何资源。
deployment.apps/load-generator created
deployment "load-generator" successfully rolled out
NAME REFERENCE TARGETS MINPODS MAXPODS REPLICAS AGE
taskboard Deployment/taskboard 478%/50% 2 5 2 <动态时间>
taskboard Deployment/taskboard 478%/50% 2 5 5 <动态时间>这轮真实结果中,CPU 达到目标值的 478%,HPA 将副本从 2 调到上限 5。478% 不是使用了 4.78 个 CPU 核心,而是相对于每个 Pod 50m request 的平均百分比。
第一条 HPA 记录里 REPLICAS 仍为 2,但 TARGETS 已到 478%,说明指标先高于目标,扩容写入或 Pod 创建还没完全反映到状态;后一条 REPLICAS=5 说明目标 Deployment 已扩到上限。按原始公式,478% 对 50% 的比率远高于 1,计算结果会超过 5,因此 maxReplicas 截断为 5。
HPA 的 TARGETS 是纳入计算 Pod 的平均利用率,不是负载工具的 CPU,也不是整个节点 CPU。新副本加入后,流量被更多后端分担,平均值通常会下降,但采样有滞后,列表可能暂时继续显示扩容前的高值。
如果 TARGETS 升高而 REPLICAS 不变,执行 kubectl describe hpa,读取 AbleToScale、ScalingActive、ScalingLimited conditions 与 Events。ScalingLimited=True 可能只是命中了 maxReplicas;FailedGetResourceMetric 指向指标链;新副本 Pending 则属于节点容量或调度问题。不要只盯着 watch 一行数字。
负载验证完成后删除请求循环,再观察 HPA 缩容。delete -f 使用同一清单定位并删除 load-generator Deployment,它的 Pod 会随所有权级联删除;TaskBoard 和 HPA 不受影响。随后继续 watch,预期先看到旧高样本、再看到低利用率、最后才看到副本下降:
kubectl delete -f k8s/load-generator.yaml
kubectl get hpa taskboard -n taskboard --watch看到副本回到 2 后,按 Ctrl+C 结束观察。
deployment.apps "load-generator" deleted
NAME REFERENCE TARGETS MINPODS MAXPODS REPLICAS AGE
taskboard Deployment/taskboard 478%/50% 2 5 5 <动态时间>
taskboard Deployment/taskboard 5%/50% 2 5 5 <动态时间>
taskboard Deployment/taskboard 5%/50% 2 5 2 <动态时间>指标先降到 5%,副本再从 5 回到 2。即使配置了 30 秒稳定窗口,完整等待时间仍会受到指标采样、HPA 同步周期和 Pod 终止过程影响,不能把 30 秒理解成精确倒计时。
三行正好展示三种时刻:负载刚删除时 Metrics Server 仍保留高样本,HPA 维持 5;新样本降到 5% 后,稳定窗口仍让副本保持 5;窗口和控制周期满足后,REPLICAS 才回到 minReplicas=2。缩容不是把当前 Pod CPU 一降就立即删除三个实例,而是一个带历史建议的控制决定。
若副本长时间停在 5,先确认 load-generator Pod 已全部消失,再查看 kubectl top pod 与 HPA conditions。应用自身后台任务也可能保持 CPU;稳定窗口之外还可能受 HPA policies、缺失指标和未就绪 Pod 的保守计算影响。若副本低于预期或服务抖动,检查 minReplicas、readiness 和终止行为,而不是先把稳定窗口删掉。
滚动发布首先受容量约束。maxSurge=1 要求至少有空间容纳额外 Pod;当节点资源紧张或配额已满,新 Pod 会 Pending,rollout 无法推进。maxUnavailable=0 保住现有可用副本,却也意味着没有额外容量时可能停在原地。生产设置应结合峰值容量、Pod 启动时间和故障预算,不是把零不可用复制给所有工作负载。
其次是应用兼容。新旧版本共存期间,数据库 schema、消息消费者和 API 客户端都要能互操作。常见的 expand/contract 数据迁移会先增加向后兼容结构,再发布新代码,最后在旧版本完全退出后清理旧结构。Deployment undo 只能恢复 Pod 模板,不能自动撤销数据库 migration、外部队列消息或第三方调用。
HPA 也不是容量规划的替代品。扩容有采样、决策、调度、镜像准备和应用启动延迟,突发流量可能在新副本 Ready 前就到达。minReplicas 要覆盖常态和合理突发缓冲;启动慢的应用可以结合预热、预测扩容或外部指标。maxReplicas 则要同时考虑下游数据库连接数、队列分区和节点容量,不能只按前端 CPU 越多越好。
CPU 指标只适合 CPU 与负载大致相关的应用。I/O 等待、队列长度、请求延迟或并发连接可能比 CPU 更能表达容量需求。autoscaling/v2 支持自定义和外部指标,但指标必须有稳定语义、可用性和合理目标。用一个高噪声业务指标直接驱动副本,可能把短暂异常放大成频繁伸缩。
控制器之间还会相互作用。HPA 增加 Pod 后,调度器需要节点容量;节点扩容器可能再增加节点;新 Pod 的启动 CPU 又可能进入 HPA 样本。readiness、初始化延迟、稳定窗口和资源 request 是让这些循环不互相放大的关键。任何自动化控制都应配合 metrics、events、变更记录和上限保护。
下一章会让 TaskBoard 连接 Redis。进入前要把失败发布和负载实验留下的中间态全部收敛,但不删除 HPA 或 Metrics Server:
下面依次读取镜像、副本、HPA、临时负载和指标 API。Deployment 输出额外读取 updatedReplicas,确认两个副本都来自当前 2.0.0 模板;最后从 toolbox-safe 请求 Service,把控制面状态与 HTTP 结果对齐:
kubectl get deployment taskboard -n taskboard --output custom-columns='NAME:.metadata.name,IMAGE:.spec.template.spec.containers[0].image,UPDATED:.status.updatedReplicas,READY:.status.readyReplicas,AVAILABLE:.status.availableReplicas'
kubectl get hpa taskboard -n taskboard
kubectl get deployment load-generator -n taskboard
kubectl get apiservice v1beta1.metrics.k8s.io
kubectl exec -n taskboard toolbox-safe -- wget -qO- http://taskboard/api/infoNAME IMAGE UPDATED READY AVAILABLE
taskboard taskboard:2.0.0 2 2 2
NAME REFERENCE TARGETS MINPODS MAXPODS REPLICAS AGE
taskboard Deployment/taskboard <实时值>/50% 2 5 2 <动态时间>
Error from server (NotFound): deployments.apps "load-generator" not found
NAME SERVICE AVAILABLE AGE
v1beta1.metrics.k8s.io kube-system/metrics-server True <动态时间>
{"service":"taskboard","version":"2.0.0","message":"你好,配置已经更新","pod":"<动态 Pod 名>","namespace":"taskboard","redisConfigured":true}TARGETS、Pod 名与 AGE 是动态值;UPDATED=2 证明两个副本都来自当前模板,最终 JSON 的 version=2.0.0 证明 Service 用户路径一致。NotFound 在这里是成功的清理证据,说明临时负载已删除。若镜像仍是 9.9.9 或有 ImagePullBackOff Pod,先完成 undo;若 HPA 仍为 5,确认负载 Pod 已消失并等待控制循环,不要直接删除 TaskBoard Pod 强迫缩容。
本章完成了两条控制链:Deployment 用 readiness 和新旧 ReplicaSet 协调版本,HPA 用资源指标、request、上下限和稳定策略协调副本。下一章增加 Redis 后,readiness 会开始表达依赖状态,持久数据也会引入不同于无状态副本的新生命周期。