上了 K8s 之后,Pod 每天重启三四次
6 月初把订单服务迁到公司新搭的 K8s 集群(1.19)。上线第一天就发现问题:Pod 频繁重启。
$ kubectl get pod -l app=order-service -n trade
NAME READY STATUS RESTARTS AGE
order-service-7d9f8b6c4d-2xk4p 1/1 Running 3 4h21m
order-service-7d9f8b6c4d-8mn2q 1/1 Running 2 4h21m
order-service-7d9f8b6c4d-p8vz7 1/1 Running 4 4h19m
4 小时重启 2 到 4 次。奇怪的是服务本身没报错,就是突然没了。
先看 Pod 的终止原因
$ kubectl describe pod order-service-7d9f8b6c4d-2xk4p -n trade
Containers:
order-service:
State: Running
Started: Mon, 07 Jun 2021 14:22:41 +0800
Last State: Terminated
Reason: OOMKilled
Exit Code: 137
Started: Mon, 07 Jun 2021 09:14:02 +0800
Finished: Mon, 07 Jun 2021 14:22:38 +0800
Limits:
cpu: 2
memory: 2Gi
Requests:
cpu: 500m
memory: 2Gi
Reason: OOMKilled 配上 Exit Code: 137(128 + 9,被 SIGKILL),很明确了:内存超过 limit 2Gi,被 cgroup 杀掉。
注意 JVM 日志里完全没有 OutOfMemoryError。因为这不是 JVM 层面的 OOM,是操作系统直接把进程 kill 了,JVM 连打日志的机会都没有。这也是一开始最难理解的地方。
根因:JVM 看到的不是容器内存
进到容器里看两个数字:
$ kubectl exec -it order-service-7d9f8b6c4d-2xk4p -- sh
# cgroup 给的内存上限
$ cat /sys/fs/cgroup/memory/memory.limit_in_bytes
2147483648 # 2 Gi,跟 limit 对得上
# JVM 认为的最大堆
$ java -XX:+PrintFlagsFinal -version | grep MaxHeapSize
size_t MaxHeapSize = 4261412864 # 3.97 GB
容器限制 2 GB,JVM 却认为最大堆能到 3.97 GB。这不是配置错了,是老版本 JVM 的经典问题:JVM 启动时读的是宿主机的内存,而不是 cgroup 的限制。
我们的宿主机是 64 GB 内存的机器,JVM 按默认规则取物理内存的 1/4:64 / 4 = 16 GB。这里显示 3.97 GB 是因为我们还设了 -Xmx4g。不管哪个数字,都远大于容器的 2 Gi limit。
JVM 的容器感知
JDK 从 8u191 开始支持容器感知(UseContainerSupport),会自动读取 cgroup 的内存限制。我们用的是 openjdk:11.0.10-jre-slim,默认开启。
开启后,堆大小默认按容器 limit 的 25% 计算。2 Gi 的容器只能拿到 512 MB 堆,对我们太小了。必须用百分比参数调:
-XX:+UseContainerSupport
-XX:InitialRAMPercentage=70.0
-XX:MaxRAMPercentage=70.0
-XX:MinRAMPercentage=50.0
三个参数的含义:
| 参数 | 含义 | 我们的值 |
|---|---|---|
MaxRAMPercentage | 堆上限占可用内存的比例 | 70% |
InitialRAMPercentage | 初始堆占可用内存的比例 | 70%(和最大一致,避免扩容抖动) |
MinRAMPercentage | 小内存环境(< 256 MB)时的堆比例 | 50% |
为什么是 70% 而不是 90%? 因为 JVM 进程占用的内存远不止堆:
堆(Heap) 1.4 GB ← -Xmx 控制的部分
元空间 Metaspace 180 MB ← -XX:MaxMetaspaceSize
Code Cache 60 MB
直接内存 Direct Buffer 256 MB ← -XX:MaxDirectMemorySize
线程栈 Thread Stack 200 MB ← 200 线程 × 1 MB
GC 和 JIT 开销 ~150 MB
--------------------------------
合计 约 2.25 GB
堆只占 70%,剩下的 30% 给这些堆外区域。这个比例不是拍的,是我们用 Native Memory Tracking 实测出来的:
-XX:NativeMemoryTracking=summary -XX:+UnlockDiagnosticVMOptions
$ jcmd 1 VM.native_memory summary
Native Memory Tracking:
Total: reserved=2841MB, committed=2218MB
- Java Heap (reserved=1433MB, committed=1433MB)
- Class (reserved=1182MB, committed= 182MB)
- Thread (reserved= 218MB, committed= 218MB)
- Code (reserved= 251MB, committed= 61MB)
- GC (reserved= 84MB, committed= 84MB)
- Symbol (reserved= 38MB, committed= 38MB)
- Native Memory Tracking (reserved= 12MB, committed= 12MB)
committed 2218 MB,容器 limit 是 2 Gi(2147 MB)。已经超了。这就是 OOMKilled 的直接原因。
我们最后把堆设成 65%,容器 limit 提到 2.5 Gi。
requests 和 limits 怎么配
resources:
requests:
cpu: 500m
memory: 2Gi
limits:
cpu: "2"
memory: 2500Mi
内存:requests 和 limits 建议一致
内存的 requests 影响调度(K8s 按 requests 找有足够内存的节点),limits 是硬上限(超过就 OOMKilled)。
建议两者设成一样,这样 Pod 的 QoS 等级是 Guaranteed——节点资源紧张时,Guaranteed 的 Pod 最后才被驱逐。如果 limits > requests,QoS 是 Burstable,超用时优先被 kill。
我们一开始 requests 2Gi、limits 2Gi(一致),后来为了留余量把两者一起提到 2.5 Gi。
CPU:我们最后去掉了 limit
CPU 的 limit 不会 kill 进程,而是限流(throttling):一个调度周期(默认 100 ms)内用超了就强制等待下一个周期。
看 throttling 的指标:
$ kubectl exec -it order-service-xxx -- cat /sys/fs/cgroup/cpu/cpu.stat
nr_periods 4821334
nr_throttled 882341
throttled_time 2148823412213
throttle 比例 = 882341 / 4821334 = 18.3%
累计被限流时间 = 2148 秒(约 36 分钟)
18.3% 的时间在等 CPU。这直接导致接口 P99 抖动。
我们的做法:只保留 requests,去掉 CPU limit:
resources:
requests:
cpu: "1"
memory: 2500Mi
limits:
memory: 2500Mi # 只限制内存
这不是没有代价:某个 Pod 跑飞了会抢同节点其他 Pod 的 CPU。权衡下来我们选择了"稳定性优先"——内存必须限制(会真 kill),CPU 不限制(只是变慢)。
JVM 看到的 CPU 核数
另一个坑:JVM 会根据 CPU 核数决定 GC 线程数、JIT 编译线程数、ForkJoinPool 的并行度。
# 容器 requests cpu=1,但 JVM 看到的是宿主机的核数
$ kubectl exec -it xxx -- java -XX:+PrintFlagsFinal -version | grep ActiveProcessorCount
uintx ActiveProcessorCount = 64
# 宿主机 64 核,JVM 照着 64 核开了 43 个 GC 线程
$ jcmd 1 VM.flags | grep -i gc
-XX:ConcGCThreads=11 -XX:ParallelGCThreads=43
43 个 GC 线程跑在 1 核的容器上,上下文切换开销巨大。解决办法是显式指定:
-XX:ActiveProcessorCount=2
我们按容器的 CPU requests 向上取整设这个值。设完之后 GC 停顿时间降了约 30%。
完整的 deployment 片段
apiVersion: apps/v1
kind: Deployment
metadata:
name: order-service
spec:
replicas: 6
template:
spec:
containers:
- name: order-service
image: registry.xxx.com/trade/order-service:1.4.2
ports:
- containerPort: 8080
env:
- name: JAVA_TOOL_OPTIONS
value: >-
-XX:+UseContainerSupport
-XX:MaxRAMPercentage=65.0
-XX:InitialRAMPercentage=65.0
-XX:MaxMetaspaceSize=256m
-XX:MaxDirectMemorySize=256m
-Xss512k
-XX:ActiveProcessorCount=2
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/data/dump
resources:
requests:
cpu: "1"
memory: 2500Mi
limits:
memory: 2500Mi
readinessProbe:
httpGet:
path: /actuator/health/readiness
port: 8080
initialDelaySeconds: 30
periodSeconds: 10
livenessProbe:
httpGet:
path: /actuator/health/liveness
port: 8080
initialDelaySeconds: 90
periodSeconds: 15
几个说明:
- 用
JAVA_TOOL_OPTIONS而不是命令行参数。这样不用改 Dockerfile 的 ENTRYPOINT,也方便用 ConfigMap 统一管理。注意这个环境变量对所有 JVM 进程生效(包括你在容器里手动敲的java -version),调试时留意。 -Xss512k。默认线程栈 1 MB,我们 200 个线程就是 200 MB。改成 512 KB 省一半。改之前要确认没有特别深的递归调用。-XX:MaxDirectMemorySize必须显式设。它的默认值等于-Xmx,在容器里很容易让总内存超限。我们用的 Netty 会分配堆外内存。- 生存探针的
initialDelaySeconds给足。Spring Boot 应用启动要 40 到 60 秒,设成 30 秒的话会在启动过程中被判定不健康而重启,形成"永远起不来"的死循环。我们设了 90 秒。
排查 OOMKilled 的清单
# 1. 确认是不是 OOMKilled
kubectl describe pod <pod> | grep -A5 "Last State"
# 2. 查看事件(节点层面的 OOM 会有记录)
kubectl get events --field-selector involvedObject.name=<pod> --sort-by=.lastTimestamp
# 3. 节点上查看内核日志
$ ssh node-01 'dmesg -T | grep -i "killed process"'
[Mon Jun 7 14:22:38 2021] Memory cgroup out of memory:
Kill process 31288 (java) score 892 or sacrifice child
[Mon Jun 7 14:22:38 2021] Killed process 31288 (java), UID 0, total-vm:4821884kB,
anon-rss:2218112kB, file-rss:0kB, shmem-rss:0kB
# 4. 实时看内存
kubectl top pod -l app=order-service -n trade
# 5. 容器内看 cgroup 限制
cat /sys/fs/cgroup/memory/memory.limit_in_bytes
第 3 步的 dmesg 是最直接的证据。anon-rss:2218112kB 就是被杀时的实际物理内存占用,2.1 GB,正好撞上 2 Gi 的限制。
调整前后
| 指标 | 调整前 | 调整后 |
|---|---|---|
| OOMKilled 重启 | 每天 9~14 次 | 0 次(连续 3 周) |
| CPU throttle 比例 | 18.3% | 0% |
| 接口 P99 | 412 ms(抖动到 2 s) | 238 ms |
| GC 线程数 | 43 | 2(ParallelGCThreads) |
| 堆大小 | 3.97 GB(超 limit) | 1.6 GB |
下篇预告
这篇先把《Kubernetes 部署 Java 应用的资源配置》里的坑列了,下一篇写我们当时是怎么在线上工程里真正落地的——包括那次让领导拍桌的故障复盘。