Docker 分层缓存:把 Node 镜像构建压到秒级

举报
yd_231746916 发表于 2026/10/27 13:18:56 2026/10/27
【摘要】 构建慢,十有八九不是机器不行,是 缓存没命中 。同样是 Node 项目,改一行业务代码就重新 npm install 一次,3 分钟的构建里 2 分 50 秒在装依赖——这篇把这件事讲透:缓存的命中条件是什么、为什么你的Docker CI/CD 构建优化

构建慢,十有八九不是机器不行,是缓存没命中。同样是 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 镜像的日常构建会从分钟级降到秒级,而且不需要加机器。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。