本文来自一批 Spring Boot 服务在托管 Kubernetes 上的 Deployment 治理。服务名、镜像、命名空间和容量已泛化。系列导航:Kubernetes 重建迁移教程系列。
最初的问题看起来互不相关:
- 两个 Pod 经常落在同一节点上。
- 部分服务滚动发布时长期处于 Pending 状态。
- Nacos 启动时无法创建
/root/nacos。 - 文件上传组件因为
mkdir failed导致 Spring Context 启动失败。 - 某些服务常驻 3–4 GiB,另一些只有 1 GiB。
- 仓储类服务拥有大量 RabbitMQ Listener,在途消息推高 Heap。
- 有的 Deployment 还在加载 JProfiler,有的已经关闭。
- 五份“几乎相同”的 Java Deployment 越改越不一致。
如果分别给每个服务的 YAML 打补丁,很快又会发生配置漂移。最终方案不是把五个服务合并成一个 Deployment,而是:
一份参数化模板
→ 渲染出多个相互独立的 Deployment
→ 结构和安全基线一致
→ 只让资源、端口、节点池等数据存在差异
“合并 Deployment”到底合并什么
一个 Deployment 只能描述一类 Pod 模板。不同服务仍然需要独立的:
- Deployment 名称。
- 镜像和版本。
- Service 与端口。
- 副本数。
- 回滚边界。
- 扩缩容和发布节奏。
真正应合并的是重复的模板源码:
旧结构
common Deployment
finance Deployment
finance-rest Deployment
system-setting Deployment
repair-check Deployment
新结构
一份 Java Deployment 模板
+ app.name / app.port / app.replicas / resourceClass / nodePool 等参数
这样修改探针或安全上下文时只改一个地方,同时仍会生成多个 Kubernetes Deployment。
基线整体结构
flowchart TD
A["参数化 Java Deployment"] --> B["滚动发布与可用性"]
A --> C["调度与故障域"]
A --> D["容器安全与可写目录"]
A --> E["JVM 与诊断"]
A --> F["RabbitMQ 在途量"]
A --> G["资源分档"]
B --> H["渲染后的独立 Deployment"]
C --> H
D --> H
E --> H
F --> H
G --> H
这六个部分应该一起评审。只优化 JVM,却让探针在 GC 时误杀 Pod;只强制跨节点,却没有给滚动发布预留第三个 Pod 的 request,都会引发新的事故。
第 1 层:让滚动发布有明确完成条件
spec:
replicas: <replicas>
revisionHistoryLimit: 5
progressDeadlineSeconds: 600
minReadySeconds: 20
strategy:
type: RollingUpdate
rollingUpdate:
maxUnavailable: 0
maxSurge: 1
含义分别是:
maxUnavailable: 0:新 Pod 稳定前不主动减少旧副本。maxSurge: 1:发布期间最多增加一个临时副本。minReadySeconds: 20:刚变成 Ready 并不等于已经稳定。progressDeadlineSeconds:发布卡住时,让控制器明确报告失败。revisionHistoryLimit:限制保留的回滚版本数量,避免 ReplicaSet 无限增长。
这套策略要求节点池能临时容纳一个 surge Pod。调度器按 request 预留,不按 kubectl top 的瞬时用量计算。
探针、preStop、优雅停机和 PDB 已在Kubernetes 无感滚动发布中给出完整模板。本篇不再重复,只定义统一模板的选择规则:
- Spring Boot 3 Servlet 服务使用 Actuator liveness/readiness。
- 尚未暴露 Availability 探针的 Netty、网关和 WebSocket 服务暂时使用 TCP 探针。
- 兼容例外必须在模板条件中可见,并有迁移期限。
- liveness 不访问数据库、Redis、RabbitMQ 或远程 HTTP。
第 2 层:安全收紧后必须显式提供可写目录
安全基线:
spec:
automountServiceAccountToken: false
enableServiceLinks: false
securityContext:
seccompProfile:
type: RuntimeDefault
containers:
- name: application
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop: [ALL]
第三方 SDK 仍可能依据 HOME 或 user.home 写入 Nacos failover、上传断点和临时文件。模板为它们提供有上限的可写 emptyDir:
volumeMounts:
- name: tmp-volume
mountPath: /tmp
env:
- name: HOME
value: /tmp
- name: JDK_JAVA_OPTIONS
value: >-
-Duser.home=/tmp
-DJM.SNAPSHOT.PATH=/tmp
-DJM.LOG.PATH=/data/java/logs/nacos
volumes:
- name: tmp-volume
emptyDir:
sizeLimit: 2Gi
emptyDir.sizeLimit 防止临时文件无限占用节点的 ephemeral storage。真正需要跨 Pod 保存的数据仍应使用 PVC 或对象存储,不能因为路径可写就把 emptyDir 当成持久盘。
目录相关事故、异常链和 Nacos 路径见Spring Boot 3 容器启动异常复盘,这里不再重复堆栈分析。
第 3 层:运行时参数引用既有基线
模板只负责执行经过验证的运行基线,参数依据放在对应专题:
| 模板内容 | 规则 | 详细说明 |
|---|---|---|
| JDK 17 Heap、G1、Metaspace、Dump | 普通服务按百分比设置堆大小;不全局添加周期 GC | JDK 17 内存优化 |
| JProfiler | agent 文件可预置,native agent 默认关闭 | JDK 17 内存优化 |
MALLOC_ARENA_MAX=4 | 仅用于已验证的 glibc 镜像 | JDK 17 内存优化 |
RabbitMQ 1/3/10 | 全局统一消费者并发与 prefetch | Nacos 全局配置基线 |
| Redis、Hikari、Tomcat | 在 Nacos 中分层治理,不复制到 YAML | Nacos 全局配置基线 |
这样,每个参数只在一篇文章中维护单一解释。Deployment 模板引用结论,不复制完整调优过程。
第 4 层:同一模板,资源仍然要分档
统一模板不等于所有服务使用相同容量。可以保留少量可审计的资源类别:
| 类型 | CPU request | Memory request | Memory limit | 适用范围 |
|---|---|---|---|---|
| standard | 250m | 1536Mi | 4Gi | 普通 API |
| medium | 500m | 2Gi | 4Gi | 采购、仓储等 |
| heavy | 500m | 4Gi | 5Gi | 财务或批处理 |
每个例外都要附证据,例如稳态 P95、峰值、GC、线程数和滚动发布所需的临时容量。不要让 if app.name == ... 无限增长;当例外变多时,应改用明确的 resourceClass 参数。
第 5 层:多副本必须跨节点,但不要锁死滚动发布
在 Kubernetes 1.30 中,可以按当前 ReplicaSet revision 强制分散:
topologySpreadConstraints:
- maxSkew: 1
topologyKey: kubernetes.io/hostname
whenUnsatisfiable: DoNotSchedule
matchLabelKeys:
- pod-template-hash
labelSelector:
matchLabels:
app: <application>
version: <version>
Kubernetes topology spread 文档明确给出了使用 pod-template-hash 区分 Deployment revision 的方式。
没有 matchLabelKeys 时,新旧 ReplicaSet 可能一起参与 skew 计算。在 maxUnavailable: 0 的发布中,旧 Pod 尚未退出,新 Pod 又被严格 spread 阻塞,发布可能形成死锁。
DoNotSchedule 会保证两个同 revision 副本不落在同一主机,但前提是目标节点池至少有两个合格节点,并且 request 有容量。如果业务更看重“尽快启动”而不是强制故障域,应显式选择 ScheduleAnyway,不能模糊地说“尽量分散”。
模板发布顺序
一份模板影响几十个服务,发布顺序必须比普通应用更保守:
- 渲染模板,保存目标 Deployment 的完整 YAML。
- 做 schema 校验和 server-side dry-run。
- 选择一个普通双副本服务试点。
- 等新 ReplicaSet 全部 Ready,并保持 Ready 超过
minReadySeconds。 - 验证 Nacos、Redis、RabbitMQ、数据库和真实接口。
- 检查副本节点分布、GC 日志、RSS 和连接数。
- 再处理一个重型服务和一个兼容旧版 Boot 的服务。
- 分批推广,不同时重启几十个客户端。
验证命令:
kubectl apply --server-side --dry-run=server -f rendered.yaml
kubectl -n <namespace> rollout status deployment/<name> --timeout=10m
kubectl -n <namespace> get pod -l app=<name> -o wide
kubectl -n <namespace> get rs -l app=<name>
kubectl -n <namespace> describe pod <new-pod>
滚动重启只会按照集群中当前 Deployment spec 重建 Pod。Git 仓库里的模板已经修改,并不代表线上 spec 已经更新;必须先走渲染和发布流水线。
完成定义
- 所有 Java 服务由同一模板渲染,但仍是独立 Deployment。
- 通用模板不包含未经普遍验证的周期 GC 和诊断 agent。
- startup、readiness、liveness 的职责不同。
- Nacos、上传和临时文件的写入均使用受控可写卷。
- RabbitMQ 并发和 prefetch 有全局保守上限与业务例外。
- requests 基于实际 P95/峰值,并为 surge Pod 留出容量。
- 双副本按 revision 强制跨节点。
- 安全上下文、SIGTERM、Heap Dump 和日志路径均已验证。
- 每个应用例外都有原因、负责人和移除条件。
最终结论
统一 Deployment 的价值不是减少几份 YAML,而是把数十个服务共享的运行契约收敛到一个地方:
如何启动
+ 如何被探测
+ 如何退出
+ 可以写哪里
+ 最多使用多少资源
+ 消息同时在途多少
+ 副本如何跨故障域
结构统一之后,差异才会变得可见。资源占用高的服务可以继续使用更高档位,旧网关可以暂时使用 TCP 探针,但这些都必须是显式数据,而不是散落在五份 Deployment 中、逐渐漂移的复制粘贴。
DISCUSSION
讨论与反馈