三年前,我们满怀信心地把核心业务系统搬上了K8s。当时想得很简单:Java我们熟,K8s也学了,打个镜像、写个YAML,能跑不就完事了?
结果呢?第一个月风平浪静,半年后开始每周被叫醒救火。最夸张的时候,凌晨2点的报警能连环响,运维兄弟看到钉钉群的消息就手抖。
今天不聊虚的,就说说我们这套跑了三年的Java服务,是怎么从“能跑就行”进化到“三年不出大事故”的。全程真实踩坑记录,每一个问题都是真金白银换来的教训。
一、先说说我们当时有多惨
这是一个典型的业务中台服务:
- Spring Boot + JDK 17
- 日均千万级请求,有明显业务高峰
- 对响应时间敏感(处于调用链中段)
- 大促期间流量能翻5-10倍
当时的标准配置长这样:
resources:
requests:
cpu: "1"
memory: "2Gi"
limits:
cpu: "2"
memory: "2Gi"
JVM参数也是祖传配方:
-Xms2g -Xmx2g
看着挺规范对吧?该有的都有了。但问题就是从这个“标准配置”开始的。
二、那些年我们踩过的坑(你以为的偶发,其实是必然)
1️⃣ Pod莫名其妙就挂了
最诡异的是:Pod显示OOMKilled,但翻遍Java日志,找不到任何OOM异常。
Reason: OOMKilled
重启就能好,但谁也不知道为什么。第一次,大家说是偶发;第二次,说是流量波动;第三次,有人开始嘀咕“K8s是不是不稳定”...
2️⃣ 高峰期响应时间忽长忽短
监控上出现了一个反直觉的现象:
- CPU利用率只有40%-50%
- 内存接近limit
- RT(响应时间)隔一会儿就抽风一次
- Full GC偶尔出现
这个时候最容易犯的错误是:盲目加Pod。
结果呢?OOM确实少了,但资源成本直接翻倍,根因压根没解决。
3️⃣ 滚动升级必出502
发版的时候总有几个用户反馈“打不开页面”。一看日志:
- 一半Pod已经Ready
- 另一半还在重启
- 网关疯狂报错
最头疼的是:测试环境永远复现不出来。这才是真正的“生产环境专属bug”。
三、问题的本质:我们用物理机的思维,跑容器的JVM
排查到最后,我给团队下了一个结论:这不是K8s的问题,也不是Java的问题,而是我们一直用“物理机时代的JVM思维”,在跑“强边界的容器环境”。
物理机时代,JVM觉得自己就是这台机器的老大,内存随便用,不行还有Swap。但容器里呢?
JVM实际消耗的内存包括:
- Heap(堆)
- Metaspace(元空间)
- Direct Memory(直接内存)
- Thread Stack(线程栈)
- JVM自身开销
- OS Buffer
而K8s只认一件事:总和 > memory limit → 立即OOMKilled
没有任何商量余地,没有Swap可以缓冲,超了就是死。
四、六轮治理,把“玄学”变成“科学”
第一轮:重构资源与JVM参数
最关键的一步:抛弃固定Xmx
# 删除这行祖传代码
# -Xms2g -Xmx2g
# 改为容器感知模式
-XX:+UseContainerSupport
-XX:MaxRAMPercentage=70
设计逻辑很简单:
- 容器limit = 2Gi
- 堆内存 ≈ 1.4Gi(70%)
- 剩下600M给非堆和OS
这才是“容器优先”的JVM设计思路。
重新定义资源策略(基于监控数据,不是拍脑袋):
requests:
cpu: "800m" # 长期占用
memory: "2Gi" # 稳态内存≈1.6Gi
limits:
cpu: "2" # 允许突发
memory: "2.5Gi" # 非堆波动≈300-400M
第二轮:让OOM不再是“黑盒”
强制留下案发现场:
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/heapdumps
原则:线上OOM,必须有Dump文件。 没有Dump的OOM排查,本质就是猜谜。
让JVM学会“体面地死”:
-XX:+ExitOnOutOfMemoryError
这一步是为K8s设计的:JVM出问题,Pod立刻退出,K8s自动拉起新实例。这才是平台化自愈的基础,而不是让一个半死不活的Pod在那硬撑。
第三轮:把“假死”交给平台处理
探针职责重新划分:
readinessProbe:
httpGet:
path: /actuator/health/readiness
initialDelaySeconds: 30
periodSeconds: 5
livenessProbe:
httpGet:
path: /actuator/health/liveness
initialDelaySeconds: 60
periodSeconds: 15
startupProbe:
httpGet:
path: /actuator/health/startup
failureThreshold: 30
periodSeconds: 10
每个探针只干一件事:
- Readiness:只判断是否接流量
- Liveness:只判断是否需要重启
- Startup:给慢启动应用留足时间(Java你懂的)
真正的优雅停机:
spec:
terminationGracePeriodSeconds: 60
containers:
- name: app
lifecycle:
preStop:
exec:
command: ["/bin/sh", "-c", "sleep 20"]
配合Spring Boot配置:
server:
shutdown: graceful
spring:
lifecycle:
timeout-per-shutdown-phase: 30s
第四轮:从“能看到”到“能预测”
通过Micrometer暴露JVM指标:
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-registry-prometheus</artifactId>
</dependency>
监控这些关键指标:
- Heap / Non-Heap使用趋势
- GC次数与耗时(分代统计)
- 线程数变化
- 类加载/卸载数量
效果:
- OOM不再是突发,内存泄漏有趋势可循
- GC抖动能提前预警
- 线程泄漏看得见
第五轮:重新设计扩缩容策略
为什么HPA只看CPU一定会踩坑?
在Java场景下:
- GC抖动
- 内存压力
- 线程阻塞
往往不会直接反映在CPU上。
我们的最终策略:
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
spec:
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 70
- type: Pods
pods:
metric:
name: jvm_gc_pause_seconds_count
target:
type: AverageValue
averageValue: "5"
- type: Pods
pods:
metric:
name: http_server_requests_seconds_count
target:
type: AverageValue
averageValue: "100"
组合指标:CPU + GC频率 + QPS,多维度判断。
第六轮:建设稳定性文化
最后这一轮最虚,但也最重要。
建立三个习惯:
- 任何OOM必须有根因分析,不能“重启就好”
- 每次发布前Review监控基线,看资源趋势是否异常
- 定期压测,验证极限情况下的表现
几个关键认知转变:
✅ JVM必须是“容器感知的JVM”
✅ limit不是JVM的可用内存,而是留给整个Pod的
✅ OOM是系统设计问题,不是偶发异常
✅ K8s负责重启,JVM负责体面退出
✅ 稳定性来自长期治理,不是一次调优
五、三年后的今天
这套治理体系跑下来:
- OOM从“每周偶发” → “一年可能都没有一次”
- 滚动升级502 → 彻底消失
- 资源使用率下降20%+(因为不再盲目加副本)
- 最重要的:再没有被OOMKilled半夜叫醒过
真正的成就感不是参数调得多完美,而是当你看到凌晨3点的手机,只有一条“发布成功”的通知,没有连环报警。
写在最后
Java上K8s,技术门槛其实不高,但治理门槛很高。它考验的不是你会不会写YAML,而是你能不能把JVM和容器这两个完全不同的设计哲学统一起来。
这套方法论,我们踩了三年坑才总结出来。希望能帮你少踩几个,早日睡个安稳觉。
你的Java服务在Kubernetes上遇到过什么奇葩问题?欢迎在云栈社区的运维板块一起交流探讨,共同避坑!