Skip to main content

15. 原地升级

为什么需要原地升级​

Deployment 的滚动更新流程你是知道的:创建新 Pod → 等 Ready → 删旧 Pod。Pod 在 Node 间迁移,IP 跟着变。

对无状态服务来说这没什么。但对有状态服务来说,每一次滚动更新都是一次 mini 故障转移——Pod 去新的 Node 了,之前挂的 PV 要重新 attach、数据要重新 warm up、依赖的服务要重新建立连接。重启可能几十秒,重建连接又是几十秒。

原地升级(In-place Update)的思路很简单:Pod 不动,Node 不变,只换容器镜像。

这样省掉了:

  1. 创建 Pod Sandbox 的开销(不用重建 net namespace)
  2. PV detach → re-attach 的时间(Pod 还在同一台 Node 上)
  3. IP 漂移的问题(Pod IP 不变)
  4. 调度重建的时间(不用重新调度的排队和过滤)

K8S 原生支持什么、不支持什么​

K8S 对"原地升级"的态度不是一刀切的。分两块:

镜像更新——原生不支持。 Pod 创建后 spec.containers[].image 不可变。API Server 校验阶段就拦了:

// k8s.io/kubernetes/pkg/apis/core/validation/validation.go
func ValidatePodUpdate(newPod, oldPod *core.Pod) field.ErrorList {
if newPod.Spec.Containers[0].Image != oldPod.Spec.Containers[0].Image {
allErrs = append(allErrs, field.Forbidden(...)) // 不让改
}
}

这是刻意设计——K8S 希望 Pod 是不可变的,变更通过整体替换完成。

资源调整——原生支持(K8S 1.27 Alpha,1.33 Beta)。 InPlacePodVerticalScaling feature gate 从 1.27 引入,1.33 升为 Beta 并默认开启,允许在不重启 Pod 的情况下原地修改 CPU/内存的 request 和 limit:

apiVersion: v1
kind: Pod
spec:
containers:
- name: nginx
image: nginx:1.25
resources:
requests:
cpu: 500m
memory: 256Mi
limits:
cpu: 1000m
memory: 512Mi
resizePolicy: # ← K8S 1.27+ 的新字段
- resourceName: cpu
restartPolicy: NotRequired # CPU 改了不重启 Pod
- resourceName: memory
restartPolicy: NotRequired

resizePolicy 告诉 K8S:这个容器的 CPU 和内存可以在不重启 Pod 的情况下调整。底层走的是 CRI 的 UpdateContainerResources 接口——kubelet 直接调 containerd 改 cgroup 的 cpu.max 和 memory.max,容器进程不中断,IP 不变,Sandbox 不变。

这跟 OpenKruise 的原地升级范围不一样:

操作K8S 原生OpenKruise CloneSet
原地改 CPU/内存支持(1.27+)支持
原地改镜像不支持支持
原地改 Env不支持支持
Node 间迁移不动 Node不动 Node

K8S 原生方案只覆盖了"VPA 改资源不重建 Pod"这个场景——不解决"换镜像"的需求。你想把 nginx:1.25 原地升级到 1.26——还是得靠 OpenKruise 或重建 Pod。

OpenKruise 的解法:补上镜像原地升级​

OpenKruise 是阿里开源的 K8S 扩展项目,核心功能 CloneSet——支持原地改镜像的 Workload。

OpenKruise 是阿里开源的 K8S 扩展项目,其中一个核心功能就是 CloneSet——支持原地升级的 Workload。

apiVersion: apps.kruise.io/v1alpha1
kind: CloneSet
metadata:
name: nginx
spec:
replicas: 3
updateStrategy:
type: InPlaceIfPossible # 能原地升就原地升
template:
spec:
containers:
- name: nginx
image: nginx:1.25

底层原理不复杂。Kruise 的控制器在更新时:

  1. 不再创建新 Pod(不调 CRI 的 RunPodSandbox)
  2. 直接调 containerd 的 UpdateContainerResources 接口
  3. Container 停掉旧进程 → 换上新镜像 → 启动新进程

所有的操作在 Pod Sandbox 内部完成,Sandbox 的 net namespace 不释放,Pod IP 不变化。

// kruise/pkg/controller/cloneset/cloneset_controller.go 简化
func (r *ReconcileCloneSet) updatePodInPlace(pod *v1.Pod, newImage string) error {
// 1. 通过 CRI 接口更新容器 spec(image 变了)
// 2. 更新 Pod Status(ContainerStatuses 里的 ImageID 变了)
// 3. 不需要做 Node 切换、不需要等待调度
}

Advanced StatefulSet:有状态的原地升级​

CloneSet 对标 Deployment——无状态、随意替换。但如果你需要固定身份 + 原地升级(比如 Kafka 的 Broker),OpenKruise 的 Advanced StatefulSet 把 StatefulSet 的有序性和原地升级合并了:

apiVersion: apps.kruise.io/v1beta1
kind: StatefulSet
metadata:
name: kafka
spec:
replicas: 3
updateStrategy:
type: InPlaceIfPossible
rollingUpdate:
partition: 0 # StatefulSet 的金丝雀依然生效
template:
spec:
containers:
- name: kafka
image: confluentinc/cp-kafka:7.5.0

跟标准 StatefulSet 的区别:更新时不重建 Pod——kafka-2、kafka-1、kafka-0 依次原地换镜像。每个 Pod 的 hostname、固定 IP、PVC 全保留。配合 partition 金丝雀——先更新 kafka-2,验证 Kafka 集群健康之后再改 partition=0 全量更新。

镜像预热​

原地升级省了调度和分配,但镜像拉取的时间省不掉。如果镜像有 2GB(大模型推理镜像常有的事),不管原地不原地,docker pull 都得等。

OpenKruise 的 ImagePullJob 可以在更新之前预先拉镜像到 Node 上:

apiVersion: apps.kruise.io/v1alpha1
kind: ImagePullJob
metadata:
name: preheat-kafka
spec:
image: confluentinc/cp-kafka:7.5.0
selector:
matchLabels:
app: kafka # 所有跑 kafka 的 Node 先拉这个镜像
completionPolicy:
type: Always

效果:原地升级开始时,containerd 发现镜像已存在本地 → 容器秒起。

代价和取舍​

原地升级不能改所有东西。能原地升的只有:

能原地升不能原地升
容器 imageNode 名称
Env 变量Volume 挂载变化
资源 request/limit*InitContainer 变化
  • 资源调整需要看 CRI 是否支持 UpdateContainerResources。

另外,原地升级跳过了重新调度这一步——如果你的集群在更新期间 Node 坏了,Pod 不会自动漂移到别的 Node(因为 Pod 本来就在这个 Node 上)。这跟 Deployment 的重建模式是一个本质取舍:原地升级牺牲了 Pod 的跨 Node 恢复能力,换来了零迁移开销。

思考题:原地换镜像期间,旧进程停了、新进程还没起来——Readiness/Liveness 探针在这个窗口期是怎么表现的?K8S 会不会因为探针失败把 Pod 杀掉?如果你配了 PodDisruptionBudget,会触发吗?

思考题:K8S 1.33 的 InPlacePodVerticalScaling 能原地改 CPU/内存,OpenKruise CloneSet 能原地换镜像——能不能把这俩叠在一起用?一个 CloneSet Pod 同时享受"原地换镜像"和"原地改资源"?

K3S 的场景​

K3S 的轻量设计让它很适合边缘计算——Node 少、资源紧张、网络不那么可靠。原地升级在 K3S 上的收益比在大型 K8S 数据中心更明显:

  • 边缘 Node 的网络带宽有限——少拉一次镜像、少传一次 CSI attach 请求就是节省
  • 边缘 Node 的 CPU 不富裕——跳过调度、Sandbox 创建的开销时,CPU 能耗更低
  • 边缘 Pod IP 跟设备绑定——IP 不变意味着 IoT 设备不需要重连

K3S 没有内置 OpenKruise(那是个外部 CRD),但它完全支持 OpenKruise 作为 Operator 跑在 K3S 集群上。Kruise CloneSet 的 CRD 在 K3S 上跟 K8S 上的行为一模一样。

小结​

  • 原地升级只换镜像/资源不换 Node,省掉 Pod 迁移的全部开销
  • K8S 原生从 1.27 引入、1.33 升为 Beta 的原地资源调整(InPlacePodVerticalScaling + resizePolicy),但不支持原地改镜像
  • 原地改镜像需要 OpenKruise CloneSet 或类似 CRD
  • 适合有状态服务 / 边缘场景,不适合需要 Node 级别容灾的场合
  • K3S 完全兼容,且收益在边缘场景下更大

解答​

原地换镜像期间,探针会怎么表现?

原地升级的重启窗口(旧进程停 → 新进程启)期间,探针会有短暂失败。关键是看这个失败窗口在 kubelet 眼中是否超过了 failureThreshold × periodSeconds。

OpenKruise CloneSet 的原地升级流程:

1. Container 收到 SIGTERM / SIGKILL(旧进程停)
2. CRI UpdateContainerResources / 重建容器(换镜像)
3. 新进程启动
← 这个窗口期间,Readiness probe 可能失败
← kubelet 判断失败次数是否超过 failureThreshold

如果新进程启动足够快(几秒内),failureThreshold=3, periodSeconds=10 意味着 kubelet 需要连续 30 秒失败才会把 Pod 标 Unready——不够时间,探针没有感知到这次重启。

但如果新进程启动慢(比如 Java 应用启动 60 秒),探针会触达 failureThreshold。关键区别:OpenKruise 的原地升级不创建新 Pod——所以 kubelet 不会因为"Pod 不 Ready"而杀掉 Pod。kubelet 杀 Pod 只在 Deployment 的滚动更新流程中由 Controller 发起——而原地升级的 Pod 没有 Controller 在管它(CloneSet 控制器在管,但 CloneSet 不杀 Pod)。

Readiness probe 失败的效果:Pod 从 Service 的 Endpoints 中暂时摘掉(不接收流量),但 Pod 本身不删,新进程启动后 probe 重新通过 → 自动加回 Service。

Liveness probe 呢? 如果 liveness probe 在窗口期内连续失败超过 failureThreshold,kubelet 会杀掉容器并重建。这跟纯原地升级冲突了。建议原地升级的 Pod 要么把 initialDelaySeconds 设大一点(覆盖启动窗口),要么不用 liveness probe(对原地升级的场景——单进程应用——liveness probe 本身意义不大)。

PodDisruptionBudget(PDB) 不关心原地升级。PDB 只限制自愿性中断——Controller 主动发起的 Pod 删除。原地升级的 Pod 自始至终没被删——PDB 看不到任何事件。

2. 原地换镜像 + 原地改资源能一起用吗?​

技术上可以,但要看是谁在管 Pod。

InPlacePodVerticalScaling 是 kubelet + CRI 层面的能力——kubelet 调用 containerd 的 UpdateContainerResources 修改 cgroup 参数。只要 Pod 的 resizePolicy 设了 NotRequired,kubelet 在处理 VPA 的资源变更时就不会重建 Pod——直接调 CRI 改 cgroup。

OpenKruise CloneSet 的原地换镜像是通过 CRI 的 CreateContainer + 删旧容器实现的——也不走重建 Pod 的流程。

两者操作的层面不一样:

操作谁发起走的 CRI 方法Pod 重建?
原地改 CPU/内存VPA → kubeletUpdateContainerResources否
原地换镜像CloneSet ControllerCreateContainer(新镜像)否

但如果 VPA 在 Auto 模式下决定改资源——VPA 更新 Deployment/CloneSet 的 spec template → CloneSet Controller 发现 spec 变了 → 触发一次原地升级——这次升级是"换镜像 + 改资源"叠加进行的。在一个更新操作中,CloneSet 先检查"这个变更你能原地升吗"——镜像变了,可以 → 调 CRI 建新容器(带新的 cgroup 限制)→ 删旧容器。资源也跟着变了。

如果 CloneSet 判定"不能原地升"(比如改了 NodeName 或 volume),就会走完整的重建流程——跟 Deployment 的滚动更新一样,新 Pod、新 Sandbox、新 IP。

一句话:K8S 原生能力 + OpenKruise 可以叠加——只要是"原地能升"的场景,两者在同一个 Pod 上不冲突。

下一章,Pod 不光要 CPU——还要 GPU。