为什么我的Java应用内存溢出时会被Docker Kill掉?

像我们这样在Docker中运行Java应用程序的人,可能已经遇到过JVM在容器中运行时无法准确检测可用内存的问题。jvm没有准确地检测Docker容器中可用的内存,而是查看机器的可用内存。这可能导致在容器内运行的 Java应用程序在尝试使用超出Docker容器限制的内存量时被终止的情况

jvm对可用内存的错误检测与Linux工具/lib有关,这些工具/lib是在cgroups存在之前创建的,用于返回系统资源信息(例如, /proc/meminfo/proc/vmstat )。它们返回主机的资源信息(该主机是物理机还是虚拟机)。

让我们通过观察一个简单的Java应用程序在Docker容器中运行时如何分配一定百分比的内存来探索这个过程。我们将把应用程序部署为Kubernetes pod(使用Minikube)来说明Kubernetes上也存在这个问题,这并不奇怪,因为Kubernetes使用Docker作为容器引擎。

package com.banzaicloud;

import java.util.Vector;

public class MemoryConsumer {

    private static float CAP = 0.8f;  // 80%
    private static int ONE_MB = 1024 * 1024;

    private static Vector cache = new Vector();

    public static void main(String[] args) {
        Runtime rt = Runtime.getRuntime();

        long maxMemBytes = rt.maxMemory();
        long usedMemBytes = rt.totalMemory() - rt.freeMemory();
        long freeMemBytes = rt.maxMemory() - usedMemBytes;

        int allocBytes = Math.round(freeMemBytes * CAP);

        System.out.println("Initial free memory: " + freeMemBytes/ONE_MB + "MB");
        System.out.println("Max memory: " + maxMemBytes/ONE_MB + "MB");

        System.out.println("Reserve: " + allocBytes/ONE_MB + "MB");

        for (int i = 0; i < allocBytes / ONE_MB; i++){
            cache.add(new byte[ONE_MB]);
        }

        usedMemBytes = rt.totalMemory() - rt.freeMemory();
        freeMemBytes = rt.maxMemory() - usedMemBytes;

        System.out.println("Free memory: " + freeMemBytes/ONE_MB + "MB");

    }
}

我们使用Docker构建文件来创建包含jar的图像,jar是从上面的Java代码构建的。我们需要这个Docker映像,以便将应用程序部署为Kubernetes Pod。

Dockerfile文件

FROM openjdk:8-alpine

ADD memory_consumer.jar /opt/local/jars/memory_consumer.jar

CMD java $JVM_OPTS -cp /opt/local/jars/memory_consumer.jar com.banzaicloud.MemoryConsumer
docker build -t memory_consumer .

现在我们有了Docker映像,我们需要创建一个pod定义来将应用程序部署到kubernetes:

memory-consumer.yaml

apiVersion: v1
kind: Pod
metadata:
  name: memory-consumer
spec:
  containers:
  - name: memory-consumer-container
    image: memory_consumer
    imagePullPolicy: Never
    resources:
      requests:
        memory: "64Mi"
      limits:
        memory: "256Mi"
  restartPolicy: Never

此pod定义确保将容器调度到至少有64MB可用内存的节点,并且不允许其使用超过256MB的内存。

$ kubectl create -f memory-consumer.yaml
pod "memory-consumer" created

输出:

$ kubectl logs memory-consumer
Initial free memory: 877MB
Max memory: 878MB
Reserve: 702MB
Killed

$ kubectl get po --show-all
NAME              READY     STATUS      RESTARTS   AGE
memory-consumer   0/1       OOMKilled   0          1m

在容器内运行的Java应用程序检测到877MB的可用内存,因此试图保留702MB的可用内存。因为我们之前将最大内存使用限制为256MB,所以容器被终止。

为了避免这种结果,我们需要指示JVM可以保留的最大内存量。我们通过 -Xmx 选项来实现。我们需要修改pod定义,将 -Xmx 设置通过 JVM_OPTS env 变量传递给容器中的Java应用程序。

memory-consumer.yaml

apiVersion: v1
kind: Pod
metadata:
  name: memory-consumer
spec:
  containers:
  - name: memory-consumer-container
    image: memory_consumer
    imagePullPolicy: Never
    resources:
      requests:
        memory: "64Mi"
      limits:
        memory: "256Mi"
    env:
    - name: JVM_OPTS
      value: "-Xms64M -Xmx256M"
  restartPolicy: Never
$ kubectl delete pod memory-consumer
pod "memory-consumer" deleted

$ kubectl get po --show-all
No resources found.

$ kubectl create -f memory_consumer.yaml
pod "memory-consumer" created

$ kubectl logs memory-consumer
Initial free memory: 227MB
Max memory: 228MB
Reserve: 181MB
Free memory: 50MB

$ kubectl get po --show-all
NAME              READY     STATUS      RESTARTS   AGE
memory-consumer   0/1       Completed   0          1m

这次应用程序运行成功;它检测到我们通过 -Xmx256M 传递的正确可用内存,因此没有达到pod定义中指定的内存限制内存“256Mi”。

虽然这个解决方案可行,但它需要在两个地方指定内存限制:一个是作为容器内存的限制:“256Mi”,另一个是在传递给 -Xmx256M 的选项中。如果JVM根据内存“256Mi”设置准确地检测到可用内存的最大数量,会更方便,不是吗?

好吧,Java9中有一个变化,使它具有Docker意识,它已经被后移植到Java8。

为了利用此功能,我们的pod定义必须如下所示:

memory-consumer.yaml

apiVersion: v1
kind: Pod
metadata:
  name: memory-consumer
spec:
  containers:
  - name: memory-consumer-container
    image: memory_consumer
    imagePullPolicy: Never
    resources:
      requests:
        memory: "64Mi"
      limits:
        memory: "256Mi"
    env:
    - name: JVM_OPTS
      value: "-XX:+UnlockExperimentalVMOptions -XX:+UseCGroupMemoryLimitForHeap -XX:MaxRAMFraction=1 -Xms64M"
  restartPolicy: Never
$ kubectl delete pod memory-consumer
pod "memory-consumer" deleted

$ kubectl get pod --show-all
No resources found.

$ kubectl create -f memory_consumer.yaml
pod "memory-consumer" created

$ kubectl logs memory-consumer
Initial free memory: 227MB
Max memory: 228MB
Reserve: 181MB
Free memory: 54MB

$ kubectl get po --show-all
NAME              READY     STATUS      RESTARTS   AGE
memory-consumer   0/1       Completed   0          50s

请注意 -XX:MaxRAMFraction=1 ,通过它我们可以 告诉JVM要使用多少可用内存作为最大堆大小

通过 -XmxUseCGroupMemoryLimitForHeap 动态设置一个考虑可用内存限制的最大堆大小是很重要的,因为它有助于在内存使用接近其限制时通知JVM,以便释放空间。如果最大堆大小不正确(超过可用内存限制),JVM可能会盲目地达到该限制而不尝试释放内存,进程将被 oom kill

这个 java.lang.OutOfMemoryError 内存溢出的错误是不同的。它表示最大堆大小不足以在内存中容纳所有活动对象。如果是这种情况,则需要通过 -Xmx 增加最大堆大小,或者如果使用 UseCGroupMemoryLimitForHeap ,则通过容器的内存限制增加最大堆大小。

在k8s上运行基于JVM的工作负载时,cgroups的使用是非常实际的。