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的差不多。至此,改造就全部完成了。

0x04 参考资料及延伸阅读

  1. 容器中的JVM资源该如何被安全的限制?
  2. DOCKER基础技术:LINUX CGROUP