データベースなしで高可用性Webサービスを構築する
(blog.screenshotbot.io)- Screenshotbotは別途DBを使わず、WebサーバープロセスのRAM上の状態をデータストアとして使い、スナップショットとトランザクションログで障害後の状態を復旧する
- SQLのシリアライズやDBとの往復、別途バックグラウンドジョブサービスを減らし、単一プロセスでの開発体験を簡素化し、インメモリインデックスとスレッドベースの処理を活用する
- 高可用性はRaft合意アルゴリズムでトランザクションログを3台のサーバーに複製することで確保し、リーダー障害時には数秒以内に新しいリーダーがリクエストを引き継ぐ
- 実際の実装はCommon Lisp、bknr.datastore、オープンソースのbknr.cluster、Baidu Braft、EFSを組み合わせており、サーバー再起動は通常1〜2か月に1回程度
- 著名なエンタープライズ顧客のCIがコミットやPull RequestごとにAPIを数百回呼び出しても、4コア16GBのマシンでCPU使用率は最大20%程度で、その大半は画像処理によるもの
RAM上の状態をデータベースのように扱う構造
- 従来のWebサービスでは、Rails、Django、NodeのようなWebフレームワークと、MySQL、PostgreSQL、MongoDBのようなデータベースを組み合わせて選ぶ方式が一般的だった
- この10年で、このアプローチを見直すに値する環境変化が起きた
- ディスクがNVMeによって高速化した
- EBS/EFSのようなディスクがより堅牢になった
- RAMが安価になり、ほとんどのスタートアップのデータがRAMに収まるようになった
- 数百コアのマシンをレンタルできるようになった
- 2014年にRaft合意アルゴリズムが発表され、堅牢な実装が増えた
- このアーキテクチャはWebサービスとデータベースインスタンスを統合し、プロセスのメモリ状態をデータベースのように使用する
- SQLiteのようにデータをシリアライズして保存するのではなく、RAM内のオブジェクトやフィールドをアプリケーション状態として直接扱う
Explore: 初期プロダクトの反復を簡素化する方法
- すべてのデータがRAM上にあれば、SQLクエリにシリアライズする必要がなく、複数のフロントエンドサーバーが単一DBに接続する構造も減らせる
- 負荷が増えた場合は、より多くのRAMとCPUを備えた大きなサーバーを使うことで対応できる
- インデックスには、ディスクレイテンシに合わせたB-treeではなく、インメモリハッシュテーブルのような構造を使用できる
- Screenshotbotでは、functional collectionsを使ったインデックスがスケーラビリティに重要だった
- データ取得がRAM読み取りなので、DB往復を減らすための特殊な構造やAsync-IOは不要
- バックグラウンドジョブは同じ大きなプロセス内で実行されるスレッドになり、並行処理はほとんどインメモリのmutexとcondition variableで解決できる
障害復旧: スナップショットとトランザクションログ
- プロセスクラッシュに備え、RAM全体の状態を定期的にスナップショットとして保存する
- 最後のスナップショット以降の変更は、まずディスクにトランザクションログとして記録してからメモリ状態を変更する
foo.setBar(2)は、fooのbarフィールドが2に変わったというトランザクションを記録した後、実際のフィールドを変更するnew Foo()はFooオブジェクトが生成されたというトランザクションを記録した後、新しいオブジェクトを返す
- 再起動時には、まずスナップショットを読み込み、トランザクションログを再生して状態を復旧する
- インデックスの変更はトランザクションログに入れなくてもよい
- 例えば
Fooのbarフィールドにインデックスがある場合、setBarがインデックスを更新し、この更新はスナップショットのロードやトランザクション再生の過程でも再び行われる
- 例えば
単一プロセスが可能にするコードの形
- すべてのリクエストが同じプロセスで処理されるため、サーバーメモリにクロージャを保存してページリクエスト処理に使える
- Screenshotbotの
https://screenshotbot.io/n/nnnnnnn形式のURLは内部クロージャにマッピングされる - クロージャがオブジェクト参照を保持しているため、ページ遷移ごとにオブジェクトIDを渡したり、オブジェクトをシリアライズしたりする必要が減る
- デバッグ、プロファイリング、モニタリングの対象も1つのサービスに絞られる
- MySQL slow query logのような別途DBの観測ポイントがなくなる
- 1つのサービスが落ちればサイトも落ちるが、サービスとサーバーが1つだけなら失敗の可能性も低いと見ている
- サーバーが死んだ場合、AWSが数分以内に新しいサーバーを立ち上げられる
- テストコードもデータベースをmockする必要がないため容易になる
Expand: Raftで高可用性を確保する
- 高リスクの顧客が99.999%の可用性を求める場合、単一サーバー構成だけでは不十分
- サーバー障害時にAWSが再び立ち上げるまで数分かかることがある
- プロセスがディスクスナップショットを復元するのにも数分かかることがある
- 再デプロイ中のサービス再起動で、サーバーが数分間停止することがある
- Raft合意アルゴリズムは、有限状態機械であるWebサーバー/データベースのトランザクションログを複製する
- 3台のマシンに複製すると、リーダー障害時に数秒以内に新しいリーダーが選出され、リクエスト処理を継続する
- 開発者がコードを書く方法を大きく変えずに、単純なサービスを高可用性データベースのようにできる
- Raftベースの構成では、サーバーを停止せずにローリングデプロイも可能
Extract: シャーディングと想定されるボトルネック
- さらに多くの大規模顧客を扱う段階では、大企業がデータベースで使う方法と同様にシャーディングを適用できる
- 各シャードは独自のクラスターになり得る
- Screenshotbotはすでにエンタープライズ顧客ごとに専用クラスターを提供している
- MetaはMySQLクラスターの複製を処理するためにRaftへ移行しており、Screenshotbotは別途データベースなしで似た方式を使っている
- 主なボトルネック候補はcommit-threadのスケーリング
- 読み取りスレッドはうまく並列化される
- 1つのcommit-threadがトランザクションを1つずつ適用する
- Raftが複数のトランザクションをまとめてディスクにコミットするため、ディスクレイテンシは重要ではない
- 懸念点は、トランザクション適用のCPUコストが単一コア性能を超える場合
- その場合はcommitコストをプロファイリングし、処理の一部をトランザクションスレッドの外へ移すか、シャーディングを検討できる
Screenshotbotの実際のスタック
- ScreenshotbotはCommon Lispを使用している
- 初期実装ではMySQLを使っていたが、MySQLで並行性を扱うのが難しかったため、bknr.datastoreに置き換えた
- bknr.datastoreは、Explore段階で説明した構造をCommon Lisp向けに提供するライブラリ
- この構造では、単一プロセス内のスレッドがWebリクエストを処理するため、強力なマルチスレッドが重要
- この要件のため、RubyやPythonは適していないと見ている
- クロージャをサーバーメモリに保持するため、サーバーを頻繁に再起動しにくい
- 再起動するとクロージャを失う
- 代わりに、実行中のプロセスでホットリロードによってコードを更新する
- Common Lispは、クラス定義変更時に既存オブジェクトを更新する標準である
reinitialize-instanceのような機能を提供する
クラスター、ファイル保存、運用規模
- サーバー再起動は現在、通常1〜2か月に1回程度発生する
- 再起動が必要なときは、Raftクラスターでローリング再起動を行う
- インストールごとに3台のサーバークラスターを使用し、この構成ではサーバー1台が停止する状況を許容する
- Kubernetesは現在使用しておらず、まだ必要ないと見ている
- Raft実装はbknr.datastore上に作った独自ライブラリ
- bknr.clusterをオープンソースとして公開している
- 内部ではBaiduのBraftを使用している
- Braftはバックグラウンドスナップショットを処理し、スナップショット作成中でもサーバーがリクエストを処理し続けられる
- 画像ファイルやdatastoreに入れるべきでないblobは、3台のサーバーが共有するEFSに保存する
- EFSは高可用性NFS
- S3よりもエラー条件を別途扱う必要がなく、扱いやすいと見ている
- 外部サーバーと相互作用せずにディスクへ書き込むため、テスト可能性も高い
現在の性能と適用範囲
- Screenshotbotはいくつかの大規模エンタープライズ顧客と、特によく知られた顧客1社を支援している
- その顧客のCIで実行されており、すべてのコミットとPull RequestごとにAPIリクエストを数百回受け取る
- この負荷でも、リクエスト処理は4コア16GBのマシンで十分
- 複製サーバーも似たマシンを使い、ほとんどアイドル状態
- CPU使用率は最大20%程度
- CPU使用量の大半は画像処理によって発生する
- 必要な規模より大きな規模を前提に設計しないことが重要
- Common Lispを選ぶ場合、関連構成はScreenshotbot OSSとして利用できる
1件のコメント
Hacker News の意見
この構造は、HashiCorp の Nomad、Consul、Vault が作られている方式とおおむね似ています(私は Nomad のメンテナーの一人です)。確かに特異な構造ですが、慣れると開発者体験はかなり良いです
メモリ内の状態は望む形で持てるので、アプリケーションに合わせたインデックスやクエリ関数を自分で作れます。Raft FSM に
:memory:SQLite を使ってもよいですが、メモリ内トランザクションストアを作る、または見つけることができれば(私たちは独自の go-memdb を使っています)、状態の読み取りは単なる関数呼び出しになります。古い読み取りや書き込み偏重を防ぐのも単純です。書き込むすべてのオブジェクトに Raft インデックスがあるので、「フォロワーにオブジェクト foo を問い合わせるが、少なくともインデックス 123 まで待て」といった API を作れます。通常なら RDBMS や外部ストレージに押し込める多くの「魔法」を取り除いてくれますただし、「インフラ」領域以外の新しいスタートアップでこの構造を選ぶのは慎重になります。事実上、自前のデータベースを作ることになるからです。ノード間 RPC、ディスク永続化、メモリ内トランザクション状態ストアといった基本要素をうまく選ぶか、自分で書く必要があります。アップグレードは特に難しく、新しいコードが旧バージョンのノードには理解できないエンティティを Raft ログに書こうとする可能性があります。さらに悪い場合、処理方法が変わって旧ノードが誤って解釈してしまうこともあります。無料のランチはありません
:memory:SQLite を使ってもよい」という部分は、rqlite[1] が最初の約7年間使っていた基本設計でした。しかし rqlite はディスクベースの SQLiteへ移行し、WAL モードとPRAGMA synchronous=OFF[2] を使うと、RAM に書くのと同じくらい速いか、十分に近い速度でした。そのおかげで:memory:SQLite データベースの制約も避けられました。その一つが最大サイズ 2GB の制限です。最初からディスクモードを使うべきだったのですが、今になって分かりましたrqlite は Nomad と同じ Raft ライブラリ[3] を使っているので、このうち一部はすでにご存じかもしれません
アップグレード問題は実際に存在します。Nomad では現場で頻繁に起きているのか気になります。rqlite の10年の開発期間で新しい Raft Entry タイプを導入したことは非常にまれで、実際のユーザーが直面したのは一度だけでした。対応方法の一つは、新しいタイプを理解するが決して書き込まないバージョンを先に配布し、そのバージョンが完全に行き渡った後で、実際に新しいタイプを書くバージョンへ上げることです。ただし実際にそうしたことはなく、エンドユーザーにも規律が必要です
[1] https://www.rqlite.io
[2] 危険に聞こえるかもしれませんが、現在の rqlite の設計では、起動時に基盤となる SQLite データベースを Raft ログから完全に再構築します(Raft ログは書き込みごとに fsync されます)。そのため停電などで SQLite データベースが破損しても、SQLite データベースは rqlite の権威あるデータストアではないので、大きな意味はありません
[3] https://github.com/hashicorp/raft
ただし拡大段階でレプリケーションを使い始めると、エンジニアリング上の課題が生じるのは確かです。それでも、どれも解決可能な問題です。Common Lisp ではコードをホットリロードできるので、一部のマイグレーションがずっと簡単になる点も助けになります
数十年前、PG は Viaweb でデータベースを使っておらず、デスクトップアプリはそうではないのに、Web アプリがデータベースのフロントエンドのようになるのは奇妙に見えた、と書いていた[0]。HN もデータベースを使っていない。
しかし現代のデスクトップ・モバイルアプリが一般にデータベース、多くは SQLite を使うようになったため、もはやそのまま当てはまる話ではない。リレーショナルなデータ保存とクエリは、非常に広い範囲のアプリケーションでかなり有用だと分かってきたからだ。
[0] https://www.paulgraham.com/vwfaq.html
vwfaq には依然としてディスクからデータを読むという記述があり、「HTTP リクエストに応答するプロセスを起動する」という話もある。ここでの「データベース」は、データを永続化する別サーバーと、そのデータを取得するために別のサーバーと通信しなければならない構成を指していたように見える。
この定義なら、当然 SQLite はデータベースから外れる。また、すでにディスクからデータを読んでいるなら、データベースを使っているか、場当たり的に作った独自の永続化レイヤーを実装していることになる。アプリ起動時に SQLite からデータを読み込むなら、それでもデータベースを使っていると見なすのかが問題だ。
こうした考え方の問題は、データベースの核心が、低レベルの詳細を気にせず便利な方法でデータを保存し取得できることにある、という事実を無視している点にある。データをデータベースに保存するというのは、どこかで Postgres インスタンスを立ち上げ、Web 経由でデータを取ってくるという意味ではない。すべてのデータをメモリ上に置き、ログ構造のデータ構造でスナップショットをディスクに保存するプロセスを用意したなら、おめでとう。たった今、独自データベースを作ったことになる。
第二に、この20年で RDBMS のクエリはずっと単純になった。定型コードを減らしてくれるあらゆる ORM や行マッパーが登場した。
デスクトップ・モバイルアプリに有用な全文検索のような高度な機能も出てきた。今日、デスクトップアプリに RDBMS を使うのは良い選択だ。
Web ははるかに大きくなり、こうした慣行の多くは今日では通用しないだろう。顧客と通話中に、今のレベルのテスト負荷を抱えたままプロダクションマシンへライブ修正を押し込んだら、ここにいるかなりの人は私の精神状態を疑うはずだ。
面白いことを試したい気持ちは分かるが、MySQL や Postgres の最も基本的な部分を学ばないためにこうするのは、途方もない時間の無駄に見える。普通にその上に作れば済む話で、特にパブリッククラウドで動かすならなおさらだ。
ラウンドトリップ遅延の増加や同時実行性の問題という話には納得できない。後者には基本的なチューニングや、騒がしい顧客を分離するといった単純な解決策がある。彼らのブログの別記事では、1日1,000万行を追加する可能性やインデックス作成の課題について語っているが、それは文字どおり何でもない。その10倍になっても、カスタムソリューションをエンジニアリングする理由にはならないと思う。
本当に必要になるまでは「悪いほうが良い」が正しく、その時が来ればはっきり分かる。その時点ではボトルネックも分かっているので、最初から過剰に作り込むより賢く対処できる。
ところが、ブロガーが SQLite を選ばない理由として、不要な機能があるかもしれないと主張しているのは困惑する。それは途方もなく、何の正当化にもならない。
この記事は、仮想上の問題に対する悪い解決策から出発し、明白な解決策を拒むために無理な理屈を並べる必死の試みのように読める。
自分の考えが間違っているのか、それとも議論を呼ぶが不要に見えるこの追加の努力こそが製品の一部であり、この領域で成功する方法なのかが気になる。
「データが依然として直列化されるSQLiteのようなものを言っているのではない」から始めておきながら、結局は直列化が必要で、レプリケーションもしなければならない独自のトランザクションログに行き着くのは奇妙。データベースのレプリケーションも本来そうやっているもの
負荷が1台のサーバーにすべて入るなら、そのサーバーでデータベースを動かせばよく、「データベースへの往復を減らす特別な構造」みたいなものは忘れればよい。データがすべてRAMに収まるなら、望むならデータベースにRAMディスクを使い、標準ツールで永続ストレージにレプリケートすればよい。それが実際には単純
なので基本的には https://litestream.io/ のようなもの。明示的なRaft構成のおかげで切り替えはより速いかもしれない。Litestreamユーザーではないので微妙な違いは分からないが、とても似ているように聞こえる
こうした過度な単純化はさておき、アイデア自体はかなり気に入っているし、記事も概念をかなりうまく説得していると思う。多くのシステムでは、異常なほど成功したとしても事業の大部分または全部を支えられるほど十分にスケールするだろうし、性能はほぼどんな代替案と比べてもばかげているほど良いはず
データベースをコールドスタートした後、かなり大きな
selectを2回実行してみればよいトランザクションデータベースはExpandとExtractの段階では単純だが、Exploreの段階ではプロダクトではなくインフラの問題に集中させるため、追加の負担になる。Explore段階では顧客がいないのでデータもなく、データの信頼性も重要ではない
bknr.datastoreで全てをメモリに置く方式(レプリケーションなし)はExplore段階では単純。しかしExpand段階に入ると、データ整合性を保証するための運用負担が生じる
とはいえExpand段階に到達する頃には、すでにプロダクトを検証し、コードもかなり書いている。その時点でトランザクションデータベースに書き直すのは筋が悪く、上に Raftレプリケーション を追加する方が簡単
この記事の論理には困惑する。これがステートフルなアプリケーションをより単純かつ高速にする方法だというのか?
前提は弱く、主張は過剰。著者は 直列化の難しさ を誇張して、弱い主張をより強く見せている
こういう人たちはたいてい一緒に働きにくい。自分が相手にしなくて済むよう、沈めるスタートアップを見つけてくれてよかった
新しいプロジェクトを始めると、データ構造はたいてい「属性を持つ項目のリスト」になる。たとえば今フィットネスアプリを書いているが、データは運動のリストで、各運動にはタイトル、説明、動画URL、その他の属性がある
普通はそれらの項目を
dataディレクトリのYAMLファイルに入れるところから始める。実際には、元のYAMLのおかしな点を取り除いたカスタムYAML方言。各値は文字列で、魔法のような型変換はない。新しい項目を作るのは単にvim crunches.yamlでデータを入れる作業で、編集や削除もこのデータ構造ではとても簡単プロジェクトが大きくなると、普通はDBスキーマを作り、項目をMariaDBやSQLiteに移す
今回は項目(運動)を SQLite DBのJSONカラム に移すつもり。1つの項目のすべての属性を単一のJSONフィールドに保存し、JSONフィールドをYAMLのように編集できる小さなDBブラウザを書こうとしている。人間が読めるデータを編集する便利さを維持するため
DBブラウザを書くのはかなり素直にできそう。少しのncursesでテーブルを見て回り、1つ選び、行を探索し、行を挿入・削除する。フィールドを編集するときはVimを起動する。フィールドがJSONならVimに渡す前にYAMLへ変換し、ユーザーがVimを終了したら再びJSONに戻す
記事の序盤で説明されていたものは、基本的に NUMAマシン が動作していた方式に似ていた(例:SGI AltixやUV)。また、彼らが主張した利点は、低レイテンシと、大容量RAM上でマルチスレッドにより作業を並列化できることだった。クラスタリングは、100万ドルを超えるマシンに対する低コストの代替案として登場した。アプリが単にメモリに書くと透過的にディスクへマッピングされるAS/400の永続性とも類似点がある
今や安価なハードウェアで、クラスタ型NUMAマシンの利点へ時間をさかのぼっているようなもの。その過程で改善もあり、記事は面白く読めた
過去のもう一つの手法は、クラスタ内部でTCP/IPスタックを取り除き、関連する問題をなくすことだった。Active Messagesのような解決策は、ハードウェア上の薄い層だった。強い整合性が組み込まれたネットワークルーター設計もある。彼らにできることはかなり多い
大きくなれば ハードウェアの機会 もある。CPU側でSGIは2つのことをした。NUMAマシンは1つのシステム内のCPUとRAMの数を拡張し、FPGAをメモリバスに直接差し込んでカスタムアクセラレータとして使えるようにした。最後に、一部のコンピュータサイエンス論文は、マルチスレッドのボトルネックをなくす、または減らすために、プロセッサ命令セットやチップ内ネットワークなどを修正していた。OpenPitonのようなチップは、オープンでカスタマイズ可能なコアでコア数を増やす(例:32個)
「データを SQL クエリにシリアライズする必要がなければ、どれほど素晴らしいものを作れるか想像してみてほしい」という話は、十分に成熟したアクターモデル[0]の実装、たとえば Akka Event Sourcing[1] にすでに存在している。次の問題にも対応している。
「では重要なのは、プロセスがクラッシュしたときにどう復旧するかだ。答えは簡単だ。定期的に RAM 全体のスナップショットを取ればよい」
「Web 開発のための新しいアーキテクチャ」を作る必要もなく、本質的には解決されている。アクターを使って RAFT プロトコルを探求するオープンソースの試みも、ここ[2]とここ[3]にある。
0 - https://en.wikipedia.org/wiki/History_of_the_Actor_model
1 - https://doc.akka.io/docs/akka/current/typed/persistence.html
2 - https://github.com/Michael-Dratch/RAFT_Implementation
3 - https://github.com/invkrh/akka-raft
Cosmos DB、MongoDB、DynamoDB のようなものを永続化プロバイダーとして選べば、永続化された状態をクエリすることもできる。
https://learn.microsoft.com/en-us/dotnet/orleans/grains/grai...
https://learn.microsoft.com/en-us/dotnet/orleans/grains/tran...
https://learn.microsoft.com/en-us/dotnet/orleans/grains/even...
最初に思ったのは「おお、Common Lisp で書いていたときに自分がやっていたやり方だ。誰かがその手法を再発見したとは面白い」だった。
でもそうではなく、単にLisp の人たちがほかにもいただけだった。
「RAM は非常に安い」というのは、開発者にとって最大の誤解かもしれない。
SSD はスループットや IOPS の面で 100〜10000 倍良くなり、vCPU のドルあたり性能も 20〜50 倍上がった。45/32nm から今では 5nm/3nm になり、クロックあたりの命令処理量もはるかに高くなった。
しかしRAM 価格は CPU や SSD ほどにはほとんど下がっていない。ずっと速くなっている可能性はあるし、より高密度なチップで多くのメモリを挿せるようになり、チャネル数もデュアルから 8 本や 12 本に増えているかもしれない。だが 2008〜2022 年の DRAM スポット価格を見ると、DRAM の最安値は 3 回にわたって約 2.8 ドル/GB で同じ水準だった。同じ期間のサイクルでは 6〜8 ドル/GB まで上下していた。つまりこの約 15 年間、安値圏や高値圏で DRAM を買ったなら、インフレを無視すれば価格はおおむね ±10〜20% 程度で似たようなものだったはずだ。
2022 年半ばになってようやく 2.8 ドル/GB の壁を破り、1 ドル/GB 近くまで崩れたあと、DDR5 基準で約 2 ドル/GB に落ち着いた。
今ではサーバー 1 台に 4TB の RAM を載せることはできる。だからといって DRAM がものすごく安いという意味ではない。平均的な開発者やビッグテックの開発者は 2010 年よりはるかに多く稼ぐようになり、そのため RAM がずっと手の届きやすいものになったと感じているのだ。実際には、この 15 年間の最安値で見ても DRAM の価格下落はせいぜい 2 倍を少し超える程度だ。そして 1〜2 年以内に DRAM 価格が再び急騰する可能性は大きい。
単純な例として、20 年前にあるノードが 16GB RAM に制限されていたなら、システムで 4TB RAM を確保するには 256 台のノードが必要だったはずだ(各 OS のオーバーヘッドは除く)。
今日では単一ノードがその 4TB 全体を 1 つのシャーシに収められる。
RAM チップ自体の総コストは変わっていないかもしれないが、物理システムでその RAM を実際に利用するコストは劇的に下がった。