【祝1.0🥟】Bunランタイム上で動作するHTTP APIをApp Runnerへホスティングしてみた

2023.09.13

はじめに

9月8日(現地時間)にJavaScriptランタイムのBunのバージョン1.0がリリースされました🥳🥟 本記事では、Bunランタイムを生かしたHTTP APIを作ってApp Runnerにホスティングしてみます。

Bunとは

JavaScriptランタイムは現在 Node.js Deno Bunと複数ある状況で、それぞれ異なる要素技術で作られています。詳しくはBun first impressions techfeedを参考にするのが分かりやすいです。よく挙げられる点として、ネイティブ実装にZigの採用、JITコンパイラとしてJavaScriptCoreを採用している点です。1.0の紹介動画であるBun 1.0 is hereでは速度の話がよく出てきており、強く押し出している印象を受けました。

設計ゴールに関しては、bun.sh | Design Goalsが参考になります。

構成

構成は以下の通りです。Bunのバージョンは1.0.1を利用します。この肉まんのアイコンかわいいですね。

本記事の主旨としてなるべくBunを活かす構成を考えました。結果、Bunの組み込み関数で特徴的なbun:sqliteを活用してTODOのHTTP APIを作成してみます。構成の詳細は以下の通りです。

要素 採用 理由
ホスティング先 App Runner Lambdaのカスタムランタイムがありますが、ネイティブサポートされていないため現時点では現実採用されにくいため
データベース SQLite3 BunにはSQLite3ドライバがネイティブ(bun:sqlite)で実装されているため
冗長化構成 Litestream SQLite3とS3をレプリケートするため
HTTP Webフレームワーク Hono Bunをサポートしているため

ソースコード

ソースコードはGitHubにあります。bunコマンドで全て完結します。(コードフォーマットだけ手軽に行きたいのでdeno fmtを使っています。。)

github.com/shuntaka9576/bun-apprunner-template

Quick Start

以下のコマンドで、環境構築ホスティング全て完了します。本プロジェクトでは、Dockerコンテナクライアントとしてfinchの利用を推奨します。

cd ./packages/aws
export CDK_DOCKER=$(which finch)
bunx cdk deploy bun-apprunner-app

処理としては、以下の通りです。CDKが全部やってくれます。

  1. ローカルでコンテナビルド
  2. コンテナをECRへpush
  3. App RunnerやS3のリソースを作成
  4. コンテナのデプロイ

ローカルでコンテナをビルドします。ローカルのDocker環境が整備されていることを確認してください。App Runnerは、x86である必要があるためarmだと動作しません。今回コンテナではバイナリ含め全てx86でビルドされたものを利用しているので互換性はありません。

CDK実行結果

✨  Synthesis time: 1.65s

This deployment will make potentially sensitive changes according to your current security approval level (--require-approval broadening).
Please confirm you intend to make the following modifications:

(中略)

IAM Statement Changes
bun-apprunner-app.BunApprunnerHostingConstructAppRunnerUriAEE3023C = https://xxxxxxxx.ap-northeast-1.awsapprunner.com <- このURLを利用

API利用例

# GET /tasks
$ curl -v https://xxxxxxxx.ap-northeast-1.awsapprunner.com/tasks
(中略)
{"tasks":[]}
# POST /tasks
$ curl -v -X POST https://xxxxxxxx.ap-northeast-1.awsapprunner.com/tasks -d '{"title": "foo"}'
(中略)
{"taskId":"71f21d95-b4f6-4327-9134-452ed346184a","title":"foo","createAt":"2023/09/13 17:09:13"}
# GET /tasks/:taskId
$ curl -v https://xxxxxxxx.ap-northeast-1.awsapprunner.com/tasks/71f21d95-b4f6-4327-9134-452ed346184a
(中略)
{"taskId":"71f21d95-b4f6-4327-9134-452ed346184a","title":"foo","createAt":"2023/09/13 17:09:13"}

解説

一部しか紹介しないため、気になる点がありましたら実際のソースコードを参照してください。

bunコマンド

bunコマンドは、npm yarn pnpmに当たるコマンドです。特に後述する依存のインストールはかなり高速です。またworkspaceに対応しています。ただ、-wオプションは現時点ではないようなので、特定のパスに行ってパッケージ追加が必要です。よく使うコマンドを紹介します。

依存のインストール

--froze-lockfile--productionオプションもありますが、コンテナ内部だと矛盾が出てしまいうまく動作しませんでした。故に後述のDockerfileではオプションを指定していません。

bun install

スクリプト実行

--hotでホットリロードが可能です。HTTP API実装では重宝します。--bunオプションは、bunランタイムに動作を強制させます。既存のNext.jsやNestJSアプリが起動するのは、このオプションを設定していないためで、裏側でnodeプロセスが起動しています。

bun run --hot --bun ./src/main.ts

bunxnpxのようなコマンド、cdkeslintなどのCLIを利用する際に利用します。

bunx cdk deploy bun-apprunner-app

コンテナイメージ

マルチステージビルドで、litestreambunを取得して、アプリ側のコンテナへ移動しています。イメージサイズは約90MBでした。

bun*-baselineのバイナリを利用しています。baselineなしのバイナリだと、コンテナでbunコマンド起動時にクラッシュすることがあったためです。"Illegal instruction (core dumped)" when using bun through code-serverが起因していそうです。MacOSでビルドしたので、Linuxだとまた結果が変わるかもしれません。

FROM debian:stable-slim as get

WORKDIR /bun

RUN apt-get update
RUN apt-get install curl unzip -y
RUN curl --fail --location --progress-bar --output "/bun/bun.zip" "https://github.com/oven-sh/bun/releases/download/bun-v1.0.1/bun-linux-x64-baseline.zip"
RUN unzip -d /bun -q -o "/bun/bun.zip"
RUN mv /bun/bun-linux-x64-baseline/bun /usr/local/bin/bun
RUN chmod 777 /usr/local/bin/bun

COPY package.json bun.lockb /bun
COPY packages/app /bun/packages/app
RUN bun install

ADD https://github.com/benbjohnson/litestream/releases/download/v0.3.11/litestream-v0.3.11-linux-amd64.tar.gz /tmp/litestream.tar.gz
RUN tar -C /usr/local/bin -xzf /tmp/litestream.tar.gz

FROM debian:stable-slim

WORKDIR /work

COPY --from=get /usr/local/bin/bun /bin/bun
COPY --from=get /usr/local/bin/litestream /bin/litestream
COPY --from=get /bun/node_modules /work/node_modules
COPY --from=get /bun/packages /work/packages

RUN apt-get update && \
  apt-get install -y \
    sqlite3 \
    ca-certificates && \
  rm -rf /var/lib/apt/lists/*

RUN mv /work/packages/app/litestream.yml /etc
RUN chmod +x /work/packages/app/entrypoint.sh

WORKDIR /work/packages/app

ENTRYPOINT ["./entrypoint.sh"]

SQLite3操作(bun:sqlite)

bun:sqliteを使った読み込み/書き込み処理は以下の通りです。型の安全性はないですが、CURD処理を書くのに十分な機能がある印象でした。トランザクション処理もあります。詳しくはSQLite – API | Bun Docsが参考になります。型チェックをしっかり行いたい場合は、zodを利用すると良いと思います。

import { Task } from "src/entity/task";
import { Database } from "bun:sqlite";
import { v4 as uuid } from "uuid";
import { DateTime } from "luxon";

const db = new Database("./todo.db");

const insertTask = db.prepare(
  "INSERT INTO task (task_id, title) VALUES ($taskId, $title);",
);
const queryTask = db.query(
  "SELECT task_id, title, create_at FROM task WHERE task_id = $taskId;",
);
const queryTaskAll = db.query("SELECT task_id, title, create_at FROM task;");

// 書き込み
export const AddTask = async (title: string): Promise<Task> => {
  const taskId = uuid();
  await insertTask.run({
    $taskId: taskId,
    $title: title,
  });

  const record = queryTask.get({
    $taskId: taskId,
  }) as
    | {
      task_id: string;
      title: string;
      create_at: string;
    }
    | undefined;

  if (record == null) {
    throw new Error("unexpect insert exception");
  }

  return {
    taskId: record.task_id,
    title: record.title,
    createAt: DateTime.fromSQL(record.create_at, { zone: "UTC" }),
  };
};

// 取り出し(単体)
export const GetTask = async (taskId: string): Promise<Task | null> => {
  const record = queryTask.get({
    $taskId: taskId,
  }) as
    | {
      task_id: string;
      title: string;
      create_at: string;
    }
    | undefined;

  if (record == null) {
    return null;
  }

  return {
    taskId: record.task_id,
    title: record.title,
    createAt: DateTime.fromSQL(record.create_at, { zone: "UTC" }),
  };
};

// 取り出し(複数)
export const ListTask = async (): Promise<Task[]> => {
  const records = queryTaskAll.all() as {
    task_id: string;
    title: string;
    create_at: string;
  }[];

  return records.map((record) => ({
    taskId: record.task_id,
    title: record.title,
    createAt: DateTime.fromSQL(record.create_at, { zone: "UTC" }),
  }));
};

最後に

本アプリ実装ではluxonuuidといったモジュールを利用しましたが、問題なく動作しており、特にハマることなく既存のnpm資産を生かしつつアプリが作れました。またbun bunxがキビキビ動作して気持ちよく、今回のようなHTTP APIサーバーの実装はホットリロードと相性がよく開発体験がよかったです。ホットリロード自体は、Node.jsでも設定を入れたり、NestJSはCLI側で対応していたりはしますが、ネイティブサポートされていると小さく切り出してトラブルシュートなんかも手軽でいいですね。bun installは高速でnpm互換があるため、CIのみbunを使う事例もあるそうです。今回試せませんでしたが、組み込みのテストツールも気になっています。

現時点で問題なく使えていますが、より他のランタイムと差別化要素や安定性が出てくると流行りそうな気がしています。

引き続きウォッチしていきたいと思います!