誰もが使っている構成
Buzz をセルフホストしているなら、ほぼ間違いなく deploy/compose/compose.yml にある本番 compose バンドルを、何らかの形で動かしているはずです。起動するサービスは 5 つ。リレー、Postgres、Redis、MinIO、そして buzz-media バケットを作って終わる一度きりの minio-init です。メディアのバックエンドは MinIO です。チャットに貼られた画像、ボイスメモ、動画はすべて S3 オブジェクトになり、リレーは compose ネットワーク越しに http://minio:9000 でそれにアクセスします。
注目してほしいのは volumes ブロックです。この話の舞台はそこにあります。buzz-postgres-data と buzz-minio-data はどちらもローカルの Docker ボリュームで、つまり両方が同じマシンの同じファイルシステム上にあります。会話の履歴とメディアのアーカイブが、1 本のディスク、1 つの IOPS の枠、1 つの空き容量を分け合っているわけです。初日は何の問題もありません。障害はあとからやって来て、しかもどれも前触れがありません。
block/buzz の本番 compose バンドル(抜粋)。MinIO は、Postgres とリレーが動くのと同じマシンのローカル Docker ボリュームからメディアを配信します。
満杯になる前に満杯になる
2 つのストアは食べ方が違います。Postgres はメッセージ 1 件あたり数 KB ずつ増え、レイテンシに敏感な小さな同期書き込みでディスクに触れます。メディアは添付 1 件あたり数 MB ずつ増え、ストリームで流れ込みます。両者を 1 本のディスクに載せると、大きなアップロードのたびにイベントログが必要とする fsync と競合し、しかもディスクがいつ埋まるかは、両者ともに急なほうの曲線、つまりメディアの増え方で決まります。
もっと見えにくいのは、MinIO がディスクが満杯になる前に、設計として書き込みを受け付けなくなる点です。現行の MinIO(cmd/object-api-utils.go の hasSpaceFor)では、受け取る書き込みはイレイジャーコーディングの管理分として 2 倍に見積もられ、長さが宣言されていないストリームは一律 1 GiB として扱われ(diskAssumeUnknownSize)、書き込みによってドライブが diskFillFraction、つまりハードコードされた 99% を超える場合は拒否されます。そこに達したときの API の応答はこうです。Storage backend has reached its minimum free drive threshold. Please delete a few objects to proceed.
実際には、df がまだ数 GB の空きを示しているうちからアップロードが失敗し始めます。500 MB の動画を受け付けるには丸ごと 1 GB の余裕が必要で、長さ不明のストリームは実際のサイズに関係なく 1 GB を課され、ディスクの最後の 1% はもともと使えません。アラートを使用率 95% で鳴らす設定なら、MinIO のほうが先にそこへ達するかもしれません。アラートはディスクのグラフではなく、ストレージバックエンドの拒否に対して設定してください。
2 秒かかるアップロード
本番で最初に学んだこと。メディアのアップロードには、どれもほぼきっかり 2 秒のレイテンシがついていました。40 KB のスクリーンショットで 2 秒。3 MB の写真で 2 秒と少し。マシンは暇で、帯域にも問題はなく、数字がやけにきれいでした。それが手がかりです。負荷ならばらつきが出ます。平らな 2 秒はタイマーです。
タイマーはクライアント側にありました。Buzz のメディア関連クレートは rust-s3 クレートを fail-on-err フィーチャー付きでビルドしており(crates/buzz-media/Cargo.toml)、これによって 2xx 以外の応答はすべてエラーになります。まったく正当な 404 も例外ではありません。そしてアップロード処理は、新しいオブジェクトを書く前に、メタデータのサイドカーと blob がすでに存在するかをストレージに問い合わせます。upload.rs にある 2 回の head 呼び出しです。初回のアップロードでは、どちらの答えも正しく 404 になり、その 2 つの 404 がどちらもエラーになり、クレートのリトライ処理がそれぞれのリトライ前に丸 1 秒スリープします。存在確認が 2 回、スリープが 2 回。ファイルの 1 バイト目が動く前に、2,000 ms の空白です。
何も遅くはありませんでした。何も過負荷ではありませんでした。リレーは、もともとエラーではない応答に対するリトライポリシーを律儀に待っていただけです。アップロード 1 回につき 2 回、すべてのアップロードで。この教訓は Buzz に限りません。レイテンシの数字がきれいで一定なら、CPU のプロファイリングはやめて、経路上にあるすべてのクライアントのリトライポリシーを読みに行ってください。
2 回起きる読み込み
本番で学んだ 2 つ目のこと。Buzz はすべての blob の隣に小さなメタデータのサイドカーを保存し、配信処理はそれを読んで正規の拡張子と MIME タイプを知ります。content-type の偽装を防ぐための、筋の良い設計です。ところが crates/buzz-relay/src/api/media.rs を通して 1 件のメディアリクエストを追うと、サイドカーはまず MIME を答えるために 1 回読まれ、さらに resolve_s3_key の中でもう 1 回読まれています。こちらは同じ情報を組み立て直すために、同じオブジェクトを独自に取得しているのです。blob そのものへの HEAD と GET を加えると、1 件のメディアリクエストに、順番に実行されるストレージとの往復が 4 回かかります。
ループバックの MinIO なら往復は 1、2 ms なので、重複は目に見えません。そしてまさにそれが問題です。ローカルのストレージはこのパターンを許し、あなたから隠してしまいます。同じコードの下に実際のネットワークレイテンシを置くと、リクエストのたびに 1 ホップあたり数十 ms の重複分を払うことになります。私たちはストレージ層に小さなメタデータキャッシュを置いてこれを吸収しています。上限は小さく抑え、メディアのバイトがキャッシュを押し出すことがないようにしています。教訓はこうです。ストレージがまだローカルにあるうちに往復の回数を数えてください。ストレージを移した日から、請求されるのはそのパターンそのものだからです。ミリ秒単位で。
大きなボリュームが解決策にならない理由
反射的な対策は、ブロックストレージのボリュームをマウントし、buzz-minio-data をそこへ移すことです。これでメディアの置き場所は確保できますが、たちの悪い形の請求書が新しく届くようになります。ブロックストレージはプロビジョニングした GB 単位の課金で、大手クラウドではおおむね 1 GB あたり月 $0.08 から $0.10(代表的な数字で、プロバイダーによって異なります)。中身が満杯でも空でも、ボリューム全体の料金を払います。99% の下限はこれをさらに悪くします。MinIO が要求する余裕は、プロビジョニングしなければならないのに決して使えない領域だからです。
さらに、状態を持つシステムをもう 1 つ抱えることになります。ボリュームは監視し、リサイズし(稼働中のデータに対するファイルシステム操作なので、慎重に日程を組むことになります)、スナップショットを取り、何よりバックアップしなければなりません。Postgres のダンプにメディアは含まれていないからです。メディアのボリュームを取りこぼしたバックアップを復元すると、画像もボイスメモも共有ファイルもすべてリンク切れになったチャットログが戻ってきます。これにバックアップの日に気づく人はいません。気づくのは復元の日です。
私たちが代わりに動かしているもの
私たちはホスティングしているリレーの下にあるメディアストレージ層を置き換えました。リレーは引き続き S3 を話しますが、メディアオブジェクトは Cloudflare R2 に置かれ、マシンから完全に切り離されています。ワークスペースごとに専用の名前空間の下へ書き込み、リレーのディスクは本来リレーのディスクが持つべきもの、つまりイベントデータベースとソフトウェアだけを持つ状態に戻ります。メディアの増加は、借りるマシンの大きさを決める要因ではなくなります。
保存時の暗号化についての答えも変わります。標準の compose は MinIO に保存時の暗号化を設定していないため、メディアオブジェクトは VPS のディスク上に平文のファイルとして置かれます。そのディスク、あるいはそのスナップショットやバックアップイメージを読める人なら、誰でもすべての添付ファイルを読めます。R2 はすべてを常に保存時に暗号化します。これは比較の話であって、魔法の話ではありません。鍵はストレージプロバイダーのものなので、守られるのはディスク上とスナップショット内のバイトです。そして信頼する相手が、VPS のストレージに触れたことのあるすべての人から、Cloudflare に変わるということです。
バックアップの意味も正されます。私たちのバックアップは、メディアを付録ではなく成果物の一部として扱います。バックアップにメディアオブジェクトが 1 つでも欠けていればバックアップは失敗とし、各オブジェクトはコンテンツハッシュと照合し、復元は、含まれているはずのすべてのメディアキーに HEAD リクエストを送って自らを証明します。ファイルを取り出せない復元は、復元の時点ではっきりと失敗します。それを知るのに許されるタイミングは、そこしかありません。
数字
R2 Standard のストレージは 1 GB あたり月 $0.015 で、転送料金(egress)はかかりません。本稿執筆時点で Cloudflare が公開している価格です。チームのメディア 500 GB の保存料は月 $7.50 で、それをチームに配信する転送料はゼロです。操作は別途課金で、書き込み 100 万回あたり $4.50、読み込み 100 万回あたり $0.36。チャットの負荷では、この項目が効いてくる数字にはまず届きません。
同じ 500 GB をブロックストレージのボリュームに置くと、代表的な 1 GB あたり月 $0.08 から $0.10 で月 $40 から $50 になり、しかも増える前にプロビジョニングしておく必要があります。そこに空き容量の下限が要求する余裕分と、プロバイダーがそのバイトの配信に課す料金が加わります。メディアをマシンの外に出す本当の理由は価格差ではなく、運用の形です。それでも、移行の費用は価格差でまかなえます。
もう一つ、名前について
同じ本番ノートから短い警告を一つ。リレーのホスト名は、誰かが参加する前に慎重に決めてください。コミュニティのアイデンティティはホスト名に結びついていきます。50 人がその古い名前を自分の居場所と呼ぶようになってから、名前変更の仕組みを学ぶはめになるのは避けたいはずです。
結局どうすればいいか
ここに書いたことはすべて、hachiflow.com のマネージドサービスとして運用しています。月額 $50 の定額で、name.hachiflow.chat にあなた専用のリレー、人もエージェントも無制限、メディアはここで説明したとおり R2 に置かれます。
セルフホストを続けたい場合は、効果の大きい 3 つの対策だけは取り入れて、続けてください。メディアをリレーのディスクから出すこと。アラートは df ではなくストレージバックエンドの拒否に対して設定すること。メディアを含まないバックアップは失敗したバックアップとして扱うこと。この 3 つにお金はかかりません。そしてこのページの価値の大半は、この 3 つにあります。