こんにちは,id:hanazukiです.松山で開催されたRubyKaigi 2025に何人かのKMC部員とともに参加し,Wi-Fiの構築・運用をやっていました.KMC活動ブログに記事を書くのは2023年のDNSリゾルバの記事ぶりです.
現地でも噂されてしまっていたようですが,今年のRubyKaigiでは15人いたNOCメンバーの過半数が会期中に体調不良でダウンするインシデントに見舞われてしまいました.私はどうやら風邪と食中毒を時間差でもらったようで,RubyKaigi本編もNOCの活動も満足に参加できず,たいへん残念でした.愛媛で買ってきたみかんを1週間くらい食べ続けていたら快復しました.
さて,RubyKaigi 2025では,今年からの新しい試みとしてIPv6-mostlyネットワークを提供していました.IPv6-mostlyとは,464XLATと呼ばれるIPv6移行技術を対応クライアントへ選択的に展開することで,漸進的にIPv4の利用を削減してゆく方式のことです.464XLATは,IPv4アプリケーションとIPv4のサーバ間の通信の中間部分をIPv6で代替する技術です.そのために必要なNAT64(IPv6からIPv4へのNAPT)の実装として,OSSではJoolというLinuxカーネルモジュールがよく利用されていますが,今回のRubyKaigiではNAT64のRubyによる代替実装を開発することを目的の一つとして,IPv6-mostlyネットワークの実装実験を行いました.
先日の東京Ruby会議12での発表でも紹介したように,1ヶ月ほど前から自宅の検証環境で種々のクライアントとのIPv6-mostlyの相互運用試験をして本番に望んだのですが,松山ではあまり安定稼働させられませんでした.わが家の検証環境は私物のネットワーク機材の余り物で組んでいる都合で,本番で利用しているCisco WLC(無線LANコントローラ)ではなくCisco Mobility Expressを使っていたところ,どうやらWLC(もしくはRubyKaigiで使っているWLCの設定)とmacOSのIPv6-mostly実装に相性の問題があったようです.日本Rubyの会に予備機材として同型のWLCを1台追加調達してもらったので,それを使って原因究明をし次回へ向けて調整したいと思っています.
RubyKaigiのIPv6-mostlyネットワークの全貌についてはチームメイトのid:sora_hに譲ることにして,本稿では今回のRubyKaigiで使用するために新しく開発したソフトウェアとその際の工夫(あるいは小ネタ)を紹介します.もちろんこれらのソフトウェアの主要部分はRubyで書かれています.RubyKaigiなので.
Xlat: SIITのRuby実装
今回RubyKaigiにIPv6-mostlyを導入するにあたって,sora_hとhanazukiでSIITの実装をRubyで書きました.これをXlatと呼んでいます.
SIIT (Stateless IP/ICMP Translation)は,IPv4データグラムとIPv6データグラムの相互変換をするアルゴリズムで,RFC 7915で定義されています.IPv4とIPv6ではヘッダの形式が異なるので,SIITは翻訳先のバージョンのIPヘッダをできるだけ辻褄をあわせた形で生成し,IPv4ネットワークとIPv6ネットワークの間で相互にIPデータグラムのやりとりをできるようにします.
また,IPv4とIPv6ではアドレス体系も異なるので,IPv4データグラムをIPv6データグラムに翻訳するときには,送信元と宛先のIPアドレスを適当に書き換えてやる必要があります.IPv6-mostlyでは,IPv4の世界を表すIPv6プレフィクスを一つ選んで(これをPref64::/nと呼びます),IPv4アドレスをPref64::/nの末尾に埋め込むことでアドレスの対応付けを行います(詳細はRFC 6052を参照).例えば,RubyKaigiではPref64::/nとして2001:df0:8500:ca64:a9:8200::/96を使いました.このとき192.50.220.162は2001:df0:8500:ca64:a9:8200:c032:dca2に対応付けられます.
このPref64::/nは16 bit単位での1の補数和が0になるように選んであります(0x2001+0x0df0+0x8500+0xca64+0x00a9+0x8200=0xffff=0).このようなプレフィクスのことを「チェックサム中立 (checksum-neutral)」であると言います.TCPやUDPのチェックサムは1の補数和で計算されるので,この部分がチェックサムの値に影響を与えないということです.チェックサム中立なPref64::/nを選ぶと,翻訳時にL4ヘッダのチェックサムを更新する必要がなく少しだけ効率的になるメリットがあります.
IPv6でのNAPTであるNAPTv6(プレフィクスを取り替えるNPTv6とは異なるので注意)とSIIT(IPv6からIPv4への変換)を関数合成すると,NAT64(IPv6からIPv4へのNAPT)を作れます.LinuxカーネルのNetfilterがNAPTv6の機能を持っているので,これとユーザランドのXlatを組み合わせることでNAT64を実現できる寸法です.
RubyKaigiでは,県民文化会館の楽屋廊下で2台のN100ミニPCにインストールされたXlatが,みなさんのMacBook・iPhone・AndroidなどIPv6-mostly対応クライアントからのトラフィックをNAT64していました.メトリクスを見ると,NAT64にはピークで200 Mbpsくらい流れていたようです.事前の検証では1500 Bのロングパケットで1台あたり1 Gbps(つまり,NICの限界)までは捌けることを確認していたので,パフォーマンス面では十分な余裕がありました.
オブジェクト割当の削減
今年のRubyKaigiでClass#newが遅いのだという発表がありましたね(私は裏番組を見ていました).Xlatの初期の実装でもオブジェクトを作りすぎていて遅いという問題を踏んでいて,できるだけオブジェクトを使い回せるようにAPIを変更する修正を行いました.
Xlatのパフォーマンスの問題を調べるのに,NOCメンバーでプロファイラ職人のosyoyu (id:tomo_ari)が開発しているPf2というプロファイラを使わせてもらいました.Pf2はどのRubyメソッドがどのC関数を呼び出しているのか可視化できるプロファイラです.Pf2を使うとどのメソッドでオブジェクトの割当が行われているか一目瞭然になるので便利でした.たとえば,パーサを改善する前と少し改善した後のフレームグラフを比較するとXlat::Protocols::Ip#parseの部分の盛り上がりが減っているのが見てとれます.最新版ではもう少し改善しています.
オブジェクトの割当がパフォーマンスを悪化させるのは,Class#newが遅いだけではなく,付随的にガーベジコレクション(GC)が実行されることも要因になっています.TCPの輻輳制御アルゴリズムはネットワークのRTTを予測して適正な流量でパケットを流します.GCが走るとパケットの処理時間にゆらぎ(ジッター)が発生することになり,これはTCPのパフォーマンスに悪影響を及ぼします.したがって,オブジェクトの割当はできるだけ減らすのが望ましいです.
IO::BufferとベクタIOの利用
IPv4とIPv6ではヘッダの長さが異なります.IPv4では20 Bから60 Bの可変長,IPv6では40 Bの固定長ヘッダにさまざまな拡張ヘッダを付与できて長さに上限がありません.したがって,IPv4とIPv6の間でデータグラムを翻訳すると,ほとんどの場合,長さが変わります.
MRIでは,Stringの部分文字列を取ったり,String同士を連結したりすると,文字列のバイト列をコピーすることになります*1.それゆえ,SIITのように長さの変わるバイト列の操作をStringで実装すると,どうしてもデータのコピー回数が多くなり非効率です.
Xlatでは,バイト列をIO::Bufferを使って保持し,LinuxのベクタIO (writev(2))を使って出力することで,Rubyプロセス内での「ゼロコピー」を実現しています.例えばヘッダとペイロードをそれぞれIO::Bufferとして持っているときに,これらを1回のwritev(2)システムコールでまとめて出力することで,1つのIPデータグラムとして出力するという具合です.IO::BufferでベクタIOを使うAPIが今のRubyには無いので,この部分だけ拡張として実装しています.
IO::Bufferの部分バイト列(スライス)を抜き出す#sliceメソッドは,バイト列のコピーこそしないものの,IO::Bufferのインスタンスを新しく割り当ててしまう問題があります.C++のstd::span<T>やRustの&[T]のようなファットポインタ(ポインタの長さの対)のつもりで使うとGCのコストが乗ってきて痛い目を見るということです.これの回避策は簡単で,スライスを作る代わりに(buffer, offset, length)の3つ組を持ち回って,どうしても必要な時までIO::Buffer#sliceの呼び出しを遅延させることです.
YJITとRactor
YJITを有効にすると勝手にプログラムが速くなり,たいへん偉大でした.具体的には,1スレッドのXlatで1ストリームのTCPを処理した場合のスループットが240 Mbpsから430 Mbpsに改善するくらいの効果がありました.メソッドをあまり細かく分けないコーディングスタイルが効いているのではないかと想像していますが,実際のところは詳しく計っていません.IO::BufferはStringなどと比べてまだあまりYJITで最適化されていないので,たとえばIO::Buffer#get_valueなどをインライン化できるようになると嬉しいのではないか,など処理系の方からも更なる改善の余地はありそうに考えています.
また,SIITのSが“stateless”の頭文字であることからわかるように,SIITは状態を持たないアルゴリズムになっています.すなわち,ある一つのデータグラムを翻訳する際に,そのデータグラムの前後のデータグラムの情報を必要としません.これは異なるRactorインスタンス間で状態を共有できないというRactorの制約に対して非常に都合がよい性質です.翻訳ワーカーをそれぞれ個別のRactorとして起動することで,マルチコアを活かしてCPU処理を並列化できます.Ractorの“actor”の部分は何も使っていませんが,GVLがRactor毎に分割されたのが効いてくる事例でした.
RubyKaigiではRactorローカルGCの発表を聞きそびれてしまったのですが,RactorローカルGCが実現した場合,GC中のワーカーにはタスクを渡さないように制御することで,タスクの処理がGCによる停止の影響をうけないようにできるのではないかと考えました.オブジェクト割当を目の敵にしなくてよくなれば嬉しく,気になっています.
ファジング
SIITへの入力はすべてユーザ端末かインターネットから与えられるもので,データグラムが正しい形式に従っている保証はありません.Rubyスクリプトとして書いている以上,範囲外参照でメモリが壊れるような深刻な事態には陥りませんが,壊れたデータグラムを食べさせられて例外を上げてしまうのは防ぎたいです.そこでRuzzyというライブラリを使ってパーサとSIIT実装のファジングを行っていました.
Ruzzyは内部的にLLVMのlibFuzzerを使っていて,コードカバレッジを拡大するようにランダムな入力を生成し続ける仕組みになっています.Rubyスクリプトをファジングする場合は,Integerの比較をフックすることで分岐をどちらに進んだかを記録しているようです.したがって,例えばIO::Buffer#<=>のようなCで実装された比較メソッドをRubyスクリプト中で分岐条件に使っている場合,これをIntegerの比較を使うようにRubyで実装し直すことによって,Rubyインタプリタ自体を計装のためにリビルドすることなくファジングを行えます.
範囲外参照や境界エラーのようなありがちなバグを発見してくれるのみならず,思いもよらないような異常なパケットを次々に生成してくれて大いにデバッグの助けになりました.
やりのこし
会期までに完成させられなかったこともたくさんあり,来年度にむけて磨いてゆきたいと思っています.TCPとUDPのパケットの翻訳をするパスに関しては,オブジェクトの割当をかなり減らしたのですが,ICMPを扱う部分でまだ非効率な部分が残っているのを改善したいというのもその一つです.
ベクタIOを行う部分は拡張として実装したのですが,現状IO::Bufferの配列を引数として取るインターフェイスになっています.つまり呼び出し側でIO::Bufferの出力に必要な範囲のスライスをとる前提です.これでは先述のIO::Buffer#sliceのオーバーヘッドを避けられないことから,もうすこしよいインターフェイスを考えたいです.
また,この拡張はRustとmagnusを使って実装したものの,安全性のために微妙に余分なオーバーヘッドが乗ることがわかって,C++で書き直そうかと思っています.さいきん別のところで必要になってrcxというモダンC++でRubyの拡張を書くためのライブラリを作っているというのもあります*2.これもまた別の機会に紹介したいと思います.
conntrack_exporter
Netfilterのコネクショントラッキング(conntrack)の状態を可視化するために,conntrack_exporterをRubyで書きました.同名のソフトウェアは世の中に存在するのですが,今回書いたconntrack_exporterは,conntrackエントリをL3プロトコル・L4プロトコル・ラベル(connlabel)で集約して数えられるのが特徴です.connlabelとは各conntrackエントリにある128 b長のビットセットで,各ビットが1つのラベルに対応しています.nftablesなどを使うとconnlabelに管理用の好きな値を設定できます.
RubyKaigiではNAT64のアドレスプール毎に異なるconnlabelを付与することで,各アドレスプールからL4ポートがどれだけ使われているかを識別できるようにしていました.UDPでは,NAPTの外側IPアドレス1つあたり64,512本(UDPで利用可能なすべてのポート2**16個から,システムポート2**10個を除いた数)のコネクションしか張れない制約があるため,ポートの利用率を監視することでアドレスプールのサイズを決める目安になります.
nl/ynl
conntrack_exporterを実装するにあたって,Rubyで使えるNetlinkのライブラリが必要となりnlという名のgemを書きました.NetlinkとはLinuxのプロセス間通信の仕組みの一つで,主にアプリケーションがカーネルの持っている情報を取得したり,カーネルの状態を変更したりするのに使われています.
Netlinkの通信プロトコル自体は,ソケット上でリクエスト・リプライのメッセージを順にやりとりするだけで,さほど複雑ではありません.しかし,対話したいカーネルのサブシステムごとにメッセージをエンコード・デコードするコードを書くのがたいへんに面倒という問題が知られています.メッセージは基本的にTLV (tag-length-value)のフォーマットに従っているものの,tagはC言語のヘッダで定義されていて,valueのフォーマットはCヘッダのコメントか,運がよければmanpageに自然言語で書かれているものを解読しなければなりません.このような理由から,網羅的なエンコーダ・デコーダを書くことも現実的には困難で,メンテナンスが続かなくなるプロジェクトも少なくありませんでした.
このような問題を解決するために,機械可読でCに依存しないメッセージスキーマを提供するプロジェクトがYNLです.YNLのYがYAMLのYであるように,YAML形式で各サブシステムのメッセージのフォーマットを定義しています.スキーマはカーネルツリーのDocumentation/netlink/specsディレクトリ以下に含まれています.このスキーマから各言語のコードを生成することで,エンコーダ・デコーダを無料で手に入れられ,カーネルのアップデートに追従するのも簡単になるというもくろみです.Rubyのコードを生成するynl gemも作りました(nlと同じGitHubリポジトリに置いています).
YNLは始まったばかりのプロジェクトで,まだ全てのサブシステムを網羅しているわけではないですが,conntrackサブシステムのメッセージスキーマがちょうどいま開発中のLinux 6.15で追加されて,conntrack_exporterを作るのに利用できました.YNL自体はあらゆるサブシステムをカバーするために結構複雑になっていて,ynl gemではまだ全ての機能は実装できていません.これはおいおいやっていこうと思います.
ところで,nlのNetlinkメッセージをデコード・エンコードする部分もIO::Bufferをベースに書きました.pack,unpackのフォーマット文字列を覚えることから開放されるのもIO::Bufferの利点です.
来年に向けて
年始の東京Ruby会議12ではDNSの話をしたものの,その後IPv6-mostlyの実装にかまけていて,今年のDNSリゾルバには小さな改善しか盛り込めませんでした.DNS-over-QUIC (RFC 9250)を喋るようになっていることや,RESINFO (RFC 9606)を返すようになっていることに気づいた人はいるでしょうか.IPv6-mostlyを導入するにあたって,DNSリゾルバもIPv6に対応しました.これも実はXlatのNAT64を使っていて,クライアントからIPv6で送られてきたクエリをXlatで翻訳してIPv4のDNSリゾルバに流していました.来年こそはDNS関連でなにかコードを書けるようなことをやりたいと思っています.もっとも,秋にはKaigi on RailsのWi-Fiをやるかもしれないという話もあるので,IPv6-mostlyの検証をまだもうすこしやっているのだと思いますが.
関連記事
BAKUCHIKU BANBAN #1で,id:sorahがKaigi Wi-Fiの近況を話してくれました.