NBDプロトコル・Goライブラリを作った

NBD(Network Block Device)は、Linux で利用可能なリモートでブロックアクセスを行うための仕組みです。 通常 HDD/SDD に送信されるブロックアクセスの内容を、TCP/UNIX Socket を通じてサーバに送ることができます。 例えば、他のマシンの SSD の一部 /dev/sda1 を NBD を通じて手元のマシンに接続することなどができます。

iSCSI 等のプロトコルと比較するととてもサーバの実装が容易で、基本的にはネゴシエーション・ブロック範囲への Read,Write の処理を正しく扱うことができれば最低限の自作の実装が行えます。

この記事は kb Club Advent Calendar 2019 5 日目の記事です。 https://adventar.org/calendars/4021

社内勉強会で行ったものを再編集して掲載しています。

NBD の概要

NBD は Linux のカーネルモジュール実装・クライアントの実装・サーバの実装と主に 3 つのコンポーネントが協調して動作します。

クライアントの実装は、引数から受け取った情報を元にサーバへと接続した後にネゴシエーションと呼ばれる接続の初期段階の処理を行い、ソケットを nbd カーネルモジュールに渡します。 ソケットが nbd モジュールに移ると、実際にブロックデバイスとしての Read/Write 等のコマンドを受け付けるようになります。

以下は簡単なクライアントの利用方法です。 nbd カーネルモジュールを読み込むと、 /dev/nbd0 から /dev/nbd15 まで作成されます。 この時点ではこのデバイスは利用できませんが、 nbd-client コマンドで接続先のホスト名・ポート番号接続するデバイス名を指定し、接続が完了すれば利用できるようになります。 余談ですが、このデバイス数は modprobe nbd nbds_max=64 max_part=64 等で変更できます。

# apt install nbd-client
# modprobe nbd
# nbd-client localhost 1234 /dev/nbd0

サーバの実装はクライアントと比較すると単純で、単なる TCP/UNIX Socket のサーバを建てるだけです。 そのため、適切なアクセス権が有れば特権でなくとも動作させることができます。 永続化の宛先は、ファイルや /dev/sda1 などのブロックデバイス等が指定できます。

# apt install nbd-server
# nbd-server 1234 ./device.img

また、サーバが複数のブロックデバイスを公開する事を想定したプロトコル設計がなされています。 クライアントの引数を nbd-client -N hoge localhost 1234 /dev/nbd0 などと指定すれば、サーバは hoge という名前のデバイスが有るかを確認し、存在すればその情報をクライアントに返します。サーバの設定ファイルを記述することで、名前と実デバイスは自由に対応付を行うことが出来ます。

クライアントに返されるデバイスを「Export」と呼び、その情報の内容は概ね以下のとおりです。 ブロックアクセスに最低限必要な項目も含まれていることが分かります。

  • 名前 (文字列)
  • 説明文 (文字列)
  • 総容量 (バイト単位の数値)
  • ブロックサイズ (バイト単位の数値)

プロトコルの概要

それでは、通信の内容を追っていきます。 まずは全体を簡単に紹介します。

主に NBD の通信は 3 つのフェーズで構成されます。

  • ソケット接続 (TCP 等のソケット接続)
  • Handshake (前半・後半で通信方法や内容が変わる)
  • Transmission (ブロックのやり取り)

Handshake のフェーズでは、プロトコルやハンドシェイク内の機能の交換・サーバが公開するブロックデバイス情報(Export)の交換と接続先 Export の決定を行います。 Transmission のフェーズでは、実際に Read/Write/Flush 等の命令がソケットに流れます。 ハンドシェイク内でフラグを渡せば、Trim/Discord 等のコマンドも流れるようになります。

Step1. Socket Connection

TCP/UNIX Socket を Listen するサーバを作る・クライアントを作ります。 特殊なことはせず、サーバだとこんなイメージです。

l, _ := net.Listen("tcp", "1234")

for {
  conn, err := l.Accept()
  // todo
}

Step2. Handshake phase 1

ハンドシェイクは大きく分けて 2 つのフェーズに分けることが出来ます。 ここでは初期段階の後半のハンドシェイクに関わるバージョン情報をフラグとして交換します。 ハンドシェイクの際は必ず同期通信を行い、相手の返答があるまで双方はバイト列を送信することは出来ません。

S: 64bit 0x4e42444d41474943 (ASCII: NBDMAGIC)
S: 64bit 0x49484156454F5054 (ASCII: IHAVEOPT)
S: 32bit 0x00000003 ハンドシェイク用フラグ (NBD_FLAG_FIXED_NEWSTYLE|NBD_FLAG_NO_ZEROES)
C: 32bit 0x00000003 クライアント用フラグ (NBD_FLAG_C_FIXED_NEWSTYLE|NBD_FLAG_C_NO_ZEROES)

S: から始まる行がサーバから送信するバイト列・C: から始まる行がクライアントから送信するバイト列を表します。 ソケットを接続するとサーバは ASCII 文字列で NBDMAGIC IHAVEOPT を表すマジックナンバーを送信します。 続けて、ハンドシェイク内で使用する機能を表すビット積を 32bit で送信します。 ここではフラグの意味は触れませんが、現在主に使われているフラグは 0x03 です。 サーバ・クライアントは、各々が対応する機能のビットを立てたフラグを送受信し、互換性が無ければソケットを切断します。

Step3. Handshake phase 2

ハンドシェイクの後半では、オプションの交換を行います。 このフェーズは、クライアントがサーバに情報を問い合わせるという形で進行します。 様々なオプションのタイプが用意されており、Export 情報・TLS 接続情報などがやり取りされます。

C: 64bit 0x49484156454F5054 (ASCII: IHAVEOPT)
C: 32bit 0x0007 OptionType
C: 32bit バッファの長さ(uint32)
C: ?bytes リクエスト用バイト列 (↑で指定されたバイト数)


S: 64bit 0x3e889045565a9 (返信用のマジックナンバー)
S: 32bit 0x0007 OptionType
S: 32bit 0x0001 返信の種別コード (0x01: NBD_REP_ACK)
S: 32bit バッファの長さ(uint32)
S: ?bytes レスポンス用バイト列(uint32)

上の例は OptionType を 0x0007 としていますが、他の OptionType も存在します。 まずクライアントは ASCII 文字列で IHAVEOPT を表すマジックナンバーを送信します。 続けて、OptionType・リクエストボディの長さ・任意長のリクエストボディを送信します。

サーバも同様に返信します。 返信の種別コードは、正常にレスポンスを行えていることを表す NBD_REP_ACK や、 複数のレスポンスが存在することを表す NBD_REP_LIST 等が有ります。 NBD_REP_LIST の場合はサーバが連続してレスポンスを繰り返し、 クライアントは種別が NBD_REP_ACK 等になるまで受信を繰り返します。

OptionType には以下のようなバリエーションがあります。 OptionType によって、ペイロードの扱い方はそれぞれ異なります。 NBD_OPT_GO だけ実装すれば、Linux の nbd-client コマンドからの接続を行えるようになります。 このオプションは、ペイロードで Export の名前・説明文・ブロックサイズ・総容量などのやり取りを行い、 返信が終了すればそのまま次の Transmission フェーズへと移行します。

  • 0x002 NBD_OPT_ABORT
    • ネゴシエーションを終了する
  • 0x003 NBD_OPT_LIST
    • サーバは利用可能なエクスポートのリスト名を返す
  • 0x005 NBD_OPT_STARTTLS
    • クライアントは TLS を開始したい・TLS 接続に必要なデータを送信
  • 0x0006 NBD_OPT_INFO
    • クライアントは、エクスポート名と欲しい情報リストを渡す
    • サーバは、エクスポートに対応する情報をリストで返す
  • 0x0007 NBD_OPT_GO
    • 必須 これだけ実装すれば Linux クライアントから接続可能
    • NBD_OPT_INFO とほぼ同じ挙動
    • サーバ側の返信が終わった時点で、即時に Transmission フェーズへ移行する

ペイロードを埋めた状態での、NBD_OPT_GOのリクエスト例を示します。 OptionType にNBD_OPT_GOを指定し、リクエストボディの長さを今回は 14byte とします。 リクエストボディの中には、クライアントが要求する Export の名前・その情報の項目を詰めます。

// クライアントは、NBD_OPT_GOオプションを送出 
C: 64bit 0x49484156454F5054 (ASCII: IHAVEOPT)
C: 32bit 0x0007 (OptionType: NBD_OPT_GO)
C: 32bit 0x000e (バッファの長さ: 14bytes)

// クライアントが要求する情報
C: 32bit 00 00 00 04 (uint32 4バイトのエクスポート名)
C: 4bytes 6E 61 6D 65 (要求するエクスポート名 可変長文字列: name)
C: 16bit 00 04 (uint16 4つの情報を要求 16bitが4つ続く)
C: 16bit 00 00 (NBD_INFO_EXPORT: 大きさ・利用可能な機能を教えて)
C: 16bit 00 01 (NBD_INFO_NAME: ストレージの名前を教えて)
C: 16bit 00 02 (NBD_INFO_DESCRIPTION: ストレージの説明文を教えて)
C: 16bit 00 03 (NBD_INFO_BLOCK_SIZE: サポートされるブロックサイズを教えて)

続いてサーバからの返信内容です。 以下では、2 つの情報について返信を行っています。 最初 2 つの返信種別が NBD_OPT_LIST で、最後はペイロードが空の NBD_OPT_LIST という事でも分かると思います。 オプションNBD_OPT_GOの情報タイプ0x00 NBD_INFO_EXPORTでは、ブロックストレージの大きさ及び、 TRIM(Discord)などの機能の可否を返しています。 その他、BlockSize 等を返すことも出来ます。

// サーバ側のNBD_INFO_EXPORTへの返信
S: 64bit 0x3e889045565a9 (返信用のマジックナンバー)
S: 32bit 0x00000007 (OptionType: NBD_OPT_GO)
S: 32bit 0x00000003 (返信の種別コード: NBD_OPT_LIST)
S: 32bit 0x0000000c (長さ:12bytes これから送信されるペイロード長)
S: 16bit 00 00 (情報タイプ: NBD_INFO_EXPORT これによって以下のレイアウトが決まる)
S: 64bit 00 00 00 00 00 a0 00 00 (ブロックストレージの大きさ)
S: 16bit 00 00 (トランスポート用フラグ: なし TRIMコマンドの利用可否などが本来入る)

// サーバ側のNBD_INFO_NAMEへの返信
S: 64bit 0x3e889045565a9 (返信用のマジックナンバー)
S: 32bit 0x00000007 (OptionType: NBD_OPT_GO)
S: 32bit 0x00000003 (返信の種別コード: NBD_OPT_LIST)
S: 32bit 0x00000006 (長さ:6bytes これから送信されるペイロード長)
S: 16bit 00 01 (情報タイプ: NBD_INFO_NAME)
S: 4bytes 6E 61 6D 65 (可変長文字列: name)

// Description, BlockSizeの返信を省略

// サーバ側はオプションを全て送ったので、Ackを送信して即時にTransmissionモードへと移行
S: 64bit 0x3e889045565a9 (返信用のマジックナンバー)
S: 32bit 0x00000007 (OptionType: NBD_OPT_GO)
S: 32bit: 0x0001 返信の種別コード (0x01: NBD_REP_ACK)
S: 32bit 0x00000000 (長さ:0bytes)

上のNBD_OPT_GOでの例では、要求された情報に対してのレスポンスを返していますが、 要求されている情報を返さない・要求されていない情報を返すことも許容されます。 不明なレスポンス等がある時は、無視することも行えますが接続を切断することが推奨されます。

ここまでの処理で、以下の事が行えました。次は実際のデータ転送を行います。

  • tcp/unix socket の接続
  • ハンドシェイクのバージョン指定
  • オプションによって、ストレージの名前・説明文・容量・ブロックサイズ等を交換

Step4. Transmission phase

このフェーズに入ると、非同期な通信が可能となります。 基本的な動作は、Offset/Length 指定して読み書きするだけです。 コマンドへの送信には必ずハンドル番号を付与し、返信にも同じハンドル番号を付与することで、非同期のコマンド送受信を管理します。

以下は読み取りを行う際の例です。 64bit のハンドル番号・64bit 整数のアドレス値・32bit のバッファ長を送信し、レスポンスを得ています。

C: 32bit 0x25609513 (Magic)
C: 32bit 0x00000000  (Type: NBD_CMD_READ)
C: 64bit 0x3800000004000000 (Handle)
C: 64bit 0x0000000000000000 (From: 0byte目から)
C: 32bit 0x00001000 (Length: 4096byte)

S: 32bit 0x67446698 (Magic)
S: 32bit 0x00000000 (errors)
S: 64bit 0x3800000004000000 (Handle)
S: 4096 bytes (バイト列)

以下は書き込みを行う際の例です。 先程の情報に加え、クライアントがバイト列を送信します。

C: 32bit 0x25609513 (Magic)
C: 32bit 0x00000001  (Type: NBD_CMD_WRITE)
C: 64bit 0x3900000004000000 (Handle)
C: 64bit 0x0000000000000000 (From: 0byte目から)
C: 32bit 0x00001000 (Length: 4096byte)
C: 4096 bytes (バイト列)

S: 32bit 0x67446698 (Magic)
S: 32bit 0x00000000 (errors)
S: 64bit 0x3900000004000000 (Handle)

この他、TRIM 等のコマンドも Handshake phase にて適切に設定を行っていれば利用が可能となります。 基本的は NBD は 1 つのソケットに直列化したコマンドを送受信するので、高速化のための MultiQueue などは利用できない点は注意が必要です。

Go のライブラリ

以上が NBD プロトコルの詳細でした。 非常にシンプルで様々な言語で楽に実装できると思ったので、手始めに Go のライブラリを作成しました。

https://github.com/kamijin-fanta/nbd-go

薄いラッパーとして動作し、2 つのインタフェース・6 個程度のメソッドを実装するだけで任意のブロックストレージが作れます。 サンプルコードとして、50 行余りで書いたオンメモリストレージを入れています。

https://github.com/kamijin-fanta/nbd-go/blob/master/examples/inmemory/inmemory.go

あまりエラー処理を真面目にやっていないので、実際に使うとなれば変更を加えようかなと考えています。

reference