33.2 微服务部署
一句话
微服务部署的难点不是“多写几份 YAML”,而是三笔账必须一起算:副本怎么分布(失败域)、发布时允许多少不可用(滚动参数)、一个可用区挂掉还剩多少容量(冗余系数)——三者算不清,副本数就是拍的。
部署单元:每个服务七类对象
| 对象 | 作用 | 少了会怎样 |
|---|---|---|
| Deployment / StatefulSet | 副本与版本 | 无法滚动、无法回滚 |
| Service | 稳定访问入口 | 调用方只能写 Pod IP |
| ConfigMap | 非敏感配置 | 配置打进镜像,改配置要重建 |
| Secret | 凭据 | 密钥散落在镜像里 |
| HPA | 按负载扩缩 | 只能按峰值配置,平时浪费 |
| PDB | 自愿中断时保底副本 | 节点维护时可能一次性杀掉全部副本 |
| 网络策略 / Ingress | 准入与暴露面 | 服务默认全通,横向移动无阻力 |
规模感:20 个服务 × 4 套环境 × 7 类对象 = 560 个部署对象。这 560 个对象里任何一个漂移,都是一次“只有出故障时才会发现”的配置差异(30.4 服务治理 (注册、发现、熔断、限流) 的漂移检测同理)。
💡 单个 Deployment 的字段怎么写、探针参数怎么给、GitOps 怎么收敛,主场在 54.3 部署编排。本节只算“N 个服务一起部署”时的容量与失败域。
失败域与三笔账
| 失败域 | 典型影响 | 靠什么控制 |
|---|---|---|
| 进程 | 单副本崩溃 | 副本数 ≥ 2、readiness 探针 |
| 节点 | 一台机器挂了,上面的 Pod 全没 | 反亲和(每个节点最多 1 个副本) |
| 可用区 | 一个 AZ 网络/电力故障 | 跨 AZ 打散 + 冗余系数 ≥ 1/(1-1/AZ数) |
| 区域 | 整个地域故障 | 多地域(主场在 36.4 两地三中心与多活) |
三笔账的关系:
正常容量 = 副本数 × 单 Pod 容量
滚动容量 = (副本数 − ceil(副本数 × maxUnavailable)) × 单 Pod 容量
AZ 故障容量 = (副本数 − 每 AZ 副本数) × 单 Pod 容量
三个数都必须 ≥ 峰值 QPS,否则就会在某一种状态下超载。
package main
import (
"fmt"
"math"
)
const (
peakQPS = 900.0 // 峰值
perPodQPS = 120.0 // 单 Pod 能扛的 QPS
azs = 3 // 可用区数
)
func main() {
fmt.Println("=== 副本分布与失败域 ===")
fmt.Printf("峰值 %.0f QPS,单 Pod %.0f QPS,%d 个可用区,滚动 maxUnavailable=25%%\n\n",
peakQPS, perPodQPS, azs)
fmt.Printf("%-6s %-6s %-10s %-14s %-12s %s\n", "副本", "每AZ", "正常容量", "滚动(-25%)", "单AZ故障", "判定")
for _, r := range []int{9, 12, 15} {
perAZ := r / azs
steady := float64(r) * perPodQPS
down := int(math.Ceil(float64(r) * 0.25))
roll := float64(r-down) * perPodQPS
azDown := float64(r-perAZ) * perPodQPS
verdict := "OK"
if roll < peakQPS || azDown < peakQPS {
verdict = "不满足峰值"
}
fmt.Printf("%-6d %-6d %-10.0f %-14.0f %-12.0f %s\n", r, perAZ, steady, roll, azDown, verdict)
}
// 反推:要扛住“单 AZ 故障 + 峰值”,至少多少副本
need := int(math.Ceil(peakQPS / (perPodQPS * (1 - 1.0/azs))))
needAZ := int(math.Ceil(float64(need)/azs)) * azs
minAvailable := int(math.Ceil(peakQPS / perPodQPS))
maxUnavailable := needAZ - minAvailable
fmt.Printf("\n单 AZ 故障仍扛峰值:至少 %d 副本 → 按 AZ 均匀放为 %d 副本(每 AZ %d)\n",
need, needAZ, needAZ/azs)
fmt.Printf("滚动发布时至少要保持 %d 个可用副本 → maxUnavailable ≤ %d 个(%d%%)\n",
minAvailable, maxUnavailable, maxUnavailable*100/needAZ)
fmt.Printf("冗余系数 = %d / %d = %.2f(即“为故障多备 %.0f%% 的容量”)\n",
needAZ, minAvailable, float64(needAZ)/float64(minAvailable),
(float64(needAZ)/float64(minAvailable)-1)*100)
}
本机实测(macOS,Apple M1,Go 1.27):
=== 副本分布与失败域 ===
峰值 900 QPS,单 Pod 120 QPS,3 个可用区,滚动 maxUnavailable=25%
副本 每AZ 正常容量 滚动(-25%) 单AZ故障 判定
9 3 1080 720 720 不满足峰值
12 4 1440 1080 960 OK
15 5 1800 1320 1200 OK
单 AZ 故障仍扛峰值:至少 12 副本 → 按 AZ 均匀放为 12 副本(每 AZ 4)
滚动发布时至少要保持 8 个可用副本 → maxUnavailable ≤ 4 个(33%)
冗余系数 = 12 / 8 = 1.50(即“为故障多备 50% 的容量”)
读数:
| 读数 | 含义 | 动作 |
|---|---|---|
| 9 副本:正常 1080 QPS 够,但滚动时只剩 720 | 发布期间超载 20%,表现为“每次发布都有几分钟 5xx” | 调小 maxUnavailable,或先扩容再发布 |
| 9 副本:单 AZ 故障只剩 720 | 一个可用区抖动就打穿峰值 | 副本数要按 1/(1-1/3) = 1.5 的冗余系数配 |
| 12 副本:滚动 1080、AZ 故障 960 | 两种状态都能扛住峰值 | 但仍然要先确认“每 AZ 均匀放”(反亲和) |
| 建议 maxUnavailable ≤ 4 个(33%) | 12 副本时,只要同时不可用 ≤ 4 个就安全 | 别用默认的 25% 就完事,要按峰值反推 |
🔑 副本数不是按“平时够用”配的,是按“最坏状态下仍够用”配的:
副本数 ≥ 峰值 / (单 Pod 容量 × (1 − 1/AZ数))。这个公式把“多活冗余”从感觉变成了一个乘法——AZ 越多,冗余系数越接近 1(3 AZ 要 1.5 倍,5 AZ 只要 1.25 倍)。
部署顺序:依赖方向决定顺序
| 顺序 | 做什么 | 为什么必须在这个位置 |
|---|---|---|
| 1 | 数据库变更(先扩后缩,Expand-Contract) | 新代码上线前,老代码必须仍能跑在新 schema 上(55.4 数据库变更管理) |
| 2 | 被依赖方(下游服务) | 上游先发会调用到不存在的接口 |
| 3 | 依赖方(上游服务) | 此时下游已就绪 |
| 4 | 网关路由 / 灰度规则 | 最后才切流量;切早了用户直接打到未就绪版本 |
| 5 | 数据回填 / 清理旧字段 | 在观察期之后,确认没有旧版本在读 |
⚠️ 这个顺序在“一次发布多个服务”时是硬约束。很多“分布式单体”的联合发布窗口,本质就是顺序无法拆开:数据库变更、上下游、网关必须一起动(30.3 分布式单体反模式 的锁步率)。
反模式
| 反模式 | 后果 |
|---|---|
| 副本数按平时 QPS 配,不考虑 AZ 故障 | 单 AZ 抖动即雪崩,且平时看不出来 |
| 没有反亲和,Pod 全落在一个节点 | 节点重启 = 服务全挂 |
| maxUnavailable 用默认值,从不按峰值反推 | 每次发布都有容量低谷,监控上表现为周期性 5xx |
| PDB 设成 minAvailable = 副本数 | 节点排空永远卡住,集群维护无法进行 |
| 所有服务共用一个 Ingress/网关配置仓 | 改一条路由要全量发布网关,风险与影响面对不上 |
| 先发上游再发下游 | 上游调用到旧接口,报错后回滚,回滚又依赖顺序 |
| 每个服务一套独立流水线,但没有统一的部署编排 | 20 条流水线各自为政,发布顺序靠人肉记 |
自检
Q1 12 个副本、3 个 AZ、单 Pod 120 QPS、峰值 900 QPS,maxUnavailable=25%。发布期间会不会超载?怎么改?
▸ 答案:25% × 12 = 3 个不可用,剩 9 个 × 120 = 1080 QPS,仍高于峰值 900,不会超载(这是刚好安全的情况)。但要注意两点:① ceil(12×0.25)=3,如果副本数是 10,ceil(2.5)=3,剩 7 个 × 120 = 840 < 900 就会超载;② 剩余容量 1080 对峰值 900 只有 20% 余量,发布期间如果叠加流量高峰或单 Pod 性能下降,就会打穿。稳妥做法是把 maxUnavailable 调到 2(剩 1200)或先扩容到 15 再发布。
Q2 为什么冗余系数是 1/(1-1/AZ数),而不是简单的“多备一个 AZ”?
▸ 答案:因为 AZ 故障时损失的是那一个 AZ 的副本,占比 1/AZ数。3 AZ 时损失 1/3,剩 2/3 容量,要让它仍 ≥ 峰值,总容量就必须 ≥ 峰值 × 3/2 = 1.5 倍。这就是冗余系数 1.5 的来源。5 AZ 时损失 1/5,系数降到 1.25。注意前提是副本在每个 AZ 均匀分布(反亲和 + 拓扑分布约束),否则“损失 1/3”不成立,可能一个 AZ 里放了 60% 的副本。
Q3 一个团队每次发布都会出现 3–5 分钟的 5xx 尖峰,但副本数、CPU、内存看起来都够。最可能的原因是什么?
▸ 答案:滚动期间的容量低谷(本节第一笔账)叠加就绪探针过早放流量。两个机制:① maxUnavailable 让可用副本数在发布期间下降,若这个数 × 单 Pod 容量 < 峰值,就会超载;② 新 Pod 的 readiness 探针如果在应用真正能服务之前就通过(比如只检查端口),流量会打到还没预热完的实例,表现为“新版本一上线就报错”。排查顺序:先算发布期间的可用容量,再看 readiness 探针检查的是端口还是真实依赖。
Q4 为什么“数据库变更”必须排在所有服务发布之前,而且要用 Expand-Contract 两步走?
▸ 答案:因为发布期间新旧版本同时在线(30.6 服务版本与兼容 的共存窗口)。如果先发代码再改表,新代码引用的字段还不存在;如果一次性改表(删字段/改类型),旧版本立刻读不到。Expand-Contract 的做法是:第一步只加(新字段/新表,双写),等所有实例都升级完,第二步才缩(删旧字段)。顺序是“先让两边都能跑,再清理”,这样任何时刻任意版本组合都能工作。