Skip to main content

3. Deployment 控制循环

上一章讲了控制循环的通用原理——Informer + Workqueue + Reconcile。这一章拿 K8S 最常用的 Deployment Controller 开刀,看真实世界的 Reconcile 长什么样。

但直接讲 Deployment 之前,得先说清楚一个比它更基础的东西——ReplicaSet。不搞懂 ReplicaSet,Deployment 的设计你只能看个热闹。

从最朴素的 Controller 说起​

假设让你设计一个 Controller,管 N 个相同的 Pod。最直接的思路:

// 朴素版:直接管 Pod
type MyController struct {
replicas int
template PodTemplate
}

func (c *MyController) Reconcile() error {
pods := listPods(c.labels)
diff := c.replicas - len(pods)
if diff > 0 {
createPods(diff, c.template)
} else if diff < 0 {
deletePods(pods, -diff)
}
return nil
}

这个 Controller 直接管 Pod——读期望副本数,读当前 Pod 数,多了杀少了建。

K8S 早期真有这么个东西,叫 ReplicationController(注意是三个单词,跟后面要讲的 ReplicaSet 不是一回事)。它就是上面这个朴素设计。

ReplicationController 的问题​

设计太简单,慢慢暴露两个短板:

  • selector 太弱:只支持等值匹配(app=nginx),不支持 app in (nginx, apache) 这种集合匹配
  • 没有版本概念:更新 Pod 模板时,老 Pod 跟新 Pod 混在一起,没法区分"哪些是旧版本的"

第二个短板尤其要命——发版时你不知道哪些 Pod 是新版本的,没法做"先杀旧的、再建新的"这种滚动更新。

ReplicaSet:朴素 Controller 的升级版​

K8S 后来出了 ReplicaSet,跟 ReplicationController 干一模一样的活——管 N 个相同的 Pod——但补上了这两个短板:selector 更强,而且引入了"Pod 模板哈希"来标识版本。

ReplicaSet 的 Reconcile​

ReplicaSet 的 Reconcile 就是上面那个朴素逻辑,没有任何花活:

func Reconcile(rs *appsv1.ReplicaSet) error {
// 1. 找当前 RS 管的 Pod(按 selector 匹配)
pods := listPods(rs.Spec.Selector)

// 2. 算差异
diff := rs.Spec.Replicas - len(pods)

if diff > 0 {
// 不够:创建 diff 个 Pod
for i := 0; i < diff; i++ {
createPod(rs) // 用 rs.Spec.Template 作为 Pod 模板
}
} else if diff < 0 {
// 多了:按优先级杀多余的 Pod
deletePods(pods, -diff)
}

// 3. 更新 status
rs.Status.Replicas = len(pods)
rs.Status.ReadyReplicas = countReady(pods)

return nil
}

就这些。ReplicaSet 的全部职责就是"维持 N 个跟我 selector 匹配的 Pod"。

Pod 模板哈希​

ReplicaSet 创建 Pod 时,会自动给 Pod 打一个 label:pod-template-hash: <某串哈希>。这串哈希是根据 ReplicaSet 的 Pod 模板算出来的——模板变了,哈希就变。

ReplicaSet A (template v1)
└── Pod-1 (labels: pod-template-hash=abc123, app=nginx)
└── Pod-2 (labels: pod-template-hash=abc123, app=nginx)

ReplicaSet B (template v2)
└── Pod-3 (labels: pod-template-hash=def456, app=nginx)
└── Pod-4 (labels: pod-template-hash=def456, app=nginx)

为什么要这个 hash?因为多个 ReplicaSet 可能 selector 重叠——都选 app=nginx 的 Pod。没有 hash 的话,ReplicaSet A 和 B 都以为对方的 Pod 是自己的,互相抢。

hash 让每个 ReplicaSet 只认自己创建的 Pod——selector + pod-template-hash 双重锁定,不会抢错。

杀哪个 Pod:优先级算法​

ReplicaSet 要缩容时,杀哪些 Pod 不是随便挑。有个排序:

1. Pending 还没跑起来的 Pod(最该杀,省得浪费资源)
2. 不在 Node 上的 Pod(已经是孤儿)
3. Ready 状态为 false 的 Pod
4. 容器重启次数多的 Pod(不健康)
5. 创建时间晚的 Pod(新兵先撤)
6. 跑在调度紧张节点上的 Pod(顺手缓解节点压力)

这套排序写在 k8s.io/kubernetes/pkg/controller/controller_utils.go 的 ActivePods 类型里。打比方:裁军时先裁逃兵、伤员,再裁新兵,最后才动老兵。

Deployment:为什么要在 ReplicaSet 上面再套一层​

ReplicaSet 能管 Pod 了,为什么还要 Deployment?

问题在滚动更新。假设你直接用 ReplicaSet 管 nginx,要发版从 v1 换 v2:

方案 1:改 ReplicaSet 的镜像
ReplicaSet (template: nginx:v1) → Pod × 3 (nginx:v1)

改成:
ReplicaSet (template: nginx:v2) → Pod × 3 (nginx:v1) ← Pod 还是旧的!

改 ReplicaSet 的模板,已有的 Pod 不会自动更新——Pod 一旦创建,镜像就定了。ReplicaSet 只对新创建的 Pod 用新模板,老 Pod 不动。

要更新老 Pod,得把它们杀掉,让 ReplicaSet 用新模板重建。但你直接杀 Pod 会中断服务——3 个全杀了再建,流量全断。

滚动更新需要"两套 ReplicaSet"​

正确的做法:保留旧 ReplicaSet 管旧 Pod,新建一个 ReplicaSet 管新 Pod,慢慢把副本数从旧 RS 挪到新 RS。

ReplicaSet-v1 (nginx:v1, replicas: 3) → Pod-v1 × 3 ← 旧的,慢慢缩
ReplicaSet-v2 (nginx:v2, replicas: 0) → Pod-v2 × 0 ← 新的,慢慢扩

但谁来做这个"慢慢挪"的工作?ReplicaSet 自己只会维持副本数,不会跨 RS 调度。

Deployment 就是干这个的——它不管 Pod,它管 ReplicaSet。发版时 Deployment 创建新 ReplicaSet,慢慢扩新 RS 的 replicas、缩旧 RS 的 replicas,实现滚动更新。

Deployment
│
│ 管理(创建/扩缩/删除)
▼
ReplicaSet-v1 (replicas: 0) ← 旧的,保留用于回滚
ReplicaSet-v2 (replicas: 3) ← 当前活跃
│
│ 管理(创建/删除)
▼
Pod × 3

打比方:ReplicaSet 是直接管士兵的班长。Deployment 是连长——他不直接管士兵,他管班长。打仗时连长指挥"一班守、二班上",实现战术轮换。Deployment 指挥"v1 班缩、v2 班扩",实现滚动更新。

Deployment Controller 的 Reconcile​

func Reconcile(deployment *appsv1.Deployment) error {
// 1. 算当前 Pod 模板的 hash
// (Pod 模板变了,hash 就变,对应的 ReplicaSet 就不同)
newHash := computeTemplateHash(deployment.Spec.Template)

// 2. 找 hash 匹配的 ReplicaSet("当前应该活跃的 RS")
newRS := findReplicaSetByHash(deployment, newHash)

// 3. 没有?创建
if newRS == nil {
newRS = createReplicaSet(deployment, newHash)
}

// 4. 滚动更新:按 maxSurge 和 maxUnavailable 算新 RS 该有多少
newReplicas := computeNewReplicas(deployment, newRS, oldRSs)
if newRS.Spec.Replicas != newReplicas {
updateReplicaSetReplicas(newRS, newReplicas)
}

// 5. 缩旧 RS(按 maxUnavailable 算)
scaleDownOldReplicaSets(deployment, oldRSs)

// 6. 清理太老的旧 RS(revisionHistoryLimit)
cleanupOldReplicaSets(deployment, revisionHistoryLimit)

// 7. 更新 status
updateDeploymentStatus(deployment)

return nil
}

关键在第 4-5 步——不是一步到位把新 RS 扩到 3、旧 RS 缩到 0。每一轮 Reconcile 只挪一点,按 maxSurge 和 maxUnavailable 控制:

  • maxSurge: 1:最多比 replicas 多 1 个(新 RS 可以临时超编 1 个)
  • maxUnavailable: 1:最多比 replicas 少 1 个(旧 RS 至少保留 replicas-1 个)

假设 replicas: 3, maxSurge: 1, maxUnavailable: 1:

初始: RS-v1=3, RS-v2=0 (总 3,可用 3)
第 1 轮:RS-v1=3, RS-v2=1 (总 4,超 1) ← 先扩新
第 2 轮:RS-v1=2, RS-v2=1 (总 3,可用 2) ← v2 ready 后缩旧
第 3 轮:RS-v1=2, RS-v2=2 (总 4,超 1) ← 再扩新
第 4 轮:RS-v1=1, RS-v2=2 (总 3,可用 2) ← v2 ready 后再缩旧
第 5 轮:RS-v1=1, RS-v2=3 (总 4,超 1)
第 6 轮:RS-v1=0, RS-v2=3 (总 3,完成)

先扩后缩——保证滚动过程中总有足够的 Pod 可用。先扩新 RS 到 4,等新 Pod ready 了,再缩旧 RS 到 2。永远不少于 replicas - maxUnavailable 个可用 Pod。

revisionHistoryLimit:保留几个旧 RS​

spec:
revisionHistoryLimit: 10 # 保留 10 个旧 ReplicaSet

发版后旧的 ReplicaSet 不删,只把 replicas 设为 0。保留它们是为了回滚——回滚就是把旧 RS 的 replicas 调回 3,新 RS 调到 0,比重新拉镜像快。

保留太多占 etcd 存储,保留太少回滚不了几步。默认 10 是个平衡值。

ReplicaSet 为什么不直接给用户用​

你可能想——既然 ReplicaSet 能管 Pod,我直接用 ReplicaSet 不就行了,干嘛套一层 Deployment?

可以,但你会失去:

  • 滚动更新(Deployment 帮你做 RS 之间的副本迁移)
  • 回滚(kubectl rollout undo)
  • 版本历史(kubectl rollout history)
  • 发布策略控制(maxSurge/maxUnavailable)

裸用 ReplicaSet 等于自己手动管这些——又回到命令式运维了。所以实践中几乎不直接用 ReplicaSet,都用 Deployment。ReplicaSet 是 Deployment 的实现细节,对用户大部分时候不可见。

设计背后的权衡​

Deployment 的套娃设计不是为了好看,是让"版本管理"和"副本维持"职责分离:

  • ReplicaSet 只管"维持 N 个 Pod"——不管版本,不管滚动
  • Deployment 只管"版本之间的副本迁移"——不管单个 Pod

各管一摊,逻辑清晰。如果合在一起,Reconcile 逻辑会纠缠成"既管副本数又管版本又管滚动"的大泥球。

还有一个好处——用资源做协作媒介。Deployment 和 ReplicaSet 都是独立的 K8S 资源,通过 label selector 关联。状态显式存在 etcd 里,kubectl get rs 能看到,调试方便。如果用内存里的数据结构做协作,重启就丢了。

前置阅读​

思考题​

问题 1:Deployment 滚动更新时,为什么是"先扩新 RS 再缩旧 RS",不能反过来?

问题 2:ReplicaSet 的 pod-template-hash label 是给谁看的?去掉会怎样?

问题 3:Deployment 保留旧 ReplicaSet 做版本历史。这些 replicas: 0 的 ReplicaSet 占资源吗?


解答​

问题 1:反过来"先缩旧再扩新"会导致中间一段时间总副本数少于期望——比如 replicas: 3,先缩旧 RS 到 2,再扩新 RS 到 3,中间有段时间只有 2 个 Pod。如果此刻流量打过来,2 个 Pod 扛不住。先扩后缩保证滚动过程中总副本数始终 >= replicas,可用性不掉。

问题 2:给 ReplicaSet Controller 自己看的。多个 ReplicaSet 的 selector 可能重叠(都选 app=nginx),hash 让每个 RS 只认自己创建的 Pod——selector + pod-template-hash 双重锁定。去掉的话,新旧 RS 会互相把对方的 Pod 算成自己的,副本数算乱,互相抢 Pod。

问题 3:几乎不占。replicas: 0 的 ReplicaSet 不创建 Pod,不占计算资源。占的只是 etcd 里几个对象的存储(每个几十 KB)。保留它们是为了回滚——回滚就是把旧 RS 的 replicas 调回 3,新 RS 调到 0,比重新拉镜像快。

下一章​

下一章看另一个工作负载 Controller——StatefulSet 控制循环——它不套娃,直接管 Pod,但引入了 ControllerRevision 做版本管理。