Joe Blog

随着容器技术的成熟,越来越多的企业客户在企业中选择Docker和Kubernetes作为应用平台的基础。然而在实践过程中,还会遇到很多具体问题。

普通的容器在容器中看到的资源还是宿主机的资源,那么假设宿主机128G而你给容器配额2G,此时堆内存按照128G去分,可想而知后果,同理还有gc线程数等

在对Java应用容器化部署的过程中,会出现现象:自己设置了容器的资源限制,但是Java应用容器在运行中还是会莫名奇妙地被OOM Killer干掉。

这背后一个非常常见的原因是:没有正确设置容器的资源限制以及对应的JVM的堆空间大小。

我们拿一个tomcat应用为例,分别用不同版本的tomcat:7tomcat:8 运行,其实例代码和Kubernetes部署文件

apiVersion: v1
kind: Pod
metadata:
  name: jvm-tomcat-7
  labels:
    app: jvm-tomcat-7
spec:
  initContainers:
  - image: registry.cn-hangzhou.aliyuncs.com/denverdino/system-info
    name: app
    imagePullPolicy: IfNotPresent
    command:
      - "cp"
      - "-r"
      - "/system-info"
      - "/app"
    volumeMounts:
    - mountPath: /app
      name: app-volume
  containers:
  - image: tomcat:7.0.88-jre7
    name: tomcat
    imagePullPolicy: IfNotPresent
    volumeMounts:
    - mountPath: /usr/local/tomcat/webapps
      name: app-volume
    ports:
    - containerPort: 8080
    resources:
      requests:
        memory: "256Mi"
        cpu: "500m"
      limits:
        memory: "256Mi"
        cpu: "500m"
  volumes:
  - name: app-volume
    emptyDir: {}
apiVersion: v1
kind: Service
metadata:
  name: jvm-tomcat-7
spec:
  type: NodePort
  selector:
    app: jvm-tomcat-7
  ports:
    - protocol: TCP
      port: 8080
      targetPort: 8080
apiVersion: v1
kind: Pod
metadata:
  name: jvm-tomcat-8
  labels:
    app: jvm-tomcat-8
spec:
  initContainers:
  - image: registry.cn-hangzhou.aliyuncs.com/denverdino/system-info
    name: app
    imagePullPolicy: IfNotPresent
    command:
      - "cp"
      - "-r"
      - "/system-info"
      - "/app"
    volumeMounts:
    - mountPath: /app
      name: app-volume
  containers:
  - image: tomcat:8-jre8
    name: tomcat
    imagePullPolicy: IfNotPresent
    volumeMounts:
    - mountPath: /usr/local/tomcat/webapps
      name: app-volume
    ports:
    - containerPort: 8080
    resources:
      requests:
        memory: "256Mi"
        cpu: "500m"
      limits:
        memory: "256Mi"
        cpu: "500m"
  volumes:
  - name: app-volume
    emptyDir: {}
apiVersion: v1
kind: Service
metadata:
  name: jvm-tomcat-8
spec:
  type: NodePort
  selector:
    app: jvm-tomcat-8
  ports:
    - protocol: TCP
      port: 8080
      targetPort: 8080
  1. tomcat 容器会保持运行,而且我们限制了容器最大的内存用量为256MB内存。

  2. Pod中的app是一个初始化容器,负责把一个JSP应用拷贝到 tomcat 容器的 “webapps”目录下system-info

    : 镜像中JSP应用index.jsp用于显示JVM和系统资源信息。

# tomcat:7 
root@k8s-master-1:~/k8s_manifests/jvm-limits-journey# curl http://192.168.2.12:22436/system-info/ | html2text
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
100   926  100   926    0     0    179      0  0:00:05  0:00:05 --:--:--   233
Java version     Oracle Corporation 1.7.0_181
Operating system Linux 4.15.0-51-generic
Server           Apache Tomcat/7.0.88
Memory           Used 40 of 150 MB, Max 1769 MB
Physica Memory   7953 MB
CPU Cores        4
Heap Memory Usage     init = 130310784(127256K) used = 43449432(42431K)
                      committed = 157810688(154112K) max = 1854930944(1811456K)
Non-Heap Memory Usage init = 24576000(24000K) used = 24298384(23728K) committed
                      = 26148864(25536K) max = 224395264(219136K)

我们可以发现,容器中看到的系统内存是 7953MB,而JVM Heap Size最大是 1769MB。这个跟我们设置容器资源的容量为256MB的资源限制不一样,如果这样,当应用内存的用量超出了256MB,JVM还没对其进行GC,而JVM进程就会被系统直接OOM干掉了。

# tomcat:8
root@k8s-master-1:~/k8s_manifests/jvm-limits-journey# curl http://192.168.2.12:27136/system-info/ | html2text
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
100   903  100   903    0     0  26558      0 --:--:-- --:--:-- --:--:-- 26558
Java version     Oracle Corporation 1.8.0_212
Operating system Linux 4.15.0-51-generic
Server           Apache Tomcat/8.5.41
Memory           Used 13 of 16 MB, Max 121 MB
Physica Memory   7953 MB
CPU Cores        1
Heap Memory Usage     init = 8388608(8192K) used = 15783696(15413K) committed =
                      17588224(17176K) max = 127729664(124736K)
Non-Heap Memory Usage init = 2555904(2496K) used = 30587264(29870K) committed =
                      31391744(30656K) max = -1(-1K)

我们看到JVM最大的Heap大小变成了121MB,这很不错,这样就能保证我们的应用内存的用量不超出256MB,就不会轻易被OOM了。随后问题又来了,为什么我们设置了容器最大内存限制是256MB,而JVM只给Heap设置了121MB的最大值呢?

这就涉及到JVM的内存管理的细节了,JVM中的内存消耗包含Heap和Non-Heap两类;类似Class的元信息,JIT编译过的代码,线程堆栈(thread stack),GC需要的内存空间等都属于Non-Heap内存,所以JVM还会根据CGroup的资源限制预留出部分内存给Non Heap,来保障系统的稳定。(在上面的示例中我们可以看到,tomcat启动后Non Heap占用了近32MB的内存)

问题在于:

JVM GC(垃圾对象回收)对Java程序执行性能有一定的影响。默认的JVM使用公式“ParallelGCThreads = (ncpus <= 8) ? ncpus : 3 + ((ncpus * 5) / 8)” 来计算做并行GC的线程数,其中ncpus是JVM发现的系统CPU个数。一旦容器中JVM发现了宿主机的CPU个数(通常比容器实际CPU限制多很多),这就会导致JVM启动过多的GC线程,直接的结果就导致GC性能下降。Java服务的感受就是延时增加,TP监控曲线突刺增加,吞吐量下降。针对这个问题有各种解法:

解决思路

开启CGroup资源感知

从Java SE 8u131开始,在JDK 9中,JVM在Docker CPU限制方面透明地识别Docker。这意味着如果-XX:ParalllelGCThreads,或-XX:CICompilerCount未指定为命令行选项,JVM将应用Docker CPU限制作为JVM在系统上看到的CPU数量。然后,JVM将调整GC线程和JIT编译器线程的数量,就好像它在一个将CPU数量设置为Docker CPU限制的裸机系统上运行一样。如果-XX:ParallelGCThreads或-XX:CICompilerCount被指定为JVM命令行选项,并且指定了Docker CPU限制,则JVM将使用-XX:ParallelGCThreads和-XX:CICompilerCount值。

对于Docker内存限制,最大Java堆的透明设置还有一些工作要做。要在没有通过-Xmx设置最大Java堆的情况下让JVM知道Docker内存限制,需要两个JVM命令行选项,-XX:+ UnlockExperimentalVMOptions -XX:+ UseCGroupMemoryLimitForHeap

...
env:
    - name: JAVA_OPTS
      value: "-XX:+UnlockExperimentalVMOptions -XX:+UseCGroupMemoryLimitForHeap -XX:ConcGCThreads=2 -XX:ParallelGCThreads=1"
...
#java version "1.7.0_181"
#OpenJDK Runtime Environment (IcedTea 2.6.14) (7u181-2.6.14-1~deb8u1)
#OpenJDK 64-Bit Server VM (build 24.181-b01, mixed mode

root@k8s-master-1:~/k8s_manifests/jvm-limits-journey# kubectl logs -f jvm-tomcat-7 -c tomcat | grep -E "MaxHeapSize|ParallelGCThreads|ConcGCThreads|CICompilerCount|CICompilerCountPerCPU"
     intx CICompilerCount                           = 2               {product}           
     bool CICompilerCountPerCPU                     = false           {product}           
    uintx ConcGCThreads                            := 2               {product}           
    uintx MaxHeapSize                              := 134217728       {product}           
    uintx ParallelGCThreads                        := 1               {product}  
# MaxHeapSize=128M

容器内部感知CGroup资源限制

如果无法利用JDK 8/9的新特性,比如还在使用JDK6的老应用,我们还可以在容器内部利用脚本来获取容器的CGroup资源限制,并通过设置JVM的Heap大小。

...
- image: tomcat:6.0.43-jre7
    name: tomcat
    imagePullPolicy: IfNotPresent
...

# OS CPU=4 Memory=8G
# MaxHeapSize= 8G / 4 =2G
root@k8s-master-1:~/k8s_manifests/jvm-limits-journey# kubectl logs -f jvm-tomcat-6 -c tomcat | grep -E "MaxHeapSize|ParallelGCThreads|ConcGCThreads|CICompilerCount|CICompilerCountPerCPU"
     intx CICompilerCount                           = 2               {product}           
     bool CICompilerCountPerCPU                     = false           {product}           
    uintx ConcGCThreads                             = 0               {product}           
    uintx MaxHeapSize                              := 2086666240      {product}           
    uintx ParallelGCThreads                         = 4               {product}   

Docker1.7开始将容器cgroup信息挂载到容器中,所以应用可以从 /sys/fs/cgroup/memory/memory.limit_in_bytes 等文件获取内存、 CPU等设置,在容器的应用启动命令中根据Cgroup配置正确的资源设置 -Xmx, -XX:ParallelGCThreads等参数

FROM tomcat:8

ENV RESERVED_MEGABYTES 256
COPY entrypoint.sh /entrypoint.sh
CMD ["/entrypoint.sh"]
#!/bin/bash

limit_in_bytes=$(cat /sys/fs/cgroup/memory/memory.limit_in_bytes)
if [ "$limit_in_bytes" -ne "9223372036854771712" ]
then
    limit_in_megabytes=$(expr $limit_in_bytes \/ 1048576)
    heap_size=$(expr $limit_in_megabytes - $RESERVED_MEGABYTES)
    export JAVA_OPTS="-Xmx${heap_size}m $JAVA_OPTS "
    #ParallelGCThreads = (ncpus < 8 )? 3 : (ncpus *5)/8 +3
    #export JAVA_OPTS="-XX:ParallelGCThreads=${ParallelGCThreads} $JAVA_OPTS"
    echo JAVA_OPTS=$JAVA_OPTS
fi
exec catalina.sh run

总结