Skip to content

Dockerfile 最佳实践 ​

镜像大小和构建速度不是"优化项",直接决定部署速度、拉取耗时、攻击面。这一节按"一条 Dockerfile 从能用改到好用"的顺序写。

场景 ​

一个 Spring Boot 的 jar,从 800MB 的 JDK 镜像 + COPY 全量源码,改造成 180MB、构建 30 秒、以非 root 运行的镜像。

一、能用的版本 ​

dockerfile
FROM openjdk:17
COPY . /app
WORKDIR /app
RUN mvn package
CMD java -jar target/app.jar

四个问题:

  1. openjdk:17 基础镜像就 470MB,还包含 JDK(编译工具),运行只要 JRE。
  2. COPY . 把 target/、.git/、node_modules/ 全拷进去,构建上下文巨大。
  3. RUN mvn package 每次都重新下载依赖,因为源码变了导致缓存失效。
  4. CMD 是 shell 形式,主进程变成 /bin/sh -c,SIGTERM 传不到 Java,docker stop 只能等超时后 SIGKILL。

二、多阶段构建 ​

dockerfile
# ---------- 构建阶段 ----------
FROM maven:3.9-eclipse-temurin-17 AS build
WORKDIR /src

# 先只拷 pom,让依赖层能缓存
COPY pom.xml .
RUN mvn -B -q dependency:go-offline

# 再拷源码
COPY src ./src
RUN mvn -B -q clean package -DskipTests

# ---------- 运行阶段 ----------
FROM eclipse-temurin:17-jre-alpine
WORKDIR /app

# 非 root 用户
RUN addgroup -S app && adduser -S -G app app

COPY --from=build /src/target/app.jar /app/app.jar
RUN chown -R app:app /app
USER app

EXPOSE 8080
ENTRYPOINT ["java", "-XX:MaxRAMPercentage=70", "-jar", "/app/app.jar"]
bash
docker build -t app:1.0.0 .
text
[+] Building 42.1s (16/16) FINISHED
 => [build 1/6] FROM docker.io/library/maven:3.9-eclipse-temurin-17          12.4s
 => [build 2/6] WORKDIR /src                                                  0.1s
 => [build 3/6] COPY pom.xml .                                                0.0s
 => [build 4/6] RUN mvn -B -q dependency:go-offline                          28.7s
 => [build 5/6] COPY src ./src                                                0.1s
 => [build 6/6] RUN mvn -B -q clean package -DskipTests                       8.2s
 => [stage-1 1/6] FROM docker.io/library/eclipse-temurin:17-jre-alpine        2.1s
 => [stage-1 2/6] WORKDIR /app                                                0.0s
 => [stage-1 3/6] RUN addgroup -S app && adduser -S -G app app                0.3s
 => [stage-1 4/6] COPY --from=build /src/target/app.jar /app/app.jar          0.2s
 => [stage-1 5/6] RUN chown -R app:app /app                                   0.2s
 => [stage-1 6/6] ENTRYPOINT ["java", "-XX:MaxRAMPercentage=70", ...]         0.0s
 => exporting to image                                                        0.8s

第二次改代码再构建:

text
 => CACHED [build 1/6] FROM docker.io/library/maven:3.9-eclipse-temurin-17
 => CACHED [build 3/6] COPY pom.xml .
 => CACHED [build 4/6] RUN mvn -B -q dependency:go-offline
 => [build 5/6] COPY src ./src                                                0.1s

依赖层命中缓存,只重跑编译。

三、层的顺序就是缓存的顺序 ​

Docker 的缓存规则:某一层失效,它之后的所有层全部失效。

dockerfile
# 差:改一行代码,依赖全部重下
COPY . .
RUN mvn dependency:go-offline && mvn package

# 好:依赖和代码分开
COPY pom.xml .
RUN mvn dependency:go-offline
COPY src ./src
RUN mvn package

其他语言的等价写法:

语言先拷再拷
Nodepackage.json package-lock.jsonsrc/
Pythonrequirements.txtapp/
Gogo.mod go.sum源码
Javapom.xmlsrc/

四、.dockerignore ​

不看这个文件,COPY . 会把整个目录(包括 .git)发给 daemon——构建上下文几百 MB,光传输就几十秒。

text
# .dockerignore
.git
.gitignore
.idea
.vscode
target
build
node_modules
*.log
*.md
docker-compose*.yml
.env
bash
docker build -t app:1.0.0 . 2>&1 | head -3
text
[+] Building 12.3s (16/16) FINISHED
 => [internal] load build context                                            0.2s
 => => transferring context: 184.21kB

transferring context 从 300MB 降到 184kB,构建时间省一大半。

五、RUN 合并与清理 ​

dockerfile
# 差:三层,且 apt 缓存留在镜像里
RUN apt-get update
RUN apt-get install -y curl
RUN rm -rf /var/lib/apt/lists/*

# 好:一层,缓存不落进镜像
RUN apt-get update \
 && apt-get install -y --no-install-recommends curl \
 && rm -rf /var/lib/apt/lists/*

原因:每一层都是独立的只读层,rm 只是在新层里"标记删除",文件仍然在底层镜像里占空间。

不要 apt-get upgrade

RUN apt-get upgrade 会让镜像不可复现,且容易把基础镜像搞坏。要升级就换新的基础镜像 tag。

六、COPY vs ADD ​

指令行为
COPY只拷本地文件,行为明确
ADD额外支持 URL 下载和 tar 自动解压,行为隐式

除非要自动解压 tar,一律用 COPY。ADD 从 URL 下载不会校验、不会缓存、权限还可能不对。

dockerfile
COPY --chown=app:app config/ /app/config/
COPY --from=build /src/target/app.jar /app/app.jar

--chown 免掉一次 RUN chown(少一层)。

七、ENTRYPOINT 与 CMD ​

dockerfile
# exec 形式:推荐
ENTRYPOINT ["java", "-jar", "/app/app.jar"]
CMD ["--spring.profiles.active=prod"]
dockerfile
# shell 形式:PID 1 是 /bin/sh,信号收不到
CMD java -jar /app/app.jar

组合规则:

ENTRYPOINTCMD结果
execexecENTRYPOINT + CMD 拼接,docker run 参数覆盖 CMD
exec无只跑 ENTRYPOINT,docker run 参数追加
shell任意CMD 被忽略

验证主进程是 PID 1:

bash
docker exec app ps -o pid,ppid,args -p 1
text
PID   PPID  ARGS
    1     0  java -XX:MaxRAMPercentage=70 -jar /app/app.jar

如果是 /bin/sh -c java ...,说明用了 shell 形式。

八、HEALTHCHECK ​

dockerfile
HEALTHCHECK --interval=30s --timeout=3s --start-period=40s --retries=3 \
  CMD wget -qO- http://127.0.0.1:8080/actuator/health || exit 1
bash
docker ps --format 'table {{.Names}}\t{{.Status}}'
text
NAMES       STATUS
order-api   Up 2 minutes (healthy)

start-period=40s 是启动宽限期——Java 应用冷启动要几十秒,这段时间内失败不计入 retries。不设的话,应用还没起来就被判 unhealthy,配合编排工具会被反复重启。

镜像里没有 wget/curl 怎么办

distroless 镜像没有任何 HTTP 客户端。要么装一个(增大体积),要么改用暴露端口的简单检查:

dockerfile
HEALTHCHECK CMD ["/app/healthcheck"]     # 自己写的小二进制

或者干脆不在 Dockerfile 里做,交给 Compose/K8s 的探针配置。

九、基础镜像怎么选 ​

基础镜像大小适用
eclipse-temurin:17-jre~210MB通用,调试方便
eclipse-temurin:17-jre-alpine~85MB体积优先(注意 musl libc 兼容性)
gcr.io/distroless/java17~190MB安全优先,无 shell
eclipse-temurin:17-jdk~330MB只在构建阶段用

Alpine 的坑:基于 musl libc,某些 JNI 库、glibc 依赖的二进制跑不起来。遇到 not found 但文件明明存在,多半是这个原因——换 Debian 系基础镜像。

distroless 的坑:没有 shell、没有包管理器,docker exec sh 会失败。优点是攻击面极小(CVE 少)。

十、BuildKit 与多平台 ​

bash
# 启用 BuildKit(Docker 23+ 默认启用)
export DOCKER_BUILDKIT=1

# 多架构构建并推送
docker buildx create --name multi --use --bootstrap
docker buildx build \
  --platform linux/amd64,linux/arm64 \
  -t registry.internal:5000/app:1.0.0 --push .
text
[+] Building 186.4s (24/24) FINISHED
 => [linux/amd64 build 4/6] RUN mvn -B -q dependency:go-offline              31.2s
 => [linux/arm64 build 4/6] RUN mvn -B -q dependency:go-offline              38.7s
 => exporting to image                                                      12.1s
 => => exporting manifest list sha256:8f2c...                                0.0s

多平台构建的镜像是一个 manifest list,拉取时 Docker 自动选架构。

缓存挂载(BuildKit 特性,Maven 依赖跨构建复用):

dockerfile
RUN --mount=type=cache,target=/root/.m2 \
    mvn -B -q clean package -DskipTests

比 dependency:go-offline 更快,且不受 COPY pom.xml 层缓存影响。

十一、安全基线 ​

dockerfile
# 1. 非 root 运行
USER app

# 2. 只读根文件系统(需要写 /tmp 时单独挂 tmpfs)
# docker run --read-only --tmpfs /tmp ...

# 3. 固定基础镜像版本,不用 latest
FROM eclipse-temurin:17.0.13_11-jre-alpine

# 4. 不装多余工具(curl、vim、编译器等)
# 5. 不把密钥写进镜像

检查镜像里的用户和暴露端口:

bash
docker image inspect app:1.0.0 -f 'user={{.Config.User}} ports={{json .Config.ExposedPorts}}'
text
user=app ports={"8080/tcp":{}}

扫描漏洞:

bash
docker scout cves app:1.0.0
# 或
trivy image app:1.0.0

十二、踩过的坑 ​

现象根因处置
改一行业务代码要重下所有依赖层顺序错了先拷依赖清单文件
构建上下文传输几百 MB缺 .dockerignore加上 target、.git、node_modules
镜像 1.5GB用了 JDK 镜像 + 构建工具混在运行镜像里多阶段构建
docker stop 等 10 秒CMD shell 形式,信号收不到改 exec 形式
容器里 not found 但文件存在Alpine musl 与 glibc 二进制不兼容换 Debian 系基础镜像
非 root 用户写不了日志目录目录属主是 rootCOPY --chown 或在 Dockerfile 里 chown
HEALTHCHECK 一直 unhealthy缺 start-period加启动宽限期
多平台构建报 exec format error只 build 了 amd64,在 arm 机器上跑--platform 指定或构建 manifest list
镜像拉取慢单层太大合并层、清理缓存、用 alpine