随着容器技术的成熟,越来越多的企业客户在企业中选择Docker和Kubernetes作为应用平台的基础。然而在实践过程中,还会遇到很多具体问题。
普通的容器在容器中看到的资源还是宿主机的资源,那么假设宿主机128G而你给容器配额2G,此时堆内存按照128G去分,可想而知后果,同理还有gc线程数等
在对Java应用容器化部署的过程中,会出现现象:自己设置了容器的资源限制,但是Java应用容器在运行中还是会莫名奇妙地被OOM Killer干掉。
这背后一个非常常见的原因是:没有正确设置容器的资源限制以及对应的JVM的堆空间大小。
我们拿一个tomcat应用为例,分别用不同版本的tomcat:7
、tomcat: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
tomcat
容器会保持运行,而且我们限制了容器最大的内存用量为256MB内存。
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的内存)
procfs
的/proc
目录,其包含如:meminfo, cpuinfo,stat, uptime等资源信息。一些监控工具如free/top或遗留应用还依赖上述文件内容获取资源配置和使用情况。当它们在容器中运行时,就会把宿主机的资源状态读取出来,引起错误和不便。JVM GC(垃圾对象回收)对Java程序执行性能有一定的影响。默认的JVM使用公式“ParallelGCThreads = (ncpus <= 8) ? ncpus : 3 + ((ncpus * 5) / 8)” 来计算做并行GC的线程数,其中ncpus是JVM发现的系统CPU个数。一旦容器中JVM发现了宿主机的CPU个数(通常比容器实际CPU限制多很多),这就会导致JVM启动过多的GC线程,直接的结果就导致GC性能下降。Java服务的感受就是延时增加,TP监控曲线突刺增加,吞吐量下降。针对这个问题有各种解法:
从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
如果无法利用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
-Xmx
-XX:ParallelGCThreads
-XX:CICompilerCount
-XX:+ UnlockExperimentalVMOptions -XX:+ UseCGroupMemoryLimitForHeap
开启cgroup资源感知