\( \def\vector#1{\boldsymbol{#1}} \newcommand{\argmax}{\mathop{\rm arg~max}\limits} \)

CometBFT: P2P ネットワーク

Takami Torao CometBFT 0.37.2 #Blockchain #CometBFT #Tendermint #Cosmos
  • このエントリーをはてなブックマークに追加

概要

P2P ネットワーク機能は (例えばコンセンサスアルゴリズムのように) CometBFT ノードが協調しながら進行する必要のある処理のためのフレームワークである。基本はアクターモデルに似た設計であり、ノード間のメッセージングから TCP のような下層の通信レイヤーの詳細を隠蔽し、メッセージの配信とそれを処理するリアクター (サービス) という概念に抽象化する。

CometBFT の p2p パッケージに含まれる機構はメッセージのブロードキャストや他のノードの発見 (discovery) といった P2P の基本的な機能を含んでいる。特にメッセージの受信を発端としてリアクターから開始するイベント駆動の動作は CometBFT の主要な設計のベースとなっている。

Table of Contents

  1. 概要
  2. P2P メッセージの送受信
    1. メッセージ接続
    2. チャネルとリアクター
    3. メッセージの受信とリアクターの呼び出し
    4. メッセージの P2P 送信
    5. メッセージのブロードキャスト送信
  3. PEX プロトコル
    1. PEX リアクター
      1. シードモードでのアドレス要求動作
      2. 非シードモードでのアドレス要求動作
      3. PEX リクエスト受信時の動作
    2. アドレスキャッシュ
      1. アドレスの選択方法
        1. ランダム抽出
        2. バイアス付きランダム抽出 (複数)
        3. バイアス付きランダム抽出 (単一)
      2. 復元と保存
  4. 参照

P2P メッセージの送受信

Keywords: MConnection, Switch, Peer, Envelope, Reactor, Channel

CometBFT のピア間の基本的な通信は Peer の SendEnvelope で Envelope (メッセージ) を送信すると、そのリモートノードの対応するチャネル ID のリアクターの ReceiveEnvelope でその Envelope を受信するという動作である。

Fig 1. CometBFT の基本的な P2P 通信。この図はノード \(B\) からノード \(A\) 上のチャネル ID が \(Z\) のリアクターに対してメッセージを送信している。リモートのピア上では Envelope で指定したチャネル ID のリアクターに伝達される。

Switch の主な役割は 1) 接続状態を監視し接続中のピアを保持する、2) チャネルとリアクターをマッピングする、3) 既知のピアアドレスをキャッシュすることである。

Peer は特定のピアに対する接続のエンドポイントを表している。Peer \(A\) に対してチャネル ID \(Z\) を指定して SendEnvelope(\(e_Z\)) を実行すると、実際のピア \(A\) で動作しているチャネル \(Z\) に対応するリアクターの ReceiveEnvelope(\(e_Z\)) が呼び出される。

メッセージ接続

Fig 2. メッセージ接続に関連するクラス図 (クリックで拡大)。

メッセージ接続 MConnection は特定のピアに対する接続の 1 セッションを表している (通常は TCP 接続を想像すると良い)。この接続の上で送受信されるメッセージ (Envelope) は 1 バイトのチャネル ID によって宛先のリアクターを識別する。これは複数のチャネル/リアクターが一つのメッセージ接続を共有して通信するいわゆるインターリーブ通信である。

Fig 3. Peer と MConnection によるノード \(A\) と \(B\) の双方向のメッセージング。

メッセージ接続は 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
Table 1. 現在の CometBFT 実装で定義されているチャネル ID と対応するリアクター。

メッセージの受信とリアクターの呼び出し

MConnection が開始すると新しいスレッドでメッセージ受信イベントループ recvRoutine を開始する。このイベントループでメッセージを受信するonReceive コールバックが起動しチャネル ID に対応するリアクターReceiveEnvelope() が呼び出される。

Fig 4. メッセージ受信からリアクター呼び出しまでの経路。

リアクターはメッセージ受信のイベントループ内で呼び出されていることに注意。リアクター内で時間のかかる処理を行うと他のリアクターに対する受信処理を巻き込んで遅延する可能性がある。

メッセージの P2P 送信

受信処理と同様に MConnection が開始するとメッセージ送信イベントループ sendRoutine を開始する。Peer インスタンスに対して SendEnvelope を呼び出すと、メッセージのバイナリは一旦 Channel のキューに保存され、メッセージ送信イベントループで非同期に送信される。

Fig 5. メッセージ受信要求から実際に送信されるまでの経路。

SendEnvelope の実行結果は送信キューにデータを追加できたかを示す値であり送信に成功したことを示す値ではないことに注意。SendEnvelope は Channel の送信キューがいっぱいで 10 秒経過しても空きができなかった場合に false を返す。

メッセージのブロードキャスト送信

メッセージのブロードキャストは Switch の BroadcastEnvelope に実装されている。ただし、これは単純に Switch が接続しているすべてのノードに対して並行して SendEnvelope を実行しているだけである。したがって、送信と同様にこの返値の true は送信キューに値を追加したことを示すのみであり、送信キューがいっぱいで送信できないノードが存在する場合、返値の chan からの結果の取得は 10 秒程度待って false を得る動作になるだろう。

PEX プロトコル

Keywords: PEX Reactor, AddrBook

PEX (peer exchange; ピア交換) プロトコルはノードがネットワーク内の他のピアを検出するためにバックグラウンドで既知のアドレスリストを交換するサービスである。これはビットコインのピア検出プロトコルに基づいている。この処理を行う PEX リアクターは他のピアに対して定期的にアドレスを要求し、またピアの要求に応じて自身の認識しているアドレスを提供する。

Fig 6. PEX サービスを行う処理のクラス図。

PEX リアクター

PEX リアクターは一連のアドレス情報交換プロセスを駆動しアドレスキャッシュを最新に保つための機構。

PEX リアクターはノードがシードモード (seed mode) で動作しているかどうかでアドレスキャッシュの更新動作が異なる。シードモードで起動したノードは定期的にノードを切り替えて特定のノードに過度に依存しないように自身のアドレスキャッシュを更新する。一方で非シードモードでは同じノードと長期間接続しながらアドレスキャッシュを更新するため負荷は少ない。どちらのモードでも最終的に PEX リアクターの RequestAddr が呼び出されるとノードに対してアドレス要求、具体的には PexRequest メッセージ送信が発生する。

PEX リアクターは PEX のバックグラウンド動作とは無関係に Switch がノードと接続したときの AddPeer で (必要であれば) アドレス要求を行うことに注意。Fig 7Fig 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 を実行して繰り返す。

Fig 7. シードノードとして動作するときの PEX リアクターのアドレス要求。

非シードモードでのアドレス要求動作

シードモードの CometBFT ノードが比較的短期間で接続先のノードを切り替えながらアドレスキャッシュを更新するのに対して、非シードモードのノードは接続中のノードと長期間接続し続ける。シードモードと主な違いは 1) 切断等により接続中のノード数に空きが出たときのみ新しいノードへの接続を試みる、2) アドレスキャッシュに空きが出たときのみアドレス要求を送信する点である。

非シードモードにおける PEX リアクターのバックグラウンド動作を Fig 8 に示す。PEXリアクターが開始すると 30 秒ごとensurePeers を実行するスレッドが開始する。この処理は 1) 必要な接続済みノード数に満たない場合はアドレスキャッシュからランダムに選定したアドレスと接続を確立し、2) アドレスキャッシュのエントリ数が上限に達していなければ現在接続中のノードの一つをランダムに選択してアドレス要求のメッセージを送信する。

Fig 8. 非シードノードとして動作するときの PEX リアクターのアドレス要求。

PEX リクエスト受信時の動作

ノード \(A\) がノード \(B\) に PEX リクエストでアドレス要求を行ったときの処理の流れを Fig 9 に示す。基本的に \(B\) は要求に応じて自身がキャッシュしているアドレスセットを送信し、\(A\) はそのアドレスを自身のキャッシュに追加するという動きだが、\(A\) や \(B\) がシードノードかどうかで動作の詳細が異なる。

Fig 9. キャッシュの更新動作。

アドレス要求リクエストを受け取った PEX リアクターの ReceiveEnvelope はアドレスキャッシュからランダムにアドレスを選択して相手に送信する。自身がシードノードかつ自分から相手に接続している場合 (つまり Fig 9 の例では \(B\) から \(A\) に接続して MConnection を確立している場合)、送信するアドレスの選択には GetSelectionWithBias を使用し、そうでない場合は GetSelection を使用する。

アドレス要求の応答を受け取ったノードはそのアドレスを自身のアドレスキャッシュに登録する。ここでアドレスの取得元が (自身の設定により認識している) シードノードであれば、得られたアドレスすべてのノードに対して接続を試みる。

アドレスキャッシュ

アドレスキャッシュは CometBFT が P2P ネットワークで発見したピアの接続アドレスを他のノードへの拡散や後の接続のために記憶しておく機構である。この接続アドレスは AddrBook クラスによって管理される。

AddrBook にキャッシュされるアドレスにはいくつかの属性が付けられる:

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 を行っているため、書き込み中を別のプロセスが読み込んで失敗したり、書き込み中にプロセスが停止したとしても既存のアドレスキャッシュが破壊されることはない。

参照

  1. Implementation of the p2p layer (v0.38.0-rc3)