📈

社内ISUCONのスコアボードを、Hono + Cloudflare Workers, D1で作った話

2024/01/09に公開

BEENOSの三上です。
2023年10月にBEENOS株式会社 社内ISUCON(以下、B-ISUCON)を開催しました。
この社内B-ISUCONで使用したスコアボードを、Hono + Cloudflare Workers&D1で構築したのでこれの記録を残します。

なお、今回試したコードの最終形はGitHub repositoryに公開しています。
適宜ご参照ください。

なお、「ISUCON」は、LINEヤフー株式会社の商標または登録商標です。

Hono & Cloudflare選定の理由、流れ

一言で言えば、今回は "軽量さ" に重きをおきHonoとCloudflare Workers&D1を使用させて頂きました。

HonoはCloudflareのDeveloper Advocateであるyusukebeさんが推進されている、Cloudflare Workers上での動作に特化した非常に軽量なフレームワークです。
(参考: Hono[炎]っていうイケてる名前のフレームワークを作っている
個人的に最近Cloudflare各種プロダクトの使用感が良く、とても気に入っていたところでして、しかも「そこで動く日本発のOSSがある...?!これはとても推せる!!」と思い気になっていました。

今回社内ISUCONのスコアボードを作るにあたりなるべく軽量に済ませたく、これまでであればFirebaseなど使っていたところでした。
Cloudflareでは最近になりD1も仲間入りしており、データの取り扱いも便利になったことで要件に対して充足していたため、「Honoも含めて試してみよう!!」と採用を決めました。
(B-ISUCON競技日の1日しか使わないですしね。)

環境

モノ version
Node.js 20.9.0
npm 9.6.7
Wrangler 3.15.0
Hono 3.10.1

やっていく

ここからは実査に構築したスコアボードの内容を、手順を追って説明していきます。

プロジェクトの初期化

まず create hono します。
(プロジェクトのディレクトリなどは必要に応じて作成しておいてください。僕はghq使っているので ghq create aki-webii/scoreboard 的なことをしました。)

npm create hono@latest .

途中で使用するtemplateを尋ねられるので、cloudflare-workersを選択します。

npm create hono@latest .
Need to install the following packages:
  create-hono@0.3.2
Ok to proceed? (y) y

create-hono version 0.3.2
✔ Using target directory … .
✔ Which template do you want to use? › cloudflare-workers
cloned honojs/starter#main to [path to project]
✔ Copied project files

使用するnpm package類もここで一通り導入しておきます。

npm i -D wrangler @cloudflare/workers-types zod @hono/zod-validator drizzle-kit drizzle-orm better-sqlite3

Cloudflare D1のセットアップ

ベンチマーカーからスコアをPOSTし保存しておくため、Cloudflare D1のdatabaseをセットアップしていきます。
WranglerからD1 databaseを作成します。

# D1 databaseの作成
npx wrangler d1 create b-isucon-scoreboard

作成に成功すると、下記のようなログが流れるので、このうち [[d1_databases]] 以降をコピーして wrangler.toml に記載してください。

❯ npx wrangler d1 create b-isucon-scoreboard
✅ Successfully created DB 'b-isucon-scoreboard' in region APAC
Created your database using D1's new storage backend. The new storage backend
is not yet recommended for production workloads, but backs up your data via
point-in-time restore.

[[d1_databases]]
binding = "DB" # i.e. available in your Worker on env.DB
database_name = "b-isucon-scoreboard"
database_id = "[database id]"

次に、D1の中身をセットアップしていくのですが、今回ORMはDrizzleORMを使用します。
src/schema.ts にSchemaを記載します。

src/schema.ts
import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core"

export const scores = sqliteTable("scores", {
  id: integer("id").primaryKey({ autoIncrement: true }).notNull(),
  teamId: text("team_id").notNull(),
  score: integer("score").notNull(),
  registeredAt: integer("registered_at").notNull(),
});

次に src/schema.ts を元にmigrationを生成し、これをD1 databaseに適用していきます。

# schemaからmigrationを生成
npx drizzle-kit generate:sqlite --out migrations --schema src/schema.ts
# migrationを適用
npx wrangler d1 migrations apply b-isucon-scoreboard

この実行に成功していれば下記のようなlogとなるので、その後テーブルが作成されていることを確認します。

❯ npx wrangler d1 migrations apply b-isucon-scoreboard
Migrations to be applied:
┌──────────────────────────┐
│ name                     │
├──────────────────────────┤
│ 0000_perfect_vampiro.sql │
└──────────────────────────┘
✔ About to apply 1 migration(s)
Your database may not be available to serve requests during the migration, continue? … yes
🌀 Mapping SQL input into an array of statements
🌀 Parsing 2 statements
🌀 Executing on b-isucon-scoreboard ([database id]):
🚣 Executed 2 commands in 0.38260000000000005ms
┌──────────────────────────┬────────┐
│ name                     │ status │
├──────────────────────────┼────────┤
│ 0000_perfect_vampiro.sql │ ✅       │
└──────────────────────────┴────────┘

❯ npx wrangler d1 execute b-isucon-scoreboard --command='select name from sqlite_master where type="table";'
🌀 Mapping SQL input into an array of statements
🌀 Parsing 1 statements
🌀 Executing on b-isucon-scoreboard ([database id]):
🚣 Executed 1 commands in 0.1888ms
┌─────────────────┐
│ name            │
├─────────────────┤
│ _cf_KV          │
├─────────────────┤
│ d1_migrations   │
├─────────────────┤
│ sqlite_sequence │
├─────────────────┤
│ scores          │
└─────────────────┘

scores が作成されていますね。

ベンチマーカーの log から、スコアをデータに放り込む

セットアップしたD1データベースに対し、スコアを投げ込むためのshell scriptを用意しておきます。
B-ISUCON当日はこのscriptを各チームのベンチマーカーインスタンスに用意し、手動で叩いて貰っていました。
※ リクエストを受けるendpointはこの後作成します。

bench.sh
#!/usr/bin/env bash

set -ue -o pipefail
export LC_ALL=C

# ベンチマーク対象となるIPが引数で渡されているかチェック
if [ $# -ne 1 ]; then
  echo "Usage: bench.sh <target-IP>"
  exit 1
fi

# ベンチマークを実施し、結果からスコアを取り出す
export ISUXBENCH_TARGET=$1
./bin/benchmarker --stage=prod --request-timeout=10s --initialize-request-timeout=60s | tee /tmp/bench_log
SCORE=$(sed -n -E -e 's/^.*\[SCORE\]\s([0-9]*).*/\1/p' /tmp/bench_log)

# チームIDをインスタンスメタデータから取得する
# ※ この点、事前にベンチマーカーインスタンスに対して "TeamID" というタグ名でチームIDを付与する必要がありますし、インスタンスメタデータの取得を許可するような設定にしておいてください。
TEAM_ID=$(curl -s http://169.254.169.254/latest/meta-data/tags/instance/TeamId)

# スコアとチームIDをスコア登録エンドポイントにPOST
curl -s -X POST -H "Content-Type: application/json" -d "{\"score\":$SCORE,\"teamId\":\"$TEAM_ID\"}" https://b-isucon-scoreboard.[CF user ID].workers.dev/api/scores > /dev/null || true

スコア表示部分を実装

最後にCloudflare Workers上で動作するHonoのアプリケーションを用意します。

src/index.ts
src/index.ts
import { Context, Hono } from "hono";
import { z } from "zod";
import { zValidator } from "@hono/zod-validator";
import { html } from "hono/html";
import { drizzle } from "drizzle-orm/d1";
import { scores } from "./schema";
import { desc, sql } from "drizzle-orm";

const teamNameMap: { [key: string]: string } = {
  // ここにチームID(key, ベンチマーカーのTeamIDタグに設定しているもの)とチーム名(value)の形式でチーム情報書き連ねてください
  team0: "テストチーム",
};

type Bindings = {
  DB: D1Database;
};

const app = new Hono<{ Bindings: Bindings }>();

const getScoreData = (c: Context<{ Bindings: Bindings }>) => {};

app.get("/", (c) => {
  return c.html(
    html`
      <!DOCTYPE html>
      <html>
        <head>
          <meta
            name="viewport"
            content="width=device-width, initial-scale=1.0"
          />
          <script src="https://cdn.tailwindcss.com"></script>
          <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
          <script src="https://cdnjs.cloudflare.com/ajax/libs/date-fns/1.30.1/date_fns.js"></script>
          <script src="https://cdn.jsdelivr.net/npm/chartjs-adapter-date-fns/dist/chartjs-adapter-date-fns.bundle.min.js"></script>
          <title>B-ISUCON portal</title>
        </head>
        <body>
          <div class="grid place-items-center">
            <h2 class="text-4xl font-bold mb-4 self-start">時系列スコア</h2>
            <div style="width: 1200px;">
              <canvas id="scoreLineChart"></canvas>
            </div>
          </div>

          <div class="grid place-items-center">
            <h2 class="text-4xl font-bold mb-4 self-start">最新スコア</h2>
            <div style="width: 1200px;">
              <canvas id="latestScoreChart"></canvas>
            </div>
          </div>
        </body>

        <script>
          (function () {
            const ctx = document.getElementById("scoreLineChart");

            const chart = new Chart(ctx, {
              type: "line",
              data: {
                datasets: [],
              },
              options: {
                responsive: true,
                scales: {
                  x: {
                    type: "time",
                    time: {
                      unit: "minute",
                      displayFormats: {
                        minute: "HH:mm",
                      },
                    },
                  },
                  y: {
                    beginAtZero: true,
                  },
                },
              },
            });

            const getChartData = async () => {
              const response = await fetch("/api/scores");

              return response.json();
            };

            const redrawChart = (data) => {
              chart.data.datasets = data;
              chart.update();
            };

            let chartData = {
              latestTimestamp: 0,
              datasets: [],
            };

            setInterval(async () => {
              const data = await getChartData();
              if (data.latestTimestamp !== chartData.latestTimestamp) {
                chartData = data;
                redrawChart(data.datasets);
              }
            }, 5 * 60 * 1000);

            getChartData().then((data) => redrawChart(data.datasets));
          })();

          (function () {
            const ctx = document.getElementById("latestScoreChart");

            const chart = new Chart(ctx, {
              type: "bar",
              data: {
                labels: [],
                datasets: [],
              },
              options: {
                responsive: true,
                indexAxis: "y",
                scales: {
                  y: {
                    beginAtZero: true,
                  },
                },
                plugins: {
                  legend: {
                    display: false,
                  },
                },
              },
            });

            const getChartData = async () => {
              const response = await fetch("/api/scores/latest");

              return response.json();
            };

            const redrawChart = (labels, datasets) => {
              chart.data.labels = labels;
              chart.data.datasets = datasets;
              chart.update();
            };

            let chartData = {
              latestTimestamp: 0,
              labels: [],
              datasets: [],
            };

            setInterval(async () => {
              const data = await getChartData();
              if (data.latestTimestamp !== chartData.latestTimestamp) {
                chartData = data;
                redrawChart(data.labels, data.datasets);
              }
            }, 5 * 60 * 1000);

            getChartData().then((data) => redrawChart(data.labels, data.datasets));
          })();
        </script>
      </html>
    `
  );
});

app.get("/api/scores", async (c) => {
  const db = drizzle(c.env.DB);
  const result = await db.select().from(scores).all();

  type ScoreChartRecord = {
    x: number;
    y: Number;
  };

  let latestTimestamp = 0;
  const scoreSets: {
    [key: string]: ScoreChartRecord[];
  } = {};
  result.forEach((scoreRecord) => {
    if (!scoreSets[scoreRecord.teamId]) {
      scoreSets[scoreRecord.teamId] = [];
    }
    scoreSets[scoreRecord.teamId].push({
      x: scoreRecord.registeredAt,
      y: scoreRecord.score,
    });

    if (latestTimestamp < scoreRecord.registeredAt) {
      latestTimestamp = scoreRecord.registeredAt;
    }
  });

  const datasets = [];
  for (let [key, value] of Object.entries(scoreSets)) {
    datasets.push({
      label: teamNameMap[key],
      data: value,
      borderWidth: 1,
    });
  }

  return c.json({
    latestTimestamp,
    datasets,
  });
});

app.get("/api/scores/latest", async (c) => {
  const db = drizzle(c.env.DB);
  const result = await db.select({
    teamId: scores.teamId,
    score: scores.score,
    maxRegisteredAt: sql`max(${scores.registeredAt})`
  }).from(scores).groupBy(scores.teamId).orderBy(desc(scores.score)).all();

  const labels = result.map((record) => teamNameMap[record.teamId]);
  const data = result.map((record) => record.score);
  const latestTimestamp = result.map((record) => record.maxRegisteredAt).reduce((a, b) => Math.max(a as number, b as number), 0);

  return c.json({
    latestTimestamp,
    labels,
    datasets: [
      {
        data: data,
        backgroundColor: [
          "rgba(255, 99, 132, 0.2)",
          "rgba(255, 159, 64, 0.2)",
          "rgba(255, 205, 86, 0.2)",
          "rgba(75, 192, 192, 0.2)",
          "rgba(54, 162, 235, 0.2)",
          "rgba(153, 102, 255, 0.2)",
          "rgba(201, 203, 207, 0.2)",
        ],
        borderColor: [
          "rgb(255, 99, 132)",
          "rgb(255, 159, 64)",
          "rgb(255, 205, 86)",
          "rgb(75, 192, 192)",
          "rgb(54, 162, 235)",
          "rgb(153, 102, 255)",
          "rgb(201, 203, 207)",
        ],
        borderWidth: 1,
      },
    ],
  });
});

app.post(
  "/api/scores",
  zValidator(
    "json",
    z.object({
      teamId: z.string(),
      score: z.number(),
    })
  ),
  async (c) => {
    // スコアフリーズ時はこの実装を有効にする。
    // c.status(201);
    // return c.body(null);

    const requestBody = c.req.valid('json');

    const db = drizzle(c.env.DB);
    await db.insert(scores).values({
      teamId: requestBody.teamId,
      score: requestBody.score,
      registeredAt: Date.now(),
    });

    c.status(201);
    return c.body(null);
  }
);

export default app;

注意点としては、横軸が時間軸となるため、若干Chart.jsの設定が混み合っています。

deploy時は wrangler deploy を使用するので、これを package.json に登録しておきます。
※ 一応 wrangler dev を実行するコマンドも記載しています。

package.json
  "scripts": {
+    "dev": "wrangler dev src/index.ts",
+    "deploy": "wrangler deploy --minify src/index.ts",
  }

deploy

最後にdeployし、動作検証をしていきましょう。

npm run deploy

手っ取り早く、curl経由でスコアを投入してみます。

curl https://b-isucon-scoreboard.[CF user ID].workers.dev/api/scores -X POST -H 'Content-Type: application/json' --data-raw '{"teamId":"team0","score":300}'

ブラウザでアクセスしてみましょう。

テストデータを投入後、画面を表示してみた例

動作も良さそうですね。

まとめ

お疲れさまです。
以上で、Hono + Cloudflare Workers, D1で社内ISUCON向けのスコアボードの実装が行えました。
Honoは初めて使わせて頂きましたが、zodとの連携もスムーズで、良い書き心地でした。

一方で、今回の実装ではHonoの真価を発揮したとまでは言えなさそうで、引き続き小さなものづくりを重ね、その可能性を探っていきたい次第です。

本記事を参照して頂きありがとうございました。

Wanted!!

BEENOSグループでは一緒に働いて頂けるエンジニアを強く求めております!
少しでも気になった方は、社内の様子や大事にしていることなどをThe BEENOSにて発信しておりますので、是非ご覧ください。

https://beenos.com/blog/

とても気になった方はこちらで求人も公開しておりますので、お気軽にご応募ください!
「自分に該当する職種がないな...?」と思った方はオープンポジションとしてご応募頂けると大変嬉しいです。🙌

世界で戦えるサービスを創っていきたい方、是非ご連絡ください!よろしくお願い致します!!

世界で戦えるサービスを創っていく


BEENOS Tech Blog

Discussion