分层缓存机制:理解Docker镜像层,利用缓存加速构建

讲到底层去了。Docker 镜像为什么能加速?说白了就靠分层缓存。很多新手上来就写 Dockerfile,一个 RUN 到底,构建一次跑半小时。

缓存用得好的话,一个改动只要几秒就完事。我当年第一个生产项目,构建一次得 20 分钟,后来优化到 2 分钟。靠的就是这招。

Docker 镜像分层结构与缓存命中流程 基座层 FROM ubuntu:22.04 系统依赖层 RUN apt-get update && ... 应用依赖层 COPY requirements.txt && RUN pip install 代码层 COPY src/ 入口层 CMD / ENTRYPOINT ✔ 缓存命中 ✔ 缓存命中 ✔ 缓存命中 ✘ 缓存失效 — 需重建 代码层一旦失效,入口层必须重建;但前三层不变,照样命中缓存

从分层到缓存,到底在说什么?

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)把最终镜像压缩到最小。具体怎么拆?我们会在后面的章节详细讲。

三个你一定会遇到的缓存坑

这些坑我都踩过,分享出来让你少走弯路:

  1. 缓存炸弹 — COPY 了不该复制的东西
    比如 COPY . /app 的时候,把 node_modules、__pycache__ 一起复制进去。这些临时文件一变化,缓存就失效。
    解决方案:用 .dockerignore 严格过滤,或者精确指定要复制的路径。
  2. 依赖锁文件不一致
    没有使用 yarn.lock 或 package-lock.json 的话,每次 npm install 安装的依赖版本都可能不同,哪怕你感觉什么都没改。
    解决方案:始终使用锁文件,并且在 Dockerfile 中先 COPY 锁文件,再 RUN install。
  3. 跨平台换行符问题
    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