🐷

BunとNext.jsのアプリケーションをECSにデプロイしてみた

2023/12/21に公開

はじめに

サーバーサイドのJSランタイムと言えば、以前まではNode1強でしたが、昨今ではBunやDenoが登場し、少しずつですがNode1強の時代も終わりが近づいているように感じています。

とはいえ、まだまだ新参者であるため、Nodeほど本番の環境で扱った趣旨の記事を見かけないのが現状です。

今回はそんな新参者のBunを簡易的なアプリケーションの作成から、ECSにデプロイするところまでを勉強を兼ねて試してみました!

Bunってなんだっけ?

ある程度は知っている前提で、ここでは詳しくは触れませんが、Nodeとの違いをcopilotに聞いてみた結果を載せておきます。

こんにちは、こちらはBingです。😊

BunとNodeの違いについて、以下のように説明します。

- BunとNodeは、どちらもブラウザ以外の環境でJavaScriptを実行できるランタイムですが、BunはNodeよりも高速に起動し、コードを実行できます。
- BunはNodeとは異なるJavaScriptエンジンであるWebKitを使用しており、ブラウザとの互換性が高いです。
- BunはAll in Oneなツールで、バンドル、トランスパイラ、パッケージマネージャなどが最初からBunに含まれています。Nodeでは、webpackやnpmなどの別のツールを使用する必要があります。
- BunはESMとCJSの挙動がミックスされており、import/exportやrequireなどの両方のモジュールシステムを使えます。Nodeでは、ESMとCJSの互換性問題に苦しんでおり、設定が難解です。

公式ページはこちらなので、気になる方はそちらから確認してみてください。

https://bun.sh/

アプリケーションを作成する

導入

公式にガイドがあるので、そちらが参考になります。

https://bun.sh/guides/ecosystem/nextjs

とても簡単で下記コマンドを実行して、自分のカスタマイズしたいように質問に答えていけば自動でテンプレートが生成されます。

bun create next-app

ここで生成されるもののほとんどはNodeで実行した時と変わらないので、違う部分だけとりあげていきます。

ロックファイルについて

BunではNodeと違い、package-lock.jsonは生成されません。
代わりにbun.lockbというバイナリファイルが生成されます。

(お饅頭アイコンかわいい)

このバイナリファイルがロックファイルの役割を果たしているのですが、当然バイナリファイルなので、人が読めるものではありません。

そのため、人がパッケージの依存確認をできるように、下記コマンドで別途ロックファイルを生成します。

bun install -y

こちらのコマンドを実行するとyarn.lockが生成され、そちらから内容を確認することができるようになります。

なんでバイナリのロックファイルを生成しているかというと、パフォーマンスを上げるためのようです↓

https://bun.sh/docs/install/lockfile

installコマンドについて

結果としてbun.lockbyarn.lockの2種類のロックファイルが出来上がるわけですが、ここでnpmを使い慣れている方は下記のように疑問に思うかもしれません。

installコマンドを実行したらどちらのロックファイルを基にするのだろう?🤔」

npmではロックファイルが存在する場合、そちらの情報をもとにパッケージをインストールしてきます。

https://docs.npmjs.com/cli/v10/commands/npm-install

This command installs a package and any packages that it depends on. If the package has a package-lock, or an npm shrinkwrap file, or a yarn lock file, the installation of dependencies will be driven by that, respecting the following order of precedence:

  • npm-shrinkwrap.json
  • package-lock.json
  • yarn.lock

ロックファイルを2種類で表せるBunはどうなのかというと、どちらのロックファイルも基にしないのが正解になります。

どういう事かというと、Bunのinstallコマンドはpackage.jsonを基に行われます。
そして、ロックファイルはこのコマンド実行時に生成および更新される仕様になっています。
yarn.lockyオプションをつけた際のみ生成・更新されます)

https://bun.sh/docs/cli/install

それではロックファイルはどう利用するのかというと、frozen-lockfileオプションがあるので、こちらでbun.lockbを利用したインストールができます。
ですので、production環境向けに使用する場合はこのオプションで実行することになります。

この仕様に関して、npmと比べてBunではいちいちオプションをつけなければ、インストールされるバージョンが変わってしまうのかと考える方もいるかもしれませんが、個人的にはそもそもpackage.jsonで固定バージョンを扱い、バージョン更新はrenovateなどのツールで管理するべきだと思うので、その利用法では適した仕様かなと考えています。

固定バージョンを扱うべきという考え方については、こちらを参考にしてください↓

https://docs.renovatebot.com/dependency-pinning/

renovateについて

前述の部分でrenovateを用いた運用を勧めたのですが、Bunとrenovateとの連携は発展途上のようです。

https://github.com/renovatebot/renovate/discussions/24511

いくつかサポートされていない部分はあるものの、bun.lockbの更新はサポートされ始めたはずなのですが、自分が試したところpackage.jsonyarn.lockは検出してもらえるが、bun.lockbは検出してもらえませんでした😿

renovateのダッシュボードに、ペンディングブランチとしてロックファイルメンテナンスが残り続け、チェックボックスなどから手動でPR作成を行おうとしても作成できないバグっぽい事象も確認したので、まだBunの本格サポートにはいたっていなそうです。。

コンテナ化

今回は本番環境へのデプロイまでのフローを試してみたかったので、アプリケーションの中身はデフォルトからほぼ変えずにこのまま本番環境に向けての準備を進めていきます。

本番環境ではECSを使用し、開発環境でもDockerをつかったものにするので、コンテナ化の対応してみました。

(Bun特有の話はないので、わかっているよって方は本番環境の準備まで飛ばしてもらって大丈夫です!)

ディレクトリ構造

対応後のディレクトリ構造はこんな感じになりました

deploymentディレクトリは後続の本番環境用のDockerfileなどを配置します。)

開発環境向けDockerfile

FROM oven/bun:1.0-alpine

WORKDIR /application

COPY package.json ./

RUN bun install

開発環境用のものなので、特に凝った処理は必要ないです。
ベースイメージとしてはBun側で提供されている公式のイメージを使用します。

https://hub.docker.com/r/oven/bun

ここではalpineベースのイメージを使用していますが、自由に選んでもらっていいと思います。
nodeはalpineで使わないほうがいいという話がどこかであったような気がしますが、Bunではどうなのかは特に調べていないです。

compose.yml

version: "3.8"
services:
  application:
    container_name: application
    build:
      context: ./application
      dockerfile: ../Dockerfile
    ports:
      - 3000:3000
    volumes:
      - ./application:/application
      - node_modules:/application/node_modules
    command: ["dev"]

volumes:
  node_modules:

書いてある通りですが、ビルド方法としてはコンテキストにapplicationディレクトリを指定し、先ほどのDockerfileを利用してビルドしています。

ポートはNext.jsデフォルトの3000でホストとコンテナ側双方で設定しています。

アプリケーションの中身があるapplicationディレクトリをコンテナ側root配下にバインドマウントし、node_modulesは名前付きボリュームを紐づけました。

また、コンテナ立ち上げとともに環境が立ち上がってほしいので、devコマンドを実行させています。

ここまで準備ができれば、docker compose up -dで開発環境のアプリケーションを立ち上げることができるようになります。

本番環境の準備

下準備

Dockerに最適化するために、Next.jsのコンフィグでoutput: 'standalone'を指定します。

/** @type {import('next').NextConfig} */
const nextConfig = {
  output: 'standalone'
}

module.exports = nextConfig

この設定値については、こちらが参考になります↓

https://nextjs.org/docs/app/api-reference/next-config-js/output#automatically-copying-traced-files

本番環境向けDockerfile

前述のDockerfileとは別で本番環境に最適化されたDockerfileを作成します。

作成方法としては、いわゆるマルチステージビルドを採用し、最終的に生成するイメージには必要なファイルのみがある状態にします。

このマルチステージビルドを初めて見たときは何をやっているのかさっぱりわからなかったのですが、冷静に上から順に読んでいくと割と単純で、ビルドをフェーズに分けて、フェーズごとの成果物を必要な部分のみ次のフェーズに渡していくといった具合です。

Next.jsとBunそれぞれの公式にもサンプルがあるので、そちらを参考に作成します。

https://github.com/vercel/next.js/blob/canary/examples/with-docker/Dockerfile

https://bun.sh/guides/ecosystem/docker

そして、Next.jsのサンプルをベースにしつつ、Bunのサンプルも参考にして、いろいろと試行錯誤したのですが、最終的にこのような構成になりました↓

FROM oven/bun:1.0-alpine AS base
WORKDIR /application

FROM base AS deps
COPY package.json bun.lockb ./
RUN bun install --frozen-lockfile

FROM base AS builder
COPY . .
COPY --from=deps /application/node_modules ./node_modules
ENV NODE_ENV=production
RUN bun run build

FROM base AS runner
ENV NODE_ENV=production
COPY --from=builder /application/public ./public

COPY --from=builder /application/.next/standalone ./
COPY --from=builder /application/.next/static ./.next/static

USER bun
EXPOSE 3000
ENV PORT 3000
ENV HOSTNAME "0.0.0.0"
CMD ["bun", "run", "server.js"]

base

baseとしては開発環境同様のイメージを使用し、これを各フェーズのベースイメージとして使用します。

deps

depsではパッケージのインストール処理を行います。
インストール処理ではproductionオプションはつけていませんが、このビルド方法では後続のビルド処理で使用するため必要ありません。
(むしろつけると後続のbuilderフェーズで失敗します)

builder

builderではビルドコマンドを実行します。
ここでは本来ビルドコマンドを実行するだけなのですが、Bunでは環境変数でNODE_ENV=productionを事前に指定する必要があります。

Next.jsのドキュメントでは下記のようにあり、本来はビルドコマンド実行時にはNODE_ENVの指定がなければproductionとして実行してくれるため、この指定は必要ないはずなのですが、どうもBunではproductionでビルドコマンドが実行されていないようで、ビルド処理に失敗します。

https://nextjs.org/docs/app/building-your-application/configuring/environment-variables#good-to-know

If the environment variable NODE_ENV is unassigned, Next.js automatically assigns development when running the next dev command, or production for all other commands.

下記が失敗時のエラーです。
(直接的な原因がわからず、対応方法を見つけるまで時間がかかりました😿)

> Export encountered errors on following paths:
  /_error: /404
  /_error: /500
  /_not-found
  /page: /
error: script "build" exited with code 1 (SIGHUP)

NODE_ENVの指定がなぜproductionとして動かないのかは、ベースコンテナの環境変数などを確認してみたのですが、未だに原因がわかっておらず、わかる方がいらっしゃったら教えていただきたいです🙏

runner

runnerでは実際に稼働させるイメージを定義します。
基本的にはNext.jsのサンプル通りに、builderフェーズで生成したものをコピー、稼働用の設定追加、エントリーファイルの実行を定義します。

Next.jsのサンプルと違うところとしては、使用するユーザー設定で、Bunのベースイメージには、同サンプルにあるようにbunというユーザーが準備されているので、こちらを利用します。

ここまで定義すれば本番環境用のDockerfileは完成で、コード面の準備も完了です。

インフラを構築する

ECRの準備

ここからAWS側の準備に入ります。

コンソールでECRの画面に移り、リポジトリを作成します。

今回はプライベートリポジトリで、bun-appという名前のリポジトリを作りましたが、ここの設定値はお好みで大丈夫です。

作成後、リポジトリの画面に移ると下記のようなボタンがあるので、ここからコマンドを表示します。

ここで表示された内容を基にコマンドを実行すれば、ECRにイメージをプッシュできます。

ただし、自分と同じディレクトリ構造でアプリケーションを作成した場合は、2番目のコマンドがコンテキストやファイルの指定が必要になるので、下記のようなコマンドに置き換わります。

docker build -t bun-app -f deployment/Dockerfile application

ここまで実行するとイメージのURIが発行されるので、ECRの準備が完了です。

タスク定義の作成

ECS側の準備に移ります。
タスク定義では、コンテナの立ち上げ方などの設定を行います。

ECSのタスク定義のページから、新しいタスク定義の作成を実行します。

タスク定義ファミリー名は自由で大丈夫です。

起動タイプは勉強もかねて今回はEC2にしました。
(Fargateでも動かすことは可能ですが、若干ほかの設定を変える必要があります。)

今回はALBを使用しないので、ネットワークモードでbridgeを選択してhttpアクセスからコンテナ側の3000ポートにつなげるようにします。

起動タイプをFagateで設定した場合はawsvpcしか選択できないので、ホストのポート番号とコンテナ側のポート番号を一致させる必要があります。

タスクサイズの設定に関しては使用するインスタンスに合わせた数値に設定してあれば大丈夫です。
今回はt2.microで動かす予定なので、この数値にしています。

条件付きタスクロールはデフォルトのものを使用しています。

コンテナの設定では前述のECRにあげたイメージを使用します。
イメージURIはECRの画面にあるので、そこからコピーしてきます。

ポートマッピングはALBを使わずにhttpアクセスでコンテナにつなげるため、ホスト側80、コンテナ側3000で設定します。

その他の設定項目については基本的にデフォルトのままで大丈夫なのですが、Fargateで利用する場合は環境変数を追加で設定してあげる必要があり、画像にある通りHOSTNAME0.0.0.0をいれます。

この環境変数はDockerfileでも定義しているので不要に見えますが、Fargate側のデフォルト環境変数でHOSTNAMEの環境変数が上書きされてしまうので、再度0.0.0.0で上書いてあげる必要があります。

ついでに、Fargate側のデフォルト環境変数については、現状ドキュメントに乗っていないようです😿

https://zenn.dev/xeres/articles/2021-09-14-env-vars-with-ecs-on-fargate

また、今回は試してみるだけなのでヘルスチェックを設定していませんが、検証でも環境を立ち上げる時にヘルスチェックを設定しておくとデバッグに有効なので、必要に応じて設定しておくことをお勧めします。

本番環境の立ち上げ

必要なリソースは揃ってきたので、本番環境の立ち上げに移ります。

クラスター作成

ECSのクラスターのページへ移動します。
クラスターとは、前述のタスクを動かすインスタンス群のことで、その設定をここで行います。

クラスター名・名前空間は適当で大丈夫です。

今回はECS on EC2構成なのでEC2インスタンスを選択します。

AutoScalingグループはお好みのものを設定していただいて大丈夫です。
今回は新しく作成します。

OSとアーキテクチャもお好みで大丈夫ですが、使用予定のインスタンスタイプに合わせたものにしましょう。
今回はt2.microを使用予定なのでAmazon Linux 2023をしています。

前述の通りインスタンスタイプはt2.microを選択します。

容量に関しては、検証で立ち上げるだけなので最小0、最大2に設定しています。

sshキーは、検証する際にはインスタンス内に入れたほうがいろいろとやりやすいので、アタッチしておくことをお勧めします。

VPCやサブネットは良しなに設定しましょう。
今回は検証のため、セキュリティーグループで自身のIPからのアクセスに絞っています。

他設定値はデフォルトのままで進めます。

タスクの実行

サーバーを立ち上げてアプリケーションを動かしていきます。
先ほど作成したクラスターの画面でサービスを作成します。

コンピューティングオプションはデフォルトのキャパシティープロバイダー戦略を選択すると、AWS側で良しなに立ち上げてくれます。

起動タイプを利用する場合は、自身でASGの調整などを行う必要があります。

アプリケーションタイプはウェブアプリを立ち上げるのでサービスを選択します。

ファミリーでは自身が作成した、タスク定義ファミリー名を選択しましょう。

サービス名は適当につけてもらってで大丈夫です。

必要なタスク数は、今回は検証のため1にしていますが、必要に応じて調整してください。

他は検証であればデフォルト値で大丈夫ですが、サーキットブレイカーを有効にしておくと、デプロイ失敗時にタスクも自動的に削除されるので、デバッグの際は一時的にオフにしておくと原因が特定しやすくなる場合があります。

この状態で作成ボタンを押すと、ついにデプロイが開始されます。

動作確認

デプロイ状況の確認

CloudFormationの画面からデプロイステータスは確認可能です。

起動中のタスクの確認

動かしているクラスターのタスクタブから、起動中のタスクを確認することができます。

起動中のタスク画面でログを確認し、以下のようなログが出ていればタスクの立ち上げは成功です。

なお、✓ Ready in NaNmsのログでは本来起動にかかった時間が表示されるのですが、Bun側の問題でNaNと表示されているそうです。
今後のバージョンアップで改善するようなので、それまではこのままの表示になりそうです。

https://github.com/oven-sh/bun/pull/6542

アクセス確認

起動中のタスクの画面でパブリックIPのリンクがあるので、そこから確認できます。
無事に画面が表示されれば成功です!

終わりに

いくつかundocumentedな部分があり、とても苦労しましたが、どうにか立ち上げることができ、いい勉強になりました。

苦労した分、この記事が同じようなことをやろうとしている方の参考になると幸いです。

なお、今回作成したBunのアプリケーションのコードは、Githubで公開しているので、気になる方はこちらからご覧ください↓

https://github.com/shoXIII/Bun

Discussion