CubeFSの論文を読んだ

CubeFSの論文 を読んだ。 CubeFSはOPPOが主体的に開発しているOSSの分散ストレージで、 HDFS、POSIX、objstoreのインターフェースを持ちCSI pluginの提供も可能かつ高性能高可用性を謳っている。 CNCF のIncubating projectでもある。

この論文の著者欄は中国科学技術大学とjd.comのエンジニアとなっているが、 jd.comとOPPOに直接の強い資本関係は無さそうであるし、 どういう経緯でOPPOが主体となったのかは分からないがコアのコンセプトや技術はあまり変わっていなさそうではある。

このメモは論文の単なる要約ではなく、私自身の意見も踏まえた上で書いているので誤解している箇所もあるだろうし、論文に書かれていないことも書いているのであくまでただのメモだと認識してほしい。

背景

現代のコンテナが一般的となったシステムでは、計算が実際に行われるノードとストレージの結合を上手く抽象化してやる必要がある。密結合な状態ではコンテナの動的なスケジューリングは難しい1 。 ストレージシステムをワークロードごとに複数抱えるのはコスト(金銭的なものに限らず、運用コストも含むと思う)の問題であまりやりたくない。 大規模なファイルを扱うならHDFS があるし、 小さなファイルに対してはHaystack がある。 ただどちらのワークロードにも対応したファイルシステムというのは少ない。 それでもいくつかの先行研究はあるのだが、それらは大抵one-size-fits-allなアプローチをとる(単純に対応出来るファイルサイズの幅を持たせているだけ、という意味だと思う)。 それだとレプリケーションの最適化がしづらいので問題である。

加えて、ストレージシステムにはメタデータサービスが必要だがシングルノードのメタデータサーバはハードウェア限界からボトルネックが生まれる2 。 かと言って単純に分割すればいいというわけでもなく、 メタデータのリバランシングが行われると大きなR/W性能の低下が生じる。 (単純なConsistent hashingとかがそうだ。 ノード数に対するスケーラビリティとノード増減に対するElastic性はまた別に話だということの好例だと思う)

そしてPOSIXの厳格なインターフェースは性能向上を図る上でとても厄介だ。 大抵の分散ファイルシステムは一貫性を弱めることで解決しているが、 それでもある程度のatomicityは保証する必要があるし、 それは性能についての制限を生む。

これらの問題に対処するために作られたCubeFSは以下の特徴を持つ

  • 汎用高性能ストレージ
    • Linuxのpunch holeインターフェースの活用がキモである
  • シナリオ別のレプリケーション
    • appendとoverwriteの2つのレプリケーション戦略の使い分け
  • 使用量を考慮したメタデータの配置
    • メタデータのリバランシングを回避する
    • MooseFSも同様のアプローチを取っている
  • POSIXマンティクスの緩和と原子性の保証
    • 厳格なPOSIXせマンティクスは性能を稼げないので、注意深く弱めた
    • (私見)これはみんなやってること

アーキテクチャ

Metadata subsystem、Data subsystem、Resource managerの3つのコンポーネントからなる。 Metadata、Dataはそれぞれノード内でPartitionに分割される。 CubeFSの重要なコンセプトにVolumeというものがあり、  前述のPartitionは1つのVolumeに属する(1つのVolumeは複数のPartitionから構成されうる)。

Volumeはクライアントから見た場合は1つのファイルシステムと同じように見えて、マウント可能である。

Resource managerはシステム自体の管理を行うノードでNodeの追加や削除などのメタ的な操作を処理し、 ノードのリソースを監視する。 このリソースマネージャーはRaftで一貫性を保持し、データはローカルのRocksDBで永続化される(ただし基本的にはinmemoryを指向している)。

メタデータサーバ

inodeとdentryを扱うBTree、free listなどをインメモリに持つ構造。 レプリケーションはMultiRaftを使い、ローカルディスクにログを書いてFailure recovoeryを行う。 メタデータ操作中のエラーはdentryに属さないinodeを作り出すが、 これの解決は困難であるのでクライアントがretryを行うことで発生率を下げる。

データサーバ

複数のData partitionを持つ。Data partionはextent store を持ち、またidとreplicaへのアドレスを持つ。 高速化のためにCRCのchecksumのキャッシュをインメモリに持っている。

データサーバはある境界値t(デフォルトだと128 KB )より小さいものを Small file、大きいものをLarge fileとして別々に扱う。

Large fileは単一または複数のextentを排他的に使う。 一つのファイルを構成するextentは複数のデータパーティションに属しうるので、 データサーバも当然異なりうる。

Small fileはextentを共有する。extentとオフセットの情報はメタデータサーバに記録され、 fallocate で領域を確保することで細かいストレージ管理をLinuxに委任している。

シナリオ別のレプリケーション

Sequential Writeはprimary-backupレプリケーション、 ランダムアクセスはMultiRaftベースのoverwriteレプリケーションを行う(メタデータもこのoverwriteレプリケーションを行う)。

primary-backupレプリケーションで上書きを行うとその度に新しいextentが発生し、 かつそのextentは元のextentとリンクさせる必要がある。 これは容易に断片化を招くしその管理にコストがかかるので適切ではない。

一方でoverwriteレプリケーションはWrite ampiliationを発生させるが、 大半がSequentialになるようなHPCやmicroserviceでよくあるシナリオではこれは大きな問題ではない。

Failure recovery

まずはprimary-backupレプリケーションでextentを用意し、 次にMultiRaftでそのextentをoverwriteして復元を行う。

またデータパーティションは古いデータが残っていることを許容する。 Raftベースでのファイルシステムであるため、 クライアントに返されるデータは完全にコミットされたデータだけとなる。 データが古いかどうかはoffsetの大小を見ればよく、これはメタデータサーバで管理されている。 (Read after Write annormalyとかどうなんでしょうね?)

リソースマネージャ

シングルノードではなくマルチノードでメタデータサービスを構築する場合の手法として、 hashingやsubtree partitioningなどがあるが、 これらは新規のノード追加などで再分散が必要となる。 これは高速な水平スケーリングが行われる現代のコンテナ環境においては頭痛の元となる。

lazyhybridやdynamic partitioningといった手法による対処もあるが、 これはエンジニアリングコストがかかりメンテナンス性に悪影響を及ぼす。

使用率ベースの分散は単純な方法でこれを解決する。 メタデータ、データの両方のパーティションを最も使用率が低いノードに割り当てるだけである。 この方法は新規にノードが追加されてもそのノードが優先的に使用されるだけでデータのrebalancingは走らない。(冗長性確保のためのノード分散があるので、実際はもう少し複雑だと思う)

また、メタデータについてはテーブルの分割が必要になる場合がある(inodeかdentryが上限に達した場合) その場合は現在までに使われているinodeの最大値 + Δを基準に分割を行う。 (ここからもわかる通り、CubeFSは二分木的な分割手法ではなく最大値に達した場合に新しくアロケーションを行う戦略を全面的に採用している。これはデータ量に対する柔軟性には欠けるが、永続的なストレージの場合は順次使用量が増すようなワークロードとなるし、現実的にハードウェアの性能には上限があるので十分に妥当だと思う)

また何らかの障害でメタデータパーティションが利用不能となった場合は残りのレプリケはreadonlyに設定され、 新しいパーティションへの移行が手動で(手動で?)行われる。 (ノード障害への対応がちょっと面倒になりそうだし、停電からの復旧作業とかが大変そうだ。これは要検証)

クライアント

FUSEで提供され、データそれ自体を除く多くの情報をキャッシュする。

最適化

  • heatbeatの最小化
    • これは所謂MultiRaftそのもの
  • 非永続的なコネクション
    • 大量のコネクションが保持され続けるのはリソースの無駄

メタデータ操作

多くのPOSIXに準拠したファイルシステムは空間的局所性のために同じノードに メタデータとデータを置くが、CubeFSの配置戦略では別々のノードに配置されうるしその場合は分散トランザクションが必要となる。これはパフォーマンスに大きな影響を与える。

そこでトレードオフとして、dentryが 常に 少なくとも1つのinodeに結び付けられている場合に原子性の要件を緩和する。 この手法の欠点はdentryに結びつけられないorphanなinodeが発生する可能性があることだ。 orphan inodeのGCは困難であるので、なるべく発生しないように注意深く設計を行い、 かつfsck のようなツールも提供している。

Create

Resource managerがメタデータパーティションを割り当てるので、 そこでinodeをアロケートし、成功した場合にdentryを作成し割り当てる。 dentryの作成に失敗した場合はunlink リクエストを送り、 Orphanなinodeのリストへと作成したinodeを追加する。 このリストはクライアントからのevict リクエストによってGCされる。

Link

inodeでnlink をインクリメントしてからdentryを作成する。 失敗すればnlink を巻き戻す

Unlink

dentryを削除し、inodeのnlink をデクリメントする。 デクリメント後のnlink が0ならOrphan listに追加する。 dentryの削除失敗やデクリメント失敗はそのまま失敗として終了する。

ファイル操作

CubeFSは逐次一貫性のみを保証し、複数のクライアントからの同時書き込み防止などのメカニズムは提供しない(提供されるとSSFとかがまともに使えないのでこの方が良い気がする)

Sequential Write

Sequential writeでは逐次的に固定サイズのパケットをリーダーへ送り続ける。 リーダーのアドレスは前述の通りキャッシュされているので いきなり送信することが可能である。 リーダーからcommit完了の通知を受けるとローカルのキャッシュを即座に更新し、 fsync が呼ばれるか、または一定の周期でメタデータサービスとの同期を行う。 このデータ操作はRaftではなく一般的なLeader-Followerレプリケーションである。 Raftを使わない理由は追加でログを書くのを避けてWrite amplificationの回避するため。

Random Write

Sequential Writeとの差分はレプリケーションがRaftで行われること、 メタデータの更新が不要なことだ。

削除

メタデータサービスからの削除は既に述べたとおり。 データサービスからの削除は非同期で実行される。

読みとり

読み取りは Raftの リーダーノードからのみ行われる(Primary-backupレプリケーションのリーダーはRaftのリーダーとは異なる場合がある) この時のリクエストはクライアント側のキャッシュを元に構築することで高速化を実現している。

Disucssion

Centralization v.s. Decentralization

Centralizationアプローチは単一のサーバーがボトルネックとなりやすい反面、 実装を単純にすることが可能である。

CubeFSは全体としてはCentralizationアプローチを採用しているが、 メタデータ、データのサービスは分散されている。Resource managerは クラスタリングはされているが単純なRaftなのでスケールしない。 しかしキャッシュを十分に活用することにより、ボトルネックとはなり得ないことはCubeFSの チームの実験によって明らかになっている。

メタデータとデータを切り離すかどうか

CubeFSはメタデータとデータを切り離している。 これは一貫性保持をより困難にするが、 メタデータをinmemoryに保持した上でmemory intensiveなサーバと Disk intensiveなサーバで別々に特化させることでコストとパフォーマンスを向上させることが可能である。 また、これに加えてデプロイの柔軟性を確保することも可能となる。

(HPC向けの高パフォーマンスファイルシステムもメタデータとデータを分散させる構成を取るものが多い)

一貫性モデル

低レイヤのストレージ層はprimary-backupとRaftの二種類のレプリケーションプロトコルを用いて 強い一貫性を提供する。 一方でファイルシステム層それ自体の保証する一貫性は弱いが、多くのワークロードにおいてこれは問題にならない。

比較

同様のハードウェア構成をとったCephと比較した。Cephはbluestore を用いている。 メタデータの試験はiormdtest を用いている。 TreeCreation 以外は2から5倍ほどのパフォーマンスの優位性がある。  Large fileの試験ではdirect ioモードでfio を用い、40 GiB のファイルを操作した。 Sequentialに関しては大きな差はないが、 Random accessではCephに対して1.5から2倍ほどのパフォーマンスを発揮する。 また小さなファイルに関してもCephに対して大きく有利であり、 クライアント数が大きい場合のWriteこそ同等程度だが、 Readでは特に強い優位性を見せている。

関連研究

  • GFS, HDFS
    • 大きなファイルを志向。スケーラビリティのためにメタデータサービスを分割している
  • Haystack
    • ログ追記型でメタデータをinmemoryに持つ
    • CubeFSはpunch holeを活用してより良いパフォーマンスを実現する
    • 一貫性もCubeFSの方が強い
  • Windows Azure Storage
    • ランダム書き込みを低レイヤに渡す前に専用のレイヤがある
  • PolarFS
    • RDMA, NVMe, SPDKといった技術を活用している
  • OctopusFS
    • Storageのtieringを行う
  • GlusterFS
  • MooseFS
    • 高機能だがメタデータサーバは単一
  • MapR-FS
    • Cephとにている

Footnotes

  1. ここだとネットワーク越しにストレージを提供するアプローチの話をしているが、 TopoLVM のように ストレージに合わせて計算をスケジューリングするアプローチもある。 こちらのアプローチだとストレージとの通信がSATAやNVMeとなるので 低レイテンシ高スループットになるしスケジューリングも自動化されている。 その一方でノード障害には弱い(LVMなので可用性はRAIDで担保するしかない)し、 ストレージノード間のデータ移動も難しい。また、NVMoFでIBやSlinghshot使えばよく無い? みたいな身も蓋もない意見もあり得る……。

  2. メタデータサーバが単一だとボトルネックになると論文では主張しているが、 これに関しては全く逆の意見もある。 現代のCPUアーキテクチャではもはやシングルノードですら内部バスで繋がった分散システムだが、 それでもノード内とノード外の通信のコストは全然違う。 強力なSmartNICと大量のDRAMを積み強力なCPUで動かせばシングルノードの方がむしろ 性能出るんじゃ無いかという話もある。 SingularFSは(まだ論文読めてないが)このアプローチを取っている。 IO500の上位システムはもはやCPU 2 cycleで1回のメタデータ操作しているし(わけわからん) 以外と無茶苦茶な話でも無いのかもしれない。 DRAMで巨大なメモリ空間を用意するのは大変だしそもそも永続化出来ないじゃんという話については Persistet Memoryがあった。今は(ほぼ)死んだけどCXLで復活するかもしれないし。 とは言えこれは耐障害性をあまり考えなくてもよく、かつ金を掛けられるHPCでの話なので Webサービスを提供する会社だとまた話は変わってくる。