BunアプリケーションをDockerizeする方法
Bunアプリを本番向けDockerfile、.dockerignore、0.0.0.0バインド、healthcheck、Compose、SIGTERM終了でDocker化します。
本番環境に耐えうるBunサービス用のDockerfileはわずか数行で書けますが、ローカルで動作するDockerfileとデプロイに耐えられるDockerfileの差は、4つの点に集約されます。それは、ピン留めされたベースイメージ、非rootユーザー、0.0.0.0にバインドされたサービス、そしてインフライトリクエストをドレインするSIGTERMハンドラーです。本記事では動作するDockerfileを提示した上で、それぞれのギャップをコピー&ペースト可能なコードで埋めていきます。
あなたはBunアプリ(API、ワーカー、またはBFF)をローカルマシンで動かしています。Bunのクセを苦労して学ばずにコンテナ化する必要があります。以下では、最小限のDockerfile、レイヤーキャッシング、マルチステージによるコンパイル済みバイナリ、本番環境向けのハードニング、PostgresとのDocker Compose構成、Bun組み込みサーバー向けのグレースフルシャットダウンハンドラー、そして最も一般的な4つのサイレント障害とその修正方法を解説します。
重要なポイント
- コンテナ内で
127.0.0.1のみをリッスンするBunサービスは、公開ポート経由では到達不能です。Dockerのポート公開がトラフィックを転送できるよう、0.0.0.0(全IPv4インターフェース)にバインドしてください。 - 2025年1月22日にリリースされたBun 1.2では、デフォルトのロックファイル形式がテキストベースの
bun.lockに変更され、旧来のバイナリ形式bun.lockbが置き換えられました。bun install --save-text-lockfile --frozen-lockfile --lockfile-onlyで移行できます。 - Dockerイメージ参照はタグを指定しない場合
latestがデフォルトとなり、デプロイ間でBunランタイムが暗黙的に変わる可能性があります。oven/bun:1のように明示的なタグをピン留めしてください。 - アプリケーションソースより先に
package.jsonとbun.lockをコピーすることで、ソースのみが変更された再ビルド時にDockerのレイヤーキャッシュがbun installをスキップできます。 - SIGTERMハンドラーがない場合、DockerはSIGTERMを送信し、デフォルトのstopタイムアウト(Linuxコンテナでは10秒)まで待機した後、プロセスが終了していなければSIGKILLを送信します。これによりインフライトリクエストがドロップされる可能性があります。
最小限のDockerfile
4行で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を追加する
Discover how at OpenReplay.com.
.dockerignoreはビルドコンテキストをスリムに保ち、シークレットやローカルの成果物がイメージにコピーされるのを防ぎます。何よりも先に作成してください。
node_modules
.git
.gitignore
.env
.env.*
dist
*.log
.vscode
README.md
特に重要なエントリーが2つあります。node_modulesは除外します。bun installがロックファイルからイメージ内で再生成するためです。ホストのnode_modulesをコピーすると、コンテナに合わないプラットフォーム固有のバイナリが混入する可能性があります。.envと.env.*は除外します。ローカルのシークレットがイメージレイヤーに入り込まないようにするためです。イメージレイヤーに一度入ったファイルは、後のレイヤーで削除しても残り続けます。
Dockerfileではbun.lockとbun.lockbのどちらを使うべきか?
bun.lockをデフォルトとして使用してください。2025年1月22日にリリースされたBun 1.2では、デフォルトのロックファイル形式がテキストベースのbun.lockファイルに変更され、旧来のバイナリ形式bun.lockbが置き換えられました。古いチュートリアルではbun.lockbが参照されており、Dockerfileで誤ったファイル名をコピーすると、わかりにくい--frozen-lockfileエラーが発生します。
リポジトリにまだバイナリのロックファイルがある場合は、一度だけ移行してください:
bun install --save-text-lockfile --frozen-lockfile --lockfile-only
# その後、bun.lockbを削除してbun.lockをコミットする
このコマンドはBunのロックファイルドキュメントに記載されています。移行後は、Dockerfile内のすべての箇所でbun.lockを参照してください。
レイヤーキャッシング:依存関係とソースを分離する
アプリケーションソースより先にpackage.jsonとbun.lockをコピーすることで、ソースファイルのみが変更された再ビルド時にDockerのレイヤーキャッシュがbun installをスキップできます。これはローカルでの反復開発速度に対して最も効果の高い変更です。
FROM oven/bun:1
WORKDIR /app
# ロックファイルとマニフェストを先にコピー — このレイヤーは
# 依存関係が変更された場合のみ無効化される
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.lockがpackage.jsonと同期していない場合にビルドを失敗させます。これはDockerfileにおける正しい動作であり、ロックファイルのずれをランタイムではなくビルド時に検出できます。このフラグの動作は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ユーザーで実行し、本番用の依存関係のみをインストールします。この3つの変更により、動作する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はglibcではなくmusl libcを使用するため、採用前に依存関係が問題なく動作するか確認してください。
非rootで実行する。 デフォルトのコンテナユーザーはrootです。appuserを作成してUSER appuserで切り替えることで、プロセスが侵害された場合の影響範囲を限定できます。これはDockerのセキュリティガイダンスの基本事項です。
本番用の依存関係のみをインストールする。 bun install --frozen-lockfile --productionはdevDependenciesをスキップし、イメージをより小さく、攻撃対象領域をより狭くします。
Bunコンテナが公開ポートで到達できないのはなぜか?
Dockerコンテナ内で
127.0.0.1のみをリッスンするBunサービスは、公開ポート経由では到達不能です。Dockerのポート公開がトラフィックを転送できるよう、0.0.0.0(全IPv4インターフェース)にバインドしてください。
これはDockerize済みの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_healthyを指定したdepends_onは、Postgresがhealthyを報告するまでapiサービスを保留します。これには依存サービスにヘルスチェックが必要です。ヘルスチェックがなければservice_healthyは永遠に解決されません。restart: unless-stoppedはクラッシュ後にアプリを再起動しますが、手動停止は尊重します。
トップレベルのversion:キーがないことに注意してください。Compose仕様ではトップレベルのversionプロパティは廃止済みとされており、Composeはスキーマ選択に使用せず、存在する場合は警告を出します。省略してください。
環境変数:シークレットをイメージに焼き込まない
シークレットをイメージレイヤーにコピーするのではなく、実行時に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の対応するエントリでバージョン管理からも除外してください。イメージは汎用的なままとなり、シークレットは実行時に注入されます。
開発ワークフロー:バインドマウントによるホットリロード
ローカル開発では、bun --hotを実行する別のDockerfileを使用し、ソースをボリュームとしてマウントすることで、保存時にコンテナ内で即座にリロードされます。これは本番用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を送信し、デフォルトのstopタイムアウト(Linuxコンテナでは10秒)まで待機した後、プロセスが終了していなければSIGKILLを送信し、処理中のリクエストがドロップされる可能性があります。
Bun.serve()は.stop()メソッドを持つサーバーオブジェクトを返します。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()が返すサーバーインスタンスをキャプチャし、終了シグナルを受信したらそれを停止することです。
イメージサイズの目安
選択するベースイメージが最終イメージサイズの大部分を決定します。Docker Hubでは各oven/bunバリアントの圧縮サイズが公開されています。以下の数値はoven/bun:1.3.14(linux/amd64)のDocker Hubタグページから取得したものです。
| ベースイメージ | バリアント | 圧縮サイズ |
|---|---|---|
oven/bun:1.3.14 | Debianベース | 81.93 MB |
oven/bun:1.3.14-slim | Debian slim | 63.32 MB |
oven/bun:1.3.14-alpine | Alpine | 40.87 MB |
oven/bun:1.3.14-distroless | Distroless | 40.52 MB |
これらはベースイメージのサイズであり、最終的なアプリイメージではありません。実際のイメージはその上に依存関係とソースが追加されます。自分のイメージのサイズを確認するには以下を実行してください:
docker images --format "table {{.Repository}}:{{.Tag}}\t{{.Size}}"
debian:12-slim上のコンパイル済みバイナリを使用したマルチステージバリアントは、完全なBunイメージとnode_modulesを省略するため最終イメージをさらに小さくできますが、正確な差は依存関係のフットプリントによって異なります。汎用的なパーセンテージを引用するのではなく、実測してください。
DockerizeされたBunコンテナで最も一般的な障害は何か?
DockerizeされたBunコンテナがサイレントに失敗する最も一般的な4つの理由は、エントリーポイントパスの誤り、localhostバインディング、古いロックファイル、そしてtsconfig.jsonのコピーステップの欠落です。それぞれに明確な症状と一行の修正方法があります。
| 症状 | 根本原因 | 修正方法 |
|---|---|---|
| コンテナが即座に終了、コード1 | CMD内のエントリーポイントパスが誤っているか、エントリーファイルがコピーされていない | CMD ["bun", "run", "src/index.ts"]のパスがコピーされたファイルと一致するか確認し、COPY src ./srcが実行されたかチェックする |
| ポートはマッピングされているが到達不能 | サービスが127.0.0.1/localhostにバインドされている | Bun.serve({ hostname: "0.0.0.0" })で0.0.0.0にバインドする |
bun install中にビルドが中断 | bun.lockがpackage.jsonと同期していない | ローカルでbun installを実行して更新されたbun.lockをコミットする。その後--frozen-lockfileがパスするようになる |
| 起動時にモジュール解決が失敗 | tsconfig.jsonのパスエイリアスが解決されない — ファイルがコピーされていない | CMDより前にCOPY tsconfig.json ./を追加する |
コンテナが検査できる前に終了してしまう場合は、イメージ上でシェルを起動して実際にコピーされた内容を確認してください:
docker run -it --rm --entrypoint sh bun-app
# 内部で: ls /app, cat package.json など
ロックファイルのミスマッチのケースは、Dockerの問題として誤読されることが最も多いです。これはDockerによって表面化されたBunの問題です。bun install --frozen-lockfileはずれたロックファイルに対してビルドを拒否しており、Bun installドキュメントに記載されているとおりの正しい動作です。
まとめ
これですべての要素が揃いました。ピン留めされた非rootの本番用Dockerfile、マルチステージのコンパイル済みバイナリバリアント、ヘルスチェック付きPostgresを含むComposeスタック、ホットリロード対応の開発ツール、そしてリクエストをクリーンにドレインするSIGTERMハンドラーです。まずoven/bunをoven/bun:1に置き換え、0.0.0.0バインドを追加するところから始めてください。この2つの編集だけで、初回デプロイで最も起こりやすい2つの障害を排除できます。その後、サービスの成長に合わせて残りの要素を段階的に取り入れていきましょう。
よくある質問
BunのDockerコンテナでTypeScriptを実行するために別のビルドステップが必要ですか?
いいえ。BunはTypeScriptを直接実行するため、Dockerビルドにtscやtsxのコンパイルステージは不要です。CMDはたとえばCMD ['bun', 'run', 'src/index.ts']のように.tsエントリーファイルを直接指定します。唯一の注意点はパスエイリアスです。アプリがtsconfig.jsonのパスマッピングを使用している場合、tsconfig.jsonをイメージにコピーする必要があります。コピーしないと、ローカルでは動作するのにコンテナ内の起動時にモジュール解決が失敗します。
コンテナ内でbun runを実行する代わりに、bun build --compileでコンパイル済みバイナリを使うべきのはいつですか?
最終イメージを小さくしたい場合や、ランタイムステージにBunをインストールしたくない場合は、コンパイル済みバイナリのマルチステージビルドを使用してください。bun build --compileはコード、npmパッケージ、アセット、Bunランタイムをバンドルした自己完結型の実行ファイルを生成するため、node_modulesやBunイメージなしにdebian:12-slimのような最小ベース上にそのバイナリをコピーするだけで済みます。シンプルなビルドが必要な場合や、開発中のホットリロードなどランタイム機能に依存している場合は、通常のbun runを使用してください。
oven/bunイメージのalpineバリアントはすべての依存関係で動作しますか?
必ずしもそうではありません。oven/bun:1-alpineバリアントはglibcではなくmusl libcを使用しますが、デフォルトのDebianベースのoven/bun:1イメージはglibcを使用します。glibcに対してコンパイルされたネイティブ依存関係や、glibcのみのビルド済みバイナリを配布するパッケージはAlpineで失敗する可能性があります。Alpineイメージはより小さいですが、本番環境に採用する前に依存関係ツリーがそこでビルドおよび実行できることを確認してください。単純な置き換えと思い込まないようにしてください。
bun installでfrozen-lockfileエラーが発生してコンテナのビルドが失敗するのはなぜですか?
bun.lockがpackage.jsonと同期していないため、bun install --frozen-lockfileがずれたロックファイルに対してビルドを拒否しています。これはDockerfileにおける正しい動作です。ロックファイルのずれをランタイムではなくビルド時に検出できます。ローカルでbun installを実行してbun.lockを再生成し、更新されたファイルをコミットすることで修正できます。DockerfileがまだバイナリのbunlockbBを参照している場合は、Bun 1.2でテキストベース形式がデフォルトになったため、bun.lockに切り替えてください。