Dockerfile 构建 Java 应用瘦身优化

背景

早期我们对于 Docker 构建 Spring Boot 工程的方式很简单,直接构建生成 jar 可执行程序,通过 java $JAVA_OPTS -jar 启动。在 Kubernetes 环境下,如果要调整 JVM 参数,则通过 ConfigMap 维护 JAVA_OPTS 变量,方便统一管理同一个 Nacos 配置中心,如下:

properties 复制代码
-Xmn1G
-Xmx1G
-Xss256k
-Dspring.cloud.nacos.config.username=nacos 
-Dspring.cloud.nacos.config.password=nacos
-Dspring.cloud.nacos.config.server-addr=127.0.0.1:8848 

在实际的生产环境,可能定义了十几个甚至上百个 Deployment,每个 Deployment 根据不同的负载设置不同的 JVM 参数,没办法共享同一份 ConfigMap 文件。为了解决这个问题,我们应该优化下 Dockerfile 构建模板,把 JVM 参数和运行环境变量分离出去。

目标

制定一套标准 Docker 构建模板,简化 JVM 配置流程。

实现

首先从 Dockerfile 文件着手,需要考虑几个方面:

  1. 镜像分层:分离基础镜像(固定)、辅助工具(固定)、Maven 依赖(基本不变)和应用程序代码(变化),并减少镜像体积,通过镜像缓存提高构建速度。
  2. 变量简化:提取研发团队比较关心的 JVM 参数作为环境变量,例如 XMS 最小堆、XMX 最大堆、XSS 线程栈、GC 模式、GC 日志、堆转储、开启大页内存等。
  3. 容器安全:基于最小权限原则,运行容器时,以非 root 用户运行,只允许持有特定目录的读写权限。
  4. 脚本分离:将启动脚本与 Dockerfile 分离,方便从 ConfigMap 维护。

Dockerfile 内容如下,因 DockerHub 镜像仓库访问不稳定,代码使用了 m.daocloud.io 代理,您可以根据实际情况调整。

dockerfile 复制代码
# 使用基础镜像
FROM m.daocloud.io/eclipse-temurin:11-jdk-alpine AS builder

# 指定构建模块
ARG MODULE=eden-demo-cola-start

# 设置工作目录
WORKDIR /app

# 复制必要文件
COPY $MODULE/target/$MODULE.jar application.jar
COPY docker/entrypoint.sh entrypoint.sh

# 安装最小依赖项
RUN sed -i 's|https://dl-cdn.alpinelinux.org|https://mirrors.aliyun.com|g' /etc/apk/repositories \
	&& apk update \
	&& apk add --no-cache tar binutils dos2unix \
    && dos2unix entrypoint.sh \
    && jdeps --ignore-missing-deps -q \
		--recursive \
		--multi-release 11 \
		--print-module-deps \
		--class-path '/BOOT-INF/lib/*' \
		application.jar > modules.txt

# 构建运行环境
RUN $JAVA_HOME/bin/jlink \
		--verbose \
		--add-modules $(cat modules.txt),sun.misc \
		--strip-debug \
		--no-man-pages \
		--no-header-files \
		--compress=2 \
		--output /jre

# 使用 Spring Boot 的分层模式提取 JAR 文件的依赖项
RUN java -Djarmode=layertools -jar application.jar extract

# 创建容器镜像
FROM m.daocloud.io/alpine:latest

# 定义元数据
LABEL maintainer="梦想歌 <shiyindaxiaojie@gmail.com>"
LABEL version="1.0.0"

# 指定构建参数
ARG USER=tmpuser
ARG GROUP=tmpgroup

# 设置环境变量
ENV JAVA_HOME /opt/jdk/jdk-11
ENV PATH "${JAVA_HOME}/bin:${PATH}"
ENV HOME "/app"
ENV TZ "Asia/Shanghai"
ENV LANG "C.UTF-8"
ENV XMS "1g"
ENV XMX "1g"
ENV XSS "256k"
ENV GC_MODE "G1"
ENV USE_GC_LOG "Y"
ENV USE_HEAP_DUMP "Y"
ENV USE_LARGE_PAGES "N"
ENV SPRING_PROFILES_ACTIVE "dev"
ENV SERVER_PORT "8080"
ENV MANAGEMENT_SERVER_PORT "9080"

# 设置日志目录
RUN mkdir -p $HOME/logs \
	&& touch $HOME/logs/entrypoint.out \
	&& ln -sf /dev/stdout $HOME/logs/entrypoint.out \
	&& ln -sf /dev/stderr $HOME/logs/entrypoint.out

# 切换工作目录
WORKDIR $HOME

# 从基础镜像复制应用程序依赖项和模块
COPY --from=builder /jre $JAVA_HOME
COPY --from=builder /app/dependencies/ ./
COPY --from=builder /app/spring-boot-loader ./
COPY --from=builder /app/organization-dependencies ./
COPY --from=builder /app/modules-dependencies ./
COPY --from=builder /app/snapshot-dependencies/ ./
COPY --from=builder /app/application/ ./
COPY --from=builder /app/entrypoint.sh ./

# 创建普通用户
RUN addgroup -g 1000 $GROUP \
	&& adduser -u 1000 -G $GROUP -h $HOME -s /bin/bash -D $USER \
	&& chown -R $USER:$GROUP $HOME \
	&& chmod -R a+rwX $HOME

# 切换到容器用户
USER $USER

# 暴露容器端口
EXPOSE $SERVER_PORT $MANAGEMENT_SERVER_PORT

# 设置启动脚本
CMD ["./entrypoint.sh"]

笔者将启动脚本命名为 entrypoint.sh,内容如下:

bash 复制代码
#!/bin/sh

JAVA_MAJOR_VERSION=$(java -version 2>&1 | sed -E -n 's/.* version "([0-9]*).*$/\1/p')

JAVA_OPTS="${JAVA_OPTS} -server"
JAVA_OPTS="${JAVA_OPTS} -XX:+UnlockExperimentalVMOptions -XX:+UnlockDiagnosticVMOptions"
JAVA_OPTS="${JAVA_OPTS} -XX:+AlwaysPreTouch -XX:+PrintFlagsFinal -XX:-DisplayVMOutput -XX:-OmitStackTraceInFastThrow"
JAVA_OPTS="${JAVA_OPTS} -Xms${XMS:-1G} -Xmx${XMX:-1G} -Xss${XSS:-256K}"
JAVA_OPTS="${JAVA_OPTS} -XX:MetaspaceSize=${METASPACE_SIZE:-128M} -XX:MaxMetaspaceSize=${MAX_METASPACE_SIZE:-256M}"
JAVA_OPTS="${JAVA_OPTS} -XX:MaxGCPauseMillis=${MAX_GC_PAUSE_MILLIS:-200}"

if [ "${GC_MODE}" = "ShenandoahGC" ]; then
    echo "GC mode is ShenandoahGC"
    JAVA_OPTS="${JAVA_OPTS} -XX:+UseShenandoahGC"
elif [ "${GC_MODE}" = "ZGC" ]; then
    echo "GC mode is ZGC"
    JAVA_OPTS="${JAVA_OPTS} -XX:+UseZGC"
elif [ "${GC_MODE}" = "G1" ]; then
    echo "GC mode is G1"
    JAVA_OPTS="${JAVA_OPTS} -XX:+UseG1GC"
    JAVA_OPTS="${JAVA_OPTS} -XX:InitiatingHeapOccupancyPercent=${INITIATING_HEAP_OCCUPANCY_PERCENT:-45}"
    JAVA_OPTS="${JAVA_OPTS} -XX:G1ReservePercent=${G1_RESERVE_PERCENT:-10} -XX:G1HeapWastePercent=${G1_HEAP_WASTE_PERCENT:-5} "
    JAVA_OPTS="${JAVA_OPTS} -XX:G1NewSizePercent=${G1_NEW_SIZE_PERCENT:-50} -XX:G1MaxNewSizePercent=${G1_MAX_NEW_SIZE_PERCENT:-50}"
    JAVA_OPTS="${JAVA_OPTS} -XX:G1MixedGCCountTarget=${G1_MIXED_GCCOUNT_TARGET:-8}"
    JAVA_OPTS="${JAVA_OPTS} -XX:G1MixedGCLiveThresholdPercent=${G1_MIXED_GCLIVE_THRESHOLD_PERCENT:-65}"
    JAVA_OPTS="${JAVA_OPTS} -XX:+UseStringDeduplication -XX:+ParallelRefProcEnabled"
elif [ "${GC_MODE}" = "CMS" ]; then
    echo "GC mode is CMS"
    JAVA_OPTS="${JAVA_OPTS} -XX:+UseConcMarkSweepGC -Xmn${XMN:-512m}"
    JAVA_OPTS="${JAVA_OPTS} -XX:ParallelGCThreads=${PARALLEL_GC_THREADS:-2} -XX:ConcGCThreads=${CONC_GC_THREADS:-1}"
    JAVA_OPTS="${JAVA_OPTS} -XX:+UseCMSInitiatingOccupancyOnly -XX:CMSInitiatingOccupancyFraction=${CMS_INITIATING_HEAP_OCCUPANCY_PERCENT:-92}"
    JAVA_OPTS="${JAVA_OPTS} -XX:+CMSClassUnloadingEnabled -XX:+CMSScavengeBeforeRemark"
    if [ "$JAVA_MAJOR_VERSION" -le "8" ] ; then
        JAVA_OPTS="${JAVA_OPTS} -XX:+CMSIncrementalMode -XX:CMSFullGCsBeforeCompaction=${CMS_FULL_GCS_BEFORE_COMPACTION:-5}"
        JAVA_OPTS="${JAVA_OPTS} -XX:+ExplicitGCInvokesConcurrent -XX:+ExplicitGCInvokesConcurrentAndUnloadsClasses"
    fi
fi

if [ "${USE_GC_LOG}" = "Y" ]; then
    echo "GC log path is '${HOME}/logs/jvm_gc.log'."
    JAVA_OPTS="${JAVA_OPTS} -XX:+PrintVMOptions"
    if [ "$JAVA_MAJOR_VERSION" -gt "8" ] ; then
        JAVA_OPTS="${JAVA_OPTS} -Xlog:gc:file=${HOME}/logs/jvm_gc-%p-%t.log:tags,uptime,time,level:filecount=${GC_LOG_FILE_COUNT:-10},filesize=${GC_LOG_FILE_SIZE:-100M}"
    else
        JAVA_OPTS="${JAVA_OPTS} -Xloggc:${HOME}/logs/jvm_gc.log -verbose:gc -XX:+PrintGCDetails -XX:+PrintGCDateStamps -XX:+PrintGCTimeStamps"
        JAVA_OPTS="${JAVA_OPTS} -XX:+UseGCLogFileRotation -XX:NumberOfGCLogFiles=${GC_LOG_FILE_COUNT:-10} -XX:GCLogFileSize=${GC_LOG_FILE_SIZE:-100M}"
        JAVA_OPTS="${JAVA_OPTS} -XX:+PrintGCCause -XX:+PrintGCApplicationStoppedTime"
        JAVA_OPTS="${JAVA_OPTS} -XX:+PrintTLAB -XX:+PrintReferenceGC -XX:+PrintHeapAtGC"
        JAVA_OPTS="${JAVA_OPTS} -XX:+FlightRecorder -XX:+PrintSafepointStatistics -XX:PrintSafepointStatisticsCount=1"
        JAVA_OPTS="${JAVA_OPTS} -XX:+DebugNonSafepoints -XX:+SafepointTimeout -XX:SafepointTimeoutDelay=500"
    fi
fi

if [ ! -d "${HOME}/logs" ]; then
    mkdir ${HOME}/logs
fi

if [ "${USE_HEAP_DUMP}" = "Y" ]; then
    echo "Heap dump path is '${HOME}/logs/jvm_heap_dump.hprof'."
    JAVA_OPTS="${JAVA_OPTS} -XX:HeapDumpPath=${HOME}/logs/jvm_heap_dump.hprof -XX:+HeapDumpOnOutOfMemoryError"
fi

if [ "${USE_LARGE_PAGES}" = "Y" ]; then
    echo "Use large pages."
    JAVA_OPTS="${JAVA_OPTS} -XX:+UseLargePages"
fi

if [ "${JDWP_DEBUG:-N}" = "Y" ]; then
    echo "Attach to remote JVM using port ${JDWP_PORT:-5005}."
    JAVA_OPTS="${JAVA_OPTS} -Xdebug -Xrunjdwp:transport=dt_socket,address=${JDWP_PORT:-5005},server=y,suspend=n"
fi

JAVA_OPTS="${JAVA_OPTS} -Dserver.port=${SERVER_PORT} -Dmanagement.server.port=${MANAGEMENT_SERVER_PORT}"

exec java $JAVA_OPTS -noverify -Djava.security.egd=file:/dev/./urandom "org.springframework.boot.loader.JarLauncher" "$@"

脚本目前兼容 JDK8、JDK11、JDK17,主要提供了以下参数:

  1. GC_MODE:垃圾回收器,适配 ShenandoahGCZGCG1CMS
  2. USE_GC_LOG:是否启用 GC 日志,默认输出路径为 ${HOME}/logs/jvm_gc*.log
  3. USE_HEAP_DUMP:是否启用堆转储,默认输出路径为 ${HOME}/logs/jvm_heap_dump.hprof
  4. USE_LARGE_PAGES:是否启用大页。
  5. JDWP_DEBUG:是否启用 JDWP 调试,默认端口为 5005
  6. SERVER_PORT:服务端口。
  7. MANAGEMENT_SERVER_PORT:管理端口,访问路径为 /actuator

产出

为团队提供 JVM 标准模板,通过 JVM 细节做了封装,研发团队只需要微调 JVM 变量就可以启用堆转储和 GC 日志、DEBUG 模式等配置。

服务瘦身效果比较明显,给研发团队做了测试,构建体积从原来的 389 MB 缩小到 295 MB,基础镜像占用了 240 MB,也就是说 jar 从 149 MB 降到 45 MB,如下图。

本文涉及的代码完全开源,感兴趣的伙伴可以查阅 eden-demo-cola 项目。

相关推荐
我不要放纵3 小时前
docker
运维·docker·容器
smileNicky4 小时前
SpringBoot系列之从繁琐配置到一键启动之旅
java·spring boot·后端
柏油7 小时前
Spring @TransactionalEventListener 解读
spring boot·后端·spring
小小工匠8 小时前
Maven - Spring Boot 项目打包本地 jar 的 3 种方法
spring boot·maven·jar·system scope
奥格列的魔法拖鞋~9 小时前
Docker-LNMP架构 创建多项目- 单个ngixn代理多个PHP容器服务
nginx·docker·eureka·架构·php·lnmp
板板正10 小时前
Spring Boot 整合MongoDB
spring boot·后端·mongodb
泉城老铁11 小时前
在高并发场景下,如何优化线程池参数配置
spring boot·后端·架构
泉城老铁11 小时前
Spring Boot中实现多线程6种方式,提高架构性能
spring boot·后端·spring cloud
hrrrrb12 小时前
【Java Web 快速入门】九、事务管理
java·spring boot·后端
为什么要内卷,摆烂不香吗13 小时前
Docker容器技术全面解析(一):入门
docker·微服务·容器