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 秒; - 把「引导脚本」和「应用」当成两个程序对待,是这类镜像能长期稳定维护的前提。
正在加载评论…