Administrator
发布于 2021-06-08 / 4522 阅读
77

Kubernetes 部署 Java 应用的资源配置

上了 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%
接口 P99412 ms(抖动到 2 s)238 ms
GC 线程数432(ParallelGCThreads)
堆大小3.97 GB(超 limit)1.6 GB

下篇预告

这篇先把《Kubernetes 部署 Java 应用的资源配置》里的坑列了,下一篇写我们当时是怎么在线上工程里真正落地的——包括那次让领导拍桌的故障复盘。

参考