Cloud Run SQLite Persistence: GCS Mount vs. Litestream Performance Comparison (Part 1)

Cloud Run の Google Cloud Storage マウントと Litestream のパフォーマンス比較(1回目)

結論

Cloud Run の Google Cloud Storage マウントと、Litestream のどちらの手法も、130 req/s 以上を受付可能で、SQLite3 データベースも永続化できた。データベースへの更新を伴うようなリクエストでも、たった1台で 130 req/s 程度は捌くことができる。

ただし、Google Cloud Storage マウントの場合は、コンテナ起動中に新リビジョンを作成すると、データ損失の恐れがある。

3秒のテストでは短すぎるため、再実験が必要そうである。また、レイテンシを下げるため、Compute Engine VM を使って再実行する予定である。

今回の実験では、Litestream を第1世代のインスタンスで実行した場合に、900並列のリクエストを 293.4 req/s で捌くことができた。

今回実験した範囲では、手法間・世代間で倍以上パフォーマンスが違うということにはならなそうだ。

本記事の構成

  • 結論
  • 本記事の構成
  • 参考文献
  • はじめに
  • パフォーマンス比較
    • 仕様
    • Cloud Run の Google Cloud Storage マウント
    • Litestream
  • 設定概要
    • Cloud Run の Google Cloud Storage マウント
    • Litestream

参考文献

はじめに

Rails 8 では、SQLite のサポートが手厚くなり、「本番環境も SQLite で動かして良いよ」という状態である。

Cloud Run は、Google Cloud の Serverless Computing Platform であり、使用していないときにはコンテナが0にスケールダウンし、リクエストが来たときにコンテナが起動する仕組みが実現できる。アプリケーション サーバーには「状態」を持たないのが理想だが、何らかのデータベースが必要なサービスが多い。どのように永続化するかはなかなか面白い問題である。Cloud SQL のような RDBMS を使うのが一般的だが、こちらは「起動」し続けて使うことが多く、DB がゼロ スケールしないのである。Firestore のような Serverless なデータベースを用いる戦略もなくはない。

一定注目されているのが、コンテナ数を最大1に制限し、SQLite をバックエンド データベースとして使用しつつ、Cloud Storage に永続化するという戦略である。コンテナが1台しかないのであれば、競合の問題は発生しない。コンテナが起動するタイミングで SQLite のデータベースを Cloud Storage から読み取り、コンテナが終了するタイミングで Cloud Storage に書き戻しができていれば、問題なく動作しそうだ…というわけである。

Cloud Run は、Google Cloud Storage を(GCS Fuse で)マウントすることができる。また、Litestream という、SQLite の DB 及び WAL を(streaming replication で)Cloud Storage に永続化するライブラリがある。本記事では、どちらの方がパフォーマンスに優れるか、注意するべき点は何かを検証する。

  1. Cloud Run の Google Cloud Storage マウント
  2. Litestream

パフォーマンス比較

仕様

計測される側の Ruby on Rails の動作は以下である。

  • id, count の2カラムを持つ counters テーブルを作成
  • seeds.rbid = 'count_up', count = 0 のレコードを1件作成
  • /count_up への POST リクエスト毎に、id = 'count_up' のレコードの count を1増やす(update_all("count = count + 1"))
  • レスポンスには、count の値を {"count":4856} の形式で返す
  • /count_up への POST リクエストを多数回実行し、Request per second (RPS) を計測

パフォーマンス計測には k6 を用い、以下のような単純なシナリオを実行させた。

import http from 'k6/http';
import { check } from 'k6';

export const options = {
  vus: 900,
  duration: '3s',
};

export default function() {
  const res = http.post('https:// URL .asia-northeast1.run.app/count_up');
  check(res, { "status is 200": (res) => res.status === 200 });
}

コンテナの同時リクエスト受け付け数は 900 とした。

Cloud Run のコンテナには、1 vCPU と 512 MB のメモリを割り当てた。

Cloud Run の Google Cloud Storage マウント

Google Cloud Storage マウントを行う場合、第2世代が必須となる。

  1. 150並列

vus: 150 とし、計測した結果以下の結果を得た(抜粋)。

    checks_total.......................: 649     179.608057/s
    checks_succeeded...................: 100.00% 649 out of 649
    checks_failed......................: 0.00%   0 out of 649

    HTTP
    http_req_duration.......................................................: avg=656.82ms min=19.87ms med=730.44ms max=1.71s p(90)=1.48s p(95)=1.53s
      { expected_response:true }............................................: avg=656.82ms min=19.87ms med=730.44ms max=1.71s p(90)=1.48s p(95)=1.53s
    http_req_failed.........................................................: 0.00%  0 out of 649
    http_reqs...............................................................: 649    179.608057/s

running (03.6s), 000/150 VUs, 649 complete and 0 interrupted iterations

179.6 req/s 受付できたと言って良いだろう。

  1. 900並列

vus: 900 とし、計測した結果以下の結果を得た(抜粋)。

    checks_total.......................: 1441    132.552259/s
    checks_succeeded...................: 100.00% 1441 out of 1441
    checks_failed......................: 0.00%   0 out of 1441

    HTTP
    http_req_duration.......................................................: avg=4.14s min=21.17ms med=3.24s max=9.39s p(90)=8.85s p(95)=9.14s
      { expected_response:true }............................................: avg=4.14s min=21.17ms med=3.24s max=9.39s p(90)=8.85s p(95)=9.14s
    http_req_failed.........................................................: 0.00%  0 out of 1441
    http_reqs...............................................................: 1441   132.552259/s

    EXECUTION
    vus.....................................................................: 110    min=110       max=900
    vus_max.................................................................: 900    min=900       max=900

running (10.9s), 000/900 VUs, 1441 complete and 0 interrupted iterations
default ✓ [======================================] 900 VUs  3s

パフォーマンスは伸びなかった…というか悪化した。
132.6 req/s 受付できたと言って良いだろう。

Litestream

Litestream の動作には特別な要件がないため、デフォルトの世代(2025-05 時点では第1世代)と、第2世代を指定したもの、両方について確認した。

第1世代

  1. 150並列

vus: 150 とし、計測した結果以下の結果を得た(抜粋)。

    checks_total.......................: 633     135.508697/s
    checks_succeeded...................: 100.00% 633 out of 633
    checks_failed......................: 0.00%   0 out of 633

    HTTP
    http_req_duration.......................................................: avg=957.61ms min=21.95ms med=647.31ms max=3.34s p(90)=2.89s p(95)=3.1s 
      { expected_response:true }............................................: avg=957.61ms min=21.95ms med=647.31ms max=3.34s p(90)=2.89s p(95)=3.1s 
    http_req_failed.........................................................: 0.00%  0 out of 633
    http_reqs...............................................................: 633    135.508697/s

running (04.7s), 000/150 VUs, 633 complete and 0 interrupted iterations
default ✓ [======================================] 150 VUs  3s

135.5 req/s 受付できたと言って良いだろう。

  1. 900並列

vus: 900 とし、計測した結果以下の結果を得た(抜粋)。

    checks_total.......................: 1637    293.350468/s
    checks_succeeded...................: 100.00% 1637 out of 1637
    checks_failed......................: 0.00%   0 out of 1637

    HTTP
    http_req_duration.......................................................: avg=1.62s min=21.43ms med=2.18s max=3.22s p(90)=2.97s p(95)=3.06s
      { expected_response:true }............................................: avg=1.62s min=21.43ms med=2.18s max=3.22s p(90)=2.97s p(95)=3.06s
    http_req_failed.........................................................: 0.00%  0 out of 1637
    http_reqs...............................................................: 1637   293.350468/s

    EXECUTION
    vus.....................................................................: 213    min=213       max=900
    vus_max.................................................................: 900    min=900       max=900

running (05.6s), 000/900 VUs, 1637 complete and 0 interrupted iterations
default ✓ [======================================] 900 VUs  3s

293.4 req/s だった。倍以上のパフォーマンスが出ているようだ。

第2世代

  1. 150並列

vus: 150 とし、計測した結果以下の結果を得た(抜粋)。

    checks_total.......................: 557     148.273847/s
    checks_succeeded...................: 100.00% 557 out of 557
    checks_failed......................: 0.00%   0 out of 557

    HTTP
    http_req_duration.......................................................: avg=855.4ms  min=21.62ms med=955.69ms max=2.02s p(90)=1.61s p(95)=1.83s
      { expected_response:true }............................................: avg=855.4ms  min=21.62ms med=955.69ms max=2.02s p(90)=1.61s p(95)=1.83s
    http_req_failed.........................................................: 0.00%  0 out of 557
    http_reqs...............................................................: 557    148.273847/s

running (03.8s), 000/150 VUs, 557 complete and 0 interrupted iterations
default ✓ [======================================] 150 VUs  3s

148.3 req/s 受付できたと言って良いだろう。若干パフォーマンスが上がっているようだが、計測誤差程度と言えなくもない。

  1. 900並列

vus: 900 とし、計測した結果以下の結果を得た(抜粋)。

    checks_total.......................: 1347    178.225271/s
    checks_succeeded...................: 100.00% 1347 out of 1347
    checks_failed......................: 0.00%   0 out of 1347

    HTTP
    http_req_duration.......................................................: avg=2.97s min=22.08ms med=3.46s max=5.87s p(90)=5.34s p(95)=5.52s
      { expected_response:true }............................................: avg=2.97s min=22.08ms med=3.46s max=5.87s p(90)=5.34s p(95)=5.52s
    http_req_failed.........................................................: 0.00%  0 out of 1347
    http_reqs...............................................................: 1347   178.225271/s

    EXECUTION
    vus.....................................................................: 123    min=123       max=900
    vus_max.................................................................: 900    min=900       max=900

running (07.6s), 000/900 VUs, 1347 complete and 0 interrupted iterations
default ✓ [======================================] 900 VUs  3s

178.2 req/s 受付できたと言って良いだろう。150並列よりは良いようだ。

第1世代より値が悪かったので、再度実行してみた。

    checks_total.......................: 1361    185.216461/s
    checks_succeeded...................: 100.00% 1361 out of 1361
    checks_failed......................: 0.00%   0 out of 1361

    HTTP
    http_req_duration.......................................................: avg=2.78s min=21.93ms med=3.21s max=5.69s p(90)=5.32s p(95)=5.45s
      { expected_response:true }............................................: avg=2.78s min=21.93ms med=3.21s max=5.69s p(90)=5.32s p(95)=5.45s
    http_req_failed.........................................................: 0.00%  0 out of 1361
    http_reqs...............................................................: 1361   185.216461/s

    EXECUTION
    vus.....................................................................: 79     min=79        max=900
    vus_max.................................................................: 900    min=900       max=900

running (07.3s), 000/900 VUs, 1361 complete and 0 interrupted iterations
default ✓ [======================================] 900 VUs  3s

185.2 req/s であり、ひとつ前のテストとはあまり変化がなかった。

設定概要

Cloud Run の Google Cloud Storage マウント

Google Cloud Storage マウントは、Cloud Run の実行環境のうち、The second generation のみで利用できる Cloud Run コンテナ ランタイムの契約

先に注意事項を書いておくと、「未保存のデータがある状態で、新リビジョンの作成等で新しいインスタンスをロードして、1時的に2台以上のインスタンスが存在してしまうと、データの一部(最新の WAL 分)が失われる」ということである。データを失わないために、アクセスが全く来ておらず、インスタンスが0の状態で、Cloud Run の新しいリビジョンを作成するのが良い。前のインスタンスに /count_up を行っている段階で、Cloud Storage に WAL が保存されているのも確認できたが、新しいインスタンスがそれをロードしないのか、良く分からなかった。一定量の保存を行ったときと、インスタンスの終了時には、WAL から DB に書き戻す処理が行われるが、そうして .sqlite3 ファイルになっていた状態であれば、データが失われることはないようだった。

細かいところは、GitHub にアップロードしているソースコード db-cloud-storage https://github.com/takotakot/rails8-google-cloud-example/tree/main/db-cloud-storage を参照してほしい。要点は以下のように、バケット名 (id) とマウント先のパス (mount_path) を指定するだけである。

# ...
    {
      volume_mounts {
        name = "sqlite-bucket"
        # See Dockerfile WORKDIR
        mount_path = "/rails/storage"
      }

    }

# ...
    volumes {
      name = "sqlite-bucket"
      gcs {
        bucket    = google_storage_bucket.sqlite_bucket.name
        read_only = false
      }
    }

細かいコミット履歴が追いたければ、Pull Request #5 の2コミット目以降が良いだろう。

Litestream

Litestream は、SQLite の WAL を Cloud Storage に永続化するライブラリである。世代 (generation) の管理機能と、復元 (restore) 機能も持つ。WAL を見張り、バックグラウンドで転送してくれる。litestream-ruby は、Ruby で Litestream を簡単に使えるようにするための Gem である。Gem をインストールし、litestream.yml を作成するだけで、SQLite の DB と WAL を Cloud Storage に永続化できる。

Litestream を使用すると、コンテナ起動時に、最新のデータを Cloud Storage からコピーし、更新が書き込まれる度に、差分を Cloud Storage にストリーミングで書き出す…ということが実現できる。

ストリーミング保存であるため、Cloud Storage バケットを直接マウントする方法と異なり、新リビジョンの作成等で新しいインスタンスをロードして、1時的に2台以上のインスタンスが存在してしまっても、データ損失は発生しなかった。

全体については別記事に譲るとして、主要な設定は以下である。Cloud Run で実行する場合、自動的にサービスアカウントを読み取って認証された状態で実行してくれるようだった。

  if [ "${LITESTREAM_REPLICA_BUCKET}" ]; then
    # Restore databases
    for db in ".sqlite3" "_cable.sqlite3" "_cache.sqlite3" "_queue.sqlite3"; do
      ./bin/rails litestream:restore -- --database="storage/${RAILS_ENV}${db}" --if-replica-exists
    done
dbs:
  - path: storage/production.sqlite3
    replicas:
      - type: gcs
        bucket: $LITESTREAM_REPLICA_BUCKET
        path: production.sqlite3
  - path: storage/production_cache.sqlite3
    replicas:
      - type: gcs
        bucket: $LITESTREAM_REPLICA_BUCKET
        path: production_cache.sqlite3
  - path: storage/production_queue.sqlite3
    replicas:
      - type: gcs
        bucket: $LITESTREAM_REPLICA_BUCKET
        path: production_queue.sqlite3
  - path: storage/production_cable.sqlite3
    replicas:
      - type: gcs
        bucket: $LITESTREAM_REPLICA_BUCKET
        path: production_cable.sqlite3