发布出事很少是"新版本代码有 bug"这一类,多数是编排层面的三件事:滚动参数把可用副本压到 0,探针让流量在进程就绪前打进来,配置改了但没触发重启。这几个问题都不在业务代码里,却都表现为 5xx。
发布方案要给三个答案:切换方式、回滚速度、配置怎么进容器。
1. 四种发布策略
| 策略 | 停机 | 回滚速度 | 资源开销 | 适用 |
|---|---|---|---|---|
| 滚动(RollingUpdate) | 无 | 中(再滚一次) | 低(原地替换) | 默认首选 |
| 蓝绿(Blue/Green) | 无 | 快(切 Service) | 高(双份资源) | 关键服务、要秒级回退 |
| 金丝雀(Canary) | 无 | 快(调权重) | 中 | 有流量比例可控的入口 |
| 重建(Recreate) | 有 | 中 | 低 | 单实例、有状态、不可并存 |
没有入口流量控制能力(没有网关/Service Mesh)时,金丝雀只能靠副本数近似——比如 10 个副本里先起 1 个新版本,本质是粗粒度滚动。
2. 滚动更新的四个参数
| 参数 | 默认 | 作用 | 调错的后果 |
|---|---|---|---|
maxSurge |
25% | 允许超出期望副本的数量 | 太大吃满资源,新 Pod 卡 Pending |
maxUnavailable |
25% | 允许同时不可用的数量 | 太大容量瞬时不足,5xx |
minReadySeconds |
0 | 就绪后再观察多久才算可用 | 为 0 时"刚起来就报错"的版本直接进池 |
progressDeadlineSeconds |
600 | 无进展多久判定失败 | 太小误判慢启动为失败,反复重试 |
spec:
replicas: 6
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 2 # 峰值 8 个 Pod
maxUnavailable: 1 # 最低 5 个可用
minReadySeconds: 15
progressDeadlineSeconds: 600
revisionHistoryLimit: 10
容量算式:滚动期间最低可用副本 = replicas - maxUnavailable。6 - 1 = 5,即发布过程中至少损失 1/6 容量,若线上水位已到 80%,这一下就会打满。
反过来说,maxSurge: 0 + maxUnavailable: 1 是"省资源但降容量"的组合;maxSurge: 1 + maxUnavailable: 0 是"不降容量但要多一份资源"。二者不能同时为 0,否则滚动永远无法推进。
3. 探针与流量次序
| 探针 | 答什么问题 | 失败后果 | 别用它做什么 |
|---|---|---|---|
startupProbe |
进程起来了没 | 重启容器 | 判业务可用性 |
readinessProbe |
能接流量了没 | 摘出 Endpoints | 判进程死活 |
livenessProbe |
还活着没 | 杀容器重启 | 探数据库/下游 |
次序是硬约束:readinessProbe 通过 → Pod 进 Endpoints → 才有流量。所以任何"启动后要预热 30 秒"的服务,都必须让 readiness 在这 30 秒里返回失败,而不是让 livenessProbe 去做这件事。
startupProbe:
httpGet: { path: /actuator/health/liveness, port: 8080 }
periodSeconds: 5
failureThreshold: 36 # 最多等 180s,覆盖 JVM + 预热
readinessProbe:
httpGet: { path: /actuator/health/readiness, port: 8080 }
periodSeconds: 5
failureThreshold: 2
livenessProbe:
httpGet: { path: /actuator/health/liveness, port: 8080 }
periodSeconds: 10
failureThreshold: 3
Spring Boot 3.3.x 里 management.endpoint.health.probes.enabled=true 会提供 /actuator/health/liveness 与 /actuator/health/readiness 两组端点,且 readiness 组默认可包含 db/redis 等 indicator——这就是"探下游"唯一该放的地方。把 db 放进 liveness,数据库抖一下就会把所有 Pod 连环杀掉,是最典型的事故。
4. 优雅下线:先摘流量,再停进程
Pod 删除是并行的两件事:从 Endpoints 摘除、向容器发 SIGTERM。两者没有先后保证,所以必须自己加缓冲。
lifecycle:
preStop:
exec:
command: ["sh","-c","sleep 5"]
terminationGracePeriodSeconds: 40
// Spring Boot:等优雅停机把在途请求处理完
server.shutdown=graceful
spring.lifecycle.timeout-per-shutdown-phase=30s
preStop sleep 5 给 kube-proxy/ingress 摘流量的时间,terminationGracePeriodSeconds 要大于 preStop + 关闭耗时,否则到点 SIGKILL,日志里只有一条 exit code 137。
5. 回滚:三条"回不去"的路
kubectl rollout status deploy/api --timeout=300s
kubectl rollout history deploy/api
kubectl rollout undo deploy/api # 回上一版
kubectl rollout undo deploy/api --to-revision=3 # 回指定版
| 回不去的原因 | 表现 | 事前对策 |
|---|---|---|
镜像被覆盖或用了 :latest |
回滚后拉到的还是新代码 | 用不可变 tag(commit sha),禁 latest |
| 配置已改且不在版本控制里 | 回滚镜像,配置仍是新的 | 配置进 Git,随版本一起回 |
| 数据库已迁移 | 老代码读新表结构报错 | 迁移向后兼容(先加列后用,两阶段删) |
第三条最难,因为它意味着回滚不是部署动作,而是数据契约问题。只要迁移遵循"扩展-收缩"(先加字段、双写、再切读、最后删旧字段),镜像回滚就能成立。
revisionHistoryLimit 也别设成 0 或 2:太小就没有历史版本可回滚。
6. 配置注入的三种方式
| 方式 | 生效时机 | 变更是否重启 | 适用 |
|---|---|---|---|
| 环境变量 | 容器创建时 | 必须重启 | 简单标量、连接串 |
| 挂载文件(ConfigMap/Secret) | kubelet 同步后(分钟级) | 取决于应用是否热加载 | 配置文件、证书 |
| 配置中心 | 应用主动拉取/推送 | 可热更新 | 频繁变更、多环境 |
三条铁律:环境变量改了就一定得重启(进程创建后无法改);挂载文件更新有延迟且用 subPath 挂的单文件永不更新;配置中心热更新要求应用真的实现了刷新逻辑,否则只是内存里换了个值。
volumes:
- name: cfg
configMap: { name: api-config }
containers:
- volumeMounts:
- name: cfg
mountPath: /etc/api/application.yml
subPath: application.yml # 陷阱:不会随 ConfigMap 更新
用 subPath 时唯一可靠的做法是改配置就改 Pod 模板,靠滚动更新生效。常见做法是给 Pod 加一个 checksum 注解:
metadata:
annotations:
checksum/config: "{{ sha256sum of application.yml }}"
配置一变注解就变,Pod 模板变化触发滚动——这样"配置变更"和"发布"走同一条可回滚的路径。
7. Secret 不是加密
| 认知 | 事实 |
|---|---|
| Secret 比 ConfigMap 安全 | 只是 base64 编码,任何能 get secret 的人都能读 |
| etcd 里是密文 | 默认不加密;需配 EncryptionConfiguration |
| 有了 RBAC 就安全 | RBAC 只管"能不能读",不管"读到了能干嘛" |
| 打进镜像最方便 | 镜像可被任何有拉取权限的人解出来 |
# 生产:外部密钥管理 + CSI 驱动,Secret 不落地成 K8s 对象
- name: db-password
secretStoreRef: { name: vault-backend, kind: ClusterSecretStore }
至少做到:etcd 静态加密、限制 get/list secrets 的 RBAC、密钥用外部 KMS/Vault 注入、镜像里绝不含密钥。
8. requests 与 limits:一个管调度,一个管杀
| 字段 | 谁用 | 设错的后果 |
|---|---|---|
requests.cpu |
调度器分配节点 | 设太小 → 节点超卖 → 互相抢 CPU |
requests.memory |
调度器 + 驱逐判断 | 设太小 → 节点内存超卖 → OOMKill |
limits.cpu |
cgroup 限流(CFS) | 设太小 → 周期性卡顿,延迟尖刺 |
limits.memory |
OOMKill 阈值 | 设太小 → 直接 137 |
JVM 服务的两个常见错法:limits.memory 设 1Gi 但没设堆,JVM 按宿主机内存算默认堆,直接被 OOMKill;或者堆设满整个 limit,容器开销一挤就死。
resources:
requests: { cpu: "500m", memory: "1Gi" }
limits: { cpu: "2", memory: "2Gi" }
env:
- name: JAVA_TOOL_OPTIONS
value: "-XX:MaxRAMPercentage=70 -XX:+UseG1GC"
MaxRAMPercentage=70 给堆外(元空间、线程栈、直接内存、Netty)留 30%。看到 exit code 137 且日志里没有 OutOfMemoryError,基本就是 cgroup 内存超限,不是 JVM 堆溢出——两者的排查路径完全不同。
9. 常见坑
| 坑 | 现象 | 改法 |
|---|---|---|
maxUnavailable 过大 |
发布期间 5xx 上升 | 降到 1,或加 maxSurge |
minReadySeconds: 0 |
坏版本立刻全量 | 设 10~30s |
| liveness 探 DB | 数据库抖动引发连环重启 | 只探进程,db 放 readiness |
无 preStop |
摘流量前进程已退,少量 5xx | preStop sleep 5 |
用 :latest |
回滚无效 | 不可变 tag |
subPath 挂配置 |
改了 ConfigMap 不生效 | checksum 注解触发滚动 |
revisionHistoryLimit: 0 |
无版本可回 | 保留 3~10 |
| 只设 limits 不设 requests | 调度按最小值分配,节点超卖 | requests 必填 |
10. 排错
| 现象 | 排查方向 |
|---|---|
Pod 一直 Pending |
资源不足或 maxSurge 超出配额;看 kubectl describe 事件 |
| 滚动卡住不动 | progressDeadlineSeconds + readiness 一直不通过 |
| 新 Pod Ready 但无流量 | Service selector / targetPort 不匹配;查 Endpoints |
日志 exit code 137 |
cgroup 内存超限或 terminationGracePeriodSeconds 到点 |
| 代码回滚了行为没变 | 镜像 tag 被覆盖,或配置/DB 已变 |
| 延迟周期性尖刺 | limits.cpu 触发 CFS 限流;看 container_cpu_cfs_throttled |
11. 边界
| 场景 | 更适合的做法 |
|---|---|
| 单实例内部工具 | Recreate 即可,不必滚动 |
| 有状态服务 | StatefulSet + 有序更新,滚动语义不同 |
| 数据库迁移 | 独立 Job,与发布解耦,走扩展-收缩 |
| 极短生命周期任务 | Job/CronJob,谈发布策略没意义 |
| 无入口流量控制 | 金丝雀退化为按副本比例近似 |
12. 自测
maxSurge: 0与maxUnavailable: 1组合下,发布期间可用副本是多少?- 为什么"探数据库"这件事只能放在 readiness 而不是 liveness?
preStop sleep 5解决的是什么时序问题?- 三种"回滚不生效"的原因里,哪一种不是部署问题?
- 用
subPath挂载的配置文件为什么改 ConfigMap 不生效? - 看到
exit code 137却找不到 OOM 日志,先查什么?