コンテナを自作する
Dockerに頼らずnamespaceとchrootとcgroupを直に組み、プロセスを隔離する最小コンテナを数十行で作れる。なぜコンテナがVMより軽いのかを、カーネル機能の実物から腹落ちさせる。
- コンテナはclone(2)にCLONE_NEWPID/NEWNS/NEWUTS等のフラグを渡して名前空間を分離し、chrootでルートを差し替え、cgroupで資源を制限しただけの「ただのプロセス」である。
- VMがゲストカーネルとハードウェア仮想化を丸ごと積むのに対し、コンテナはホストのカーネルを共有し追加の命令変換がゼロなので、起動が速くメモリも軽い。
- 最小実装で省いたネットワーク名前空間・overlayfsによるイメージ層・seccomp/capability制限が、本物のランタイムを「使える隔離」に仕上げている。
何を作るか
作るのは「1つのプロセスを、あたかも専用マシンの中で動いているかのように隔離する」最小コンテナです。具体的には、コンテナ内でホストとは別のプロセスIDツリー(コンテナ内では自分がPID 1に見える)、独立したファイルシステムのルート、専用のホスト名を持たせ、さらに使えるメモリとCPUに上限をかけます。docker runが数百ミリ秒でやってくれるあの隔離を、clone(2)・mount(2)・chroot(2)・cgroupファイルへの書き込みだけで再現します。
作ると分かるのは、コンテナに「コンテナという実体」は無いという事実です。コンテナとは、Linuxカーネルが個別に提供する複数の隔離機能(名前空間・cgroup・ルート差し替え)を1つのプロセスに束ねて適用した状態の呼び名にすぎません。この視点を持つと、なぜコンテナが仮想マシンより桁違いに軽いのか、なぜコンテナ同士でカーネルの脆弱性を共有してしまうのか、といった実務上の勘所が一本の線でつながります。カーネル側の詳細はOSトピックが土台になります。
最小実装の全体像
横にスクロール
組み立てるパーツは3つで、それぞれ独立した役割を持ちます。
| パーツ | カーネル機能 | 何を隔離/制限するか |
|---|---|---|
| 名前空間 (namespace) | clone(2)のフラグ | プロセス・マウント・ホスト名・ネットワーク等の「見え方」 |
| ルート差し替え | pivot_root / chroot(2) | ファイルシステムのルート(何が見えるか) |
| 資源制限 (cgroup) | cgroup v2 の階層 | メモリ・CPU・PID数などの「使える量」 |
流れはこうです。親プロセスがclone(2)を各種フラグ付きで呼ぶと、指定した名前空間を新規作成した子プロセスが生まれます。子プロセスは、用意しておいたrootfs(最小のディレクトリ群)へルートを差し替え、そこで目的のコマンド(例: シェル)をexecします。並行して、cgroupにこの子プロセスを登録し、メモリとCPUの上限を書き込みます。名前空間が「隔離(isolation)」を、cgroupが「制限(limit)」を担当し、両者は直交する別機構だという点が最重要です。名前空間とcgroupの内部実装はnamespaceとcgroupで詳しく扱っています。
段階を追って作る
ステップ1: clone(2)で名前空間を分ける
通常のfork()は親と同じ名前空間を引き継ぎます。clone(2)にCLONE_NEW*系フラグを渡すと、その種類の名前空間だけを新規作成した子が生まれます。主要フラグは次の通りです。
| フラグ | 作る名前空間 | 効果 |
|---|---|---|
| CLONE_NEWPID | PID | コンテナ内で自分がPID 1に見える |
| CLONE_NEWNS | mount | マウント操作がホストへ漏れない |
| CLONE_NEWUTS | UTS | ホスト名を独立に変更できる |
| CLONE_NEWNET | network | 専用のネットワークスタックを持つ |
| CLONE_NEWUSER | user | コンテナ内のrootをホストの非rootへ対応付け |
擬似コードでは次のように、フラグをORで束ねて子を起動します。CLONE_NEWPID を付けた子から見ると、自分のPIDは1になります。ここで一点注意があり、CLONE_NEWPID だけではpsや/procはまだホストのプロセスを見せます。PID名前空間はカーネル内部の番号付けを分けるだけで、/procの中身は次のステップでprocを新規マウントして初めて隔離されます。
int flags = CLONE_NEWPID | CLONE_NEWNS | CLONE_NEWUTS | SIGCHLD;
pid_t child = clone(child_fn, stack_top, flags, arg);
// child_fn の中では getpid() == 1 に見える
コンテナ内のPID 1は、通常のカーネルが担うプロセス回収(親を失った孤児プロセスのreap)を肩代わりします。PID 1がゾンビを回収しないとコンテナ内にゾンビが溜まります。本物のランタイムがtiniなどの軽量initを噛ませるのはこのためです。ゾンビ回収の原理はプロセスとスレッド側の基礎が効いてきます。
ステップ2: rootfsを用意してルートを差し替える
コンテナに「別マシンらしさ」を与えるのがルート差し替えです。まず、コマンド実行に必要な最小のディレクトリ群(/bin、/lib、/proc、/sys などの受け皿)を1つのディレクトリツリーとして用意します。これがrootfsです。alpineのような最小ディストリのファイル群を展開すれば数MBで揃います。
子プロセスの中で、UTS名前空間を使ってホスト名を設定し、procを新規マウントし、ルートを差し替えます。chrootは簡便ですが抜け出す既知の手口があるため、本番実装はpivot_rootで旧ルートを切り離します。ここではまずchrootで原理を押さえます。
sethostname("mycontainer") # UTS名前空間なのでホストに影響しない
mount("proc", "/newroot/proc", "proc") # このPID名前空間のプロセスだけが見える /proc
chroot("/newroot") # ルートを差し替え
chdir("/") # カレントもルート直下へ
exec("/bin/sh") # 隔離された空間でシェルを起動
procを名前空間内で新規マウントし直すのが鍵です。これでpsはコンテナ内のプロセスだけを表示し、PID名前空間の隔離が体感できる形で完成します。マウントの伝播(propagation)を止めておかないとホストへマウントが漏れるため、実装では事前にルートマウントをprivateにしておきます。ファイルシステム側の詳細はOSのマウント関連が背景です。
ステップ3: cgroupでメモリとCPUを制限する
ここまでは「見え方」の隔離で、資源は無制限に使えてしまいます。制限を担うのがcgroup(control group)です。cgroup v2ではファイルシステムのように階層を作り、制御ファイルへ値を書き込むだけで上限を設定できます。
# コンテナ用のグループを作る
mkdir /sys/fs/cgroup/mycontainer
# メモリ上限を100MiBに
echo 104857600 > /sys/fs/cgroup/mycontainer/memory.max
# CPUを50%に(100msの周期のうち50msまで)
echo "50000 100000" > /sys/fs/cgroup/mycontainer/cpu.max
# 対象プロセスを登録(このPIDと子孫が制限対象になる)
echo <PID> > /sys/fs/cgroup/mycontainer/cgroup.procs
memory.maxを超えるとそのグループ内でOOM killerが発動し、超過プロセスが停止します(ホスト全体は巻き込まれません)。cpu.maxの"50000 100000"は「100ms(100000マイクロ秒)ごとに最大50ms(50000マイクロ秒)だけCPUを使える」という帯域制限で、実効50%になります。名前空間と違い、cgroupはcloneのフラグではなく「プロセスをグループのファイルへ登録する」という別経路で効く点に注意してください。cgroupによる資源制御の内部はnamespaceとcgroupを参照してください。
名前空間は「他が見えない」、cgroupは「使いすぎない」を担います。両者は独立なので、名前空間だけ分けてcgroupを付け忘れると、1つのコンテナがメモリを食い尽くしてホストごと巻き込む事故が起きます。「見え方」と「使える量」を必ずセットで設計するのが要点です。
発展と本物との違い
ここまでの数十行で「PIDとファイルとホスト名が隔離され、メモリとCPUに上限がかかったプロセス」ができました。しかし本物のコンテナランタイムとの差はまだ大きく、その差こそが「実運用の隔離」の中身です。
第一にネットワーク名前空間です。CLONE_NEWNETで作った直後のネットワーク名前空間はループバックしか持たず、外部と通信できません。実運用ではvethペア(仮想的なLANケーブルの両端)を作り、片端をコンテナの名前空間へ、もう片端をホストのブリッジへ挿し、NATやルーティングを設定して初めて通信できます。この配線がDockerのdocker0ブリッジの正体です。仮想ネットワークの原理はネットワークトピックが土台になります。
第二にイメージ層です。本実装はrootfsを丸ごとコピーしましたが、これでは容量も起動も重い。実運用はoverlayfs(重ね合わせファイルシステム)で、読み取り専用の共通イメージ層の上に、コンテナごとの薄い書き込み層を重ねます。ベースイメージを複数コンテナで共有でき、変更差分だけが書き込み層に溜まる(copy-on-write)ので、起動が速くディスクも節約できます。
第三にセキュリティです。最小実装のコンテナはホストのカーネルをそのまま叩けるため、危険なシステムコールも実行できてしまいます。本物はseccompでシステムコールを絞り、capabilityで特権を細分化して剥奪し、user名前空間でコンテナ内rootをホストの非rootに落とします。ここが、コンテナがVMより「隔離が甘い」と言われる核心です。
VMはゲストOSのカーネルを丸ごと起動し、ハードウェア仮想化層を介して命令やI/Oを変換します。対してコンテナはホストのカーネルをそのまま共有し、追加のカーネルも命令変換層も持ちません。コンテナの実体はホスト上の「ただのプロセス」に名前空間とcgroupを適用しただけなので、起動はプロセス生成と同等に速く、メモリのオーバーヘッドもほぼプロセス1個分です。裏返せば、カーネルを共有するがゆえにカーネルの脆弱性はコンテナ間で共有され、そこがVMとの隔離強度の差になります。
まとめると、コンテナとは「名前空間で隔離し、chroot/pivot_rootでルートを差し替え、cgroupで資源を制限したプロセス」に尽きます。Dockerが足しているのはイメージ配布・レイヤ管理・ネットワーク配線・セキュリティ制限といった実運用の装備であり、隔離の核はカーネルが提供するこの3機能です。より深いカーネルの仕組みはOS、仮想ネットワークはネットワークを合わせて読むと、コンテナ技術の全体像が立体的に見えてきます。
自作で学ぶの記事ガイド
コンテナを自作するを実務で読む
TL;DRは入口です。実際に選ぶ・使う段階では、何を解決するか、何と比較するか、導入後にどこで詰まるかまで見る必要があります。
解決すること
コンテナ
比較で見る軸
難易度: advanced / カテゴリ: 自作で学ぶ / タグ数: 6
導入後に効く点
VMがゲストカーネルとハードウェア仮想化を丸ごと積むのに対し、コンテナはホストのカーネルを共有し追加の命令変換がゼロなので、起動が速くメモリも軽い。
先に潰すリスク
用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。
- 難易度
- advanced
- カテゴリ
- 自作で学ぶ
- タグ数
- 6
判断チェックリスト
- 自社の用途が「コンテナ / Linux」に近いか確認する。
- 強みである「コンテナはclone(2)にCLONE_NEWPID/NEWNS/NEWUTS等のフラグを渡して名前空間を分離し、chrootでルートを差し替え、cgroupで資源を制限しただけの「ただのプロセス」である。」が本当に評価軸になるか確認する。
- 注意点の「用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。」を運用で吸収できるか確認する。
- 公開値や仕様値は、対象プラン・対象機種・対象リージョンまで確認する。
- 既存システム、ID、ネットワーク、監視、バックアップとの接続方法を先に洗い出す。
- 小さく試してから、本番移行、権限設計、障害時手順、コスト監視を決める。