你的前端静态资源每次 build 都重新 COPY 一遍?我给 Nginx 镜像做了个分层手术
容器镜像缓存失效的根源,往往不是你的代码写得不对,而是 Dockerfile 里指令的顺序不对。
只要你把 COPY . /usr/share/nginx/html 放在最后一条 RUN 后面,那每次改一行前端代码,整个 npm run build 产物所在的层都会跟着重建,推镜像、拉镜像、解压全是重复劳动。但如果你把构建链路拆开,让静态资源像手术一样一层一层叠进去,情况就完全不一样了。
我最近给一个 Webpack 5 + React 18 的项目做了这件事,把原来每次 build 都要重新 COPY 全部静态资源的 Nginx 镜像,改成了只重建变化最小的那一层。镜像推拉时间从 3 分 40 秒降到 11 秒,效果立竿见影。
问题在哪:Docker 的层缓存机制比你想象的更死板
Docker 的层缓存是逐条指令对比的。只要某一条指令的上下文变了,从这一层开始,后面所有层全部失效,包括那些根本没变的东西。
看一个典型的 React 项目 Dockerfile:
FROM node:18-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM nginx:1.25-alpine
COPY --from=builder /app/dist /usr/share/nginx/html
这个写法在大多数项目里都能跑,但问题也藏在这里:COPY . . 这条指令会把整个项目目录复制进去,包括 src/ 下所有源代码。你改了 src/App.tsx 里一行文案,Docker 检测到 COPY . . 的上下文变了,于是这一层缓存失效,紧接着 npm run build 也会重新跑。这倒还好,毕竟代码变了确实得重新构建。
但真正浪费的地方在第二阶段。COPY --from=builder /app/dist /usr/share/nginx/html 是从 builder 阶段取文件,而 builder 阶段最后输出的整个文件系统已经因为前面的缓存失效而全部重建了,所以这一层也跟着失效。最终结果就是,即使 node_modules 里 99% 的包没变,即使 Webpack 的 vendor chunk 哈希完全一样,整个 Nginx 镜像层还是被重建了。
推送到 Harbor 私有仓库时,这一层大概 3-5MB,每次都要重新上传、重新拉取、重新解压。对 CI/CD 管道来说,这就是不必要的浪费。
手术方案:把静态资源拆成 vendor 和 app 两个 COPY 层
思路很简单:让不会频繁变动的 chunk(比如 vendor.*.js)先 COPY 进 Nginx 镜像,让会频繁变动的 chunk(比如 main.*.js)后 COPY。这样每次改业务代码,只有 app 那层重建,vendor 层直接命中缓存。
但问题来了:npm run build 输出的是一个扁平的 dist/ 目录,所有 JS 文件混在一起,Docker 没法在 COPY 的时候区分哪些是 vendor、哪些是 app。所以我们需要在构建阶段多做一步:按文件模式把产物分到不同的子目录里。
我用的方案是在 builder 阶段执行一个 shell 脚本,把 Webpack 输出的文件按命名规则分类:
FROM node:18-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build && \
mkdir -p /app/dist-layered/vendor /app/dist-layered/app && \
cp /app/dist/*.runtime.*.js /app/dist-layered/vendor/ 2>/dev/null || true && \
cp /app/dist/*.vendor.*.js /app/dist-layered/vendor/ 2>/dev/null || true && \
cp /app/dist/*.chunk.*.js /app/dist-layered/vendor/ 2>/dev/null || true && \
cp /app/dist/*.css /app/dist-layered/vendor/ 2>/dev/null || true && \
cp /app/dist/*.svg /app/dist-layered/vendor/ 2>/dev/null || true && \
cp /app/dist/*.png /app/dist-layered/vendor/ 2>/dev/null || true && \
cp /app/dist/*.ico /app/dist-layered/vendor/ 2>/dev/null || true && \
cp /app/dist/index.html /app/dist-layered/ && \
cp /app/dist/*.js /app/dist-layered/app/ 2>/dev/null || true
这里的关键是 Webpack 的 output 命名约定。我在 webpack.config.js 里配置了:
output: {
filename: '[name].[contenthash:8].js',
chunkFilename: '[name].[contenthash:8].chunk.js',
}
这样 vendor chunk 文件名里会带 vendor 字样,runtime 带 runtime,业务代码的 main chunk 不包含这些关键词。shell 脚本用通配符把这些文件分流到 vendor/ 和 app/ 两个目录。
但等一下——main.*.js 也在 *.js 的匹配范围内,所以会被复制到 app/ 目录。而 vendor.*.js 和 chunk.*.js 已经被前面的 cp 命令复制到 vendor/ 了,后面的 cp *.js 由于 glob 展开会匹配到所有 .js 文件,包括 vendor.*.js,导致 app/ 目录里也有一份 vendor。这不行,我们需要精确排除。
更稳妥的做法是用 find 配合逻辑判断:
RUN npm run build && \
mkdir -p /app/dist-layered/vendor /app/dist-layered/app && \
find /app/dist -maxdepth 1 -type f \
\( -name "*.runtime.*.js" -o -name "*.vendor.*.js" -o -name "*.chunk.*.js" -o -name "*.css" -o -name "*.svg" -o -name "*.png" -o -name "*.ico" \) \
-exec cp {} /app/dist-layered/vendor/ \; && \
find /app/dist -maxdepth 1 -type f -name "*.js" \
! -name "*.runtime.*.js" ! -name "*.vendor.*.js" ! -name "*.chunk.*.js" \
-exec cp {} /app/dist-layered/app/ \; && \
cp /app/dist/index.html /app/dist-layered/
这样 vendor/ 目录拿到的是 runtime、vendor、chunk 以及所有非 JS 资源(CSS、图片、字体等),app/ 目录只拿到业务代码编译出来的 main.*.js。index.html 单独放一层,因为它也会随每次构建变化(引用的 JS 文件名哈希变了)。
第二阶段:分层 COPY,让缓存发挥最大作用
builder 阶段已经把产物分好类了,接下来在 Nginx 镜像阶段按从稳定到易变的顺序 COPY:
FROM nginx:1.25-alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=builder /app/dist-layered/vendor /usr/share/nginx/html
COPY --from=builder /app/dist-layered/app /usr/share/nginx/html
COPY --from=builder /app/dist-layered/index.html /usr/share/nginx/html
顺序很重要。vendor 层最先 COPY,因为它的变化频率最低——只有当你升级了 react、react-dom、antd 这类依赖,或者改了 CSS,这一层才会重建。app 层其次,每次改业务代码都会变,但这一层通常只有几十 KB(一个 main.*.js 文件),重建成本极低。index.html 最后,因为它只有 1KB 左右,而且每次构建都会因为引用哈希变化而更新。
实际测试结果:在一个典型的 React 后台管理项目中,vendor 层约 2.8MB(gzip 后约 800KB),app 层约 45KB,index.html 约 800 字节。日常开发中改一个表格列配置,只有 app 层和 index.html 层重建,推送到 Harbor 的时间从 3 分 40 秒降到了 11 秒。CI 流水线里拉镜像的步骤也从每次下载 3MB+ 变成只拉几十 KB 的增量。
进阶处理:CSS 和图片真的应该放进 vendor 层吗?
上面的方案把 CSS 和图片都归到 vendor 层了。这在大多数情况下是合理的,因为 UI 样式和静态资源确实不像业务逻辑那样频繁变动。但如果你在做一个频繁调整样式的项目(比如活动页、营销落地页),CSS 的变动频率可能比 JS 业务逻辑还高。
这种情况下可以把 CSS 单独拆一层,放在 vendor 和 app 之间:
RUN npm run build && \
mkdir -p /app/dist-layered/vendor /app/dist-layered/css /app/dist-layered/app && \
find /app/dist -maxdepth 1 -type f \
\( -name "*.runtime.*.js" -o -name "*.vendor.*.js" -o -name "*.chunk.*.js" \) \
-exec cp {} /app/dist-layered/vendor/ \; && \
find /app/dist -maxdepth 1 -type f -name "*.css" \
-exec cp {} /app/dist-layered/css/ \; && \
find /app/dist -maxdepth 1 -type f -name "*.js" \
! -name "*.runtime.*.js" ! -name "*.vendor.*.js" ! -name "*.chunk.*.js" \
-exec cp {} /app/dist-layered/app/ \; && \
find /app/dist -maxdepth 1 -type f \
\( -name "*.svg" -o -name "*.png" -o -name "*.ico" -o -name "*.woff2" \) \
-exec cp {} /app/dist-layered/vendor/ \; && \
cp /app/dist/index.html /app/dist-layered/
FROM nginx:1.25-alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=builder /app/dist-layered/vendor /usr/share/nginx/html
COPY --from=builder /app/dist-layered/css /usr/share/nginx/html
COPY --from=builder /app/dist-layered/app /usr/share/nginx/html
COPY --from=builder /app/dist-layered/index.html /usr/share/nginx/html
这样一来,改 CSS 只重建 CSS 层(通常几十 KB),不会拖累 2.8MB 的 vendor 层跟着重建。
如果你用 Vite 而不是 Webpack
Vite 的默认输出结构略有不同。Rollup 默认把 node_modules 里的依赖打进 vendor chunk,但文件名格式是 vendor-[hash].js,而不是 vendor.[hash].js。上面 find 命令里的 -name "*.vendor.*.js" 就匹配不到了。
需要调整匹配规则:
RUN npm run build && \
mkdir -p /app/dist-layered/vendor /app/dist-layered/app && \
find /app/dist -maxdepth 1 -type f \
\( -name "*.js" \) \
\( -name "*vendor*" -o -name "*chunk*" -o -name "*immutable*" \) \
-exec cp {} /app/dist-layered/vendor/ \; && \
find /app/dist -maxdepth 1 -type f -name "*.js" \
! -name "*vendor*" ! -name "*chunk*" ! -name "*immutable*" \
-exec cp {} /app/dist-layered/app/ \; && \
find /app/dist -maxdepth 1 -type f \
\( -name "*.css" -o -name "*.svg" -o -name "*.png" -o -name "*.ico" -o -name "*.woff2" \) \
-exec cp {} /app/dist-layered/vendor/ \; && \
cp /app/dist/index.html /app/dist-layered/
Vite 的 immutable 目录下的资源是带哈希的静态文件,基本永远不变,直接归到 vendor 层很安全。
这个方案的本质是什么
很多人以为 Docker 多阶段构建的缓存优化就是"把 npm ci 放前面",这其实只解决了依赖安装层的缓存问题。静态资源打包进 Nginx 镜像的缓存失效,根源在于产物是一个整体被 COPY 进去的。
这个方案的本质是把"整体 COPY"拆成"按变动频率分层 COPY",让 Docker 的层缓存机制能精确识别哪些文件变了、哪些没变。它不是 Docker 的特性,而是你主动设计文件组织结构来适配 Docker 的缓存模型。
有点像数据库里的分区表——你把热数据和冷数据放在不同的分区里,查询和維護的成本就降下来了。这里的 vendor 层是冷数据,app 层是热数据,index.html 是元数据。
CI 流水线里怎么验证效果
做完这个优化后,你可以在 CI 脚本里加一段验证逻辑,确保分层确实生效了。我用的是 GitLab CI,在 build 阶段后面加了一个 job:
verify-layers:
image: docker:24-cli
script:
- docker pull $CI_REGISTRY_IMAGE:$CI_COMMIT_REF_SLUG || true
- docker build --cache-from $CI_REGISTRY_IMAGE:$CI_COMMIT_REF_SLUG -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA .
- |
echo "=== Layer history ==="
docker history $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA --format "table {{.CreatedBy}}\t{{.Size}}" | head -10
- |
LAST_BUILD_ID=$(docker images --filter "reference=$CI_REGISTRY_IMAGE" --format "{{.ID}}" | head -1)
if [ -n "$LAST_BUILD_ID" ]; then
echo "=== Comparing with previous build ==="
docker history $LAST_BUILD_ID --format "{{.ID}}" > /tmp/prev_layers.txt
docker history $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA --format "{{.ID}}" > /tmp/curr_layers.txt
SHARED_LAYERS=$(comm -12 /tmp/prev_layers.txt /tmp/curr_layers.txt | wc -l)
echo "Shared layers (cached): $SHARED_LAYERS"
fi
推镜像的时候观察输出,如果 vendor 层显示 "Already exists",就说明缓存命中成功了。
常见问题
这个方案对 Docker BuildKit 的 --mount=type=cache 还有意义吗?
有意义,两者解决的是不同层面的问题。BuildKit 的 cache mount 解决的是构建阶段的中间产物缓存(比如 node_modules、Webpack 缓存目录),它能加速 npm run build 本身。而这个分层 COPY 方案解决的是最终镜像层的缓存——即使 npm run build 已经通过 BuildKit 加速到 12 秒完成,最后推送镜像时如果整个 /usr/share/nginx/html 层都重建,该传的 3MB 数据还是得传。两者配合使用效果最好。
如果我的项目用 Code Splitting 拆了很多异步 chunk,这些 chunk 该放 vendor 还是 app?
取决于这些 chunk 的变动频率。如果你的异步 chunk 是从 node_modules 里拆出来的(比如 react-router、moment),它们跟 vendor 一样稳定,放进 vendor 层。如果是业务模块的异步加载(比如 pages/Dashboard.chunk.js),它们跟业务代码一样频繁变动,放进 app 层。判断标准很简单:改了 src/ 下的业务代码后,这个 chunk 的文件名哈希会不会变?会变就放 app,不会变就放 vendor。
nginx.conf 的 COPY 放在最前面,改 Nginx 配置岂不是会让后面所有层重建?
对,但这是故意的。nginx.conf 的变动频率极低(通常只在项目初期或架构调整时改动),把它放在最前面可以让它的缓存尽可能被复用。如果你确实频繁调整 Nginx 配置,可以把它移到 vendor 层之后、app 层之前。但说实话,如果 Nginx 配置都在频繁改,那你的项目可能正处在比镜像缓存更需要关注的问题上。