1. Docker ≠ VM
从某些角度看,Docker 与 虚拟机 有些类似。
如:自己的 shell、能独立安装软件包、运行时与其它容器互不干扰。
但 Docker 的虚拟化远没有虚拟机彻底。Docker 是一种更轻量化的隔离技术。如:
-> 用 namespace 技术为每个容器提供单独的命名空间,实现对网络、PID、用户、IPC通信、文件系统挂载点等方面的隔离;
-> 用 CGroup 技术对CPU、内存、磁盘IO等计算资源进行管理
在虚拟机平台上,任何程序(包括Java)只要调用的是同一个系统API,其行为都是一致的,无需额外考虑兼容性。
而 Docker 的不完全虚拟化则意味着部分底层细节是需要用户理解Docker的行为后作额外处理的。也就是说这部分兼容性需用户自行处理。
如,在 Docker 环境中:
-> CGroup 这种资源管理技术对 Java 是不能自动处理的。
-> namespace 这种隔离技术则导致 jcmd、jstack 等工具需要做一些修改才能正常使用。
因为这些工具依赖于“/proc/”下提供的一些信息。
2. Java程序在Docker机制下的问题
Java程序在Docker机制下的主要问题来自JVM对系统可用资源的误判。
2.1 运行时资源误判
Docker环境中,CPU和内存等资源是通过CGroup(Control Group)实现的。JDK 在 8u131 之前不会识别这些限制,可能导致过度“索取”资源而引发问题。如:
2.1.1 可用内存误判
JVM启动时会根据检测的系统内存大小设置自身的相关内存使用限制。
包括堆、元数据区、直接内存等。如:
初始堆大小为系统内存的 1/64;
最大堆大小为系统内存的 1/4
基于误判的系统可用内存,JVM 的相关参数设置就可能不合理。
这会导致 JVM 试图使用超过容器限制的内存,进而导致运行时 JVM OOM 或 容器因 OOM 而被 kill;
另外,某些时候,我们会通过参数 -XX:OnOutOfMemoryError 来指定一段脚本,以便让Java 程序在 OOM 时自动重启。
但是在Docker容器环境下,如果Java程序已经过度提交内存,容器可能无法支持这种基于 fork 创建的新进程正常运行。
2.1.2 CPU可用核数误判
因误判可用的CPU资源,JVM 设置的 GC 并行线程数超过了容器的限制。
JVM 会将检测到的系统 CPU 核数,作为确定 Parallel GC 并行线程数 和 JIT compiler 线程数的依据
ForkJoinPool 机制中的并行等级也会受到影响
2.2 其它影响
早期Java程序主要是长时间运行在大型服务器端的应用,Java早期的优化也主要针对这类场景,所以内存占用大、启动速度慢等现象并不算最大的痛点。
但当Java程序被应用到Docker化的微服务或Serverless架构中时,便凸显了这些痛点。
3. 如何解决Java程序在Docker环境中的可用资源误判问题
方案一:升级JDK
最新JDK对Docker容器的支持已经比较完善。所以这通常这是最稳妥的方案。可以避免自己去费尽心力查找各种潜在的漏洞,并给出相应的补救措施。专业事交给专业的人去做!
JDK 10:
JDK 10 默认自适应Docker环境中的各种资源限制和实现差异。
还可以用参数 -XX:ActiveProcessorCount 来指定 CPU 核数。
JDK 9:
内存方面:
通过参数 -XX:+UnlockExperimentalVMOptions 和 -XX:+UseCGroupMemoryLimitForHeap 设置堆内存限制
注:
-> UnlockExperimentalVMOptions 必须在前
-> 只在 Linux 环境有效
CPU方面:
JDK 9 可以正确理解 Docker 中 --cpuset-cpus 等相关CPU限制的参数。所以无需额外设置
JDK 8u131:
JDK 8 从该版本开始已包括 JDK 9 中对 Docker 的支持,所以可以像 JDK 9 那样设置相关参数。
方案二:继续使用当前JDK,并明确设置相关资源限制
如:通过 Docker 参数 -e 将最大堆内存设置信息放入参数 JAVA_OPTIONS,让其内部的Java程序结合该Docker环境变量作为JVM的启动参数之一。
-e JAVA_OPTIONS='-Xmx1g'
-XX:ParallelGCThreads、-XX:CICompilerCount 等JVM参数也可以通过上述方法传递
最好也告知 JVM 最大系统内存限制。因为堆只是 JVM 所使用内存的一部分。
-XX:MaxRAM='cat /sys/fs/cgroup/memory/memory.limit_in_bytes'