インターンレポート: RootlessコンテナのTCP/IP高速化

松本直樹
nttlabs
Published in
20 min readFeb 16, 2022

--

はじめまして、インターン生の松本直樹と申します。 この記事では、私がNTT研究所におけるインターン「コンテナランタイムの実装と評価」のインターン期間中に取り組んだ「bypass4netns」について紹介させていただきます。

自己紹介

私は京都大学 情報学研究科に所属し、 普段は次世代型ホームネットワークと称してホームネットワークとSDNの融合や計算処理オフロードに関する研究に取り組んでいます。

コンテナ技術やその周辺のネットワーク技術に関しては普段から興味があったものの、 時間をかけて取り組む機会がありませんでした。 その折に、今回のインターンの募集を見つけ、実装を中心に触れることができる良い機会だと思い応募させていただきました。 インターン期間中はRootlessコンテナとネットワーク周りにどっぷりと浸ることができ、 非常に貴重な体験ができました。

はじめに: Rootless コンテナについて

Rootless コンテナは特権を必要としないコンテナのことであり、nerdctl(containerd) や docker, podman で利用することができます。nerdctl は Docker互換のcontainerd用CLIであり、コンテナ関連の新機能の実験場として、NTTなどcontainerdコミュニティが開発しています。今回のインターンにおいても、 Rootless コンテナの TCP/IP を高速化する実験として bypass4netns と nerdctl への改良を行いました。

Rootless コンテナの利点

Rootless コンテナの概要としては、インターン期間中に私のメンターをしてくださった須田さんの発表資料が参考になります。

containerd: Rootless Containers 2020, Akihiro Suda, KubeCon NA 2020

Hardening Docker daemon with Rootless mode, Akihiro Suda, DockerCon 2019

Rootless コンテナの使命は「セキュリティリスクの低減」にあり、利点としては 「特権なしでランタイムが動くため、バグがあった場合に被害を最小限に留められる」 という点があります。 そのため、コンテナランタイム等のセキュリティリスクを減らす選択肢の一つとして取り上げられることが多くあります。

Rootless コンテナにおけるネットワークの弱点

すでに Rootless コンテナにおけるネットワークについても、完全な非特権状態で実現されています。 しかし、Rootful なコンテナに比べて、Rootless なコンテナにおける対外通信が遅いという問題があります。

Rootful, Rootless コンテナにおける iperf3 速度測定の結果比較

nerdctl 0.16.1で実際に特権あり(Rootful)と特権なし(Rootless)の場合についてiperf3で速度測定をしたところ、 実際に大きく性能差があることが分かります。 特に、コンテナからホストへの通信は大きなパフォーマンスの低下が発生しており、 大容量のデータ転送を含むような環境ではボトルネックになると考えられます。※slirp4netns 単体の場合より極端に遅い原因としてはCNIプラグインの相性により、slirp4netns の本来の性能を発揮できていない可能性が高いです。

一般的なコンテナにおけるネットワーク

一般的なコンテナでは、ネットワークについてはホスト上に veth デバイスを作成し、一方をホスト上のコンテナ用ブリッジデバイスへ、 もう一方をコンテナ内の network namespace に紐づけることでコンテナ内からホスト側、ホスト側からコンテナ内への通信を実現しています。

Rootful なコンテナにおけるネットワーク

Rootless コンテナにおけるネットワーク

ホスト上での veth デバイスの作成は特権(CAP_NET_ADMIN)を必要とするため、一般的なコンテナのネットワークの仕組みは Rootless コンテナでは利用できません。 そのため、Rooless コンテナでは、以下の2つの方法によってホストとの通信や対外通信を実現しています。

lxc-user-nic はSUIDが付与された実行バイナリにより、特権で実行されるバイナリをlxc-user-nicに限定し、ランタイムは Rootlessで動作させた状態で、veth デバイスで作成することができます。 Rootful なコンテナと同様に veth デバイスを作成するため、性能劣化はほとんど発生しませんが、 SUIDを付与している以上、実行バイナリは root 権限で実行されることになるためセキュリティの観点ではリスクが大きくなってしまいます。

slirp4netnsrootlesskitでは、user namespace内ではnetwork namespace, vethデバイスを特権なしに自由に作成可能であることと、ホスト上のプロセスと Unix domain socket を介したファイルディスクリプタの交換は可能であることを利用した方法により、特権を必要としないコンテナネットワーキングを実現しています。

slirp4netns を利用した内部から外部への通信の中継

Rootless 用の network namespace 内にブリッジ(実際には nerdctl0) を作成し、 そこに別の network namespace で作成されたコンテナに対して veth デバイスをバインドします。 ここまでは前述したように特権を要しません。 しかし、 namespace 外のホストと通信する場合、非特権で通信可能な経路が存在しないことが問題となります。

そこで、コンテナ内ネットワークから外部への通信の中継を行うslirp4netns では、namespace 内にtap デバイスを作成し、ホスト側のslirp4netns デーモンにファイルディスクリプタを渡します。

ホスト側のslirp4netnsデーモンはtapデバイスで受け取ったパケットを解釈して必要に応じてソケットの作成やconnect(2)send(2) 等を行い、プロキシとしてパケットをホストから送信します。ここでも RAW_SOCKET ではなく、パケットに応じてSOCK_DGRAMSOCK_STREAMなソケットを作成してメッセージを送信するため特権を要しません。

rootlesskit を利用した外部から内部への通信の中継

外部からコンテナ内ネットワークへの通信の中継を行う rootlesskitでは、ホスト側でデーモンが接続を待機しています。接続をaccept(2) で受け入れ、ファイルディスクリプタを取得すると同時に、network namespace 内の rootlesskit が 127.0.0.1 に対して接続を行います。この時、127.0.0.1 に対する接続は CNI プラグインによって公開対象のコンテナのポートへとルーティングされます。そして、親と子のファイルディスクリプタ間でパケットのコピーを行うことにより、通信の中継を行っています。

このように、slirp4netnsrootlesskitにより特権を要さないコンテナネットワーキングを実現しています。しかし、対外通信はすべて一度ユーザーランドで処理する必要がある という大きなオーバーヘッドが存在します。 また、単純にパケットを吸い上げるだけでなく、TCP/IP の処理を行う必要があるという要件も存在します。 slirp4netns ではゼロコピーや MTU を 64KiB まで拡大することによりパフォーマンスの改善を行っていますが、 Rootful なコンテナと比して遅いという現状があります。

bypass4netns による Rootless コンテナネットワーキングの高速化

前述したように、Rootless な環境では対外通信において非常に大きな性能劣化が発生し、 今後の利用において問題となることが想定されます。

今回のインターンでは、PoCとして実装されていた bypass4netns を拡張し、 「対外通信について Rootful な場合と同等以上の性能を達成する」ことができました。

bypass4netns を利用した場合を含む iperf3 速度測定の結果比較

上に示すグラフにあるように、Rootless with bypass4netns の場合について、 Rootfulな場合とほぼ同等な性能を達成できていることが分かります。

すでにインターン期間中の成果物は rootless-containers/bypass4netnscontainerd/nerdctl にマージされており、 成果物を含む新しい nerdctl が nerdctl v0.17.0 としてリリースされています。パッケージを解凍し、

$ containerd-rootless-setuptool.sh install-bypass4netnsd
$ nerdctl run -it --label nerdctl/bypass4netns=true alpine

と実行することで利用できます。

bypass4netns の概要

一言で言うと、bypass4netns では 対外通信に利用するソケットのファイルディスクリプタをホスト側で確保したものに差し替えることで高速化を達成しています。

ソケットとファイルディスクリプタの関係

bypass4netns の詳細に踏み込む前に、 ソケットとファイルディスクリプタの関係について整理しておきます。 Linux においてソケットは一般的に syscall であるsocket(2) を利用して 作成します。この時、カーネルではソケットの実体が作成され、ユーザー空間のプログラムでは、 その実体を参照する識別子がファイルディスクリプタとして、socket(2)の戻り値から得られます。

ファイルディスクリプタとソケットの関係

ソケットに関する操作(connect(2), bind(2)等)ではファイルディスクリプタを引数に指定することで カーネル側にどのソケットに対する操作であるか知らせることで一連の操作を行っています。

ソケットのファイルディスクリプタは、あくまでソケットの実体を参照する存在であるため、 dup(2)fork(2)で複製したり、 sendmsg(2)と Unix domain socket を利用してほかのプロセスにファイルディスクリプタを送り別プロセスで作成されたソケットを利用するといった柔軟な利用をすることができます。

そして、原理上はホスト上でソケットを確保し得られたファイルディスクリプタを コンテナ内のプロセスに対して送り込みか差し替えることにより、そのファイルディスクリプタから ホストが確保したソケットをコンテナ内のプロセスから利用することが可能です。 すると、ホストが確保したソケットは直接外部と通信を行うことができるため、 slirp4netns で問題となっていたオーバーヘッドを無視することができ、 問題を解決することができます。

ファイルディスクリプタの複製および他プロセスへの転送

実際に、bypass4netns ではこの原理に従い、 ソケットのファイルディスクリプタを対外通信に関係するものについては差し替えることで性能改善を達成しています。

seccomp を利用したファイルディスクリプタの参照先の差し替え

原理としては非常に簡単なものの、 実際にコンテナ内のプロセスがもつファイルディスクリプタを適切に置き換えることは簡単ではありません。 原則として実行されるバイナリに手を加えることができず、 ptrace(2)による syscall エミュレーションもオーバーヘッドの観点から避けたい選択肢であります。

そういった状況において、Linux kernel 5.9 で seccomp にSECCOMP_IOCTL_NOTIF_ADDFD という今回の要件を満たす機能の追加がなされました。

元々、seccomp ではポリシーベースのsyscall実行制御以外にも、 Linux kernel 5.0 で seccomp notify と呼ばれる動的な実行制御の仕組みが導入されました。 この仕組みでは syscall の実行を監査するプロセスにおいて、 seccomp notify fd 経由で seccomp モジュールからのsyscall 実行の通知を受け取ると、 内容に従って適切にフィルタリングをして実行するか否かの判断し、判断結果をseccompモジュールに対して通知します。 seccomp はその内容に従って syscall を実行したり、しなかったりといった実行制御を行います。

seccomp notify の概要

SECCOMP_IOCTL_NOTIF_ADDFD は、この seccomp notify と組み合わせて利用することを想定された拡張となっています。 具体的にはこのように動きます。 最初に seccomp notify fd 経由で通知を受け取ると、syscall や引数の内容に応じて参照先の差し替えを行うか決定します。差し替える場合は、ioctl でカーネルに対して指示を出すと、ファイルディスクリプタが差し替えられます。差し替えが終わったのちに syscall を実行します。もちろん seccomp 本来の役割としてここで syscall を実行しないことも可能です。

SECCOM_IOCTL_NOTIF_ADDFD を利用した参照先の切り替え

そして、seccomp notify は containerd が主に利用する低位ランタイムである runc の最新版 runc 1.1 では既にサポートされているため、 サイドモジュールとしてこれらのファイルディスクリプタを差し替える処理を実装することで、 コンテナランタイム側には大きな変更を加えることなく実現することができます。

ファイルディスクリプタの参照先を差し替えるタイミング

これまでで、ソケットのファイルディスクリプタを差し替えることが現実的に可能であることが分かりました。 実際に bypass4netns では以下の syscall についてフックし、ファイルディスクリプタの参照先の差し替えを行っています。

  • connect(2): 接続先がコンテナネットワーク外の場合差し替え
  • sendto(2): 接続先がコンテナネットワーク外の場合差し替え(SOCK_DGRAM)
  • sendmsg(2): 接続先がコンテナネットワーク外の場合差し替え(SOCK_DGRAM)
  • bind(2): バインドしようとしているポートが --publish で指定されている場合、ホスト側のポートにバインドしたソケットのファイルディスクリプタに差し替え

他にも、setsockopt(2) については常時フックし、差し替えのタイミングでそれまで設定されていたオプションについて ホスト側で確保したソケットに適用するといったことを行っています。

また、コンテナ内のプロセスが close(2) なしで同じファイルディスクリプタを使いまわす場合も考慮する必要があります。 具体的には、SOCK_DGRAMなソケットにおいて同じファイルディスクリプタについて、

  1. sendto(2) 接続先がコンテナネットワーク外 → ホスト側のソケットを利用
  2. sendto(2) 接続先がコンテナネットワーク内 → コンテナ側のソケットを利用
  3. sendto(2) 接続先がコンテナネットワーク外 → ホスト側のソケットを利用

といった切り替えが行われる可能性があるため、 コンテナ内のプロセスが確保したソケットは bypass4netns 側で保持し、状態管理を行っています。

このようにすることで、接続先やバインドするポートに応じて適切にファイルディスクリプタの参照先を差し替え、 Rootful なコンテナとほぼ同等なネットワーク性能を達成しています。

ベンチマーク

iperf3

iperf3 による転送速度の比較はすでに示したように、Rootful なコンテナとほぼ同等の性能を達成できること分かっています。

bypass4netns を利用した場合を含む iperf3 速度測定の結果比較

bypass4netns がある場合とない場合について、CPU使用率についても測定を行いました。 環境は Hyper-V VM(2 Core) Ubuntu 21.10 環境で、1Gbps にレート制限をかけたiperf3(コンテナ→ホスト)実行中の CPU使用率を vmstat で取得し、その結果の user と sys を足し合わせたものをグラフにしました。

iperf3 実行中の bypass4netns の有無に応じた CPU 使用率の比較

グラフから分かるように、bypass4netns がない場合はCPU(2 Core)を65%近く利用している一方で、 bypass4netns を利用する場合は10%を前後しています。 前述したように、slirp4netnsではユーザーランドで動くTCP/IPスタックを持ち、 そこで一度パケットを処理する必要があるため処理に計算能力を要します。

一方で、bypass4netns ではパケットの処理自体はカーネル空間で行われるため、 通常のLinuxと同じように高速かつ効率的なパケット処理が行われ、結果としてCPU使用率が低くなったと考えられます。

できる限り多くのコンテナをサーバーに収容したい場合において、 ネットワークの処理に割ける計算資源は限られるため、そういった場合においても bypass4netns は有用であると言えます。

syscall フックによるオーバーヘッド

bypass4netns では、一部の syscall についてフックしているためオーバーヘッドが生じます。 実際に、以下のようなソケットを作成してコンテナネットワーク外のエンドポイントに connect(2)、 それからclose(2) という単純な syscall 実行の流れを10万回実行し、それを10回実行した際の所要時間の平均値により比較を行いました。

func do(dstAddr net.IP, dstPort int) error {
sock, err := syscall.Socket(syscall.AF_INET, syscall.SOCK_DGRAM, syscall.IPPROTO_IP)
if err != nil {
return err
}
ip := dstAddr.To4()
dst := syscall.SockaddrInet4{
Port: dstPort,
Addr: [4]byte{ip[0], ip[1], ip[2], ip[3]},
}
err = syscall.Connect(sock, &dst)
if err != nil {
return err
}
err = syscall.Close(sock)
if err != nil {
return err
}
return nil
}

結果が以下の表になります。 syscall(ここではconnect(2),close(2)) を bypass4netns でフックすることにより40倍程度のオーバーヘッドが生じていることが分かります。

syscall 実行のオーバーヘッド計測結果

SOCK_STREAM の場合は connect(2) やそのあとの send(2) においてネットワーク側の 処理に時間を要するため、syscallのオーバーヘッドによる性能低下の影響は小さくなると考えられます。 また、実アプリケーションにおいてどういった影響があるかは未知数であるため、そういった実環境に即した性能測定を今後行いたいと考えています。

※ こちらの性能測定はhttps://github.com/naoki9911/bypass4netns/tree/perf-test/test/benchmark/syscall で再現可能です。

セキュリティ上の懸念点

bypass4netns では、ホストで確保したソケットをそのまま本来は分離された環境であるコンテナ内で利用することにより 高速化を達成しています。 そのため、差し替えによって得られたホスト側のソケットについては、コンテナ内で自由に利用することができ、 ホストのlocalhostに対する接続をブロックしきれない可能性といった、セキュリティ上のリスクが増加する可能性があります。 そのため、bypass4netns 側で適切に syscall をフックして不適切な挙動をできないようにする必要があります。

しかし、bypass4netns や管理デーモンである bypass4netnsd はすべてユーザー権限で動いているため、 arp spoofing を行うための不正なパケットを送信するといったことは不可能です。

Rootless ながらもセキュリティリスクが少し大きくなることと、 大きな性能改善というバランスを運用形態に応じて取る必要があると言えます。

今後の展開

前述したように、すでにインターン期間中の成果物は rootless-containers/bypass4netnscontainerd/nerdctl にマージされており、 成果物を含む新しい nerdctl が nerdctl v0.17.0 としてリリースされています。 手順に従えば簡単に利用できますので、ぜひ利用していただき、フィードバックを頂けると幸いです。

bypass4netns にはTODOとして残されたタスクやセキュリティ上の安全性の検証といったタスクがいくつかあり、 インターン期間後も趣味として少しずつ取り組んでいきたいと思っています。

おわりに

今回のインターンはちょうど2週間という短い期間で、 Rootless コンテナについてほとんど知らない状態から始まり、 須田さんが開発されている bypass4netns の PoC コードの読解、Go言語への移植、 各種syscallへの対応、 nerdctl への統合パッチ作成、そして最後に全成果を upstream へ merge してもらうことができ、 非常に充実し、なおかつ自分が面白いと思うものを発見することができたインターン期間でした。

インターン期間中は須田さんをはじめとするNTT研究所の関係者の方々には日々のミーティングや相談、 コードレビューなどで大変お世話になりました。本当にありがとうございました。

以上で、本記事を締めさせていただきます。最後まで読んでくださり、ありがとうございました。

--

--