外观
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四个问题:
openjdk:17基础镜像就 470MB,还包含 JDK(编译工具),运行只要 JRE。COPY .把target/、.git/、node_modules/全拷进去,构建上下文巨大。RUN mvn package每次都重新下载依赖,因为源码变了导致缓存失效。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其他语言的等价写法:
| 语言 | 先拷 | 再拷 |
|---|---|---|
| Node | package.json package-lock.json | src/ |
| Python | requirements.txt | app/ |
| Go | go.mod go.sum | 源码 |
| Java | pom.xml | src/ |
四、.dockerignore
不看这个文件,COPY . 会把整个目录(包括 .git)发给 daemon——构建上下文几百 MB,光传输就几十秒。
text
# .dockerignore
.git
.gitignore
.idea
.vscode
target
build
node_modules
*.log
*.md
docker-compose*.yml
.envbash
docker build -t app:1.0.0 . 2>&1 | head -3text
[+] Building 12.3s (16/16) FINISHED
=> [internal] load build context 0.2s
=> => transferring context: 184.21kBtransferring 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组合规则:
| ENTRYPOINT | CMD | 结果 |
|---|---|---|
| exec | exec | ENTRYPOINT + CMD 拼接,docker run 参数覆盖 CMD |
| exec | 无 | 只跑 ENTRYPOINT,docker run 参数追加 |
| shell | 任意 | CMD 被忽略 |
验证主进程是 PID 1:
bash
docker exec app ps -o pid,ppid,args -p 1text
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 1bash
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 用户写不了日志目录 | 目录属主是 root | COPY --chown 或在 Dockerfile 里 chown |
HEALTHCHECK 一直 unhealthy | 缺 start-period | 加启动宽限期 |
| 多平台构建报 exec format error | 只 build 了 amd64,在 arm 机器上跑 | --platform 指定或构建 manifest list |
| 镜像拉取慢 | 单层太大 | 合并层、清理缓存、用 alpine |