CometBFT: P2P ネットワーク
概要
P2P ネットワーク機能は (例えばコンセンサスアルゴリズムのように) CometBFT ノードが協調しながら進行する必要のある処理のためのフレームワークである。基本はアクターモデルに似た設計であり、ノード間のメッセージングから TCP のような下層の通信レイヤーの詳細を隠蔽し、メッセージの配信とそれを処理するリアクター (サービス) という概念に抽象化する。
CometBFT の p2p
パッケージに含まれる機構はメッセージのブロードキャストや他のノードの発見 (discovery) といった P2P の基本的な機能を含んでいる。特にメッセージの受信を発端としてリアクターから開始するイベント駆動の動作は CometBFT の主要な設計のベースとなっている。
Table of Contents
P2P メッセージの送受信
Keywords: MConnection, Switch, Peer, Envelope, Reactor, Channel
CometBFT のピア間の基本的な通信は Peer の SendEnvelope で Envelope (メッセージ) を送信すると、そのリモートノードの対応するチャネル ID のリアクターの ReceiveEnvelope でその Envelope を受信するという動作である。
Switch の主な役割は 1) 接続状態を監視し接続中のピアを保持する、2) チャネルとリアクターをマッピングする、3) 既知のピアアドレスをキャッシュすることである。
Peer は特定のピアに対する接続のエンドポイントを表している。Peer \(A\) に対してチャネル ID \(Z\) を指定して SendEnvelope(\(e_Z\)) を実行すると、実際のピア \(A\) で動作しているチャネル \(Z\) に対応するリアクターの ReceiveEnvelope(\(e_Z\)) が呼び出される。
メッセージ接続
メッセージ接続 MConnection は特定のピアに対する接続の 1 セッションを表している (通常は TCP 接続を想像すると良い)。この接続の上で送受信されるメッセージ (Envelope) は 1 バイトのチャネル ID によって宛先のリアクターを識別する。これは複数のチャネル/リアクターが一つのメッセージ接続を共有して通信するいわゆるインターリーブ通信である。
メッセージ接続は Envelope の送受信以外に設定ファイルで指定した間隔で ping-pong による疎通確認を行っている (デフォルトは 60 秒間隔 45 秒タイムアウト)。pong 応答がタイムアウトするとピアに対するメッセージ接続は終了する。
チャネルとリアクター
標準的な実装ではチャネル ID に対応するリアクターはノード構築時の NewNode 内で設定される。すべての標準のリアクターは PEX を除いて Switch 構築時に定義される (PEX リアクターはその後で選択的に追加される)。アプリケーション実装は createSwitch 時に CustomReactors() のオプションを指定するか、Switch 構築後に AddReactor を使って独自のリアクターを追加することができる (AddReactor はスレッドセーフではないので NewNode 内で使用すべきである)。
チャネルID\(\mapsto\)リアクターのマップは Switch の reactorsByCh
フィールドに保持されている。すべての Peer インスタンスはこのマップを共有し、下層の MConnection で受信したメッセージをこのマップに基づいて適切なリアクターに配送する。
Table 1 は現在の CometBFT 実装で定義されているチャネルとそれに対応するリアクターである。10 個のチャネルと 6 個のリアクターが存在する。
チャネル ID | チャネル | 想定するメッセージ | リアクター | 処理 |
---|---|---|---|---|
0x00 | PEX Channel | PexRequest, PexAddrs | PEX リアクター | ピアアドレスの交換。 |
0x20 | State Channel | NewRoundStepMessage, NewValidBlockMessage, HasVoteMessage, VoteSetMaj23Message | コンセンサスリアクター | BFT アルゴリズムの実行。 |
0x21 | Data Channel | ProposalMessage, ProposalPOLMessage, BlockPartMessage | ||
0x22 | Vote Channel | VoteMessage | ||
0x23 | VoteSet Bits Channel | VoteSetBitsMessage | ||
0x30 | Mempool Channel | Txs | Mempool リアクター (v0) | トランザクションの拡散 (ゴシッピング)。 |
Mempool リアクター (v1) | ||||
0x38 | Evidence Channel | EvidenceList | Evidence リアクター | |
0x40 | Blocksync Channel | BlockRequest, StatusRequest, StatusResponse, NoBlockResponse | Blocksync リアクター | |
0x60 | Snapshot Channel | SnapshotsRequest, SnapshotsResponse | Statesync リアクター | |
0x61 | Chunk Channel | ChunkRequest, ChunkResponse |
メッセージの受信とリアクターの呼び出し
MConnection が開始すると新しいスレッドでメッセージ受信イベントループ recvRoutine
を開始する。このイベントループでメッセージを受信すると onReceive
コールバックが起動し、チャネル ID に対応するリアクターの ReceiveEnvelope()
が呼び出される。
リアクターはメッセージ受信のイベントループ内で呼び出されていることに注意。リアクター内で時間のかかる処理を行うと他のリアクターに対する受信処理を巻き込んで遅延する可能性がある。
メッセージの P2P 送信
受信処理と同様に MConnection が開始するとメッセージ送信イベントループ sendRoutine
を開始する。Peer インスタンスに対して SendEnvelope を呼び出すと、メッセージのバイナリは一旦 Channel のキューに保存され、メッセージ送信イベントループで非同期に送信される。
SendEnvelope の実行結果は送信キューにデータを追加できたかを示す値であり送信に成功したことを示す値ではないことに注意。SendEnvelope は Channel の送信キューがいっぱいで 10 秒経過しても空きができなかった場合に false を返す。
メッセージのブロードキャスト送信
メッセージのブロードキャストは Switch の BroadcastEnvelope に実装されている。ただし、これは単純に Switch が接続しているすべてのノードに対して並行して SendEnvelope を実行しているだけである。したがって、送信と同様にこの返値の true は送信キューに値を追加したことを示すのみであり、送信キューがいっぱいで送信できないノードが存在する場合、返値の chan からの結果の取得は 10 秒程度待って false を得る動作になるだろう。
PEX プロトコル
Keywords: PEX Reactor, AddrBook
PEX (peer exchange; ピア交換) プロトコルはノードがネットワーク内の他のピアを検出するためにバックグラウンドで既知のアドレスリストを交換するサービスである。これはビットコインのピア検出プロトコルに基づいている。この処理を行う PEX リアクターは他のピアに対して定期的にアドレスを要求し、またピアの要求に応じて自身の認識しているアドレスを提供する。
PEX リアクター
PEX リアクターは一連のアドレス情報交換プロセスを駆動しアドレスキャッシュを最新に保つための機構。
PEX リアクターはノードがシードモード (seed mode) で動作しているかどうかでアドレスキャッシュの更新動作が異なる。シードモードで起動したノードは定期的にノードを切り替えて特定のノードに過度に依存しないように自身のアドレスキャッシュを更新する。一方で非シードモードでは同じノードと長期間接続しながらアドレスキャッシュを更新するため負荷は少ない。どちらのモードでも最終的に PEX リアクターの RequestAddr が呼び出されるとノードに対してアドレス要求、具体的には PexRequest メッセージ送信が発生する。
PEX リアクターは PEX のバックグラウンド動作とは無関係に Switch がノードと接続したときの AddPeer で (必要であれば) アドレス要求を行うことに注意。Fig 7 と Fig 8 で示すように、PEX リアクターは同じノードに対して RequestAddrs が何度も呼び出される (雑な) 作りになっているように見える。
P2P では悪意のあるノードが間違った情報を大量かつ一方的に手当たり次第に送りつける push flooding 攻撃がよく知られている。push flooding 攻撃が成立すると、システム内の正しい情報の割合は指数関数的に減少してネットワーク全体がすぐに機能しなくなる。一方で PEX リアクターはリクエストを送ったノードからの応答のみを受け入れることで push flooding を防止しキャッシュのポイズニングを難しくしている。それでも接続先のノードが悪意のあるノードだったりキャッシュがポイズニングされている可能性はあり、CometBFT がこのようなポイズニングをどのように考えているかは不明。
シードモードでのアドレス要求動作
一般的な分散ネットワークにおけるシードノードとは、新しくネットワークに参加するノードが別のノードのアドレスを取得するために最初に接続することを推奨されている比較的信頼性の高いノードである。CometBFT のノードは設定やコマンドラインオプションによりシードモードとして起動することができる (デフォルトは非シードモード)。
シードモードにおける PEX リアクターのバックグラウンド動作を Fig 7 に示す。シードモードの PEX リアクターは 1) 他のシードノードと接続し、2) アドレスキャッシュからランダムにを選択したノードとの接続を確立し、3) アドレス交換リクエストを送信し、4) 一定時間の経過したノードとの接続を終了する。2 から 4 の動作を 30 秒ごとに crawlPeers を実行して繰り返す。
非シードモードでのアドレス要求動作
シードモードの CometBFT ノードが比較的短期間で接続先のノードを切り替えながらアドレスキャッシュを更新するのに対して、非シードモードのノードは接続中のノードと長期間接続し続ける。シードモードと主な違いは 1) 切断等により接続中のノード数に空きが出たときのみ新しいノードへの接続を試みる、2) アドレスキャッシュに空きが出たときのみアドレス要求を送信する点である。
非シードモードにおける PEX リアクターのバックグラウンド動作を Fig 8 に示す。PEXリアクターが開始すると 30 秒ごとにensurePeers を実行するスレッドが開始する。この処理は 1) 必要な接続済みノード数に満たない場合はアドレスキャッシュからランダムに選定したアドレスと接続を確立し、2) アドレスキャッシュのエントリ数が上限に達していなければ現在接続中のノードの一つをランダムに選択してアドレス要求のメッセージを送信する。
PEX リクエスト受信時の動作
ノード \(A\) がノード \(B\) に PEX リクエストでアドレス要求を行ったときの処理の流れを Fig 9 に示す。基本的に \(B\) は要求に応じて自身がキャッシュしているアドレスセットを送信し、\(A\) はそのアドレスを自身のキャッシュに追加するという動きだが、\(A\) や \(B\) がシードノードかどうかで動作の詳細が異なる。
アドレス要求リクエストを受け取った PEX リアクターの ReceiveEnvelope はアドレスキャッシュからランダムにアドレスを選択して相手に送信する。自身がシードノードかつ自分から相手に接続している場合 (つまり Fig 9 の例では \(B\) から \(A\) に接続して MConnection を確立している場合)、送信するアドレスの選択には GetSelectionWithBias を使用し、そうでない場合は GetSelection を使用する。
アドレス要求の応答を受け取ったノードはそのアドレスを自身のアドレスキャッシュに登録する。ここでアドレスの取得元が (自身の設定により認識している) シードノードであれば、得られたアドレスすべてのノードに対して接続を試みる。
アドレスキャッシュ
アドレスキャッシュは CometBFT が P2P ネットワークで発見したピアの接続アドレスを他のノードへの拡散や後の接続のために記憶しておく機構である。この接続アドレスは AddrBook クラスによって管理される。
AddrBook にキャッシュされるアドレスにはいくつかの属性が付けられる:
New, Old: New と Old の違いは PEX がアドレスを要求されたときに返すアドレス選択で識別される。New は一般ランクのアドレス、Old (Good) はより長期に正常駆動が確認されていて信頼できると見なされたランクのアドレスを表している。PEX は投票、またはブロック生成のイベントを与信として、ノードごとにそれらが 1000 回発生するごとにそのアドレスを Good とマークする。
Old への新しいアドレスの追加によって Old から追い出された最も古いアドレスは再び New に戻る。また New への新しいアドレスの追加によって New から追い出された最も古いアドレスは破棄される。
addrBook には New と Old を保持するために map をさらに配列化しハッシュ値をインデックスとしてアクセスする buckets と呼ばれる構造を使っている。このようなハッシュテーブルに似た構造をわざわざ構築している理由ははっきりしないが、ランダム選択で map を毎回直列化するコストの削減を意図しているのかもしれない。
Ban: PEX はエラーの発生したノードに対して 24 時間の Ban (隔離措置) をおこなう。Ban の対象となる状況は、1) 不正な PEX メッセージを送信したアドレス、2) 接続できない、または 3) ErrSwitchAuthenticationFailure エラーの発生したアドレスである (ただし標準実装では ErrSwitchAuthenticationFailure が発生している箇所は存在しない)。
Ban により Bad とマークされたアドレスは、アドレスキャッシュに記憶されることはなく PEX による拡散の対象外となる。Ban 期間の過ぎたアドレスはアドレスキャッシュのエントリが不足したときに New とマークされたアドレスとして復帰する。
Our: 自ノードを表すアドレス。外部向けのアドレスや Listen でバインドしたアドレスなどが該当する。Our とマークされているアドレスはキャッシュアドレスに記憶されることはなく PEX による拡散の対象外となる。
Private ID:
private_peer_ids
に指定したリストに含まれるノード ID はプライベートと見なされてアドレスキャッシュに記憶されることはなく PEX による拡散の対象外となる。これは内部的なネットワークに所属するノードのアドレスを外部に拡散しないようにする設定である。
PEX のアドレス要求に応答するためのアドレス選択は New または Old のキャッシュから行われる。
アドレスの選択方法
AddrBook にキャッシュされている New とマークされたアドレス集合を \(\mathcal{A}_{\rm new}\)、Old とマークされたアドレス集合を \(\mathcal{A}_{\rm old}\) とし、選択の対象となるアドレスの集合を \(\mathcal{A}=\mathcal{A}_{\rm new} \cup \mathcal{A}_{\rm old}\) と表記する。また集合 \(X\) からランダムに \(n\) 個の重複しない要素を選択する操作を \({\rm rand}(X,n)\) とする。
ランダム抽出
AddrBook の GetSelection メソッドは New, Old のアドレス集合からランダムに \(n\) 個を選択して返す \({\rm rand}(\mathcal{A}, n)\) の機能である。ここで \(n\) は式 (\(\ref{get_selection_n}\)) で算出される。\[ \begin{equation} n = \min\left(250, \max\left(\min(32, |\mathcal{A}|), \frac{|\mathcal{A}| \times 23}{100}\right)\right) \label{get_selection_n} \end{equation} \] 式 (\(\ref{get_selection_n}\)) は、キャッシュされているアドレス数 \(|\mathcal{A}|\) の 23% を狙うが、アドレス数が 32 個より少ない場合はすべてのアドレス数、また最大でも 250 個のアドレス数となる。キャッシュされているアドレス数に対する \(n\) の推移は 32 に踊り場のある 250 上限の二段階一次線形となる。算出方法や定数の根拠は不明。
バイアス付きランダム抽出 (複数)
AddrBook の GetSelectionWithBias メソッドは引数 \(b \in [0,100]\) を取り、式 (\(\ref{get_selection_n}\)) に基づく \(n\) 個のアドレスをランダムに返す \({\rm rand}(\mathcal{A}_{\rm new},n_{\rm new}) \cup {\rm rand}(\mathcal{A}_{\rm old},n_{\rm old})\) の機能である。GetSelection が New, Old 混合のアドレス集合から \(n\) 個を選んでいたのに対して、このメソッドは式 (\(\ref{get_selection_with_bias}\)) に基づいて \(n\) 個の返値に含まれる New, Old のアドレス数を決定する。\[ \begin{equation} \left\{ \begin{array}{lcl} n_{\rm new} & = & \max\left( n \times \frac{b}{100}, n - n_{\rm old} \right) \\ n_{\rm old} & = & n - n_{\rm new} \end{array} \right. \label{get_selection_with_bias} \end{equation} \]
バイアス付きランダム抽出 (単一)
AddrBook の PickAddress メソッドは引数 \(b \in [0,100]\) を取りランダムに選択した 1 つのアドレスを返す \({\rm rand}(\mathcal{A},1)\) の機能である。\(b=50\) のときに New と Old のどちらが選ばれるかはそれらのアドレス数の平方根に対する比と等しくなり (ここで平方根を取っている理由は分らない)、\(b=100\) で必ず New のアドレスが選択されるようになる。
採用している乱択アルゴリズムは累積和法に似ているが、New, Old どちらの buckets を使うかや、buckets の中のどの bucket を使うかと言った点で累積和法とは異なる。
復元と保存
AddrBook は OnStart 時に設定の addr_book_file
に示されるファイルから loadFromFile を用いて復元される。OnStart は同時に 2 分おきにアドレスキャッシュを保存するスレッドを開始する。
アドレスキャッシュは Save を経由して saveToFile で永続化される。対象となるアドレスは New と Old のみで、設定の addr_book_file
に示されるファイルに JSON 形式で保存される。
アドレスキャッシュの保存は WriteFileAtomic を使用している。これは POSIX ファイルシステムの流儀に従って一時ファイルに書き込んでから rename を行っているため、書き込み中を別のプロセスが読み込んで失敗したり、書き込み中にプロセスが停止したとしても既存のアドレスキャッシュが破壊されることはない。
参照
- Implementation of the p2p layer (v0.38.0-rc3)