Java 基础体系 · 第 100/100 篇。示例统一以 Java 25 LTS 为语言和 JVM 基线;框架示例使用与其兼容的现代稳定版本。

Java 服务上 Kubernetes:资源、探针、JVM 容器感知和优雅关闭

Kubernetes 并不是“把 JAR 放进容器再启动”的进程管理器。它会根据资源声明决定 Pod 能否被调度,根据探针决定流量是否进入 Pod、是否重启容器,根据终止流程决定服务能否在发布或缩容时完成请求。与此同时,JVM 还要把容器的 CPU 和内存限制转换为堆大小、GC 线程数和编译线程数等运行参数。

因此,一个 Java 服务在 Kubernetes 上是否稳定,至少取决于四个相互影响的机制:

  1. Kubernetes 如何计算和执行 CPU、内存资源约束;
  2. 存活、就绪、启动探针如何影响流量和重启;
  3. JVM 如何感知 cgroup 容器边界;
  4. Pod 终止时,Kubernetes、容器进程和 Spring 应用如何协同完成关闭。

一、先建立运行模型:Pod、容器和 Java 进程分别负责什么

Pod 是 Kubernetes 调度和网络的基本单位。一个 Pod 可以包含一个或多个容器,但本文以“一个 Pod 中运行一个 Java 应用容器”为主。

容器是由容器运行时隔离出来的进程集合。它不是虚拟机,容器中的 Java 进程仍然共享宿主机内核,并通过 Linux cgroup 接收 CPU、内存等资源限制。

Java 进程是最终执行应用代码的进程。JVM 并不知道 Kubernetes 的 YAML;它只能通过操作系统提供的 cgroup 信息判断可用 CPU 和内存,并据此进行一部分自动调优。

一次请求的大致路径如下:

flowchart LR
    C[客户端] --> S[Service]
    S --> E[EndpointSlice]
    E --> P[Ready Pod]
    P --> K[Kubelet]
    K --> J[Java 进程]
    J --> A[Spring 应用]
    A --> D[数据库/缓存/下游服务]

    K -->|liveness 失败| R[重启容器]
    K -->|readiness 失败| N[移出 Service 流量]
    K -->|Pod 删除| T[终止流程]
    T --> J

这里有三个容易混淆的事实:

  • Service 通常只把流量转发到通过 readiness probe 的 Pod;
  • liveness probe 失败通常会导致容器重启,但不会直接表示“暂时不要接流量”;
  • Pod 被删除时,readiness 变化和应用收到终止信号之间存在分布式传播延迟,不能假设所有代理会在同一时刻停止发送请求。

二、资源:request 是调度约束,limit 是运行时上限

Kubernetes 容器资源声明主要包含:

  • requests.cpu:调度器为容器寻找节点时考虑的 CPU 预留量;
  • requests.memory:调度器为容器寻找节点时考虑的内存预留量;
  • limits.cpu:容器可使用的 CPU 上限;
  • limits.memory:容器可使用的内存上限。

例如:

resources:
  requests:
    cpu: "500m"
    memory: "512Mi"
  limits:
    cpu: "1"
    memory: "768Mi"

其中:

  • 1 个 CPU 表示一个逻辑 CPU 的计算容量;
  • 500m 表示 0.5 个 CPU;
  • Mi2202^{20} 字节;
  • MB 通常表示十进制兆字节,不能与 Mi 混用。

1. 调度如何使用 request

假设节点可分配资源为:

CPU:4
内存:8 GiB

节点上已有 Pod 的 CPU request 总和为 3.6,内存 request 总和为 6 GiB。此时:

剩余 CPU request = 4 - 3.6 = 0.4
剩余内存 request = 8 GiB - 6 GiB = 2 GiB

对于前面的新容器:

CPU request = 0.5
内存 request = 512 MiB

即使节点此刻实际空闲 2 个 CPU,只要调度器计算的 CPU request 剩余量只有 0.4,这个 Pod 也可能无法调度到该节点。调度阶段主要依据声明的 request,而不是瞬时 kubectl top 利用率。

因此,request 不是“应用平时应该使用多少”的纯统计字段,而是调度器用来做容量承诺的输入。

2. CPU limit 的执行方式

CPU 是可压缩资源。一个容器超过 CPU limit 时,通常不会被直接杀死,而是受到 cgroup CPU 配额限制,也就是 throttling,CPU 限流

例如:

limits:
  cpu: "1"

表示在一个调度周期内,容器平均最多获得约一个 CPU 的时间。具体调度行为由 Linux cgroup 和内核版本实现,但结果通常表现为:

  • Java 线程 runnable,却得不到足够 CPU;
  • GC、JIT 编译和业务线程互相争抢 CPU;
  • 请求延迟升高;
  • 探针因为超时而失败。

这解释了一个常见现象:CPU 使用率看起来没有“打满”,但延迟和探针超时已经恶化。容器可能正在被 cgroup 限流,而不是没有 CPU 压力。

cpu request 还会影响一些上层机制。例如 HPA 按 CPU 利用率扩缩容时,常见计算形式近似为:

CPU 利用率=实际 CPU 使用量CPU request\text{CPU 利用率} = \frac{\text{实际 CPU 使用量}}{\text{CPU request}}

同样的实际使用量,request 越小,利用率百分比越高。因此 request 不能随意设置得极小,否则会同时影响调度密度和扩缩容信号。

3. 内存 limit 的执行方式

内存是不可压缩资源。容器超过 memory limit 时,内核可能触发 cgroup OOM,杀死容器中的进程。Kubernetes 随后通常会记录类似:

Reason:    OOMKilled
Exit Code: 137

137 常见于进程收到 SIGKILL,但它不是“Java 一定抛出了 OutOfMemoryError”的证明。必须区分:

  • JVM 堆耗尽:通常可看到 java.lang.OutOfMemoryError: Java heap space
  • Metaspace 耗尽:可能是 OutOfMemoryError: Metaspace
  • 直接内存耗尽:可能是 Direct buffer memory
  • cgroup 级别 OOM:进程可能来不及打印任何 Java 异常,直接被内核杀掉。

可使用以下命令确认:

kubectl describe pod <pod-name>
kubectl get pod <pod-name> \
  -o jsonpath='{range .status.containerStatuses[*]}{.name}{" reason="}{.lastState.terminated.reason}{" exit="}{.lastState.terminated.exitCode}{"\n"}{end}'
kubectl logs <pod-name> --previous

kubectl describe pod 中的 Last StateReason 和事件是判断重启原因的第一手信息;不能仅凭 Java 日志判断所有内存问题。

4. QoS 类别和取舍

Kubernetes 根据 request 和 limit 为 Pod 分配 QoS 类别:

  • Guaranteed:每个容器的 CPU、内存 request 和 limit 都设置,且对应值相等;
  • Burstable:至少设置了部分资源,但不满足 Guaranteed;
  • BestEffort:没有设置资源 request 和 limit。

这不是性能等级,而是节点内存压力下的驱逐优先级等行为输入。生产 Java 服务通常至少需要内存 request;完全不声明资源会让调度和故障诊断失去基础。


三、JVM 容器感知:Java 看到的不是宿主机全部资源

JVM 的容器感知是指 JVM 使用容器的 cgroup CPU 和内存限制,而不是盲目使用宿主机资源。

现代 JDK,包括 Java 25,默认启用容器支持;相关行为可以通过:

java -XX:+PrintFlagsFinal -version | grep -E \
  'UseContainerSupport|MaxRAMPercentage|InitialRAMPercentage|ActiveProcessorCount'

查看。UseContainerSupport 通常默认为启用,但生产环境仍应以实际 JDK 构建版本的输出为准。

1. JVM 内存不是只有 Java heap

容器内存上限 LL 需要容纳整个 Java 进程,而不是只有 -Xmx 指定的堆:

LH+M+C+T+D+G+N+OL \geq H + M + C + T + D + G + N + O

其中:

  • HH:Java heap,Java 堆;
  • MM:Metaspace 和类元数据;
  • CC:Code Cache,JIT 编译代码缓存;
  • TT:线程栈;
  • DD:Direct Buffer 等堆外缓冲区;
  • GG:GC 数据结构;
  • NN:JNI、动态库和 JVM 本身的 native 内存;
  • OO:其他内存映射、运行库和应用 native 分配。

-Xmx 只约束 HH,不约束整个左侧表达式。

假设:

limits:
  memory: "512Mi"

如果 JVM 按 25% 的最大堆比例计算最大堆,则最大堆大约为:

512 MiB × 25% = 128 MiB

但这不代表进程只会占用 128 MiB,也不代表剩余的 384 MiB 一定足够。假设应用有:

Java heap              128 MiB
线程栈:80 个×1 MiB      80 MiB
Metaspace              100 MiB
Direct Buffer           64 MiB
JVM/GC/Code Cache       90 MiB
其他                    70 MiB
--------------------------------
合计                   532 MiB

即使堆没有溢出,整个 cgroup 仍可能超过 512Mi,最后被内核 OOMKill。

这里的 25% 只是某些服务器型 JVM 的默认人体工学参数示例,不应把它当成所有 JDK、所有机器和所有模式下的固定规范。应直接查看实际值:

java -XX:+PrintFlagsFinal -version 2>&1 \
  | grep -E 'MaxHeapSize|MaxRAMPercentage|InitialRAMPercentage'

2. 显式设置堆上限

在资源稳定、应用内存模型已测量的服务中,可以显式设置:

env:
  - name: JAVA_TOOL_OPTIONS
    value: >-
      -XX:MaxRAMPercentage=60
      -XX:InitialRAMPercentage=20
      -XX:MaxDirectMemorySize=64m

这种配置表达的是:

  • 最大堆按容器可见内存的一定比例计算;
  • 初始堆不要一开始就占满;
  • 直接内存设置额外上限。

MaxDirectMemorySize 不能覆盖所有 native 内存,线程栈、Metaspace、JNI 分配仍然存在。比例也不是通用答案,必须结合线程数、框架、缓存、消息客户端和负载测试确定。

如果直接指定:

-Xms256m -Xmx256m

那么 heap 上限更明确,但应用仍然需要为 heap 之外的内存留下余量。对于 512Mi 的容器,-Xmx512m 几乎没有给 JVM 和 native 内存留下合理空间,是典型反例。

3. CPU 感知与并行度

JVM 会根据容器可见 CPU 估算:

  • GC 并行线程;
  • JIT 编译线程;
  • ForkJoinPool 等默认并行度;
  • 部分 Java 库中的处理并行度。

当 Pod 设置:

limits:
  cpu: "500m"

应用不应假设自己拥有宿主机的全部 CPU。容器感知可以避免 JVM 把宿主机几十个 CPU 当成自己的资源,但 CPU limit 很小且线程数量很多时,仍可能出现上下文切换和调度延迟。

-XX:ActiveProcessorCount=N 可以覆盖 JVM 使用的处理器数量估计:

env:
  - name: JAVA_TOOL_OPTIONS
    value: "-XX:ActiveProcessorCount=2"

它不会改变 cgroup 的实际 CPU 配额,只影响 JVM 和依赖库的并行度判断。因此,不能用它“伪造更多 CPU”;设置过大只会让线程竞争更加严重。

4. JVM 诊断工具也必须容器化地理解

建议在问题现场同时查看 Kubernetes 视角和 JVM 视角:

kubectl exec -it <pod-name> -- sh

# 查看 Java 进程及启动参数
ps -ef
jcmd 1 VM.command_line
jcmd 1 GC.heap_info

如果启动时加入:

-XX:NativeMemoryTracking=summary

可以进一步执行:

jcmd 1 VM.native_memory summary

Native Memory Tracking 会增加一定开销,通常只在需要定位 native 内存问题时开启,而不是无条件地作为性能配置。


四、探针:启动、存活、就绪解决的是三个不同问题

Kubernetes 常用三类探针:

  • startup probe:应用是否完成启动;
  • liveness probe:进程是否陷入无法自愈的状态;
  • readiness probe:当前是否应该接收业务流量。

它们不是三个不同 URL 的机械复制,而是三个不同的状态判断。

1. startup probe 解决“启动慢”

如果配置了 startupProbe,在它成功以前,Kubernetes 不会执行 liveness 和 readiness 探针。设:

startupProbe:
  periodSeconds: 5
  failureThreshold: 60

则启动探针最多允许大约:

5×60=300 秒5 \times 60 = 300\text{ 秒}

这里是按周期粗略估算;实际还会受首次执行时机、超时和 kubelet 调度影响。

没有 startup probe 时,下面这种配置可能杀死一个正常但启动较慢的 Java 应用:

livenessProbe:
  httpGet:
    path: /actuator/health/liveness
    port: 8080
  initialDelaySeconds: 10
  periodSeconds: 5
  failureThreshold: 3

如果应用需要 30 秒启动,10 秒后连续三次失败,容器就可能在真正启动完成前被重启。

2. liveness 不应检查所有依赖

liveness 的问题是:“这个进程是否已经无法通过重启自愈?”

如果 liveness 同时检查数据库、Redis 和第三方支付服务,那么下游故障会导致所有 Pod 一起重启:

数据库故障
  -> liveness 失败
  -> 所有 Pod 重启
  -> 连接池和线程重新建立
  -> 数据库恢复压力更大
  -> 形成重启风暴

因此,liveness 一般检查:

  • HTTP 服务线程是否仍能处理请求;
  • 应用内部是否进入明确的不可恢复状态;
  • 必要的核心运行状态是否正常。

外部依赖暂时不可用,更适合影响 readiness,而不是 liveness。这样 Pod 会停止接收新流量,但不会通过重启放大故障。

3. readiness 是流量资格,不是进程健康的同义词

readiness 失败后,Service 通常会逐步停止把新请求发往该 Pod,但已经建立的连接、代理缓存和正在执行的请求不会瞬间消失。

readiness 可以因为以下原因失败:

  • 应用刚启动,缓存尚未加载;
  • 数据库连接池尚未准备好;
  • 正在执行优雅关闭;
  • 依赖服务不可用,当前 Pod 不应再接收新请求;
  • 应用过载,需要暂时摘流。

Spring Boot 提供了 Kubernetes 常用的健康组端点:

/actuator/health/liveness
/actuator/health/readiness

使用前需要满足两个条件:

  1. 引入 Actuator;
  2. 该端点已暴露并受正确的安全策略保护。

一个常见配置如下:

management:
  endpoints:
    web:
      exposure:
        include: health
  endpoint:
    health:
      probes:
        enabled: true
  health:
    livenessstate:
      enabled: true
    readinessstate:
      enabled: true

具体默认行为可能随 Spring Boot 版本和 Kubernetes 环境检测而变化,生产配置应显式确认。Spring Boot 的 LivenessStateReadinessState 由应用生命周期状态驱动,不能把它们当成数据库探测器的自动替代品。

如果 Actuator 端点需要认证,Kubernetes probe 也必须能完成认证;更常见的做法是只暴露健康组所需的最小端点,并通过网络策略、管理端口或应用安全配置限制访问范围。不能为了让探针通过而把全部管理端点公开到公网。

4. 一份可运行的探针配置

apiVersion: apps/v1
kind: Deployment
metadata:
  name: orders
spec:
  replicas: 2
  selector:
    matchLabels:
      app: orders
  template:
    metadata:
      labels:
        app: orders
    spec:
      terminationGracePeriodSeconds: 40
      containers:
        - name: orders
          image: example.com/orders:1.0.0
          ports:
            - name: http
              containerPort: 8080
          resources:
            requests:
              cpu: "500m"
              memory: "512Mi"
            limits:
              cpu: "1"
              memory: "768Mi"
          startupProbe:
            httpGet:
              path: /actuator/health/liveness
              port: http
            periodSeconds: 5
            timeoutSeconds: 2
            failureThreshold: 60
          livenessProbe:
            httpGet:
              path: /actuator/health/liveness
              port: http
            periodSeconds: 10
            timeoutSeconds: 2
            failureThreshold: 3
          readinessProbe:
            httpGet:
              path: /actuator/health/readiness
              port: http
            periodSeconds: 5
            timeoutSeconds: 2
            failureThreshold: 2

其逻辑是:

  • 最多允许约 5 分钟完成初始启动;
  • 启动完成后,每 10 秒判断一次是否需要重启;
  • 每 5 秒判断一次是否继续接收流量;
  • readiness 连续两次失败后,通常会被摘出可用端点;
  • liveness 连续三次失败后,kubelet 可能重启容器。

探针本身也会消耗 CPU 和连接资源。如果检查接口执行复杂 SQL、访问多个下游或等待锁,那么探针可能反过来放大故障。健康检查应尽量短、确定、低依赖。


五、优雅关闭:从删除 Pod 到 Java 线程结束

优雅关闭是指应用在收到终止信号后:

  1. 停止接收新的工作;
  2. 让已开始的请求、消息或任务完成,或在期限内取消;
  3. 关闭监听器、线程池、连接池等资源;
  4. 在超时前退出进程。

Kubernetes 的典型终止路径如下:

sequenceDiagram
    participant C as 控制器
    participant API as API Server
    participant K as Kubelet
    participant E as EndpointSlice/代理
    participant J as Java 进程
    participant S as Spring 生命周期

    C->>API: 删除 Pod 或滚动更新
    API-->>E: Pod 进入终止状态,逐步停止新流量
    API-->>K: 发送 Pod 终止信息
    K->>J: 执行 preStop(如配置)
    K->>J: 发送 SIGTERM
    J->>S: Spring 开始关闭
    S->>J: 停止接收新请求,等待活动请求
    S-->>J: 关闭完成
    K->>J: 超过 grace period,发送 SIGKILL

这张图需要补充两个重要边界:

  • terminationGracePeriodSeconds 的倒计时从终止流程开始,不是从 SIGTERM 到达后才开始;
  • EndpointSlice 更新、Service 代理和客户端连接池都有传播延迟,不能把“删除端点”和“应用收到 SIGTERM”理解为严格同步事件。

1. Java 进程必须正确接收 SIGTERM

容器镜像的启动命令应让 Java 成为可接收信号的主进程。Dockerfile 推荐:

FROM eclipse-temurin:25-jre

WORKDIR /app
COPY target/orders.jar /app/orders.jar

USER 10001
ENTRYPOINT ["java", "-jar", "/app/orders.jar"]

JSON 数组形式是 exec form,Java 进程通常直接成为容器主进程。应避免:

ENTRYPOINT ["sh", "-c", "java -jar /app/orders.jar"]

除非明确处理信号转发。否则 shell 可能成为 PID 1,SIGTERM 未必按预期传给 Java,导致应用没有机会执行关闭逻辑,最终被 SIGKILL 强制杀死。

2. Spring Boot 的关闭生命周期

Spring Boot 支持 graceful shutdown。常见配置:

server:
  shutdown: graceful

spring:
  lifecycle:
    timeout-per-shutdown-phase: 30s

server.shutdown=graceful 的作用是让内嵌 Web 服务器进入关闭流程,停止接收新的请求,并等待活动请求结束。timeout-per-shutdown-phase 是 Spring 生命周期阶段等待的时间,不等于 Kubernetes 的全部终止预算。

对于 terminationGracePeriodSeconds: 40,可以粗略分配:

Kubernetes 终止总预算       40 秒
preStop 或等待流量摘除        5 秒
Spring Web 优雅关闭          30 秒
剩余缓冲                     5 秒

这不是 Kubernetes 的强制内部划分,而是设计预算。实际必须满足:

TpreStop+TSpring shutdown+T传播与缓冲TterminationGracePeriodT_{\text{preStop}}+ T_{\text{Spring shutdown}}+ T_{\text{传播与缓冲}} \leq T_{\text{terminationGracePeriod}}

如果 Spring 配置等待 60 秒,而 Pod grace period 只有 30 秒,Spring 的配置不会延长 Kubernetes 的生命周期;30 秒到期后,kubelet 仍可能发送 SIGKILL

3. 长请求、异步任务和消息消费的边界

假设一个 HTTP 请求已经执行了 35 秒,Pod 的 grace period 只有 30 秒。即使 Spring 正确进入优雅关闭,该请求也不可能保证完成。优雅关闭不是无限等待,而是在有限期限内降低中断概率。

异步任务还要额外处理:

  • 停止接收新任务;
  • 等待线程池中的任务;
  • 为任务设置最大执行时间;
  • 对不可重试操作设计幂等性;
  • 对消息消费者先停止拉取,再处理已取得的消息;
  • 超时后让任务回到可重试或补偿路径。

如果只配置 HTTP 服务器 graceful shutdown,却没有关闭自建线程池、定时任务和消息消费者,应用仍可能在退出时丢失任务或拖过终止期限。

4. preStop 的使用风险

可以配置:

lifecycle:
  preStop:
    exec:
      command: ["/bin/sh", "-c", "sleep 5"]

这类延迟有时用于等待流量摘除,但它只是经验性的缓冲,不是对所有网络代理的严格保证。它还会消耗 termination grace period,并可能在镜像没有 /bin/sh 时直接失败。

更可靠的方式是让应用自身在收到 SIGTERM 后立即进入拒绝新流量状态,并依靠 readiness 反映这一状态;preStop 只在经过验证确实需要时使用,而不是用固定睡眠掩盖关闭时序问题。


六、把资源、探针和关闭参数放在同一个部署中

下面是一份简化但完整的示例:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: orders
spec:
  replicas: 2
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxUnavailable: 0
      maxSurge: 1
  selector:
    matchLabels:
      app: orders
  template:
    metadata:
      labels:
        app: orders
    spec:
      terminationGracePeriodSeconds: 40
      containers:
        - name: orders
          image: example.com/orders:1.0.0
          imagePullPolicy: IfNotPresent
          ports:
            - name: http
              containerPort: 8080

          env:
            - name: JAVA_TOOL_OPTIONS
              value: >-
                -XX:MaxRAMPercentage=60
                -XX:InitialRAMPercentage=20

          resources:
            requests:
              cpu: "500m"
              memory: "512Mi"
            limits:
              cpu: "1"
              memory: "768Mi"

          startupProbe:
            httpGet:
              path: /actuator/health/liveness
              port: http
            periodSeconds: 5
            timeoutSeconds: 2
            failureThreshold: 60

          livenessProbe:
            httpGet:
              path: /actuator/health/liveness
              port: http
            periodSeconds: 10
            timeoutSeconds: 2
            failureThreshold: 3

          readinessProbe:
            httpGet:
              path: /actuator/health/readiness
              port: http
            periodSeconds: 5
            timeoutSeconds: 2
            failureThreshold: 2

对应的 Spring 配置:

server:
  shutdown: graceful

spring:
  lifecycle:
    timeout-per-shutdown-phase: 30s

management:
  endpoints:
    web:
      exposure:
        include: health
  endpoint:
    health:
      probes:
        enabled: true
  health:
    livenessstate:
      enabled: true
    readinessstate:
      enabled: true

部署和验证:

kubectl apply -f deployment.yaml
kubectl rollout status deployment/orders
kubectl get pods -l app=orders -w
kubectl describe pod -l app=orders

预期观察到:

  • Pod 先处于未就绪状态;
  • startup probe 成功后,readiness 才能决定是否接流量;
  • 发布新版本时,旧 Pod 进入终止流程;
  • 新 Pod 通过 readiness 后,滚动更新继续推进;
  • 旧 Pod 如果在 grace period 内完成关闭,应正常退出,而不是出现 OOMKilled 或强制终止。

如果发布卡住,应先区分是:

kubectl get pods -l app=orders
kubectl describe pod <pod-name>
kubectl logs <pod-name>
kubectl get events --sort-by=.lastTimestamp
  • CrashLoopBackOff:重点看进程退出、启动异常和 liveness;
  • 0/1 Ready:重点看 readiness、端口、Actuator 暴露和依赖初始化;
  • Pending:重点看 request、节点容量、污点和亲和性;
  • OOMKilled:重点看 cgroup 内存、堆外内存和 limit;
  • 终止时无关闭日志:重点看 PID 1、信号转发和 grace period。

七、常见错误及其因果关系

错误一:把 -Xmx 设置成 memory limit

limits:
  memory: "1Gi"

env:
  - name: JAVA_TOOL_OPTIONS
    value: "-Xmx1g"

错误原因是把“堆上限”等同于“进程上限”。Metaspace、线程栈、Direct Buffer 和 GC 元数据会继续消耗 cgroup 内存,最终可能由内核直接杀进程。

错误二:readiness 和 liveness 使用同一个复杂检查

下游数据库短暂不可用时:

数据库不可用
  -> liveness 失败
  -> Pod 重启
  -> 所有连接池重新连接
  -> 下游恢复更慢

更合理的语义通常是:数据库不可用时 readiness 失败,Pod 暂停接收新流量;只有应用自身进入无法通过重启恢复的状态时,才让 liveness 失败。

错误三:用 initialDelaySeconds 代替 startup probe

固定延迟只能覆盖已知的启动时间。启动时间受类加载、JIT、容器 CPU、远程配置和缓存影响,可能在不同节点上变化。startup probe 能把“启动期”与“稳定运行期”分开,避免 liveness 在启动阶段误杀。

错误四:grace period 小于最大请求时间,却要求请求必然完成

如果最大业务请求耗时为 45 秒,而终止预算只有 30 秒,就不存在“所有请求都优雅完成”的配置解。必须缩短请求、增加预算、采用异步化,或设计可恢复的重试与幂等机制。

错误五:探针通过了,就认为服务可用

HTTP 端点返回 200 只能证明该次探针请求满足端点逻辑。它不代表:

  • 所有业务接口都正常;
  • 数据库拥有足够连接;
  • 下游服务未超时;
  • 应用没有被 CPU throttling;
  • 当前 Pod 能承受新的流量。

探针应回答明确问题,监控、日志、业务指标和端到端检查负责回答其他问题。


八、生产验证应验证“失败路径”,而不只是启动成功

资源和生命周期配置不能只通过一次 kubectl apply 验证。至少应观察以下场景:

  1. 启动慢:人为增加初始化耗时,确认 startup probe 不会触发 liveness 重启;
  2. 下游不可用:确认 readiness 失败而不是所有 Pod 被 liveness 重启;
  3. 内存接近上限:观察 kubectl top pod、JVM heap、native memory 和 OOM 事件;
  4. CPU 受限:检查延迟、GC 时间和 cgroup throttling 指标;
  5. 滚动更新:发送持续请求,确认旧 Pod 不再接收新流量,并让已有请求完成;
  6. 强制超时:让一个任务超过 grace period,确认它能被安全取消、重试或补偿。

资源配置的核心不是“给一个看起来合理的数字”,而是让以下关系在真实负载下成立:

业务峰值内存+JVM 非堆内存+安全余量<memory limit\text{业务峰值内存}+ \text{JVM 非堆内存}+ \text{安全余量} < \text{memory limit}

启动最长时间<startup probe 允许窗口\text{启动最长时间} < \text{startup probe 允许窗口}

preStop 时间+关闭时间+传播缓冲<termination grace period\text{preStop 时间}+ \text{关闭时间}+ \text{传播缓冲} < \text{termination grace period}

当这三个条件都被测量和验证时,Kubernetes 的资源管理、探针决策、JVM 容器感知和 Spring 优雅关闭才形成一个完整的运行闭环,而不是四组彼此独立的 YAML 参数。


系列导航与关联阅读

官方资料

本文依据 Java、Spring 与相关项目官方文档重新梳理;正文、示例与生产清单由 WR BLOG 编写。