Docker 与容器 列表全部文章
Docker 与容器
Docker 与容器第 5 / 5 篇

Docker 笔记(一)镜像篇:分层原理、Dockerfile 写法与镜像瘦身

镜像不是单个文件,是一叠只读层。这篇讲清联合文件系统与写时复制、Dockerfile 每条指令分别产生什么代价、构建缓存怎么命中(为什么改一行代码要重装依赖)、多阶段构建怎么把 1.2G 瘦到 120M,以及基础镜像怎么选。

更多

镜像看起来是一个文件,实际上是一叠只读层。理解这件事,才能解释三个高频疑问:为什么改一行代码要重装一遍依赖、为什么在 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 -csh 不转发信号,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 / full600MB-1GB+glibc什么都有,调试方便体积大、CVE 多
-slim(Debian slim)约 180-250MBglibc兼容性好,体积可控仍带包管理器和 shell
alpine约 5-80MBmusl极小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。

八、"改了代码镜像纹丝不动"的三种死法

这是最常遇到的困惑,按概率排序:

  1. 构建缓存命中了旧层:改的文件在 COPY . . 里但校验和没变?不可能——通常是改的文件根本没进构建上下文,看第 2 条。
  2. 文件被 .dockerignore 排除了:改的那个文件压根没传给 Docker daemon。
  3. 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 是两条最低成本的收益。
— 感谢读到这里 —发现错误,或想补充?告诉我 →
TURN KNOWLEDGE INTO SOMETHING REAL

有想法,也想把它做出来?

从技术方案到上线交付,聊聊你的下一个项目。

聊聊需求

一起聊聊

搜索文章

输入关键词,按标题和摘要即时筛选。

浏览分类文章归档

阅读设置

字号
行宽
专注模式

专注模式会隐藏页头与侧栏,只留正文。设置保存在当前浏览器。

稍后阅读

保存在当前浏览器,留给下一次安静阅读。

总浏览 -- 次 · 总访客 -- 人 · 今日浏览 -- 次 · 今日访客 -- 人