2018年的Docker和JVM


即使Docker是2016年的大事情,它今天仍然很热!它是最受欢迎的Orchestration平台Kubernetes的基础,它已成为云部署的首选解决方案。
Docker是容器应用/微服务的事实标准解决方案。如果你运行Java应用程序,你需要知道一些陷阱和技巧。

为什么我要将JAVA放在容器中呢?
这是一个很好的问题。Java不是用“ 一次编写,随处运行 ”的口号构建的吗?尽管如此,该语句的意思Java应用仅包含Java二进制文件。
您的字节码(Jar文件)将可能在每个的JVM版本上正常运行。然而,数据库驱动程序呢?文件系统访问?联网?可用熵?您依赖的第三方应用程序?所有这些因素都会因操作系统而异。
通常,您的应用程序需要在Java二进制文件和任何第三方依赖项之间取得良好的平衡。如果您曾经支持过客户安装的Java应用程序,那么您就会知道我的意思。

首先是虚拟机
在容器之前,通用解决方案是使用虚拟机。使用您选择的操作系统创建一个新的空白虚拟计算机,安装所有第三方依赖项,复制Java二进制文件,获得快照并最终递交。
使用VM,您可以确定所发送的内容与其运行方式完全相同,并且每次都一致。环境配置问题没有空间。
它们还提供强大的封装。如果您在云中运行应用程序,则每个虚拟机都将被隔离。在同一硬件上运行的VM之间的损坏空间非常有限。
但是,总有一个但是,却很重!如果您在应用程序中发现了一个错误并且必须更改一行代码,则必须重新编译Jar文件,重新安装VM并运送整个代码。一行代码变成 几个GB文件,可以上传到云端或下载到客户端。操作系统文件很重,可能比Java二进制文件重得多,并且每次都必须发送它们,即使它们没有真正改变。

Docker来救援
如上所述,VM拥有自己的操作系统副本,而容器则更小,并且只包含您要发送的内容。

使用容器,操作系统(确切地说,它是正在共享的内核,您可以选择从不同的发行版(如Ubuntu,Debian,Alpine等)构建图像)由引擎(例如Docker)提供,并且您不需要将其和你的应用一起交付。
使用Docker,您可以交付以层为单位构建的图像。构建镜像的说明放在Dockerfile中。

从概念上讲,Dockerfile可能是这样的:

  1. 从空白的Ubuntu发行版开始
  2. 安装Java
  3. 安装依赖项A.
  4. 安装依赖关系B.
  5. 复制jar文件

Dockerfile中的每条指令都会创建一个不可变 层。这很聪明,也是一个很好的优化。只有当你修改改变最后一层代码时,则只需上传最后一层,之前未更改的图层将被缓存; 最终用户只需从镜像底部下载更改的图层。使用Docker,一行代码的更改意味着只有几MB上传/下载(如果这是VM,则更改将以GB而不是MB)。

请注意,容器不提供与VM相同级别的封装。Docker容器只是在主机上运行的进程。有一些Linux内核功能(即命名空间和控制组)有助于降低Docker容器的访问级别,但这远远不如VM隔离那样具有弹性。这可能是您的业务的问题,也可能不是,但您需要注意。

JAVA + DOCKER = ???
在我们研究如何在Docker容器中打包Jar文件之前,我们需要涵盖一些重要的限制。Java 1.0于1996年发布,而Linux容器起源于2008年左右。由此可见,预计JVM不会容纳Linux容器。

Docker的一个关键功能是能够限制容器的内存和CPU使用。这是在云中运行许多Docker容器在经济上有趣的主要原因之一。像Kubernetes(k8s)这样的业务流程解决方案将尝试在多个节点上有效地 “包装” 容器。这里的“包装”是指打包内存和CPU。如果为Docker容器提供内存和CPU的合理界限,K8将能够在多个节点上有效地安排它们。

不幸的是,这正是Java缺乏的地方。让我们用一个例子来理解这个问题。

想象一下,你有一个32GB内存的节点,你想使用Docker运行一个限制为1GB的Java应用程序。如果未提供-Xmx参数,则JVM将使用其默认配置:

  • JVM将检查总可用内存。因为JVM不知道Linux容器(特别是限制内存的控制组),所以它认为它在主机上运行并且可以访问完整的 32GB可用内存。
  • 默认情况下,JVM将使用MaxMemory / 4,在这种情况下为8GB(32GB / 4)。
  • 随着堆大小的增长并超过1GB,容器将被Docker杀死。

早期的Docker Java采用者有一段时间试图理解为什么他们的JVM在没有任何错误消息的情况下崩溃。要了解发生了什么,你需要检查被杀死的Docker容器,在这种情况下,你会看到一条消息说“OOM被杀 ”(OutOf Memory)。

当然,一个明显的解决方案是使用Xmx参数修复JVM的堆大小,但这意味着您需要控制内存两次,一次在Docker中,一次在JVM中。每当你想要做出改变时,你必须做两次。不理想。

此问题的第一个解决方法是使用Java 8u131和Java 9发布的版本,我说解决方法是因为你必须使用心爱的-XX:+ UnlockExperimentalVMOptions参数。如果您从事金融服务,我相信您很乐意向您的客户或您的老板解释这是明智之举。

然后你必须使用-XX:+ UseCGroupMemoryLimitForHeap,这将告诉JVM检查控制组内存限制以设置最大堆大小。
最后,您必须使用-XX:MaxRAMFraction来决定可以为JVM分配的最大内存部分。不幸的是,这个参数是一个自然数。例如,将Docker内存限制设置为1GB,您将拥有以下内容:

  • -XX:MaxRAMFraction = 1最大堆大小为1GB。这不是很好,因为你不能给JVM 100%的允许内存。该容器上可能还有其他组件正在运行
  • -XX:MaxRAMFraction = 2最大堆大小为500MB。那更好但现在看来我们浪费了很多内存。
  • -XX:MaxRAMFraction = 3最大堆大小为250MB。你正在支付1GB的内存,你的Java应用程序可以使用250MB。这有点荒谬
  • -XX:MaxRAMFraction = 4 太小。

基本上,控制最大可用RAM的JVM标志被设置为分数而不是百分比,这使得很难设置能够有效利用可用(允许)RAM的值。
我们专注于内存,但同样适用于CPU。你需要使用像这样的参数
-Djava.util.concurrent.ForkJoinPool.common.parallelism = 2
控制应用程序中不同线程池的大小。2表示两个线程(最大值将限制为主机上可用的超线程数)。
总而言之,使用Java 8u131和Java 9,你会有类似的配置:

-XX:+UnlockExperimentalVMOptions
-XX:+UseCGroupMemoryLimitForHeap
-XX:MaxRAMFraction=2
-Djava.util.concurrent.ForkJoinPool.common.parallelism=2

幸运的是Java 10来救援。首先,您不必使用可怕的实验功能标志。如果在Linux容器中运行Java应用程序,JVM将自动检测控制组内存限制。否则,您只需添加-XX:-UseContainerSupport。

然后,您可以使用-XX控制内存:InitialRAMPercentage,-XX:MaxRAMPercentage和-XX:MinRAMPercentage。比如

  • Docker内存限制:1GB
  • -XX:InitialRAMPercentage = 50
  • -XX:MaxRAMPercentage = 70

您的JVM将从500MB(50%)堆大小开始,并将增长到700MB(70%),在容器中最大可用内存为1GB。

Java2Docker
将Java应用程序转换为Docker镜像的方法有很多种。
您可以使用Maven插件(fabric8Spotify)或Graddle插件。但也许最简单和更语义的方法是自己编写Dockerfile。这种方法还允许您利用在JDK9引入的JLINK。使用jlink,您可以构建一个自定义JDK二进制文件,其中只包含应用程序所需的模块。
我们来看一个例子:

FROM adoptopenjdk/openjdk11 AS jdkBuilder
RUN $JAVA_HOME/bin/jlink \
--module-path /opt/jdk/jmods \
--verbose \
--**add**-modules java.base \
--output /opt/jdk-minimal \
--compress 2 \
--no-header-files
 
FROM debian:9-slim
COPY --from=jdkBuilder /opt/jdk-minimal /opt/jdk-minimal
ENV JAVA_HOME=/opt/jdk-minimal
COPY target/*.jar /opt/
CMD $JAVA_HOME/bin/java $JAVA_OPTS -jar /opt/*.jar

让我们一行一行地解释它:

FROM adoptopenjdk/openjdk11 AS jdkBuilder

我们从包含完整JDK 11的现有Docker镜像开始。这里我们使用AdoptOpenJDK提供的构建,但您可以使用任何其他分发(例如新发布的AWS Corretto)。AS jdkBuilderinstruction是一个特殊的指令,告诉Docker我们想要启动一个名为jdkBuilder的“阶段”。这将在以后有用。

RUN $JAVA_HOME/bin/jlink \
--module-path /opt/jdk/jmods \
--verbose \
--add-modules java.base \
--output /opt/jdk-minimal \
--compress 2 \
--no-header-files

我们运行jlink来构建我们的自定义JDK二进制文件。在此示例中,我们仅使用java.base模块。如果您仍在编写旧的类路径类型应用程序,则必须手动添加应用程序所需的所有模块。例如,对于我的一个Spring应用程序,我使用以下模块:

--add-modules java.base,java.logging,java.xml,
java.xml.bind,java.sql,jdk.unsupported,
java.naming,java.desktop,java.management,
java.security.jgss,java.security.sasl,
jdk.crypto.cryptoki,jdk.crypto.ec,
java.instrument,jdk.management.agent,
jdk.localedata

如果您正在编写带有模块的Java应用程序,您可以让jlink 推断出需要哪些模块。为此,您需要将模块添加到module-path参数(MacOS / Linux上用“:”分隔的路径列表和Windows上的“;”)。但是因为这个过程发生在Docker镜像中,你需要使用COPY命令将其结束。然后,您只需要在-add-modules命令中添加自己的模块,并自动添加所需的模块。所以它会是这样的:

FROM adoptopenjdk/openjdk11 AS jdkBuilder
COPY path/to/module-info.class /opt/myModules
RUN $JAVA_HOME/bin/jlink \
--module-path /opt/jdk/jmods:/opt/myModules \
--verbose \
--**add**-modules my-module \
--output /opt/jdk-minimal \
--compress 2 \
--no-header-files

FROM debian:9-slim
因为我们使用另一个FROM关键字,Docker将丢弃我们迄今为止所做的所有事情并开始一个全新的镜像。这里我们从一个安装了Debian 9的Docker镜像开始,并安装了最小的依赖项(slim标签)。这个Debian映像甚至没有Java,所以我们接下来会安装

COPY --from=jdkBuilder /opt/jdk-minimal /opt/jdk-minimal
ENV JAVA_HOME=/opt/jdk-minimal

这是舞台名称变得重要的地方。我们可以告诉Docker 从早期阶段复制特定文件夹,在这种情况下从jdkBuilder阶段复制。这很有趣,因为在第一阶段我们可以下载很多最终不需要的中间库。
在这种情况下,我们从完整的JDK 11发行版开始,重量为200 + MB,但我们只需复制我们的自定义JDK二进制文件,通常为~50 / 60MB; 取决于您必须导入的JDK模块。然后我们将JAVA_HOME环境变量设置为指向我们新构建的JDK二进制文件。
这种技术称为Docker多阶段构建,确实非常有用。它可以有效地利用所创建的图层,并有助于制作更纤薄的Docker镜像。如果您查看了典型的Dockerfile,可能会看到如下所示的说明:

rm -rf /var/lib/apt/lists/* \
apt-get clean && apt-get update && apt-get upgrade -y \
apt-get install -y --no-install-recommends curl ca-certificates \
rm -rf /var/lib/apt/lists/* \
...

这是一种种在单个Dockerfile指令中将尽可能多的命令分组的技术,这技术对于最小化镜像的层数很有用。大量层可能会影响运行时的性能。但是,这种方法也存在缺陷。每条指令有一个层意味着创建了许多缓存的检查点。
如果你在第15条指令中在Dockerfile中犯了一个错误,Docker就不必重新运行前面的14,它可以简单地从缓存中恢复它们。如果你的一个步骤是下载一个400MB的文件,这个指令缓存将为你节省大量的时间。
好消息是多阶段使这种方法过时了!您可以创建第一个“Throw-away”阶段,在这个阶段创建任意数量的图层。然后,您将创建一个新的“final”阶段,在该阶段中,您只从第一个Throw-away阶段复制所需的文件。
第一阶段的许多层将被完全忽略!

COPY target/*.jar /opt/
现在我们安装了Java,我们需要复制你的应用程序。上面这行将从目标目录复制任何jar文件并将它们放在opt文件夹中

CMD $JAVA_HOME/bin/java $JAVA_OPTS -jar /opt/*.jar
最后,这告诉Docker在容器运行时执行哪个命令。这里我们只运行java并允许在运行时传递JAVA_OPTS变量。