知序
返回
保存草稿
发布
H1
H2
H3
1
2
3
Excalidraw 图
AI 写作
AI 优化
目录
输入内容后显示目录
Markdown
# 01 - 镜像与分层:Dockerfile 从写到跑 > 这篇解决什么问题:网上讲 Dockerfile 的文章大多在罗列"有哪些指令",看完还是不知道自己的 Spring Boot 项目该怎么写。 > 本文不讲手册,而是盯着一个真实的 Spring Boot + Maven 项目,把 Dockerfile 从"能跑"改到"能上生产":镜像从 900MB 瘦到 180MB,构建从每次都重下依赖变成秒级命中缓存。 > 读完你会明白:镜像为什么是分层的、缓存为什么会失效,以及体积到底该怎么减。 --- ## 1. 先看一个能跑但不合格的第一版 假设项目就是一个最普通的 Spring Boot 应用:  *图:Spring Boot 项目结构,以及 Dockerfile 与 .dockerignore 该放的位置* ```text blog-app/ ├── pom.xml ├── src/ │ ├── main/ │ │ ├── java/com/example/blog/BlogApplication.java │ │ └── resources/application.yml │ └── test/java/com/example/blog/... └── target/ └── blog-app-1.0.0.jar ← mvn package 的产物,约 40MB ``` 照着教程抄一份最常见的 Dockerfile: ```dockerfile FROM openjdk:8 WORKDIR /app COPY . . RUN mvn package -DskipTests EXPOSE 8080 CMD ["java", "-jar", "target/blog-app-1.0.0.jar"] ``` `docker build` 能成功,`docker run` 也能起来,但放到生产视角看有四个硬伤: | 问题 | 现象 | 为什么是问题 | |---|---|---| | 镜像体积大 | `docker images` 显示 900MB+ | 拉镜像慢、占仓库空间、发布等待长 | | 每次都重下依赖 | 改一行代码,构建又要好几分钟 | 依赖下载时间被重复计入每次构建 | | 用了 JDK 而非 JRE | 基础镜像自带编译器、`javac`、调试工具 | 生产根本不需要,白多上百 MB | | 以 root 运行 | 容器内 `whoami` 是 root | 容器被突破,权限直接到宿主 root | 还有一处隐藏的坑:`openjdk:8` 这个镜像里**根本没有 Maven**,真要跑通还得在 `RUN` 里先装一遍,体积和时间会进一步膨胀。这恰好说明第一版是照着"怎么让它跑起来"写的,而不是照着"怎么让它长期稳定地跑"写的。下面逐个解决,先从最根本的"为什么它这么大"说起。 > 一句话:**能 `docker build` 成功不等于是一份合格的 Dockerfile;判断标准是体积、构建速度、权限和可维护性。** --- ## 2. 镜像 = 一组只读层 Docker 默认用 **overlay2** 存储驱动(一套 UnionFS 实现),镜像不是一个大文件,而是一叠**只读层**:  *图:镜像分层与容器可写层:只读层可复用共享,最上面的可写层随容器消失* ```text ┌───────────────────────────────────────┐ │ 容器可写层(Container Layer) │ ← 运行时新增,可读可写,可丢 ├───────────────────────────────────────┤ │ 应用层:COPY target/*.jar │ ← 40MB,代码一改就变 ├───────────────────────────────────────┤ │ 依赖层:RUN mvn dependency:go-offline │ ← 200MB+,pom 不变就一直复用 ├───────────────────────────────────────┤ │ 基础层:FROM eclipse-temurin:17-jre │ ← 250MB,跟着基础镜像走 └───────────────────────────────────────┘ ``` Dockerfile 里**每一条会改动文件系统的指令(`COPY` / `ADD` / `RUN` / `WORKDIR`)都会生成一层**。想知道每层多大,直接用 `docker history`: ```text $ docker history blog-app:1.0.0 IMAGE CREATED CREATED BY SIZE 6f1b2c9a8d3e 2 minutes ago CMD ["java" "-jar" "target/blog-app-1.0.0.… 0B <missing> 2 minutes ago RUN /bin/sh -c mvn package -DskipTests … 512MB <missing> 2 minutes ago COPY . . # buildkit 9.4MB <missing> 2 minutes ago WORKDIR /app 0B <missing> 3 years ago /bin/sh -c set -eux; … 366MB ``` (`<missing>` 表示这些是匿名中间层,没有独立镜像 ID,属正常现象。)从这张表能读出三个决定性的事实: - **大小在层里**:`mvn package` 那层 512MB 是体积元凶,基础镜像层 366MB 排第二。 - **层是共享的**:同一台机器上两个基于 `eclipse-temurin:17-jre` 的镜像,基础层只落盘一份。 - **层是缓存单位**:改某一行,只有它及其**后面**的层需要重建,前面的层直接复用。这就是全部优化的钥匙。 > 一句话:**镜像是只读层的堆叠,每层都不大,但叠起来很重;优化的本质就是"让不该变的层别变"。** --- ## 3. 构建缓存:为什么改了代码就要重装依赖 Docker 逐行执行 Dockerfile,**每层拿"上一层结果 + 当前指令"去算缓存键**,键没变就复用旧层;而**某一行失效,它之后的所有层都跟着失效**。对照第一版的 `COPY . .`:  *图:构建缓存的两条路径对比:一次性 COPY . . 会让依赖层一起失效,拆开写则只重跑打包* ```text 【路径 A:一次性 COPY . .(第一版写法)】 第 1 次构建 第 2 次构建(只改一行 Java 代码) ┌──────────────────────┐ ┌──────────────────────┐ │ COPY . . 命中 │ │ COPY . . 失效 │ ← 源码变了 ├──────────────────────┤ ├──────────────────────┤ │ RUN mvn package 命中 │ │ RUN mvn package 失效 │ ├──────────────────────┤ ├──────────────────────┤ │ 耗时 4 分钟 │ │ 耗时 4 分钟(依赖重下) │ └──────────────────────┘ └──────────────────────┘ 【路径 B:先依赖后源码(优化写法)】 第 1 次构建 第 2 次构建(只改一行 Java 代码) ┌──────────────────────┐ ┌──────────────────────┐ │ COPY pom.xml 命中 │ │ COPY pom.xml 命中 │ ← pom 没变 ├──────────────────────┤ ├──────────────────────┤ │ RUN go-offline 命中 │ │ RUN go-offline 命中 │ ← 依赖层复用 ├──────────────────────┤ ├──────────────────────┤ │ COPY src 命中 │ │ COPY src 失效 │ ← 只重建源码层 ├──────────────────────┤ ├──────────────────────┤ │ RUN mvn package 命中 │ │ RUN mvn package 失效 │ ← 只重编译,秒级 ├──────────────────────┤ ├──────────────────────┤ │ 耗时 4 分钟 │ │ 耗时 20 秒 │ └──────────────────────┘ └──────────────────────┘ ``` 差别只在**顺序**:把"不常变的依赖描述文件"和"常变的源码"拆开。优化后的写法: ```dockerfile FROM maven:3.9-eclipse-temurin-17 WORKDIR /build # 先只拷 pom,把依赖一次性拉全(只要 pom 没变,这层永远命中) COPY pom.xml . RUN mvn dependency:go-offline -B # 再拷源码,最后才打包 COPY src ./src RUN mvn package -DskipTests -B ``` `-B` 是 batch mode,避免进度条刷屏、让 CI 日志可读;`dependency:go-offline` 会预下载所有依赖,之后 `package` 就不再联网。 ### 3.1 别把垃圾一起送进构建上下文 另一个隐性开销是**构建上下文**:`docker build ... .` 结尾那个 `.`,会把当前目录**整个**打包发给守护进程。项目里已有 `target/`、`.git/`、`.idea/` 时,几百 MB 会被白白传输,还会破坏缓存。用 `.dockerignore` 排除掉: ```text .git .gitignore target *.log .idea *.iml node_modules ``` > 一句话:**缓存失效是连锁反应——让稳定的东西排前面、易变的东西排后面,构建速度能差十倍。** --- ## 4. 多阶段构建 缓存解决了"快",体积还得单独解决。第一版镜像里同时塞进了 Maven、JDK、源码和本地仓库缓存,而这些运行时一个都不需要。**多阶段构建**就是用一个"构建镜像"干活,再用一个"运行镜像"只装产物:  *图:单阶段与多阶段构建对比:构建期的 JDK、Maven、源码都不进最终镜像,体积从 900MB+ 降到约 250MB* ```text 【单阶段】 【多阶段】 ┌────────────────────────┐ ┌────────────────────────┐ │ maven:3.9-eclipse-temurin│ │ builder 阶段 │ │ ├─ Maven + JDK │ │ maven:3.9-eclipse-temurin│ │ ├─ ~/.m2 本地仓库 │ │ ├─ Maven + JDK │ │ ├─ 源码 src/ │ │ ├─ 依赖缓存 │ │ ├─ target/ │ │ └─ 产出 app.jar │ │ └─ app.jar │ └───────────┬────────────┘ └────────────────────────┘ │ COPY --from=builder 约 900MB v ┌────────────────────────┐ │ runtime 阶段 │ │ eclipse-temurin:17-jre │ │ └─ app.jar │ └────────────────────────┘ 约 250MB ``` 完整的生产级 Dockerfile: ```dockerfile # ── 阶段一:构建 ─────────────────────────────────── FROM maven:3.9-eclipse-temurin-17 AS builder WORKDIR /build COPY pom.xml . RUN mvn dependency:go-offline -B COPY src ./src RUN mvn package -DskipTests -B # ── 阶段二:运行 ─────────────────────────────────── FROM eclipse-temurin:17-jre-jammy WORKDIR /app RUN groupadd -r app && useradd -r -g app app # 建普通用户,别用 root 跑业务 COPY --from=builder /build/target/blog-app-1.0.0.jar app.jar RUN chown -R app:app /app USER app EXPOSE 8080 ENTRYPOINT ["java", "-jar", "/app/app.jar"] ``` `COPY --from=builder` 是两个阶段唯一的通道——**除这个 jar,构建阶段的一切都不会进入最终镜像**。瘦身是分档实现的,每一档来源明确: | 阶段 | 体积 | 靠什么瘦下来 | |---|---|---| | 单阶段 `openjdk:8` + Maven + 源码 | 约 900MB | 起点 | | 多阶段(builder + jre-jammy) | 约 250MB | 构建工具、源码、`.m2` 缓存留在 builder 阶段 | | 换 JRE 基础镜像 + 精简依赖 | 约 180MB | `FROM` 用 `-jre` 而非 JDK,业务侧去掉多余依赖 | | 再上 distroless | 约 150MB | 去掉 shell、包管理器等组件(代价见第 5 节) | 体积不是越小越好,每一档都要问一句"省下的这几十 MB 值不值得多付这些维护成本"。250MB 到 180MB 这档性价比最高,再往下压收益递减、排查难度明显上升。 > 一句话:**多阶段构建让"构建环境"和"运行环境"彻底分家,最终镜像里只留一个 jar 和一个 JRE。** --- ## 5. 基础镜像怎么选 `FROM` 决定了镜像的下限(体积)和上限(可维护性)。常见选项: | 基础镜像 | 体积量级 | 优点 | 代价 / 坑 | |---|---|---|---| | `openjdk:8` | 500MB+ | 网上教程最多 | **已停止维护,新项目别用**;不带 Maven | | `eclipse-temurin:17-jre-jammy` | 约 250MB | Ubuntu 系,glibc 完整,兼容性最好 | 体积中等,含少量非必需组件 | | `eclipse-temurin:17-jre-alpine` | 约 180MB | 体积小 | 用 **musl libc**,与 glibc 行为有差异 | | `gcr.io/distroless/java17` | 约 150MB | 无 shell,攻击面最小 | **没有 shell,`docker exec` 进不去**,排障难 | | `scratch` + 静态编译 | 几 MB | 极小 | 只适合 Go/Rust 等静态语言 | alpine 体积小是真的,但它不用 glibc 而用 **musl libc**,会带来一串只有 Java 应用才遇到的诡异问题: ```text 1. JVM 时区 / 字符集数据可能缺失 → 中文乱码、时间错乱 2. 依赖 glibc 的原生库(libc.so.6)→ UnsatisfiedLinkError 3. DNS 解析行为不同 → 偶发 UnknownHostException 4. 某些 APM / 监控 agent 无法加载 → javaagent 挂不上 5. 线程栈默认更小 → 高并发下偶发 StackOverflowError ``` 这些问题的共同点是:**本地测试全好,线上偶发,且极难复现**。对以网络和并发为主的 Spring Boot 应用,稳定性远比那 70MB 重要。 > 一句话:**Java 应用优先选 `-jammy`(Debian/Ubuntu 系)的 temurin JRE;不上 alpine、不追极致体积,除非你很清楚自己在换什么。** --- ## 6. Dockerfile 指令速查 | 指令 | 作用 | 注意点 | |---|---|---| | `FROM` | 指定基础镜像,构建起点 | 必须第一条(`ARG` 除外);决定体积下限 | | `RUN` | 构建时执行命令,产生一层 | 用 `&&` 合并减少层数;结尾清理临时文件 | | `COPY` | 从构建上下文复制文件进镜像 | 优先用它,语义明确;支持 `--from=` 跨阶段复制 | | `ADD` | 复制 + 自动解压 tar + 支持 URL | **行为隐式、容易被误解,非必要不用**,老实用 `COPY` | | `ENV` | 环境变量,**镜像内持久** | 运行期可见、会写进镜像;别拿它存密钥 | | `ARG` | 构建期变量,**构建完消失** | 只用于构建参数;**密码别放 `ARG`,会留在层历史里** | | `WORKDIR` | 设置工作目录 | 不存在会自动创建;尽量用绝对路径 | | `EXPOSE` | 声明容器监听端口 | **只是文档,不真开端口**;对外暴露靠 `docker run -p` | | `USER` | 指定后续指令与运行时用户 | 生产必写,不写就是 root | | `VOLUME` | 声明匿名数据卷挂载点 | 声明后该目录写入不进镜像层;持久化更常用 `-v` | | `HEALTHCHECK` | 定义健康检查命令 | 单机有用,进 K8s 后由探针接管 | | `ENTRYPOINT` | 容器启动的固定入口 | 推荐 exec 形式,能正确接收信号 | | `CMD` | 默认参数 / 默认命令 | 与 `ENTRYPOINT` 配合时提供默认参数,可被覆盖 | 三个最容易搞混的点: - **`ADD` vs `COPY`**:`ADD` 会自动解压 tar、能下载 URL,但隐式行为多,别人无法一眼看懂。**显式优于隐式,用 `COPY`。** - **`ENV` vs `ARG`**:`ENV` 运行期可见,`ARG` 构建完就没了;但 `ARG` 的值会留在层历史里,`docker history` 能看到,**所以密码、token 绝不能放 `ARG`**,要用 BuildKit secret。 - **`ENTRYPOINT` vs `CMD`**:前者定义"这容器是干什么的",后者给默认参数。都要用 exec 数组形式——shell 形式下 `java` 会变成子进程,收不到 `SIGTERM`,`docker stop` 要等超时才杀掉(第 02 篇详述)。 --- ## 7. 构建与验证命令 ```bash # ── 构建 ─────────────────────────────────────────────── docker build -t blog-app:1.0.0 . # 常规构建 docker build --no-cache -t blog-app:1.0.0 . # 强制不用缓存,验证"全新构建"结果 docker build --progress=plain -t blog-app:1.0.0 . # 输出每步耗时,定位慢在哪层 # ── 运行 ─────────────────────────────────────────────── docker run -d -p 8080:8080 --name blog-app blog-app:1.0.0 docker logs -f blog-app # 确认 Spring Boot 启动完成 # ── 验证体积与层数 ────────────────────────────────────── docker images # 看最终 SIZE docker history blog-app:1.0.0 # 看每层大小,找出体积大头 docker inspect blog-app:1.0.0 | grep -A2 RootFS # 看层数(Layers 数组长度) ``` `docker images` 的期望输出(对比第 1 节的 900MB): ```text REPOSITORY TAG IMAGE ID CREATED SIZE blog-app 1.0.0 c8d9e0f1a2b3 30 seconds ago 253MB eclipse-temurin 17-jre-jammy 4a3b2c1d0e9f 2 weeks ago 253MB maven 3.9-eclipse-temurin-17 8b7c6d5e4f3a 3 weeks ago 780MB ``` `maven` 那个 780MB 只存在于构建机,不跟着应用发布走——`docker history blog-app:1.0.0` 里应该看不到 Maven、源码和 `.m2` 缓存,这正是多阶段构建的价值。 --- ## 8. 踩过的坑 ```text 1. 改一行代码构建又要重下依赖 → COPY . . 让依赖层一起失效 → 拆成「COPY pom.xml → go-offline」和「COPY src → package」 2. 构建上下文几百 MB 卡在第一步 → 没有 .dockerignore,target/ 和 .git/ 被一起发送 → 加 .dockerignore 排除它们 3. 镜像体积怎么都降不下来 → 复制了整个 target/,或基础镜像本身就大 → COPY 只带最终 jar,用 docker history 逐层找大头 4. alpine 后线上偶发 UnknownHost / 乱码 → musl libc 与 glibc 在 DNS、locale 上行为不同 → Java 应用换 jammy/temurin 系 5. 密码写进了 Dockerfile → 用 ENV/ARG 传敏感值,都会写进镜像元数据 → 运行时环境变量或 K8s Secret 注入,构建期用 BuildKit secret ``` --- ## 9. 本篇小结  *图:本篇链路回顾:Dockerfile 逐行执行生成只读镜像,运行时在只读层上叠加可写层* ```text Dockerfile │ 逐行执行,每条 COPY/RUN/ADD 生成一层 v ┌─────────────────────────────────────────────┐ │ 构建缓存:稳定的排前面,易变的排后面 │ │ (COPY pom.xml → go-offline → COPY src) │ └─────────────────────────────────────────────┘ │ 多阶段:builder 干活,runtime 只收产物 v ┌─────────────────────────────────────────────┐ │ builder:maven + JDK + 源码 + 依赖缓存 │ │ │ COPY --from=builder app.jar │ │ v │ │ runtime:jre-jammy + app.jar + 非 root 用户 │ └─────────────────────────────────────────────┘ │ docker build -t blog-app:1.0.0 . v 最终镜像:约 250MB,只有 jar + JRE + 一个普通用户 │ v 它能不能稳定跑起来、端口和数据怎么处理 ──→ 第 02 篇 ``` 下一篇:[02 - 容器运行时:入口、网络、数据与优雅退出]
预览
发布文章
添加摘要让读者更快了解文章内容
取消
确认发布
AI 写作助手
输入一个主题,AI 将为你生成一篇 Markdown 博客文章
AI 正在写作中...
取消
开始生成
AI 优化助手
描述你的修改需求,AI 将在原文基础上进行补充或微调
AI 正在优化中...
取消
开始优化
画布
从已有图里选
✕
图名
保存后生成 /images/excalidraw/<图名>.svg(只能用字母、数字、短横线、下划线、点)
取消
保存并插入