分层缓存机制:理解Docker镜像层,利用缓存加速构建
讲到底层去了。Docker 镜像为什么能加速?说白了就靠分层缓存。很多新手上来就写 Dockerfile,一个 RUN 到底,构建一次跑半小时。
缓存用得好的话,一个改动只要几秒就完事。我当年第一个生产项目,构建一次得 20 分钟,后来优化到 2 分钟。靠的就是这招。
从分层到缓存,到底在说什么?
Docker 镜像啊,就是一摞只读层的堆叠。每一层对应 Dockerfile 里的一条指令。每层都是一个独立文件系统快照。
关键点在这里:每个层都有一个唯一的哈希。如果指令和指令上下文(比如 COPY 的文件内容)都没变,那这个层的哈希就不会变。
Docker 构建的时候会逐个检查:当前层的哈希和缓存里的是不是一样?一样就直接用缓存,跳过执行。这就是缓存命中。
一句话结论:层不变 → 哈希不变 → 缓存命中 → 秒级构建。
缓存机制到底怎么走的?
我建议你运行一次 docker build,看看日志就明白了:
$ docker build -t myapp:v1 .
Step 1/5 : FROM ubuntu:22.04
---> a1b2c3d4e5f6
Step 2/5 : RUN apt-get update && apt-get install -y python3
---> Using cache
---> b2c3d4e5f6a7
Step 3/5 : COPY requirements.txt /app/
---> Using cache
---> c3d4e5f6a7b8
Step 4/5 : RUN pip install -r /app/requirements.txt
---> Using cache
---> d4e5f6a7b8c9
Step 5/5 : COPY src/ /app/
---> e5f6a7b8c9d0
Successfully built e5f6a7b8c9d0
Successfully tagged myapp:v1
看到了吧?
- Using cache 表示这一层从缓存里拉出来了。
- 最后那层 COPY src/ 是新构建的,因为源文件变了。
- 前三层没动过,所以全部命中缓存,一秒搞定。
如果我后来改了 src 里的一个文件,下次构建会怎样?
第三步 COPY requirements.txt 没变,继续命中。但第四步 COPY src/ 变了,哈希不同,从这一层开始,后面全部重新走。
实用小技巧:跑 docker build --no-cache 可以强制跳过所有缓存,全部重新构建。这在排查缓存导致的 bug 时非常有用。
各指令的缓存行为
不同的指令,缓存判定规则不一样。我整理了一个表格,你平时写 Dockerfile 时可以对着看:
| 指令 | 缓存判据 | 缓存失效条件 | 典型示例 |
|---|---|---|---|
FROM |
镜像名 + 标签 | 换了基础镜像版本 | FROM node:18 → FROM node:20 |
RUN xxx |
命令字符串 | 命令内容变了(注意空格!) | RUN apt-get update |
COPY . /app |
文件内容哈希 | 任何被复制的文件改了 | COPY requirements.txt /app |
ADD |
文件内容 + URL | 同上,URL 变了也算 | ADD https://example.com/data.tar.gz /tmp |
RUN pip install |
命令字符串 | requirements.txt 改了(但 Docker 不会自动关联文件!) | RUN pip install -r requirements.txt |
ENV, WORKDIR |
参数值 | 参数变了 | ENV NODE_ENV=production |
CMD, ENTRYPOINT |
字符串或列表 | 内容变了 | CMD ["node","app.js"] |
优化实践:把不变放前面,易变放后面
避坑指南来了。我曾经接手一个项目,Dockerfile 长这样:
FROM python:3.11-slim
COPY . /app
WORKDIR /app
RUN pip install -r requirements.txt
每次改一行代码,整个 pip install 都得重跑。因为 COPY . /app 把整个源码目录都复制进去了,任何文件变了,这一层缓存就炸了。
更可怕的是,pip install 放在 COPY 后面,等于每次都要重新下载几十个包。构建一次少说 5 分钟,多则 20 分钟。
核心原则(八字口诀):不变放前,易变放后。
改造一下就好了。把不常变的东西(系统依赖、Python 依赖)先装好,再把经常变的代码放到最后:
FROM python:3.11-slim
# 第一步:系统依赖(几乎不变)
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc \
libpq-dev \
&& rm -rf /var/lib/apt/lists/*
# 第二步:Python 依赖(requirements.txt 变了才重装)
COPY requirements.txt /app/
RUN pip install --no-cache-dir -r /app/requirements.txt
# 第三步:应用代码(最常变,放最后)
COPY src/ /app/
WORKDIR /app
CMD ["python","app.py"]
改了代码后,只重新构建最后两步。前三层全部命中缓存,构建时间从 5 分钟降到 15 秒。
实战技巧:按需复制,不要一股脑全丢进去
很多人习惯写 COPY . /app,然后被缓存搞的怀疑人生。千万别这么干。
你想想看,COPY . 会复制 Dockerfile 所在目录下的所有东西,包括 node_modules、.git、__pycache__、日志文件等乱七八糟的。这些文件一变动,缓存就失效了。
正确做法:使用 .dockerignore 文件来排除不必要的东西,同时用精确的 COPY 指令按需复制。
# .dockerignore
node_modules/
.git/
__pycache__/
*.log
.env
.gitignore
Dockerfile
同时,把依赖声明文件(requirements.txt、package.json、yarn.lock)单独复制一次:
# 先只复制依赖声明文件
COPY package.json yarn.lock /app/
# 运行安装
RUN yarn install --frozen-lockfile
# 最后复制源代码
COPY src/ /app/src/
这样可以确保:只要 package.json 和 yarn.lock 没变,就算改了源代码,依赖安装那层也不会重新构建。
再说一个我踩过的坑:换行符不一样也会导致缓存失效。我在 Linux 上构建的镜像一切正常,同事用 Windows 改了 requirements.txt 后提交,换行符从 LF 变成了 CRLF,结果 Docker 认为文件改了,pip install 那层缓存就全废了。解决方案?在项目中统一用 .gitattributes 强制 LF 换行,确保跨平台一致性。
怎么看镜像层和统计缓存
Docker 提供了 docker history 命令,可以查看镜像每一层的大小和构建命令:
$ docker history myapp:v1
IMAGE CREATED CREATED BY SIZE
e5f6a7b8c9d0 2 hours ago CMD ["python","app.py"] 0B
d4e5f6a7b8c9 2 hours ago COPY src/ /app/ 1.2MB
c3d4e5f6a7b8 2 hours ago RUN pip install ... 245MB
b2c3d4e5f6a7 2 hours ago COPY requirements.txt /app/ 1.5kB
a1b2c3d4e5f6 2 hours ago RUN apt-get install ... 380MB
<missing> 1 week ago FROM python:3.11-slim 150MB
从这个输出可以看出:
- FROM 层 150MB:基础镜像
- 系统依赖层 380MB:安装了 gcc、libpq-dev 等
- Python 依赖层 245MB:pip install 的结果
- 代码层 1.2MB:源代码(最小,变化最频繁)
这个顺序刚好对应了“不变放前,易变放后”的原则。最下面三层(从下往上看)几乎不变,代码层放在最上面,变了也只影响那一层。
调优方向:如果你发现某个层特别大(比如 500MB 的系统依赖),但又不常改,可以把它拆成独立的镜像层,然后用多阶段构建(muli-stage build)把最终镜像压缩到最小。具体怎么拆?我们会在后面的章节详细讲。
三个你一定会遇到的缓存坑
这些坑我都踩过,分享出来让你少走弯路:
- 缓存炸弹 — COPY 了不该复制的东西
比如COPY . /app的时候,把node_modules、__pycache__一起复制进去。这些临时文件一变化,缓存就失效。
解决方案:用.dockerignore严格过滤,或者精确指定要复制的路径。 - 依赖锁文件不一致
没有使用yarn.lock或package-lock.json的话,每次npm install安装的依赖版本都可能不同,哪怕你感觉什么都没改。
解决方案:始终使用锁文件,并且在 Dockerfile 中先 COPY 锁文件,再 RUN install。 - 跨平台换行符问题
Windows 下的 CRLF 和 Linux 下的 LF 在 Docker 看来完全不一样。如果你在 Windows 上编辑了requirements.txt并提交,其他同事在 Linux 上构建时缓存就失效了。
解决方案:在项目根目录放一个.gitattributes文件,内容如下:* text=auto eol=lf *.txt text eol=lf *.py text eol=lf *.json text eol=lf