本文来自一批 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普通服务按百分比设置堆大小;不全局添加周期 GCJDK 17 内存优化
JProfileragent 文件可预置,native agent 默认关闭JDK 17 内存优化
MALLOC_ARENA_MAX=4仅用于已验证的 glibc 镜像JDK 17 内存优化
RabbitMQ 1/3/10全局统一消费者并发与 prefetchNacos 全局配置基线
Redis、Hikari、Tomcat在 Nacos 中分层治理,不复制到 YAMLNacos 全局配置基线

这样,每个参数只在一篇文章中维护单一解释。Deployment 模板引用结论,不复制完整调优过程。

第 4 层:同一模板,资源仍然要分档

统一模板不等于所有服务使用相同容量。可以保留少量可审计的资源类别:

类型CPU requestMemory requestMemory limit适用范围
standard250m1536Mi4Gi普通 API
medium500m2Gi4Gi采购、仓储等
heavy500m4Gi5Gi财务或批处理

每个例外都要附证据,例如稳态 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,不能模糊地说“尽量分散”。

模板发布顺序

一份模板影响几十个服务,发布顺序必须比普通应用更保守:

  1. 渲染模板,保存目标 Deployment 的完整 YAML。
  2. 做 schema 校验和 server-side dry-run。
  3. 选择一个普通双副本服务试点。
  4. 等新 ReplicaSet 全部 Ready,并保持 Ready 超过 minReadySeconds。
  5. 验证 Nacos、Redis、RabbitMQ、数据库和真实接口。
  6. 检查副本节点分布、GC 日志、RSS 和连接数。
  7. 再处理一个重型服务和一个兼容旧版 Boot 的服务。
  8. 分批推广,不同时重启几十个客户端。

验证命令:

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 中、逐渐漂移的复制粘贴。