リアーキテクティング: RedisからSQLiteへ移行
(wafris.org)- Wafrisは、Railsミドルウェアクライアント v1 で利用していたユーザー所有の Redis ストレージを、v2 では SQLiteベースのストレージ に置き換え、デプロイの難易度とリクエスト評価の遅延を同時に下げようとした
- 既存の Redis 採用は、Heroku や Sidekiq といった Rails エコシステムの慣性に影響されていたが、実運用ではユーザーが Redis 管理者 の役割まで背負わされる問題が大きくなった
- すべてのインバウンド HTTP リクエストをルールと照合する 読み取りパス が中核的なボトルネックであり、レポート用の書き込みは低速処理やバッチ・非同期への分離が可能だった
- ローカルの MacBook Air M2 で 120 万件の範囲データセットを使い、最悪ケースの IP 範囲検索をテストした結果、SQLite はローカル Redis より約 3倍高速 で、ネットワーク遅延は含まれていない
- v2 は、一定時間またはリクエスト数ごとに新しいルールを確認し、新しい SQLite DB 全体をダウンロードする同期構造を採用し、成功したインストール数が約 3倍増加 した
Redis ベースの v1 で表面化したデプロイ摩擦
- Wafris はオープンソースの Web アプリケーションファイアウォール企業で、Rails アプリケーションに組み込むミドルウェアクライアントを提供している
- v1 クライアントは、アプリケーションと一緒にデプロイされる ユーザー所有の Redis ストレージ を必要としていた
- 初期の Redis 採用には、Heroku で Redis を簡単に接続できた環境、リモートアクセスの容易さ、Sidekiq のような成功事例が影響していた
- 実際のユーザー環境はもっと多様で、多くのユーザーが Redis のデプロイや設定の問題 をデバッグすることに苦労していた
- RailsWorld 2023 でも、Rails アプリケーションの横に Redis サーバーが当然必要だという前提に否定的な雰囲気があった
速度問題の核心はネットワーク遅延
- Redis は従来型の RDBMS と比べれば高速だが、別個のデータベースである以上、接続・メモリ・プロセス管理を必要とする
- クラウド環境では ネットワーク遅延 がリクエスト処理性能を直接左右する
- Wafris はアプリケーションに入ってくるすべての HTTP リクエストを保存済みルールと照合する必要がある
- v1 クライアントをどれだけ高速化しても、アプリケーションが配置されたネットワークが遅ければ全体の応答は遅くなり得た
- Rails アプリが常に単一の “majestic monolith” 形式でデプロイされるわけでもなかった
- 複数ゾーンにデプロイされるアプリ
- 責務が重なる複数サーバーに分かれたアプリ
- 一部だけが Rails で、他の言語やフレームワークと一緒にデプロイされるアプリ
- こうした運用環境では Redis 利用の摩擦がさらに大きくなった
Wafris のリクエスト処理における読み取りと書き込みの分離
- Wafris は Rails ミドルウェアとして導入され、「IP 1.2.3.4 をブロック」のようなルールを設定したうえで、流入リクエストをそのルールと照合する
- 単純化した処理フローは 2 段階である
- HTTP リクエストをルールと照合し、一致すれば 403、そうでなければ 200 を返す
- ブロック・許可・通過といった処理結果をレポートする
- 第1段階である ルール読み取り は、レポート書き込みよりはるかに重要である
- リクエストは逐次的に処理されなければならない
- フィルタリングが機能しなければ悪意あるリクエストが通過し得る
- リクエスト評価はユーザーが体感するサイト性能に影響する
- レポート書き込みは、より低速に、バッチで、非同期に処理できる
SQLite を選んだ理由
- Wafris の主要ボトルネックはネットワーク I/O であり、SQLite ドキュメントの「SQLite はクライアント/サーバー型データベースと競争しない。SQLite が競争する相手は fopen() である」という一文が選定に影響した
- ネットワーク往復をなくすだけでも Redis ベースの構造より速くなり得るという仮説のもと、SQLite と Redis をベンチマークした
- 参考資料は次のとおり
- Aaron Francis の “High Performance SQLite”: https://highperformancesqlite.com/
- Stephen Margheim の SQLite on Rails - the how and why of optimal performance
- Oldmoe: https://oldmoe.blog/
ベンチマーク範囲と意図的な限界
- ベンチマークは汎用的なデータベース性能比較ではなく、Wafris の ホットパス と最悪のクエリを狙ったバイアスのあるテストだった
- 最悪のクエリは、IP 範囲とカテゴリをマッピングする “lexical decimal” データ構造に対する検索だった
- 単純な例として、IP アドレスが2つのアドレスの間の範囲にあるかを確認して国を返す IP → 国マッピングがある
- この構造は数百万行規模で、IPv6 では各項目が大きい
- 範囲検索は事前計算したうえで、2つのストレージに書き込んだ
- SQLite のテーブル
- Redis の sorted set
- 病的なケースでは、各インバウンド HTTP リクエストがリクエスト元 IP を次の範囲群と照合しなければならない
- ユーザー定義の許可範囲
- ユーザー定義のブロック範囲
- GeoIP 範囲
- IP レピュテーション範囲
- このクエリ種別が十分に重要だったため、他のクエリや機能は移植せず、この1種類だけをテストした
テスト方法と結果
- テストはローカルの MacBook Air M2 で、Homebrew で導入した Redis とローカル SQLite DB を使って行われた
- プロトコルは次のとおりだった
- 既存の範囲データセット 120 万件を使用
- 複数の IP セットを同じ順序で SQLite と Redis に対して実行
- 各倍率ごとにテストを 5 回実行し、平均を使用
- Wafris の特定ユースケースでは、SQLite はローカル Redis より約 3倍高速 だった
- この結果は ネットワーク遅延を考慮する前 の数値である
- テストは意図的に単純な設定と現実利用の欠点を反映しており、一般的なデータベース比較へと一般化するのは難しい
チャートに表れない運用上の違い
- たとえ SQLite の性能がベンチマークで Redis より大幅に悪かったとしても、同一データセンターやリージョン内の Redis までのネットワーク遅延を考えると、実環境ではなお高速になり得ると判断した
- Redis サーバーがクラスター化やシャーディングで堅牢に構成されていても、ネットワーク帯域幅、接続数、リージョン間遅延といった制約は残る
- SQLite は各コンピュートインスタンスにローカルに存在するため、Wafris のこのユースケースでは 水平スケーリングのコスト がほぼ消える
- オンボーディングも SQLite のほうが単純である
- ユーザーは SQLite が使われていることを意識しなくても、gem を Web アプリに追加して実行できる
- Redis にも追加の最適化余地は多いが、Wafris はキャッシュ退避ポリシーのような基本設定の変更すら、ユーザーに一貫して促すことが難しかった
SQLite への移行後に必要だった構造変更
- Redis ベースの v1 の更新フローは単純だった
- ユーザーが Wafris Hub でルールを更新
- Wafris Hub がユーザーの Redis ストレージ内のルールを更新
- SQLite では、Wafris Hub が Web サーバーへ SQLite データベースを直接「プッシュ」することはできなかった
- 一部の SQLite as a service 提供者なら類似の方法を可能にするが、コスト・性能・セキュリティ上の理由から Wafris には適していなかった
- 個々のユーザーがそれをデプロイする必要がある
- ポートを開ける必要がある
- インバウンド接続を許可する必要がある
- SQLite ベースの v2 の更新フローは次のようになった
- ユーザーが Wafris Hub でルールを更新
- クライアントが時間またはリクエスト数ベースの一定間隔で更新されたルールを確認
- ルールが変わっていれば、クライアントがまったく新しい SQLite データベースをダウンロード
- この構造により、ユーザーのインストールと設定の責任は大幅に減り、v2 クライアントの成功インストール数は約 3倍増加 した
分散デプロイにおける SQLite の構造
- AWS、Heroku、Fly のようなクラウド環境で Rails アプリがオートスケーリングすると、コンピュートインスタンスは増えてもデータベースは一緒には増えないことが多い
- リクエストが 100 req/s から 10,000 req/s に増えると、dyno、machine、EC2 インスタンスは立ち上がるが、データベースのボトルネック はそのまま残り得る
- Wafris が観測した Rails アプリ障害の主因は、実際の DDoS よりもクレデンシャルスタッフィングや悪質なボットトラフィックであることが多かった
- こうしたトラフィックはオートスケーリングを引き起こす
- その後、データベース接続が枯渇してアプリが停止し得る
- SQLite DB を各コンピュートインスタンスへ同期すれば、新しいインスタンス上のすべての呼び出しをローカルのまま維持できる
書き込みパスはクライアントから除去
- 先のテストでは書き込み処理を考慮しておらず、SQLite がすべての役割に適しているとも見ていない
- Wafris は SQLite を 読み取り中心の役割 に合わせて使い、書き込みパスは別途再設計した
- v2 のレポート経路は次のように変わった
- Wafris Hub へ非同期接続してレポート
- レポートデータをバッチ送信
- クライアントからデータベース書き込みを完全に除去
- この構造は他の多くのサービスには適さないかもしれないが、Wafris ユーザーが求める簡単なデプロイと高速なクライアントという目標には合っている
- SQLite ベースの v2 アーキテクチャは、すでに複数のサイトが攻撃に耐えてオンライン状態を維持するのに役立っており、Wafris のサポート作業とユーザーの不便も減らした
1件のコメント
Hacker Newsのコメント
各アプリケーションサーバーがSQLiteデータベースファイルのコピーを持ち、一定周期で丸ごと置き換えるモデルが本当に興味深い
ここではWebアプリケーションファイアウォールのルールに使っているが、機能フラグ設定にもよく合いそう。機能フラグはリクエストごとに何十回も確認されることがあり、「ユーザーがAグループに属し、IPがB国にある」といったクエリが必要になることが多いので、ローカルのSQLiteでうまく処理できる。機能フラグは更新の伝播が数秒、あるいはそれ以上遅れても、たいてい許容可能だ
ただし、この用途でSQLiteを使う例はまだ見たことがなく、普通はLMDBやKyoto CabinetのようなDBMスタイルのデータベースを使う
初期には各サーバーのCronジョブがそのサービスのデータを取得しており、Kubernetesに移行した後は、DaemonSetとホストのタグ付け/taintsに似た仕組みで複数のデータを各ホストにダウンロードさせ、そのデータを使うサービスがそのホスト上でのみ実行されるよう保証していた。Rubyではこれを「hammerspace」と呼んでいた: https://github.com/airbnb/hammerspace
こうすれば1つのリクエスト内で高コストな参照を2回以上行わずに済み、リクエスト処理中にフラグが更新されて値の整合性が失われるリスクもない
その上に、ゴシップネットワークや明示的なツリー型キャッシュ/配布網のような、さまざまな配布メカニズムを載せられる。より新しいバージョンを持つ相手なら誰でも更新を配れる
現在のDBバージョンをアプリケーション成果物に含めつつ、アプリ起動後は効率的に更新できる。機能フラグやライブ設定では、コードレベルのデフォルト値ではなく最新状態にfallbackできる
どのクライアントでもグローバルカウンタをETAGのように送って差分だけを受け取れ、ネットワークが一時的に切断された後の再接続も単純かつ低コストになる
数分程度の履歴を保持できるなら、W3C Baggageヘッダーのような形でマイクロサービス呼び出しにカウンタを付け、複数サービスで同じバージョン番号を基準にフラグ/設定/データを評価できる。時間ベースの評価機能がなくても、生成番号をログに残せば事後デバッグに非常に役立つ
ネットワーク遅延以外にも、Redisでは読み書きの遅延が参照するキー数にほぼ線形に比例する傾向を見てきたが、記事のグラフでも似た傾向が見える
モノリシックアプリでPostgresとRedisを用途別に併用したときはかなりうまく動いたが、新機能を共有Redisクラスタに押し込むのがあまりにも簡単だった。Redisはシングルスレッドなので、10万個以上のキーを大量参照する配慮のない機能が1つあるだけで、他の機能まで遅くなりうる
そのため、Redisは一度に1キー、または小さく固定された数のキーを読み書きするとき、たとえば人気エンドポイントのロックやレート制限のように、さまざまなランダムな機能がRedisを使う場合に特に向いている、というガイドラインを提案した
このケースではRedisは単純な単一キー(IPアドレス)参照では真価を発揮するだろうが、範囲クエリ表現のようなより複雑な読み取りにはあまり向かないようだ。ローカルRedisと比べてSQLiteがこれほどうまく動く理由を深く理解しているわけではないので、予想外で興味深かった
Redisは、より豊富な基本操作を持つキャッシュとして捉えるのが最もよい。適切に使えば高速で安定している
しかし、基本的なRDBMSに適していないものをRedisに入れ始めると、すぐにワークキューやさまざまな種類のロックが生まれ、最終的に性能が崖を越えるか、別の理由で破綻するのは時間の問題になる。復旧プロセスはたいてい煩雑になり、ある程度のデータ損失を受け入れる結末になりがちだ
こうした流れは少しずつ簡単に起こるので、避けるには規律が必要だ。SQLiteの性能はネットワークオーバーヘッドを避けられるだけでなく、多くの人がシリアライズ/デシリアライズのコストを過小評価している点も大きい。Redisプロトコルがかなりミニマルでもコストは積み重なり、SQLiteでは多くの処理がプロセス内のメモリコピーに帰着する
数週間前、Neonの社内ハッカソンでRedisのワイヤプロトコルであるRESPをPostgresクエリに変換する小さなNode.jsサーバーを作った
とても楽しいハッキングプロジェクトだった: https://github.com/btholt/redis-to-postgres
データベースが読み取り専用で複製が不要なサーバー側SQLiteがかなりうまく適合する特殊なユースケースのように聞こえる
他の代替案としては静的ファイルをメモリに載せる方法もあるだろうが、この場合は常にメモリに置いておくにはデータ量が多く、そのためSQLiteがよい代替案になっているようだ
違いは、SQLiteが非常に堅牢で、高性能で、例外的にしっかりテストされている点だ
RailsWorld 2023でRedisに対して「血の匂いを嗅ぎつけた」ような雰囲気があったというくだりが気になる
キャリアの中で本番運用のRailsアプリは1つしか触ったことがなく、そのアプリではRedisを使っていたので、最近の流れはよく分からない。エコシステムがビジネス面でRedisに背を向けつつあるのか、ライセンス変更のせいなのか、それとも YAGNI に近い話なのかが気になる
当時はRescueと一緒に、インデックス作成やトランスコーディングのような非同期ジョブの予約に主に使っていて、そのときはかなり良いツールに見えた
2024 Railsコミュニティ調査でも、Redisは依然としてアプリで最も多く使われているデータストアとして挙がっている
ただし、多くのアプリがRedisを使ってはいても、実際にはSidekiqのような用途にしか使っておらず、リアルタイムリーダーボードやベクターDB機能のような用途には活用していない場合が多いので、実際の利用実態はややぼんやりしている
今では、ある程度トラフィックのあるRails構成なら、フロントエンドサーバー、SQL DB、キー・バリューストア(RedisまたはMemcached)、そしてそのキー・バリューストアを指すキャッシュストアを置くことが多い。ところが、APIクォータやレート制限を維持する一般的なキー・バリュー用途と、キャッシュの利用パターンはかなり異なるため、面倒になる
ディスク性能が十分速くなり、SQL性能も向上したことで、キー・バリューストアを取り除き、従来のキー・バリュー用途はSQL DBに、キャッシュバックエンドはディスクに分けようとする動きがある。新しいNVMeディスクはRAMにほぼ迫るほど高速で、しかもはるかに安価なので、より多くをキャッシュできる
しかし、KubernetesとRedis-as-a-serviceの時代では、その「共有メモリ」は別のラック上の別のVMに置かれることになる。そこまで来ると、すでにリソース効率は捨てているわけで、だったらS3でファイルを読み書きしたほうがよいと思う
「Redisは従来のRDBMSより速いが、それでも接続、メモリ、プロセスなどを管理しなければならないデータベースなので、スタックをより脆弱にする」というくだりについて、玩具レベルを超えるトランザクションを扱い始めたら、リレーショナルであれノンリレーショナルであれ、すべてのデータベースはおおむね同程度の管理と保守が必要になると思う
「速い」という部分も少し面白い。JOINを気にしないなら、行の挿入や読み取りもかなり速い
ローカルディスクから直接読むのだと理解すれば、JOINやトランザクションの話はあまり意味をなさなくなる。すでにそれだけで桁違いに速い経路だからだ
VACUUMをいつ実行すべきかは、ドキュメントを読むたびに混乱する。最後にSQLiteベースのアプリケーションをデプロイしたときは、単にカウンターを置いて、多数の書き込みの後にVACUUMを実行する方式で対処していた
独立したサーバープロセスを運用しなければならないデータベースであれば、管理と保守の手間に差が出るのは当然だ。極端な話、SQLiteとOracleを使うのが本当に同じだとは信じがたい
GoでSQLite上にRedisを再実装した Redka に興味があるかもしれない: https://github.com/nalgeon/redka
クールなプロジェクトなのは確かだが、これでは切り替える動機はほとんどない
Redisの使い方が合っているか確認したい
v1では、WAFとRedisが同じサーバー上にあり、クライアントが管理パネルで新しいルールを設定すると、そのルールが管理パネルと同じサーバー上のRedisに入り、Redis内部の同期メカニズムによって世界中のWAFの横にあるローカルRedisへルール更新が伝播する、という構造だったのかが気になる。その後、新しいリクエストがどこかのWAFに来ると、WAFが更新済みのRedisルールでリクエスト/IPを検証する流れだったのか確認したい
v2では、Redisクラスタを取り除き、すべてのWAFサーバーがSQLite DBを持ち、管理パネルから各WAF+SQLiteサーバーへ新しいルールを配布する別の同期メカニズムを作り、新しいリクエストが来たらWAFが更新済みのSQLiteルールで高速に検証する構造なのかが気になる
最高の一文は「SQLiteはクライアント/サーバー型データベースとは競争しない。SQLiteはfopen()と競争する」だ