跳到主要内容 构建 Java 镜像的 10 个最佳实践 | 极客日志
Java java
构建 Java 镜像的 10 个最佳实践 构建高质量 Java 容器镜像的 10 个最佳实践。内容包括选择合适的基础镜像(如 Temurin slim)、使用多阶段构建分离编译与运行环境、优化 Docker 层缓存以加速构建、以非 root 用户运行提升安全性、正确处理进程信号实现优雅停机、调整 JVM 参数适配容器资源限制、配置健康检查保障服务可用性、最小化镜像层数与清理冗余文件、利用.dockerignore 排除无关内容,以及实施镜像安全扫描与基础镜像更新。这些实践有助于提升 Java 应用在容器环境中的性能、安全性和可维护性。
MqEngine 发布于 2026/3/27 更新于 2026/4/16 2 浏览引言
随着容器化技术的普及,Java 应用也越来越多地运行在 Docker 容器中。构建一个高效、安全、可维护的 Java 镜像并非简单的 docker build,它涉及到基础镜像选择、构建过程优化、运行时配置、安全加固等多个方面。本文将深入探讨构建 Java 镜像的 10 个最佳实践,每个实践都会详细解释其背后的原理、具体实施步骤、常见陷阱以及进阶技巧,帮助你在生产环境中构建出高质量的 Java 容器镜像。
实践 1:选择合适的基础镜像
1.1 基础镜像的重要性
基础镜像是 Docker 镜像的起点,它决定了镜像的底层操作系统、运行时库以及 Java 运行环境的提供方式。选择不当会导致镜像体积臃肿、安全漏洞增多、构建速度缓慢甚至运行时兼容性问题。因此,选择一个合适的基础镜像是构建 Java 镜像的第一步,也是最关键的一步。
1.2 常见的基础镜像类型
1.2.1 官方 OpenJDK 镜像 Docker Hub 上曾经最流行的 Java 基础镜像是 openjdk 系列,例如 openjdk:8-jre-alpine、openjdk:11-jre-slim 等。这些镜像由 OpenJDK 项目提供,分为多个变种:
openjdk:<version> :基于完整的操作系统(通常是 Debian),包含 JDK 和完整的工具链,体积较大(~300MB+)。
openjdk:<version>-slim :基于 Debian 的 slim 变种,移除了许多不必要的包,体积稍小(~200MB)。
openjdk:<version>-alpine :基于 Alpine Linux,使用 musl libc 替代 glibc,体积非常小(~100MB)。
1.2.2 Eclipse Temurin (Adoptium) Eclipse Adoptium 是 OpenJDK 构建的官方发行版,前身是 AdoptOpenJDK。它的镜像通常标记为 eclipse-temurin:<version>,同样提供多种变体(如 jdk、jre、alpine)。Temurin 通过了 AQAvit 测试套件,质量有保障,是目前广泛推荐的 Java 基础镜像。
1.2.3 Red Hat Universal Base Image (UBI) Red Hat 提供的 UBI 镜像,适用于需要 Red Hat 认证或希望使用 RHEL 生态的用户。UBI 镜像可以免费使用,并包含 Red Hat 的漏洞修复支持。对应 Java 版本有 ubi8/openjdk-11 等。
1.2.4 Distroless 镜像 Google 开源的 Distroless 镜像只包含应用及其运行时依赖,不包含包管理器、shell 等操作系统组件。这极大地减少了攻击面,但同时也增加了调试难度。例如 gcr.io/distroless/java 或 gcr.io/distroless/java17-debian11。
1.2.5 基于特定厂商的镜像 Oracle 官方也提供了 Oracle JDK 镜像(需授权),Amazon Corretto、Azul Zulu 等也都有对应的 Docker 镜像。选择这些镜像通常是为了获得厂商的技术支持或特定的性能优化。
1.3 如何选择:权衡体积、安全性与兼容性 镜像类型 优点 缺点 适用场景 完整 Debian 兼容性好,包含调试工具 体积大,漏洞面广 开发环境,需要调试工具时 Slim 体积较小,移除不必要包 可能缺少某些库,导致应用依赖缺失 通用生产环境 Alpine 极小体积(<100MB),安全面小 基于 musl libc,可能出现 glibc 兼容性问题 对体积有极致要求的场景 Distroless 最小攻击面,极安全 无 shell,难以调试,构建复杂 高度安全敏感的生产环境 UBI 企业级支持,符合 Red Hat 生态 体积较大 企业级应用,需要认证
推荐 :对于大多数 Java 应用,建议使用 Eclipse Temurin 的 slim 变体 (如 eclipse-temurin:17-jre-jammy)或 基于 Debian 的 slim 镜像 。它们提供了良好的兼容性和较小的体积。如果应用对体积极其敏感且经过充分测试,可以考虑 Alpine。如果安全要求极高,可以尝试 Distroless,但需要配合完善的监控和日志系统。
1.4 示例:不同基础镜像的 Dockerfile 对比 假设我们有一个简单的 Spring Boot 应用(jar 包名为 app.jar)。
使用 Temurin 17 JRE (slim) :
FROM eclipse-temurin:17-jre-jammy COPY app.jar /app.jar ENTRYPOINT ["java", "-jar", "/app.jar"]
使用 Alpine + glibc 兼容层 (某些 Java 库依赖 glibc):
FROM eclipse-temurin:17-jre-alpine COPY app.jar /app.jar ENTRYPOINT ["java", "-jar", "/app.jar"]
FROM gcr.io/distroless/java17-debian11 COPY app.jar /app.jar ENTRYPOINT ["java", "-jar", "/app.jar"]
1.5 注意事项
标签不要使用 latest :latest 指向的版本会变化,可能导致构建不一致。务必使用具体的版本号,如 eclipse-temurin:17-jre-jammy。
JDK vs JRE :运行时只需要 JRE,但有些基础镜像只提供 JDK。JDK 包含了编译器、调试工具等,体积更大。尽量选择 JRE 版。
Alpine 的 musl 问题 :某些 Java 原生库(如 JNI)可能依赖 glibc,在 Alpine 上会失败。可以在 Alpine 上安装 gcompat 或使用 alpine-sdk,但会增加体积和复杂度。建议先在测试环境中验证。
Distroless 的调试 :由于没有 shell,无法 docker exec 进入容器调试。需要通过挂载调试工具或依赖日志、监控来排查问题。也可以使用 debug 版本的 distroless 镜像(如 :debug 标签),但生产环境应切换回非 debug 版本。
实践 2:使用多阶段构建
2.1 什么是多阶段构建 多阶段构建是 Docker 17.05 引入的功能,允许在一个 Dockerfile 中使用多个 FROM 指令。每个阶段可以基于不同的基础镜像,并且可以有选择地将文件从上一个阶段复制到下一个阶段。最终生成的镜像只包含最后一个阶段的内容,之前的阶段被丢弃。
2.2 为什么需要多阶段构建 Java 应用的构建通常需要 JDK、构建工具(如 Maven、Gradle)和所有源代码,这些都会占用大量空间。如果将这些全部留在最终镜像中,镜像体积会非常庞大,而且包含了不必要的构建工具,增加了安全风险。多阶段构建可以将'构建环境'和'运行环境'分离,最终只保留运行所需的 JRE 和编译好的 jar 包。
2.3 典型的多阶段构建 Dockerfile(Maven 项目) 下面是一个使用 Maven 构建 Spring Boot 应用的多阶段构建示例:
# 第一阶段:构建阶段 FROM maven:3.8.6-eclipse-temurin-17 AS builder WORKDIR /app # 复制 pom.xml 和源码 COPY pom.xml . COPY src ./src # 下载依赖并打包(跳过测试以加快速度) RUN mvn clean package -DskipTests # 第二阶段:运行阶段 FROM eclipse-temurin:17-jre-jammy WORKDIR /app # 从构建阶段复制生成的 jar 包 COPY --from=builder /app/target/*.jar app.jar # 设置用户(后续实践会详细说明) RUN useradd -r -u 1001 -g root appuser && chown -R appuser /app USER appuser ENTRYPOINT ["java", "-jar", "app.jar"]
第一阶段 :使用包含 JDK 和 Maven 的 maven 镜像,将源代码复制进去,执行 mvn package。所有构建工具和中间产物都保存在这一阶段。
第二阶段 :使用仅包含 JRE 的 eclipse-temurin 镜像,从第一阶段复制出构建好的 jar 包,然后设置非 root 用户并运行应用。
最终镜像仅包含 JRE 和 app.jar,大小通常在 200MB 以内(取决于基础镜像和应用本身)。
2.4 对于 Gradle 项目的多阶段构建 # 第一阶段 FROM gradle:7.6-jdk17 AS builder WORKDIR /app COPY build.gradle settings.gradle ./ COPY src ./src RUN gradle bootJar --no-daemon # 第二阶段 FROM eclipse-temurin:17-jre-jammy COPY --from=builder /app/build/libs/*.jar app.jar # ... 后续相同
2.5 进阶:利用构建缓存加速 Maven 或 Gradle 每次构建都会下载大量依赖。在多阶段构建中,如果每次都重新下载,会非常耗时。可以利用 Docker 的层缓存机制,将依赖下载与源码编译分离。
FROM maven:3.8.6-eclipse-temurin-17 AS builder WORKDIR /app # 先复制 pom.xml,然后下载依赖(这一步会缓存依赖层,除非 pom.xml 变化) COPY pom.xml . RUN mvn dependency:go-offline # 再复制源码并打包 COPY src ./src RUN mvn clean package -DskipTests
mvn dependency:go-offline 会提前下载所有依赖,这样当 pom.xml 未变化时,Docker 会直接使用缓存层,跳过下载步骤,大大加快构建速度。
2.6 常见问题与注意事项
构建上下文大小 :多阶段构建中,第一阶段的 COPY 指令会包含所有源代码。务必使用 .dockerignore 排除不必要的文件(如本地构建产物、IDE 配置),否则会导致构建上下文过大,传输缓慢。
依赖缓存目录 :Maven 的本地仓库默认在 /root/.m2,如果希望在多个构建之间共享缓存,可以考虑使用 Docker 的 --mount=type=cache(需要 BuildKit 支持)或外部卷。但简单场景下,dependency:go-offline 已足够。
多模块项目 :如果项目包含多个模块,需要仔细处理模块间的依赖。可以将所有模块的 pom.xml 复制到工作目录,然后先下载所有模块的依赖,再逐步编译。更复杂的情况可能需要使用构建工具特定的插件来优化。
实践 3:分层构建以优化缓存
3.1 Docker 镜像的层与缓存机制 Docker 镜像由一系列只读层组成,每一层对应 Dockerfile 中的一条指令(如 RUN、COPY、ADD)。当构建镜像时,Docker 会检查缓存:如果指令对应的层未发生变化(比如 COPY 的文件内容未变,或 RUN 命令字符串未变),则会复用已有的缓存层,跳过执行。这极大地加快了重复构建的速度。
3.2 Java 应用的分层策略 对于 Java 应用,最频繁变动的部分是业务代码,而依赖项(如 Spring Boot 的 jar 包)通常相对稳定。因此,应该将依赖项和应用代码分开复制到镜像中,使得依赖层可以长期被缓存,而只有代码变化时才重新构建应用层。
3.3 常规做法:先复制构建描述文件,后复制源码 以 Maven 项目为例,一个利用缓存的分层 Dockerfile 如下:
FROM eclipse-temurin:17-jre-jammy AS builder WORKDIR /app # 第一步:复制 pom.xml 和可能用到的父 pom、settings.xml 等 COPY pom.xml . # 如果有父模块,也需要复制 COPY parent/pom.xml ./parent/ # 下载所有依赖(不执行编译) RUN mvn dependency:go-offline # 第二步:复制源码(这一步会频繁变化) COPY src ./src # 第三步:打包 RUN mvn clean package -DskipTests # 第四步:提取构建好的 jar RUN cp target/*.jar app.jar
但注意,这个例子仍然将源码复制和打包放在了不同的层。更好的做法是利用 Spring Boot 2.3+ 引入的分层 jar 特性(或手动解压依赖和类文件)。
3.4 利用 Spring Boot 的分层 Jar 特性 Spring Boot 2.3.0 开始支持使用 layers 将 fat jar 拆分为多个层(依赖、Spring 框架、应用类等)。结合 Docker 的多阶段构建,可以更精细地利用缓存。
<plugin > <groupId > org.springframework.boot</groupId > <artifactId > spring-boot-maven-plugin</artifactId > <configuration > <layers > <enabled > true</enabled > </layers > </configuration > </plugin >
# 构建阶段 FROM eclipse-temurin:17-jdk-jammy AS builder WORKDIR /app COPY . . RUN ./mvnw clean package # 提取分层 jar 的各个层到指定目录 RUN java -Djarmode=layertools -jar target/*.jar extract --destination extracted # 运行阶段 FROM eclipse-temurin:17-jre-jammy WORKDIR /app # 依次复制各个层,顺序为:依赖 -> Spring Boot 依赖 -> 应用类 -> 资源文件 COPY --from=builder /app/extracted/dependencies/ ./ COPY --from=builder /app/extracted/spring-boot-loader/ ./ COPY --from=builder /app/extracted/snapshot-dependencies/ ./ COPY --from=builder /app/extracted/application/ ./ ENTRYPOINT ["java", "org.springframework.boot.loader.JarLauncher"]
这样,当只有应用代码变化时,只有 application 层会重新构建,而巨大的依赖层(dependencies)保持不变,极大提升了构建速度。
3.5 手动实现依赖与代码分离 如果没有使用 Spring Boot 的 layers 工具,也可以手动实现类似效果。例如,使用 Maven 将依赖复制到一个目录,然后将应用 jar 复制到另一个目录。
示例 (使用 Maven 的 dependency 插件):
FROM maven:3.8.6-eclipse-temurin-17 AS builder WORKDIR /app COPY pom.xml . RUN mvn dependency:copy-dependencies -DoutputDirectory=target/dependency COPY src ./src RUN mvn clean compile assembly:single # 或其他打包方式 FROM eclipse-temurin:17-jre-jammy WORKDIR /app COPY --from=builder /app/target/dependency ./lib COPY --from=builder /app/target/*.jar ./app.jar ENTRYPOINT ["java", "-cp", "app.jar:lib/*", "com.example.Main"]
这种方式将依赖复制到 lib 目录,应用 jar 放在根目录,运行时通过 -cp 指定类路径。当依赖未变化时,第一阶段的 dependency:copy-dependencies 会命中缓存。
3.6 注意事项
指令顺序很重要 :将变化频率低的指令放在前面,变化频繁的放在后面。例如,先 COPY pom.xml,后 COPY src。
避免不必要的缓存失效 :不要在 RUN 指令中执行会频繁变化的操作,如 RUN apt-get update 最好与 apt-get install 合并,并用 --no-install-recommends 避免安装不必要的包,但更重要的是确保即使代码变化,这一层也不会重新运行(除非基础镜像更新)。
使用 BuildKit :启用 BuildKit(设置 DOCKER_BUILDKIT=1)可以获得更高级的缓存特性,如 --mount=type=cache 用于缓存 Maven 仓库,避免反复下载。
实践 4:以非 root 用户运行应用
4.1 为什么需要非 root 用户 Docker 容器默认以 root 用户运行。如果容器内的进程以 root 权限运行,一旦容器被攻击(例如通过应用漏洞),攻击者将获得容器内的 root 权限,可能进一步逃逸到宿主机或造成更大破坏。遵循最小权限原则,应该创建一个普通用户来运行应用,只赋予必要的权限。
4.2 如何在 Dockerfile 中创建用户 在 Linux 基础镜像中,可以使用 useradd 或 adduser 命令创建用户。推荐使用 useradd 并指定 UID、GID,便于跨环境一致。
# 创建用户组和用户(-r 表示系统用户,-u 指定 UID,-g 指定主组) RUN groupadd -r appgroup && useradd -r -g appgroup -u 1001 appuser # 或者更简单的写法(假设 root 组存在) RUN useradd -r -u 1001 -g root appuser
4.3 处理文件和端口权限 切换用户后,应用进程只能访问该用户有权限的文件和目录。因此,需要确保应用需要读写的目录(如工作目录、配置文件、日志目录)归该用户所有或具有适当的权限。
FROM eclipse-temurin:17-jre-jammy WORKDIR /app # 复制 jar 包 COPY --from=builder /app/target/*.jar app.jar # 创建用户并设置权限 RUN useradd -r -u 1001 -g root appuser && \ chown -R appuser:root /app # 如果应用需要写入某个目录,确保权限 RUN mkdir -p /data/logs && chown -R appuser:root /data/logs # 暴露端口(非 root 用户可以绑定 1024 以上的端口) EXPOSE 8080 USER appuser ENTRYPOINT ["java", "-jar", "app.jar"]
注意 :如果应用需要监听 80 或 443 端口,普通用户无法绑定。可以在容器内使用 authbind 或直接使用更高的端口,并在宿主机做端口映射(如 -p 80:8080)。
4.4 在 Kubernetes 中增强安全性 除了在 Dockerfile 中设置用户,Kubernetes 还提供了 securityContext 来进一步限制容器的权限:
apiVersion: v1 kind: Pod spec: containers: - name: myapp image: myapp:latest securityContext: runAsUser: 1001 runAsGroup: 1001 allowPrivilegeEscalation: false readOnlyRootFilesystem: true capabilities: drop: ["ALL" ]
这些设置可以确保即使镜像以 root 用户运行,Kubernetes 也会强制以指定用户启动,并阻止提权、使用只读根文件系统等。
4.5 常见陷阱与解决方案
jar 文件权限 :从构建阶段复制的 jar 文件可能被复制为 root 所有,导致普通用户无法读取。可以在复制后使用 chown 修改所有权。
挂载卷的权限 :如果挂载了外部卷(如日志目录),卷的权限可能由宿主机决定。可以确保容器内用户 UID 与宿主机目录所有者一致,或使用 initContainer 调整权限。
使用基础镜像预设的用户 :有些基础镜像(如 eclipse-temurin)默认已经创建了 appuser 用户(UID 1000),可以直接使用,但需要确认是否存在。
FROM eclipse-temurin:17-jre-jammy # 该镜像已存在用户组和用户:root 组和 appuser(UID 1000) USER appuser COPY --chown=appuser:root app.jar app.jar ENTRYPOINT ["java", "-jar", "app.jar"]
4.6 为什么不在构建阶段就切换用户? 构建阶段(多阶段构建的第一阶段)通常需要 root 权限来安装依赖、编译等。在构建阶段切换到普通用户可能导致权限不足。因此,只在最终运行阶段切换用户。
实践 5:正确处理进程信号与优雅停机
5.1 容器生命周期与信号 当 Docker 容器停止时(例如执行 docker stop 或 Kubernetes 终止 Pod),Docker 会向容器内的主进程(PID 1)发送 SIGTERM 信号,等待一段时间(默认 10 秒)后,如果进程仍未退出,则发送 SIGKILL 强制终止。Java 应用需要正确处理 SIGTERM,以便在退出前完成清理工作(如关闭数据库连接、释放资源、完成正在处理的请求)。
5.2 exec 格式 vs shell 格式 Dockerfile 中的 ENTRYPOINT 和 CMD 有两种格式:
exec 格式 :ENTRYPOINT ["java", "-jar", "app.jar"],直接执行程序,PID 1 是 Java 进程。
shell 格式 :ENTRYPOINT java -jar app.jar,实际执行的是 /bin/sh -c "java -jar app.jar",PID 1 是 shell 进程,Java 进程作为子进程。
使用 shell 格式时,shell 进程会接收信号,但不会传递给子进程(除非编写信号处理脚本)。因此,Java 进程无法收到 SIGTERM,也就无法优雅停机。务必使用 exec 格式 。
5.3 Java 如何响应 SIGTERM Java 默认对 SIGTERM 的处理是立即退出,但可以通过添加关闭钩子(Shutdown Hook)来执行清理操作。Spring Boot 应用默认注册了关闭钩子,会调用 ApplicationContext 的关闭逻辑(如销毁 beans、关闭连接池等)。对于非 Spring 应用,可以手动注册:
Runtime.getRuntime().addShutdownHook(new Thread (() -> { System.out.println("Shutting down gracefully..." );
5.4 配置优雅停机超时 Spring Boot 2.3 引入了优雅停机的内置支持,可以在 application.properties 中配置:
server.shutdown=graceful spring.lifecycle.timeout-per-shutdown-phase=30s
这样,当收到 SIGTERM 后,Spring Boot 会停止接收新请求,等待现有请求完成,最多等待 30 秒,然后退出。
5.5 Docker 中的停止信号 Docker 默认发送 SIGTERM,但也可以通过 STOPSIGNAL 指令自定义。通常无需修改。
5.6 完整示例 FROM eclipse-temurin:17-jre-jammy RUN useradd -r -u 1001 -g root appuser WORKDIR /app COPY --chown=appuser:root app.jar app.jar USER appuser # 使用 exec 格式 ENTRYPOINT ["java", "-jar", "app.jar"]
server.shutdown=graceful spring.lifecycle.timeout-per-shutdown-phase=30s
当执行 docker stop 时,Docker 发送 SIGTERM,Java 进程接收后触发 Spring Boot 的优雅停机,在 30 秒内完成当前请求后退出。
5.7 处理信号的高级话题
信号与 JVM 的交互 :JVM 本身会处理一些信号,如 SIGQUIT(打印线程堆栈)、SIGTERM(触发关闭钩子)。可以通过 -Xrs 选项禁用这些处理,但不推荐。
非 Spring Boot 应用的优雅停机 :可以自定义关闭钩子,但需要注意关闭钩子的执行时间。使用 Thread.join 等确保主线程等待清理完成。
Kubernetes 中的 preStop Hook :除了信号处理,Kubernetes 还提供了 preStop 生命周期钩子,可以在容器终止前执行自定义命令(如通知负载均衡器下线)。可以和信号机制结合使用。
lifecycle: preStop: exec: command: ["/bin/sh" , "-c" , "sleep 5 && kill -SIGTERM 1" ]
实践 6:优化 JVM 参数以适应容器环境
6.1 容器环境下的 JVM 默认行为 在 Java 10 之前,JVM 无法识别 cgroup 资源限制,默认会根据宿主机的 CPU 和内存来设置堆大小、GC 线程等,这可能导致在容器中内存分配过大而被 OOM Kill。Java 10 引入了 容器感知 功能(-XX:+UseContainerSupport),该选项在 Java 11+ 中默认启用。它允许 JVM 读取 cgroup 的限制并相应调整自身行为。
6.2 设置内存限制 即使启用了 UseContainerSupport,JVM 默认的最大堆大小可能是宿主机内存的 1/4,或者容器内存的一定比例。为了更精细地控制,建议显式设置堆内存比例或绝对值。
ENV JAVA_OPTS="-XX:MaxRAMPercentage=70.0 -XX:InitialRAMPercentage=70.0 -XX:MinRAMPercentage=50.0" ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar app.jar"]
-XX:MaxRAMPercentage 指定 JVM 堆最大占用容器内存的百分比(例如 70%)。这样,当容器内存限制为 1GB 时,堆最大为 700MB,留出 300MB 给 JVM 元空间、线程栈、堆外内存等。
6.3 CPU 限制 JVM 也会根据可用的 CPU 核心数设置 GC 线程数等。可以使用 -XX:ActiveProcessorCount 强制指定 CPU 数量,但通常 JVM 能自动检测到容器 CPU 限制。如果希望限制 JVM 使用的 CPU 时间,也可以通过 Linux 的 cpu 配额控制,JVM 会自动感知。
6.4 选择合适的 GC 策略
G1GC :Java 9+ 默认的垃圾收集器,适合大内存、低停顿的场景,推荐用于大多数服务器应用。
Parallel GC :吞吐量优先,适合后台批处理应用,但停顿时间可能较长。
Serial GC :单线程,适合小内存、客户端应用或容器内内存极小时(<100MB)。
ZGC/Shenandoah :极低停顿,适合大内存、低延迟要求的应用,但需要较新 JDK 版本。
可以在 JAVA_OPTS 中指定,例如 -XX:+UseG1GC。
6.5 完整 JVM 参数示例 FROM eclipse-temurin:17-jre-jammy # 设置默认 JVM 参数 ENV JAVA_OPTS="-XX:MaxRAMPercentage=70.0 -XX:+UseG1GC -XX:+UseContainerSupport -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/tmp/heapdump.hprof" COPY app.jar app.jar ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar app.jar"]
-XX:+HeapDumpOnOutOfMemoryError 在 OOM 时生成堆转储,便于事后分析。
-XX:HeapDumpPath 指定堆转储路径,建议挂载外部卷保存。
6.6 通过环境变量动态传递 JVM 参数 在 Kubernetes 等环境中,可以通过环境变量覆盖默认参数。例如:
env: - name: JAVA_OPTS value: "-XX:MaxRAMPercentage=80.0 -XshowSettings:system"
在 Dockerfile 中使用 $JAVA_OPTS 即可实现灵活配置。
6.7 常见误区
在 Dockerfile 中硬编码堆大小 :如 -Xmx512m 会忽略容器内存限制的变化,导致资源浪费或 OOM。推荐使用百分比。
忽略元空间 :元空间(Metaspace)默认不受容器内存限制,可能占用过多内存。可以通过 -XX:MaxMetaspaceSize 限制,但通常不需要。
不了解 JVM 默认的 GC 线程数 :如果容器 CPU 限制很少(如 0.5 核),JVM 可能仍会启动多个 GC 线程,导致竞争。可以手动设置 -XX:ParallelGCThreads 等。
盲目复制参数 :不同 JDK 版本对参数的实现有差异,应查阅对应版本的文档。
6.8 使用 JDK 11+ 的容器感知特性 验证 JVM 是否正确识别了容器限制,可以启动容器并查看日志或执行 jcmd。在容器内运行 java -XshowSettings:system -version 会打印系统设置,包括检测到的内存和 CPU。
实践 7:实现健康检查
7.1 为什么要健康检查 健康检查让 Docker 或 Kubernetes 能够了解应用的运行状态。如果应用卡死、内存泄漏或无法响应请求,健康检查可以自动重启容器,提高服务的可用性。
7.2 Dockerfile 中的 HEALTHCHECK 指令 Docker 提供了 HEALTHCHECK 指令,格式如下:
HEALTHCHECK [选项] CMD <命令>
--interval=<间隔>:检查间隔,默认 30s。
--timeout=<超时>:命令超时时间,默认 30s。
--start-period=<启动期>:容器启动后多久开始检查,默认 0s。
--retries=<重试次数>:连续失败多少次认为不健康,默认 3。
命令执行后,返回 0 表示健康,返回 1 表示不健康。
7.3 Java 应用的健康检查端点 对于 Spring Boot 应用,可以添加 Actuator 依赖,暴露 /actuator/health 端点。该端点返回 JSON 格式的健康状态。其他框架也有类似健康检查机制。
7.4 如何实现健康检查命令 由于基础镜像可能不包含 curl 或 wget,需要确保 HEALTHCHECK 命令可用。常见方案:
使用 curl :安装 curl(增加镜像体积)。
使用 wget :安装 wget。
使用 Java 自带的工具 :如 jps 等,但无法检查应用逻辑。
使用 HTTP 客户端脚本 :如果镜像有 Python 或 bash,可以编写简单脚本。
推荐安装 curl 或 wget,因为镜像通常已包含(slim 镜像可能没有,需要额外安装)。为了保持镜像精简,可以使用 wget -q --spider http://localhost:8080/actuator/health,但需要确认镜像是否有 wget。
示例 Dockerfile (假设使用 eclipse-temurin,它基于 Ubuntu/Debian,默认无 curl):
FROM eclipse-temurin:17-jre-jammy # 安装 curl RUN apt-get update && apt-get install -y --no-install-recommends curl && \ apt-get clean && rm -rf /var/lib/apt/lists/* COPY app.jar app.jar HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \ CMD curl -f http://localhost:8080/actuator/health || exit 1 ENTRYPOINT ["java", "-jar", "app.jar"]
7.5 不使用额外工具的替代方案 如果不想安装 curl,可以依赖 Docker 的 TCP 检查?但 HEALTHCHECK 不支持直接 TCP 检查(需要命令)。可以使用 nc 或 bash 脚本,但同样需要额外工具。
更好的方法是使用 Kubernetes 的存活探针和就绪探针 ,它们支持 HTTP 请求,无需在镜像内安装工具。但在 Dockerfile 中定义 HEALTHCHECK 对于独立容器仍有价值。
7.6 Kubernetes 探针示例 Kubernetes 支持 livenessProbe 和 readinessProbe,可以直接发送 HTTP 请求到指定端口和路径,无需 curl。
livenessProbe: httpGet: path: /actuator/health/liveness port: 8080 initialDelaySeconds: 30 periodSeconds: 10 readinessProbe: httpGet: path: /actuator/health/readiness port: 8080 initialDelaySeconds: 20 periodSeconds: 5
Spring Boot Actuator 默认将 /actuator/health 作为整体健康检查,可以通过配置启用单独的 liveness 和 readiness 端点。
7.7 健康检查的最佳实践
区分存活和就绪 :存活探针用于判断是否需要重启,就绪探针用于判断是否接收流量。Spring Boot 2.3+ 提供了 LivenessStateHealthIndicator 和 ReadinessStateHealthIndicator,可以分别使用。
设置合理的启动延迟 :应用启动可能需要时间(如初始化数据库连接),start-period 或 initialDelaySeconds 应足够长,避免误判。
避免依赖外部服务 :健康检查应主要检查应用自身状态,不应调用下游服务(如数据库),否则下游故障会导致上游容器重启,造成级联故障。可以使用独立的 readiness 探针检查下游,但不应作为存活探针。
超时设置要短 :健康检查命令不能长时间阻塞,应设置合理的超时。
实践 8:最小化镜像层数和清理不必要的文件
8.1 每一条指令增加一层 Docker 镜像由层组成,每个 RUN、COPY、ADD 指令都会创建一个新的层。虽然层有助于缓存复用,但过多的层会增加镜像的拉取和存储时间(因为需要下载所有层)。因此,需要在缓存和层数之间平衡。
8.2 合并 RUN 指令 尽量将多个 shell 命令合并到一个 RUN 中,用 && 连接,并在同一层清理临时文件。这样可以减少层数,同时避免中间产物残留。
RUN apt-get update RUN apt-get install -y curl RUN apt-get clean RUN rm -rf /var/lib/apt/lists/*
RUN apt-get update && \ apt-get install -y --no-install-recommends curl && \ apt-get clean && \ rm -rf /var/lib/apt/lists/*
8.3 清理包管理器缓存 如上面示例,在安装完包后立即清理缓存。不同的基础镜像有不同的包管理器:
Debian/Ubuntu:apt-get clean 和 rm -rf /var/lib/apt/lists/*
Alpine:apk add --no-cache 或在 RUN 后 rm -rf /var/cache/apk/*
CentOS/RHEL:yum clean all
8.4 减少不必要的文件复制
使用 .dockerignore 排除不需要的文件(将在实践 9 详细说明)。
如果复制的是压缩包,可以在 ADD 后自动解压,但最好在 RUN 中手动处理并删除压缩包。
对于多阶段构建,确保最终阶段只复制必要的文件,不要复制中间产物。
8.5 使用 --link 优化层合并(BuildKit) 在 Docker BuildKit 中,可以使用 --link 选项在 COPY 或 ADD 时创建独立的层,便于缓存,但不会增加最终镜像大小。例如:
COPY --link app.jar app.jar
--link 将文件复制到一个新层,并且该层不会与之前层的内容合并,但最终镜像仍然只有一层?实际上,--link 主要用于改善缓存和并发构建,对于层数减少没有直接帮助,但可以避免复制时创建额外的层。
8.6 Java 特有的清理
删除临时文件 :Java 应用运行时可能产生临时文件(如 /tmp),但容器重启后会消失,无需特殊处理。
清理构建工具缓存 :在多阶段构建中,构建阶段的镜像最终会被丢弃,无需在最终镜像中清理。
Spring Boot 的临时文件 :Spring Boot 默认使用 /tmp 作为工作目录,如果需要保留日志,应挂载卷。
8.7 最终镜像大小的衡量 可以使用 docker history 查看镜像各层大小,或使用 dive 工具分析镜像内容,找出不必要的文件。
docker history myapp:latest
实践 9:使用 .dockerignore 排除不必要的文件
9.1 构建上下文的概念 当执行 docker build 时,Docker 客户端会将指定路径(通常是当前目录)的所有文件打包发送给 Docker 守护进程,这个集合称为构建上下文。如果上下文中包含大量无关文件(如 .git、node_modules、target、IDE 配置等),会导致构建缓慢,甚至可能将敏感信息(如密码文件)意外包含进去。
9.2 .dockerignore 的作用 类似 .gitignore,.dockerignore 文件用于指定在构建上下文中忽略的文件和目录。被忽略的文件不会被发送到守护进程,从而加快构建速度并减少安全风险。
9.3 典型的 .dockerignore 内容 # Git .git .gitignore # 构建产物 target/ build/ *.jar *.war *.ear # IDE 配置 .idea/ *.iml .classpath .project .settings/ # 日志文件 *.log # 临时文件 *.tmp *.swp *~ # Docker 相关 Dockerfile .dockerignore # 其他 README.md LICENSE *.md
9.4 如何测试 .dockerignore 效果 可以使用 docker build 的 --no-cache 选项并观察输出,或者使用 docker build -f Dockerfile --no-cache --progress=plain . 查看发送的上下文大小。
tar -czf /dev/stdout --exclude-from=.dockerignore . | wc -c
9.5 特殊注意事项
通配符规则 :.dockerignore 支持类似 .gitignore 的通配符模式。例如 * 匹配任意文件,** 匹配任意层级目录。
否定模式 :可以使用 ! 来排除例外,例如 !target/*.jar 可以保留构建好的 jar 包,但通常不建议这样做,因为目标 jar 应该是构建阶段生成的,而非本地预先存在的。
Dockerfile 本身 :通常会将 Dockerfile 本身忽略?实际上不需要,因为 Dockerfile 默认不会作为构建上下文的一部分被复制到镜像中(除非显式 COPY)。但放在 .dockerignore 中可以避免意外复制。
多阶段构建中的上下文 :即使使用了多阶段构建,第一阶段仍然需要复制源代码,所以忽略不必要的文件仍然很重要。
实践 10:镜像安全扫描和基础镜像更新
10.1 容器镜像的安全风险 容器镜像可能包含已知漏洞的软件包、恶意软件、错误配置等。基础镜像的漏洞会直接传递给应用镜像。因此,必须定期扫描镜像并修复漏洞。
10.2 漏洞扫描工具 有许多开源和商业工具可用于扫描 Docker 镜像:
Trivy :由 Aqua Security 开发,开源、易用,支持多种操作系统和语言包。
Clair :CoreOS 开发的静态分析工具,常用于 CI/CD。
Snyk :商业工具,有免费层,深度集成 GitHub。
Docker Scout :Docker 官方提供的扫描工具。
Grype :Anchore 开源的工具,与 Syft 配合使用。
10.3 集成扫描到 CI/CD 在持续集成流水线中(如 Jenkins、GitLab CI、GitHub Actions),可以在构建镜像后立即进行扫描,如果发现高危漏洞,则中断构建或发送告警。
GitHub Actions 示例 (使用 Trivy):
- name: Build Docker image run: docker build -t myapp:${{ github.sha }} . - name: Scan image with Trivy uses: aquasecurity/trivy-action@master with: image-ref: myapp:${{ github.sha }} format: 'sarif' output: 'trivy-results.sarif' severity: 'CRITICAL,HIGH'
10.4 保持基础镜像更新
避免使用 latest 标签 :总是使用具体的版本标签,如 eclipse-temurin:17-jre-jammy-20231010 或至少 17-jre-jammy。但 jammy 指向的镜像也会随时间更新(安全修复)。可以使用工具如 Dependabot 或 Renovate 来定期检查基础镜像更新并自动提交 PR。
定期重建镜像 :即使代码未变,也应定期(如每周)基于最新的基础镜像重建应用镜像,以获取安全补丁。
使用镜像摘要(Digest) :最精确的锁定方式是使用镜像的 SHA256 摘要,例如 FROM eclipse-temurin@sha256:abc123...。但更新时需要手动修改,适合自动化流程。
10.5 使用最小化基础镜像减少攻击面 如实践 1 所述,使用 slim 或 distroless 镜像可以减少不必要的软件包,从而降低漏洞数量。Distroless 镜像甚至没有 shell,极大限制了攻击者一旦进入容器后的活动能力。
10.6 镜像签名与验证 使用 Docker Content Trust (DCT) 或 Notary 对镜像进行签名,确保拉取的镜像未被篡改。在 Kubernetes 中,可以配置 ImagePolicyWebhook 或使用 Sigstore 的 Cosign 进行验证。
10.7 运行时安全
以非 root 用户运行(实践 4)。
设置只读根文件系统(readOnlyRootFilesystem)。
限制容器能力(drop all capabilities)。
使用 seccomp 或 AppArmor 配置文件。
10.8 安全扫描的局限性 扫描工具只能检测已知漏洞,无法发现逻辑漏洞或配置错误。安全是一个持续的过程,需要结合代码审计、渗透测试等手段。
结语 构建高质量的 Java 镜像并非一蹴而就,它需要从基础镜像选择、构建过程优化、运行时配置、安全加固等多个维度综合考虑。
微信扫一扫,关注极客日志 微信公众号「极客日志」,在微信中扫描左侧二维码关注。展示文案:极客日志 zeeklog
相关免费在线工具 Keycode 信息 查找任何按下的键的javascript键代码、代码、位置和修饰符。 在线工具,Keycode 信息在线工具,online
Escape 与 Native 编解码 JavaScript 字符串转义/反转义;Java 风格 \uXXXX(Native2Ascii)编码与解码。 在线工具,Escape 与 Native 编解码在线工具,online
JavaScript / HTML 格式化 使用 Prettier 在浏览器内格式化 JavaScript 或 HTML 片段。 在线工具,JavaScript / HTML 格式化在线工具,online
JavaScript 压缩与混淆 Terser 压缩、变量名混淆,或 javascript-obfuscator 高强度混淆(体积会增大)。 在线工具,JavaScript 压缩与混淆在线工具,online
Base64 字符串编码/解码 将字符串编码和解码为其 Base64 格式表示形式即可。 在线工具,Base64 字符串编码/解码在线工具,online
Base64 文件转换器 将字符串、文件或图像转换为其 Base64 表示形式。 在线工具,Base64 文件转换器在线工具,online