🍵

containerd の CLI 実装を読む

2024/05/17に公開


Photo by Johannes Plenio

はじめに

この記事では containerd の CLI がどのように実装されているか、そのコードを大まかに読んでいきます。内容についてはサービスの初期化やシグナルハンドリングなどに軽く触れる形で構成しています。

対象読者

  • OSS のコードを読んでみたい方
  • Go に興味を持っている方

リポジトリ

https://github.com/containerd/containerd

留意点

  • containerd のリポジトリには複数のバイナリが含まれています(バイナリのビルド手順は同梱の BUILDIND.md に記載されている)。今回は「containerd」の CLI 実装を読んでいきます。
  • タグは v1.7.16 を参照します。

エントリーポイント

containerd は CLI を実装するために urfave / cli を採用しています。command.App 関数は次のとおり、そのインスタンスを返します。

https://github.com/containerd/containerd/blob/main/cmd/containerd/main.go#L37-L43

これ以降は command.App 関数の実装を中心に見ていきます。

CLI のセットアップ

https://github.com/containerd/containerd/blob/v1.7.16/cmd/containerd/command/main.go#L63-L291

  • 64 行目:cli.NewApp 関数で urfave / cli のインスタンスが生成され、続けてコマンドオプションやサブコマンドの登録などが行われます。
  • 108 行目:コマンドが実行する処理は無名関数として app.Action フィールドに代入します。このアクションは主にシグナルハンドラの登録と containerd に付随する各種サービス(プラグイン)の起動処理を実行します。

シグナルハンドラの登録

シグナルハンドラは handleSignals 関数で登録されます。

https://github.com/containerd/containerd/blob/v1.7.16/cmd/containerd/command/main.go#L158

handleSignals 関数の実装は次のとおりです。

https://github.com/containerd/containerd/blob/v1.7.16/cmd/containerd/command/main_unix.go#L38-L74

シグナルによる割り込みが発生すると done チャンネルが close される仕組みとなっています。ハンドラが補足するシグナルと、それぞれの挙動は次のとおりです。

SIGPIPE

このシグナルは無視されます( 背景については末尾の「余談」に記載 )。

SIGUSR1

dumpStacks 関数でスタックトレースをログとして出力します( ファイルに書き出すオプションもあり )。

その他

goroutine が参照している context を cancel し、さらに done チャンネルを close することで containerd の状態を終了に向かわせます。

containerd の起動処理を少し深掘る

複数サービスの起動を並行処理として実装する

containerd はプラグインという形で機能を拡張できるようになっています( 参考 )。ちなみにリポジトリに同梱されているファイルには次のとおりプラグインの設定が記述されています。

https://github.com/containerd/containerd/blob/v1.7.16/contrib/Dockerfile.test.d/cri-in-userns/etc_containerd_config.toml

このファイルは config オプションのデフォルト値となっています( 参考 )。

https://github.com/containerd/containerd/blob/v1.7.16/cmd/containerd/command/main.go#L83

ここからは設定ファイルではなくプラグインの初期化に関連するロジックを見ていきたいので、設定関連の深掘りは避けます。

containerd は Built-in プラグインを pkg ディレクトリに収めています。これらは次のように cmd/containerd/builtins ディレクトリのファイルでインポートされています。

https://github.com/containerd/containerd/blob/v1.7.16/cmd/containerd/builtins/cri.go

上記 cri パッケージの init 関数は次のとおりです。

https://github.com/containerd/containerd/blob/v1.7.16/pkg/cri/cri.go#L42-L57

InitFn というフィールドは plugin パッケージの Init 関数で呼び出されます( 132 行目 )。

https://github.com/containerd/containerd/blob/v1.7.16/plugin/plugin.go#L131-L140

Init 関数は server パッケージの New 関数で呼び出されます( 243 行目 )。

https://github.com/containerd/containerd/blob/v1.7.16/services/server/server.go#L109-L306

New 関数は command パッケージの App 関数から呼び出されます( 194 行目 )。

https://github.com/containerd/containerd/blob/v1.7.16/cmd/containerd/command/main.go#L63-L291

次のイメージは一連の関数呼び出しを図示したものです。

ここで再び cri パッケージを見てみます。特筆したいのは 115 行目です( initCRIService 関数内 )。plugin パッケージの InitContext 構造体が持つ RegisterReadiness フィールドを関数として呼び出しています( 115 行目 )。

https://github.com/containerd/containerd/blob/v1.7.16/pkg/cri/cri.go#L59-L124

このフィールドは次のとおり関数を代入できるように定義されています( 38 行目 )。

https://github.com/containerd/containerd/blob/v1.7.16/plugin/context.go#L31-L46

RegisterReadiness が参照する関数は server パッケージの New 関数内で Init 関数が呼び出される前に無名関数として定義されています。

https://github.com/containerd/containerd/blob/v1.7.16/services/server/server.go#L230-L233

この無名関数の内部では Server 構造体の RegisterReadiness メソッドが呼ばれます。このメソッドの定義は次のとおりです。

https://github.com/containerd/containerd/blob/v1.7.16/services/server/server.go#L417-L422

ここが特に注目したい部分です。ready フィールド( sync.WaitGroup 型 )の Add メソッドを呼び出して WaitGroup のカウンターをインクリメントしています。これは containerd の次のコードと関連しています。

https://github.com/containerd/containerd/blob/v1.7.16/cmd/containerd/command/main.go#L273-L287

273 行目で readyC チャンネルが作られ、続く goroutine からそれを参照します。さらに 279-287 行目の select ブロックは readyC チャンネルが close されるのを待っています。要するにここでは goroutine で起動されたサービスが利用可能となるのを待機しています(サービス起動後は 285 行目で done チャンネルが close されるのを待ちます)。ここで再び前述の initCRIService 関数を見てみます。

https://github.com/containerd/containerd/blob/v1.7.16/pkg/cri/cri.go#L115-L121

RegisterReadiness が参照する関数が返す関数が ready という変数に代入されています。この変数は 117 行目で Run 関数に渡り、サービスの初期化が完了した時点で呼び出され、WaitGroup のカウンタをデクリメントします。

ここまでの要点を簡単に整理すると次のようになる。

  • server パッケージの New 関数内で RegisterReadiness 関数が定義され、後続のinitCRIService メソッド( cri パッケージ )で呼び出される
  • 呼び出された RegisterReadiness 関数は WaitGroup のカウンターをインクリメントする
  • New 関数内の select ブロックは WaitGroup のカウンターが 0 になる(初期化の待機を要求する全てのサービスが起動している状態)まで待機する

以上のように WaitGroup と channel を使うことで、複数の goroutine の状態変化をトリガーとして処理を進めるロジックを記述できます。

あとがき

今回は触れなかったのですが containerd の CLI 実装にはコマンド引数やサブコマンドの定義、TOML ファイルから設定をロードする処理なども含まれているので、CLI を開発する上での基本となる要素を多くを学べると思います。

また、daemon に必要な最低限の機能以外はクライアント側に持たせる Smart Client Model アーキテクチャや プラグインでアプリケーションに拡張性を持たせる仕組み についても知ることができるため、興味があれば 付属ドキュメント も眺めてみてください 😉

参考資料

余談

containerd が SIGPIPE を無視する背景

関連する変更が含まれるコミットログを検索したところ、次の2つのプルリクエストに辿り着きました。

https://github.com/containerd/containerd/pull/1155

https://github.com/containerd/containerd/pull/4918

さらに前者の issue に記載されたリンクを辿っていったところ、どうやら journald がリスタートする際に SIGPIPE が発生して containerd がクラッシュする問題があったようです( 参考 )。それを回避するために containerd は SIGPIPE を無視しています。

また、後者はログを出力する前に SIGPIPE を捕捉して continue する変更となっています。SIGPIPE が発生した際にログ出力を行うと SIGPIPE が延々と発生して CPU 使用率が大きく上昇するとのことです。

Discussion