一次 Next.js Docker 镜像体积与文章预生成取舍的实测

2026年07月31日1 次阅读0 人喜欢
DockerNext.js性能优化CI/CD架构实践

结论先说

这次对 Next.js 生产镜像和文章详情页做了本地 Docker 实测,最终采用两个修复,并保留文章详情页预生成:

  • .dockerignore 排除文档、设计素材、代码索引和部署辅助目录,避免它们进入 Docker 构建上下文或被 Next.js standalone tracing 带入运行镜像。
  • Dockerfile.prod 使用 COPY --chown,并且只对真正需要运行时写入的 logs.next/cache.next/server/app 做权限处理,避免对整个 .next 递归 chown 产生重复大层。
  • 继续使用 generateStaticParams 预生成文章详情页。生产环境已有 CDN,预生成仍然能覆盖 CDN 未命中、缓存清理、搜索引擎抓取和源站冷启动等场景。

背景

项目使用 Next.js standalone 输出,生产镜像由 Dockerfile.prod 构建。文章详情页位于 src/app/[year]/[month]/[date]/[title]/page.tsx,通过 generateStaticParams() 查询文章路径并在构建期间生成静态页面,同时设置 revalidate = 3600

最初的 Dockerfile 在 builder 阶段执行 COPY . .,而 .dockerignore 没有排除 docsassets.codegraph.deploy 等目录。虽然其中一部分不会最终进入运行镜像,但它们会增加构建上下文;其中被 standalone tracing 判断为运行时依赖或项目文件的内容,还可能继续进入最终镜像。

实验口径

实验在本地 Docker Desktop 上完成,镜像架构是当前本机架构,生产环境如果构建 amd64,绝对体积可能略有差异,但各项变化的方向和层级关系具有参考价值。每个实验都使用同一份代码、同一份文章数据和同一套构建参数,分别检查镜像大小、压缩导出大小、运行时目录以及文章预生成结果。

构建数据库连接参数时还遇到一个容易混淆的问题:项目使用 Prisma MariaDB adapter,构建参数需要 mariadb:// 协议;本地 .env 中的旧 mysql:// 写法会让 Prisma 在构建阶段直接报连接串格式错误。这次只在构建命令中转换参数,没有修改项目配置。

镜像体积对比

方案 Docker 镜像显示大小 gzip 导出大小 运行时 /app 详情页预生成
基线 852MB 约 189.1MB 284.8MB 保留,186 条路径
只排除开发/设计/部署文件 763MB 约 145.3MB 241.6MB 保留,186 条路径
不预生成文章详情页 643MB 约 164.3MB 197.5MB 不保留
只优化权限层 641MB 约 156.7MB 内容基本不变 保留,186 条路径
两项修复后的最终镜像 551MB 约 112.9MB 241.6MB 保留,186 条路径

基线构建上下文在 Docker 输出中约为 291.98MB;排除无关内容后最终构建上下文约为 68.70kB。这里的上下文传输值是 Docker BuildKit 的传输统计,不应简单理解为目录原始字节数,但足以说明排除规则已经生效。

基线运行镜像中确实可以看到 docs 约 20.2MB、assets 约 22.8MB,以及其他项目文件。.codegraph.deploy 等内容虽然没有全部进入最终运行镜像,但仍然会参与构建上下文传输和构建过程,因此一起排除更合理。

权限层的差异更大:原 Dockerfile 在复制 standalone 内容后执行 chown -R nextjs:nodejs logs .next,会让大范围 .next 内容在镜像层中产生额外变化。改成 COPY --chown 后,只有可写目录单独处理权限,最终镜像从基线 852MB 降到 551MB。

文章预生成到底占多少空间

本次基线构建得到 186 条文章详情路径,对应 2,232 个文章相关输出文件,直接文件内容合计约 84,488 KiB,也就是约 82.5MiB。它是 .next/server/app 体积中最主要的增长来源之一。

但要区分两种口径:文章静态文件本身约 82.5MiB;由于 Docker 分层、standalone 输出、权限变更和压缩方式不同,删除预生成后镜像显示大小并不会只减少 82.5MiB。实验中不预生成的运行时 /app 比基线少约 87.3MB,而 Docker 镜像层的变化更大。

不预生成会慢多少

使用同一篇文章 /2022/01/11/github-action 做源站直连测试,分别启动预生成和不预生成的冷容器:

  • 预生成容器首次请求约 27~40ms。
  • 不预生成容器首次请求约 958ms~1.31s。
  • 不预生成首次生成完成后,后续命中缓存约 6~8ms,与预生成容器基本相同。

因此不预生成的主要代价不是每次请求都慢,而是某个路径在当前实例或当前缓存层第一次未命中时,需要源站现场生成一次,实测多出约 0.93~1.27 秒。项目当前返回的缓存策略包含 s-maxage=3600stale-while-revalidate,外层 CDN 命中后,大多数用户不会感知这次源站生成。

不过 CDN 不能完全消除这个问题:发布文章后的首次访问、CDN 刷新后的回源、不同节点首次访问、源站重启后的冷缓存,以及搜索引擎抓取,都可能触发源站请求。预生成把这一次成本放到了构建期间,也让源站在发布后更稳定。

最终修改

.dockerignore 现在排除了以下内容:

text 复制代码
docs
assets
.codegraph
.claude
.agents
.deploy
performance
*.md

这些目录和 Markdown 文件属于开发、设计、索引、部署或项目说明内容,不是生产运行时必需品。生产构建仍然从源码、配置模板和依赖中生成 Next.js 输出。

Dockerfile.prod 的运行阶段现在使用 COPY --chown=nextjs:nodejs;运行时只创建并授权 logs.next/cache.next/server/app。没有删除文章预生成输出,也没有修改 generateStaticParams()

最终结论

当前生产方案选择保留文章详情页预生成。原因是它只增加约 82.5MiB 的文章静态输出,却换来了源站冷请求稳定的首屏速度;而 CDN 继续负责绝大多数用户请求的边缘缓存。

镜像方面,优先落地 .dockerignore 和权限层修复。两项修复后的本地镜像从 852MB 降到 551MB,gzip 导出从约 189.1MB 降到约 112.9MB,同时 docs、assets 等无关内容不再进入运行镜像,文章详情页仍然保持 186 条预生成路径。

如果未来文章数量增长到几千、几万篇,再单独评估按需生成、分批构建或把静态页面迁移到对象存储/CDN;以当前规模和已有 CDN 架构,保留预生成是更稳妥的选择。

加载评论中...