应用上 K8s 的第一周,我们接连遇到:服务半夜被 OOMKilled、高峰期接口莫名超时、发布时流量打到还没就绪的 Pod。这些问题在虚机时代从未出现——容器环境下 JVM 的「常识」需要重建。
内存:堆与 cgroup 的对齐
早期 JVM 读的是宿主机的物理内存,容器 limit 2G 的机器上 JVM 可能按 16G 物理内存计算默认堆,直接被 cgroup 杀掉。JDK 8u191+ 与 JDK 11+ 已支持容器感知,但仍建议显式指定,并把堆控制在 limit 的 70% 以内,给元空间、直接内存、线程栈留出余量。
1resources:2 requests:3 memory: "2Gi"4 cpu: "500m"5 limits:6 memory: "2Gi" # 与 requests 相等,QoS 为 Guaranteed,避免被驱逐7 cpu: "2"8env:9 - name: JAVA_OPTS10 value: >-11 -Xmx1400m -Xms1400m12 -XX:MaxMetaspaceSize=256m13 -XX:MaxDirectMemorySize=128m14 -XX:+UseG1GC一个隐蔽的杀手是堆外内存:Netty、NIO 的 DirectBuffer 不受 -Xmx 约束。我们一次 OOMKilled 排查了两天,最后通过 Native Memory Tracking 定位到 gRPC 客户端的连接池直接内存泄漏——从此 -XX:MaxDirectMemorySize 成为所有服务的标配。
CPU 限流:莫名超时的元凶
K8s 的 CPU limit 通过 cfs quota 实现:100ms 周期内用完配额就强制节流。JVM 应用启动期 JIT 编译、GC 都会瞬时吃满 CPU,触发 throttle 后接口毛刺到秒级。排查入口是容器指标 container_cpu_cfs_throttled_seconds_total。
- 对延迟敏感的服务:CPU limit 设为核数整数(如 2 而非 1500m),减少 quota 切片碎片化。
- 启动预热:加大 initialDelaySeconds,或用启动探针(startupProbe)兜住 JIT 高峰期。
- 有条件的服务可评估彻底去掉 CPU limit(保留 requests),让突发流量用满空闲 CPU——这在有资源保障策略的集群里是可行实践。
探针:别让健康检查杀死服务
1startupProbe: # 启动探针:救活慢启动的 Java 应用2 httpGet: { path: /actuator/health/liveness, port: 8080 }3 failureThreshold: 304 periodSeconds: 10 # 最长容忍 300s 启动5readinessProbe: # 就绪探针:控制是否接流量6 httpGet: { path: /actuator/health/readiness, port: 8080 }7 periodSeconds: 58livenessProbe: # 存活探针:失败即重启,判断条件必须保守9 httpGet: { path: /actuator/health/liveness, port: 8080 }10 periodSeconds: 1511 failureThreshold: 5教训来自一次连环重启:数据库慢查询导致接口超时,liveness 探针跟着失败,K8s 批量重启 Pod,重启后的冷启动叠加流量更让数据库雪上加霜。此后我们规定:liveness 只检测 JVM 是否存活,绝不依赖下游;readiness 才允许检查数据库连接池。
容器化不是把 jar 包塞进镜像就完事,JVM 需要知道自己在笼子里——内存、CPU、调度,每一项都要重新对齐。
排障工具箱
最后沉淀一套排查动线:OOMKilled 看 previous 日志与 NMT;接口超时先查 CPU throttle 指标再查 GC 日志;Pod 频繁重启先分清是 liveness 失败还是节点驱逐。配合 kubectl debug 临时挂载 JDK 工具容器,绝大多数问题十分钟内能定位到层。