はじめに
docker runと打った瞬間にすごい勢いでいろいろ動き出すのが魔法にしか見えず、Docker ってずっと怖くて苦手意識がぬぐえません。
ここでは、コンテナ技術がどういう仕組みで動いているのか、それを支える namespace と cgroup という代表的な 2 つの技術に重点を置いて調べたことをまとめました。
認識違いがあればぜひ教えてください。
※ 本記事では、Linux カーネル上で動作する「一般的な Linux コンテナとしての Docker」を対象としています。Windows コンテナや WebAssembly(WASM)ランタイムは含みません。
※ 内部機構にフォーカスしているため、Docker の使い方、Tips などの実用性は含みません。
Docker とは
コンテナ隔離環境によりアプリケーションを開発・配置・実行できるプラットフォームです。
もう少し仕組みよりに説明すると、ざっくり、Linuxカーネルが持つnamespace(見える世界を分離する仕組み)と cgroup(使える資源を制御する仕組み)を組み合わせて、ホストとは独立した小さな環境(コンテナ)を作り出す技術です。
さらに実運用では capabilities / seccomp / LSM(AppArmor/SELinux) なども併用し、安全に隔離・実行できるようになっています。
🌍 世界を分ける「namespace」と、使える資源を制御する「cgroup」
namespace (名前空間)とは
namespace は、プロセスが “見える世界” を切り替えます。
namespace という言葉は、たとえば C# などで クラスやメソッドの名前が衝突しないよう、論理的な空間を区切る仕組みでも使われますが、Linux における namespace は論理的な識別子だけでなく、実際のリソース(PID、ネットワーク、ファイルシステムのマウントポイントなど) にまで及びます。
ちなみに、「名前空間」というと、隔離した namespace にラベルを付けたくなったりしますが、Linux の namespace ではラベルは付けず自動で割り振られた ID で見分けます。
namespace という機能は単体で存在するわけでなく、独立させたいリソースごとに分かれています。
たとえば、PID namespace ではその空間に属するプロセス一覧しか見えず、network namespace では独立したネットワークスタックを持つことができます。
以下は主要な namespace です(man unshareより抜粋):
mount, UTS, IPC, network, PID, cgroup, user, time
cgroup とは
※ cgroup には v1 と v2 がありますが、ここでは v2 前提で記載します。
メモリや CPU、I/O などのリソースに関して制限をするカーネル機能です。cgroup を使用することにより、システムリソースの割り当て、優先順位付け、拒否、管理、監視を細かく制御できます。
cgroup で制御するリソースはコントローラー(もしくはサブシステム)と呼ばれ、memory、cpu、io などがあります。
コンテナ という隔離空間は上記 2 つの技術の組み合わせで作ることができます。
なお、namespace も cgroup も、“コンテナを作るため” に生まれたわけではなく、Linux の汎用的なカーネル機能です。
👪 コンテナ内でのプロセスのふるまい
では、名前空間で区切られたコンテナの中は、ホストと同じような小さな ”世界” になっているのでしょうか?ここではホストとコンテナ内の違いについて見ていきます。
特にこの章では、namespace の中でも、プロセスの見え方に直結する PID namespace と、コンテナ内のユーザー権限に関わる user namespace にフォーカスします。
どちらもコンテナの振る舞いやセキュリティにおいてとても重要な役割を持っており、他の namespaceとは少し性質が異なります。
名前空間は入れ子
namespace で区切ることで、ホストから世界を切り離すことができると述べましたが、コンテナ作成等のために意図的に隔離しなくても ”デフォルトの名前空間” というものが最初から存在します。
Linux では PID=1 から始まって様々なプロセスが作られますが、何も指定しなければデフォルトの名前空間を利用し、子プロセスは親プロセスから引き継いだ名前空間を利用します。
なお、このデフォルトの名前空間は俗に init namespace と呼ばれます。コンテナが作られるときには、ここから分岐する形で内部に新しい名前空間が生成され、まるで入れ子構造のように重なっていきます。
つまり、コンテナの中に入ってさらに名前空間を切ることで、”マトリョーシカ” を作ることも可能です。(兄弟にもできるので、ツリー構造といったほうが正確かも)
親と子の非対称性
上述の通り、名前空間の構造は入れ子です。ここで留意すべきは、子は親を超えられませんが、親は子を見通せます。
たとえば、init namespace にいるホストは、すべてのコンテナ内プロセス(子のPID namespace)を ps aux などで確認できます。
一方、コンテナ内からは、ホストのプロセスは見えません。これはセキュリティ・隔離・可観測性の観点で非常に重要な性質です。
※ 観測や操作は権限に依存します。また、“見える=何でもできる” ではありません。
以下は、同じプロセスの PID がコンテナ内とホストでは異なって見える例です
◆ コンテナ内(PID namespace の中)
root@container:/# ps -o pid,ppid,cmd
PID PPID CMD
1 0 /bin/bash
7 1 ps -o pid,ppid,cmd
◆ ホストから見た同じコンテナのプロセス
$ docker top debian-ps -eo pid,ppid,cmd
PID PPID CMD
7299 7275 /bin/bash
PID namespace の中の PID=1
上記の出力例にもありますが、コンテナの中に入ると、自分の PID が 1 になっていることがあります。
これは、新しい PID 名前空間の中で最初に作られたプロセスであることを意味します。
Linux では、PID=1 のプロセスには特別な責任が課されています。
Linux のプロセス管理では、子プロセスが終了したあと、その終了ステータスを親が wait() で回収しないと、カーネル内に情報が残り続けて「ゾンビ」になります。
子が終了しても 親が生きていれば、その親が責任を持って回収(wait)します。
――― でも、親が先に死んでいたら?
その場合、カーネルが自動的に「新しい親」として PID=1 に引き取らせます。これを re-parenting(リペアレンティング) と呼びます。
PID 名前空間の PID=1 が終了すると、同じ名前空間の他プロセスは一斉に SIGKILL を受けて掃除されます。
たとえば、以下のような構成を考えます:
PID=1 ← init / sleep / tini(PID namespace の最初のプロセス)
└── PID=14 ← script.sh を実行しているプロセス
└── PID=15 ← script.sh の中でバックグラウンド実行された子プロセス
PID=14 が script.sh を実行し、その中で別のプロセス(PID=15) をバックグラウンドで起動。その後、PID=14 がすぐに exit してしまうと…
→ PID=15 の PPID (親プロセスID) は 1 に変わる
このとき、PID=1 が wait() を呼び出さなければ、PID=15 はゾンビ状態で残り続けます。
init namespace に属する PID=1 が wait() を呼び出さないということは起こり得ませんが、コンテナ内で PID =1 を割り振られたプロセスが、wait() を呼び出すかどうかは実装次第です。
Docker における PID=1 問題と Tini
PID=1 に求められる役割をコンテナ内でも適切に実行させるには、アプリケーション自身が signal handling や wait() を正しく実装している必要があります。
また、PID=1 は多くのシグナルで既定動作が無視されます。適切にハンドラを書いていないと SIGTERM が届いても終了しないことがあります。
それが難しいときの選択肢が --init オプションです。
Docker はデフォルトで tini という軽量の init プロセスを使えるようになっていて、これを有効化すると、まず最初に Tini が PID=1 として立ち上がります。
Tini は「ゾンビプロセスの回収」や「シグナルハンドリング」といった、PID=1 の重要な責務を果たしてくれます。そして、CMD や ENTRYPOINT で指定したコマンドは Tini の子プロセスとして起動されるようになります。
Docker の user namespace はデフォルトでオフ
無効(デフォルト)の場合
コンテナ内の UID=0(root)は、そのままホストの UID=0 となります。
namespace のおかげで「見える世界」(PID, mount, network など)は狭められていますが、root である以上、境界が崩れたらホストを直接操作できる危険があります。
例:--privileged オプション、誤ったマウントなどがあるとホスト側を root 権限で操作可能に。
有効の場合
user namespace をオンにすると、コンテナ内の root は ホスト上の一般ユーザーにマッピングされます。
/etc/subuid /etc/subgid で定義された範囲を uid_map / gid_map に割り当て、コンテナ root → ホスト UID 100000 のようにマッピングされます。
コンテナ内では root に見えますが、ホストから見ればただの非特権ユーザーとなります。
また、現実には存在しないユーザーにマッピングすることも可能であり、そうすることで、コンテナ内部からの権限昇格による攻撃を防ぐことができます。
ただし、デバイスアクセスなど一部機能に制限が生じることがあります。
おわりに
ここでは namespace と cgroup という、Docker を支える代表的なカーネル機能に絞って見てきました。
ただ実際には、Docker の裏側ではもう少し複雑な仕組みが動いています。
たとえば、コンテナの起動には汎用の高レベルコンテナランタイム( containerd )や低レベルコンテナランタイム( runc )という別の実行エンジンが関わっていて、プロセス空間の分離や、新たなコンテナの立ち上げは runcが担っています。
このように、Docker は単に namespace や cgroup を直接叩いているわけではなく、複数のレイヤを通じてコンテナを起動しています。
こうした仕組みについては、私自身まだ理解の途中で、今回はその詳細までは踏み込めていません。
今後は runc や containerd の役割や、system call レベルでどのようにプロセス空間が構築されるのか、さらに深掘りしていきたいと思っています。