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 会重新尝试;失败不会把旧版本推平

两个结论:

  1. 收敛是幂等的:场景 A 收敛后再跑一轮,没有任何动作;同样的期望执行 100 次,结果一样。
  2. 失败不推平旧版本:场景 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 发布策略)。

关联