镜像看起来是一个文件,实际上是一叠只读层。理解这件事,才能解释三个高频疑问:为什么改一行代码要重装一遍依赖、为什么在 Dockerfile 里 rm 掉大文件镜像反而没变小、为什么多阶段构建能把 1.2G 瘦到 120M。这篇把镜像的结构、Dockerfile 每条指令的代价、构建缓存的命中规则与瘦身手段一次讲透。
一、镜像不是文件,是一叠只读层
Docker 用联合文件系统(主流是 overlay2)把多层目录叠成一个视图:下面的层全是只读的,容器启动时在最上面加一层可写的容器层。
# 看一个镜像到底有几层、每层多大
docker history --human --format "{{.CreatedBy}}: {{.Size}}" myapp:1.0
# 看某层的实际内容在哪(lowerdir 是只读层,upperdir 是可写层)
docker inspect myapp:1.0 --format '{{.GraphDriver.Data}}'叠出来的效果是这样的:
容器层(可写,Container layer) ← 你的所有修改都发生在这里
─────────────────────────────
Layer 5: COPY app.jar
Layer 4: RUN apt-get install -y curl
Layer 3: COPY pom.xml
Layer 2: RUN apk add --no-cache tzdata
Layer 1: FROM eclipse-temurin:21-jre ← 基础镜像的所有层
─────────────────────────────
(全部只读)写时复制(Copy-on-Write)是这里的关键:容器要修改下层某个文件时,Docker 先把那个文件从只读层复制到容器层,再改。所以:
- 多个容器共享同一个镜像的只读层,不占额外磁盘;
- 容器里改文件很快,但第一次改大文件会有一份拷贝开销;
- 容器删掉,容器层就没了——没挂 volume 的数据一律蒸发。
二、Dockerfile 每条指令分别产生什么
不是每条指令都会产生一层。RUN、COPY、ADD 会新增层;ENV、WORKDIR、EXPOSE、LABEL、USER 只改镜像元数据,产生空层(几乎不占空间,但也算一层)。
| 指令 | 要点 | 容易踩的坑 |
|---|---|---|
| FROM | 指定基础镜像,必须是第一条(ARG 除外) | 用 latest 会导致今天和明天构建出不同的东西 |
| RUN | 构建期执行命令,产生新层 | 每条 RUN 一层,拆太碎会让镜像变胖 |
| COPY | 复制宿主机文件进镜像 | 路径末尾带 / 与否行为不同 |
| ADD | 同 COPY,额外支持 URL 和自动解压 tar | 自动解压和远程下载都是隐式行为,一律用 COPY |
| ENV | 设置环境变量,容器运行时仍在 | 把密码写进 ENV 会留在 docker inspect 里 |
| ARG | 构建期变量,构建完不保留 | 但它会留在 docker history 里,别用它传密钥 |
| WORKDIR | 设置工作目录,不存在会自动创建 | 相对路径是相对上一个 WORKDIR,不是当前目录 |
| EXPOSE | 纯声明,写给人看和给工具读 | 它不映射端口,真实映射靠 docker run -p |
| USER | 指定运行用户 | 不写就是 root,生产环境是个隐患 |
| ENTRYPOINT / CMD | 启动命令与默认参数 | 见下节,shell 格式会吃掉信号 |
三、ENTRYPOINT 与 CMD:用错会收不到停止信号
两者的关系:ENTRYPOINT 是"要执行的程序",CMD 是"默认参数"。都写时,CMD 的内容会作为参数追加到 ENTRYPOINT 后面。
ENTRYPOINT ["java", "-jar", "/app/app.jar"]
CMD ["--spring.profiles.active=prod"]
# 实际执行:java -jar /app/app.jar --spring.profiles.active=prod
# docker run 时跟的参数会覆盖 CMD:
# docker run myapp --debug → java -jar /app/app.jar --debug更要紧的是两种书写格式的区别:
| 格式 | 写法 | PID 1 是谁 | 信号 |
|---|---|---|---|
| exec 格式(推荐) | ENTRYPOINT ["java","-jar","app.jar"] | 就是 java 进程 | 能收到 SIGTERM,可优雅停机 |
| shell 格式 | ENTRYPOINT java -jar app.jar | /bin/sh -c | sh 不转发信号,docker stop 只能等到超时被 SIGKILL |
结论:一律用 JSON 数组的 exec 格式。想用环境变量、管道、&& 时用 shell 格式,但要清楚代价:容器停止时会硬杀,正在处理的请求会丢。
四、构建缓存:为什么改一行代码要重装依赖
Docker 逐层比对缓存,命中就不重新执行。判定规则:
- 指令文本完全一样,且父层也命中;
COPY/ADD还要比对文件内容的校验和(文件变了就失效);- 一旦某层失效,它后面所有层全部重跑。
最常见的错误写法:
# 反面教材:先全量拷贝,再装依赖
COPY . .
RUN npm install # 改任何一行代码,这一步都要重跑正确写法是先拷依赖清单,装完依赖,再拷源码:
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY . . # 只有源码变了才从这里开始重建Maven/Gradle 同理,先只拷 pom.xml(或 build.gradle)把依赖层固定下来。开启 BuildKit 后还能用缓存挂载,避免每次都走网络:
DOCKER_BUILDKIT=1 docker build -t myapp:1.0 .# syntax=docker/dockerfile:1
RUN --mount=type=cache,target=/root/.m2 mvn -B package -DskipTests五、多阶段构建:1.2G 瘦到 120M
编译需要 JDK、Maven、gcc、node_modules;运行只需要一个 jar。多阶段构建就是把两者分开,只把产物拷进最终镜像。
# ---------- 阶段一:构建 ----------
FROM maven:3.9-eclipse-temurin-21 AS builder
WORKDIR /build
COPY pom.xml .
RUN mvn -B -q dependency:go-offline # 依赖单独一层,利用缓存
COPY src ./src
RUN mvn -B -q package -DskipTests
# ---------- 阶段二:运行 ----------
FROM eclipse-temurin:21-jre-jammy
WORKDIR /app
COPY --from=builder /build/target/app.jar app.jar
RUN useradd -u 10001 -r -s /usr/sbin/nologin appuser \
&& chown -R 10001:10001 /app
USER 10001
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "/app/app.jar"]效果:构建镜像里那些 JDK、Maven 仓库、源码、测试依赖,一行代码都没进最终镜像。体积和攻击面一起降下来。
六、基础镜像怎么选
| 基础镜像 | 典型体积 | C 库 | 优点 | 代价 |
|---|---|---|---|---|
:latest / full | 600MB-1GB+ | glibc | 什么都有,调试方便 | 体积大、CVE 多 |
-slim(Debian slim) | 约 180-250MB | glibc | 兼容性好,体积可控 | 仍带包管理器和 shell |
alpine | 约 5-80MB | musl | 极小 | musl 与 glibc 行为差异(DNS 解析、时区、某些 JNI) |
distroless | 约 20-120MB | 视变体 | 没有 shell、没有包管理器,攻击面最小 | 没法 docker exec sh 进去排查 |
我的取舍:Java 服务用 eclipse-temurin:21-jre-jammy 或 -slim,要极简再上 distroless;alpine 上的 Java 要特别小心,musl 的内存分配和 DNS 行为跟 glibc 不一样,出问题很难查。
七、镜像瘦身清单
- 合并 RUN 并在同一层清理缓存:
apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/*。分两条写的话,删掉的缓存还在前一层里躺着,镜像不会变小。 - 必须有 .dockerignore:排除
.git、target、node_modules、*.log。否则构建上下文几百兆,COPY 还会把本地产物带进镜像。 - 别在镜像里装调试工具(vim、curl、telnet)。真要调试,用多阶段给个
-debug变体,或者临时docker exec里装。 - 用 dive 看每一层到底加了什么:一眼看出哪层塞了 200M。
- 固定 tag:
eclipse-temurin:21.0.5_11-jre-jammy,不要latest。
八、"改了代码镜像纹丝不动"的三种死法
这是最常遇到的困惑,按概率排序:
- 构建缓存命中了旧层:改的文件在
COPY . .里但校验和没变?不可能——通常是改的文件根本没进构建上下文,看第 2 条。 - 文件被 .dockerignore 排除了:改的那个文件压根没传给 Docker daemon。
- COPY 的是本地构建产物(比如
target/app.jar),但你没重新mvn package,拷进去的还是旧的。真正的解法是让构建在镜像里完成(多阶段)。
另外,本地已有同 tag 镜像时 docker run 不会去拉新的,docker pull 一下再跑。相关的排查过程我单独写了一篇:挂载目录 Permission denied 及两个"难兄难弟":时区差 8 小时、改了代码镜像纹丝不动。
九、命令清单
docker build -t myapp:1.0 . # 构建
docker build --no-cache -t myapp:1.0 . # 跳过缓存
docker build --build-arg VERSION=1.0 . # 传构建参数
docker images # 列出本地镜像
docker history myapp:1.0 # 看分层
docker inspect myapp:1.0 # 看完整元数据
docker save myapp:1.0 | gzip > myapp.tar.gz # 导出(离线环境)
docker load < myapp.tar.gz # 导入
docker tag myapp:1.0 registry.example.com/team/myapp:1.0
docker push registry.example.com/team/myapp:1.0
docker image prune -a # 清理未被使用的镜像(危险,先确认)
docker system df # 看 Docker 占了多少磁盘备忘
- 镜像 = 一叠只读层 + 一层可写容器层,改文件靠写时复制;容器一删,容器层就没了。
- 基础镜像别用
latest,Java 优先 jre-slim,别轻易上 alpine。 - COPY 分两步:先依赖清单装依赖,再拷源码,缓存才命中得了。
- RUN 用
&&串起来,并在同一层清掉包管理器缓存。 - ENTRYPOINT / CMD 一律写 JSON 数组(exec 格式),否则收不到 SIGTERM。
- 瘦身靠多阶段构建,不靠在后面几层里
rm。 - .dockerignore 和固定 tag 是两条最低成本的收益。
一起聊聊