0x00 起因
公司最近部分应用要从 Docker Swarm 迁移到 Kubernetes,而迁移到新的 Kubernetes 上的应用都要做资源的限制,否则如果 Pod 不断地占用机器资源把整个节点都拖垮了那就很糟糕了。。所以我按照 Kubernetes 的文档做了限制后,发现并没有什么卵用,容器不断的被 OOMKIILED 然后又重启,服务也一直无法访问,所以需要研究下Java 应用到底该怎么限制内存资源。
0x01 分析
当我在 google 搜索了一波后,发现这个问题就是JVM 无法得知容器的资源限制,所以按照 JVM 的默认规则,它分配的 Max Heap Size 是系统内存的1/4,所以就很容易超出 resource limits 的限制,导致容器被 kill 掉。
而造成这个问题的原因是什么呢,这就得说回 Docker 容器,我们都知道 Docker 容器本质上就是一个被隔离的用户态进程,而构成这个进程自然就少不了三驾马车:
- cgroups 做进程的资源限制
- namespace 做命名空间隔离
- aufs 做联合文件挂载实现文件系统
我们要限制 Java 程序自然就与 cgroups 有关了,在 Linux 上,一切皆文件,所以系统的资源信息等也是以一种特殊的文件形式放在/proc
目录下的,像我们常用的一些top,free,ps
等查看系统资源的工具本质上也是从这个目录下获取的信息。但是 cgroups 限制资源不一样,它是在/sys/fs/cgroups
目录下对指定 namespace 的做限制。
而我们的 JVM 是怎么获取到的当前进程的内存信息的呢?是通过读取挂载的/proc/meminfo
文件了,那么由于/proc/meminfo
里面展示的是宿主机的内存资源,从而让容器产生了自己是地主的感觉,还以为有大把的内存可以给它用,其实自己只是一个长工。。
0x02 方法
了解了这个问题的成因后,并在网上搜集了一些资料,我发现解决这个问题的方式就是在 Java 启动命令前加上JVM_OPTS
参数,而具体加什么参数和 Java 的版本有关,总的来说呢,规则如下:
-
Java < Java8_u131
: 如果低于这个版本,那么在 Java 容器的 CMD 命令里得加上具体的内存分配大小,如"-Xms64M -Xmx256M"
,注意-Xms
最好不超过 Pod 限制资源3/4,因为不止是 JVM 要使用内存,容器本身也是需要内存的。 -
Java < 10
: 如果 Java 版本在这个区间,那么我们就不需要明确地指定最大堆的大小了,这几个版本实际上已经可以从 cgroups 获取资源限制信息,只不过这个特性需要手动开启,需要加上参数"-XX:+UnlockExperimentalVMOptions -XX:+UseCGroupMemoryLimitForHeap -XX:MaxRAMFraction=1"
- 如果 Java 版本大于 10 : 恭喜你,这个特性是默认开启的
- 其他下游 Java 分支: 不太清楚,请查看官方文档
0x03 实践
我司 Java 容器启动方式有两种,一种是通过 jar 包启动,还有一种是用 Tomcat启动,所以我会分别介绍这两种 Java 应用的资源限制方式。
1. JAR
公司的Java 版本有的用的是 1.7,还有的用的是 1.8 小于 131的版本,镜像也是用的 CentOS 或者 Ubuntu 的基础镜像做的,体积大得惨不忍睹。。所以我决定使用 alpine 的镜像重新构建,并且只保留 jre,这个基础镜像的 Dockerfile 可参考jeanblanchard/java,至于其他的步骤就很简单了,我的 Dockerfile 如下:
FROM 18.16.200.10:5000/oracle-jre8:u231
WORKDIR /home
COPY xxxx.jar .
RUN ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime && \
echo "Asia/Shanghai" >> /etc/timezone
CMD java $JVM_OPTS -Duser.timezone=GMT+08 -jar /home/xxxx.jar
我的 Yaml 文件大致如下,加上 env 的环境变量和资源限制:
...
containers:
- name: xxxxx
image: 18.16.200.191:5000/xxx:201910310754
env:
- name: JVM_OPTS
value: "-XX:+UnlockExperimentalVMOptions -XX:+UseCGroupMemoryLimitForHeap -XX:MaxRAMFraction=1 -Xms256M"
resources:
requests:
cpu: 0.1
limits:
cpu: 1
memory: 1.5Gi
...
2. Tomcat
Dockerfile 文件如下:
FROM tomcat:8.5-jdk8
WORKDIR /usr/local/tomcat
COPY xxx.war webapps/ROOT.war
RUN unzip webapps/ROOT.war -d webapps/ROOT/ && rm -f webapps/ROOT.war
ENV JAVA_OPTS="-XX:+UnlockExperimentalVMOptions -XX:+UseCGroupMemoryLimitForHeap -XX:MaxRAMFraction=1 -Xms256M"
EXPOSE 8080
CMD ["/usr/local/tomcat/bin/catalina.sh", "run"]
在tomcat 的启动文件 catalina.sh
中可以通过环境变量JAVA_OPTS
传入参数。
Yaml 文件则与 JAR的差不多。至此,改造就全部完成了。