摘要:Dockerfile 是制造层的流水线:每条改文件系统的指令产出一层,构建缓存按指令与输入逐层匹配。本节逐条拆解常用指令的真实行为,用可复现实验建立缓存命中的直觉,并指出那些"看起来等价、实际上天差地别"的写法。
从一份最小的 Dockerfile 开始:
FROM python:3.12-slim WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY . . ENV TZ=Asia/Shanghai CMD ["python", "server.py"]
执行构建:
docker build -t demo:1 .
输出里每一行 Step 就是流水线的一道工序。关键观察是第二次构建:
docker build -t demo:1 .
这次几乎每一步都秒过,输出里满是 CACHED。现在改一下业务代码再构建——只改 server.py,不动 requirements.txt:
docker build -t demo:2 .
输出出现了分水岭:COPY requirements.txt . 之前全部 CACHED,COPY . . 开始重新执行,pip install 也在缓存里活着。这就是缓存的核心规则:缓存按层匹配,从第一条变化的指令起,其后所有层全部重建。而 COPY requirements.txt 与 COPY . . 被拆成两步,正是为了让代价高昂的 pip install 依赖一份几乎不变的小文件,从而躲过业务代码变更引发的缓存雪崩。
FROM:指定基础镜像,是第零层。选基础镜像是镜像体积与构建复杂度的第一道分水岭,slim 与 alpine 变体的差别在 2.3 节展开。
RUN:在容器里执行一条命令并把文件系统差异定格为一层。两条 RUN 各成一层:
RUN apt-get update RUN apt-get install -y curl
这个写法有个隐蔽的问题:apt 缓存层在第一条里生成,装包在第二条里用。如果第二条缓存失效重跑,而第一条还是旧缓存,装的就是旧索引里的包。更糟的是两条 RUN 各留下一份中间垃圾。合并成一条是标准做法:
RUN apt-get update && apt-get install -y curl \ && rm -rf /var/lib/apt/lists/*
同一层里装完即清理,垃圾不进层。
COPY 与 ADD:把文件复制进镜像并成层。ADD 多了自动解压远端压缩包与 URL 下载两个魔法,恰恰因为魔法不可预期,社区共识是一律用 COPY,除非你明确需要解压功能。COPY 还带缓存细节:它按文件内容校验缓存,文件内容没变就命中,哪怕时间戳变了。
ENV、WORKDIR、EXPOSE、LABEL:只写元数据,不产生文件层。它们照样参与缓存链——改了 ENV,其后的 RUN 层全部失效,因为指令内容变了。
CMD 与 ENTRYPOINT:都不产生层,都是给容器定默认入口,区别在运行时如何拼接。Docker 实际执行的命令是 ENTRYPOINT + CMD 的拼接:
ENTRYPOINT ["python", "server.py"] CMD ["--port", "8000"]
不带参数启动,进程就是 python server.py --port 8000;docker run demo --port 9000 时 CMD 被整体替换,变成 9000。这就是它们的标准分工:ENTRYPOINT 定"程序",CMD 定"默认参数"。exec 数组形式(JSON 数组)与 shell 形式的差别也有讲究:CMD python server.py 实际执行 /bin/sh -c "python server.py",你的程序成了 sh 的子进程,接收不到 SIGTERM 信号,容器要等 10 秒超时才被强杀。生产镜像一律用 exec 数组形式。
HEALTHCHECK:给容器一个自描述的健康检查,编排系统按它判断容器是否需要重启,第 6 章会用到。
把缓存规则整理成一张表:
| 变化源 | 缓存影响 |
|---|---|
| 指令文本变化 | 该层失效,其后全部失效 |
| COPY 的源文件内容变化 | 该层失效,其后全部失效 |
| 基础镜像更新(同 tag) | 全部失效 |
| 只是重新 build | 全部命中 |
由此推出缓存友好的指令排序原则:从最稳定到最不稳定。基础镜像、系统依赖、语言依赖、配置文件、业务代码,越不容易变的越靠前。反例则是把 COPY . . 放在 pip install 之前——每次改一行代码都要重装全部依赖,构建从 20 秒变 8 分钟。
⚠️ 还有个新手黑洞:.dockerignore 不写好,COPY . . 会把本地 git 历史、node_modules、日志统统打进镜像,既拖慢构建又污染镜像。构建上下文里放一个 .dockerignore:
.git node_modules *.log .env
# 第一次:全部新建 docker build -t cache-demo . # 第二次:什么都不改,全部 CACHED docker build -t cache-demo . # 第三步:touch 一个无关紧要的文件再构建 echo "# note" >> README.md && docker build -t cache-demo .
第三次构建里,COPY . . 之前的层命中,从 COPY . . 起重建。对照输出里的 CACHED 标记与 2.1 节讲的"指令产层",你会看到缓存其实就是"层能不能复用"这个问题的答案。
层造出来了,接下来解决它怎么高效分发、怎么减肥。