《Spring Boot 实战》15.2 Kubernetes 部署要点

从一份可上生产的 Deployment 出发,讲清 resources.requests/limits 与 JVM MaxRAMPercentage 的配合(为何不写死 -Xmx、heap 为何不能超过 limit)、三种探针的分工与雪崩误用、ConfigMap/Secret 注入、多副本无状态、滚动更新参数与时区设置。

本节目标:从一份可上生产的 Deployment 出发,把资源与 JVM 内存、三种探针、配置注入、无状态与滚动更新这几组「默认值一错就出事」的字段讲透,并给出判断依据。
适用版本:Spring Boot 4.1.x(Java 21)

15.2 Kubernetes 部署要点

上一节我们把 book-loan 打成了分层镜像。这一节把它调度到集群上。Kubernetes 的 Deployment 字段很多,但真正会在生产上翻车的集中在几组:资源与 JVM 内存没对齐、探针配错导致雪崩、配置注入方式泄露凭据、多副本下会话粘住、滚动更新参数让服务出现短暂不可用。本节逐组拆解。文中的 K8s 资源与 kubectl 输出均为示例,本机没有真实集群,请以你自己集群的行为为准。

一份能上生产的 Deployment

先给一份完整的、可以照着改的模板,后面的小节逐块解释为什么这么写:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: book-loan
  labels:
    app: book-loan
spec:
  replicas: 3
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 1
      maxUnavailable: 0
  selector:
    matchLabels:
      app: book-loan
  template:
    metadata:
      labels:
        app: book-loan
    spec:
      terminationGracePeriodSeconds: 45
      containers:
        - name: app
          image: registry.example.com/book-loan:1.0.0
          ports:
            - containerPort: 8080
          env:
            - name: SPRING_PROFILES_ACTIVE
              value: prod
            - name: TZ
              value: Asia/Shanghai
            - name: JAVA_TOOL_OPTIONS
              value: "-XX:MaxRAMPercentage=75 -XX:+ExitOnOutOfMemoryError"
          envFrom:
            - configMapRef:
                name: book-loan-config
          resources:
            requests:
              cpu: "500m"
              memory: "768Mi"
            limits:
              cpu: "2"
              memory: "768Mi"
          startupProbe:
            httpGet:
              path: /actuator/health/liveness
              port: 8080
            failureThreshold: 30
            periodSeconds: 2
          livenessProbe:
            httpGet:
              path: /actuator/health/liveness
              port: 8080
            periodSeconds: 10
            failureThreshold: 3
          readinessProbe:
            httpGet:
              path: /actuator/health/readiness
              port: 8080
            periodSeconds: 5
            failureThreshold: 3

/actuator/health/liveness 与 /actuator/health/readiness 这两个端点的语义、以及它们与 Spring Boot 内部状态机的对应关系,是下一节(15.3)的主题;本节先关注 Deployment 这一侧怎么用它们。

resources.requests/limits 与 JVM 内存的配合

这是最容易配错、后果又最直接的一组。先厘清两个概念:

  • requests 是调度依据。调度器按 requests 之和挑节点,它不限制运行时用量。
  • limits 是运行时上限,由 cgroup 强制执行。内存超过 limit 会被 OOMKill,CPU 超过 limit 会被节流(throttle)。

JVM 这边,从 JDK 10 起 UseContainerSupport 默认开启,JVM 会读取 cgroup 的 memory limit 作为可用内存,并据此计算默认堆大小。所以生产上正确做法是配合 -XX:MaxRAMPercentage 而不是写死 -Xmx:

-XX:MaxRAMPercentage=75     # 堆 = 容器 limit 的 75%

对照一下两种写法:

写法堆大小换 limit 时问题
-Xmx512m固定 512 MB需要改启动参数重新发布与 limit 脱钩,容易配错
-XX:MaxRAMPercentage=75随 limit 自动算只改 Deployment,堆自动跟随需要保证 limit 覆盖全部内存开销

堆不能等于 limit,更不能超过 limit。 JVM 的内存占用远不止堆:还有元空间(metaspace)、线程栈(每个约 1 MB)、直接内存(NIO / Netty)、code cache、GC 自身结构。把这些算进去,经验值是堆最多占 limit 的 60%–75%,剩下留给非堆部分。一个 768Mi 的 limit 配 75% 大约得到 576 MB 堆,留出约 190 MB 给非堆,是安全的;如果把 MaxRAMPercentage 调到 90% 以上,稍有 native 分配就会 OOMKill,而且 OOMKill 不会在应用日志里留痕迹,排查时很容易误判成「程序 bug」。

再看 requests 与 limits 的关系。二者是否相等,决定了 Pod 的 QoS 等级与稳定性:

场景配置QoS影响
两者都相等requests == limitsGuaranteed最不容易被驱逐,适合延迟敏感的核心服务
只内存相等memory 相等,cpu 不等Burstable常见折中:内存有保障,CPU 允许突发
都不等requests < limitsBurstable节省资源,但可能被节流或驱逐

上例把 memory 的 requests 与 limits 设成相等(都是 768Mi),CPU 则是 requests: 500m、limits: 2。这样做的理由是:内存是「一超就死」的资源,让 requests 等于 limits 能避免节点内存紧张时 book-loan 被优先驱逐;CPU 是「超了只会慢」的资源,留出突发空间能让流量尖峰时不被立刻节流。若要求更严格的隔离,把 CPU 也设成相等即可升到 Guaranteed。

关于 CPU limit 还有一条反直觉的经验:JVM 服务最好不设 CPU limit,或设得足够宽。因为 CFS 的节流是「按 100ms 周期」的,一旦某次 GC 或启动突发超过配额,整个周期内线程被冻结,表现为「偶发几十毫秒的卡顿」。很多团队的实践是「设 CPU requests,不设 CPU limits」,用 requests 保证基本份额,靠节点容量兜住突发。这属于平台策略,是否采用要和你的 SRE 约定。

最后别忘了 -XX:+ExitOnOutOfMemoryError:一旦真的 OOM,让进程立即退出而不是进入半死不活的状态,Kubernetes 才能靠重启恢复,而不是让一个僵死的容器继续接流量。

三种探针的分工与误用

Kubernetes 有三种探针,职责完全不同,混用是雪崩的常见根源:

探针失败时动作应该回答的问题
startupProbe继续等待,不重启、不摘流量进程「启动完成」了吗
readinessProbe从 Service 端点摘除,不重启现在「能接流量」吗
livenessProbe重启容器进程「还活着」吗

它们的协作关系是:startupProbe 通过之前,livenessProbe 与 readinessProbe 都不生效。这解决了一个经典难题——JVM 应用冷启动可能要几十秒,如果直接用 liveness 探测,启动慢的 Pod 会在真正就绪前就被判定死亡并重启,陷入「永远起不来」的循环。startupProbe 给启动阶段一段独立的宽限期(上例是 failureThreshold 30 × periodSeconds 2 = 60s),启动完成后交棒给 liveness。

最大的误用是把 livenessProbe 指向一个依赖外部系统的端点。 设想 book-loan 的 liveness 探针打的是「数据库连通性」:数据库抖动 30 秒,所有 Pod 的 liveness 同时失败,Kubernetes 把它们全部重启;重启后的 Pod 依然连不上数据库,再次失败,再全部重启——集群进入「全体重启风暴」,本该只影响查询的数据库抖动,被放大成整站不可用。这就是雪崩。

正确的分工是:

  • liveness 只反映「进程自身是否还能推进」。Spring Boot 的 liveness 健康组恰好只包含内部状态,不包含数据库、Redis 等外部依赖,所以指向 /actuator/health/liveness 是安全的。
  • readiness 才反映「能不能对外服务」,它可以(也常常)包含外部依赖。数据库不可用时,Pod 被摘出流量、等恢复后自动加回,但不会被重启。

至于 startupProbe,不是每个服务都必须配。纯 Spring Boot 应用启动通常在秒级,不配也问题不大;但只要你的应用启动要跑 Flyway 迁移、加载大缓存、预热,就应当配,避免 liveness 误杀。判断方法:量一下冷启动的 P99,如果接近或超过 liveness 的 failureThreshold × periodSeconds,就必须加 startupProbe。

ConfigMap 与 Secret 注入配置

配置分两类注入:非敏感的放 ConfigMap,敏感的口令、Token 放 Secret。两者都可以用 envFrom 一次性把整组键值变成环境变量:

envFrom:
  - configMapRef:
      name: book-loan-config
  - secretRef:
      name: book-loan-secrets

对应的 ConfigMap 键名用 Spring Boot 的 relaxed binding 风格写,SPRING_DATASOURCE_URL 会自动映射到 spring.datasource.url:

apiVersion: v1
kind: ConfigMap
metadata:
  name: book-loan-config
data:
  SPRING_DATASOURCE_URL: jdbc:postgresql://book-loan-db:5432/bookloan
  LOGGING_LEVEL_COM_EXAMPLE_BOOKLOAN: info
  BOOKLOAN_LOAN_MAX_CONCURRENT_PER_MEMBER: "5"

但环境变量不是 Secret 的最佳载体。环境变量会出现在 kubectl describe pod、/proc/<pid>/environ、崩溃转储里,泄露面大。3.2 节讲过更稳妥的方式是挂载成文件,用 spring.config.import=configtree: 读取:

volumeMounts:
  - name: secrets
    mountPath: /run/secrets
    readOnly: true
volumes:
  - name: secrets
    secret:
      secretName: book-loan-secrets

这样口令只以文件形式存在于容器内,权限可收到 0400,不进环境变量、不进命令行。选哪种取决于你的威胁模型:图省事用 envFrom,合规要求高用 configtree 挂载。

SPRING_PROFILES_ACTIVE 与多环境

多环境通过环境变量 SPRING_PROFILES_ACTIVE 激活 profile。它的值由 relaxed binding 映射到 spring.profiles.active,所以是 SPRING_PROFILES_ACTIVE=prod 而不是写 spring.profiles.active:

env:
  - name: SPRING_PROFILES_ACTIVE
    value: prod

3.1 节讲过环境是「正交维度」:这里的 prod 可以是 profile group 的 key,展开成 prod-base,region-cn,tier-standard 一组。不要把地域、规格也塞进这个变量拼成一长串,那会让「同一份 Deployment 复用到不同地域」变得困难——地域差异应该由不同的 ConfigMap(或 kustomize overlay)承载,而不是改环境变量。

多副本下的无状态要求

replicas: 3 意味着同一个用户的下一次请求可能落到另一个 Pod。如果应用把登录会话存在本机内存(默认的 HttpSession),就会出现「刷新一下掉登录」。解决顺序是:

  1. 优先做成无状态:用 JWT(9.2 节)或把会话放 Redis(Spring Session),任意 Pod 都能校验。这是唯一能干净扩展的做法。
  2. 临时方案才是会话粘滞:给 Service 配 sessionAffinity: ClientIP,或让 ingress 做 sticky。它是权宜之计——粘滞会让负载不均,且 Pod 重启时会话照样丢,不适合当长期方案。

除了会话,无状态还包括:不要把业务状态写进本地磁盘。容器文件系统是临时的,Pod 重建就没了。上传的文件要进对象存储(14.2 节),临时文件用 /tmp(可配 emptyDir)。

滚动更新:maxSurge 与 maxUnavailable

RollingUpdate 的两个参数控制更新节奏:

  • maxSurge:更新期间允许超出 replicas 的 Pod 数(多起几个新的)。
  • maxUnavailable:更新期间允许低于 replicas 的可用 Pod 数(先干掉几个旧的)。

要零中断,把 maxUnavailable 设为 0、maxSurge 设为 1:先起一个新 Pod,等它 readinessProbe 通过、加入 Service,再摘掉一个旧 Pod,如此滚动。代价是需要集群有额外容量容纳 surge 出来的 Pod。

strategy:
  type: RollingUpdate
  rollingUpdate:
    maxSurge: 1
    maxUnavailable: 0

反过来说,如果容量紧张、允许短暂降容,可以设 maxSurge: 0, maxUnavailable: 1,代价是更新期间少一个副本。最不该做的是两个都设成默认的大值(Kubernetes 默认两者都是 25%),那会在更新时一次性摘掉大量副本。

零中断还依赖两件事:readinessProbe 必须真实反映就绪(否则新 Pod 还没准备好就被加进 Service),以及停机侧要优雅(见 15.3)。只看 Deployment 这一侧是不够的。

时区与 TZ

容器默认时区是 UTC。book-loan 的「逾期」判断、due_at 的比较都对时区敏感,如果应用按 UTC 存、按本地时区展示,口径不一致就会出错。两个原则:

  • 存储与计算一律用 UTC,时间戳存 timestamptz,业务逻辑内部统一。
  • 展示层再转本地时区。如果确实需要进程时区(比如日志时间戳按本地时间打),设 TZ 环境变量:
env:
  - name: TZ
    value: Asia/Shanghai

TZ 会被 JVM 读取并设置默认时区,等价于 -Duser.timezone=Asia/Shanghai。基础镜像里要有 tzdata(temurin 镜像自带),否则 TZ 设了也不生效。不要让不同环境的 Pod 用不同 TZ 做业务计算,那会让「同一笔借阅在不同环境逾期时间不同」,是极难复现的 bug。

常见坑

  • MaxRAMPercentage 配到 90%+:堆吃满 limit,非堆一分配就 OOMKill,且日志里看不到 OOM 记录,误判成应用 bug。
  • liveness 探针含数据库检查:数据库抖动引发全体重启风暴,把局部故障放大成整站故障。
  • readinessProbe 指向 /actuator/health(含全部组件):任一非关键依赖短暂不可用都会把 Pod 摘出流量,可用性被无关组件拖累。readiness 该用独立的 readiness 组。
  • startupProbe 缺失且启动慢:liveness 在启动完成前就开始计时,Pod 反复重启。
  • Secret 用环境变量注入:kubectl describe、崩溃转储、子进程环境里都能看到,泄露面大。
  • 本地会话 + 多副本:表现为「随机掉登录」,加副本后才发现。要么无状态,要么提前接好共享会话存储。
  • maxUnavailable 用默认值:更新时一次性摘掉四分之一副本,流量尖峰下会打满剩余 Pod。

小结

Kubernetes 部署的坑集中在「默认值不对齐」上:资源要按 requests == limits(至少内存)来配 QoS,JVM 用 MaxRAMPercentage 跟随 limit 且留足非堆空间;三种探针各司其职,liveness 绝不依赖外部系统,readiness 才是反映可服务性的那个;敏感配置优先挂载文件而非环境变量;多副本要求会话无状态;滚动更新用 maxSurge: 1 / maxUnavailable: 0 保零中断,但这要配合正确的探针和优雅停机。

探针与优雅停机的应用侧细节——健康端点分组、ReadinessState 状态机、server.shutdown=graceful 与 terminationGracePeriodSeconds 的时序——是下一节的主题。

阅读导航:上一节:15.1 镜像构建与分层 · 下一节:15.3 健康检查与优雅停机 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 《Spring Boot 入门》18.3 打包与运行
  2. 《Spring Boot 入门》18.2 实现
  3. 《Spring Boot 入门》18.1 需求与设计