Docker 镜像分层缓存完全指南:从一次"改一行代码、构建却重跑 5 分钟 npm install"看懂层缓存

2022 年我把一个 Node.js 后端服务容器化写人生第一个正经的 Dockerfile 怎么写这件事我压根没多想第一版我做得很顺手 FROM node 设好工作目录 COPY 把整个项目拷进去 RUN npm install 装依赖 RUN npm run build 打包最后 CMD 启动就完事了本地构建一次真不错虽然 npm install 跑了四五分钟但毕竟装这么多包慢点正常镜像也确实出来了我心里很踏实可等我真正开始日常开发一天构建几十次一串问题冒了出来第一种最先把我打懵我只改了一行业务代码重新构建 npm install 居然又从头跑了整整四分半明明我一个依赖都没动第二种最难缠这事每次都发生改注释要重装改个文案要重装每天几十次全耗在等 npm install 上第三种最头疼我把项目接到 CI 发现 CI 上构建更慢好像缓存完全没起作用第四种最莫名其妙同事说他本地构建挺快的可换台机器又变回几分钟快慢毫无规律我盯着这一连串问题想了很久才彻底想明白第一版错在一个根本的认知上我以为 Dockerfile 就是一串构建步骤顺序无所谓反正最后构建出来的镜像是一样的这句话把 Dockerfile 当成了一个从头跑到尾的一次性的 shell 脚本可它不是 Docker 镜像不是一个整体它是一摞只读的层叠起来的 Dockerfile 里每一条指令执行完都会产生一个新的层而 docker build 会缓存这些层下一次构建它从第一条指令开始逐层比对如果这条指令本身没改并且它依赖的输入也没变 Docker 就直接拿出上次那个层来复用这叫缓存命中可一旦某一层没命中需要重建它后面的每一层哪怕指令一个字都没改也全部强制失效缓存失效是会向下传染的而且是单向的上游污染下游下游救不了上游我那个第一版把 COPY 写在了 npm install 前面只要项目里任何一个文件变了 COPY 这一层立刻失效紧跟其后的 npm install 层被传染也失效只能重装真正写好 Dockerfile 核心不是把步骤列全而是理解镜像是分层的层是带缓存的缓存失效会单向向下传染于是刻意地安排指令顺序把几乎不变的东西放在最前面把天天都变的业务源代码放在最后面让那个最慢最该被缓存的依赖安装层稳稳地待在易变内容的上游本文从头梳理为什么改一行就重装是错的镜像分层和层缓存到底怎么回事缓存失效为什么会传染怎么把依赖安装和代码拷贝拆开怎么用 dockerignore 和 BuildKit 榨干缓存以及 CI 缓存这些把构建提速做扎实要避开的坑

2022 年我把一个 Node.js 后端服务容器化,写人生第一个正经的 Dockerfile。怎么写?这件事我压根没多想。第一版我做得很顺手:FROM node,设好工作目录,COPY . . 把整个项目拷进去,RUN npm install 装依赖,RUN npm run build 打包,最后 CMD 启动。就完事了。本地构建一次——真不错:虽然 npm install 跑了四五分钟,但毕竟装这么多包,慢点正常,镜像也确实出来了。我心里很踏实:"Dockerfile 嘛,把构建步骤一条条列出来不就行了?"可等我真正开始日常开发、一天构建几十次,一串问题冒了出来。第一种最先把我打懵:我只改了一行业务代码,重新构建,npm install 居然又从头跑了整整四分半——明明我一个依赖都没动。第二种最难缠:这事每次都发生,改注释要重装、改个文案要重装,每天几十次、每次几分钟全耗在等 npm install 上。第三种最头疼:我把项目接到 CI,发现 CI 上构建更慢,好像缓存完全没起作用,每次都从最底层从头来。第四种最莫名其妙:同事说他本地构建挺快的,可换台机器、或者清一下 Docker,又变回几分钟——快慢毫无规律。我盯着这一连串问题想了很久才彻底想明白,第一版错在一个根本的认知上:我以为"Dockerfile 就是一串构建步骤,顺序无所谓,反正最后构建出来的镜像是一样的"。这句话把 Dockerfile 当成了一个从头跑到尾的、一次性的 shell 脚本。可它不是我脑子里,docker build 就是把那几条命令依次执行一遍,执行完得到一个镜像,指令的先后只要不影响最终结果,怎么排都行。可 Docker 根本不是这么工作的。Docker 镜像不是一个整体,它是一摞只读的层叠起来的;Dockerfile 里每一条指令,执行完都会产生一个新的层,摞在上一层之上。而 docker build 真正聪明、也真正容易坑人的地方在于:它会缓存这些层。下一次构建,它从第一条指令开始逐层比对——如果这条指令本身没改、并且它依赖的输入也没变,Docker 就直接拿出上次那个层来复用,这条指令根本不重新执行,这叫缓存命中。这本该让构建快如闪电。可一旦某一层没命中、需要重建,事情就来了:它后面的每一层,哪怕指令一个字都没改,也全部强制失效、必须跟着重建。缓存失效是会向下传染的,而且是单向的——上游污染下游,下游救不了上游。我那个第一版,把 COPY . . 写在了 npm install 前面。COPY . . 这条指令,只要项目里任何一个文件变了,它的输入就变了,这一层立刻失效。于是我每改一行代码,COPY . . 失效,紧跟其后的 npm install 层被传染、也失效,只能重装。我以为指令顺序无所谓,可顺序恰恰决定了缓存能不能命中。真正写好 Dockerfile,核心不是"把步骤列全",而是理解镜像是分层的、层是带缓存的、缓存失效会单向向下传染,于是刻意地安排指令顺序:把几乎不变的东西(基础镜像、系统依赖、依赖清单)放在最前面,把天天都变的东西(业务源代码)放在最后面,让那个最慢、最该被缓存的依赖安装层,稳稳地待在易变内容的上游。这篇文章就把 Docker 分层缓存这个坑梳理一遍:为什么"改一行就重装"是错的、镜像分层和层缓存到底怎么回事、缓存失效为什么会传染、怎么把依赖安装和代码拷贝拆开、怎么用 .dockerignore 和 BuildKit 榨干缓存,以及 CI 缓存这些把构建提速做扎实要避开的坑。

问题背景

这个坑普遍,是因为"脚本顺序无所谓"在纯 shell 的世界里大体成立——一个脚本只要逻辑对,几条独立命令换个次序结果一样。把这个直觉带进 Dockerfile,就翻了车。它错得隐蔽,是因为第一版功能上完全正确:镜像能构建、容器能跑、服务正常。它只在"构建速度"这个维度上烂得彻底,而功能测试根本不会发现这一点。它又只在反复构建时才让人痛——你构建第一次,慢是应该的;你构建第二十次还是一样慢,才意识到不对劲。

把这个现象拆开,错误认知和真相是这样对应的:

  • 现象:改一行代码就重跑数分钟的依赖安装;每天几十次构建全耗在等待上;CI 上缓存仿佛失灵;构建快慢在不同机器上毫无规律。
  • 错误认知一:以为 Docker 镜像是一个整体,docker build 就是从头跑一遍脚本。真相是镜像是一摞只读层,每条指令产生一层。
  • 错误认知二:以为指令顺序无所谓,只要不影响最终镜像。真相是顺序直接决定缓存能否命中,是 Dockerfile 性能的命脉。
  • 错误认知三:以为缓存失效只影响那一层。真相是某层失效会让其下游所有层强制重建,失效单向向下传染。
  • 真相:层缓存的命中条件是"指令及其输入都未变化"。把稳定的指令放前、易变的放后,让昂贵的依赖安装层处在易变内容的上游,缓存才能稳稳命中。

一、为什么"改一行代码就重跑 install"

先把第一版那个 Dockerfile 摆出来。

FROM node:20

WORKDIR /app

# 第一版:先把整个项目拷进去,再装依赖
COPY . .
RUN npm install
RUN npm run build

CMD ["node", "dist/server.js"]

这个 Dockerfile 功能上挑不出毛病。它的问题,要看两次构建的对比才显形。第一次构建,所有层都没有缓存,老老实实从头跑;之后我只改了一行业务代码,再构建一次:

# 第一次构建:每一层都老老实实从头跑
$ docker build -t myapp .
 => [2/5] WORKDIR /app
 => [3/5] COPY . .
 => [4/5] RUN npm install              # 跑了 4 分 30 秒
 => [5/5] RUN npm run build

# 只改了一行业务代码,再次构建:
$ docker build -t myapp .
 => CACHED [2/5] WORKDIR /app
 => [3/5] COPY . .                      # 源文件变了,这层失效
 => [4/5] RUN npm install              # 又跑了 4 分 30 秒!
 => [5/5] RUN npm run build

关键就在这第二次构建的输出上。WORKDIR 那层显示 CACHED——命中了缓存。可 COPY . . 这层没有 CACHED:因为 COPY . . 的缓存键,取决于它拷贝的所有文件的内容,我改了一行代码,文件内容就变了,这层的输入变了,缓存失效。然后致命的一步来了:RUN npm install 这条指令我一个字都没改,可它紧跟在已经失效的 COPY . . 之后——它的"上游"脏了,它就必须跟着重建。于是依赖被整整重装了一遍。RUN npm run build 同理,继续被传染。

这里要建立的第一个、也是最重要的认知是:docker build 不是一个从头跑到尾的脚本,而是一条带缓存的、逐层比对的流水线。镜像是一摞只读层,Dockerfile 每条指令产出一层。构建时 Docker 从第一条指令开始,逐层问同一个问题:"这条指令、以及它依赖的输入,和上次相比变了没有?"没变,就直接把上次的层拿来用,指令根本不执行,这就是 CACHED;变了,这一层就重新执行。而最关键、也最反直觉的一点是:层之间是有上下游依赖的,一个层的"输入"里,包含了它上面那一层。所以一旦某层失效,它就成了一个"脏"的上游,它下面的每一层——哪怕指令一字未改——都会被判定为"输入变了",统统强制重建。缓存失效像污水一样,只会从上游往下游流,且没有任何下游手段能挡住它。理解了这条,第一版的病因就一清二楚:COPY . . 是个极其敏感的层,项目里任何一个文件的任何一点改动都会让它失效;而我偏偏把它放在了昂贵的 npm install 上游。我每改一行代码,污水就从 COPY . . 这层灌下去,把下面的 npm install、npm build 全淹了。病根不在 npm install 慢,而在它站错了位置。

二、镜像是分层的:每条指令一个层

把上一节的原理再夯实一下。一个 Docker 镜像,物理上就是若干个只读层叠在一起:最底下是基础镜像(FROM node:20 本身就是一摞层),往上每执行一条 RUNCOPYWORKDIR 指令,就在顶上加一个新层,这个层只记录"这条指令带来的文件系统变化"。最终的镜像,就是这些层从下到上叠加后看到的样子。

这个"层"不是抽象概念,你可以直接把它们看出来。docker history 能列出一个镜像由哪些层构成、每条指令产生的层多大:

# 查看镜像每一层是哪条指令产生的、各占多大
$ docker history myapp:latest --no-trunc --format \
    "table {{.CreatedBy}}\t{{.Size}}"

# 输出能让你一眼看出:
#  - 哪一层最大(通常是 RUN npm install 那层)
#  - 哪些层因为指令顺序不好而频繁重建
# 一条经验:让"大且稳定"的层尽量靠前,把缓存价值最大化

层的缓存键怎么算,不同指令不一样,这点必须分清:对 RUN 指令,缓存键基本就是那条命令的字符串本身加上它的上游层——命令文本没变、上游没变,就命中;对 COPYADD 指令,缓存键还要加上被拷贝文件的实际内容——文件内容一变,即使 COPY 那行字没改,也失效。这就解释了为什么 COPY . . 如此"脆弱":它的缓存键绑定了项目里每一个文件的内容,几乎你做任何改动都会动到它。

这里要建立的认知是:把镜像理解成"一摞层"而不是"一个整体",是写好 Dockerfile 的地基。一旦你脑子里有了"层"这个颗粒度,你看 Dockerfile 的眼光就变了——你不再读它为"五条命令",而读它为"五个层,自下而上摞起来,每个层有自己的缓存键和复用条件"。你会本能地对每条指令多问一句:这一层的缓存键是什么?它多容易失效?它失效了会连累下面几层?docker history 的价值就在这里,它把抽象的"层"变成了你能逐行看见、能量出大小的东西,让你能定位到底是哪个大层在被反复重建、白白烧时间。还有一个常被忽略的点:RUN 的缓存键只看命令字符串,不看命令的实际效果——这意味着 RUN npm install 这行字没变、上游也没变时,哪怕远端的包其实出了新版本,Docker 也照样命中旧缓存、不会重装。缓存命中判断的是"指令和输入变没变",不是"结果应不应该变",这把双刃剑你得心里有数。

三、缓存失效会向下传染:指令顺序决定一切

现在把"失效传染"这件事完整走一遍。设想 Dockerfile 的层从上到下排成一列,构建时 Docker 逐层判断,它的决策是这样流动的:

这张图的要点是那条 变了 的分支:一旦某层失效,后面的层就不再逐个判断了,而是无条件全部重建。Docker 不会在下游帮你"捡回"缓存——它没法捡,因为下游每一层的输入都包含了上游,上游脏了,下游的输入定义上就是脏的。所以一条 Dockerfile 的缓存效率,几乎完全由"第一个会失效的层出现在多靠下的位置"决定:它越靠下,它上面能稳稳命中的层就越多,被它连累重建的层就越少。

这条规律直接给出了写 Dockerfile 的黄金法则:把指令按"变化频率从低到高"的顺序,自上而下排列。最不常变的(基础镜像、系统级依赖)放最上面,中等频率的(项目依赖清单、依赖安装)放中间,最常变的(业务源代码)放最下面。第一版之所以错,就是把"天天变"的 COPY . . 放到了"几乎不变"的 npm install 上面,彻底违反了这条法则。

这里要建立的认知是:Dockerfile 的指令顺序,不是风格问题,是性能架构问题。在普通脚本里,两条独立命令谁先谁后无所谓;但在 Dockerfile 里,顺序定义了"谁是谁的上游",而上游能污染下游、下游救不了上游——这个不对称,让顺序变成了一个有方向、有后果的决策。写每一条指令时,你都该想清楚它的"变化频率"在整条流水线里处于什么档位,然后把它放到对应的高度:稳定的沉到底、易变的浮到顶。这背后其实是一个很通用的优化思想——把昂贵且稳定的计算,挪到易变输入的上游去,这样易变输入再怎么频繁改动,都冲不到那个昂贵计算,它的结果就能被一直复用。npm install 又慢又稳定,它就该尽量往上游沉;源代码又轻又易变,它就该往下游浮。你把这两者的相对位置摆对了,80% 的构建提速就已经到手了。下一节就把这个"摆对位置"落成具体的 Dockerfile。

四、把依赖安装和代码拷贝分开

落到代码上,关键动作只有一个:不要用一条 COPY . . 把所有东西一次拷进去,而是先单独拷贝"依赖清单",装完依赖,再拷贝其余源代码。对 Node 项目,依赖清单就是 package.jsonpackage-lock.json 这两个文件。

FROM node:20

WORKDIR /app

# 先只拷贝依赖清单 —— 这两个文件很少变
COPY package.json package-lock.json ./
# 装依赖:只要上面两个文件没变,这一层就一直命中缓存
RUN npm install

# 再拷贝其余源代码 —— 这部分天天变,但它在 install 之后
COPY . .
RUN npm run build

CMD ["node", "dist/server.js"]

这个改动看着小,效果是质变。现在 npm install 这层的上游,是 COPY package.json package-lock.json——而这两个文件,只有你真正增删改依赖时才会变。你改业务代码、改注释、改文案,统统不碰它们,所以 npm install 层稳稳命中缓存。失效的只剩下游的 COPY . .npm run build。再构建一次看看:

# 改一行业务代码后再次构建优化版的 Dockerfile:
$ docker build -t myapp .
 => CACHED [2/6] WORKDIR /app
 => CACHED [3/6] COPY package.json package-lock.json ./
 => CACHED [4/6] RUN npm install         # 命中缓存,0 秒
 => [5/6] COPY . .                        # 只有这层和之后重建
 => [6/6] RUN npm run build               # 构建从 4 分钟降到几秒

同样的道理适用于任何语言,变的只是"依赖清单"叫什么名字。Python 项目里它是 requirements.txt:

# Python 应用同理:requirements.txt 是"依赖清单",先单独拷
FROM python:3.12-slim

WORKDIR /app

# 先拷依赖清单并安装 —— 只要 requirements.txt 没变就命中缓存
COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt

# 再拷应用代码 —— 改代码不会触发上面的 pip install 重跑
COPY . .

CMD ["python", "-m", "app"]

这里要建立的认知是:"先拷依赖清单、再拷源代码"这个看似琐碎的两步走,本质是在一条流水线上,人为地切出一道"稳定/易变"的分界线。在你眼里 package.json 和你的业务源码都是"项目文件",但从缓存的视角它们是两种截然不同的东西:依赖清单是低频变更的,源代码是高频变更的。一条 COPY . . 把它俩混在一个层里,等于强行让低频的跟着高频的一起失效——你把一个本可以长期命中的稳定资产,亲手绑在了一个天天爆炸的易变资产上。拆成两个 COPY,就是把这两类东西分进了不同的层,让它们各自按各自的频率失效、互不拖累。这是一个值得记住的通法:当一批东西里混着"稳定的"和"易变的",而它们又会被同一个缓存键覆盖时,就把它们拆开,让稳定的单独成一层。Dockerfile 里你能这么拆,别处的缓存设计也一样。记住分界线的位置:依赖安装必须在分界线之上,源代码拷贝必须在分界线之下。

五、用 .dockerignore 和 BuildKit 榨干缓存

分好层之后,还有两件事能把缓存利用率再往上抬。第一件是 .dockerignoreCOPY . . 拷的是"构建上下文"里的所有文件,如果你不排除,node_modules.git、日志、构建产物全都会被算进 COPY . . 的缓存键——这些目录变动极其频繁(尤其 .git),会让 COPY . . 层无谓地、频繁地失效。一个 .dockerignore 文件就能把它们挡在门外:

# .dockerignore —— 这些目录不该进入构建上下文
node_modules
dist
.git
*.log
.env
coverage

第二件是 BuildKit 的缓存挂载。前面我们让 npm install 层尽量命中缓存,但总有命中不了的时候——你确实改了依赖,package-lock.json 变了,这层只能重建。可"重建这一层"不代表"每个包都得重新从网上下载":那些没变的包,上次下载的文件其实还能用。RUN --mount=type=cache 就是干这个的,它把包管理器的下载目录挂成一个跨构建持久存在的缓存:

# syntax=docker/dockerfile:1
FROM node:20
WORKDIR /app

COPY package.json package-lock.json ./
# 把 npm 的下载缓存挂成一个持久化的 cache 目录
# 即使这一层因 lockfile 变更而失效,已下载的包仍可复用
RUN --mount=type=cache,target=/root/.npm \
    npm install

COPY . .
RUN npm run build
CMD ["node", "dist/server.js"]

这里要建立的认知是:层缓存是"全有或全无"的——一个层要么整个命中、要么整个重建,没有中间状态;而 .dockerignore 和缓存挂载,正是用来对冲这种"全有或全无"的粗粒度的。.dockerignore 对冲的是"输入被污染":它让 COPY . . 的缓存键只包含真正属于源代码的文件,把那些和你的代码无关、却变得飞快的目录(.git、日志、本地 node_modules)挡在缓存键之外,从而让这个层"该命中的时候真能命中"。缓存挂载对冲的是另一面——"层一旦失效,损失太惨重":即使 npm install 这个层整个重建了,挂载进去的下载缓存目录不属于任何层、不受层失效影响,于是重建时大量没变的包是从本地缓存秒装的,而不是重新走网络。把这两件事和前面的分层放在一起看,你就有了一个三段式的缓存策略:用分层让昂贵的层尽量不失效,用 .dockerignore 防止它被无关变更误伤,用缓存挂载兜住它万一失效时的损失。三层防护叠起来,构建速度才算真正榨干了。

六、CI 里让缓存生效,以及工程坑

把本地构建调快之后,接 CI 时常会发现缓存"又没了"。这不是 bug:CI 每次大多在一台全新的、干净的机器上跑,本地积累的那些层缓存,在那台机器上根本不存在。解法是把缓存"显式地"喂给 CI——用上一次构建推送到镜像仓库的镜像,作为这一次构建的缓存来源:

# CI 上每次都是全新机器,本地层缓存根本不存在
# 解法:把上一次构建推送的镜像,当作这次的缓存来源

# 1) 先拉取上一次推送的镜像(拉不到也不要紧)
docker pull registry.example.com/myapp:latest || true

# 2) 构建时用它做缓存来源,并打开 inline 缓存元数据
docker build \
  --cache-from registry.example.com/myapp:latest \
  --build-arg BUILDKIT_INLINE_CACHE=1 \
  -t registry.example.com/myapp:latest .

# 3) 推回去,给下一次 CI 当缓存
docker push registry.example.com/myapp:latest

另外几个真实项目里的坑。一是基础镜像标签是浮动的:FROM node:20 里的 node:20 不是一个固定不变的镜像,官方会不断给它推安全更新——某天它悄悄变了,你的 FROM 层就失效,后面所有层跟着全部重建。要构建可复现、缓存稳定,就把基础镜像用更精确的版本、甚至 digest 钉死:

# 基础镜像用精确版本钉死,避免 node:20 标签悄悄更新导致缓存全失效
FROM node:20.11.1-bookworm-slim

WORKDIR /app

# 系统级依赖几乎从不变化,放在最顶层,缓存几乎永久命中
RUN apt-get update \
 && apt-get install -y --no-install-recommends tini \
 && rm -rf /var/lib/apt/lists/*

COPY package.json package-lock.json ./
RUN npm install

COPY . .
RUN npm run build

二是系统依赖也要按"变化频率"归位:像上面这种 apt-get install 系统包的指令,变化频率比项目依赖还低,就该放在 COPY package.json 之上、紧贴 FROM。三是 RUN 指令链里清理要和安装写在同一条 RUN(用 && 连起来):如果你 apt-get install 是一层、rm 清理缓存是另一层,被删的文件其实已经留在了下层、镜像并不会变小——这点虽然偏体积,但和"一条指令一个层"是同一个原理。

这里要建立的认知是:缓存这东西,在哪台机器上、它就只在哪台机器上——它不是镜像的一部分,不会跟着 docker push 一起走。这是 CI 缓存失灵的全部根源:你本地辛苦攒下的层缓存,是你这台机器的本地状态,CI 那台一次性的干净机器对它一无所知。想让 CI 也快,你必须把"缓存"这件事从"机器的隐式本地状态"升级成"一份可以显式传递的产物"——而镜像仓库恰好是那个公共中转站:上一次 CI 把带缓存元数据的镜像推上去,下一次 CI 用 --cache-from 把它当缓存拉下来。本质上你是在用一个共享的远端,模拟出"机器之间共享缓存"的效果。再加上把基础镜像钉死——浮动标签等于给你的整条缓存链埋了颗会随机引爆的雷,FROM 一变,上面苦心经营的分层全白搭。把这些合起来,一个工程上靠谱的 Dockerfile,是同时在三个层面被设计过的:指令顺序按变化频率排好、构建上下文用 .dockerignore 收干净、CI 缓存用镜像仓库显式接力,而这一切的最底层,是基础镜像被一个确定的版本牢牢钉住。

关键概念速查

概念 说明 关键点
镜像分层 Dockerfile 每条指令产生一个只读层 镜像是层的叠加,层可被独立缓存与复用
构建缓存 Docker 复用未变化的层,跳过指令执行 命中显示 CACHED,命中条件是指令与输入都没变
缓存失效传染 某层失效后,其下游所有层强制重建 失效单向向下,上游污染下游,下游救不回
指令顺序 按变化频率从低到高自上而下排列 稳定的沉底、易变的浮顶,是性能命脉
RUN 的缓存键 取决于命令字符串本身与上游层 命令文本不变即命中,不看命令实际效果
COPY 的缓存键 额外取决于被拷贝文件的内容 任一源文件变化即令该 COPY 层失效
依赖清单先行 先单独 COPY package.json / requirements.txt 让昂贵的依赖安装层独立、长期命中缓存
.dockerignore 排除文件不进入构建上下文 避免 .git、日志等无关变更误伤 COPY 缓存
BuildKit 缓存挂载 RUN --mount=type=cache 持久化下载目录 层即使失效,已下载的包仍可复用
CI 缓存 --cache-from 用远端镜像作为构建缓存来源 CI 机器无本地缓存,须经镜像仓库显式接力

避坑清单

  1. 不要用一条 COPY . . 把所有文件一次拷进去再装依赖。源代码任何改动都会让它失效,并把下游的依赖安装层一起冲掉。
  2. 先单独 COPY 依赖清单(package.json / requirements.txt),装完依赖,再 COPY 源代码。让昂贵的安装层处于易变源码的上游。
  3. 按变化频率从低到高排列指令:基础镜像、系统依赖在最上,依赖安装在中间,业务源码在最下。
  4. 记住缓存失效会向下传染。判断一条指令该放哪,就看它失效时会连累下面多少层。
  5. 务必写 .dockerignore,排除 node_modules、.git、日志、构建产物,避免它们污染 COPY 的缓存键。
  6. 用 BuildKit 的 RUN --mount=type=cache 挂载包下载目录,让安装层即便失效也不必重新下载所有包。
  7. 基础镜像不要用浮动标签。用精确版本或 digest 钉死 FROM,否则 base 悄悄更新会让全部层失效。
  8. CI 上要显式注入缓存:docker pull 上次镜像、构建时 --cache-from 指向它,CI 机器没有本地层缓存。
  9. 安装与清理写在同一条 RUN 里。分成两层的话,被删文件仍留在下层,镜像并不会变小。
  10. 别只测"镜像能不能构建",要测"改一行代码后重建快不快"。功能正常不代表缓存设计正确。

总结

回头看,第一版栽的跟头,根子是一个认知误判:我以为 Dockerfile 是一串从头跑到尾的脚本,指令顺序无所谓。可它不是——Docker 镜像是一摞带缓存的只读层,每条指令是一层,而缓存失效会从上游单向传染到下游。我把天天都变的 COPY . .,放在了又慢又稳定的 npm install 上游,于是每改一行代码,失效就从 COPY 层灌下去,把依赖安装整个淹掉。问题从来不在 npm install 慢,而在它站错了位置。

真正写好 Dockerfile,工作量不在"把构建步骤列全",而在一次视角的转变:从"列步骤"变成"排层"。一旦你脑子里有了"层"和"失效会向下传染"这两个概念,该怎么写就自然浮现了——按变化频率把指令从低到高排好、把依赖清单和源代码拆成两个 COPY、写好 .dockerignore、用缓存挂载兜底、CI 上经镜像仓库接力缓存。每一步都不难,难的是先承认:Dockerfile 不是脚本,是一条流水线的布局图;指令的先后,不是风格,是性能架构。

我后来常拿砌墙来想这件事。盖一面墙,最底下是地基,往上是承重的砖,最上面才是那层会经常想换花样的装饰面砖。聪明的盖法,是地基和承重墙一次砌好、几乎再也不动,装饰砖放最上面——哪天想换装饰,把最上一层敲掉重贴就行,底下纹丝不动。而我的第一版,等于把"装饰砖"砌进了地基的位置:我每次想换一下最表面的装饰(改一行代码),都得把压在它上面的整面承重墙(npm install)全部拆掉重砌。Dockerfile 的指令顺序,就是在决定每一块"砖"砌在第几层:稳定的沉到地基去,易变的浮到墙顶来,你才不用为了换一块面砖去拆一堵墙。

这类问题最咬人的地方,在于它在"功能"这个维度上永远是"对"的:镜像能构建、容器能跑、测试全过,你拿不到任何一个它在报错的信号。它烂在"构建速度"这个维度,而这个维度不会有红色的失败提示,只会在你日复一日、一天几十次的等待里,悄悄偷走你的时间。所以别等到"怎么构建又是好几分钟"成了每日的麻木背景音才去查:写 Dockerfile 的第一天,就该把"镜像是分层的、缓存会向下传染"刻进设计里——先拷依赖清单再拷代码、按变化频率排指令、配齐 .dockerignore。把这套布局在写第一行 FROM 时就想清楚,你才算真正跳出了那条人人都会写、却让人人天天空等的 COPY . .

—— 别看了 · 2026
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

大模型对话历史压缩完全指南:从一次"砍掉几条旧消息、Agent 就报 400 错误"看懂上下文治理

2026-5-22 17:18:19

技术教程

RAG 检索质量评估完全指南:从一次"向量库明明命中了、答案却驴唇不对马嘴"看懂召回率与评测集

2026-5-22 17:31:44

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索