世界を動かす技術を、日本語で。

Io_uring、kTLS、Rustを用いたゼロシステムコールHTTPSサーバー

概要

  • ウェブサーバの高容量化の歴史と技術進化の流れを解説
  • epollやio_uringなどLinuxにおけるスケーラブルI/O手法の紹介
  • kTLSやdescriptorless filesなど最新最適化技術の概要
  • Rust製ウェブサーバ「tarweb」での実践例と課題
  • io_uring利用時の安全性やメモリ管理の難しさに言及

世紀転換期の高容量ウェブサーバ需要とC10k問題

  • 世紀転換期に 高容量ウェブサーバ の需要急増
  • C10k問題論文の登場による 同時1万接続 の課題提起
  • 当時の主流は プリフォーク方式 によるプロセス生成コスト削減
  • 1リクエストごとにプロセス生成が一般的だった時代背景
  • スレッド化、poll()/select()の導入による 軽量化とコンテキストスイッチ削減

select()/poll()の限界とepollの登場

  • select()/poll()は 大量接続に非スケーラブル
    • 毎ループで 巨大な配列 をカーネルに渡す必要
  • Linuxの epoll (他OSではkqueue)の登場で効率化
  • epollは 差分管理 でsyscallコスト削減
  • メインループ例:epollで新規/読込/書込を効率管理
  • ただしsyscall自体のコストが相対的に目立つ段階へ

io_uringによる非同期I/O最適化

  • syscallごとにカーネルへ命令する従来方式からの脱却
  • io_uring は命令をキューに書き込み、カーネルが非同期に処理
  • 例:accept()をキューへ投入、完了時にキューから結果取得
  • ほぼ全てのI/O操作を メモリ操作だけで完結 可能
  • 忙しいサーバならsyscall不要(straceでも何も表示されない)

マルチコア時代の設計とNUMA最適化

  • 現代CPUは 多コア化、理想は1コア1スレッド運用
  • 各スレッドをコアにバインド、 共有リードライト構造体を回避
  • NUMA構成では ローカルノードのメモリ のみ利用推奨
  • リクエスト負荷の完全分散は今後の課題

メモリ割当とリスク管理

  • ユーザ空間・カーネル空間の両方で メモリ割当 発生
  • コネクションごとの固定チャンク割当で フラグメント防止
  • カーネル側もバッファ管理が必要、socket optionで調整可能
  • RAM不足回避 が安定運用の必須要件

kTLSによるカーネルTLS最適化

  • kTLS はTLS暗号化/復号をカーネルにオフロード
  • ハンドシェイク後は sendfile() が使え、ユーザ空間とのコピー削減
  • NICのハードウェア対応時は CPU負荷の大幅削減 も可能

descriptorless filesとregister_files

  • ファイルディスクリプタの ユーザ・カーネル間受け渡しコスト 削減
  • register_filesによる descriptorless files 導入
  • ユーザ空間で見える番号は整数値で、/proc/pid/fdには現れない
  • io_uring専用で、ulimitのfd制限は適用

Rust製ウェブサーバ「tarweb」の実践

  • tarweb :単一tarファイルを配信するRust製ウェブサーバ
  • io_uringkTLS、Rustの組み合わせで最新技術を実装
  • kTLS有効化にはsetsockopt()が必要、io_uring側のPRで対応
  • TLSライブラリ(rustls)がハンドシェイク時にメモリ割当実施の可能性
  • 1リクエストごとにsyscallゼロでHTTPS応答可能

ベンチマークと今後の課題

  • 現時点で ベンチマーク未実施
  • コード整備後に測定予定

io_uring利用時の安全性とメモリ管理

  • io_uring はバッファの寿命管理が難しい
  • 操作完了までバッファを 解放・上書き不可
  • Rustのio-uring crateは 安全性保証が弱い
  • Rust本来の「コンパイル通過=安全」には未到達
  • pinningやborrowを活用した safer-ring crate の必要性

Hackerたちの意見

これめっちゃクールだね!似たようなことをずっと考えてたから、誰かがついにやってくれて嬉しい。GG!BPFの部分もRustでAyaを使って書くのをおすすめするよ。[1] - https://github.com/aya-rs/aya

straceの代わりに何を使えばいいの?何が起こってるか見たいんだけど。

eBPFベースのツールを使う必要があると思うよ。

perfを使ってスタックトレースを見たり(待機やロックのオフCPUイベントも)、ebpfもね。

いい記事だったし、素晴らしい仕事だね。パフォーマンステストが楽しみ!君の書いた内容は、11歳の時にデータベースやバックエンドを設定しようとして、オンラインでたくさんのcgi-binを見つけた時の知識とつながったよ。今思うと、それらはリクエストごとに新しいプロセスを立ち上げてたんだね。https://en.wikipedia.org/wiki/Common_Gateway_Interface 大きなゲームフォーラムで、数十TBのデモダウンロード用にsendfileが使えるようになった時のことを覚えてる。それだけでも同時接続数が大きく改善された。こういうエンジニアリングはもうやらないって思ってたけど、これやNetflixの40ms追加、GTA 5の70%のロード時間短縮を考えると、もっと影響力のある仕事ができるかもしれないね。https://netflixtechblog.com/life-of-a-netflix-partner-engine... https://nee.lv/2021/02/28/How-I-cut-GTA-Online-loading-times...

CGIだけじゃなくて、HTTPセッションはCERNやApacheの系譜でサーバー全体のフォークコピーが一般的だったんだよね!Apacheは徐々に良い答えを出してきたけど、共通のアドオンとのAPIがちょっと移行を難しくしてたから、nginxみたいなウェブサーバーが登場したんだ。これは記事にあるアーキテクチャに近い形で、最初からイベント駆動のI/Oで作られてるからね。

これのベンチマークを見てみたいな;4日前に試して、標準のepoll実装を作ったけど、uringを使ったnginxには勝てなかった。これは傲慢な夜には簡単なタスクじゃないから、君が素晴らしい数字を出せることを願ってるよ;俺のは悲しい結果だったけど、君の実装の大部分はやってないから、単に「バッチ」呼び出しを試しただけなんだ。頑張ってね、楽しんで!

素晴らしい読み物だった。次はDPDKスタイルのフルカーネルバイパスを見てみたいな。

これ知ってるか分からないけど、LUNAはもうこれをやってるよ。 https://www.usenix.org/system/files/atc23-zhu-lingjun.pdf

例えば、書き込み操作を提出する時、そのバイトのメモリ位置は解放されたり上書きされたりしてはいけない。 > io-uringクレートはこれにあまり役立たない。APIは借用チェッカーがコンパイル時に君を守ることを許可していないし、ランタイムチェックもしていないように見える。こういうコメントは前にも見たことがあって、io_uringの周りに安全な非同期Rustライブラリを構築するのは実際かなり難しい印象を受けてる。ちょっと残念だね。確か、tokioチームのアリスも最近はこれらの難しさを乗り越えようとする興味があまりないって言ってたと思う。現状のパフォーマンスが「十分良い」からね。[1] https://boats.gitlab.io/blog/post/io-uring/

io_uringの周りに安全なインターフェースを構築する正しい方法は、リング所有のバッファを使って、バッファが必要な時にリングにリクエストし、書き込みを開始する時にそのバッファをリングに返すことだと思う。

俺の考えでは、Rustの借用チェッカーがあまりサポートしてない所有権モデルがあるんだ。名前が思いつかないから「ホットポテト所有権」って呼んでるけど、基本的なアイデアは、バッファを所有権として渡して、渡した相手が(最終的には)返してくれることを期待するってこと。これは非レキシカルな借用の問題みたいなもので、純粋に安全なRustで自分で実装しようとしたときに、「バッファを返す」ってのが本当に書きづらいってすぐに気づいたんだ。

すべてを借用で表現する必要はないよ。Slabみたいなデータ構造を使ってキャンセルセーフにすることもできる。例えば、前に書いたこのライブラリはキャンセルセーフで、ライフタイムとかは使ってないんだ。 https://github.com/steelcake/io2

実はこれがRustのasyncに対する不満の一つで、長期的には言語にとって悪い追加だと思ってる理由なんだ。根本的な問題は、Rustのasyncがepollが主流だったときに開発されたこと(RustのコミュニティではIOCPに関心を持ってる人がほとんどいなかった)で、これがasyncの設計に大きく影響してるんだ(時には他の言語を通じて間接的にね)。ちょっと考えてみて。なぜ「同期的」なsyscallではこの問題がないの?readを呼ぶとき、バッファの「可変借用」をカーネルに渡すけど、これはRustの所有権/借用モデルにうまくマッピングされる。syscallはスレッドの実行をブロックするから、ユーザーコードでそれを防ぐ方法はないんだ。ポーリングベースのasyncモデルでは、この問題を回避できる。なぜなら、同じ「同期」syscallを使うけど、ブロックせずに返ってくることが保証されてるから。完了ベースのIOが所有権/借用モデルでうまく機能するためには、タスクコードが完了イベントを受け取るまで実行を続けないことを保証しなきゃいけない。ユーザーコードでポーリングされる状態機械ではそれができないんだ。でも、スレッドモデルはここにぴったり合う!もしスレッドを「グリーンスレッド」に置き換えるなら、ユーザーのRustコードは「同期的」なコードと見分けがつかなくなるよ。あと、グリーンスレッドモデルは多くのRTOSで示されているように、組み込みシステムでもちゃんと機能するからね。asyncランタイムをすべてのターゲットに必須にしないで実現する方法はいくつかあった(これがグリーンスレッドがRust 1.0から削除された主な理由)。個人的には、別の「async」ターゲットを導入するのが好きだったな。残念ながら、Rustの言語開発者たちは、約束された効率のために未検証のポーリングスタックレスモデルに賭けたんだ。今、その賭けがどうなるかを見極めているところだよ。

すごく面白かった!ベンチマークを待つのは我慢できるから、ゆっくりでいいけど、正直、著者が今はベンチマークを気にせず、まずコードをきれいにしたいって考えてるのが好きだな。ベンチマークが最大化されて、プロジェクトの存在意義がベンチマークを満たすことだけになってるこの世界で、そんな考え方を持ってる人がいるのは本当に印象的だよ。新鮮な空気を感じるし、正直、著者をすごく尊敬してる。すごく良い読み物だった、ありがとう。ktlsが存在することも知らなかったし、Io_uringがこんな風に使えるなんて思わなかった。

"ゼロシステムコール" > ビジーループを避けるために、カーネルとウェブサーバーはキューをちょっとだけ(設定可能だけどミリ秒単位で)チェックするためにビジーループをするんだ。新しいものがなければ、ウェブサーバーはシステムコールをして「眠る」ことになる。何かがキューに追加されるまでね。

最後まで記事を読むのはいいことだよ > これは、ビジーなウェブサーバーがすべてのクエリを処理するのに、一度も(セットアップが終わった後に)システムコールをする必要がないことを意味してる。キューにどんどん追加されていく限り、straceには何も表示されないんだ。

スピンしないすべてのポーリングI/Oモデルと同じように、最悪の場合、リクエストを処理するためにミリ秒待たなきゃいけないってことも意味してる。それって結構長い時間だよ。比較すると、二つのプロセス間でループバックを使ったTCPソケットの読み書きは、BSDソケットAPIを使うと数マイクロ秒なんだ。

負荷がかかっているときは、syscallはゼロだよ(rustlsのハンドシェイク内での稀な割り当てを除いてね。絶対にしないとは保証できないけど)。負荷がないときに、(実質的に)sleep()を呼ぶオーバーヘッドは、技術的には正しいけど、あんまり関係ないかな。でも、確かに、バスループのタイマーを調整して、カーネルとユーザー側で100% CPUを無限に使うこともできるよ。あえてそのidle時のsleep syscallを避けたいならね。けど、それは…あんまり良いアイデアじゃないよ。

kTLSの現状って誰か知ってる?少し前にCiliumの開発者に聞いたんだけど、トーマス・グラフが興奮して話してるのを見たからさ。そしたら、いろんなディストリビューションでカーネルのサポートが足りないから、デフォルトで有効にはできないって言われたよ。

今のところ、epoll以降で比較したものは全部イマイチだった。だから、バグだらけの自分の基盤を再実装する価値はないかな。ただ、JavaのNIO(epoll)と新しいバーチャルスレッドIO(ピン留めなし)を比較するつもり。http://github.com/tinspin/rupy