
一个 Deployment 配了 2 个副本、`maxSurge: 1`，发布过程中却能看到 4 个 Pod。这不一定是控制器越过了发布策略：ReplicaSet 的期望副本数、Available 副本数和仍在终止的进程，是三个不同的量。

另一个容易误判的场景是新版本一直不 Ready，旧版本也迟迟不减少。对于 `maxUnavailable: 0` 的部署，这可能正是控制器在保护现有容量。本篇从这两个现象解释滚动更新的判断依据。

<!--more-->

## Deployment 调整 ReplicaSet，ReplicaSet 再调整 Pod

修改 Pod template 后，Deployment Controller 为这个模板找到或创建新的 ReplicaSet，调整新旧 ReplicaSet 的 `spec.replicas`。ReplicaSet Controller 观察副本差异后创建或删除 Pod，Scheduler 负责尚未绑定节点的 Pod，kubelet 再启动和终止容器。它们通过 API 对象协调，不是 Deployment Controller 直接通知节点替换进程。

```mermaid
sequenceDiagram
    participant U as 用户
    participant A as API Server
    participant D as Deployment Controller
    participant R as ReplicaSet Controller
    participant K as kubelet
    U->>A: 修改 Pod template
    A-->>D: Watch 观察到新模板
    D->>A: 创建新 ReplicaSet 并调整副本数
    A-->>R: Watch 观察到 ReplicaSet 变化
    R->>A: 创建新 Pod 或删除旧 Pod
    Note over A,K: 新 Pod 经调度绑定到节点
    A-->>K: Watch 观察本节点 Pod 变化
    K->>K: 启动或终止容器
    K->>A: 更新 Pod 状态
    A-->>D: Pod 与 ReplicaSet 状态驱动下一轮协调
```

这条反馈链解释了发布为什么不是一次同步操作。控制器依据缓存观察到的状态重新计算，API 写入成功到容器真正退出之间存在时间差。

## 两个预算分别约束扩容和缩容

设目标副本数为 `R`，允许额外副本为 `S`，允许不可用副本为 `U`。发布时的两个主要约束是：ReplicaSet 期望副本数总和受 `R+S` 限制，缩减旧副本时要保留至少 `R-U` 个 Available 副本。

这里的百分比存在取整差异：`maxSurge` 向上取整，`maxUnavailable` 向下取整；两者不能同时为零。3 个副本配 25% 时，surge 是 1，unavailable 是 0，不能都按普通四舍五入计算。具体转换见 Kubernetes v1.35.1 的 [`ResolveFenceposts`](https://github.com/kubernetes/kubernetes/blob/v1.35.1/pkg/controller/deployment/util/deployment_util.go#L887-L905)。

{{< github-file url="https://github.com/kubernetes/kubernetes/blob/v1.35.1/pkg/controller/deployment/util/deployment_util.go#L887-L905" >}}

[`NewRSNewReplicas`](https://github.com/kubernetes/kubernetes/blob/v1.35.1/pkg/controller/deployment/util/deployment_util.go#L820-L858)汇总新旧 ReplicaSet 的期望副本数，再用 `R+S` 减去这个总和，计算新 ReplicaSet 本次可以增加多少。该结果还要受新版本自身目标副本数限制。这里统计的是 ReplicaSet 的期望副本数，不能直接理解成节点上存活进程的绝对上限。

{{< github-file url="https://github.com/kubernetes/kubernetes/blob/v1.35.1/pkg/controller/deployment/util/deployment_util.go#L820-L858" >}}

缩旧副本的判断在 [`reconcileOldReplicaSets`](https://github.com/kubernetes/kubernetes/blob/v1.35.1/pkg/controller/deployment/rolling.go#L97-L133)。它先考虑新版本里仍不可用的副本，避免新版本还没准备好就撤掉旧版本。随后结合当前 Available 数量决定能缩多少，并处理旧版本中的不健康副本。新副本创建成功本身不足以给旧副本的删除开绿灯。

{{< github-file url="https://github.com/kubernetes/kubernetes/blob/v1.35.1/pkg/controller/deployment/rolling.go#L97-L133" >}}

上述计算以控制器本轮观察到的 `R` 为输入，发布期间的目标副本数并非永远固定。HPA 或操作者修改 replicas 后，`scale` 在新旧 ReplicaSet 同时活跃的滚动更新中按比例分配增减量，并重新考虑当前 surge 预算。因此不能拿发布开始时的 `R` 推算整个发布期间的固定上限；本文实验没有同时运行 HPA，比例扩缩容的说明来自源码。

{{< github-file url="https://github.com/kubernetes/kubernetes/blob/v1.35.1/pkg/controller/deployment/sync.go#L331-L355" >}}

## Ready 与 Available 之间还有一段稳定时间

readiness probe 回答容器是否满足当前就绪条件。Pod Ready 还受其他容器和 readiness gates 影响。Deployment 使用的 Available 进一步要求 Pod 在 `minReadySeconds` 所要求的时间内保持就绪；默认值为零，非零值可以阻止刚 Ready 的副本立刻替换旧容量。

`IsPodAvailable` 在 Ready 条件为真后继续比较它的最后转换时间与 minReadySeconds，不能用一次 Ready 采样代替这段时间条件。

{{< github-file url="https://github.com/kubernetes/kubernetes/blob/v1.35.1/pkg/api/v1/pod/util.go#L283-L294" >}}

readiness probe 必须反映业务接流量所需条件。仅检查进程存在，无法证明依赖服务、缓存预热或配置已经可用。`minReadySeconds` 也不能补救错误的探针；它只是增加稳定观察时间。

`progressDeadlineSeconds` 用于报告发布缺少进展。超时会反映为 Deployment 的失败条件，不会自动替用户执行回滚；需要发布系统或操作者决定后续动作。相关字段定义见 [Deployment 官方说明](https://kubernetes.io/docs/concepts/workloads/controllers/deployment/)。

{{< github-file url="https://github.com/kubernetes/kubernetes/blob/v1.35.1/pkg/controller/deployment/progress.go#L81-L95" >}}

## 终止中的旧进程仍然占用资源

ReplicaSet 下调副本数后，旧 Pod 收到删除标记。节点执行 `preStop` 和终止宽限期内的退出流程，这些进程可能继续存活一段时间。与此同时，控制器可以依据已经下调的 ReplicaSet 副本数继续增加新 Pod。

因此，`R+S` 不能作为 CPU、内存和连接数的硬峰值预算。旧 Pod 的终止时间越长，资源重叠窗口越明显。`preStop` 消耗的也是 `terminationGracePeriodSeconds` 总预算，并不是额外赠送的等待时间。有关进程终止和 EndpointSlice 变化，见 [Pod 删除流程](/posts/kubernetes-pod-terminating/)。

即使 Available 数量一直满足预算，也不代表每个请求都成功。Service 端点更新、代理同步、已有长连接、应用停止接收新请求与完成在途请求，是不同过程。本文的副本实验没有发起 HTTP 流量，不能据此声称验证了零中断发布。

## 实验：慢启动和慢退出叠加

实验使用 `zj` 上独立 kind 集群 `article-control`，Kubernetes 和 kubectl 均为 v1.35.1。Deployment 有 2 个副本，`maxSurge=1`、`maxUnavailable=0`、`minReadySeconds=5`，readiness 通过文件是否存在控制，旧容器的 `preStop` 保持 25 秒，总终止宽限期为 40 秒。

初始版本启动时创建 readiness 文件，新版本不创建。脚本先确认新 Pod 已启动、尚未满足 readiness，同时旧的两个 Pod 都还存在：

```text
new Pod unready: 2 old + 1 new, no old deletion
{"pods": 3, "terminating": 0, "available": 2}
```

随后逐个打开新 Pod 的 readiness，等待控制器继续发布，记录到：

```text
{"pods": 4, "terminating": 1, "available": 2}
{"pods": 4, "terminating": 2, "available": 2}
rollout complete; terminating Pods are checked separately
```

这次观察同时说明了 surge 预算与真实对象数量的差别，以及 rollout 完成与所有旧进程退出的差别。数量 4 是本实验观察，不是这个配置在所有时序下都固定达到的峰值。

## 容量不足时，可用性预算也会阻止发布前进

在已完成的 Deployment 上，把新模板 CPU request 提高到实验 worker 无法提供的值。新的 Pod 得到 `Unschedulable`，旧版本仍有 2 个 Available 副本。调度事件明确指出 worker 的 CPU 不足，三个控制面节点则有未容忍的污点：

```text
capacity blocked: new Pod Unschedulable, old available=2
0/4 nodes are available: 1 Insufficient cpu, 3 node(s) had untolerated taint(s).
```

在 `maxUnavailable=0` 的条件下，控制器无法先删除健康旧副本来腾位置，新副本又无法调度，发布就会停在这里。扩大节点容量、降低真实资源需求或允许一定不可用数量是不同取舍；直接调大 `maxSurge` 只增加创建预算，不能制造节点容量。

实验最后撤销这个不可能调度的模板，并确认 Deployment 重新完成 rollout。配置、自动断言、证据与清理方法见 [k8sdev/article-control-rollout](https://github.com/jimyag/k8sdev/tree/3cfa654905a750b1716de8c32aa42d7573220954/article-control-rollout)。先按该目录 README 的固定提交准备步骤创建独立工作目录，再运行实验。

发布实验先检查新 Pod 未就绪时旧副本仍在，再持续采样终止中的 Pod 与 Available 数量。

{{< github-file url="https://github.com/jimyag/k8sdev/blob/3cfa654905a750b1716de8c32aa42d7573220954/article-control-rollout/run.py#L30-L53" >}}

分析发布问题时，应同时读取 Deployment 条件、新旧 ReplicaSet 的期望与 Available 副本数、Pod 的调度和就绪条件、删除时间戳，再对照节点资源与应用退出过程。只看 `replicas: 2` 或一次 `rollout status`，无法回答容量峰值与请求可用性。

