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 NBDOPTABORT

    • ネゴシエーションを終了する
  • 0x003 NBDOPTLIST

    • サーバは利用可能なエクスポートのリスト名を返す
  • 0x005 NBDOPTSTARTTLS

    • クライアントはTLSを開始したい・TLS接続に必要なデータを送信
  • 0x0006 NBDOPTINFO

    • クライアントは、エクスポート名と欲しい情報リストを渡す
    • サーバは、エクスポートに対応する情報をリストで返す
  • 0x0007 NBDOPTGO

    • 必須 これだけ実装すればLinuxクライアントから接続可能
    • NBDOPTINFOとほぼ同じ挙動
    • サーバ側の返信が終わった時点で、即時に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

続きを読む

Windows10 WSLのターミナル事情

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

Windows10から導入されたWSL(Windows Subsystem for Linux)ですが、その進化は凄まじいです。 2020年中にリリースされるWSL2では現在のカスタムカーネルから素のLinuxKernelに差し替えられ、 Docker等のKernelに強く依存するアプリケーションも自然に動く予定だそうです。

そんな進化に取り残されているのが…

windows wsl terminal 2019 12 04 08 37 11

ターミナルです。 上の図は、qemuでDebianを -curses オプションを与えて起動させ、ログイン後に man ps を叩いて無事崩壊した様子です。 安定性・互換性が低いだけでなく、標準のターミナルは実質 cmd.exe なので機能も不足していると言わざるを得ません。

この記事では、現時点で雑に設定してみてどう動くかを検証していきます。

Windows Terminalを試す

Microsoftも気付いているようで、 Windows Terminal というストアアプリを公開しています。 https://www.microsoft.com/en-us/p/windows-terminal-preview/9n0dx20hk701 タブ・ペイン分割等の機能も有り、少ない機能ながらしっかりと作られている印象です。 ただし、2019年の現時点では色の処理等が不十分で、カラースキームを調整したい自分は実用的では無いと判断しました。

windows wsl terminal 2019 12 04 08 41 27

背景色・文字色等の処理の都合で、重要な箇所が読めなくなっています。 多分、暗いテーマを利用していれば問題にならないと思います。

また、UACを通して管理者特権としてシェルを実行するための機能は無く、一部の操作で成約が有ります。

Fluent Terminalを試す

公式が提供しているものを導入するのが難しいので、コミュニティ開発のOSSに目を向けていきます。

WPFアプリケーションのFluentTerminalを試します。 Chocolateyで利用できるので、 choco install fluent-terminal や、 正しくリポジトリ設定を行えばPowerShellの Get-Package fluent-terminal コマンドから導入できます。 https://github.com/felixse/FluentTerminal

こちらも、明るいカラースキームでの挙動が少々怪しいですが、騙し騙し使っているとやはりcurses周りの処理が不安定です。

windows wsl terminal 2019 12 04 08 55 08

標準のターミナルと同じく、qemuでDebianを起動してmanを叩いた様子です。 こちらも崩壊しており、実用には向かないようです。 また、こちらもWPFで作られている都合上、Windows Terminalと同様に管理者特権としてシェルを実行するための手段が有りません。

ConEmuを試す

個人的に、Windows向けのターミナルの中では最も定評が有ると思っているOSSです。 https://conemu.github.io/

タブやペイン機能が有り、カラースキーム等も概ね正しく扱え、ショートカットキーを含む動作の調整がとても細部まで行えます。 また、cursesや色周りの処理も概ね正しく扱えます。 管理者特権としてシェルを実行するための仕組みも存在します。 事実、PowerShell/WSLのターミナルとして4年ほど既に使用しています。

windows wsl terminal 2019 12 04 09 14 38

ただし、スクロールバッファの処理に多少の問題が有るようで、何かの条件で壊れてしまうとタブを再起動するまでスクロールが行えなくなります。 この壊れた状態では、curses周りの処理も怪しくなり画面をかき混ぜたような状態になります。 構成管理ツールなど、標準出力に変更内容が出力されるツールをよく使うのでこの問題は頻度が低くても致命的です。

また、管理者特権でシェルを起動する事に対応しているものの、タブを開く毎にUAC画面が出るのは少々ストレスでした。

ただ、これらを考慮してもWindowsで最も高機能・安定的なターミナルはConEmuだと考えています。

結局… VcXsrv + gnome-terminal

結局の所、Linuxで広く使われているツールを利用する事で落ち着こうとしています。

LinuxのGUIアプリケーションは、設定を行うことでWSL上でも起動することが出来ます。 Windows上でX-Serverを起動し、WSL上のGUIアプリケーションに接続情報を環境変数で渡す事で実現します。

Windowsでよく使われるX-ServerとしてVcXsrvが有るので、コレをインストール・設定します。 方法は標準的なので割愛します。 https://sourceforge.net/projects/vcxsrv/

そして接続情報をWSLの環境変数に設定します。

echo 'export DISPLAY=:0.0' >> ~/.profile
. ~/.profile

次に gnome-terminal を導入します。 カラースキームはGithubのOSSプロジェクトである Mayccoll/Gogh を使用しました。

$ sudo apt install gnome-terminal
$ bash -c  "$(wget -qO- https://git.io/vQgMr)" # カラースキームの導入

結構時間はかかりますが、完了すれば gnome-terminal コマンドでターミナルが起動します。

windows wsl terminal 2019 12 04 08 50 07

おわり

Windows Terminalの進化に圧倒的な期待…!

続きを読む