GraalVM 加持 Java 容器化,速度起飞!
作者:bleem
来源:https://mritd.com/2022/11/08/java-containerization-guide/
系统选择
JDK or JRE
JDK: Java Development Kit JRE: Java Runtime Environment
javac
、jps
、jstack
、jmap
等命令, 这些都是为了调试和编译 Java 程序所必须的工具, 同时 JDK 作为开发套件是包含 JRE 的; 而 JRE 仅为 Java 运行时环境, 它只包含 Java 程序运行时所必须的一些命令以及依赖类库, 所以 JRE 会比 JDK 体积更小、更轻量。JDK 选择
OracleJDK 还是 OpenJDK
com.sun.\*
包下的相关类、接口等, 这些 API 很多是 Oracle JDK 私有的, 在 OpneJDK 中可能完全不包含或已经变更. 所以如果代码中包含相关调用则只能使用 Oracle JDK。import
引用, 而 Java 是允许存在这种无用的 import
的; 针对这种只需要重新格式化和优化导入即可。Option + Command + L
(格式化) 还有 Control + Option + O
(自动优化包导入)。OracleJDK 重建问题
OpenJDK 发行版
AdoptOpenJDK Amazon Corretto IBM Semeru Runtime Azul Zulu Liberica JDK
JVM 选择
Hotspot OpenJ9 TaobaoVM LiquidVM Azul Zing
信号量传递
BeanTest.java: 使用 @PreDestroy
注册 Hook 来监听关闭事件模拟优雅关闭Dockerfie.bad: 错误示范的 Dockerfile Dockerfile.direct: 直接运行命令来实现优雅关闭 Dockerfile.exec: 利用 exec 来实现优雅关闭 Dockerfile.bash-c: 利用 bash -c
来实现优雅关闭Dockerfile.tini: 验证 tini 在某些情况下无法实现优雅关闭 Dockerfile.dumb-init: 验证 dumb-init 在某些情况下无法实现优雅关闭
BeanTest
只做打印测试都是通用的, 所以这里直接贴代码:import org.springframework.stereotype.Component;
import javax.annotation.PreDestroy;
@Component
public class BeanTest {
@PreDestroy
public void destroy() {
System.out.println("==================================");
System.out.println("接收到终止信号, 正在执行优雅关闭...");
System.out.println("==================================");
}
}
错误的信号传递
java -jar /SpringBootGracefulShutdownExample-0.0.1-SNAPSHOT.jar
COPY entrypoint.bad.sh /
COPY target/SpringBootGracefulShutdownExample-0.0.1-SNAPSHOT.jar /
# 下面几种种方式都无法转发信号
#CMD /entrypoint.bad.sh
#CMD ["/entrypoint.bad.sh"]
CMD ["bash", "/entrypoint.bad.sh"]
docker stop
命令时明显卡顿一段时间(实际上是 docker 在等待容器内进程自己退出), 当到达预定的超时时间后容器内进程被强行终止, 故没有打印优雅关闭的日志:正确的信号传递
直接运行方式
CMD
或 ENTRYPOINT
指令运行 java 程序:COPY target/SpringBootGracefulShutdownExample-0.0.1-SNAPSHOT.jar /
CMD ["java", "-jar", "/SpringBootGracefulShutdownExample-0.0.1-SNAPSHOT.jar"]
间接 Exec 方式
exec
的方式进行最终执行; 这种方式也可以保证信号传递(不上图了):# 假装进行一些变量处理等操作...
export VERSION="0.0.1"
exec java -jar /SpringBootGracefulShutdownExample-${VERSION}-SNAPSHOT.jar
Bash-c 方式
bash -c
来执行命令; 在使用 bash -c
执行一些简单命令时, 其行为会跟 exec 很相似, 也会把子进程命令替换到父进程从而让 -c
后的命令直接接受到系统信号; 但需要注意的是, 这种方式不一定百分百成功, 比如当 -c
后面的命令中含有管道、重定向等可能仍会触发 fork
, 这时子命令仍然无法完成优雅关闭。bash -c
执行, 在命令简单情况下可以做到优雅关闭COPY entrypoint.bad.sh /
COPY target/SpringBootGracefulShutdownExample-0.0.1-SNAPSHOT.jar /
CMD ["bash", "-c", "java -jar /SpringBootGracefulShutdownExample-0.0.1-SNAPSHOT.jar"]
bash -c
的相关讨论, 可以参考 StackExchange[4]。tini 或 dump-init
RUN set -e \
&& apt update \
&& apt install tini psmisc -y
COPY entrypoint.bad.sh /
COPY target/SpringBootGracefulShutdownExample-0.0.1-SNAPSHOT.jar /
ENTRYPOINT ["tini", "-vvv", "--"]
CMD ["bash", "/entrypoint.bad.sh"]
最佳实践
1、容器内内置 tini 或者 dump-init 是比较好的做法可以防止僵尸进程 2、tini 或者 dump-init 并不能百分百实现优雅关闭 3、简单命令直接 CMD 执行可以接受信号转发实现优雅关闭 4、复杂命令在脚本内进行 exec 执行也可以接受信号转发实现优雅关闭 5、直接使用 bash -c
运行在简单命令执行时也可以优雅关闭, 但需要实际测试来确定准确性
内存限制
无配置下的自适应
使用 docker run -m 512m ...
将容器内存限制为 512m, 实际宿主机为 16G;使用 java -XX:+PrintFlagsFinal -version | grep MaxHeapSize
命令查看 JVM 默认的最大堆内存(后来发现-XshowSettings:vm
看起来更清晰)。
OpenJDK 8u111
OpenJDK 8u131
-XX:+UseCGroupMemoryLimitForHeap
参数来支持内存自适应, 这里我们先不开启, 先直接进行测试:OpenJDK 8u222
XX:+UseContainerSupport
参数来支持 JVM 容器化, 不过该版本暂时无法下载, 这里使用更高的 8u222
测试, 测试时同样暂不开启特定参数进行测试:OpenJDK 11.0.15
XX:+UseContainerSupport
已被默认开启, 所以这里我们仍然选择不去修改任何设置去测试:UseContainerSupport
开关, 仍然无法正常的自适应内存。OpenJDK 11.0.16
11.0.16
版本在不做任何设置时自动适应了容器内存限制, 堆内存从接近 4G 变为了 120M。OpenJDK 17
有配置下的自适应
OpenJDK 8u131
-XX:+UnlockExperimentalVMOptions -XX:+UseCGroupMemoryLimitForHeap
参数进行测试, 测试结果是这个选项在我当前的环境中似乎完全不生效:OpenJDK 8u222
-XX:+UseContainerSupport
, 该参数从 OpenJDK 10 反向合并而来; 我尝试使用这个参数来进行测试, 结果仍然是没什么卵用:OpenJDK 11+
-XX:+UseContainerSupport
已经自动开启, 我们不需要再做什么特殊设置, 所以结果是跟无配置测试结果一致的: 从 11.0.15
以后的版本开始能够自适应, 之前的版本(包括 11.0.15
)都不支持自适应。分析与总结
Cgroups V1
Cgroups V1
的容器化环境来说, “旧的” 一些规则仍然适用(新内核增加内核参数 systemd.unified_cgroup_hierarchy=0
回退到 Cgroups V1):1、OpenJDK 8u131 以及之后版本增加 -XX:+UnlockExperimentalVMOptions -XX:+UseCGroupMemoryLimitForHeap
参数支持内存自适应;2、OpenJDK 8u191 以及之后版本增加 -XX:+UseContainerSupport
参数支持内存自适应;3、OpenJDK 11 以及之后版本默认开启了 -XX:+UseContainerSupport
参数, 自动支持内存自适应。
Cgroups V2
Cgroups V2
, 需要注意的是针对于 Cgroups V2
的内存自适应只有在 OpneJDK 11.0.16 以及之后的版本才支持, 在这之前开启任何参数都没用。DNS 缓存
set -e
for tag in 8-jdk 11-jdk 17-jdk; do
tag_name="jvm-dns-ttl-policy"
output_file="$(mktemp)"
jvm_args=""
if ! [ "${tag}" == "8-jdk" ]; then
jvm_args="--add-exports java.base/sun.net=ALL-UNNAMED"
fi
ttl=""
if ! [ "${1}" == "" ]; then
ttl="-Dsun.net.inetaddr.ttl=${1}"
fi
dockerfile="
FROM eclipse-temurin:${tag}
WORKDIR /var/tmp
RUN printf ' \\
public class DNSTTLPolicy { \\
public static void main(String args[]) { \\
System.out.printf(\"Implementation DNS TTL for JVM in Docker image based on 'eclipse-temurin:${tag}' is %%d seconds\\\\n\", sun.net.InetAddressCachePolicy.get()); \\
} \\
}' >DNSTTLPolicy.java
RUN javac ${jvm_args} DNSTTLPolicy.java -XDignore.symbol.file
CMD java ${jvm_args} ${ttl} DNSTTLPolicy
ENTRYPOINT java ${jvm_args} ${ttl} DNSTTLPolicy
"
dockerfile_security_manager="
FROM eclipse-temurin:${tag}
WORKDIR /var/tmp
RUN printf ' \\
public class DNSTTLPolicy { \\
public static void main(String args[]) { \\
System.out.printf(\"Implementation DNS TTL for JVM in Docker image based on 'eclipse-temurin:${tag}' (with security manager enabled) is %%d seconds\\\\n\", sun.net.InetAddressCachePolicy.get()); \\
} \\
}' >DNSTTLPolicy.java
RUN printf ' \\
grant { \\
permission java.security.AllPermission; \\
};' >all-permissions.policy
RUN javac ${jvm_args} DNSTTLPolicy.java -XDignore.symbol.file
CMD java ${jvm_args} ${ttl} -Djava.security.manager -Djava.security.policy==all-permissions.policy DNSTTLPolicy
ENTRYPOINT java ${jvm_args} ${ttl} -Djava.security.manager -Djava.security.policy==all-permissions.policy DNSTTLPolicy
"
echo "Building Docker image based on eclipse-temurin:${tag} ..." >&2
docker build -t "${tag_name}" - <<<"${dockerfile}" 2>&1 > /dev/null
docker run --rm "${tag_name}" &>"${output_file}"
cat "${output_file}"
docker build -t "${tag_name}" - <<<"${dockerfile_security_manager}" 2>&1 > /dev/null
docker run --rm "${tag_name}" &>"${output_file}"
cat "${output_file}"
echo ""
done
默认 DNS 缓存
Security Manager
则变为 -1s, 那么 -1s 什么意思呢(截取自 OpenJDK 11 源码):*
* -1: caching forever
* any positive value: the number of seconds to cache an address for
*
* default value is forever (FOREVER), as we let the platform do the
* caching. For security reasons, this caching is made forever when
* a security manager is set.
*/
private static volatile int cachePolicy = FOREVER;
/* The Java-level namelookup cache policy for negative lookups:
*
* -1: caching forever
* any positive value: the number of seconds to cache an address for
*
* default value is 0. It can be set to some other value for
* performance reasons.
*/
private static volatile int negativeCachePolicy = NEVER;
设置 DNS 缓存
-Dsun.net.inetaddr.ttl=xxx
参数手动设置 DNS 缓存时间:Security Manager
都会遵循我们的设置. 如果需要更细致的调试 DNS 缓存推荐使用 Alibaba 开源的 DCM[7] 工具。Native 编译
JAVA_HOME
和 PATH
变量, 并使用 mvn clean package -Dmaven.test.skip=true -Pnative
编译即可:target
目录下生成可以直接执行的二进制文件, 以下为启动速度对比测试:引用链接
eclipse-temurin: https://hub.docker.com/_/eclipse-temurin
[2]ibm-semeru-runtimes: https://hub.docker.com/_/ibm-semeru-runtimes
[3]GitHub: https://github.com/mritd/SpringBootGracefulShutdownExample
[4]StackExchange: https://unix.stackexchange.com/questions/466496/why-is-there-no-apparent-clone-or-fork-in-simple-bash-command-and-how-its-done
[5]JDK-8230305: https://bugs.openjdk.org/browse/JDK-8230305
[6]某大佬: https://gist.github.com/andystanton/958a9a87f5b5a4eae537f96f896a19bc
[7]DCM: https://github.com/alibaba/java-dns-cache-manipulator
推荐阅读
你好,我是程序猿DD,10年开发老司机、阿里云MVP、腾讯云TVP、出过书创过业、国企4年互联网6年。从普通开发到架构师、再到合伙人。一路过来,给我最深的感受就是一定要不断学习并关注前沿。只要你能坚持下来,多思考、少抱怨、勤动手,就很容易实现弯道超车!所以,不要问我现在干什么是否来得及。如果你看好一个事情,一定是坚持了才能看到希望,而不是看到希望才去坚持。相信我,只要坚持下来,你一定比现在更好!如果你还没什么方向,可以先关注我,这里会经常分享一些前沿资讯,帮你积累弯道超车的资本。