引言随着容器化技术的普及Java 应用也越来越多地运行在 Docker 容器中。构建一个高效、安全、可维护的 Java 镜像并非简单的docker build它涉及到基础镜像选择、构建过程优化、运行时配置、安全加固等多个方面。本文将深入探讨构建 Java 镜像的 10 个最佳实践每个实践都会详细解释其背后的原理、具体实施步骤、常见陷阱以及进阶技巧帮助你在生产环境中构建出高质量的 Java 容器镜像。全文约 2 万字适合开发人员、运维人员以及架构师阅读。实践 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)dockerfileFROM eclipse-temurin:17-jre-jammy COPY app.jar /app.jar ENTRYPOINT [java, -jar, /app.jar]使用 Alpine glibc 兼容层某些 Java 库依赖 glibcdockerfileFROM eclipse-temurin:17-jre-alpine COPY app.jar /app.jar ENTRYPOINT [java, -jar, /app.jar]使用 DistrolessdockerfileFROM gcr.io/distroless/java17-debian11 COPY app.jar /app.jar ENTRYPOINT [java, -jar, /app.jar]1.5 注意事项标签不要使用latestlatest指向的版本会变化可能导致构建不一致。务必使用具体的版本号如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 典型的多阶段构建 DockerfileMaven 项目下面是一个使用 Maven 构建 Spring Boot 应用的多阶段构建示例dockerfile# 第一阶段构建阶段 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 --frombuilder /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 项目的多阶段构建类似地可以使用 Gradle 镜像dockerfile# 第一阶段 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 --frombuilder /app/build/libs/*.jar app.jar # ... 后续相同2.5 进阶利用构建缓存加速Maven 或 Gradle 每次构建都会下载大量依赖。在多阶段构建中如果每次都重新下载会非常耗时。可以利用 Docker 的层缓存机制将依赖下载与源码编译分离。Maven 优化示例dockerfileFROM 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 -DskipTestsmvn dependency:go-offline会提前下载所有依赖这样当 pom.xml 未变化时Docker 会直接使用缓存层跳过下载步骤大大加快构建速度。2.6 常见问题与注意事项构建上下文大小多阶段构建中第一阶段的 COPY 指令会包含所有源代码。务必使用.dockerignore排除不必要的文件如本地构建产物、IDE 配置否则会导致构建上下文过大传输缓慢。依赖缓存目录Maven 的本地仓库默认在/root/.m2如果希望在多个构建之间共享缓存可以考虑使用 Docker 的--mounttypecache需要 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 如下dockerfileFROM 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 的多阶段构建可以更精细地利用缓存。首先需要在pom.xml中配置插件xmlplugin groupIdorg.springframework.boot/groupId artifactIdspring-boot-maven-plugin/artifactId configuration layers enabledtrue/enabled /layers /configuration /plugin然后在 Dockerfile 中使用分层提取dockerfile# 构建阶段 FROM eclipse-temurin:17-jdk-jammy AS builder WORKDIR /app COPY . . RUN ./mvnw clean package # 提取分层 jar 的各个层到指定目录 RUN java -Djarmodelayertools -jar target/*.jar extract --destination extracted # 运行阶段 FROM eclipse-temurin:17-jre-jammy WORKDIR /app # 依次复制各个层顺序为依赖 - Spring Boot 依赖 - 应用类 - 资源文件 COPY --frombuilder /app/extracted/dependencies/ ./ COPY --frombuilder /app/extracted/spring-boot-loader/ ./ COPY --frombuilder /app/extracted/snapshot-dependencies/ ./ COPY --frombuilder /app/extracted/application/ ./ ENTRYPOINT [java, org.springframework.boot.loader.JarLauncher]这样当只有应用代码变化时只有application层会重新构建而巨大的依赖层dependencies保持不变极大提升了构建速度。3.5 手动实现依赖与代码分离如果没有使用 Spring Boot 的 layers 工具也可以手动实现类似效果。例如使用 Maven 将依赖复制到一个目录然后将应用 jar 复制到另一个目录。示例使用 Maven 的 dependency 插件dockerfileFROM maven:3.8.6-eclipse-temurin-17 AS builder WORKDIR /app COPY pom.xml . RUN mvn dependency:copy-dependencies -DoutputDirectorytarget/dependency COPY src ./src RUN mvn clean compile assembly:single # 或其他打包方式 FROM eclipse-temurin:17-jre-jammy WORKDIR /app COPY --frombuilder /app/target/dependency ./lib COPY --frombuilder /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_BUILDKIT1可以获得更高级的缓存特性如--mounttypecache用于缓存 Maven 仓库避免反复下载。实践 4以非 root 用户运行应用4.1 为什么需要非 root 用户Docker 容器默认以 root 用户运行。如果容器内的进程以 root 权限运行一旦容器被攻击例如通过应用漏洞攻击者将获得容器内的 root 权限可能进一步逃逸到宿主机或造成更大破坏。遵循最小权限原则应该创建一个普通用户来运行应用只赋予必要的权限。4.2 如何在 Dockerfile 中创建用户在 Linux 基础镜像中可以使用useradd或adduser命令创建用户。推荐使用useradd并指定 UID、GID便于跨环境一致。dockerfile# 创建用户组和用户-r 表示系统用户-u 指定 UID-g 指定主组 RUN groupadd -r appgroup useradd -r -g appgroup -u 1001 appuser # 或者更简单的写法假设 root 组存在 RUN useradd -r -u 1001 -g root appuser然后使用USER指令切换到该用户dockerfileUSER appuser4.3 处理文件和端口权限切换用户后应用进程只能访问该用户有权限的文件和目录。因此需要确保应用需要读写的目录如工作目录、配置文件、日志目录归该用户所有或具有适当的权限。dockerfileFROM eclipse-temurin:17-jre-jammy WORKDIR /app # 复制 jar 包 COPY --frombuilder /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来进一步限制容器的权限yamlapiVersion: 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可以直接使用但需要确认是否存在。示例直接使用基础镜像预设的用户dockerfileFROM eclipse-temurin:17-jre-jammy # 该镜像已存在用户组和用户root 组和 appuserUID 1000 USER appuser COPY --chownappuser:root app.jar app.jar ENTRYPOINT [java, -jar, app.jar]使用--chown确保复制时文件所有者正确。4.6 为什么不在构建阶段就切换用户构建阶段多阶段构建的第一阶段通常需要 root 权限来安装依赖、编译等。在构建阶段切换到普通用户可能导致权限不足。因此只在最终运行阶段切换用户。实践 5正确处理进程信号与优雅停机5.1 容器生命周期与信号当 Docker 容器停止时例如执行docker stop或 Kubernetes 终止 PodDocker 会向容器内的主进程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.jarPID 1 是 shell 进程Java 进程作为子进程。使用 shell 格式时shell 进程会接收信号但不会传递给子进程除非编写信号处理脚本。因此Java 进程无法收到SIGTERM也就无法优雅停机。务必使用 exec 格式。5.3 Java 如何响应 SIGTERMJava 默认对SIGTERM的处理是立即退出但可以通过添加关闭钩子Shutdown Hook来执行清理操作。Spring Boot 应用默认注册了关闭钩子会调用ApplicationContext的关闭逻辑如销毁 beans、关闭连接池等。对于非 Spring 应用可以手动注册javaRuntime.getRuntime().addShutdownHook(new Thread(() - { System.out.println(Shutting down gracefully...); // 执行清理 }));5.4 配置优雅停机超时Spring Boot 2.3 引入了优雅停机的内置支持可以在application.properties中配置propertiesserver.shutdowngraceful spring.lifecycle.timeout-per-shutdown-phase30s这样当收到SIGTERM后Spring Boot 会停止接收新请求等待现有请求完成最多等待 30 秒然后退出。5.5 Docker 中的停止信号Docker 默认发送SIGTERM但也可以通过STOPSIGNAL指令自定义。通常无需修改。5.6 完整示例DockerfiledockerfileFROM eclipse-temurin:17-jre-jammy RUN useradd -r -u 1001 -g root appuser WORKDIR /app COPY --chownappuser:root app.jar app.jar USER appuser # 使用 exec 格式 ENTRYPOINT [java, -jar, app.jar]application.propertiespropertiesserver.shutdowngraceful spring.lifecycle.timeout-per-shutdown-phase30s当执行docker stop时Docker 发送 SIGTERMJava 进程接收后触发 Spring Boot 的优雅停机在 30 秒内完成当前请求后退出。5.7 处理信号的高级话题信号与 JVM 的交互JVM 本身会处理一些信号如SIGQUIT打印线程堆栈、SIGTERM触发关闭钩子。可以通过-Xrs选项禁用这些处理但不推荐。非 Spring Boot 应用的优雅停机可以自定义关闭钩子但需要注意关闭钩子的执行时间。使用Thread.join等确保主线程等待清理完成。Kubernetes 中的 preStop Hook除了信号处理Kubernetes 还提供了preStop生命周期钩子可以在容器终止前执行自定义命令如通知负载均衡器下线。可以和信号机制结合使用。preStop 示例yamllifecycle: preStop: exec: command: [/bin/sh, -c, sleep 5 kill -SIGTERM 1]但通常直接依赖 SIGTERM 即可。实践 6优化 JVM 参数以适应容器环境6.1 容器环境下的 JVM 默认行为在 Java 10 之前JVM 无法识别 cgroup 资源限制默认会根据宿主机的 CPU 和内存来设置堆大小、GC 线程等这可能导致在容器中内存分配过大而被 OOM Kill。Java 10 引入了容器感知功能-XX:UseContainerSupport该选项在 Java 11 中默认启用。它允许 JVM 读取 cgroup 的限制并相应调整自身行为。6.2 设置内存限制即使启用了UseContainerSupportJVM 默认的最大堆大小可能是宿主机内存的 1/4或者容器内存的一定比例。为了更精细地控制建议显式设置堆内存比例或绝对值。常见做法是使用百分比dockerfileENV JAVA_OPTS-XX:MaxRAMPercentage70.0 -XX:InitialRAMPercentage70.0 -XX:MinRAMPercentage50.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 策略G1GCJava 9 默认的垃圾收集器适合大内存、低停顿的场景推荐用于大多数服务器应用。Parallel GC吞吐量优先适合后台批处理应用但停顿时间可能较长。Serial GC单线程适合小内存、客户端应用或容器内内存极小时100MB。ZGC/Shenandoah极低停顿适合大内存、低延迟要求的应用但需要较新 JDK 版本。可以在 JAVA_OPTS 中指定例如-XX:UseG1GC。6.5 完整 JVM 参数示例dockerfileFROM eclipse-temurin:17-jre-jammy # 设置默认 JVM 参数 ENV JAVA_OPTS-XX:MaxRAMPercentage70.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 等环境中可以通过环境变量覆盖默认参数。例如yamlenv: - name: JAVA_OPTS value: -XX:MaxRAMPercentage80.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指令格式如下dockerfileHEALTHCHECK [选项] 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默认无 curldockerfileFROM 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 --interval30s --timeout3s --start-period10s --retries3 \ 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。yamllivenessProbe: httpGet: path: /actuator/health/liveness port: 8080 initialDelaySeconds: 30 periodSeconds: 10 readinessProbe: httpGet: path: /actuator/health/readiness port: 8080 initialDelaySeconds: 20 periodSeconds: 5Spring 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中用连接并在同一层清理临时文件。这样可以减少层数同时避免中间产物残留。反例产生多层且残留缓存dockerfileRUN apt-get update RUN apt-get install -y curl RUN apt-get clean RUN rm -rf /var/lib/apt/lists/*正例dockerfileRUN apt-get update \ apt-get install -y --no-install-recommends curl \ apt-get clean \ rm -rf /var/lib/apt/lists/*8.3 清理包管理器缓存如上面示例在安装完包后立即清理缓存。不同的基础镜像有不同的包管理器Debian/Ubuntuapt-get clean和rm -rf /var/lib/apt/lists/*Alpineapk add --no-cache或在RUN后rm -rf /var/cache/apk/*CentOS/RHELyum clean all8.4 减少不必要的文件复制使用.dockerignore排除不需要的文件将在实践 9 详细说明。如果复制的是压缩包可以在ADD后自动解压但最好在RUN中手动处理并删除压缩包。对于多阶段构建确保最终阶段只复制必要的文件不要复制中间产物。8.5 使用--link优化层合并BuildKit在 Docker BuildKit 中可以使用--link选项在 COPY 或 ADD 时创建独立的层便于缓存但不会增加最终镜像大小。例如dockerfileCOPY --link app.jar app.jar--link将文件复制到一个新层并且该层不会与之前层的内容合并但最终镜像仍然只有一层实际上--link主要用于改善缓存和并发构建对于层数减少没有直接帮助但可以避免复制时创建额外的层。8.6 Java 特有的清理删除临时文件Java 应用运行时可能产生临时文件如/tmp但容器重启后会消失无需特殊处理。清理构建工具缓存在多阶段构建中构建阶段的镜像最终会被丢弃无需在最终镜像中清理。Spring Boot 的临时文件Spring Boot 默认使用/tmp作为工作目录如果需要保留日志应挂载卷。8.7 最终镜像大小的衡量可以使用docker history查看镜像各层大小或使用dive工具分析镜像内容找出不必要的文件。示例bashdocker history myapp:latest实践 9使用 .dockerignore 排除不必要的文件9.1 构建上下文的概念当执行docker build时Docker 客户端会将指定路径通常是当前目录的所有文件打包发送给 Docker 守护进程这个集合称为构建上下文。如果上下文中包含大量无关文件如.git、node_modules、target、IDE 配置等会导致构建缓慢甚至可能将敏感信息如密码文件意外包含进去。9.2 .dockerignore 的作用类似.gitignore.dockerignore文件用于指定在构建上下文中忽略的文件和目录。被忽略的文件不会被发送到守护进程从而加快构建速度并减少安全风险。9.3 典型的 .dockerignore 内容对于 Java 项目建议包含以下内容text# Git .git .gitignore # 构建产物 target/ build/ *.jar *.war *.ear # IDE 配置 .idea/ *.iml .classpath .project .settings/ # 日志文件 *.log # 临时文件 *.tmp *.swp *~ # Docker 相关 Dockerfile .dockerignore # 其他 README.md LICENSE *.md9.4 如何测试 .dockerignore 效果可以使用docker build的--no-cache选项并观察输出或者使用docker build -f Dockerfile --no-cache --progressplain .查看发送的上下文大小。也可以使用tar模拟bashtar -czf /dev/stdout --exclude-from.dockerignore . | wc -c9.5 特殊注意事项通配符规则.dockerignore支持类似.gitignore的通配符模式。例如*匹配任意文件**匹配任意层级目录。否定模式可以使用!来排除例外例如!target/*.jar可以保留构建好的 jar 包但通常不建议这样做因为目标 jar 应该是构建阶段生成的而非本地预先存在的。Dockerfile 本身通常会将Dockerfile本身忽略实际上不需要因为Dockerfile默认不会作为构建上下文的一部分被复制到镜像中除非显式 COPY。但放在.dockerignore中可以避免意外复制。多阶段构建中的上下文即使使用了多阶段构建第一阶段仍然需要复制源代码所以忽略不必要的文件仍然很重要。实践 10镜像安全扫描和基础镜像更新10.1 容器镜像的安全风险容器镜像可能包含已知漏洞的软件包、恶意软件、错误配置等。基础镜像的漏洞会直接传递给应用镜像。因此必须定期扫描镜像并修复漏洞。10.2 漏洞扫描工具有许多开源和商业工具可用于扫描 Docker 镜像Trivy由 Aqua Security 开发开源、易用支持多种操作系统和语言包。ClairCoreOS 开发的静态分析工具常用于 CI/CD。Snyk商业工具有免费层深度集成 GitHub。Docker ScoutDocker 官方提供的扫描工具。GrypeAnchore 开源的工具与 Syft 配合使用。10.3 集成扫描到 CI/CD在持续集成流水线中如 Jenkins、GitLab CI、GitHub Actions可以在构建镜像后立即进行扫描如果发现高危漏洞则中断构建或发送告警。GitHub Actions 示例使用 Trivyyaml- name: Build Docker image run: docker build -t myapp:${{ github.sha }} . - name: Scan image with Trivy uses: aquasecurity/trivy-actionmaster with: image-ref: myapp:${{ github.sha }} format: sarif output: trivy-results.sarif severity: CRITICAL,HIGH10.4 保持基础镜像更新避免使用latest标签总是使用具体的版本标签如eclipse-temurin:17-jre-jammy-20231010或至少17-jre-jammy。但jammy指向的镜像也会随时间更新安全修复。可以使用工具如 Dependabot 或 Renovate 来定期检查基础镜像更新并自动提交 PR。定期重建镜像即使代码未变也应定期如每周基于最新的基础镜像重建应用镜像以获取安全补丁。使用镜像摘要Digest最精确的锁定方式是使用镜像的 SHA256 摘要例如FROM eclipse-temurinsha256: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 镜像并非一蹴而就它需要从基础镜像选择、构建过程优化、运行时配置、安全加固等多个维度综合考虑。