项目:Nuxt 4.5.2 + Nitro 2.13.4 + bun 1.3.14
背景与目标
项目原本使用 node:26-alpine + pnpm 进行构建和运行,镜像体积约 196MB。本地开发环境已经切换到 bun,但 Dockerfile 仍停留在 node+pnpm。
目标是统一使用 bun,缩小镜像体积、加快构建速度。
尝试一:bun build --compile 生成独立二进制(失败)
思路:像 Go 一样编译成单个 ELF 二进制,省去 node_modules,实现最小化部署。
text
bun build ./server/index.ts --compile --outfile server
初步结果:
-
编译成功,生成约 100MB 的独立可执行文件
-
SSR 首页返回 200
-
API 路由正常工作
问题:
-
静态资源
/_nuxt/*.js返回 500 -
错误:
ENOENT: /$bunfs/public/_nuxt/xxx.js
根因:
Nitro 的 bun preset 通过 serveStatic('/$bunfs/public/') 服务静态资源。bun build --compile 会将代码和依赖打包进二进制,但不会嵌入 Nitro 运行时动态读取的 public 目录文件。这是 Nitro 层面的兼容缺陷,与 bun 的编译机制无关。
尝试了 7 种绕过方案(symlink、wrapper、monkey-patch fs、asset-naming 等),全部失败。
结论: 在当前 Nitro 版本下,bun build --compile 不适用于 Nuxt SSR 生产部署。
尝试二:bun run 模式(成功)
改用 bun 作为运行时执行 Nitro 产物,不编译成独立二进制:
bash
bun run .output/server/index.mjs
SSR、静态资源、API 路由全部正常工作,启动时间约 30ms。
最终方案:全 bun Dockerfile
dockerfile
FROM oven/bun:1-alpine AS build
WORKDIR /app
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
COPY packages ./packages
COPY apps/web ./apps/web
RUN bun install
WORKDIR /app/apps/web
RUN NITRO_PRESET=bun bun run build
# 关键:在 .output/server 内扁平化安装依赖
WORKDIR /app/apps/web/.output/server
RUN bun install --production --no-cache && rm -rf /root/.bun
FROM alpine:3.21
COPY --from=oven/bun:1-alpine /usr/local/bin/bun /usr/local/bin/bun
RUN apk add --no-cache libgcc libstdc++ && chmod +x /usr/local/bin/bun
WORKDIR /app
COPY --from=build /app/apps/web/.output .output
EXPOSE 3200
ENV NITRO_PORT=3200
CMD ["bun", "run", ".output/server/index.mjs"]
关键设计点
1. build 阶段全 bun
bun install 替代 pnpm,速度从约 30s 降至约 9s。bun run build 替代 pnpm build。
2. NITRO_PRESET=bun
让 Nitro 生成 bun 原生 SSR 产物。
3. .output/server 二次安装依赖
pnpm 的 workspace 结构使用符号链接,bun 无法完全解析。在 .output/server 目录内执行 bun install --production 可以将依赖扁平化,解决 Cannot find module '@vue/shared' 类问题。
4. 清理 bun 缓存
bun install 默认在 /root/.bun 缓存下载的包(约 155MB)。用 --no-cache 配合 rm -rf /root/.bun 清理,避免镜像膨胀。
5. 运行阶段最小化
从 oven/bun:1-alpine 只复制 bun 二进制(约 74.5MB),运行在 alpine:3.21(约 8.5MB)之上,不依赖完整镜像。
镜像分层
| 层 | 大小 |
|---|---|
| alpine 基础 | 8.5MB |
| bun 二进制 | 74.5MB |
| libgcc/libstdc++ | 3.0MB |
| .output(含 node_modules) | 48.2MB |
| 合计 | 180MB |
效果对比
| 指标 | 旧方案(node+pnpm) | 新方案(全 bun) |
|---|---|---|
| 镜像体积 | 196MB | 180MB |
| 构建工具链 | node + pnpm | bun |
| 运行时体积 | node(141MB) | bun(74.5MB) |
| 依赖安装速度 | ~30s | ~9s |
| SSR | ✅ | ✅ |
| 静态资源 | ✅ | ✅ |
| API 路由 | ✅ | ✅ |
验证通过:docker run 后访问首页和 favicon 均返回 200,日志显示 Listening on http://localhost:3200/。
踩坑记录
坑 1:bun install 缓存导致镜像膨胀
bun install 默认在 /root/.bun 缓存下载的包,约 155MB。多阶段构建中即使不保留该目录,若未在构建阶段清理,缓存层仍会被带入最终镜像。
解决: --no-cache + rm -rf /root/.bun
坑 2:pnpm 符号链接与 bun 不兼容
pnpm 的 node_modules 使用符号链接结构,bun 运行时无法完全解析,导致 Cannot find module 错误。
解决: 在 .output/server 内重新执行 bun install --production,将依赖扁平化(实化符号链接)。
坑 3:Nitro bun preset 静态资源路径
Nitro 的 bun preset 使用 /$bunfs/public/ 虚拟路径服务静态资源。bun build --compile 不会将这些文件嵌入二进制,导致静态资源 500。
解决: 放弃 bun build --compile,改用 bun run 模式运行 .output 产物。
经验总结
-
bun run 是目前 Nuxt SSR 项目中最可靠的 bun 部署方式 。
bun build --compile适合纯 CLI 工具或无静态资源的 API 服务,不适合需要 serveStatic 的 SSR 应用。 -
pnpm workspace 迁移到 bun 时需注意符号链接处理。在产物目录内二次安装依赖是有效的缓解手段。
-
bun 的缓存机制在 Docker 构建中需要主动清理,否则会显著增加镜像体积。
-
总镜像体积从 196MB 降到 180MB,虽然减少幅度不大,但构建速度提升明显(30s → 9s),运行时也更轻量。