本节目标:从一份可上生产的 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 == limits | Guaranteed | 最不容易被驱逐,适合延迟敏感的核心服务 |
| 只内存相等 | memory 相等,cpu 不等 | Burstable | 常见折中:内存有保障,CPU 允许突发 |
| 都不等 | requests < limits | Burstable | 节省资源,但可能被节流或驱逐 |
上例把 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),就会出现「刷新一下掉登录」。解决顺序是:
- 优先做成无状态:用 JWT(9.2 节)或把会话放 Redis(Spring Session),任意 Pod 都能校验。这是唯一能干净扩展的做法。
- 临时方案才是会话粘滞:给 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 健康检查与优雅停机 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。