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 的做法是:第一步只加(新字段/新表,双写),等所有实例都升级完,第二步才缩(删旧字段)。顺序是“先让两边都能跑,再清理”,这样任何时刻任意版本组合都能工作。

关联