Next.js standalone Docker 镜像分层优化
最近给一个 Next.js 博客构建镜像时,我发现一个很别扭的问题:明明只改了几行组件代码,docker push 还是要上传几十 MB。
我最先怀疑的是 Docker 构建上下文太大。检查 .dockerignore 和构建日志后,发现发送给 Docker 的文件只有几 MB,问题不在这里。真正变化的是最终镜像层。
standalone 为什么会让整个层失效
Next.js 配置 output: "standalone" 后,构建目录中会生成运行应用所需的最小文件集合:
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "standalone",
};
export default nextConfig;常见的 Dockerfile 会直接复制整个 standalone 目录:
COPY --from=builder /repo/apps/site/.next/standalone ./写起来很省事,但这里有个容易忽略的问题:一次 COPY 通常对应一个镜像层。standalone 目录里既有相对稳定的 node_modules,也有每次构建都会变化的 .next 应用产物。
假设目录大小是:
node_modules 25 MB
apps/site/.next 70 MB改一行代码后,.next 发生变化,整个 COPY 层的摘要也会变化。镜像仓库按层保存压缩数据,不会拿新旧层里的每个文件做增量同步,因此这个接近 95 MB 的层需要重新推送。经过压缩后,终端里看到的通常就是几十 MB。
这和 npm ci 有没有命中缓存是两件事。依赖安装层即使显示 CACHED,最终运行镜像里的 standalone 层仍可能重新上传。
把依赖和应用产物拆开
最直接的改法是保持 standalone 的目录结构,但不要一次复制整个目录:
# syntax=docker/dockerfile:1
FROM node:24-alpine AS builder
WORKDIR /repo
COPY package.json package-lock.json ./
COPY apps/site/package.json ./apps/site/package.json
RUN --mount=type=cache,target=/root/.npm \
npm ci --workspace apps/site --include-workspace-root
COPY apps/site ./apps/site
RUN --mount=type=cache,target=/repo/apps/site/.next/cache \
npm run build -w apps/site
FROM node:24-alpine AS runner
ENV NODE_ENV=production
ENV PORT=3000
ENV HOSTNAME=0.0.0.0
WORKDIR /app
COPY --link --from=builder /repo/apps/site/.next/standalone/node_modules ./node_modules
COPY --link --from=builder /repo/apps/site/public ./apps/site/public
COPY --link --from=builder /repo/apps/site/.next/standalone/apps/site ./apps/site
COPY --link --from=builder /repo/apps/site/.next/static ./apps/site/.next/static
WORKDIR /app/apps/site
EXPOSE 3000
CMD ["node", "server.js"]# syntax=docker/dockerfile:1 是什么
这一行不是普通注释,也不会进入镜像层。它是 Dockerfile 的 parser directive,用来告诉 BuildKit 使用 docker/dockerfile:1 这个 Dockerfile frontend 解析后面的指令。这里的 1 表示最新稳定的 1.x 语法,不是固定的 1.0。
它不是所有 Dockerfile 的必填项。删掉后,BuildKit 会使用 Docker Engine 自带的 frontend。只要自带版本已经支持当前 Dockerfile 中的语法,构建结果没有区别。
这份示例保留它是因为使用了 COPY --link,该选项至少需要 Dockerfile 1.4。显式声明语法版本可以避免较旧的内置 frontend 把 --link 当成未知参数。
代价是 BuildKit 第一次构建时可能需要拉取 docker/dockerfile:1 frontend。在 Docker Hub 访问受限的环境里,如果确认服务器自带的 Dockerfile frontend 已达到 1.4,可以删除这一行;如果不能确认,保留它并确保构建机能够拉取该 frontend。
这样拆分后,运行时依赖有了独立镜像层。只修改页面或组件时,只要依赖版本没有变化,镜像仓库就能复用原来的 node_modules 层。
这里不能只按变化频率机械排列。假如博客从 Markdown 生成 SSG 页面,文章源文件变化后,.next/standalone/apps/site/.next 也一定变化。前者是构建输入,后者是构建结果,应用产物应该放在相关输入之后。
public 与这条依赖链没有直接关系,而且通常比应用产物稳定。我在这些运行阶段的复制指令上使用了 COPY --link。BuildKit 会把每次复制保存成可独立复用的层,前面的层发生变化时,未变化的 public 层不会跟着失效。仅靠调整普通 COPY 的先后顺序,无法同时表达几条互不依赖的变化链。
复制目标也只写到 .next/standalone/apps/site,不复制整个 standalone/apps。这个目录包含 monorepo 中站点运行所需的 server.js、package.json 和 .next,复制到 ./apps/site 后仍保持 standalone 生成的运行路径。
后面的应用产物会与已有的 ./apps/site/public 目录合并,不会删除已经复制进去的文件。.next/static 同样使用独立层,最后合并到应用目录。
/root/.npm 和 .next/cache 两个缓存挂载主要用于缩短构建时间。它们不会进入最终镜像,也不会直接减少 docker push 的上传量。
为什么拆层后仍可能上传几十 MB
我检查过一次实际的 standalone 产物,应用目录约 70 MB,其中大部分位于:
.next/server/app这是静态生成页面的服务器产物。使用 generateStaticParams 预生成大量路由时,每个页面可能同时产生 HTML、RSC 和元数据文件。一次构建生成上百个静态页面,累计到几十 MB 很正常。
即使只有一个源文件变化,Next.js 也可能重新生成这些页面。它们位于同一个应用镜像层中,所以这个层仍然需要上传。分层优化能避免同时重传运行时依赖,但不能把 70 MB 的应用产物变成几百 KB 的增量补丁。
如果必须继续缩小应用层,只剩下几种会改变部署方式的选择:
- 减少构建期预生成的路由,改为按请求渲染或 ISR。
- 将体积较大的静态资源交给对象存储或 CDN。
- 在目标服务器上拉取源码并构建,避开镜像仓库上传应用层,但会增加服务器构建时间和部署依赖。
对博客来说,我更愿意保留静态生成。发布时多上传一些数据,换来更简单的运行环境和稳定的页面响应,通常是可以接受的。
如何确认到底是哪一层在上传
构建时先观察缓存命中情况:
docker build --progress=plain -f Dockerfile -t OWNER/site:latest .然后查看镜像历史:
docker history OWNER/site:latest重点比较几个 COPY 层的大小。优化后,输出中应该能分别看到依赖层和应用层。
也可以在连续两次构建后执行推送:
docker push OWNER/site:latest第二次只改一个组件。如果依赖层显示 Layer already exists,说明拆层已经生效。应用层继续上传并不代表缓存失效,而是 Next.js 构建产物确实发生了变化。
结论
Next.js standalone 镜像只改少量代码仍上传几十 MB,通常不是构建上下文的问题,而是运行时依赖和应用产物被放进了同一个镜像层。
把 node_modules 与 .next 拆开后,依赖层可以长期复用。至于静态生成产生的应用层,它本身就是完整的部署结果。只要继续使用大量 SSG 页面,代码变化后重新上传这个层就是镜像仓库按层工作的正常结果。