Dockerfile 从构建原理到生产镜像
只会写 FROM、COPY、ENTRYPOINT,并不等于会构建生产镜像。真正需要理解的是:docker build 为什么要发送构建上下文,每条指令如何形成层,缓存为什么命中或失效,镜像配置怎样与 docker run 参数合并,为什么 Shell form 会影响信号,密钥为什么即使删除仍可能留在历史层,以及 Java 8 和现代 JDK 在容器资源识别上有什么区别。
学习目标
学完本页,应能独立完成:
- 从 Dockerfile、构建上下文到 OCI 镜像解释完整构建过程。
- 解释镜像层、配置对象、内容摘要、缓存命中和失效原理。
- 正确区分
RUN、CMD、ENTRYPOINT、Shell form 和 Exec form。 - 使用
.dockerignore、多阶段构建和 BuildKit 缓存优化 Java 镜像。 - 分别构建可运行的 JDK 8 与 JDK 17 Spring Boot 镜像。
- 正确处理非 root 用户、文件权限、密钥、日志、时区、证书和优雅停机。
- 从构建失败、启动失败、镜像过大、缓存失效、安全扫描失败等现象反推原因。
- 在面试中说明 Dockerfile 原理,而不只是背指令名称。
一、Dockerfile解决的不是“打包”,而是可重复交付
手工部署通常包含安装 JDK、创建目录、复制 Jar、设置参数、启动服务。问题是这些步骤只存在于人的记忆和服务器当前状态中:
| 手工操作问题 | 生产后果 |
|---|---|
| 每台机器安装不同 JDK | 同一 Jar 在不同机器表现不同 |
| 临时修改服务器文件 | Git 中没有记录,无法审计和复现 |
| 直接覆盖旧 Jar | 不知道线上文件来自哪个 commit |
| 密码写进启动脚本 | 容易进入历史、日志和备份 |
| 回滚依赖人工找文件 | 恢复时间不可控 |
Dockerfile 把运行环境描述成代码。它不是虚拟机安装脚本,而是从基础镜像出发,生成一组只读文件系统层和一份运行配置。
flowchart TD
A["应用源码与依赖描述"] --> B["Dockerfile"]
B --> C["docker build 或 BuildKit"]
C --> D["执行构建步骤"]
D --> E["生成只读文件系统层"]
D --> F["生成镜像运行配置"]
E --> G["不可变镜像"]
F --> G
G --> H["推送镜像仓库"]
H --> I["测试、预发、生产拉取同一摘要"]真正稳定的交付原则是:
同一个已验证镜像按内容摘要从测试环境晋级到生产环境,而不是在每个环境重新修改或重新打包。
二、docker build完整过程
假设目录如下:
order-api/
├── Dockerfile
├── .dockerignore
├── pom.xml
├── src/
└── target/order-api.jar执行:
docker build -t order-api:1.0 .最后的 . 不是装饰,它表示构建上下文目录。
2.1 什么是构建上下文
Dockerfile 中:
COPY target/order-api.jar /app/app.jar源路径不是任意宿主机绝对路径,而是相对于构建上下文。构建器只能读取上下文中且未被 .dockerignore 排除的内容。
以下写法不能越过上下文读取父目录:
COPY ../secret.txt /app/secret.txt这样设计有两个原因:
- 构建可能在远程 BuildKit、CI 节点或集群构建器执行,不能依赖本机任意路径。
- 明确输入边界,构建结果才可能复现,也能减少意外读取宿主机敏感文件。
2.2 构建器逐步做什么
flowchart TD
A["读取 Dockerfile"] --> B["解析语法与构建阶段"]
B --> C["读取并过滤构建上下文"]
C --> D["解析 FROM 基础镜像摘要"]
D --> E["按依赖关系执行指令"]
E --> F{"缓存是否可复用"}
F -- "可以" --> G["复用已有结果"]
F -- "不可以" --> H["在临时根文件系统执行"]
H --> I["计算变更内容摘要"]
G --> J["组装镜像清单与配置"]
I --> J
J --> K["写入本地镜像存储或推送仓库"]现代 Docker 通常使用 BuildKit。它会建立构建步骤依赖图,跳过最终结果不需要的阶段,并支持并行步骤、缓存导入导出、Secret Mount 和 Cache Mount。Dockerfile 描述的是目标状态和步骤依赖,不应把它简单理解为传统 Shell 脚本。
2.3 镜像最终包含什么
一个镜像至少包含两类信息:
| 内容 | 示例 | 作用 |
|---|---|---|
| 文件系统层 | JRE、Jar、证书、配置模板 | 容器 rootfs 的只读基础 |
| 镜像配置 | Env、WorkingDir、User、Entrypoint、Cmd | 容器的默认运行参数 |
ENV、USER、WORKDIR、ENTRYPOINT 等指令即使不产生大量文件,也会改变镜像配置。运行容器时,Docker 再把镜像默认配置与 docker run 参数合并。
三、镜像层、内容摘要与缓存
3.1 为什么镜像要分层
镜像不是一个不可分割的大压缩包。不同镜像可以共享相同的只读层:
flowchart TD
A["基础操作系统层"] --> B["JRE 层"]
B --> C["依赖层"]
C --> D["应用代码层"]
D --> E["order-api 镜像"]
C --> F["另一个应用代码层"]
F --> G["inventory-api 镜像"]分层带来:
- 相同内容只下载和保存一次。
- 构建时可以复用没有变化的步骤。
- 推送新版本时只上传变化的层。
- 每层由内容摘要标识,内容改变摘要就改变。
3.2 哪些指令会影响层
RUN、COPY、ADD 会产生文件系统变化。ENV、CMD 等主要改变镜像配置。现代 BuildKit 的内部实现不应机械理解为“一行必然对应一个传统中间容器”,但从学习和优化角度,仍应关注每一步输入与输出是否稳定。
查看镜像历史:
docker history --no-trunc order-api:1.0
docker image inspect order-api:1.03.3 缓存为什么失效
构建器会根据指令、父步骤结果和输入内容判断能否复用缓存。
例如:
COPY . /src
RUN mvn package源码中任意文件改变,COPY . 的输入摘要就改变,后面的 Maven 构建通常都要重新执行。
更合理的顺序是先复制变化较少的依赖描述,再下载依赖:
FROM maven:3.9.9-eclipse-temurin-17 AS build
WORKDIR /src
COPY pom.xml ./
RUN mvn -B dependency:go-offline
COPY src ./src
RUN mvn -B clean package -DskipTests当只修改 Java 源码而 pom.xml 没变时,依赖下载层有机会复用。
3.4 缓存命中不等于远程内容永远最新
下面指令只写了同一个字符串:
RUN apt-get update && apt-get install -y curl如果缓存可用,构建器可能复用旧结果,并不会因为软件仓库今天有新版本就自动失效。需要明确决定:
- 是否追求完全固定版本和可复现构建。
- 何时主动使用
--no-cache。 - 何时更新基础镜像摘要。
- 是否使用漏洞扫描推动基础镜像升级。
不要把“每次自动拿最新”误认为稳定。生产镜像更重视可追踪升级,而不是不可控漂移。
四、.dockerignore为什么重要
推荐示例:
.git
.idea
.vscode
node_modules
target
*.log
.env
secrets/
docs/如果本例需要复制宿主机已经构建好的 Jar,就不能把整个 target 排除,可以精确排除:
target/*
!target/order-api.jar.dockerignore解决三个问题:
- 减少发送给构建器的上下文,加快构建。
- 避免无关文件变化导致
COPY .缓存失效。 - 降低
.git、.env、私钥、IDE 配置意外进入镜像的风险。
检查构建上下文不能只看最终容器。敏感文件即使没有被最终阶段复制,也可能进入中间层、远程缓存或 CI 构建记录,所以应从输入边界就排除。
五、Dockerfile核心指令与内部含义
5.1 FROM:选择根文件系统和运行时边界
FROM eclipse-temurin:8-jre-jammyFROM 决定基础文件系统、系统库、证书、时区数据、包管理器和 JRE。标签是可变名称,内容摘要才是不可变身份:
FROM eclipse-temurin:17-jre-jammy@sha256:实际摘要固定摘要可以提高复现性,但也意味着基础镜像有安全更新时不会自动获得,需要由依赖更新流程主动升级并重新测试。
选择基础镜像时不能只比体积:
| 类型 | 优点 | 风险与边界 |
|---|---|---|
| Ubuntu/Debian JRE | 兼容性和排障工具较好 | 体积相对大 |
| Alpine | 较小 | musl 与 glibc 差异可能影响 JNI、字体和本地库 |
| Distroless | 攻击面较小 | 通常没有 Shell,排障方式不同 |
scratch | 几乎空 | 只适合静态二进制或自行提供全部依赖,不适合普通 Jar |
5.2 WORKDIR:确定后续相对路径
WORKDIR /app后续 COPY 目标相对路径、RUN、CMD 和 ENTRYPOINT 默认都在该目录下执行。相比:
RUN cd /app && java -jar app.jarWORKDIR 会进入镜像配置并持续影响后续指令,更清晰也不依赖某一次 Shell 的临时工作目录。
5.3 COPY与ADD
COPY --chown=10001:10001 app.jar /app/app.jar默认优先使用 COPY,因为语义明确。ADD 还支持本地 tar 自动解包等额外行为,容易让读者误判结果;只有确实需要额外语义时才使用。
常见错误:
- 源文件被
.dockerignore排除。 - 使用错误的构建上下文,导致文件不存在。
- 复制后文件属于 root,而运行用户无权读取。
COPY . .把密钥、日志和构建产物一起复制。
5.4 RUN只发生在构建阶段
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl \
&& rm -rf /var/lib/apt/lists/*这条命令在构建镜像时执行,不会在每次容器启动时执行。更新索引、安装软件和清理索引放在同一个 RUN 中,是因为如果先在一层产生大文件、后在另一层删除,下层内容仍然存在于镜像中,镜像体积不会按直觉完全缩小。
反例:
RUN apt-get update
RUN apt-get install -y curl
RUN rm -rf /var/lib/apt/lists/*这个写法还可能让软件索引缓存与安装步骤产生不一致。
5.5 ARG与ENV
ARG APP_VERSION=dev
ENV APP_VERSION=${APP_VERSION}| 对比 | ARG | ENV |
|---|---|---|
| 主要作用阶段 | 构建阶段 | 构建后仍进入镜像运行配置 |
| 容器默认可见 | 否,除非转成 ENV 或写入文件 | 是 |
| 适合内容 | 非敏感构建参数、版本号 | 非敏感运行默认值 |
| 是否适合密码 | 不适合 | 不适合 |
不要认为 ARG 不进入最终环境变量就适合传密码。它仍可能出现在构建历史、缓存、日志或构建平台元数据中。
BuildKit Secret Mount 示例:
# syntax=docker/dockerfile:1
FROM maven:3.9.9-eclipse-temurin-17 AS build
WORKDIR /src
COPY pom.xml .
RUN --mount=type=secret,id=maven_settings,target=/root/.m2/settings.xml \
mvn -B dependency:go-offline构建时:
docker build \
--secret id=maven_settings,src=$HOME/.m2/settings.xml \
-t order-api:1.0 .Secret Mount 只在该构建步骤临时可见,不应被复制到结果层。仍需确保命令不会主动把秘密打印到日志或复制到其他路径。
5.6 EXPOSE不会发布端口
EXPOSE 8080它只是镜像元数据,表达应用预计监听 8080。真正发布到宿主机需要:
docker run -p 18080:8080 order-api:1.0并且 Java 应用必须确实监听容器内 8080,通常绑定 0.0.0.0。详细报文路径见 Docker网络底层原理。
5.7 USER:容器进程不应默认获得root权限
RUN groupadd --gid 10001 app \
&& useradd --uid 10001 --gid app --create-home app
COPY --chown=app:app app.jar /app/app.jar
USER app容器中的 root 与宿主机 root 不是完全相同的安全边界,但 root 进程配合错误的挂载、Capability、设备或 Docker socket 会显著扩大事故影响。
采用固定数字 UID/GID 还有利于处理 Volume 与宿主机文件权限。仅写用户名但不同镜像中用户 ID 不同,挂载后仍可能产生权限问题。
5.8 VOLUME不是备份声明
VOLUME ["/data"]它声明该路径适合挂载,但不能替代部署阶段明确的数据生命周期设计。匿名卷可能难以追踪,数据库生产部署更应在 Compose 或编排平台中显式定义命名卷、存储类和备份方案。详见 Docker存储挂载与数据生命周期。
5.9 LABEL用于追踪制品来源
ARG GIT_COMMIT=unknown
ARG BUILD_TIME=unknown
LABEL org.opencontainers.image.revision=$GIT_COMMIT \
org.opencontainers.image.created=$BUILD_TIME \
org.opencontainers.image.source="https://git.example.com/order/order-api"查看:
docker image inspect \
--format '{{json .Config.Labels}}' \
order-api:1.0标签不能保证镜像可信,但能帮助回答“镜像来自哪个 commit、何时构建、源仓库在哪里”。可信供应链还需要受控构建、签名、SBOM、漏洞扫描和准入策略。
5.10 HEALTHCHECK检测应用是否可服务
HEALTHCHECK --interval=30s --timeout=3s --start-period=60s --retries=3 \
CMD wget -qO- http://127.0.0.1:8080/actuator/health/readiness || exit 1注意:基础镜像必须真的包含 wget。健康检查不是越复杂越好:
- 只检查进程存在,可能出现“假存活”。
- 强依赖所有下游,某个非核心依赖抖动可能导致所有实例同时不健康。
- 间隔过短会给应用和依赖制造额外流量。
- Docker 健康状态本身不会自动理解业务,应结合编排平台和监控策略。
5.11 STOPSIGNAL与优雅停机
STOPSIGNAL SIGTERMDocker 默认停止流程是先向容器主进程发送终止信号,等待超时后再发送 SIGKILL。Java/Spring Boot 应让 JVM 成为正确接收信号的主进程,并在宽限时间内停止接流量、处理在途请求、关闭线程池和连接。
六、CMD、ENTRYPOINT与最终命令合并
这是 Dockerfile 面试和生产故障中最容易混乱的部分。
6.1 Exec form与Shell form
Exec form:
ENTRYPOINT ["java", "-jar", "/app/app.jar"]Shell form:
ENTRYPOINT java -jar /app/app.jarShell form 通常等价于通过 /bin/sh -c 执行,可能导致:
/bin/sh成为 PID 1,而不是 JVM。- 信号转发行为依赖 Shell。
- 参数和变量发生 Shell 展开、分词和转义。
- 极简镜像没有
/bin/sh时直接失败。
Exec form 不经过 Shell,参数边界明确,通常更适合生产入口。
6.2 ENTRYPOINT与CMD如何配合
ENTRYPOINT ["java", "-jar", "/app/app.jar"]
CMD ["--spring.profiles.active=prod"]默认运行结果相当于:
java -jar /app/app.jar --spring.profiles.active=prod执行:
docker run order-api:1.0 --spring.profiles.active=test运行时参数会替换镜像的 CMD,但保留 ENTRYPOINT,最终相当于:
java -jar /app/app.jar --spring.profiles.active=test如果镜像只有:
CMD ["java", "-jar", "/app/app.jar"]那么:
docker run order-api:1.0 sh会整体替换 CMD,尝试执行 sh。
需要覆盖 Entrypoint 时显式使用:
docker run --entrypoint java order-api:1.0 -version检查最终配置:
docker image inspect \
--format 'Entrypoint={{json .Config.Entrypoint}} Cmd={{json .Config.Cmd}}' \
order-api:1.06.3 为什么 Exec form不会自动展开环境变量
下面写法不会让 Docker 自动把 $JAVA_OPTS 拆成多个 JVM 参数:
ENV JAVA_OPTS="-Xms256m -Xmx512m"
ENTRYPOINT ["java", "$JAVA_OPTS", "-jar", "/app/app.jar"]JSON 数组直接把字符串传给进程,不经过 Shell 展开。Java 更推荐使用 JVM 原生识别的环境变量:
ENV JAVA_TOOL_OPTIONS="-XX:MaxRAMPercentage=75.0 -Dfile.encoding=UTF-8"
ENTRYPOINT ["java", "-jar", "/app/app.jar"]如果确实需要 Shell 组装参数,可以使用入口脚本,并在最后 exec:
#!/bin/sh
set -eu
exec java ${JAVA_OPTS:-} -jar /app/app.jar "$@"exec 用 JVM 替换 Shell,使 JVM 成为容器主进程。这里的变量分词行为仍需受控,复杂参数更适合数组化脚本或平台原生参数配置。
七、多阶段构建为什么能减小镜像和攻击面
构建 Java 项目需要 Maven、编译器和源码,但运行 Jar 通常只需要 JRE、Jar 和必要证书。多阶段构建把两者分开:
flowchart TD
A["build阶段:Maven和JDK"] --> B["编译、测试、打包"]
B --> C["得到Jar"]
C --> D["runtime阶段:只含JRE"]
D --> E["复制Jar"]
E --> F["较小的生产镜像"]构建阶段不会自动进入最终镜像,只有 COPY --from=build 选择的文件会进入运行阶段。
7.1 可运行的JDK 8示例
# syntax=docker/dockerfile:1
FROM maven:3.8.8-eclipse-temurin-8 AS build
WORKDIR /src
COPY pom.xml ./
RUN --mount=type=cache,target=/root/.m2 \
mvn -B dependency:go-offline
COPY src ./src
RUN --mount=type=cache,target=/root/.m2 \
mvn -B clean package
FROM eclipse-temurin:8-jre-jammy AS runtime
RUN groupadd --gid 10001 app \
&& useradd --uid 10001 --gid app --create-home app
WORKDIR /app
COPY --from=build --chown=app:app /src/target/order-api.jar /app/app.jar
USER 10001:10001
EXPOSE 8080
ENV JAVA_TOOL_OPTIONS="-Dfile.encoding=UTF-8 -Duser.timezone=Asia/Shanghai"
ENTRYPOINT ["java", "-jar", "/app/app.jar"]构建与验证:
docker build -t order-api:jdk8 .
docker run --rm order-api:jdk8 --version
docker image inspect order-api:jdk8JDK 8 必须注意具体更新版本。较早的 JDK 8 对 cgroup 资源识别并不完善,JVM 可能按宿主机内存估算堆大小,最终被容器内存限制杀死。不同发行版回移容器支持的时间也可能不同,不能只写“JDK 8”就假定行为一致。应验证:
docker run --rm --memory=512m order-api:jdk8 java -XX:+PrintFlagsFinal -version重点结合具体版本检查 UseContainerSupport、堆大小和容器限制。旧版本必要时显式设置 -Xms、-Xmx,并为 Metaspace、线程栈、Direct Memory、Code Cache 和 native memory 预留空间。
7.2 可运行的JDK 17示例
# syntax=docker/dockerfile:1
FROM maven:3.9.9-eclipse-temurin-17 AS build
WORKDIR /src
COPY pom.xml ./
RUN --mount=type=cache,target=/root/.m2 \
mvn -B dependency:go-offline
COPY src ./src
RUN --mount=type=cache,target=/root/.m2 \
mvn -B clean package
FROM eclipse-temurin:17-jre-jammy AS runtime
RUN groupadd --gid 10001 app \
&& useradd --uid 10001 --gid app --create-home app
WORKDIR /app
COPY --from=build --chown=app:app /src/target/order-api.jar /app/app.jar
USER 10001:10001
EXPOSE 8080
ENV JAVA_TOOL_OPTIONS="-XX:InitialRAMPercentage=50.0 -XX:MaxRAMPercentage=75.0 -Dfile.encoding=UTF-8 -Duser.timezone=Asia/Shanghai"
ENTRYPOINT ["java", "-jar", "/app/app.jar"]现代 JDK 默认具有更完善的容器感知能力,但 MaxRAMPercentage=75 也不表示容器内存可以全部交给 Java Heap。仍要为非堆和 native memory 留空间,并结合线程数、Direct Buffer、JNI、代理和 sidecar 实际测量。
7.3 测试是否应该在镜像构建中跳过
示例里执行了 mvn clean package,默认会跑测试。CI 中可以先在独立阶段运行测试,再构建只接受已验证制品的镜像;也可以在多阶段构建中运行。关键不是固定形式,而是满足:
- 未通过测试的 commit 不能生成可晋级生产的制品。
- 构建日志能证明测试结果对应哪个 commit。
- 镜像中的 Jar 与已验证 Jar 是同一个制品,而不是之后重新打包的未知结果。
长期使用 -DskipTests 却没有其他测试关卡,会把失败推迟到部署甚至生产。
八、Spring Boot分层Jar优化
普通胖 Jar 中,应用类改一行,整个 Jar 内容摘要变化,Docker 看到的是一个全新大文件层。Spring Boot 支持将依赖、启动器和应用代码拆成变化频率不同的层。
思路如下:
dependencies 第三方稳定依赖,变化最少
spring-boot-loader Spring Boot Loader
snapshot-dependencies 快照依赖
application 当前应用代码,变化最多不同 Spring Boot 版本的 layertools 和 Maven/Gradle 配置方式存在差异,使用前应以项目实际版本验证。核心目的不是为了目录好看,而是让改业务代码时尽量只推送 application 层。
示意 Dockerfile:
FROM eclipse-temurin:17-jre-jammy AS extract
WORKDIR /layers
COPY target/order-api.jar app.jar
RUN java -Djarmode=layertools -jar app.jar extract
FROM eclipse-temurin:17-jre-jammy
WORKDIR /app
COPY --from=extract /layers/dependencies/ ./
COPY --from=extract /layers/spring-boot-loader/ ./
COPY --from=extract /layers/snapshot-dependencies/ ./
COPY --from=extract /layers/application/ ./
ENTRYPOINT ["java", "org.springframework.boot.loader.launch.JarLauncher"]某些旧版本的 Loader 主类路径不同。如果照抄后出现 ClassNotFoundException,应先检查 Spring Boot 版本和解压目录,而不是盲目换 JDK。
九、构建参数、运行配置与密钥边界
推荐边界:
| 信息 | 推荐位置 | 原因 |
|---|---|---|
| JRE和系统依赖 | 镜像 | 运行环境的一部分 |
| 应用Jar | 镜像 | 不可变制品 |
| 默认非敏感参数 | Dockerfile ENV或应用默认配置 | 提供合理默认值 |
| 环境差异配置 | Compose、Kubernetes ConfigMap、配置中心 | 镜像跨环境复用 |
| 密码、Token、私钥 | Secret系统或运行时受控挂载 | 不进入镜像和Git |
| 临时构建凭据 | BuildKit Secret Mount | 不持久化到结果层 |
错误示例:
COPY application-prod.yml /app/application.yml
ENV DB_PASSWORD=123456
RUN echo "token=abcdef" > /root/token.txt
RUN rm /root/token.txt最后一行删除文件,不代表前一层中不存在。攻击者若能获得镜像层,仍可能恢复内容。敏感信息从未进入构建上下文和普通层,才是正确方向。
十、可复现、安全且可追踪的生产镜像
10.1 不要只使用latest
建议同时使用:
order-api:1.8.3
order-api:git-a1b2c3d
order-api@sha256:不可变内容摘要Tag 是指向内容的可变引用。同一个 latest 今天和明天可能对应不同镜像。生产部署如果只记录 latest,事故后很难证明当时到底运行了什么。
10.2 基础镜像升级不是重新写Dockerfile
需要建立:
- 基础镜像和应用依赖漏洞扫描。
- 定期更新基础镜像标签或摘要。
- 重新构建并执行测试。
- 生成 SBOM,记录镜像包含的软件。
- 签名并在部署入口验证来源。
镜像体积小通常有利于分发,但“最小”不是唯一指标。可维护性、兼容性、补丁策略、证书、时区、字体和排障方案同样重要。
10.3 不要把Docker Socket挂进普通应用
volumes:
- /var/run/docker.sock:/var/run/docker.sock能够操作 Docker Socket 的容器通常可以创建高权限容器、挂载宿主机目录,接近获得宿主机控制能力。它不是普通“调用一个本地 API”,必须按高权限基础设施对待。
十一、商业场景:订单服务镜像如何进入生产
场景:订单服务使用 JDK 8,流水线构建镜像,测试环境验证后发布到生产。
flowchart TD
A["提交Git commit"] --> B["CI检出固定commit"]
B --> C["编译与自动化测试"]
C --> D["多阶段构建镜像"]
D --> E["扫描漏洞并生成SBOM"]
E --> F{"质量门禁是否通过"}
F -- "否" --> G["阻断发布"]
F -- "是" --> H["推送带commit标签的镜像"]
H --> I["测试环境按摘要部署"]
I --> J["接口、健康与资源验证"]
J --> K["同一摘要晋级生产"]
K --> L["观察错误率、P99和业务成功率"]生产 Dockerfile 示例:
# syntax=docker/dockerfile:1
FROM maven:3.8.8-eclipse-temurin-8 AS build
WORKDIR /src
COPY pom.xml ./
RUN --mount=type=cache,target=/root/.m2 mvn -B dependency:go-offline
COPY src ./src
RUN --mount=type=cache,target=/root/.m2 mvn -B clean verify
FROM eclipse-temurin:8-jre-jammy
ARG GIT_COMMIT=unknown
ARG BUILD_TIME=unknown
LABEL org.opencontainers.image.revision=$GIT_COMMIT \
org.opencontainers.image.created=$BUILD_TIME
RUN groupadd --gid 10001 app \
&& useradd --uid 10001 --gid app --create-home app
WORKDIR /app
COPY --from=build --chown=10001:10001 /src/target/order-api.jar /app/app.jar
USER 10001:10001
EXPOSE 8080
ENV JAVA_TOOL_OPTIONS="-Xms512m -Xmx512m -Dfile.encoding=UTF-8 -Duser.timezone=Asia/Shanghai"
STOPSIGNAL SIGTERM
ENTRYPOINT ["java", "-jar", "/app/app.jar"]构建:
docker build \
--build-arg GIT_COMMIT=a1b2c3d \
--build-arg BUILD_TIME=2026-07-15T10:00:00Z \
-t registry.example.com/order/order-api:git-a1b2c3d .运行验证:
docker run -d \
--name order-api \
--memory=1g \
--cpus=1.5 \
-p 18080:8080 \
-e SPRING_PROFILES_ACTIVE=test \
registry.example.com/order/order-api:git-a1b2c3d
docker ps -a --filter name=order-api
docker inspect order-api
docker logs --tail 200 order-api
curl -fsS http://127.0.0.1:18080/actuator/health这里 -Xmx512m 小于 1 GiB 容器限制,是为了给非堆、线程栈和 native memory 留空间;具体数值必须通过压测和监控确定,不能把这个示例当成所有项目的固定答案。
十二、常见故障与排查过程
12.1 COPY failed或文件不存在
按顺序检查:
docker build最后的上下文路径是什么。- 源文件是否真的位于上下文内。
.dockerignore是否排除了该文件。- CI 的工作目录是否与本地不同。
- 大小写是否在 Linux 文件系统上不一致。
不要用扩大到磁盘根目录的构建上下文解决,这会增加泄密和性能风险。
12.2 构建缓存总是失效
检查:
- 是否过早
COPY . .。 - 是否把日志、
.git、测试报告、时间戳文件放入上下文。 - 依赖描述和源码是否拆分复制。
- CI 构建节点是否根本没有导入远程缓存。
- 基础镜像标签是否变化。
使用详细日志:
docker build --progress=plain -t order-api:test .12.3 镜像突然变得很大
docker history --no-trunc order-api:test
docker image inspect order-api:test常见原因:
- 把整个源码仓库和
.git复制进镜像。 - 安装软件后没有在同一步清理包索引。
- 在一层创建大文件、下一层再删除。
- 最终阶段仍使用 Maven/JDK 构建镜像。
- Jar、日志、转储文件被重复复制。
12.4 容器一启动就退出
docker ps -a
docker inspect --format '{{json .State}}' order-api
docker logs order-api
docker image inspect --format 'entry={{json .Config.Entrypoint}} cmd={{json .Config.Cmd}}' order-api:test判断:
- Entrypoint 文件是否存在并有执行权限。
- 脚本是否使用了 Windows CRLF,导致解释器路径异常。
- 基础镜像是否有
/bin/sh。 - Jar 路径和工作目录是否正确。
- JVM 版本是否支持该 class 文件版本。
- 非 root 用户是否能读取 Jar、证书和配置。
12.5 Permission denied
构建阶段 root 创建的文件,运行阶段切换到 UID 10001 后可能无权读取或写入。检查:
docker run --rm --entrypoint sh order-api:test -c 'id && ls -ln /app'如果镜像没有 Shell,可在同基础镜像的调试版本中检查,或通过 docker create 和 docker cp 导出文件分析。不要为了快速通过而永久改成 root;应修正 COPY --chown、目录所有权或挂载 UID/GID。
12.6 容器停止很慢
检查:
docker image inspect --format '{{json .Config.Entrypoint}}' order-api:test
docker top order-api
docker events --since 30m --filter container=order-api如果入口是 Shell form,信号可能没有正确到达 JVM。还要检查 Spring Boot 优雅停机、线程池关闭、连接池和在途请求是否超过停止宽限时间。超时后收到 SIGKILL 将没有 finally、shutdown hook 或继续保存现场的机会。
12.7 本地正常但CI构建失败
常见原因:
- 本地 Maven 缓存掩盖了缺失依赖或仓库配置。
- CI 无法访问私有仓库。
- 私有仓库凭据没有通过 Secret Mount 提供。
- 本地构建上下文包含未提交文件。
- CPU 架构不同,例如本地 arm64、部署环境 amd64。
- 代理、DNS、CA 证书或系统时间不同。
应让 CI 从干净 checkout 构建,并明确平台:
docker buildx build \
--platform linux/amd64 \
--progress=plain \
-t order-api:test \
--load .十三、构建完成后的验收清单
镜像身份
docker image inspect \
--format 'id={{.Id}} repoDigests={{json .RepoDigests}} labels={{json .Config.Labels}}' \
order-api:1.0启动配置
docker image inspect \
--format 'user={{.Config.User}} workdir={{.Config.WorkingDir}} entry={{json .Config.Entrypoint}} cmd={{json .Config.Cmd}} env={{json .Config.Env}}' \
order-api:1.0输出中可能含敏感环境变量,分享前必须脱敏。
文件和用户
docker run --rm --entrypoint sh order-api:1.0 -c 'id; ls -ln /app'资源和优雅停止
docker run -d --name order-api-test --memory=1g -p 18080:8080 order-api:1.0
docker inspect order-api-test
docker stop --time 30 order-api-test
docker inspect --format '{{json .State}}' order-api-test
docker rm order-api-test必须人工回答的问题
- 基础镜像为什么选这个发行版和版本?
- 镜像对应哪个 commit,能否按摘要回滚?
- 镜像是否以非 root 用户运行?
- 密钥是否完全没有进入上下文、层、标签和日志?
- JDK 是否正确识别容器 CPU 和内存?
- Heap 之外预留了多少内存,依据是什么?
- SIGTERM 能否到达 JVM,宽限期内能否完成优雅停机?
- 日志、上传文件、转储和数据库数据分别写到哪里?
- 发生漏洞时如何定位依赖并重新构建?
十四、面试标准回答
Dockerfile构建镜像的原理是什么
Docker构建器读取Dockerfile和受
.dockerignore过滤的构建上下文,从FROM解析基础镜像,按步骤执行RUN、COPY等操作并依据指令、父结果和输入内容复用缓存。文件变化形成内容寻址的只读层,ENV、USER、ENTRYPOINT、CMD等形成镜像配置,最后组装为可分发镜像。运行时Docker再把镜像配置与docker run参数合并,并在只读镜像层上增加容器可写层。
为什么Dockerfile顺序会影响构建速度
后续步骤依赖前一步结果。把频繁变化的源码过早复制,会使该步骤及后续缓存失效;先复制变化较少的依赖描述并下载依赖,再复制源码,修改业务代码时就可能复用依赖缓存。
.dockerignore还要排除日志、Git目录和临时文件,避免无关变化改变上下文摘要。
CMD和ENTRYPOINT有什么区别
ENTRYPOINT定义固定入口,CMD常作为默认命令或默认参数。
docker run 镜像 新参数通常替换CMD而保留ENTRYPOINT;--entrypoint才覆盖入口。生产入口优先使用Exec form,使参数边界明确并让应用直接接收信号。Shell form通常经过/bin/sh -c,可能造成Shell成为PID 1和信号转发问题。
多阶段构建为什么能减小镜像
编译阶段可以包含Maven、JDK和源码,运行阶段只从前一阶段复制Jar等必要产物,因此最终镜像不包含编译工具和源码。它既减小分发体积,也减少攻击面,但前提是没有把凭据和无关产物复制到最终阶段,构建缓存和日志也必须单独保护。
为什么删除密钥后镜像里仍可能找到
镜像层不可变。如果一个
RUN或COPY步骤把密钥写进某一层,后续步骤的删除只是上层文件系统变化,下层内容仍可能存在于镜像历史或缓存中。密钥应从一开始就不进入普通构建上下文和层,构建期间使用BuildKit Secret Mount,运行期间使用Secret系统或只读受控挂载。
Java容器为什么不能把Xmx设置成容器内存上限
容器限制约束整个Java进程及其子进程,不只Java Heap。Metaspace、线程栈、Direct Memory、Code Cache、GC和JIT本地结构、JNI等都占内存。若Xmx等于容器上限,即使堆没有泄漏,总内存也可能触发cgroup OOM。JDK 8还要核实具体更新版本的容器感知能力,不能假定所有JDK 8行为一致。
