Next.js standalone Docker 镜像分层优化
太阳作者太阳
原创内容采用 CC-4.0 协议发布,转载请注明出处

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.jspackage.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 页面,代码变化后重新上传这个层就是镜像仓库按层工作的正常结果。

参考资料