Skip to content

Dockerfile 从构建原理到生产镜像

只会写 FROMCOPYENTRYPOINT,并不等于会构建生产镜像。真正需要理解的是:docker build 为什么要发送构建上下文,每条指令如何形成层,缓存为什么命中或失效,镜像配置怎样与 docker run 参数合并,为什么 Shell form 会影响信号,密钥为什么即使删除仍可能留在历史层,以及 Java 8 和现代 JDK 在容器资源识别上有什么区别。

学习目标

学完本页,应能独立完成:

  1. 从 Dockerfile、构建上下文到 OCI 镜像解释完整构建过程。
  2. 解释镜像层、配置对象、内容摘要、缓存命中和失效原理。
  3. 正确区分 RUNCMDENTRYPOINT、Shell form 和 Exec form。
  4. 使用 .dockerignore、多阶段构建和 BuildKit 缓存优化 Java 镜像。
  5. 分别构建可运行的 JDK 8 与 JDK 17 Spring Boot 镜像。
  6. 正确处理非 root 用户、文件权限、密钥、日志、时区、证书和优雅停机。
  7. 从构建失败、启动失败、镜像过大、缓存失效、安全扫描失败等现象反推原因。
  8. 在面试中说明 Dockerfile 原理,而不只是背指令名称。

一、Dockerfile解决的不是“打包”,而是可重复交付

手工部署通常包含安装 JDK、创建目录、复制 Jar、设置参数、启动服务。问题是这些步骤只存在于人的记忆和服务器当前状态中:

手工操作问题生产后果
每台机器安装不同 JDK同一 Jar 在不同机器表现不同
临时修改服务器文件Git 中没有记录,无法审计和复现
直接覆盖旧 Jar不知道线上文件来自哪个 commit
密码写进启动脚本容易进入历史、日志和备份
回滚依赖人工找文件恢复时间不可控

Dockerfile 把运行环境描述成代码。它不是虚拟机安装脚本,而是从基础镜像出发,生成一组只读文件系统层和一份运行配置。

mermaid
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完整过程

假设目录如下:

text
order-api/
├── Dockerfile
├── .dockerignore
├── pom.xml
├── src/
└── target/order-api.jar

执行:

bash
docker build -t order-api:1.0 .

最后的 . 不是装饰,它表示构建上下文目录。

2.1 什么是构建上下文

Dockerfile 中:

dockerfile
COPY target/order-api.jar /app/app.jar

源路径不是任意宿主机绝对路径,而是相对于构建上下文。构建器只能读取上下文中且未被 .dockerignore 排除的内容。

以下写法不能越过上下文读取父目录:

dockerfile
COPY ../secret.txt /app/secret.txt

这样设计有两个原因:

  1. 构建可能在远程 BuildKit、CI 节点或集群构建器执行,不能依赖本机任意路径。
  2. 明确输入边界,构建结果才可能复现,也能减少意外读取宿主机敏感文件。

2.2 构建器逐步做什么

mermaid
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容器的默认运行参数

ENVUSERWORKDIRENTRYPOINT 等指令即使不产生大量文件,也会改变镜像配置。运行容器时,Docker 再把镜像默认配置与 docker run 参数合并。

三、镜像层、内容摘要与缓存

3.1 为什么镜像要分层

镜像不是一个不可分割的大压缩包。不同镜像可以共享相同的只读层:

mermaid
flowchart TD
    A["基础操作系统层"] --> B["JRE 层"]
    B --> C["依赖层"]
    C --> D["应用代码层"]
    D --> E["order-api 镜像"]
    C --> F["另一个应用代码层"]
    F --> G["inventory-api 镜像"]

分层带来:

  • 相同内容只下载和保存一次。
  • 构建时可以复用没有变化的步骤。
  • 推送新版本时只上传变化的层。
  • 每层由内容摘要标识,内容改变摘要就改变。

3.2 哪些指令会影响层

RUNCOPYADD 会产生文件系统变化。ENVCMD 等主要改变镜像配置。现代 BuildKit 的内部实现不应机械理解为“一行必然对应一个传统中间容器”,但从学习和优化角度,仍应关注每一步输入与输出是否稳定。

查看镜像历史:

bash
docker history --no-trunc order-api:1.0
docker image inspect order-api:1.0

3.3 缓存为什么失效

构建器会根据指令、父步骤结果和输入内容判断能否复用缓存。

例如:

dockerfile
COPY . /src
RUN mvn package

源码中任意文件改变,COPY . 的输入摘要就改变,后面的 Maven 构建通常都要重新执行。

更合理的顺序是先复制变化较少的依赖描述,再下载依赖:

dockerfile
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 缓存命中不等于远程内容永远最新

下面指令只写了同一个字符串:

dockerfile
RUN apt-get update && apt-get install -y curl

如果缓存可用,构建器可能复用旧结果,并不会因为软件仓库今天有新版本就自动失效。需要明确决定:

  • 是否追求完全固定版本和可复现构建。
  • 何时主动使用 --no-cache
  • 何时更新基础镜像摘要。
  • 是否使用漏洞扫描推动基础镜像升级。

不要把“每次自动拿最新”误认为稳定。生产镜像更重视可追踪升级,而不是不可控漂移。

四、.dockerignore为什么重要

推荐示例:

text
.git
.idea
.vscode
node_modules
target
*.log
.env
secrets/
docs/

如果本例需要复制宿主机已经构建好的 Jar,就不能把整个 target 排除,可以精确排除:

text
target/*
!target/order-api.jar

.dockerignore解决三个问题:

  1. 减少发送给构建器的上下文,加快构建。
  2. 避免无关文件变化导致 COPY . 缓存失效。
  3. 降低 .git.env、私钥、IDE 配置意外进入镜像的风险。

检查构建上下文不能只看最终容器。敏感文件即使没有被最终阶段复制,也可能进入中间层、远程缓存或 CI 构建记录,所以应从输入边界就排除。

五、Dockerfile核心指令与内部含义

5.1 FROM:选择根文件系统和运行时边界

dockerfile
FROM eclipse-temurin:8-jre-jammy

FROM 决定基础文件系统、系统库、证书、时区数据、包管理器和 JRE。标签是可变名称,内容摘要才是不可变身份:

dockerfile
FROM eclipse-temurin:17-jre-jammy@sha256:实际摘要

固定摘要可以提高复现性,但也意味着基础镜像有安全更新时不会自动获得,需要由依赖更新流程主动升级并重新测试。

选择基础镜像时不能只比体积:

类型优点风险与边界
Ubuntu/Debian JRE兼容性和排障工具较好体积相对大
Alpine较小musl 与 glibc 差异可能影响 JNI、字体和本地库
Distroless攻击面较小通常没有 Shell,排障方式不同
scratch几乎空只适合静态二进制或自行提供全部依赖,不适合普通 Jar

5.2 WORKDIR:确定后续相对路径

dockerfile
WORKDIR /app

后续 COPY 目标相对路径、RUNCMDENTRYPOINT 默认都在该目录下执行。相比:

dockerfile
RUN cd /app && java -jar app.jar

WORKDIR 会进入镜像配置并持续影响后续指令,更清晰也不依赖某一次 Shell 的临时工作目录。

5.3 COPYADD

dockerfile
COPY --chown=10001:10001 app.jar /app/app.jar

默认优先使用 COPY,因为语义明确。ADD 还支持本地 tar 自动解包等额外行为,容易让读者误判结果;只有确实需要额外语义时才使用。

常见错误:

  • 源文件被 .dockerignore 排除。
  • 使用错误的构建上下文,导致文件不存在。
  • 复制后文件属于 root,而运行用户无权读取。
  • COPY . . 把密钥、日志和构建产物一起复制。

5.4 RUN只发生在构建阶段

dockerfile
RUN apt-get update \
    && apt-get install -y --no-install-recommends curl \
    && rm -rf /var/lib/apt/lists/*

这条命令在构建镜像时执行,不会在每次容器启动时执行。更新索引、安装软件和清理索引放在同一个 RUN 中,是因为如果先在一层产生大文件、后在另一层删除,下层内容仍然存在于镜像中,镜像体积不会按直觉完全缩小。

反例:

dockerfile
RUN apt-get update
RUN apt-get install -y curl
RUN rm -rf /var/lib/apt/lists/*

这个写法还可能让软件索引缓存与安装步骤产生不一致。

5.5 ARGENV

dockerfile
ARG APP_VERSION=dev
ENV APP_VERSION=${APP_VERSION}
对比ARGENV
主要作用阶段构建阶段构建后仍进入镜像运行配置
容器默认可见否,除非转成 ENV 或写入文件
适合内容非敏感构建参数、版本号非敏感运行默认值
是否适合密码不适合不适合

不要认为 ARG 不进入最终环境变量就适合传密码。它仍可能出现在构建历史、缓存、日志或构建平台元数据中。

BuildKit Secret Mount 示例:

dockerfile
# 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

构建时:

bash
docker build \
  --secret id=maven_settings,src=$HOME/.m2/settings.xml \
  -t order-api:1.0 .

Secret Mount 只在该构建步骤临时可见,不应被复制到结果层。仍需确保命令不会主动把秘密打印到日志或复制到其他路径。

5.6 EXPOSE不会发布端口

dockerfile
EXPOSE 8080

它只是镜像元数据,表达应用预计监听 8080。真正发布到宿主机需要:

bash
docker run -p 18080:8080 order-api:1.0

并且 Java 应用必须确实监听容器内 8080,通常绑定 0.0.0.0。详细报文路径见 Docker网络底层原理

5.7 USER:容器进程不应默认获得root权限

dockerfile
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不是备份声明

dockerfile
VOLUME ["/data"]

它声明该路径适合挂载,但不能替代部署阶段明确的数据生命周期设计。匿名卷可能难以追踪,数据库生产部署更应在 Compose 或编排平台中显式定义命名卷、存储类和备份方案。详见 Docker存储挂载与数据生命周期

5.9 LABEL用于追踪制品来源

dockerfile
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"

查看:

bash
docker image inspect \
  --format '{{json .Config.Labels}}' \
  order-api:1.0

标签不能保证镜像可信,但能帮助回答“镜像来自哪个 commit、何时构建、源仓库在哪里”。可信供应链还需要受控构建、签名、SBOM、漏洞扫描和准入策略。

5.10 HEALTHCHECK检测应用是否可服务

dockerfile
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与优雅停机

dockerfile
STOPSIGNAL SIGTERM

Docker 默认停止流程是先向容器主进程发送终止信号,等待超时后再发送 SIGKILL。Java/Spring Boot 应让 JVM 成为正确接收信号的主进程,并在宽限时间内停止接流量、处理在途请求、关闭线程池和连接。

六、CMD、ENTRYPOINT与最终命令合并

这是 Dockerfile 面试和生产故障中最容易混乱的部分。

6.1 Exec form与Shell form

Exec form:

dockerfile
ENTRYPOINT ["java", "-jar", "/app/app.jar"]

Shell form:

dockerfile
ENTRYPOINT java -jar /app/app.jar

Shell form 通常等价于通过 /bin/sh -c 执行,可能导致:

  • /bin/sh 成为 PID 1,而不是 JVM。
  • 信号转发行为依赖 Shell。
  • 参数和变量发生 Shell 展开、分词和转义。
  • 极简镜像没有 /bin/sh 时直接失败。

Exec form 不经过 Shell,参数边界明确,通常更适合生产入口。

6.2 ENTRYPOINT与CMD如何配合

dockerfile
ENTRYPOINT ["java", "-jar", "/app/app.jar"]
CMD ["--spring.profiles.active=prod"]

默认运行结果相当于:

text
java -jar /app/app.jar --spring.profiles.active=prod

执行:

bash
docker run order-api:1.0 --spring.profiles.active=test

运行时参数会替换镜像的 CMD,但保留 ENTRYPOINT,最终相当于:

text
java -jar /app/app.jar --spring.profiles.active=test

如果镜像只有:

dockerfile
CMD ["java", "-jar", "/app/app.jar"]

那么:

bash
docker run order-api:1.0 sh

会整体替换 CMD,尝试执行 sh

需要覆盖 Entrypoint 时显式使用:

bash
docker run --entrypoint java order-api:1.0 -version

检查最终配置:

bash
docker image inspect \
  --format 'Entrypoint={{json .Config.Entrypoint}} Cmd={{json .Config.Cmd}}' \
  order-api:1.0

6.3 为什么 Exec form不会自动展开环境变量

下面写法不会让 Docker 自动把 $JAVA_OPTS 拆成多个 JVM 参数:

dockerfile
ENV JAVA_OPTS="-Xms256m -Xmx512m"
ENTRYPOINT ["java", "$JAVA_OPTS", "-jar", "/app/app.jar"]

JSON 数组直接把字符串传给进程,不经过 Shell 展开。Java 更推荐使用 JVM 原生识别的环境变量:

dockerfile
ENV JAVA_TOOL_OPTIONS="-XX:MaxRAMPercentage=75.0 -Dfile.encoding=UTF-8"
ENTRYPOINT ["java", "-jar", "/app/app.jar"]

如果确实需要 Shell 组装参数,可以使用入口脚本,并在最后 exec

sh
#!/bin/sh
set -eu
exec java ${JAVA_OPTS:-} -jar /app/app.jar "$@"

exec 用 JVM 替换 Shell,使 JVM 成为容器主进程。这里的变量分词行为仍需受控,复杂参数更适合数组化脚本或平台原生参数配置。

七、多阶段构建为什么能减小镜像和攻击面

构建 Java 项目需要 Maven、编译器和源码,但运行 Jar 通常只需要 JRE、Jar 和必要证书。多阶段构建把两者分开:

mermaid
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示例

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 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"]

构建与验证:

bash
docker build -t order-api:jdk8 .
docker run --rm order-api:jdk8 --version
docker image inspect order-api:jdk8

JDK 8 必须注意具体更新版本。较早的 JDK 8 对 cgroup 资源识别并不完善,JVM 可能按宿主机内存估算堆大小,最终被容器内存限制杀死。不同发行版回移容器支持的时间也可能不同,不能只写“JDK 8”就假定行为一致。应验证:

bash
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示例

dockerfile
# 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 支持将依赖、启动器和应用代码拆成变化频率不同的层。

思路如下:

text
dependencies          第三方稳定依赖,变化最少
spring-boot-loader     Spring Boot Loader
snapshot-dependencies 快照依赖
application           当前应用代码,变化最多

不同 Spring Boot 版本的 layertools 和 Maven/Gradle 配置方式存在差异,使用前应以项目实际版本验证。核心目的不是为了目录好看,而是让改业务代码时尽量只推送 application 层。

示意 Dockerfile:

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不持久化到结果层

错误示例:

dockerfile
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

建议同时使用:

text
order-api:1.8.3
order-api:git-a1b2c3d
order-api@sha256:不可变内容摘要

Tag 是指向内容的可变引用。同一个 latest 今天和明天可能对应不同镜像。生产部署如果只记录 latest,事故后很难证明当时到底运行了什么。

10.2 基础镜像升级不是重新写Dockerfile

需要建立:

  1. 基础镜像和应用依赖漏洞扫描。
  2. 定期更新基础镜像标签或摘要。
  3. 重新构建并执行测试。
  4. 生成 SBOM,记录镜像包含的软件。
  5. 签名并在部署入口验证来源。

镜像体积小通常有利于分发,但“最小”不是唯一指标。可维护性、兼容性、补丁策略、证书、时区、字体和排障方案同样重要。

10.3 不要把Docker Socket挂进普通应用

yaml
volumes:
  - /var/run/docker.sock:/var/run/docker.sock

能够操作 Docker Socket 的容器通常可以创建高权限容器、挂载宿主机目录,接近获得宿主机控制能力。它不是普通“调用一个本地 API”,必须按高权限基础设施对待。

十一、商业场景:订单服务镜像如何进入生产

场景:订单服务使用 JDK 8,流水线构建镜像,测试环境验证后发布到生产。

mermaid
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 示例:

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"]

构建:

bash
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 .

运行验证:

bash
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或文件不存在

按顺序检查:

  1. docker build 最后的上下文路径是什么。
  2. 源文件是否真的位于上下文内。
  3. .dockerignore 是否排除了该文件。
  4. CI 的工作目录是否与本地不同。
  5. 大小写是否在 Linux 文件系统上不一致。

不要用扩大到磁盘根目录的构建上下文解决,这会增加泄密和性能风险。

12.2 构建缓存总是失效

检查:

  • 是否过早 COPY . .
  • 是否把日志、.git、测试报告、时间戳文件放入上下文。
  • 依赖描述和源码是否拆分复制。
  • CI 构建节点是否根本没有导入远程缓存。
  • 基础镜像标签是否变化。

使用详细日志:

bash
docker build --progress=plain -t order-api:test .

12.3 镜像突然变得很大

bash
docker history --no-trunc order-api:test
docker image inspect order-api:test

常见原因:

  • 把整个源码仓库和 .git 复制进镜像。
  • 安装软件后没有在同一步清理包索引。
  • 在一层创建大文件、下一层再删除。
  • 最终阶段仍使用 Maven/JDK 构建镜像。
  • Jar、日志、转储文件被重复复制。

12.4 容器一启动就退出

bash
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 后可能无权读取或写入。检查:

bash
docker run --rm --entrypoint sh order-api:test -c 'id && ls -ln /app'

如果镜像没有 Shell,可在同基础镜像的调试版本中检查,或通过 docker createdocker cp 导出文件分析。不要为了快速通过而永久改成 root;应修正 COPY --chown、目录所有权或挂载 UID/GID。

12.6 容器停止很慢

检查:

bash
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 构建,并明确平台:

bash
docker buildx build \
  --platform linux/amd64 \
  --progress=plain \
  -t order-api:test \
  --load .

十三、构建完成后的验收清单

镜像身份

bash
docker image inspect \
  --format 'id={{.Id}} repoDigests={{json .RepoDigests}} labels={{json .Config.Labels}}' \
  order-api:1.0

启动配置

bash
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

输出中可能含敏感环境变量,分享前必须脱敏。

文件和用户

bash
docker run --rm --entrypoint sh order-api:1.0 -c 'id; ls -ln /app'

资源和优雅停止

bash
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解析基础镜像,按步骤执行RUNCOPY等操作并依据指令、父结果和输入内容复用缓存。文件变化形成内容寻址的只读层,ENVUSERENTRYPOINTCMD等形成镜像配置,最后组装为可分发镜像。运行时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等必要产物,因此最终镜像不包含编译工具和源码。它既减小分发体积,也减少攻击面,但前提是没有把凭据和无关产物复制到最终阶段,构建缓存和日志也必须单独保护。

为什么删除密钥后镜像里仍可能找到

镜像层不可变。如果一个RUNCOPY步骤把密钥写进某一层,后续步骤的删除只是上层文件系统变化,下层内容仍可能存在于镜像历史或缓存中。密钥应从一开始就不进入普通构建上下文和层,构建期间使用BuildKit Secret Mount,运行期间使用Secret系统或只读受控挂载。

Java容器为什么不能把Xmx设置成容器内存上限

容器限制约束整个Java进程及其子进程,不只Java Heap。Metaspace、线程栈、Direct Memory、Code Cache、GC和JIT本地结构、JNI等都占内存。若Xmx等于容器上限,即使堆没有泄漏,总内存也可能触发cgroup OOM。JDK 8还要核实具体更新版本的容器感知能力,不能假定所有JDK 8行为一致。

关联知识点