ゲームチェンジャーと噂のCloudFlare D1を性能検証してみた

2022.12.12

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

本記事はCloudflareアドベントカレンダーの10日目の記事です。

Cloudflare Advent Calendar 2022 の記事一覧

10日目は現在アルファ版として公開されているCloudflare D1についての性能検証の記事です。

Cloudflare D1は一言でいうと「エッジロケーションで利用できるSQLiteベースのリレーショナルデータベース」です。

CloudFlareのグローバルネットワークを活用して、データの読み取り専用のクローンがエッジロケーションに自動的に配置されるとのことなので、なんか凄そうです!

従来CloudFlareでのデータストアの選択肢としては、 オブジェクトストレージとしてのCloudFlare R2、 キューバリュー形式データベースとしてのCloudFlare KVなどがありましたが、 CloudFlare D1の登場によって、より多くのユースケースに対応できるようになります。

現在アルファ版が無料で試せるということのなので、どこまでの性能がでるのかゴリっと負荷をかけてみたいと思います。

CloudFlare D1の特徴

D1では、アプリケーション全体の状態を一箇所に保存して、データセット全体に対して任意のクエリを実行できるようにしたいと考えています。それが、リレーショナルデータベースの強みです。

私たちは強力であることが面倒であることと同義であるべきだとは考えていません。ほとんどのリレーショナルデータベースは巨大かつモノリシックであり、レプリケーションの設定も簡単ではないため、一般的にほとんどのシステムは、すべての読み込みと書き込みは単一のインスタンスに戻るように設計されています。D1では、これとは異なるアプローチを採用しています。

D1では、お客様の手を煩わせることなく、Cloudflareのグローバルネットワークを活用したいと考えています。D1は、お客様のユーザーの近くにデータの読み取り専用のクローンを作成し、常に変更を反映させた状態で維持します。

Larger databases: During the alpha period, our databases will be limited to 100MB but we will be looking to support larger databases in the future. If your use case requires a larger database, please reach out!

Read replication: D1 will create read-only clones of your data and distribute across Cloudflare global network — close to where your users are — and constantly keep them up-to-date with changes.

Transactions: Define a chunk of your Worker code that runs directly next to the database, giving you total control and maximum performance—each request first hits your Worker near your users, but depending on the operation, can hand off to another Worker deployed alongside a replica or your primary D1 instance to complete its work.

どちらの文章からも分かる通り、読み取り専用のノードが自動的にユーザーの近くのエッジロケーションに配置されるそうです。

おそらく書き込みはプリマリノードのに対して行われると思うのですが、ここら辺の細かい情報はドキュメントを見ても記載がありませんでした。

トランザクション分離レベルに関してもドキュメントに記載はありませんでした。 SQLiteの設定値であるPRAGMA Statementsを確認する限り、The default isolation level for SQLite is SERIALIZABLEと記載があるので、おそらくSERIALIZABLEだと思います。

検証したいこと

あまり明確に決めていませんが、 とりあえず CloudFlare Workers + Cloudflare D1 のアプリケーション構成に対して、HTTPリクエストを送り続けて、

  • リクエストの成功率はどれくらいか?
  • API全体の応答時間とDBアクセスの応答時間の比率はどれくらいか?
  • Read/Writeでどの程度違いがあるのか?
  • 離れた立地による違いがあるのか?

というのをざっくりと検証してみたいと思います。

検証準備

上の図で記載しているリソースを準備するだけなので、検証結果を知りたい方はスキップしてください。

細かく記載すると長くなるので、ポイントを絞って記載します。 GitHubのリポジトリにコードや検証データなどは公開してあるので、詳細を知りたい方はそちらをご覧ください。

CloudFlare WorkerとD1の作成と設定

基本的にはこちらのドキュメントに従い、 CloudFlare Wranglerを使ってCloudFlare WorkersとD1を作成します。

手順通り進めるだけなので、とても簡単に設定できます。

負荷テストに利用するテーブルの作成

シンプルにタイムスタンプとリクエスト元のリージョンを記録するテーブルを用意します。

  • DDL
DROP TABLE IF EXISTS test;
CREATE TABLE test (id INTEGER, timestamp TEXT, region TEXT, PRIMARY KEY (id));
  • コマンド

wrangler d1 execute arai-test-d1 --file=./sql/init.sql

アプリケーションコード

APIのルーティングはitty-routerを利用しています。

/readでは、直近で登録されたデータを1件読み出しています。

/writeでは、現在時刻とリージョンを指定してデータを書き込んでいます。

それぞれのAPIレスポンスでは、処理結果として色々なデータを返しています。 今回はCloudFlare Workers → CloudFlare D1の応答時間も計測したかったため、DBアクセス前後でDate.now()を取得し、その差分をDBアクセスの応答時間dbReqDurationとして返却しています。

本来であれば、Node組み込みのperformanceAPIを使いたいところですが、CloudFlare Workersでは対応しておらず、そこまで精度が求められるわけではないので、今回はこれでいきます。

余談になりますが、CloudFlare Workersの通常設定ではNodeのAPIは利用できず、node_compat = true を設定する必要があるみたいです。ただ、この設定を追加しても、一部のAPIしか利用できないようです。Node compatibility

import { Router } from "itty-router";

export interface Env {
  DB: D1Database;
}
const corsHeaders = {
  "Access-Control-Allow-Origin": "*",
  "Access-Control-Allow-Methods": "GET,HEAD,POST,OPTIONS",
  "Access-Control-Max-Age": "86400",
};

const generateResponse = (
  d1Result: D1Result,
  startTime: number,
  endTime: number
) => {
  const json = JSON.stringify(
    {
      d1Result,
      startTime,
      endTime,
      dbReqDuration: endTime - startTime,
    },
    null,
    2
  );
  if (d1Result.error) {
    return new Response(json, {
      status: 500,
      headers: { "content-type": "application/json", ...corsHeaders },
    });
  } else {
    return new Response(json, {
      status: 200,
      headers: { "content-type": "application/json", ...corsHeaders },
    });
  }
};

const router = Router();
router.get("/", async (request: Request, env: Env, ctx: ExecutionContext) => {
  return generateResponse({} as D1Result, 0, 0);
});

router.get(
  "/read",
  async (request: Request, env: Env, ctx: ExecutionContext) => {
    const startTime = Date.now();
    const result = await env.DB.prepare("SELECT * FROM test LIMIT 1;").all();
    const endTime = Date.now();
    return generateResponse(result, startTime, endTime);
  }
);

router.get(
  "/write",
  async (request: Request, env: Env, ctx: ExecutionContext) => {
    const { searchParams } = new URL(request.url);
    const region = searchParams.get("region");
    const startTime = Date.now();
    const result = await env.DB.prepare(
      `INSERT INTO test (timestamp, region) VALUES (STRFTIME('%s', 'now'), '${region}')`
    ).all();
    const endTime = Date.now();
    return generateResponse(result, startTime, endTime);
  }
);

export default {
  async fetch(
    request: Request,
    env: Env,
    ctx: ExecutionContext
  ): Promise<Response> {
    return router.handle(request, env, ctx);
  },
};

負荷検証用リソースの作成と設定

負荷検証ツール

今回負荷検証ツールにはK6を利用します。

readとwrite用の負荷検証スクリプトをそれぞれ作成します。また、k6-reporterという集計結果をHTML出力してくれるツールがとても簡単に導入できるので利用します。

  • read-test.js

/readを呼び出して、戻り値のデータを出力結果に記録しています。

import http from "k6/http";
import { Trend } from "k6/metrics";
import { check } from "k6";
import { htmlReport } from "https://raw.githubusercontent.com/benc-uk/k6-reporter/main/dist/bundle.js";

const dbExecDuration = new Trend("db_exec_duration");
const dbReqDuration = new Trend("db_req_duration");

export default function () {
  const res = http.get(
    "https://arai-test-cloudflare-wokers.classmethodeurope.workers.dev/read"
  );
  check(res, {
    "is status 200": (r) => r.status === 200,
  });
  if (res.status !== 200) {
    console.error(JSON.stringify(res, null, 2));
  }
  const data = res.json();
  dbExecDuration.add(data.d1Result.duration);
  dbReqDuration.add(data.dbReqDuration);
}

export function handleSummary(data) {
  const region = __ENV.REGION;
  return {
    [`out/${region}-read-test-summary.html`]: htmlReport(data),
  };
}
  • write-test.js

/writeを呼び出して、戻り値のデータを出力結果に記録しています。

import http from "k6/http";
import { Trend } from "k6/metrics";
import { check } from "k6";
import { htmlReport } from "https://raw.githubusercontent.com/benc-uk/k6-reporter/main/dist/bundle.js";

const dbExecDuration = new Trend("db_exec_duration");
const dbReqDuration = new Trend("db_req_duration");

export default function () {
  const region = __ENV.REGION;
  const res = http.get(
    `https://arai-test-cloudflare-wokers.classmethodeurope.workers.dev/write?region=${region}`
  );
  check(res, {
    "is status 200": (r) => r.status === 200,
  });
  if (res.status !== 200) {
    console.error(JSON.stringify(res, null, 2));
  }
  const data = res.json();
  dbExecDuration.add(data.d1Result.duration);
  dbReqDuration.add(data.dbReqDuration);
}

export function handleSummary(data) {
  const region = __ENV.REGION;
  return {
    [`out/${region}-write-test-summary.html`]: htmlReport(data),
  };
}

負荷検証用のサーバー

負荷を送るサーバーはAWS EC2インスタンスを利用します。

TokyoとFrankFurtの2つのリージョンに手動で作成したインスタンスからK6で負荷をかけていきます。

  • インスタンスの情報

Instance Type: t2.micro OS: Amazon Linux 2

  • K6のインストール
sudo yum install https://dl.k6.io/rpm/repo.rpm
sudo yum install --nogpgcheck k6
  • 負荷実行のコマンド

先ほど作成したK6のスクリプトを実行していきます。

今回はユーザー数を10、実行時間を600秒に設定し、Read/Writeのそれぞれに対して、別々のリージョンから同時に負荷をかけ続けていきます。

結果はCSV出力するようにしています。

  • Read Test
# TOKYO
REGION=tokyo k6 run --vus 10 --duration 600s tests/read-test.js --out csv=out/tokyo-read-test.csv
# FrankFurt
REGION=frankfurt k6 run --vus 10 --duration 600s tests/read-test.js --out csv=out/frankfurt-read-test.csv
  • Write Test
# TOKYO
REGION=tokyo k6 run --vus 10 --duration 600s tests/write-test.js --out csv=out/tokyo-write-test.csv
# FrankFurt
REGION=frankfurt k6 run --vus 10 --duration 600s tests/write-test.js --out csv=out/frankfurt-write-test.csv

検証結果と考察

検証結果の詳細はこちらのGitHubで公開しています。

Read

Tokyo FrankFurt
総リクエスト数 14,832 13,568
秒間リクエスト数 25 23
失敗リクエスト数 6 0
API全体の平均応答時間(ms) 404.48 442.16
DBアクセスの平均応答時間(ms) 393.86 428.25
  • API全体の応答時間(時系列)

Write

Tokyo FrankFurt
総リクエスト数 11,161 10,907
秒間リクエスト数 19 18
失敗リクエスト数 0 5
API全体の平均応答時間(ms) 537.83 550.20
DBアクセスの平均応答時間(ms) 527.68 536.95
  • API全体の応答時間(時系列)

考察

リクエストの成功率はどれくらいか?

合計で秒間リクエスト数が約50と割とアクセスがありますが、

Tokyo/FrankfurtnのどちらのリージョンからのRead/Writeリクエストも99.9%以上成功しています。

API全体の応答時間とDBアクセスの応答時間の比率はどれくらいか?

平均応答時間を見てみると95%以上はDBアクセスに時間がかかっているのが分かります。

リクエストがWorkerに到達するまでは比較的早いのですが、その後のDBアクセスに時間がかかっているのが分かります。

Read/Writeでどの程度違いがあるのか?

処理内容に依存すると思いますが、Tokyo/Frankfurtともに約100msぐらいWriteの方が平均応答時間が長いという結果になりました。

ただ、エッジロケーションに読み取り専用クローンが配置されるというのであれば、もう少し大きな差が出ても良さそうです。

レスポンスのserved_byという項目を確認してみると、どのリクエストもprimary-xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx.db3となっていたため、もしかしたらアルファ版では読み取り専用のクローンがまだ提供されていないのかもしれません

離れた立地による違いがあるのか?

プロット図を見てみると、Read/WriteともにTokyoからのリクエストの方が応答時間が短いのが分かります。

応答時間のほとんどをDBアクセスが占めているため、今回はDBのプライマリノードがTokyo寄りの立地に配置されているのだと推測できます。

Writeに関しては指標になりそうなデータが取得できました。 一方Readに関しては、読み取り専用クローンの機能が追加されればもう少し応答時間が短くなるのかと予想しています。

ちなみに、片方のリージョンから負荷をかけ続けて性能が向上するかというのも試してみましたが、特に変化は見られませんでした。

まとめ

いかかだったでしょうか。

少し拍子抜けな結果に終わってしまいましたが、パフォーマンス面で大きな懸念はなさそうというのが私の所感です。

CloudFlareではエッジロケーションで処理できるサービスがどんどん増えてきて、中小規模のアプリケーション開発ならもう全部これでいいんじゃないかと思えるくらいに充実してきています。

特に今回紹介したCloudFlare D1は、リレーショナルデータベースなのでより多くのユースケースに対応できるようになります。

まだアルファ版のためドキュメントや機能は少なめですが、今後の正式にリリースに期待したいと思います!!