Skip to main content

5. 工作负载管理

Pod 不能裸跑​

上一章你写好了一个 Pod YAML。但 Pod 自己跑在生产环境会翻车。

想一想:你 kubectl apply 了一个 Pod,然后机器挂了。Pod 没了,谁来替你重新创建一个?

需要重新创建,但"重建"意味着要有一个东西 Watch Pod,发现死了就创建一个新的。

Pod 不关心 Node 挂了没有,Node 挂了它就真死了,没人给它复活。

这个"替你盯着 Pod 数量"的东西就是 Controller。你 kubectl apply 的不是 Pod 本身,而是更高层的抽象,让控制器帮你管 Pod。

K8S 内置了几种工作负载控制器,分别对应不同的运行模型。

Deployment:无状态应用的标配​

Deployment 是最常用的。管理模式很简单:你指定期望的 Pod 个数,Deployment 保证一定有这么多个在跑。

apiVersion: apps/v1
kind: Deployment
metadata:
name: nginx
spec:
replicas: 3
selector:
matchLabels:
app: nginx
template:
metadata:
labels:
app: nginx
spec:
containers:
- name: nginx
image: nginx:1.25

Deployment 自己不直接管 Pod。它创建一个 ReplicaSet,ReplicaSet 去管 Pod。这层间接关系让滚动更新成为可能。

Deployment v2 (replicas=3)
├── ReplicaSet v2 (desired=3, ready=2) ← 新的在逐步上来
│ ├── Pod-4 (Running, new)
│ └── Pod-5 (Running, new)
└── ReplicaSet v1 (desired=1, ready=1) ← 旧的逐步退场
└── Pod-1 (Running, old)

滚动更新时:创建新的 ReplicaSet、逐个起新 Pod,旧 ReplicaSet 逐个缩容,过程可暂停可回滚。

思考题:Deployment 滚动更新默认新旧 Pod 会短暂并存。如果你的应用绝对不能有两个副本同时跑——不光是更新时,日常运行时也只能有一个 Pod 在工作(比如定时任务、控制器),怎么保证?K8S 内部用什么机制实现选主(Leader Election)?

Deployment Controller 的源码逻辑在 k8s.io/kubernetes/pkg/controller/deployment/deployment_controller.go:

// 简化核心循环
func (dc *DeploymentController) syncDeployment(deployment *apps.Deployment) error {
// 1. 找到当前最新的 ReplicaSet
newRS, oldRSs, err := dc.getAllReplicaSetsAndSyncRevision(deployment)

// 2. 根据 strategy 决定扩缩多少个
// RollingUpdate: 新 RS +1, 旧 RS -1
// Recreate: 旧 RS 全删,新 RS 全部起
dc.scaleReplicaSets(deployment, newRS, oldRSs)

// 3. 如果全部更新完成,清理旧 RS
dc.cleanupDeployment(oldRSs, deployment)
}

发布与回滚​

Deployment 的 spec.revisionHistoryLimit(默认 10)控制保留多少个旧 ReplicaSet——这些旧 RS 不是活着,它们的 replicas=0,只是对象没删。这就是 Deployment 的发布历史——每次更新保存一个快照。

# 看发布历史
kubectl rollout history deployment/nginx
# REVISION CHANGE-CAUSE
# 1 <none>
# 2 <none>

# 看某个版本的具体信息
kubectl rollout history deployment/nginx --revision=2

# 回滚到上一个版本
kubectl rollout undo deployment/nginx

# 回滚到指定版本
kubectl rollout undo deployment/nginx --to-revision=1

回滚的底层逻辑不复杂:Deployment Controller 把旧 ReplicaSet(revision 1 的那个)的 spec 重新读出来,把当前 Deployment 的 template 改回去,然后 scaleReplicaSets 跑一遍——新 RS(revision 3 或 2)缩到 0,旧 RS(revision 1)扩到 3。流程跟正常滚动更新一模一样,只是"新"指的是那个历史 RS。

CHANGE-CAUSE 列默认是 <none>——因为你没记录。加上 --record 或写 annotation 才能看到:

kubectl annotate deployment/nginx kubernetes.io/change-cause="update nginx to 1.25"

在 CD 管道里,通常 CI 系统自动把 git commit hash 写进这个 annotation,这样 rollout history 直接能看到每个版本对应哪个 commit。

RollingUpdate 的精细控制——spec.strategy.rollingUpdate 下有两个参数:

spec:
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1 # 滚动期间最多允许超出 replicas 多少个 Pod
maxUnavailable: 0 # 滚动期间最多允许多少个 Pod 不可用
参数含义默认值
maxSurge更新期间副本数可以比 replicas 多多少个25%(replicas=4 时最多多 1 个)
maxUnavailable更新期间最多允许多少个 Pod 不可用25%

maxSurge=1, maxUnavailable=0 是最保险的配置——逐个替换,保证 replicas 时刻维持在 3 个(从不低于,短暂多 1 个)。代价是慢。反过来 maxSurge=0, maxUnavailable=1 表示先杀掉一个旧的再创建一个新的——replicas 会短暂掉到 2 个——但不需要多余资源。你在乎"服务从来不缺 Pod"就拉高 maxSurge,你在乎"节点资源紧张"就拉高 maxUnavailable。

如果两个都设为 0 呢?

rollingUpdate:
maxSurge: 0
maxUnavailable: 0

等效于:不能用多一个 Pod(没有额外资源),也不能少一个 Pod(不接受不可用窗口)。两边都锁死了——Controller 既不能创建额外的 Pod 也不能删除任何 Pod。更新发起后,新 RS 的 Pod 数为 0(因为 maxSurge=0 不让建),旧 RS 维持现有 Pod(因为 maxUnavailable=0 不让删)。结果——升级永远卡住,任何 Pod 都不会被替换。

API Server 不会拦截这个配置(它合法),但 Deployment Controller 在 scaleReplicaSets 中算出的 delta 永远是 0——新 RS 扩不了,旧 RS 缩不了。Deployment 的 status.conditions 里会出现 Progressing=False, reason=ProgressDeadlineExceeded。

为什么允许配成 0? 这不是 bug——它是一个故意的极端配置。某些场景下你想先把 Deployment 暂停(rollout pause),手动验证完再改参数放开。或者你想用 maxSurge=0, maxUnavailable=1 的"先杀后建"模式在资源紧张的集群上跑,临时设成 0 来停掉滚动。

最小值是 1:两个参数的计算基准是 replicas。maxSurge 取 ceil(25% × 3 = 0.75 → 1),maxUnavailable 取 floor(25% × 3 = 0.75 → 0)。所以默认值不会两个都等于 0——你得明确设。但如果 replicas=1 且你明确设了 maxSurge=0, maxUnavailable=0——那就真的卡死了。

暂停和恢复​

使用 kubectl rollout pause.

# 分段发布:先起两个验证,确认没问题再继续
kubectl set image deployment/nginx nginx=nginx:1.25
kubectl rollout pause deployment/nginx # 暂停——新的只起了 maxSurge 个

# 等几分钟,验证 Pod 正常...

kubectl rollout resume deployment/nginx # 继续滚动

暂停之后 Deployment 停在中间状态:一个旧 RS(replicas 接近满)、一个新 RS(replicas=1)。你可以先观察新 Pod 的日志、指标,确认无误再 resume。

PodDisruptionBudget:保护你的服务不被打断​

滚动更新你用 maxUnavailable 控制最少保留多少个 Pod——但"更新"不是唯一会停 Pod 的场景。还有:

  • Node 排空(drain):运维要升级 Node,kubectl drain 会逐出 Node 上的 Pod
  • 集群缩容:Cluster Autoscaler 回收空闲 Node,上面的 Pod 被驱逐
  • 抢占:高优先级 Pod 要跑,低优先级 Pod 被踢

这些"主动驱逐"(Voluntary Disruption)没有 Deployment 的 maxUnavailable 保护——直接 evict API 调用,Pod 就没了。

PDB(PodDisruptionBudget)就是在"自愿性中断"时保证服务最小可用副本的:

apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: nginx-pdb
spec:
minAvailable: 2 # 最少必须有 2 个 Pod Ready
selector:
matchLabels:
app: nginx

效果:对 app=nginx 的 3 个 Pod,PDB 说"任何时候至少给我留着 2 个"。kubectl drain 尝试驱逐这 3 个 Pod 时——K8S 不让同时赶走两个(那样只剩 1 个,< minAvailable=2)。驱逐按次序来:赶走 1 个 → 等 Scheduler 在别的 Node 上重建 → 新 Pod Ready → 再赶下一个。

kubectl drain worker-1
# Pod nginx-1: evicted → 新 Pod 调度到 worker-2
# Pod nginx-2: 等待...(drain 被 PDB 卡住,只剩 2 个 Pod Ready,不能再赶)
# Pod nginx-2: 等 nginx-1 Ready → evicted
# worker-1 排空完成

minAvailable vs maxUnavailable 的区别:

参数谁设的生效场景
deploy.spec.strategy.rollingUpdate.maxUnavailableDeployment更新期间——Controller 主动缩旧 RS
pdb.spec.minAvailable用户驱逐期间——kubectl drain / CA 缩容 / 抢占

两者叠加保护:Deployment 更新保持 ≥ 2 个旧版本 Pod + PDB 保证驱逐保持 ≥ 2 个总 Pod。

也可以用百分比:

spec:
maxUnavailable: 25% # 同一时刻最多 25% Pod 可以被驱逐

不写绝对数字,应对 HPA 伸缩——Pod 从 3 扩到 12,PDB 自适应。

PDB 不阻止非自愿中断——Node 崩溃、硬件故障——这些 PDB 管不了。PDB 只保护"有计划的、K8S 主动发起的驱逐",不挡天灾。

StatefulSet:有状态就得有身份​

数据库、Kafka、Redis——这些 Pod 不能随便删,也不能随便改顺序。

StatefulSet 三个跟 Deployment 不一样的地方:

  1. 固定名字:mysql-0 挂了重启还是叫 mysql-0,DNS 地址不变
  2. 有序操作:先起 mysql-0,等它 Ready 才起 mysql-1。删的时候倒序删
  3. 独立存储:每个 Pod 有自己专属的 PV,重启后挂回同一个 PV
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: mysql
spec:
serviceName: mysql-headless
replicas: 3
podManagementPolicy: OrderedReady # 有序启动,也可以配 Parallel
volumeClaimTemplates:
- metadata:
name: data
spec:
accessModes: ["ReadWriteOnce"]
resources:
requests:
storage: 10Gi

volumeClaimTemplates 是关键——每个 Pod 自动创建一个 PVC:data-mysql-0、data-mysql-1、data-mysql-2。Pod 被删后重启,K8S 把这个 PVC 重新挂给同名的 Pod,数据不丢。

podManagementPolicy: Parallel 允许一起起(跟 Deployment 一样),但要冒多节点竞争 Leader 的风险——大部分有状态中间件需要有序启动来保证集群成立的顺序。

StatefulSet 的发布与回滚​

StatefulSet 的升级跟 Deployment 完全不同——因为它必须尊重"固定身份"和"有序"这两个约束。

更新策略(updateStrategy):

spec:
updateStrategy:
type: RollingUpdate # 或 OnDelete
rollingUpdate:
partition: 0 # 金丝雀发布的入口
策略行为
RollingUpdate从最大 index 的 Pod 开始,倒序逐个重建(mysql-2 → mysql-1 → mysql-0)
OnDeleteController 不主动更新,只有当你手动删掉 Pod 后才用新 spec 重新创建

为什么倒序更新? StatefulSet 的 Pod 有严格的身份等级——mysql-0 通常是主库,mysql-1、mysql-2 是从库。倒序更新确保了主库是最后一个被重建的——可以给主从切换留时间。

partition 金丝雀发布——StatefulSet 没有 Deployment 的 pause/resume,但有更精准的 partition:

spec:
updateStrategy:
rollingUpdate:
partition: 2 # 只有 index >= 2 的 Pod 会被更新

效果:mysql-2 先重建为新版本,mysql-0 和 mysql-1 保持不变。你验证新版本 OK 后,把 partition 改成 0,剩下的 Pod 全部更新。

初始:partition=2
mysql-0 (v1) ← 不更新(index 0 < 2)
mysql-1 (v1) ← 不更新(index 1 < 2)
mysql-2 (v2) ← 已更新(index 2 >= 2)

验证 OK → partition=0
mysql-0 (v1) → 重建 → mysql-0 (v2)
mysql-1 (v1) → 重建 → mysql-1 (v2)
mysql-2 (v2) ← 不动

比 Deployment 的 pause/resume 更灵活——你可以控制哪些 Pod 先更新,而不是"先起两个再暂停"这种粗粒度控制。

跟 Deployment 回滚的底层差异:

Deployment 回滚靠保留旧的 ReplicaSet——每个 RS 存着一个完整的 Pod template,回滚就是切 RS。StatefulSet 靠 ControllerRevision——每次 spec 变更,StatefulSet Controller 创建一个 ControllerRevision 对象保存当次 template 的快照:

// k8s.io/kubernetes/pkg/controller/statefulset/stateful_set_control.go
func (ssc *defaultStatefulSetControl) updateStatefulSet(set *apps.StatefulSet, pods []*v1.Pod) {
// 1. 如果 spec 变了,创建一个 ControllerRevision 记录
currentRevision := getCurrentRevision(set) // 新的 spec
updateRevision := getUpdateRevision(set) // 旧的 spec

// 2. 从最大 index 的 Pod 开始,逐个检查并重建
for i := *set.Spec.Replicas - 1; i >= 0 && i >= partition; i-- {
pod := pods[i]
if pod.Labels["controller-revision-hash"] != updateRevision {
// 这个 Pod 的版本不对,删除它 → Controller 用新 spec 重建
ssc.podControl.DeletePod(set.Namespace, pod.Name, set)
}
}
}

底层没有"新 ReplicaSet"——只有 Pod 的版本标记。StatefulSet Controller 不创建 RS,它直接管理 Pod。Pod 被删掉后,Controller 看到"这个 index 的 Pod 没了",用当前最新的 spec template 重建它——这个新 spec 就是 updateRevision。

OnDelete 策略就更好理解了——Controller 完全不删 Pod。你手动 kubectl delete pod mysql-2,Controller 看到 mysql-2 没了,用当前 spec 重建一个新版本。手动删一个更新一个。这是最保守的策略——适合数据库这种"你来决定什么时候切"的场景。

回滚流程:

# StatefulSet 也有 rollout history
kubectl rollout history statefulset/mysql
# REVISION
# 1
# 2

kubectl rollout undo statefulset/mysql
# 跟 Deployment 一样——回到上一个 ControllerRevision 记录的 template

StatefulSet Controller 找到上一个 ControllerRevision,把 spec template 还原,然后倒序重建 Pod。如果当前是 partition=0,回滚会逐个重建所有 Pod。如果当前 partition=2 且只有 mysql-2 更新了——回滚也只重建 mysql-2。partition 在回滚中依然有效——它保护了还没被更新的低 index Pod。

一句话:Deployment 回滚靠切 RS(无状态,随便换),StatefulSet 回滚靠 ControllerRevision(有状态,按序重建,保留身份和 PVC)。

DaemonSet:每个节点一个​

日志收集、监控 agent、网络插件——这类东西需要在每台机器上跑一份。

DaemonSet 保证每个 Node 都跑一个指定 Pod(可以指定只跑某类 Node)。

// k8s.io/kubernetes/pkg/controller/daemon/daemon_controller.go 核心逻辑简化
func (dsc *DaemonSetsController) syncDaemonSet(ds *apps.DaemonSet) error {
nodeList, _ := dsc.nodeLister.List(labels.Everything())
for _, node := range nodeList {
shouldRun := nodeShouldRunDaemonPod(node, ds)
running := getPodsOnNode(node.Name, ds)
if shouldRun && len(running) == 0 {
// 这个节点该有但没有 -> 创建一个 Pod
dsc.podControl.CreatePods(ds.Namespace, ds.Spec.Template, ds, controllerRef)
} else if !shouldRun && len(running) > 0 {
// 这个节点不该有但有 -> 删掉
dsc.podControl.DeletePod(ds.Namespace, running[0].Name, ds)
}
}
}

逻辑简单粗暴:每个 Node 检查一遍,该有的补,不该有的删。所有控制循环内味都一样——Watch 期望状态,修正实际状态。

Job / CronJob:跑完就停​

HTTP 请求想并发处理可以用 Deployment。那跑个数据迁移脚本、跑个批处理呢?跑完就该停了。

apiVersion: batch/v1
kind: Job
metadata:
name: db-migration
spec:
completions: 1
parallelism: 1
template:
spec:
containers:
- name: migration
image: my-migration:v2
restartPolicy: Never # 失败了别重启,重试由 Job Controller 负责

backoffLimit 控制重试次数(默认 6 次);completions 说一共要跑成功多少次;parallelism 可以平行跑多个(比如跑一批图片处理,一起并发)。加上 schedule 就是 CronJob。

思考题:Deployment、StatefulSet、DaemonSet、Job——这四个类型覆盖了大部分场景,但如果你手上有一个具体的应用(比如 Elasticsearch、Fluentd、Nginx Ingress Controller),你能一眼说出最适合哪个吗?想一下它们对网络地址、存储、启动顺序、副本数的要求。

小结​

负载类型管什么特点
Deployment无状态 Pod滚动更新、回滚、可随时替换
StatefulSet有状态 Pod固定名、有序、独立存储
DaemonSet每个 Node 一个日志/监控/网络 agent
Job跑完就停批处理、重试

这些控制器的套路都一样:声明期望、Watch 差异、修正状态。区别只在于"什么算差异、怎么修正"。


解答​

1. 应用不能有多个副本同时跑怎么办?​

"不能多个同时跑"分两种情况,解法不一样:

情况 A:更新期间新旧不能并存

这是 Deployment 的更新策略问题——RollingUpdate 会短暂出现 N+1 个 Pod,用 Recreate:

spec:
strategy:
type: Recreate # 先杀光旧 Pod,再起新 Pod

更新流程:旧 Pod 被 SIGTERM → 完全消失 → 新 Pod 创建。新旧从不共存,但中间有几秒真空期。

情况 B:日常运行时,同一时刻只能有一个 Pod 是"主"

这才是你说的选主模式——不是"更新时别共存",而是集群里有多个副本,但只有一个在真正干活,其他都候着。比如:

  • 定时任务(CronJob):多个副本同时跑会把数据重复处理
  • 控制器(Operator):两个 controller 同时 Reconcile 同一个资源会互相覆盖
  • 数据同步工具:两个同步程序同时读写会造成脏数据

K8S 没有内置的"Power workment"来保证单主,但有一套标准化的选主机制——基于 Lease 资源 + client-go 的 leaderelection 包。

选主的底层资源:Lease

K8S 1.14 引入的 Lease 是一种轻量级协同资源:

apiVersion: coordination.k8s.io/v1
kind: Lease
metadata:
name: my-app-leader
namespace: default
spec:
holderIdentity: "my-app-7d8f9-cb4x2" # 当前 Leader 的身份
leaseDurationSeconds: 15 # 租约时长
acquireTime: "2024-01-15T10:00:00Z"
renewTime: "2024-01-15T10:00:05Z" # 每次续约会刷新这个时间

Leader 的工作就是不停地续约这个 Lease。Follower 在旁边观察——如果 renewTime 超过 leaseDurationSeconds 没更新,Leader 就认为挂了,Follower 开始抢。

Go 代码里的选主

import "k8s.io/client-go/tools/leaderelection"
import "k8s.io/client-go/tools/leaderelection/resourcelock"

lock := &resourcelock.LeaseLock{
LeaseMeta: metav1.ObjectMeta{Name: "my-app-leader", Namespace: "default"},
Client: client.CoordinationV1(),
LockConfig: resourcelock.ResourceLockConfig{Identity: podName},
}

leaderelection.RunOrDie(ctx, leaderelection.LeaderElectionConfig{
Lock: lock,
LeaseDuration: 15 * time.Second, // 租约多久过期
RenewDeadline: 10 * time.Second, // 续约的超时时间
RetryPeriod: 2 * time.Second, // 重试间隔
Callbacks: leaderelection.LeaderCallbacks{
OnStartedLeading: func(ctx context.Context) {
// 我成了 Leader——开始干活
runMainLoop()
},
OnStoppedLeading: func() {
// 我不再是 Leader了——停掉主循环
log.Fatal("lost leadership")
},
OnNewLeader: func(identity string) {
log.Printf("新 Leader 诞生: %s", identity)
},
},
})

这跟 StatefulSet 的固定身份有什么不同?

StatefulSet 的 mysql-0、mysql-1 不是"选"出来的——是固定的。mysql-0 永远是第一个启动的,它的身份跟 Pod 名字绑定。这对于有稳定拓扑的集群(如 Kafka Broker、MySQL 主从)是对的。

但如果是"几个无状态 Pod 竞争一个锁,拿到锁的人处理任务"——这就不是 StatefulSet 的固定身份能解决的了。用 Lease 选主,锁跟 Pod 名字解耦,任何 Pod 都能在 Leader 挂掉的瞬间顶上。

选主 vs Recreate 的区别

场景解法
更新时新旧 Pod 不能共存Deployment .spec.strategy.type: Recreate
定时任务不能重复执行基于 Lease 的选主——只有一个 Pod 跑主循环
多个 Operator 不能同时 Reconcileleaderelection 包——controller-runtime 默认就做了
数据同步工具单主Lease + 选主回调

2. 每个 workload 最适合什么场景?​

一句话判断法——问你的应用三个问题:

  1. 需要固定网络地址吗?(要 → StatefulSet)
  2. 需要每个节点跑一份吗?(要 → DaemonSet)
  3. 跑完就该停吗?(要 → Job)

然后落到具体例子:

Nginx 静态文件服务 → Deployment。

不需要固定 IP,不需要持久存储,多副本随便扩缩。对外的流量通过 Service 做负载均衡,任意一个 Pod 都能处理请求。

MySQL / PostgreSQL 单实例 → StatefulSet。

数据库的数据必须持久化(volumeClaimTemplates),Pod 重启后数据不能丢。mysql-0 的身份必须稳定——如果有主从复制,主库永远是 mysql-0。

Elasticsearch 集群 → StatefulSet。

ES 节点需要固定身份(es-0、es-1、es-2),因为节点间有 gossip 协议通过 hostname 互相发现。数据分片跟节点绑定,不能随便迁移。更推荐用 ECK Operator 而不是裸 StatefulSet。

Kafka → StatefulSet(或 Strimzi Operator)。

每个 Broker 有固定 ID、独立的数据目录、固定的内部 DNS。Broker 重启后保留同样身份,消费者不会丢位。

Redis(缓存) → Deployment。

只做缓存、数据可丢的场景,Deployment 就够了。Redis Cluster 或有持久化需求则建议 StatefulSet。

Fluentd / Filebeat / node-exporter → DaemonSet。

每个 Node 上跑一份——收集这台机器上的日志和指标。绝对不能多副本(同一个 Node 上两个 fluentd 会抢同一个日志文件),绝对不能零副本(有的 Node 没日志 agent 是盲区)。

Nginx Ingress Controller / Traefik → Deployment 或 DaemonSet。

两种模型都行。Deployment + HPA 副本数自动调;DaemonSet 每个 Node 强制有一个,适合集群节点数固定的场景。

数据迁移 SQL 脚本 → Job。

跑完就停,restartPolicy: Never。需要顺序执行多个步骤可以串联多个 Job。

证书自动续签(cert-manager) → CronJob。

"scheduling + Job"的组合,定期触发。cert-manager 每隔一定时间检查证书是否快过期,快到期就创建一个 Job 去 ACME challenge 换证书。

一个经验公式:

需要每个 Node 都跑? → DaemonSet
├─ 否 → 需要网络身份 + 持久存储? → StatefulSet
│ ├─ 否 → 跑完就停? → Job/CronJob
│ │ └─ 否 → Deployment

下一章我们看这些 Pod 之间怎么互通——网络。