知序
返回
保存草稿
发布
H1
H2
H3
1
2
3
Excalidraw 图
AI 写作
AI 优化
目录
输入内容后显示目录
Markdown
# 06 - 交付与发布:把新版本安全送到集群 > 前置是「Docker 与容器交付」课程:镜像已经构建好、按不可变 tag 推入了私有仓库(例如 `registry.example.com/prod/order-service:sha-9f3a1c2`)。 > 本篇从那一步往下走,只回答三个问题:集群怎么知道有新版本、怎么保证发布过程中不掉线、出问题时怎么在几十秒内回滚。 > 不讲 Dockerfile,不讲流水线怎么写 —— 那是前置课的职责。 --- ## 1. 先划清边界 | 环节 | 谁负责 | 在哪门课 | |---|---|---| | 写 Dockerfile、构建镜像 | 「Docker 与容器交付」 | Docker 01 / 05 篇 | | 打不可变 tag、推私有仓库 | 「Docker 与容器交付」 | Docker 03 篇 `03-registry-and-tags` | | CI 流水线、凭据管理、自动打 tag | 「Docker 与容器交付」 | Docker 04 篇 `04-cicd` | | **触发集群更新、发布策略、回滚、灰度** | **本课** | **本篇(云原生 06)** | 本篇不讲 Dockerfile,也不讲 CI 配置怎么写;**起点是镜像已经按不可变 tag 构建好、已经推进了私有仓库**,从这里往下只处理"怎么把它安全地送进集群"。 > 一句话:**镜像怎么进仓库是前置课的事;镜像进仓库之后怎么安全地跑起来,才是本篇的事。** --- ## 2. 三种更新触发方式 ### 2.1 三种方式并列 集群不会自己去看仓库(除非上了 GitOps,见第 7 节),它必须被"推"一下:  *图:三种更新触发方式:声明式 apply、命令式 set image、滚动重启,各自的代价* ```text ┌── A 声明式 apply ────────┐ ┌── B 命令式 set image ────┐ ┌── C 滚动重启 restart ────┐ │ Git 里改镜像 tag │ │ kubectl set image │ │ kubectl rollout restart │ │ │ │ │ │ │ │ │ │ │ v │ │ v │ │ v │ │ kubectl apply -f │ │ 只改集群对象,不碰 Git │ │ 不改镜像,只换一批 Pod │ │ 集群 == Git,可追溯 │ │ 集群 != Git,会漂移 │ │ 配置改完用它生效 │ └──────────────────────────┘ └──────────────────────────┘ └──────────────────────────┘ ``` ```text $ kubectl apply -f deploy/order-service.yaml # A 声明式(推荐) $ kubectl set image deploy/order-service order-service=...:sha-9f3a1c2 # B 命令式 $ kubectl rollout restart deploy/order-service # C 滚动重启 $ kubectl rollout status deploy/order-service # 三种方式都靠它判成败 deployment "order-service" successfully rolled out ``` | 维度 | A. `kubectl apply -f` | B. `kubectl set image` | C. `kubectl rollout restart` | |---|---|---|---| | 与 Git 是否一致 | 一致 | **会漂移**,下次 `apply` 冲掉改动 | 漂移,但镜像没变,影响小 | | 适用场景 | 常规发布(推荐) | 救火、临时验证 | ConfigMap / Secret 变更后生效 | A、B 都会新建一个 ReplicaSet 并逐步接管副本,命令式最快的代价是**集群与仓库里的 YAML 会漂移** —— 下一次 `apply` 会把你手改的内容悄悄覆盖回去,这是"我明明改了啊"类问题的常见来源。 > 一句话:**能用 A 就用 A;B 适合救火;C 不解决版本问题,只解决"配置变了要重启"。** ### 2.2 一个高频坑:改 ConfigMap 不会重启 Pod ```text 你以为的:改了 ConfigMap → Deployment 发现配置变了 → 自动滚动更新 实际上的:ConfigMap 变了 → Deployment 完全不知道 → Pod 里跑的还是旧配置 原因:Deployment 只比较 spec.template 的哈希;ConfigMap 是另一个对象,模板里只是"引用了它的名字",名字没变 → 模板没变 → 不滚动。 ``` ```yaml # 解法:把配置内容的 checksum 写进 Pod 模板的注解 —— 内容一变,模板就变 spec: template: metadata: annotations: checksum/config: "8f3a1c2e" # ConfigMap 内容变了,就把这个字符串改掉 ``` 另一种解法是直接触发一次滚动:`kubectl rollout restart deployment/order-service`,幂等、最省事。上面那条 checksum 注解要用到的哈希可以这么算:`kubectl get cm app-config -n prod -o yaml | sha256sum | cut -c1-8`。注解的**位置**决定成败:必须写在 `spec.template.metadata.annotations` 下;写在 Deployment 自己的 `metadata.annotations` 里,改一百次也不会触发滚动(`kubernetes.io/change-cause` 就是一条"只记录、不触发"的注解)。Helm 里的 `checksum/config` 做的就是这个。 > 一句话:**Deployment 只盯着 Pod 模板,配置变了要主动触发一次滚动。** --- ## 3. 滚动更新的参数:把"不中断"变成可配置 ### 3.1 五个参数 ```yaml spec: replicas: 3 revisionHistoryLimit: 10 # 保留 10 个旧 ReplicaSet 当回滚点 minReadySeconds: 10 # Pod Ready 后再观察 10 秒,才算"可用" progressDeadlineSeconds: 600 # 10 分钟还没推进完,就标记这次发布失败 strategy: rollingUpdate: # type 不写就是默认的 RollingUpdate maxSurge: 1 # 更新期间最多多出 1 个 Pod maxUnavailable: 0 # 全程可用副本数不低于期望值 → 零停机 ``` | 参数 | 默认值 | 调大 / 调小的后果 | |---|---|---| | `maxSurge` | `25%`(3 副本即 1 个) | 调大:发布更快、更费资源;调成 `0` 就完全不允许多出副本 | | `maxUnavailable` | `25%`(3 副本即 1 个) | 调成 `1` 以上:可用副本数会掉、可能 503;**调成 `0` 才是零停机** | | `minReadySeconds` | `0` | 调大:能挡住"起来 3 秒就崩"的版本,但发布变慢;`0` 表示一 Ready 就算可用 | | `progressDeadlineSeconds` | `600` | 调小:启动慢的应用会被**误判**为失败;调大:真卡住时你发现得更晚 | | `revisionHistoryLimit` | `10` | 调小:能 `undo` 回去的版本变少(`0` 等于放弃回滚);调大:etcd 多占空间 | 关于 `progressDeadlineSeconds` 要澄清一点:**它不会自动回滚**,只是把 Deployment 标成 `ProgressDeadlineExceeded`,让 `rollout status` 返回错误。另外 `replicas` 至少要 `2`,单副本谈不上"不中断"。 ### 3.2 `maxUnavailable: 0` 的交替时序 零停机不是口号,它有确定的时序:**先起新的,等新的就绪,再停旧的**。  *图:maxSurge: 1 / maxUnavailable: 0 时的副本交替时序:可用副本数全程不低于期望值* ```text maxSurge: 1 / maxUnavailable: 0,期望副本数 3 [旧] 旧版本副本 [新] 新版本副本(已就绪) [启] 新版本副本(启动中,不计入可用) 空槽 = 无副本 时刻 槽 1 槽 2 槽 3 槽 4 可用副本数 说明 ───────────────────────────────────────────────────────────────────────────────── t0 [旧] [旧] [旧] 空槽 3 起点:恰好 3 个旧副本 t1 [旧] [旧] [旧] [启] 3 先多起 1 个(占 maxSurge 的位置) t2 [旧] [旧] [旧] [新] 4 新 Pod 已就绪,才允许动旧的 t3 空槽 [新] [新] [新] 3 停旧的、补新的往复,收尾时全部换新 ───────────────────────────────────────────────────────────────────────────────── 结论:可用副本数全程 ≥ 期望值(3),最多时是 4;代价是新旧交替期间多占 1 份资源。 ``` 零停机有**两个前提,缺一不可**。一是 `maxUnavailable: 0`,保证任何时刻可用副本数都不低于期望值;缺了它,新 Pod 还没起来就先把旧的停了,就会出现 503。二是 **readinessProbe 必须说真话** —— "就绪"是 Deployment 决定"能不能停旧的"的唯一依据;如果探针路径写错、或指向一个永远返回 200 的接口,新 Pod 一启动就被判定为就绪、流量立刻打上去,而应用还在初始化,返回 502/503,`maxUnavailable: 0` 一点忙都帮不上。这一点在「云原生」04 篇讲过:readiness 要指向 `/actuator/health/readiness`(依赖 db/redis),liveness 指向 `/actuator/health/liveness`(只看自己)。 > 一句话:**"零停机"不是一个开关,而是 `maxUnavailable: 0` 加"探针说真话"的组合;探针撒谎,参数配得再漂亮也是白搭。** --- ## 4. 回滚:必须演练过的能力 ### 4.1 四条命令 ```text $ kubectl rollout status deploy/order-service # 1. 盯住这次发布,不返回就别走开 $ kubectl rollout history deploy/order-service # 2. 看历史版本 $ kubectl rollout undo deploy/order-service # 3. 回到上一个版本 $ kubectl rollout undo deploy/order-service --to-revision=2 # 4. 回到指定版本 ``` `history` 里的 `CHANGE-CAUSE` 默认是 `<none>`(K8s 1.18 之后不再支持 `--record`),想让它有意义就自己打注解:`kubectl annotate deployment/order-service --overwrite kubernetes.io/change-cause="order-service:sha-9f3a1c2 新增优惠券计算"`。 ```text REVISION CHANGE-CAUSE 1 order-service:sha-7d1e4a9 首次上线 3 order-service:sha-9f3a1c2 新增优惠券计算 ``` ### 4.2 为什么回滚只需要几十秒 回滚快,是因为**它不需要重新构建任何东西**:旧 ReplicaSet 还在(只是被缩到 0),旧镜像也还在仓库里。 ```text $ kubectl get rs -l app=order-service NAME DESIRED CURRENT READY AGE order-service-6c9d8f7b 3 3 3 2m ← 当前版本 order-service-5b8f4c9d 0 0 0 18m ← 上一版,回滚点 ``` `rollout undo` 做的事就是:把新 RS 缩到 0、把旧 RS 拉回 3,之后只剩"起容器 + 等就绪"。3 副本、`minReadySeconds: 10` 的服务,从 `undo` 到全部就绪大约 **20~30 秒**。能回滚几个版本由 `revisionHistoryLimit` 决定(默认 10),设成 `0` 就等于放弃回滚能力。 ### 4.3 一次完整演练 **回滚不能等到出事才第一次用**,下面这个演练值得在预发环境真跑一遍: ```text $ kubectl set image deploy/order-service order-service=...:sha-bad001 # 1. 故意发一个坏版本 $ kubectl rollout status deploy/order-service --timeout=60s # 2. 新 Pod 起不来,会一直等到超时 error: deployment "order-service" exceeded its progress deadline $ kubectl get pods -l app=order-service && kubectl logs -l app=order-service --previous --tail=50 # 新 Pod 反复 CrashLoopBackOff,老 Pod 仍 Running $ kubectl rollout undo deploy/order-service # 3. 回滚 $ kubectl rollout status deploy/order-service # 4. 确认三个副本都就绪 → successfully rolled out ``` 注意:`rollout status` 只告诉你**卡住了**,不会告诉你原因;原因永远在 `get pods` / `describe pod` / `logs` 里 —— 这套定位链路就是「云原生」05 篇讲的东西。 ### 4.4 回滚回不去的东西 | `rollout undo` 能回滚 | `rollout undo` 回不去 | |---|---| | 镜像 tag、副本数、环境变量、探针、resources(`spec.template` 里的一切) | 已执行的数据库 schema 变更、已写入的数据、已发出的消息与生成的文件 | 所以数据库变更必须自己保证**向后兼容**:先加字段(允许 NULL、有默认值)→ 再发新代码、开始写新字段 → 确认稳定后才停用旧字段 → 再等一个发布周期才删旧字段。需要改字段含义时用**双写**(同时写新旧两处、读旧处,稳定后再切读取来源)。这样任何时刻回滚,数据都是完整的。 > 一句话:**能回滚的不是"代码",而是"Pod 模板";数据结构变更必须自己保证兼容。** --- ## 5. 三种发布策略 ### 5.1 三种策略的形态与取舍  *图:三种发布策略对比:滚动更新的代价、蓝绿的资源翻倍、金丝雀的指标前提* ```text ┌── 滚动更新 RollingUpdate ──┐ ┌── 蓝绿 Blue/Green ─────────┐ ┌── 金丝雀 Canary ───────────┐ │ 1 个 Deployment │ │ 2 个 Deployment 同时跑 │ │ 2 个 Deployment 按比例 │ │ │ │ │ │ │ │ │ │ │ v │ │ v │ │ v │ │ 新旧 Pod 混着接流量 │ │ Service 只指向其中一套 │ │ 新版本只接 1/6 的流量 │ │ 回滚:rollout undo │ │ 回滚:切 selector │ │ 回滚:新副本缩回 0 │ │ 代价:新版本最终占满 │ │ 代价:资源翻倍 │ │ 代价:得看懂指标 │ └────────────────────────────┘ └────────────────────────────┘ └────────────────────────────┘ ``` | 策略 | 资源开销 | 回滚速度 | 风险面 | 适用场景 | |---|---|---|---|---| | 滚动更新 | 约 +1 个副本 | `rollout undo`,20~30 秒 | 新旧混跑,问题会逐步波及**全部**副本 | 默认选择,绝大多数无状态服务 | | 蓝绿 | **2 倍**(两套同时跑) | 改 Service 的 `selector`,秒级 | 一次性全量切换,新版本的问题**全量**暴露 | 要求"整体切换、随时整体退回"、资源充足 | | 金丝雀 | 约 +1~2 个副本 | 把新副本缩回 0,秒级 | 只影响少量真实流量 | 有错误率 / 延迟指标可看,对发布风险敏感 | 滚动更新是默认选择,`maxSurge` 与 `maxUnavailable` 的组合建议只有三行: | 目标 | `maxSurge` | `maxUnavailable` | 说明 | |---|---|---|---| | 零停机(推荐) | `1` | `0` | 需要约 1 个副本的额外资源余量 | | 零停机 + 更快 | `3` | `0` | 一次多起 3 个,适合副本多、资源宽的集群 | | 资源紧张 | `0` | `1` | 不额外占资源,但更新期间可用副本少 1 个,副本少时会出现 503 | 它的短板只有一条:**没有"回头路的中途站"**。一旦开始,新版本会一批批铺开直到占满全部副本;如果新版本在每个副本上都会崩,你会在二十几秒内失去大部分容量,然后靠 `undo` 救回来。 ### 5.2 蓝绿:用 selector 一刀切 跑两套完整的 Deployment(`order-service-v1` / `order-service-v2`),除标注的三处外逐字相同: ```yaml # ① Deployment:v2 那份把下面标注的三处换成 v2 / v2 / 新 tag spec: replicas: 3 selector: { matchLabels: { app: order-service, version: v1 } } # v2 那份改成 version: v2 template: metadata: { labels: { app: order-service, version: v1 } } # v2 那份改成 version: v2 spec: containers: - name: order-service image: registry.example.com/prod/order-service:sha-7d1e4a9 # v2 换成 sha-9f3a1c2 --- # ② Service:一个入口,selector 指向哪一套,流量就去哪一套 spec: selector: { app: order-service, version: v1 } # ← 切流量只改这一行:v1 -> v2 ports: [{ name: http, port: 80, targetPort: 8080 }] ``` 切流量就一条命令,不重启任何 Pod:`kubectl patch svc order-service -p '{"spec":{"selector":{"app":"order-service","version":"v2"}}}'`。三个要点:切换前先确认 v2 三个副本都 Ready **并在 v2 上做一次冒烟测试**;切换是一次全量的,新版本的问题会同时影响 100% 的用户;切完后旧版本别忘缩容(`kubectl scale deployment/order-service-v1 --replicas=0`),忘了它你就一直在为两倍资源付费。 ### 5.3 金丝雀:先让一小部分流量试水 **做法一:两个 Deployment 共享同一个 Service,靠副本数比例分流。** 关键点是两份 Deployment 用**完全相同**的 Pod 标签,这样 Service 会把连接平均分给全部 Pod:稳定版 `replicas: 5`、金丝雀 `replicas: 1`,6 个 Pod 里就有约 1/6(17%)的流量进新版本,其余配置逐字段照抄。 ```bash kubectl scale deployment/order-service-canary --replicas=1 # 1 : 5 ≈ 17%,先看 10 分钟错误率与 P99 kubectl scale deployment/order-service-canary --replicas=3 # 没问题就放大到 3 : 3 ≈ 50%,之后把 stable 缩到 0 ``` **做法二:用 Ingress 的权重注解做精确比例**(以 nginx ingress 为例),新建一条只对金丝雀生效的 Ingress,backend 指向 `order-service-canary`,比例写在这条注解里: ```yaml metadata: annotations: nginx.ingress.kubernetes.io/canary: "true" nginx.ingress.kubernetes.io/canary-weight: "10" # 10% 流量进新版本 ``` 改 `canary-weight` 就能调比例(`0` 到 `100`),不用动 Pod 数量,比"按副本数比例"精确得多;代价是这套注解**与 Ingress 实现绑定**,换控制器就要重写。金丝雀能不能用,取决于**你有没有真实可读的指标**:判据应该是具体的错误率(金丝雀版本 5xx 占比与稳定版之差 < 0.1%)、延迟(P99 不超过稳定版的 1.2 倍)和业务指标(下单成功率没有可观测的下降),而不是"看起来没报错"。 > 一句话:**小团队先用"滚动更新 + 练熟回滚";有了真实的错误率与延迟指标,再考虑金丝雀。** --- ## 6. 发布前检查表 ```text □ 镜像用的是不可变 tag(sha-xxxxxxx 或语义化版本),不是 latest □ readinessProbe 的路径与端口和本次代码一致(改过路径或端口的版本尤其要确认) □ strategy 里设了 maxUnavailable: 0,且 replicas >= 2(单副本没有"不中断"可言) □ 回滚演练过:rollout undo 至少真跑过一次,且清楚上一个 tag 是什么 □ 本次数据库变更是向后兼容的(先加字段再加代码,先停用旧字段再删) □ 能通过指标或日志判断这次发布成功还是失败(错误率、P99、关键业务日志) □ 明确谁有权叫停,以及叫停后执行哪一条命令(通常是 rollout undo) □ ConfigMap / Secret 改过之后,主动触发过一次滚动(restart 或 checksum 注解) □ progressDeadlineSeconds 与应用启动时间匹配(别比冷启动还短) □ 仓库里留着上一版镜像,且 revisionHistoryLimit > 0 ``` --- ## 7. 再往前一步:GitOps(点到为止) 前面所有方式都有一个共同点:**"把新 tag 写进集群"是由人(或 CI)主动做的**。GitOps 把这动作收走 —— 部署清单放在一个 Git 仓库里,集群里的 Argo CD 持续监听它;你只改仓库那一行 image tag(一次 commit),控制器发现实际状态与 Git 不一致就自动同步。于是本篇的"触发 + 回滚"变成"**提交 + revert**":发布 = 一次 commit,回滚 = `git revert`;CI 也不再需要挂在集群上的高权限凭证。这属于"下一步可以学什么",本课不展开。 --- ## 8. 踩过的坑 ```text 现象:改了 ConfigMap,Pod 里的配置还是旧的 → 原因:只有 spec.template 变了才触发滚动,ConfigMap 的变化 Deployment 看不见 对策:kubectl rollout restart deploy/order-service;或在 template 上加配置 checksum 注解 现象:kubectl rollout status 一直不返回,最后报 exceeded its progress deadline → 原因:新 Pod 起不来(镜像拉不到 / 启动报错 / 探针不过) 对策:另开终端 get pods 看状态、describe pod 看 Events、logs --previous 看崩溃前日志 现象:回滚了,但问题还在 → 原因:问题不在代码,而在数据库 schema 或已写入的数据(undo 只换 Pod 模板) 对策:发布前就保证变更向后兼容;纯数据问题只能靠迁移脚本或补偿数据修复 现象:kubectl set image 之后,Git 里的 YAML 和集群里的对不上 → 原因:命令式改动不回写 Git,两边开始漂移,下次 apply 会把改动冲掉 对策:把改动补回 Git 再 apply 一次;或走 GitOps,让集群只从 Git 同步 现象:蓝绿切换很成功,下个月账单翻倍 → 原因:切完 selector 后旧版本 Deployment 还在跑,一直占着 2 倍资源 对策:观察稳定后把旧版本 scale 到 0,并把这一步写进发布流程 现象:金丝雀比例是对的,但新版本收到的几乎都是老请求(或反之)→ 原因:长连接 / 连接池不会重新做负载均衡,老连接一直挂在旧 Pod 上 对策:发布时让客户端重连(或设连接最大存活时间),用新连接的错误率做判据 ``` --- ## 9. 本篇小结 ```text 镜像已在私有仓库(不可变 tag:sha-9f3a1c2) │ v 触发更新(apply / set image / rollout restart)—— 只有 Pod 模板变了才会滚动 │ v 选择发布策略(滚动 / 蓝绿 / 金丝雀) │ v kubectl rollout status 判成败 —— 卡住就是失败,别等它自己好 │ ├─ 成功 ──> 观察指标,确认稳定后再清理旧版本 └─ 失败 ──> rollout undo(20~30 秒回到上一个 ReplicaSet);数据变更回不去 ``` --- ## 10. 课程回顾 ```text 00 总览 为什么需要 K8s、学习路线、环境准备(kind / minikube / 云托管) 01 核心对象 声明式 / API / 控制器循环 + 集群架构 + Pod + Deployment 02 网络暴露 为什么不能用 Pod IP + Service 四种类型 + Ingress 03 配置数据 ConfigMap / Secret / Volume,配置与镜像分离 04 完整实战 Spring Boot + MySQL 从镜像到外网,含零停机验证 05 排障速查 排障链路 + 命令速查 + 异常对照 + 生产清单 06 交付发布 触发更新 + 滚动参数 + 回滚演练 + 三种发布策略 ← 本篇 ```
预览
发布文章
添加摘要让读者更快了解文章内容
取消
确认发布
AI 写作助手
输入一个主题,AI 将为你生成一篇 Markdown 博客文章
AI 正在写作中...
取消
开始生成
AI 优化助手
描述你的修改需求,AI 将在原文基础上进行补充或微调
AI 正在优化中...
取消
开始优化
画布
从已有图里选
✕
图名
保存后生成 /images/excalidraw/<图名>.svg(只能用字母、数字、短横线、下划线、点)
取消
保存并插入