仮想マシンからコンテナへ — 何を捨てて何を得たのか
一言でいうと
コンテナは「軽量な仮想マシン」ではありません。カーネルを共有したまま、Linuxの2つの機能(namespaceとcgroup)でプロセスを閉じ込めたものです。そのため高速な代わりに、分離の強さはVMより弱くなります。この一文がKCNAの全範囲の根っこです。
なぜ必要なのか
仮想マシンはハードウェアを模倣します。ゲストOSがもう1つ丸ごと起動し、起動に数十秒かかり、カーネルイメージとシステムファイルがディスクとメモリを消費します。サービス1つをデプロイするために、オペレーティングシステム1つを一緒にデプロイしていたようなものです。
問題は「重さ」ではなく、デプロイの単位と実行の単位がずれていることでした。開発者が作ったのはアプリケーションなのに、運用に渡すのはVMイメージでした。その間に「私のノートPCでは動きます」が生まれます。
コンテナは問いを逆にしました。カーネルはすでにホストにあるのでそのまま使い、アプリケーションから見える世界だけを変えれば済むのではないか、と。その「見える世界」を変えるカーネル機能は、すでに存在していました。
どう動くのか
namespace: 何が見えるか
| namespace | 隠すもの | コンテナで体感される姿 |
|---|---|---|
| PID | プロセス一覧 | 自分のプロセスがPID 1に見えます |
| NET | ネットワークインターフェース・ポート | コンテナごとに独立したIPとポート空間 |
| MNT | マウントツリー | イメージのファイルシステムがルートに見えます |
| UTS | ホスト名 | コンテナ名がホスト名になります |
| IPC | 共有メモリ・セマフォ | 他のコンテナのIPCが見えません |
| USER | UID/GIDのマッピング | 中ではroot、外では非特権ユーザー |
cgroup: どれだけ使えるか
namespaceが「視野」を切り分けるなら、cgroupは「取り分」を切り分けます。CPU時間、メモリの上限、ブロックIO、PIDの数をグループ単位で制限します。メモリの上限を超えると、カーネルのOOM killerがそのcgroup内のプロセスを強制終了します。Kubernetesのresources.limits.memoryは、最終的にこのcgroupの値に落ちていきます。
どちらか一方だけではコンテナになりません。 namespaceだけだと隣のコンテナがメモリを使い尽くして巻き添えで落ち、cgroupだけだとお互いのプロセスやファイルが丸見えになります。
OCI: 標準を握っているのは誰か
初期は「コンテナ = Docker」でした。1つのベンダーが形式と実行方式をすべて握っていると、エコシステムが育たないため、OCI(Open Container Initiative)が3つを規格として切り出しました。
- Image Spec: レイヤーとマニフェストの形式です。だから
docker buildで作ったイメージが、どのランタイムでも動きます。 - Runtime Spec: 展開済みのファイルシステム(rootfs)と
config.jsonを受け取ってプロセスを起動する規約です。 - Distribution Spec: レジストリとやり取りするHTTP APIです。
ランタイムは2層構造
kubelet --(CRI/gRPC)--> containerd --(OCI Runtime Spec)--> runc --> 리눅스 커널
containerdは高レベルランタイムです。イメージを取得して展開し、スナップショットを管理し、ライフサイクルを追跡します。
runcは低レベルランタイムです。config.jsonを読んでnamespaceとcgroupを作り、プロセスをexecしたらすぐに消えます。コンテナが動いている間、runcのプロセスは存在しません。
containerdの設計原則は4つにまとめられます。シンプルさ(1つのことをうまくやる)、プラグインベース、OCI互換、gRPC APIです。すべての機能がプラグインなので、スナップショッターをoverlayfsからdevmapperへ差し替えることもできます。
12-factor: コンテナに載せやすいアプリの形
12-factorはコンテナより先に登場しましたが、今では事実上「コンテナでうまく回るアプリの条件」として読まれています。KCNAで頻出する項目は3つです。
- 設定は環境から: イメージはどの環境でも同じであるべきで、違うのは注入される値だけにします。
- プロセスはステートレス: 状態は接続されたバックエンドサービスに置き、コンテナはいつ落ちてもよい状態にします。
- 使い捨て可能性(disposability): 素早く起動し、SIGTERMを受け取ったらおとなしく後始末をして終了します。
現場での姿
筆者のホームラボのクラスターは、もともとランタイムとしてcri-dockerdを使っていました。クラスターを作り直す際にcontainerd 1.7.27へ移行しましたが、これは好みの問題ではなく、階層を1つ取り除いたということです。cri-dockerd経由ではkubelet → cri-dockerd → dockerd → containerd → runcと、途中に中継が2つ余分にありました。containerdを直接使えばkubelet → containerd → runcで完結します。同じ仕事をするのに、プロセスが2つ減ります。
もう1つ。そのホームラボは再構築のあと、カーネルを6.14にそろえました。コンテナがカーネルを共有するという事実が運用で実際に意味するのは、これです。ノードのカーネルバージョンが、そのままコンテナの使える機能の上限になります。eBPFベースのネットワーキングのようにカーネル機能に頼るスタックは、カーネルが古いとそもそも有効になりません。
続けて読むこと
このモジュールにはラボがありません。次の読み物でkubeletとcontainerdの間の通訳であるCRIを見て、そのあとのモジュールで本物のクラスターに接続し、kubectl api-resourcesから叩いてみます。