54.3 部署编排
一句话
部署编排不是“把命令发到机器上”,而是把期望状态写成可 diff 的声明、交给控制器反复收敛;判断一套编排好不好,只看三件事——现在跑的是什么、和期望差在哪、失败怎么退。
先破除四个误区
| 误区 | 现实 |
|---|---|
| “部署脚本就是编排” | 脚本执行一次就结束:失败重试、机器漂移、并发发布全靠人。编排的核心是持续收敛,不是“发一遍命令” |
“kubectl apply 返回成功就是部署成功” | apply 只写入期望状态。Pod 可能一个都没起来,镜像可能拉不动,探针可能一直失败 |
| “编排必须上 K8s” | 3 台机器、1 个服务,systemd + 健康检查就够。K8s 的复杂度要有人付:网络、存储、升级、RBAC,都是长期成本 |
| “声明式 = 自动” | 没有健康门禁和 diff,声明式只是“把 YAML 发出去”。自动化的是提交,不是正确性 |
编排到底在编排什么
| 要素 | 回答的问题 | 缺失后的症状 |
|---|---|---|
| 期望状态 | 应该有几个实例、什么 digest、多少资源、什么网络策略 | 手改机器后没人知道“标准”是什么,下次部署靠猜 |
| 收敛循环 | 实际与期望不一致时,谁去修 | 节点重启、进程崩溃后不恢复,靠人巡检 |
| 健康门禁 | 新实例真的能接流量吗 | 流量打到启动中的实例,发布期间 5xx 升高 |
| 批次与顺序 | 先起谁、后停谁、失败停在哪 | 全量同时替换,失败就是全站不可用 |
这四件事和 7.1 声明式 API 与控制器模式 里的“期望状态 / 实际状态 / 调谐循环”是同一套东西;部署编排是它在交付链路上的落地:谁写期望、谁审期望、失败怎么退。
六种做法对比
| 做法 | 幂等 | 持续收敛 | 多环境 | 回滚 | 学习/运维成本 | 适用 |
|---|---|---|---|---|---|---|
| systemd + 脚本 | 弱(要自己写) | 无(只在重启时) | 手写 | 换二进制 + restart | 低 | 单机、少量服务 |
| Docker Compose | 中(up 幂等) | 无(不纠偏) | 多文件 | 换镜像 + up | 低 | 单机多容器、开发环境 |
| Ansible | 中(模块幂等) | 无(执行即结束) | inventory | 重跑旧 playbook | 中 | 配置管理、传统机 |
| K8s + Helm | 强 | 有 | values 文件 | helm rollback | 高 | 中大型、已有集群 |
| K8s + Kustomize + Argo CD | 强 | 有,且以 git 为准 | overlay | git revert / rollback | 高 | 多环境、多团队、要审计 |
| Nomad / ECS 等 | 强 | 有 | 任务定义 | 切版本 | 中 | 非 K8s 生态 |
| 你的情况 | 选 |
|---|---|
| 1–3 台机器、单体、能接受短暂停机 | systemd + 健康检查,别上 K8s |
| 单机多容器、开发或小规模生产 | Compose |
| 已有 K8s、服务不多 | Deployment + Kustomize |
| 多环境、多人改清单、要审计“谁改了什么” | GitOps(Argo CD / Flux) |
| 强依赖顺序、一次性任务(迁移、批处理) | Job + InitContainer,或工作流引擎 |
| 没有 K8s 但要多机调度 | Nomad / ECS |
一份能用的 Deployment:门禁都在字段里
apiVersion: apps/v1
kind: Deployment
metadata:
name: order
namespace: prod
spec:
replicas: 3
revisionHistoryLimit: 5 # 保留几个旧 ReplicaSet,回滚要用
progressDeadlineSeconds: 300 # 300s 没进展就标记失败,默认 600s 太长
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1 # 允许多起 1 个,才有腾挪空间
maxUnavailable: 0 # 发布期间不允许少一个可用实例
template:
metadata:
labels: {app: order}
spec:
terminationGracePeriodSeconds: 30 # 给优雅退出留时间,配合 preStop
containers:
- name: order
# digest 占位:真实值从制品仓库取,不要写 tag
image: registry.example.com/prod/order@sha256:9f1c2ab77d00112233445566778899aabbccddeeff00112233445566778899aabb
ports: [{containerPort: 8080}]
readinessProbe: # 决定“能不能接流量”
httpGet: {path: /readyz, port: 8080}
periodSeconds: 5
failureThreshold: 2 # 5s × 2 = 10s 判定不可用
livenessProbe: # 决定“要不要重启”
httpGet: {path: /healthz, port: 8080}
periodSeconds: 10
failureThreshold: 3 # 30s 才重启,别太敏感
startupProbe: # 给慢启动留时间,避免被 liveness 误杀
httpGet: {path: /healthz, port: 8080}
periodSeconds: 2
failureThreshold: 30 # 最多等 60s
resources:
requests: {cpu: 200m, memory: 256Mi}
limits: {memory: 512Mi}
image 里的 digest 从 54.2 镜像与仓库 取,不要写 tag——tag 会漂,digest 不会。
四个字段决定了“失败怎么退”:
| 字段 | 作用 | 设错的后果 |
|---|---|---|
readinessProbe | 就绪才进 Service 端点 | 没有它:启动中的实例接流量,发布期间 5xx |
livenessProbe | 不健康就重启容器 | 探到下游依赖:下游抖动 → 全体重启 → 雪崩 |
startupProbe | 启动期间不执行 liveness | 慢启动服务被反复杀死,永远起不来 |
progressDeadlineSeconds | 多久没进展算失败 | 默认 600s,卡住的发布会“静默等待”10 分钟 |
🔑 readiness 管流量,liveness 管重启,startup 管启动期豁免。三者混用是发布事故的常见来源:把依赖检查塞进 liveness,等于把下游故障放大成自己的重启风暴。
控制器如何做自愈与滚动、Helm 如何打包分发、基础设施层如何声明,分别在 7.6 自愈、滚动更新与回滚、7.11 包管理与 Helm、14.2 Terraform 与声明式基础设施。
探针参数怎么给
| 参数 | 建议起点 | 怎么定 |
|---|---|---|
periodSeconds | 5–10s | 越小发现越快,但请求越多;按“能容忍多久的坏实例”反推 |
failureThreshold | 2–3 | 检测时间 ≈ periodSeconds × failureThreshold;readiness 可以激进,liveness 必须保守 |
timeoutSeconds | 1–3s | 要小于 period,否则探针自己会堆积 |
startupProbe.failureThreshold | 启动时间 / period | 按 P99 启动时间给 2 倍余量 |
示例:3 副本、每批 1 个新实例、启动到就绪约 20s,全量发布约 60–90s。检测一个坏实例 readiness 需要 10s,liveness 误杀需要 30s——这两个数字要写进发布预案,否则值班时说不清“为什么还没切完”。
GitOps:把“期望”放进 git
CI:代码 → 构建 → 推 digest → 改 git 里的 digest(开 PR)
CD:Argo CD watch git → 与集群 diff → sync → 等健康 → 标记 Synced/Healthy
人:只改 git;kubectl 手改会被 drift 检测显示出来,selfHeal 时被纠正
# argocd/application-order.yaml
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata: {name: order-prod, namespace: argocd}
spec:
project: default
source:
repoURL: https://git.example.com/platform/deploy.git
targetRevision: main
path: overlays/prod
destination: {server: "https://kubernetes.default.svc", namespace: prod}
syncPolicy:
automated: {prune: true, selfHeal: true} # 手改会被纠正
syncOptions: [CreateNamespace=true]
# overlays/prod/kustomization.yaml:同一份 base,只覆盖“该不同的”
resources: [../../base]
images:
- name: registry.example.com/prod/order
newDigest: sha256:9f1c2ab77d00112233445566778899aabbccddeeff00112233445566778899aabb
replicas:
- {name: order, count: 6}
overlay 只覆盖“该不同的”部分:同一份 base 保证结构一致,差异显式写在 overlay 里,检查器才有东西可比(见 54.4 环境一致性)。
GitOps 的价值不是“用 git 存 YAML”,而是三件事:审计(谁在什么时候把哪个 digest 改成什么)、diff(集群与期望差在哪)、回滚(revert 一个 commit)。
一个能跑的收敛器:先起新的,门禁通过再删旧的
package main
import "fmt"
const (
imageOld = "order@sha256:old"
imageNew = "order@sha256:new"
)
type pod struct {
name string
image string
ready bool
}
// probe 模拟就绪探针:healthy=false 时,新镜像永远不 ready。
// ready 不是“进程起来了”,而是“能接住流量了”——这正是门禁与 liveness 的分界线。
func probe(p pod, healthy bool, round int) bool {
if p.image == imageOld {
return true
}
return healthy && round >= 2
}
type orchestrator struct {
replicas int
image string
old []pod
fresh []pod
}
// step 是控制器的一轮:只做“把实际推向期望”的动作,不做别的。
func (o *orchestrator) step(healthy bool, round int) []string {
var actions []string
// ① 先起新实例(surge),旧实例仍在服务——没有这一步,失败就是全站中断。
for len(o.fresh) < o.replicas {
name := fmt.Sprintf("order-new-%d", len(o.fresh)+1)
o.fresh = append(o.fresh, pod{name: name, image: o.image})
actions = append(actions, "create "+name)
}
// ② 探针判定每个新实例是否可接流量。
for i := range o.fresh {
if !o.fresh[i].ready && probe(o.fresh[i], healthy, round) {
o.fresh[i].ready = true
actions = append(actions, "ready "+o.fresh[i].name)
}
}
// ③ 门禁:新实例全部 ready 才允许删旧实例。
ready := 0
for _, p := range o.fresh {
if p.ready {
ready++
}
}
if ready == o.replicas && len(o.old) > 0 {
actions = append(actions, fmt.Sprintf("gate passed: delete %d old", len(o.old)))
o.old = nil
}
return actions
}
func (o *orchestrator) readyOld() int {
n := 0
for _, p := range o.old {
if p.ready {
n++
}
}
return n
}
func (o *orchestrator) readyNew() int {
n := 0
for _, p := range o.fresh {
if p.ready {
n++
}
}
return n
}
func oldPods(n int) []pod {
out := make([]pod, n)
for i := range out {
out[i] = pod{name: fmt.Sprintf("order-old-%d", i+1), image: imageOld, ready: true}
}
return out
}
func main() {
for _, tc := range []struct {
name string
healthy bool
}{
{"场景 A:新版本健康", true},
{"场景 B:新版本永远不 ready", false},
} {
fmt.Println("==", tc.name, "==")
o := &orchestrator{replicas: 3, image: imageNew, old: oldPods(3)}
converged := false
for round := 1; round <= 3; round++ {
actions := o.step(tc.healthy, round)
fmt.Printf("round %d 旧 %d/3 ready 新 %d/3 ready actions=%v\n",
round, o.readyOld(), o.readyNew(), actions)
if o.readyNew() == 3 && len(o.old) == 0 {
fmt.Printf("converged in %d rounds: image=%s ready=3/3\n", round, o.image)
converged = true
break
}
}
if !converged {
// 门禁超时:删掉不健康的新实例,旧实例原样保留。
fmt.Printf("gate timeout after 3 rounds (≈30s) → delete %d new, keep %d old\n",
len(o.fresh), len(o.old))
o.fresh = nil
fmt.Printf("result: 部署失败,服务仍由旧版本承接 ready=%d/3\n", o.readyOld())
fmt.Println("下一轮 reconcile 会重新尝试;失败不会把旧版本推平")
} else {
// 幂等:同一份期望再跑一轮,没有新动作。
again := o.step(tc.healthy, 4)
fmt.Printf("再跑一轮(幂等检查):actions=%v\n", again)
}
fmt.Println()
}
}
本机运行结果:
== 场景 A:新版本健康 ==
round 1 旧 3/3 ready 新 0/3 ready actions=[create order-new-1 create order-new-2 create order-new-3]
round 2 旧 0/3 ready 新 3/3 ready actions=[ready order-new-1 ready order-new-2 ready order-new-3 gate passed: delete 3 old]
converged in 2 rounds: image=order@sha256:new ready=3/3
再跑一轮(幂等检查):actions=[]
== 场景 B:新版本永远不 ready ==
round 1 旧 3/3 ready 新 0/3 ready actions=[create order-new-1 create order-new-2 create order-new-3]
round 2 旧 3/3 ready 新 0/3 ready actions=[]
round 3 旧 3/3 ready 新 0/3 ready actions=[]
gate timeout after 3 rounds (≈30s) → delete 3 new, keep 3 old
result: 部署失败,服务仍由旧版本承接 ready=3/3
下一轮 reconcile 会重新尝试;失败不会把旧版本推平
两个结论:
- 收敛是幂等的:场景 A 收敛后再跑一轮,没有任何动作;同样的期望执行 100 次,结果一样。
- 失败不推平旧版本:场景 B 中新版本永远起不来,门禁超时后删新留旧,服务保持 3/3。反过来(先删旧再起新)就是一次全站中断。
滚动、蓝绿、金丝雀是发布策略,决定“新旧怎么共存、流量怎么切”,见 55.1 发布策略;这里只回答“编排如何保证收敛与门禁”。
生产落地:指名 → 可抄 → 可验 → 可退
指名:清单用 Kustomize(无需模板语言,天然可 diff)或 Helm(需要参数化/打包分发时);CD 用 Argo CD(UI、diff、sync 状态清晰)或 Flux(纯 GitOps、轻量)。替代:只有单机时用 systemd,不要为了“统一”上 K8s。
可抄(本机无集群,以下命令未验证):
kubectl apply -f argocd/application-order.yaml
kubectl -n argocd get applications
# 预期:order-prod Synced Healthy
argocd app diff order-prod # 集群 vs git,差在哪
argocd app sync order-prod --revision <git-sha>
argocd app rollback order-prod <history-id>
可验:本节 Go 程序真跑过,两个场景的输出如上;它验证的是“收敛 + 门禁 + 幂等”这三条性质,不依赖集群。集群侧至少验证:kubectl rollout status deploy/order 能等到就绪;故意填一个不存在的 digest,发布应卡住并最终被 progressDeadlineSeconds 标记失败,而旧 Pod 仍在服务。
可退:
- 发布失败:
kubectl rollout undo deploy/order回到上一个 ReplicaSet(受revisionHistoryLimit限制);GitOps 下是 revert 那个 commit。 - 集群与 git 不一致:先
argocd app diff看清差异,再决定 sync 还是修 git;不要直接手改集群掩盖漂移。 - 高危发布:临时关掉
automated,改为手动 sync;回滚后确认Healthy再恢复自动同步。
反模式
| 反模式 | 后果 |
|---|---|
用 kubectl edit 热修 | 下次 sync 被覆盖;或长期漂移,git 不再是事实 |
| 探针里检查下游依赖 | 下游抖动 → 全体重启 / 全体摘流量,故障放大 |
| 没有 readinessProbe | 启动中的实例接流量,发布期间 5xx |
单副本 + maxSurge: 0 | 没有腾挪空间;想不停机就得先加副本 |
用 sleep 60 当健康检查 | 慢且不可靠:起得快白等,起得慢仍失败 |
部署脚本里 latest | 说不清在跑什么,回滚无目标(见 54.1 不可变制品) |
| 迁移脚本和 Deployment 同时发 | 新代码先跑、表还没改;顺序与回滚见 55.4 数据库变更管理 (Expand-Contract) |
没有 progressDeadlineSeconds | 卡住的发布静默等待 10 分钟,值班以为“还在滚” |
自检
Q1 kubectl apply 返回 configured,为什么不能说“部署成功”?
▸ 答案:apply 只把期望状态写进 API server,控制器随后异步收敛:创建 Pod、拉镜像、启动、探针通过、加入端点。成功判据是 rollout status 就绪 + 探针通过 + 业务冒烟,不是命令退出码。把“提交成功”当“部署成功”,是发布事故里最常见的错觉。
Q2 readinessProbe 和 livenessProbe 探错了对象,分别会怎样?
▸ 答案:readiness 探下游依赖 → 下游一抖动,所有实例被摘流量,等于把下游故障升级为自身全站不可用;liveness 探下游依赖 → 下游一抖动,容器被重启,重启风暴让恢复更慢。正确做法:readiness 只探“本实例能否服务”(端口、初始化完成),liveness 只探“进程是否卡死”,依赖健康交给熔断与监控(见 35.6 熔断与降级)。
Q3 为什么 GitOps 能治“手改机器”?
▸ 答案:因为期望状态在 git,控制器持续对比并纠正(selfHeal);手改会显示为 OutOfSync,可审计、可追责,回滚就是 revert 一个 commit。它把“谁动了生产”从口头记忆变成版本历史。前提是:所有资源都进 git,且没人绕过控制器直接改。
Q4 单副本、maxSurge: 0、maxUnavailable: 1 会发生什么?如果两个都设 0 呢?
▸ 答案:前者先删旧再起新,发布期间有停机窗口(单副本时就是 100% 不可用);后者 API 直接拒绝,因为 maxSurge 与 maxUnavailable 不能同时为 0——K8s 不允许一个“既不允许多起、也不允许少一个”的滚动。要不停机,至少给 maxSurge: 1,或把副本数加到 2 以上。
Q5 部署失败时,怎样保证服务不中断?
▸ 答案:顺序必须是“先起新 → 门禁通过 → 再删旧”,失败则删新留旧。这要求:容量留有余量(能多起 1 个)、readiness 真实反映可服务、门禁有超时(progressDeadlineSeconds)。如果编排语义是 Recreate(先删后起),就没有这个性质——这正是滚动/蓝绿策略要解决的问题(55.1 发布策略)。