Docker 分层缓存:把 Node 镜像构建压到秒级
构建慢,十有八九不是机器不行,是缓存没命中。同样是 Node 项目,改一行业务代码就重新 `npm install` 一次,3 分钟的构建里 2 分 50 秒在装依赖——这篇把这件事讲透:缓存的命中条件是什么、为什么你的 Dockerfile 命不中、以及怎么验证。
一、先搞清楚 Docker 的缓存判定规则
Docker 构建是逐层的:每一层都有唯一的 layer digest,由「父层 digest + 当前指令 + 涉及的文件内容」共同决定。
关键推论只有一句:某一层失效,它之后的所有层全部失效,哪怕后面那些层的内容一个字没改。
所以缓存优化的本质不是"要不要缓存",而是把变化频率低的东西排到前面,让高频变更只影响最后几层。
二、典型的错误写法
FROM node:20-alpine
WORKDIR /app
COPY . . # ← 命门在这里
RUN npm ci
CMD ["node", "server.js"]
`COPY . .` 把源码和 `package.json` 一起塞进这一层。改一个 `console.log`,这一层的 digest 就变了,于是 `npm ci` 必然重跑。
很多人此时的处理是加大机器规格——治标,而且慢的根因还在。
三、正确写法:先依赖,后源码
FROM node:20-alpine AS deps
WORKDIR /app
# 1) 只拷贝依赖清单(这两行是命中率的关键)
COPY package.json package-lock.json ./
# 2) 用 ci 而不是 install:严格按锁文件装,且不写回 package.json
RUN npm ci --omit=dev
FROM node:20-alpine
WORKDIR /app
# 3) 依赖层单独复用,源码变更不影响它
COPY --from=deps /app/node_modules ./node_modules
COPY . .
CMD ["node", "server.js"]
改业务代码时,前三层 digest 不变,`npm ci` 直接走缓存——构建从分钟级掉到几秒。
两个细节别漏:
· `npm ci` 需要 `package-lock.json`。没有锁文件就只能退回 `npm install`,装出来的版本还可能每次不同。锁文件必须提交到仓库。 · 必须有 `.dockerignore`。否则 `COPY . .` 会把 `node_modules`、`.git`、构建产物一起打进镜像,既撑大体积,又让层 digest 更容易变。
node_modules
.git
dist
*.log
.env*
四、还要装 devDependencies 怎么办
多阶段里再拆一层,别为了省事只写一个 `RUN npm ci`:
FROM node:20-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci # 含 devDependencies
COPY . .
RUN npm run build
FROM node:20-alpine
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules # 只有生产依赖
COPY --from=build /app/dist ./dist
CMD ["node", "dist/server.js"]
好处是运行镜像里不带 `typescript`、`jest` 这类只在构建期用的东西,体积和攻击面都小一圈。
五、CI 里缓存为什么会"看起来生效了其实没生效"
本地构建有持久化的构建缓存,CI 里的 runner 通常是一次性的,跑完就销毁——缓存自然带不走。
BuildKit 的做法是把缓存显式导入/导出:
# 构建时把缓存写出去
docker buildx build \
--cache-to type=local,dest=/tmp/buildcache \
--cache-from type=local,src=/tmp/buildcache \
-t myapp:latest .
# 或用 registry 存缓存(多台 runner 共享时更实用)
docker buildx build \
--cache-to type=registry,ref=registry.example.com/myapp:buildcache,mode=max \
--cache-from type=registry,ref=registry.example.com/myapp:buildcache \
-t myapp:latest .
`mode=max` 会把中间层也缓存(对多阶段构建必要),代价是缓存体积变大。只缓存最终层用默认 `mode=min` 就够。
注意:`--cache-from` 必须和 `--cache-to` 成对使用。只 from 不 to,永远没东西可读。
六、怎么确认缓存真的命中了
不要靠"感觉快了",看 `CACHED` 字样:
docker build --progress=plain -t myapp:latest . 2>&1 | grep -E "CACHED|RUN npm"
输出里出现 `CACHED [deps 3/3] RUN npm ci` 才算命中。如果每次都 `RUN npm ci` 而没有 `CACHED`,回到第三节检查 `COPY` 的顺序。
七、几个容易踩的边界
1. `package.json` 里带脚本改动也算变更。`npm ci` 层会失效,这是正常的——依赖清单变了重新装是应该的。 2. `npm ci` 与 `npm install` 混用会互相打架。流水线里统一用 `ci`,本地开发用 `install`,别把两者产物混进同一个镜像。 3. 改了 `.dockerignore` 也可能导致缓存失效,因为它影响了 `COPY` 的实际内容,属于预期行为。 4. `--no-cache` 不是优化手段。它只是排障时的临时开关,日常构建里出现说明有人在用错误的方式"求稳"。 5. 缓存不是越久越好。基础镜像升级、依赖有安全公告时,该主动失效一次,别让旧层一直命中。
小结
缓存优化的顺序永远是:先排层(依赖在前、源码在后)→ 再定量(锁文件 + `.dockerignore`)→ 最后才谈 CI 侧持久化(`--cache-to/from`)。
把这三步做完,绝大多数 Node 镜像的日常构建会从分钟级降到秒级,而且不需要加机器。
- 点赞
- 收藏
- 关注作者
评论(0)