12k
All articles

如何将 Bun 应用容器化

使用适合生产的 Dockerfile、.dockerignore、0.0.0.0 绑定、健康检查、Compose 和 SIGTERM 优雅退出来 Dockerize Bun 应用。

OpenReplay Team
OpenReplay Team
如何将 Bun 应用容器化

一个可用于生产环境的 Bun 服务 Dockerfile 只需寥寥数行,但一个能在本地运行的 Dockerfile 与一个能够顺利完成部署的 Dockerfile 之间的差距,归结为四点:固定的基础镜像、非 root 用户、绑定到 0.0.0.0 的服务,以及能够在进程终止前处理完正在处理的请求的 SIGTERM 处理器。本文将提供可直接使用的 Dockerfile,并逐一解决上述每个问题,所有代码均可直接复制使用。

你已经有了一个 Bun 应用——可能是 API、worker 或 BFF(Backend-for-Frontend)——它在本地运行正常。你需要将其容器化,同时避免踩 Bun Docker 的那些坑。以下内容涵盖:最简 Dockerfile、层缓存、多阶段编译为独立二进制文件、生产环境加固、集成 Postgres 的 Docker Compose 配置、Bun 内置服务器的优雅关闭处理器,以及四种最常见的”静默故障”及其修复方案对照表。

核心要点

  • 在容器内部仅监听 127.0.0.1 的 Bun 服务无法通过已发布的端口访问;需绑定到 0.0.0.0(所有 IPv4 接口),以便 Docker 的端口发布机制能够将流量转发至该服务。
  • Bun 1.2 于 2025 年 1 月 22 日发布,将默认 lockfile 格式更改为基于文本的 bun.lock,取代了旧版二进制格式的 bun.lockb;可通过 bun install --save-text-lockfile --frozen-lockfile --lockfile-only 完成迁移。
  • 未指定标签时,Docker 镜像引用默认使用 latest,这可能导致每次部署时 Bun 运行时版本发生隐式变更;应固定明确的标签,例如 oven/bun:1
  • 在应用源代码之前先复制 package.jsonbun.lock,这样当仅有源代码发生变更时,Docker 的层缓存可以跳过 bun install 步骤。
  • 若没有 SIGTERM 处理器,Docker 发送 SIGTERM 后会等待默认停止超时时间(Linux 容器默认为 10 秒),若进程未退出则发送 SIGKILL,这可能导致正在处理的请求被中断丢失。

最简 Dockerfile

四行代码即可将 Bun 应用打包进容器。这是从本地运行到构建镜像的最快路径。

FROM oven/bun
COPY . .
RUN bun install
CMD ["bun", "run", "src/index.ts"]

构建命令:

docker build -t bun-app .
docker run -p 3000:3000 bun-app

Bun 可直接执行 TypeScript,因此无需 tsc 构建阶段——入口点直接指向 .ts 文件。将 src/index.ts 替换为你实际的入口文件。基础镜像为 Docker Hub 上的官方 oven/bun 镜像。

这个配置适合用于临时测试,不适合生产环境。FROM oven/bun 这一行会隐式拉取 latest 版本,将整个目录(包括 node_modules.env)复制进去,以 root 身份运行,并且每次代码变更都会重新安装依赖。本文后续内容将逐一解决这些问题。

添加 .dockerignore

.dockerignore 文件能够精简构建上下文,防止敏感信息和本地产物被复制进镜像。在进行其他任何操作之前,请先创建该文件。

node_modules
.git
.gitignore
.env
.env.*
dist
*.log
.vscode
README.md

其中有两条最为关键。排除 node_modules 是因为 bun install 会在镜像内部根据 lockfile 重新生成该目录——将宿主机的 node_modules 复制进去会带入平台相关的二进制文件,这些文件可能与容器环境不兼容。排除 .env.env.* 是为了防止本地密钥进入镜像层——一旦写入镜像层,即使后续层删除了该文件,内容依然可以被恢复。

在 Dockerfile 中应使用 bun.lock 还是 bun.lockb

建议使用 bun.lock。Bun 1.2 于 2025 年 1 月 22 日发布,将默认 lockfile 格式更改为基于文本的 bun.lock,取代了旧版二进制格式的 bun.lockb。旧版教程仍然引用 bun.lockb,在 Dockerfile 中复制错误的文件名会导致令人困惑的 --frozen-lockfile 构建失败。

如果你的仓库中仍存在二进制 lockfile,请执行一次性迁移:

bun install --save-text-lockfile --frozen-lockfile --lockfile-only
# 然后删除 bun.lockb 并提交 bun.lock

该命令在 Bun lockfile 文档中有详细说明。迁移完成后,在 Dockerfile 中统一引用 bun.lock

层缓存:将依赖安装与源代码分离

在复制应用源代码之前先复制 package.jsonbun.lock,这样当仅有源代码发生变更时,Docker 的层缓存可以跳过 bun install 步骤——这是提升本地迭代速度最有效的单项优化。

FROM oven/bun:1

WORKDIR /app

# 先复制 lockfile 和 package.json——仅当依赖发生变更时此层才会失效
COPY package.json bun.lock ./
RUN bun install --frozen-lockfile

# 最后复制源代码——修改代码只会从此处开始重新构建
COPY src ./src
COPY tsconfig.json ./

CMD ["bun", "run", "src/index.ts"]

Docker 将每条指令缓存为一个层,当输入未发生变化时复用已缓存的层。由于 COPY package.json bun.lock 位于 COPY src 之前,修改源文件不会影响依赖层,bun install 将被完全跳过。bun install --frozen-lockfile 会在 bun.lockpackage.json 不同步时使构建失败——这在 Dockerfile 中是正确的行为,因为它能在构建阶段而非运行时暴露 lockfile 漂移问题。该标志的行为在 Bun install CLI 文档中有详细说明。

注意 COPY tsconfig.json 这一行。Bun 会读取 tsconfig.json 中的路径别名和编译器选项;如果你的应用使用了路径映射但忘记复制该文件,即使在本地运行正常,容器内启动时模块解析也会失败。

多阶段构建:编译为独立二进制文件

bun build --compile 可以创建一个自包含的可执行文件,将你的代码、npm 包、静态资源以及 Bun 运行时打包在一起,因此最终镜像无需单独安装 Bun。在多阶段构建中使用该功能,只将二进制文件复制到最小化基础镜像中,从而获得更小的最终镜像。

# --- 构建阶段 ---
FROM oven/bun:1 AS build
WORKDIR /app

COPY package.json bun.lock ./
RUN bun install --frozen-lockfile

COPY src ./src
COPY tsconfig.json ./

# 编译为包含 Bun 运行时的单一可执行文件
RUN bun build ./src/index.ts --compile --outfile server

# --- 运行阶段 ---
FROM debian:12-slim
WORKDIR /app

COPY --from=build /app/server /app/server

USER nobody
EXPOSE 3000
CMD ["/app/server"]

构建阶段负责安装依赖并进行编译。运行阶段从 debian:12-slim 开始,仅复制编译好的 server 二进制文件——不包含 Bun 镜像、node_modules 或源代码。由于二进制文件已内嵌运行时,无需预先安装 Bun 即可直接运行。镜像体积的实际缩减量取决于依赖数量和静态资源大小,建议在自己的应用上实测后再引用具体数字。

生产环境加固

固定明确的 Bun 标签、以非 root 用户运行,以及仅安装生产依赖。这三项改动能将一个可运行的 Dockerfile 升级为可部署的生产级配置。

FROM oven/bun:1-alpine

WORKDIR /app

# 创建非 root 用户(Alpine 语法)
RUN addgroup -S appgroup && adduser -S appuser -G appgroup

COPY package.json bun.lock ./
RUN bun install --frozen-lockfile --production

COPY src ./src
COPY tsconfig.json ./

RUN chown -R appuser:appgroup /app
USER appuser

ENV NODE_ENV=production
EXPOSE 3000

CMD ["bun", "run", "src/index.ts"]

固定标签。 未指定标签时,Docker 镜像引用默认使用 latest,这意味着 docker pull 可能在两次部署之间隐式更换 Bun 运行时版本。固定明确的 Bun 标签可确保运行时升级是有意为之且可追溯的——Bun 的官方 Docker 指南使用 oven/bun:1。Docker Hub 上也提供 1-alpine 变体以减小镜像体积;由于 Alpine 使用 musl libc 而非 glibc,在生产环境采用前请先验证你的依赖是否兼容。

以非 root 用户运行。 容器默认以 root 用户运行。创建 appuser 并通过 USER appuser 切换至该用户,可以在进程被攻击时降低影响范围——这是 Docker 安全指南的基本要求。

仅安装生产依赖。 bun install --frozen-lockfile --production 会跳过 devDependencies,使镜像更小,攻击面更窄。

为什么我的 Bun 容器无法通过已发布的端口访问?

在 Docker 容器内部仅监听 127.0.0.1 的 Bun 服务无法通过已发布的端口访问。需绑定到 0.0.0.0(所有 IPv4 接口),以便 Docker 的端口发布机制能够将流量转发至该服务。

这是 Dockerized Bun 服务中最常见的静默故障。容器启动了,日志看起来正常,docker ps 显示端口已映射——但来自宿主机的每个请求都会挂起或返回连接错误。根本原因在于监听地址的配置。

对于原生 Bun.serve(),需显式设置 hostname

// src/index.ts
const port = Number(process.env.PORT) || 3000;

Bun.serve({
  port,
  hostname: "0.0.0.0", // 在 Docker 内部必须设置此项
  fetch(req) {
    return new Response(JSON.stringify({ status: "ok" }), {
      headers: { "Content-Type": "application/json" },
    });
  },
});

console.log(`Listening on http://0.0.0.0:${port}`);

Bun.serve() 的选项在 Bun HTTP API 文档中有详细说明。对于 Elysia,需配置 serve.hostname: "0.0.0.0"。对于其他运行在 Bun 上的框架,请确保底层 Bun 服务器配置为监听 0.0.0.0

前端与 Bun 服务通信的会话回放可以在 Dockerfile 层面暴露这一故障。绑定到 localhost 的服务在浏览器中表现为部署后立即出现一连串 fetch/XHR 超时——这种症状看起来像前端问题,实际上根源在于容器的监听地址配置。OpenReplay 等工具能够捕获这种客户端故障模式,而这类配置错误通常也正是在这个层面首先被发现的。

集成 Postgres 的 Docker Compose 配置

以下 Docker Compose 文件可以同时运行 Bun 应用和 PostgreSQL,在数据库通过健康检查后再启动应用,并在应用崩溃时自动重启。

# compose.yaml
services:
  api:
    build:
      context: .
      dockerfile: Dockerfile
    ports:
      - "3000:3000"
    env_file:
      - .env.production
    depends_on:
      postgres:
        condition: service_healthy
    restart: unless-stopped
    healthcheck:
      test: ["CMD", "wget", "--spider", "-q", "http://localhost:3000/health"]
      interval: 30s
      timeout: 5s
      retries: 3

  postgres:
    image: postgres:16-alpine
    environment:
      POSTGRES_USER: app
      POSTGRES_PASSWORD: secret
      POSTGRES_DB: appdb
    volumes:
      - pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app"]
      interval: 10s
      timeout: 5s
      retries: 5

volumes:
  pgdata:

带有 condition: service_healthydepends_on 会阻止 api 服务启动,直到 Postgres 报告健康状态。这要求被依赖的服务配置了健康检查——若未配置,service_healthy 条件将永远无法满足。restart: unless-stopped 会在应用崩溃后自动重启,但会遵守手动停止操作。

注意顶层没有 version: 键。Compose 规范已将顶层 version 属性标记为废弃;Compose 在进行 schema 选择时会忽略该属性,若存在则会发出警告。建议省略该字段。

环境变量:不要将密钥写入镜像

通过运行时的 env_file 传递配置,而非将密钥复制进镜像层。镜像层是持久化且可检查的,因此写入其中的密钥即使被后续指令删除,仍然可以被恢复。

上面的 Compose 服务配置已通过 env_file 引用了 .env.production。在代码中通过 process.env 读取这些值:

// src/config.ts
export const config = {
  port: Number(process.env.PORT) || 3000,
  databaseUrl: process.env.DATABASE_URL ?? "",
  jwtSecret: process.env.JWT_SECRET ?? "",
};

通过 .dockerignore 中的 .env.* 条目将 .env.production 排除在镜像之外,并通过 .gitignore 中的对应条目将其排除在版本控制之外。镜像保持通用性,密钥在运行时注入。

开发工作流:通过绑定挂载实现热重载

在本地开发时,使用单独的 Dockerfile 运行 bun --hot,并将源代码以卷的形式挂载,使保存操作能够立即在容器内触发重载。请将此配置与生产环境 Dockerfile 分开维护。

# Dockerfile.dev
FROM oven/bun:1-alpine
WORKDIR /app

COPY package.json bun.lock ./
RUN bun install --frozen-lockfile

COPY . .
EXPOSE 3000

CMD ["bun", "--hot", "src/index.ts"]
# compose.dev.yaml
services:
  api-dev:
    build:
      context: .
      dockerfile: Dockerfile.dev
    ports:
      - "3000:3000"
    volumes:
      - .:/app
      - /app/node_modules
    env_file:
      - .env.development

--hot 标志启用 Bun 的热重载功能:保存文件后,运行中的服务器会更新,而无需完整重启进程。绑定挂载 .:/app 使宿主机的编辑操作在容器内可见,而匿名卷 /app/node_modules 则防止宿主机目录覆盖构建时安装的依赖。使用 docker compose -f compose.dev.yaml up --build 启动。

优雅关闭:处理 SIGTERM 信号

添加 SIGTERM 处理器,确保正在处理的请求在进程退出前完成。若没有该处理器,Docker 发送 SIGTERM 后会等待默认停止超时时间(Linux 容器默认为 10 秒),若进程未退出则发送 SIGKILL,这可能导致正在处理的请求被中断丢失。

Bun.serve() 返回一个带有 .stop() 方法的 server 对象。在收到 SIGTERM 时 await 该方法,以便在退出前处理完正在进行的请求:

// src/index.ts
const server = Bun.serve({
  port: Number(process.env.PORT) || 3000,
  hostname: "0.0.0.0",
  fetch() {
    return new Response("ok");
  },
});

const shutdown = async (signal: string) => {
  console.log(`Received ${signal}, draining connections`);
  await server.stop();
  process.exit(0);
};

process.on("SIGTERM", () => shutdown("SIGTERM"));
process.on("SIGINT", () => shutdown("SIGINT"));

server 实例及其 .stop() 方法是 Bun HTTP API 的一部分。核心模式是捕获 Bun.serve() 返回的 server 实例,并在收到终止信号时调用其停止方法。

镜像体积参考

基础镜像的选择是影响最终镜像体积的主要因素。Docker Hub 为每个 oven/bun 变体发布了压缩后的体积数据;以下数据来自 oven/bun:1.3.14(linux/amd64)的 Docker Hub 标签页

基础镜像变体压缩后体积
oven/bun:1.3.14基于 Debian81.93 MB
oven/bun:1.3.14-slimDebian slim63.32 MB
oven/bun:1.3.14-alpineAlpine40.87 MB
oven/bun:1.3.14-distrolessDistroless40.52 MB

以上是基础镜像的体积,并非你的最终应用镜像体积。你的镜像还需在此基础上叠加依赖和源代码。要测量自己的镜像体积,可运行:

docker images --format "table {{.Repository}}:{{.Tag}}\t{{.Size}}"

debian:12-slim 上使用编译二进制文件的多阶段构建变体可以进一步缩减最终镜像体积,因为它省去了完整的 Bun 镜像和 node_modules,但实际缩减幅度取决于你的依赖规模——建议实测而非引用通用百分比。

Bun Docker 容器最常见的故障有哪些?

Dockerized Bun 容器静默失败最常见的四种原因是:入口点路径错误、绑定到 localhost、lockfile 过期,以及缺少 tsconfig.json 复制步骤。每种情况都有其独特的症状和一行代码的修复方案。

症状根本原因修复方案
容器立即退出,退出码为 1CMD 中的入口点路径错误,或入口文件未被复制验证 CMD ["bun", "run", "src/index.ts"] 中的路径与已复制的文件一致;检查 COPY src ./src 是否已执行
端口已映射但无法访问服务绑定到了 127.0.0.1/localhostBun.serve({ hostname: "0.0.0.0" }) 中绑定到 0.0.0.0
构建在 bun install 阶段中止bun.lockpackage.json 不同步在本地运行 bun install,提交更新后的 bun.lock;之后 --frozen-lockfile 即可通过
启动时模块解析失败tsconfig.json 路径别名无法解析——文件未被复制CMD 之前添加 COPY tsconfig.json ./

当容器在你来得及检查之前就已退出时,可以在镜像上打开一个 shell 来查看实际复制了哪些文件:

docker run -it  --rm  --entrypoint  sh bun-app
# 进入后执行:ls /app, cat package.json 等

lockfile 不匹配的情况是最常被误判为 Docker 问题的一种。它实际上是一个被 Docker 暴露出来的 Bun 问题——bun install --frozen-lockfile 拒绝基于漂移的 lockfile 进行构建,这正是其应有的行为,与 Bun install 文档中的描述完全一致。

总结

至此,你已掌握所有必要内容:固定版本、非 root 用户的生产级 Dockerfile;多阶段编译二进制文件变体;带有健康检查 Postgres 的 Compose 配置;热重载开发工具;以及能够优雅排空请求的 SIGTERM 处理器。从将 oven/bun 替换为 oven/bun:1 并添加 0.0.0.0 绑定开始——这两处修改能消除首次部署中最可能遇到的两种故障,之后随着服务的成长再逐步引入其余配置。

常见问题

在 Bun Docker 容器中运行 TypeScript 是否需要单独的构建步骤?

不需要。Bun 可以直接执行 TypeScript,因此 Docker 构建中无需 tsc 或 tsx 编译阶段。CMD 直接指向 .ts 入口文件,例如 CMD ['bun', 'run', 'src/index.ts']。唯一需要注意的是路径别名:如果你的应用使用了 tsconfig.json 路径映射,必须将 tsconfig.json 复制进镜像,否则即使本地运行正常,容器内启动时模块解析也会失败。

何时应使用 bun build --compile 编译为二进制文件,而非在容器中直接运行 bun run?

当你希望获得更小的最终镜像且运行阶段无需安装 Bun 时,请使用编译二进制文件的多阶段构建。bun build --compile 会生成一个自包含的可执行文件,将代码、npm 包、静态资源和 Bun 运行时打包在一起,因此只需将该二进制文件复制到 debian:12-slim 等最小化基础镜像中,无需 node_modules 或 Bun 镜像。对于更简单的构建场景,或在开发阶段需要热重载等运行时特性时,使用普通的 bun run 即可。

oven/bun 镜像的 alpine 变体是否适用于所有依赖?

不一定。oven/bun:1-alpine 变体使用 musl libc,而默认的基于 Debian 的 oven/bun:1 镜像使用 glibc。针对 glibc 编译的原生依赖,或仅提供 glibc 预构建二进制文件的包,在 Alpine 上可能会运行失败。Alpine 镜像体积更小,但在生产环境采用之前,请先验证你的依赖树能否正常构建和运行,不要假设可以直接替换。

为什么我的容器在 bun install 阶段因 frozen-lockfile 错误而构建失败?

构建中止是因为 bun.lock 与 package.json 不同步,而 bun install --frozen-lockfile 拒绝基于漂移的 lockfile 进行构建。这在 Dockerfile 中是正确的行为:它能在构建阶段而非运行时暴露 lockfile 漂移问题。修复方法是在本地运行 bun install 重新生成 bun.lock,然后提交更新后的文件。如果你的 Dockerfile 仍然引用旧版二进制格式的 bun.lockb,请将其切换为 bun.lock,因为 Bun 1.2 已将基于文本的格式设为默认格式。

Understand every bug

Uncover frustrations, understand bugs and fix slowdowns like never before with OpenReplay — self-hosted, with full data ownership.

Star on GitHub

We use cookies to improve your experience. By using our site, you accept cookies.