インフラストリーミングチームの近藤 (@udzura) です。今回は、ミラティブで内製しているオブジェクトストレージサーバ「b3」の紹介記事を書きたいと思います。
今回の記事は、6月にGopher Talkというイベントで発表した「Go製ミドルウェアを実践投入するにあたりやったこと」をベースに、内容を詳細にしたり直近の開発状況に合わせて更新したものです。一部内容はこの発表と重複していますがご了承ください。
オブジェクトストレージサーバを内製した背景
昨今のWeb開発ではオブジェクトストレージを用いることはごく一般的になってきましたが、多くの現場ではクラウドベンダーによるマネージドのもの、例えばAmazon S3やGoogle Cloud Storage、Azure Blob Storageなどを用いるのが普通だと思います。
もちろん、ミラティブでもクラウドのオブジェクトストレージは活用していますが、今回は一部について内部運用と内製化を検討することにしました。
具体的には配信者さんの録画データ+ダウンロード用アーカイブデータの保存先について、クラウドのオブジェクトストレージサーバから移行しようと考えました。
最初に、その背景を説明していきます。
1. 大量オブジェクトの操作や増え続ける転送量に対応したい
ミラティブの配信はベースは内製の低遅延プロトコルを用いています(テックブログ記事)が、過去の配信の録画はHLSという形式で公開しています。はじめにHLS自体の概略を説明します。
HLSはインターネット上で動画を配信する際のフォーマットの一つです。HLSという単語自体は「HTTP Live Streaming」の略で、HTTPプロトコルを使って動画を配信します。
HLSでは最初にplaylistファイルを取得させた後は、動画を小さなチャンクに分割して配信するという点が特徴的です。これにより、動画の再生を開始する前に動画全体をダウンロードする必要がなくなり、再生の開始が速くなったり、通信帯域が狭い環境でも快適に動画視聴ができるというメリットがあります。概要は図も参照してください。
一方で、配信のために、比較的小さいサイズのファイルをたくさん作る必要があります。一般的には「たくさんのファイルを作成、配布する」というオペレーションはクラウドのオブジェクトストレージとあまり相性が良くありません。一つの録画配信を分割して大量のファイルを作成する必要があるため、そのファイル数だけPut/Getのオペレーションが発生することになります。結果としてパフォーマンスの問題やオペレーションに対するコストが大きくなる傾向があります*1。
また一般に、ストレージを運用する場合に小さなファイルをたくさん取り扱うことでI/Oや帯域が占有される現象はしばしば問題になります。詳細はClouderaのHDFSについてのブログ記事やSeaweedFSのREADMEなどを参照してください。この記事では、本問題をLOSF(Lots Of Small Files)問題と呼ぶことにします。
他に、オブジェクトへのオペレーションのコストの他にも、ファイル配布のための転送データ量にもコストがかかることも考慮しないといけません。サービスが成長し録画をする人が増えるとこのコストは伸び続けてしまいます。
ミラティブではインフラはマルチクラウド構成をしています(インフラチームマネージャーのインタビュー参照)ので、このようなパブリッククラウドのオブジェクトストレージ固有のコストについて国内のDC+ベアメタルクラウドを活用することで削減できそうだと考えました。
2. 一定期間しかファイルの保持をしない
こちらもコスト的な観点になります。
Mirrativでは、プロダクト仕様として録画を取得できるのは一定期間内となっています。したがって技術的にも録画ファイルは永続的に保存する仕様ではなく、一定期間で削除しています。一定期間後も手元で保持したい、あるいは録画を加工して別のサービスなどに利用したい配信者さんのための「録画ダウンロード」機能も存在しますが、そちらも一定時間の後でファイル自体を削除します。
このファイルは、一定期間で消えるファイルなのでコールドストレージに置くことができません。しかし、容量自体は消費するのでそのコストは一定かかり続けます。
容量に対してのコストで比較すると、クラウド上のオブジェクトストレージ(それもGCSで言うStarndardクラスのようなホットストレージ)に比べてハードディスクは非常に優位です。冗長化を考慮しても、自前でホストする方がコスト面で有利になります。ということでベアメタルストレージが選択肢として有力になりました。
ただしこの要件については、オブジェクトストレージ側で一定期間でファイルを安全に消す仕組み、いわゆるVacuumと呼ばれる操作をサポートする必要もあります。Vacuum操作の留意点についてもこの記事でお話しします。
3. オンメモリ/SSD/HDDを組み合わせたチューニングがしたい
今回の要件を検討した時点で、SSD + HDDエンクロージャーという構成のベアメタルサーバを利用可能だったという背景もありました。
具体的には以下のようなスペックのサーバになります。正確なスペックではないのでその点はご留意ください。
CPU: Intel Xeon ※ただしそこまで高速な世代ではない Memory: 256GB SSD: 350GB 程度 HDD: 100TB 程度
このSSDと十分な容量のHDDを搭載したベアメタルサーバを用いる場合、例えば
- インデックスなど速度が必要なデータはオンメモリに持たせる
- uploadやdownloadの操作などで大きめの一時ファイルが必要な場面ではSSDを使う
- 永続化が必要で容量の大きなデータはHDDを使う
...といったような、ハードウェア構成や、配布するファイルの特性に応じたチューニングをできないかと考えました。ミラティブで配布する必要があるファイルは、ファイルサイズやワークロードに一定の特徴があるはずなので、その用途に合わせたチューニングをすることでコストの最適化に繋げられるはずです。
そういう目的を考えると、既存のOSSをそのまま使うだけでなく、(もちろん十分に検証した上でですが)自分たちの思う通りに設計できる内製のミドルウェアも選択肢に入ってきました。
ということで、以下の内容を目標にオブジェクトストレージ移行を進めることになりました。
- コスト面(オブジェクトへの操作やネットワーク転送、ストレージ含めた全体)を改善する
- ハードウェアを組み合わせたより深いチューニングを進められるようにする
これを進めるにあたって、初めにOSSのオブジェクトストレージ実装を用いて検証をしましたが、検証を進めるうちに問題が出てました(どういう問題かは後半の節で説明します)。そこで、その問題を考慮したオブジェクトストレージであるb3を内製し、あらためて既存のOSSのオブジェクトストレージと比較検証して、利用可能かどうかを判断することにしました。
次の節では、そのb3の特徴を紹介します。
オブジェクトストレージb3の特徴
ミラティブでは内製のオブジェクトストレージに「b3」と言う名前を付けました*2。
b3は運用上の観点を考慮して、以下のような機能・特徴を備えています。
S3 互換の基本的なAPIを実装
まず、オブジェクトストレージとしてのAPIは、Multipart Uploadを含めてS3のものと同等に実装しました。これは、クライアント実装についても既存のものを使えると言うメリットを意識しています。
ただし、versioningなど、一部の利用しない機能については非常に簡略化するか、未実装のままにしています。
LSM-Tree index+WALなDB/マージ操作に対応
b3のバックエンドは bitcaskdb という、LSM-Tree indexとWrite Ahead Log(WAL)をベースにしたデータベース実装をフォークしたものを使っています*3。LSM-Tree indexとWALを採用しつつ、LOSF対策として小さなオブジェクトを1つあるいは複数の大きなデータファイルにまとめるようにしました。また、WALすなわち追記型のデータベースになるので書き込みの性能が非常に良くなっています。録画ファイルのアップロードはチャンクファイルを連続でたくさんアップロードするため、それに対応した形です。
削除は削除マーカーの空レコードを上から書き込むという形で行うので、削除済みのデータはインデックスをマージして本当にディスクから削除する必要があります。b3では外部から定期的にシグナルを送ってマージ(Vacuum)操作を行うようにしています。
I/O 帯域を制限可能
汎用品のHDDを搭載したマシンで運用が行えるようにしたいのと、上記の経緯でファイルのマージが定期的に行われて大きな書き込みが必要になるという背景から、ディスクの帯域を使い切らないよう単位時間でのI/O帯域を制限可能に作っています。I/O帯域の制限ができることで、実機で動かした際にもハードウェアの状況に応じてチューニング可能になります。
I/O帯域の制限はGo言語の準標準ライブラリである x/time/rate
を使っています。x/time/rate
を用いると比較的簡潔に、I/Oをはじめとしたレートリミット処理を実装できます。例えば、次のようなコードでI/O制限付きのコピーを実現できます。
package main import ( "context" "fmt" "io" "os" "time" "golang.org/x/time/rate" ) const burst = 1000 * 1000 * 1000 func main() { path := os.Args[1] dest := path + ".dst" r, _ := os.Open(path) w, _ := os.Create(dest) ctx := context.Background() lim := rate.NewLimiter(rate.Limit(5*1024*1024), burst) lim.AllowN(time.Now(), burst) written := int64(0) start := time.Now() for { n, err := io.CopyN(w, r, 1024) if err == io.EOF { break } if err != nil { ... } lim.WaitN(ctx, int(n)) written += n } fmt.Printf("%s -> %s copied in %s\n", path, dest, time.Since(start)) fmt.Printf("bps = %0.4f bytes/s\n", float64(written)/float64(time.Since(start)/time.Second)) }
このプログラムを100MBのファイルに対して実行すると以下の結果が表示されます。
$ go run iosample.go /tmp/image.test /tmp/image.test -> /tmp/image.test.dst copied in 20.000333415s bps = 5242880.0000 bytes/s
より具体的には、x/time/rate
を内部で使う priorate というライブラリを使い、I/Oの帯域制限に加えマージのI/Oの優先度を下げることを実現しています。
非同期レプリケーションをサポート
オブジェクトのデータとメタデータを実際に保持するのはbitcaskdbの中ですが、bitcaskdbの機能として非同期のレプリケーション機能を実現しています。これにより、ファイルディレクトリをバックアップするために drbd や lsyncd を導入する必要がなくなり、運用が簡潔になりました。
非同期のレプリケーション機能は embedded NATS server を用いています。NATS自体はミラティブのさまざまな箇所で活用されているので、その話もどこかでできればと思います。
細かくI/Oチューニングを実施
b3はI/O周りについてきめ細かいチューニングを行っています。まず、オンメモリ、オンディスクをストレージやリクエストによって最適化する戦略をとっており、例えばオブジェクトのダウンロードをする際に、小さいファイルなら一旦オンメモリに全て保持して効率よく配布するようにしていますが、大きいファイルはSSDを指定して一旦書き出すようにし、bitcaskdbでの操作のコストは下げつつメモリを使いすぎないようにしています。
他にも、オブジェクトのmetadataとデータ自体を保持するbitcaskdbを分けています。オブジェクトのmetadataは比較的小さいデータが大量にあるため、保存するデータファイル自体は一つにしてopen()の操作を減らし、I/Oを効果的にしています。
また、Go言語の io.Reader
/Writer
インタフェースをうまく活用して効率を上げています。例えば io.MultiWriter
を用いて、アップロードされたオブジェクトをDBに書き込むと同時にEtagで使うハッシュ値を計算させる、といったことをしています。
b3が実際にどういう実装をしているかは以前の発表スライドもご参照ください。
オブジェクトストレージミドルの検証
ここまでの経緯でOSSのオブジェクトストレージで出てきた問題を踏まえてb3を自力で実装したとお話ししました。自分たちで実装する場合は既存のOSSのものと比べ問題がないか、優位な点があるかについて確認しなければならないでしょう。
b3と既存のOSSオブジェクトストレージとのベンチ比較
まず、b3と既存のOSSオブジェクトストレージとを比較検証した結果と考察を掲載します。
既存のOSSとして検討した実装は2つあります。一つは MinIO 、もう一つは SeeweadFS です。S3互換のOSSは他にもいくつかありますがこの2つが比較的運用しやすくメンテナンス状況が十分なものと言えるでしょう。
これらとb3を合わせた3つのミドルについて、以下の条件でベンチマークを掛けました。
- サーバーインスタンスはGCPの n1-standard-8 、ディスクは pd-starndard を利用する
- ワークロードとして、実際にuploadされているHLSのファイルと同等の容量・個数のファイルを、複数回、並列でupload/downloadさせる
- SeeweedFSはベンチ中
volume.vacuum -garbageThreshold 0.1
の操作をしてmergeを実行し、現実の運用に近づけている - b3については、通常のベンチと、ベンチ実行中にVacuum操作を起動した場合の両方を観測する
利用したDockerイメージのバージョンは以下です。b3は投稿時点でのバージョンを使いました。
minio: minio/minio:RELEASE.2023-09-27T15-22-50Z seaweedfs: chrislusf/seaweedfs:3.57
結果は以下のようになりました。50並列で100回のdownload操作を行なったグラフが左、upload操作を行った際のベンチが右です。実データはGistにアップしました。
以下が結果に対する考察です。
- MinIO は書き込みの性能がそこまで出ない(実ファイルに対応させて保存しているのが影響しているか)
- マージ実行の負荷がなければ、b3はSeeweedFSよりさらに書き込み性能がいい
- マージ実行を裏で行っていても、b3の性能劣化(特に書き込み)は十分に許容できる範囲で収まる
- マージ中のダウンロードについてはb3はもう一工夫必要かもしれないが、実運用では前段にCDNを挟むため、アクセス集中でここまで劣化することはほぼないと判断できる
考察の補足
計測結果のほか、既存OSSに関してはいくつか補足する点があります。これらの問題点は、検証中に判明した内容でした。
まずMinIOの書き込みについて補足します。1つはErasure Codingの設定で、MinIOには冗長性や可用性の担保のため、オブジェクトのデータとパリティを複数のノードに書き込む機能があります。今回はSingle Nodeなのでこの点の影響はないのですが、現実の運用ではこのErasure Codingによる書き込みのオーバーヘッドを考慮しないといけません。
もうひとつMinIOには削除可能なオブジェクトを定期的にチェックするクローラーの仕組みが存在し、このクローラーがI/Oをある程度奪ってしまいます。ベンチは100回の操作を行うことで実行時間を長くし、現実運用に近いクローラーの影響が出るように考慮しています。
また、SeaweadFSは、一見パフォーマンスは十分ですが、追記型データベースなので定期的なVacuum操作が必要になります。そのVacuumは時間がかかるもので(計測では64〜128MB/s程度ずつ)、さらにkeyが多い場合にロックが発生しがちになり、パフォーマンス劣化が目立つようになります。
b3でもVacuumに一定の時間は掛かりますが、I/O制限によりHDDのI/O帯域を使い切らないようにし、なおかつロックが短くなるよう調整をしています。
I/O制限の有効性の検証
もう一点、計測した内容を紹介します。
b3の特徴としてI/Oが出ないような環境でも帯域制限を行い運用できるという点がありました。b3である程度長いベンチを走らせた際のサーバのCPU利用状況のグラフを掲載します。15:20 〜 15:30 のグラフがベンチ実行中の様子です。
ご覧の通りCPUのwaitの割合が上がりすぎないように、ゆっくりとベンチを完了させることができています。実際の負荷状況を見ながらI/Oをチューニングできるのはb3の強みです。
このようなさまざまな計測を行い、その結果を踏まえて、b3はパフォーマンス、運用性ともに今回の要件に合致していると判断しました。
実際に導入してみてのトラブルと対応
b3の開発が進んできたため、実際のユーザーのファイルの一部を保存させてみることにしました。手始めに、録画アーカイブのファイルの保存先として使い、様子を見ることになりました。
録画アーカイブはHLSの録画のファイルを結合したMP4形式のファイルで、単一の数百MB〜数GBのファイルになります。録画が有効の配信者さんの中でもアーカイブのダウンロードを実行するユーザはさらに少ないため、コントロールしつつ一定のアクセスを流す上ではうってつけと考えました。
録画アーカイブで導入した際の具体的な構成図を掲載します。b3はactive-standby構成として、常時バックアップを作成しつつ、いざという時にはstandby側に保存と参照先を切り替えられるようにしています。
検証方法としては、このアーカイブファイルの一部を既存システムと新しいb3を用いたシステムの両方に保存していき、ユーザには元の保存先を参照させつつ、徐々に割合を増やして負荷や考慮もれなどを確認していきました。
この時に、I/O周りでの問題が発見されました。何が起こったかというと、master側のI/Oは問題なく帯域が制御されていたのですが、レプリカ側のstandbyのI/Oがうまく制限できていないという状況が起こっていました。レプリカ側ではマージ操作は、masterからのマージのコマンドを受信したら行うようにしています。その際に、以下のグラフのように、保存しているファイルの容量が増えるにつれて、秒間のI/O操作量が大きくなっていくという現象が起こりました。
このままではでHDDのI/O性能の上限に引っかかるため、レプリケーションのコマンド経由でマージ操作を行う際にもI/O帯域を制限するように改修し、この問題は解決しました。
修正後のグラフが以下です。
このように、ミラティブでは少しずつリクエストを送りながら不具合をあぶり出すことで、内製のミドルウェアであっても慎重かつ安全に導入できるように運用しています。引き続き課題が発生する可能性はありますが、粘り強く計測と改善を行い、コストの最適化に繋げていきたいと思います。
We’re Hiring!!
ミラティブのインフラでは日々成長しているミラティブの安定稼働を支えるため、時には大胆にミドルウェアの開発にも挑戦しています。
技術を深掘りして成長したい方お待ちしています!
*1:実際の運用では配布はCDNを経由する、chunkをまとめるなど一定の対応はしています
*2:どういう由来かは、ぜひカジュアル面談してインフラチームメンバーに聞いてみてください :)
*3:さらに大元はRiakでのbitcaskをベースにしています https://riak.com/assets/bitcask-intro.pdf