インターンレポート: コンテナランタイムの実装と評価
こんにちは。インターン生の富田祐永です。普段は情報系の研究室で超伝導量子コンピュータの研究をしています。
2022年1月から約1ヶ月間、NTT 研究所で「コンテナランタイムの実装及び評価」というテーマのインターンに参加させていただいておりました。
インターン参加経緯
改めて軽く自己紹介をさせていただきます。
私は今は修士1年の院生なのですが、実は学部時代は情報系ではなく経済学部にいました。また、趣味で何かやっていたというわけでもなかったため、特に低レイヤに関しての実装経験はほぼ皆無です。
院に入ってからも、研究の傍らでちまちまCのコンパイラを書くくらいしか余裕がなく、何か面白いものを集中して実装できる期間が欲しいなあと思っていました。そのため、今回のテーマの募集を見つけた瞬間「これだ!」となって即応募しました。
取り組んだテーマ
さて、自分が取り組んだのは「containerd-shim-runc(-v2) を Rust で再実装する」というテーマです。
containerd-shim は、高レベルランタイムである containerd と 低レベルランタイムである runc の間にいるプロセスで、コンテナごとに起動されるものになります。runc は Linux kernel とやりとりして実際にリソースの隔離を実現するわけですが、shim はこの runc のcreate, start, kill, delete
といったコマンドを直接呼び出してコンテナのライフサイクルを管理する役割を担っています。shim から containerd には、自分が担当しているコンテナがどんな状態(CREATED, RUNNING, PAUSED, STOPPED
)にあるかといった情報を伝えたり、コンテナのプロセスが exit するまで wait した上でその時刻と exit status を伝えたりします。
ここで、 containerd と shim は ttrpc という省機能版 gRPC のような RPC で通信しています。gRPC 同様に protobuf 定義になっているため、対応する rpc call をサポートして ttrpc で通信できれば、実質どんなプロセスでも呼び出すことが可能です。
shim の起動後、containerd はより上位のコンポーネント(Docker や Kubernetes 等)からのリクエストに従って shim を ttrpc call し、 runc を呼び出していくことになります。
もし shim がなかったとすると、低レベルランタイムと高レベルランタイムが密結合した状態になってしまいます。例えば containerd 本体の中に runc を直接叩く処理が必要になりますし、runc に渡しやすい形で IO, namespace, cgroup などなどを準備する処理が入ることになります。すると、何かの理由で他の低レベルランタイム(Kata Containers など)を利用したいとなった時に、containerd の一部を書き直し、その改造版 containerd を継続的にメンテナンスすることになります。勝手に書き直していた部分が本家 containerd のアップデートで色々変わりましたなんてことになると付いていくのも大変かもしれません。また、containerd のやることが増えれば増えるほど、どこかでクラッシュが起きた時に影響が及ぶ範囲が広くなってしまいます。マイクロサービスを支えるコンテナ技術のランタイムがモノリシック、なんてことになります。
そこで、containerd は ttrpc call としてランタイム操作の API を定義するだけに留め、その API を備えていればどんなランタイムでも呼べる柔軟性を確保し、プラガブルなシステムを実現しています。低レベルランタイム側は勝手に好きな機能を追加出来ますし、containerd 側とは(ttrpc の protobuf 定義が変わらない限り)独立したメンテナンスが可能で、本来提供したい機能に集中できます。containerd は Go で実装されるソフトウェアですが、shim や低レベルランタイムは他の言語で実装しても問題ありません(protobuf の codegen が対応していない言語だとかなり大変だと思いますが)。
そう、他の言語で実装してもいいんです。ということで、テーマの話に戻りますが、今回は containerd のデフォルト、 runc 用の shim である containerd-shim-runc-v2 を Rust で実装しようということでした。
主に期待することは以下の3つです。
- バイナリサイズの削減
- メモリ使用量の削減
- 起動時間(より上位のランタイムを叩いてから、shim の内部で runc コマンドが実行されるまでの)時間の短縮
Rust なので速さだ!といいたいところですが、実は3つめの起動時間はそんなに困っていないらしいです。
どちらかというと GC などが含まれて大きいバイナリを吐きがちな Go で書かれた shim を他言語で実装するとサイズがどの程度減るのか、また、GC を使わない言語で実装した場合に(特に Rust ではスコープを外れた時点で基本的にはオブジェクトが解放されます)どれくらい省メモリになるのかという部分に興味があるということですね。
実装したもの
今回の実装(の一部)はこちらの Pull Request から参照できます。
shim の API の他に runc のクライアントライブラリ go-runc に対応する crate も実装しました。こちらはかなり前にアーカイブされてしまっていますが既存の crate があったため、これを参考に実装し直しています。
また、 containerd でプロセスをよしなに扱うためのパッケージ process など、containerd のコンポーネントとして用意されているパッケージのうち shim を動かすのに必要なものについても一部実装しました。
実は、同様に IO 周りをセットアップしてあげるパッケージも実装する必要があったのですが、うまく動かせなかったため断念しました。ですので、残念ながら今回実装したものではコンテナ内プロセスの stdout/stderr を得られません。
反省点としては、結構 Go 側の実装に寄せて書こうとしてしまったことが挙げられると思っています。例えば、IO 用コンポーネントの fifo.go は生成時にこんなことをしています。
f := &fifo {
// omit
}
go func() {
// omit
file, err := os.OpenFile(fn, flag, 0) // fn is "filename"
// omit
f.file = file
close(f.opened)
}
return f
これは goroutine の上で色々チェックした後にいつのまにか fifo
にファイルがセットされているというコードですが、Rust でこんなことをしようとしたら当然 f
が 'static
ライフタイムを持たないといってコンパイラに怒られます。一応、例えばこの構造体に Option<File>
と Receiver<File>
を持たせて
impl Fifo {
pub fn new(...) -> Self {
// omit
let (rx, tx) = oneshot::channel::<File>();
tokio::spawn(async move {
let f = File::open("path").unwrap();
tx.send(f).unwrap();
}
// omit
Fifo {
file: None,
recv: rx,
// omit
}
}
}
として AsyncWrite
や AsyncRead
の impl
で File
をチェックする、みたいにすればできないことはないんですが果たしてそれでいいのか...(そもそもここの Fifo
のコンストラクタ自体が async
でかつ tokio::spawn
内で呼ばれる前提なら別にわざわざもう一度 spawn しなくとも tokio::fs::open
で開いて await
すればいいような気もします)
go func(wc io.WriteCloser) {
// omit
f, err := fifo.OpenFifo(...)
// omit
_, err := io.CopyBuffer(runcio.Stdin(), f)
}
go func(wc io.WriteCloser) {
// omit
_, err := io.CopyBuffer(wc, runcio.Stdout())
}
go func(wc io.WriteCloser) {
// omit
_, err := io.CopyBuffer(wc, runcio.Stderr())
}
これも Rust ではできません。しかも runcio
は実際には interface
で定義されていて、実装詳細を変えたいことまで考えると、愚直に Rust で書けば
let _io: Arc<dyn Io> = io.clone();
task = |...| async move {
// omit
tokio::io::copy(&mut reader, &mut _io.stdout())
}
雰囲気としてはこんな形になりそうです。ただし、stdout()
は File
なり Option<File>
を返すものだとします(説明のため unwrap
は省いています)。
しかし、これでは io
の持つ File
の所有権をもらうにせよ参照をもらうにせよ io
そのものが mutable である必要があります。これはもちろん Arc<Mutex<dyn Io>>
として中の Option<File>
から take()
してきてもいいんですが(普通の File
にして &mut File
を取ろうとしたりするとどこかのスレッドが Mutex
をロックし続けることになるので File
の所有権ごと奪う必要があります)、そうするとどこかのスレッドが stdin/out/err のどれかにアクセスするたびに他のスレッドは待たされます。一方、今回の用途的に同じエントリ(例えば stdout の read end)に複数スレッドが別々にアクセスすることはないことを知っています。ですので、今回の実装ではこんな感じにしてみました。
struct Pipe {
rd: Mutex<Option<File>>,
wr: Mutex<Option<File>>
}
struct PipedIo {
stdin: Option<Pipe>,
stdout: Option<Pipe>,
stderr: Option<Pipe>,
}
impl Io for PipedIo {
fn stdin(&self) -> Option<File> {
if let Some(ref stdin) = self.stdin {
let mut m = stdin.wr.lock().unwrap();
m.take()
} else {
None
}
}
}
これで、immutable な PipedIo as Arc<dyn Io>
から stdin()
などの呼び出しで欲しいファイルを得られました。
ちなみに、これだけ見ると「いや File
の try_clone()
使えばファイルの close とかも勝手にやってくれるしいいじゃん」という話なんですが、この PipedIo
の持つ6つの fd の一部だけこちらの指定したタイミングで閉じたい(上記同様に immutable な状態で)というシチュエーションがあって、このような形になってしまいました。
また、Pipe
の対応する rd
ないし wr
が None
でもロックを取らないといけないというのは気に入りません。一応今回のユースケースだとそういったことは起きない想定なのでいいんですが…
こんな感じで一応(現時点でちゃんとは動いてないのですが) IO 処理用の土台は作っておきました。今後の課題としてもう少し色々とベスプラを勉強しつつ、改善していきたいと思います。
評価結果
バイナリサイズと nginx:alpine
イメージで http サーバを立てた時のメモリ使用量を比較しました。今回は Rust 側で実装できた機能に合わせて Go 側の実装をコメントアウトで削った上で比較しています。
バイナリサイズは以下のようになりました。
strip 後で比較して、 Go 版は 9.2MB なのに対し、Rust 版は 2.6MB で済んでいます。(Go 版も strip していますがサイズはほとんど変化していません)
また、メモリ使用量は以下のコマンドでコンテナを起動し、VmRSS
で比較しました。
sudo ctr run -d --rm --runtime <shim-binary> --net-host docker.io/library/nginx:alpine dummy
起動直後から5分後までの計測を4回ずつ行い、以下のような結果になりました。
+------+-------------+-------------+-------------+-------------+
| lang | 1st | 2nd | 3rd | 4th |
+------+-------------+-------------+-------------+-------------+
| Rust | 1436KB | 2228KB | 1460KB | 1476KB |
+------+-------------+-------------+-------------+-------------+
| Go | 7692-8504KB | 8128-9812KB | 7776-9732KB | 8480-9916KB |
+------+-------------+-------------+-------------+-------------+
また、IO 周りの実装がまだと書きましたが、一応不安定ながら動くものが最終日にできたので、そちらの計測結果も参考として載せておきます。
+------+-------------+-------------+-------------+-------------+
| lang | 1st | 2nd | 3rd | 4th |
+------+-------------+-------------+-------------+-------------+
| Rust | 2492KB | 2556KB | 2440KB | 2436KB |
+------+-------------+-------------+-------------+-------------+
こちらは IO 処理のスレッド(tokio の task)を立てていて上記のものよりメモリ使用量が増えていますが、より公平な比較にはなると思います。
Rust 版は Go 版に比べてフットプリントが1/3程度まで減っていました。http サーバを立てるしかしていないかつ機能も制限されているとは言え、コンテナごとに必要なプロセスのフットプリントがこれだけ減れば、1つのマシンでコンテナが大量に起動される場合には嬉しいかもしれません。
インターンを終えての感想
1ヶ月があっという間に感じました。OSS のコードをまともに読み込むのも初めて、かつ Go にも触ったことがないということで、慣れない部分もあり正直大変でした。しかし、非常に勉強になりましたし、朝から晩までコードと向き合っていられるというのは楽しかったです。初心者丸出しみたいな質問を何度もしてしまいましたが、詰まっている原因を的確に指摘していただいたり、便利 tips を教えていただいたりして着実に前に進むことができました。ありがとうございました!
今回は進捗の都合で shim に付きっきりでしたが、今後は containerd 本体や runc への理解も深めていきたいです。