Next.js standalone 上 Docker:静态资源、原生依赖与 30 秒构建

output: 'standalone' 能做出很小的镜像,但它不会帮你拷 public 和 .next/static,也不会把被 webpack 打进 chunk 的数据库驱动留在 node_modules 里。记录三个坑,以及构建从 30 分钟回到 30 秒的改法。

output: 'standalone' 是 Next.js 官方推荐的容器化方式,产物很小、启动很快。但它只保证「运行时需要的东西都打包了」,不保证「你以为被拷进去的东西真的被拷进去了」。

这篇记录把 Next 15 塞进 Docker 时踩到的三个坑,以及构建时间从 30 分钟回到 30 秒的改法。

一、standalone 产物里到底有什么

next build 在开启 standalone 后会多出三个东西,它们必须分别处理:

路径 内容 要拷到哪里
.next/standalone/ server.js + 精简过的 node_modules + .next/server 镜像的工作目录
.next/static/ 浏览器端 JS/CSS chunk 工作目录/.next/static
public/ 你自己放的静态资源 工作目录/public

也就是说,standalone 只包含服务端跑起来所需的最小集合,浏览器要的文件(chunk、图片、favicon)一个都不在里面。

第一版镜像忘了拷 public 和 .next/static,现象很好认:

  • 页面 HTML 出来了(服务端渲染正常),但完全没有样式,白底黑字;
  • Network 里 /_next/static/chunks/*.js 全是 404;
  • 页面看上去像 2005 年的网站。
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/public ./public

三行都不能省。public 里可能只有几个图,但 favicon、robots.txt、上传的图片都在那儿。

二、坑二:node_modules 里没有你的数据库驱动

第二个坑更隐蔽。我在镜像里放了一个独立的引导脚本,容器启动时先跑它(等库、执行迁移、导入种子数据),结果:

Error [ERR_MODULE_NOT_FOUND]: Cannot find package 'mysql2'
imported from /app/bootstrap.mjs

奇怪的地方在于:应用本身连数据库完全正常,说明 mysql2 明明装上了。

原因:Next 打包时会用 webpack 把服务端依赖打进 chunk。lib/db/index.ts 里 import mysql from 'mysql2/promise',被内联进了 .next/server/... 的产物;而 standalone 里的 node_modules 只保留「没有被内联、需要运行时按包名解析」的那部分,所以 mysql2 不在里面。

我的引导脚本是另一个进程,从文件系统按包名 import 'mysql2/promise',自然找不到。

解法不是去补 serverExternalPackages(那会影响应用本身),而是给引导脚本单独准备一份最小依赖:

# 只装引导脚本要用的包,装在 boot/ 下
FROM base AS bootdeps
WORKDIR /boot
RUN npm init -y >/dev/null && npm install --no-audit --no-fund --omit=dev mysql2@3.24.6

# 运行镜像里放进去
COPY --from=bootdeps --chown=nextjs:nodejs /boot/node_modules ./boot/node_modules
COPY --chown=nextjs:nodejs scripts/bootstrap.mjs ./boot/bootstrap.mjs

关键是把脚本也放进 boot/,这样 ESM 解析时从 /app/boot/ 往上找,就近命中 /app/boot/node_modules,不会跟应用自己的 node_modules 打架。

entrypoint 里改成:

# 先引导(此时数据库可能还没好,脚本内部重试)
node /app/boot/bootstrap.mjs
exec node server.js

三、坑三:构建上下文太大,构建 30 分钟

第三个坑不是正确性问题,是效率问题。第一版 Dockerfile 上来就:

COPY . .
RUN npm ci && npm run build

看起来没问题,但:

  • 本地的 node_modules、.next、data/、截图目录全被塞进构建上下文,docker build 光发送上下文就要几分钟;
  • 任何一次 COPY . . 之后,只要任意文件变了,后面的 npm ci 缓存就失效,必须重新装依赖;
  • 结果一次构建 30 分钟,改一行 CSS 也要等。

改法是两件事:

1. .dockerignore 先挡住体积

node_modules
.next
.next-*
out
build
data
logs
shots
.env
.env.local
.env.*.local
*.log
tsconfig.tsbuildinfo
.git

2. 分层:依赖层只依赖 lockfile,构建层只拷真正参与构建的目录

FROM base AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci

FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
# 只拷构建需要的,不拷 README、不拷截图、不拷 docker 目录
COPY package.json next.config.mjs tsconfig.json ./
COPY app ./app
COPY lib ./lib
COPY public ./public
RUN npm run build

现在只要 package-lock.json 没变,改业务代码时的构建就是「拷几十个文件 + 跑一次 build」,30 秒左右。

代价是新增顶层目录时要记得加 COPY,忘了就会在构建时报模块找不到——这比每次等 30 分钟划算得多。

四、完整的五阶段 Dockerfile

# 1) 公共基础:只装 node 与基础工具
FROM node:22-alpine AS base
WORKDIR /app
ENV NEXT_TELEMETRY_DISABLED=1

# 2) 依赖:只吃 lockfile,缓存稳定
FROM base AS deps
COPY package.json package-lock.json ./
RUN npm ci

# 3) 引导脚本的独立依赖(解决 mysql2 解析)
FROM base AS bootdeps
WORKDIR /boot
RUN npm init -y >/dev/null && npm install --omit=dev mysql2@3.24.6

# 4) 构建
FROM base AS builder
COPY --from=deps /app/node_modules ./node_modules
COPY package.json next.config.mjs tsconfig.json ./
COPY app ./app
COPY lib ./lib
COPY public ./public
RUN npm run build

# 5) 运行
FROM base AS runner
ENV NODE_ENV=production
COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=bootdeps /boot/node_modules ./boot/node_modules
COPY scripts/bootstrap.mjs ./boot/bootstrap.mjs
COPY docker/entrypoint.sh /entrypoint.sh
USER node
EXPOSE 3000
ENTRYPOINT ["/entrypoint.sh"]

最终镜像 265 MB,其中大头是 node_modules(Next 的 server 依赖 + antd 的 SSR 相关包)。想再压只能上 node:22-alpine 已经是 alpine 了,剩下的是业务依赖,不必强求。

五、自检清单

每次改 Dockerfile 后按这几条过一遍:

  • docker run --rm 镜像 ls .next/static 有东西吗;
  • curl -I /favicon.ico、/robots.txt 是 200 吗(public 拷对了没);
  • 容器日志里 bootstrap 有没有 ERR_MODULE_NOT_FOUND;
  • docker compose build 第二次耗时是不是明显短于第一次(缓存生效);
  • 镜像里有没有 .env:docker run --rm 镜像 ls -a | grep env 应该只有 .env.example 或什么都没有。

小结

  • standalone 只解决了「服务端最小运行集」,public 和 .next/static 要自己拷;
  • 被 webpack 内联的依赖不会出现在 standalone 的 node_modules 里,任何独立进程(迁移、种子、定时任务)都要自带依赖;
  • 构建慢基本都是上下文太大 + 缓存失效,.dockerignore 与精确 COPY 能把它从 30 分钟压到 30 秒;
  • 把「引导脚本」和「应用」当成两个程序对待,是这类镜像能长期稳定维护的前提。
查看「容器与编排」全部文章 →

同分类文章

查看 0 评论 0

评论

正在加载评论…

留言提交后自动审核:正常内容立即显示,含敏感信息的会转人工复核。