为什么需要一份"运行时基线"#

很多团队在做 Java 微服务上 Kubernetes 时,JVM 调优业务代码规范写得很细,但介于两者之间的一层——镜像怎么打、探针怎么配、Pod 停机和应用停机怎么对齐、滚动策略怎么跟服务形态匹配——往往散在 Helm chart、Dockerfile 和某个老员工的脑子里。

这篇是对这一层的整理。它不是一份"最佳实践",更像是一份"基线":给一份新服务起步时不会出大问题的最小集,再加上一份知道未来要往哪儿收敛的方向。文中会明确区分"当前常见做法"和"推荐改进方向",方便对号入座。

适用范围:Java 25 + Spring Boot 3.5(Spring MVC) 这条主流栈。Reactive(WebFlux + R2DBC)的运行时模型不在这里讨论。

镜像构建:从能跑到敢跑#

一个最小可运行的 Dockerfile 通常长这样:

FROM eclipse-temurin:25-jdk-noble
WORKDIR /app
COPY ./target/app.jar .
EXPOSE 3000
ENTRYPOINT ["java", "-jar", "app.jar"]

它能跑,但离"敢长期跑"还差几件事。可以按下面这张表去 review:

关注点 常见现状 推荐方向
Base image temurin:25-jdk-noble 长期看可以切到 *-jre-jammy 或 distroless,缩小攻击面
用户身份 root 启动 Pod 层加 securityContext: runAsNonRoot: true,Dockerfile 里 USER 1000
文件系统 可写根分区 readOnlyRootFilesystem: true,临时写入挂 emptyDir
日志路径 写文件到容器内某个目录 短期保留,长期改 stdout JSON(见后文)
漏洞扫描 没有 在 CI 里加 trivy/grype,至少阻断 high+critical
SBOM 没有 syft 生成 SBOM 并归档,方便后续供应链审计

JDK vs JRE 的取舍:开发期镜像用 JDK 方便用 jcmdjstackjmap 排障;生产镜像可以换成 JRE 缩小体积。也有团队选择用同一个 JDK 镜像跑遍所有环境,理由是出问题时直接进 Pod 排查更快——这是个权衡,不是非此即彼。

没必要一次到位。这张表的价值在于:列出来之后,每一项都是一个可独立推进的小工程。

Pod 资源:先把"形态"分清楚#

很多团队的初始 Helm values 里,所有服务用的是同一份资源配置。比如:

resources:
  requests:
    cpu: 1000m
    memory: 2Gi
  limits:
    cpu: 2000m
    memory: 4Gi
replicaCount: 2

短期能跑,长期会浪费一大半资源。可以按"形态"先把服务分一下:

服务形态 典型负载特征 资源思路
前端聚合层(BFF / Gateway) 大量短连接、I/O 密集、对延迟敏感 CPU 偏紧、内存适中、副本数高一点
业务域服务(同步 API) CPU 中等、内存依赖缓存大小 资源中等、副本数 ≥2 保高可用
异步消费者(Kafka consumer) 任务量与消息积压联动 内存看消息体大小、副本数与分区数对齐
定时任务 启停型负载 用 Job/CronJob,不要长驻 Deployment

实际值要靠 kubectl top + Prometheus 的 container_memory_working_set_bytes 调出来。一种比较稳的做法是:上线前给一组保守值,跑两周再回来收口。直接拍最优值是没必要的过度优化

关于 JVM 堆和容器内存的对应关系,可以参考 《K8s 容器化 Java 应用 JVM 配置笔记》,本文不再展开。

探针:startup / liveness / readiness 各管各的#

Spring Boot 暴露了一组现成的端点,对接到 K8s 探针非常顺:

management:
  endpoint:
    health:
      probes:
        enabled: true
        add-additional-paths: true

启用后,actuator 会暴露 /actuator/health/liveness/actuator/health/readiness,分别对应 LivenessStateReadinessState

Pod 侧的最小配置:

livenessProbe:
  httpGet:
    path: /actuator/health/liveness
    port: 3000
  periodSeconds: 10
  timeoutSeconds: 5
  failureThreshold: 3

readinessProbe:
  httpGet:
    path: /actuator/health/readiness
    port: 3000
  periodSeconds: 10
  timeoutSeconds: 5
  failureThreshold: 3

容易踩坑的几个点:

1. 不要用 initialDelaySeconds 替代 startupProbe

常见的写法是:知道应用要 60 秒才能起来,就把 livenessProbe.initialDelaySeconds: 60 一拍。这能让首次启动通过,但应用真的卡死、需要快速重启时,仍然要等满 60 秒——initialDelaySeconds 是首次延迟,不是"启动期忽略"。

正确做法是引入 startupProbe

startupProbe:
  httpGet:
    path: /actuator/health/liveness
    port: 3000
  failureThreshold: 30   # 30 * periodSeconds = 5 分钟启动预算
  periodSeconds: 10

startupProbe 通过之前,liveness/readiness 完全不会被触发;它一旦通过,liveness 就以正常节奏跑。这才是给"慢启动"留预算的正确姿势。

2. health group 成员要克制

readinessState 默认就够了。把 dbrediskafka 全塞进 readiness 看似严谨,实际上:

  • 任意一个外部依赖抖一下,整批 Pod 一起从 service endpoint 摘掉,雪崩概率反而上升;
  • readiness 应该回答"我现在能不能接流量",而不是"我所有依赖都健康"。

推荐的做法:readiness 只放 readinessState;外部依赖通过专门的 metrics 和告警来跟。 真要做依赖检查,也分清楚哪些是 hard dependency(没有就拒流量)、哪些是 soft(降级即可)。

3. liveness 要朴素

liveness 探针的语义是"该不该把这个 Pod 杀掉重启"。它应该是个真·端口存活检查,不要去查 DB 或者下游服务——那种检查失败时,重启 Pod 既解决不了问题,还会把级联故障放大。

优雅停机:应用层和 Pod 层的合谋#

只在 application.yml 里写:

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

是不够的。Pod 层默认 terminationGracePeriodSeconds: 30,刚好等于 Spring Boot 的默认 30 秒——意味着应用还没排完,Pod 已经被 SIGKILL 了。

推荐的对齐方式:

spec:
  terminationGracePeriodSeconds: 60   # 大于应用 timeout-per-shutdown-phase
  containers:
    - lifecycle:
        preStop:
          exec:
            command: ["sh", "-c", "sleep 5"]   # 等 endpoint 摘除传播

为什么需要 preStop sleep:Pod 收到终止信号后,“从 Service endpoint 摘除"和"应用收到 SIGTERM"是并行发生的。如果应用反应太快,可能在新流量还在进来的时候就已经停止接收了,于是这部分请求会被 TCP RST。短短几秒的 preStop 能让 endpoint 摘除先传播到 kube-proxy / ingress controller。

消费者服务(Kafka consumer)需要额外考虑:默认 RollingUpdate 滚动一次,每一次 Pod 替换都会触发一次 Kafka rebalance。在分区多、消费速率紧的场景下,这种 rebalance 可能让积压瞬时拉高。两种缓解方式:

  • 滚动策略改成 maxSurge: 0, maxUnavailable: 1,一次只动一个 Pod,减少同时 rebalance 的次数;
  • 用 cooperative-sticky 分区分配策略,让 rebalance 不需要"全员 stop-the-world”。

这块更细的展开可以参考:Spring Boot3 graceful shutdown in Kubernetes

滚动与回滚:默认值不是答案#

不显式声明的话,K8s 走的是 RollingUpdate,默认 maxSurge: 25%, maxUnavailable: 25%。这个默认值对多数同步服务来说是合适的,但有几类服务建议显式覆盖:

服务类型 推荐策略 原因
同步 API(≥3 副本) 默认 25%/25% 保证发版期间容量不掉
同步 API(2 副本) maxSurge: 1, maxUnavailable: 0 副本少,不能让在线容量掉到 0
Kafka 消费者 maxSurge: 0, maxUnavailable: 1 减少同时 rebalance 的次数
有状态/单写入点 Recreate 不允许两个版本同时跑

回滚部分,ArgoCD 或 Helm 都能直接回到上一个 image tag,技术上不复杂;麻烦的是"哪个 tag 是已知好的"和"这次发版有没有不可回滚的变更"。一种轻量的做法:

  • 在每个 repo 的 README 里维护一段 Rollback 章节,写清楚:当前已知 good tag这次发版是否包含不可回滚变更(典型如:DROP 列、不兼容的 API schema、Kafka 消息格式变更)、如果不可回滚,前向修复的步骤是什么
  • 这一段是手工维护的,会落后于代码——所以更适合当作"对齐工具"而非"权威记录"。理想情况是接到 deploy 系统里自动产出,但在大部分团队这还是个待补的洞。

AOT 与 Native Image:什么时候值得切#

GraalVM native image 当前更适合放在 Assess 阶段:值得跟踪、值得在 PoC 上试,但暂时不建议在生产服务全面切换。理由:

  • 构建时间和镜像体积对 CI 是真实代价,多模块项目里这个代价会非线性放大;
  • 运行时可观测性工具(async-profiler、jcmd、JFR)在 native 下要么不可用要么需要额外工作;
  • 反射密集型框架(一些老的序列化库、动态代理)需要 reflection-config,调起来比想象中费劲;
  • 收益(启动时间、内存占用)对长生命周期的微服务来说,没有那么决定性——它对 Lambda/FaaS 这种短生命周期场景才更香。

如果服务确实启动时间敏感(比如要做 KEDA scale-to-zero),那 native 值得严肃评估。否则建议先把上面那些更基础的运行时洞补完。

可观测性接入:metrics / logs / traces 各走一条路#

每个服务至少应该产出三类信号。

Metrics#

actuator 暴露 Prometheus 端点:

management:
  endpoints:
    web:
      exposure:
        include: health,info,prometheus
  prometheus:
    metrics:
      export:
        enabled: true

Helm chart 里给每个服务生成一个 ServiceMonitor,由 kube-prometheus-stack 抓取(默认 30s)。推荐把 ServiceMonitor 放到 Helm template 里和 Deployment 一起出,避免有的服务"忘了"暴露——可观测性的 default 应该是 on,不是 opt-in。

Logs#

两种主流模式:

  1. 文件 + sidecar:应用写本地文件 → filebeat sidecar tail → Logstash/Fluent Bit → OpenSearch;
  2. stdout JSON:应用直接输出结构化 JSON 到 stdout → 节点 agent(filebeat/fluent-bit DaemonSet)采集 → OpenSearch。

模式 2 是 K8s 时代更"native"的做法:少一个 sidecar、少一份磁盘占用、应用本身只关心打日志。Spring Boot 3.4+ 内置了 ECS / Logstash / GELF 三种结构化日志格式,配置层面很简单:

logging:
  structured:
    format:
      console: ecs

迁移路径上有几个细节要小心:

  • 滚动 / 归档:从应用侧的 logback rolling 转成 K8s/容器运行时的日志轮转,不要两边都开;
  • MDC 字段:换 format 的时候顺便把 MDC 标准化(traceId、spanId、tenantId 等),日后查询方便;
  • 结构兼容:和已有的 ES / Loki query 对齐字段名,别等切完了才发现 dashboard 全断了。

更细的迁移记录可以参考 Spring Boot 结构化日志(ECS)实践

Traces#

OpenTelemetry Java agent 通过 init container 注入是目前比较主流的做法:

initContainers:
  - name: otel-agent
    image: otel/opentelemetry-javaagent:latest
    command: ["sh", "-c", "cp /javaagent.jar /otel/javaagent.jar"]
    volumeMounts:
      - name: otel
        mountPath: /otel
containers:
  - env:
      - name: JAVA_TOOL_OPTIONS
        value: "-javaagent:/otel/javaagent.jar"
      - name: OTEL_EXPORTER_OTLP_ENDPOINT
        value: "http://otel-collector.observability:4317"
      - name: OTEL_SERVICE_NAME
        valueFrom:
          fieldRef:
            fieldPath: metadata.labels['app.kubernetes.io/name']
    volumeMounts:
      - name: otel
        mountPath: /otel
volumes:
  - name: otel
    emptyDir: {}

这种"运行时注入"的好处:业务镜像不用绑定 OTel 版本,升级 agent 改 init container 就行。注意点:agent 版本和后端 collector 版本要保持兼容;agent 启动时会扫描所有类做插桩,对启动时间有几百毫秒到一秒的额外开销,要算进 startupProbe 的预算里。

traceId 进 MDC 之后,Logback pattern 里加上 %X{trace_id} 就能让日志和链路 join 起来,这是把三类信号串成一条线最重要的一步。

新服务上线 checklist#

第一次上生产前,可以拿这张清单逐条过:

  • Dockerfile 本地能 build、能跑,docker run 收到 SIGTERM 后能干净退出;
  • Helm chart 入口齐备,每个环境的 values 都填了;
  • 资源 按形态给(不要直接拷别的服务的值),上线后两周内回看 kubectl top 调一次;
  • 探针 liveness / readiness 接 actuator,慢启动加 startupProbe,不要靠 initialDelaySeconds 凑;
  • 优雅停机server.shutdown=gracefultimeout-per-shutdown-phaseterminationGracePeriodSeconds 对齐,preStop 留几秒;
  • 滚动策略:副本少的同步服务和 Kafka 消费者要显式覆盖默认;
  • 回滚信息 在 README 里写清楚:已知 good tag、是否可回滚、不可回滚时的前向修复步骤;
  • Metrics ServiceMonitor 已生成,能在 Grafana 看到 RED 三件套;
  • Logs 接入采集链路,至少 ERROR 级在中央日志能查到;
  • Traces OTel agent 注入完成(dev/qa 至少打开),traceId 进 MDC,日志里能看到。

这份清单是"最小集",不是"完成态"。镜像加固、CI 漏洞扫描、deploy-system 接管的回滚、stdout JSON 切换——这些都是清单之外的下一步工程,可以一项项推。

总结#

整体值得强调的就两件事:

  1. 基线不是最佳实践,是"出问题的概率最低的起点"。一开始把它写下来,比追求一个完美方案更有用;
  2. 运行时这一层最容易被忽视。JVM 调优看起来很硬核,业务代码规范看起来很重要,夹在中间的"Pod 怎么起怎么停、流量怎么进怎么出、信号怎么收"反而经常没人系统看一遍——这层做扎实,能把一大批"偶发线上小事故"提前消化掉。

如果是在搭一个新平台,建议从这层开始而不是从"业务规范"开始;如果是接手一个已有平台,把现状照着上面这份清单过一遍,写下"哪里 OK / 哪里短期凑合 / 哪里要排期改",本身就是一份很有价值的产出。