
Linuxコンテナの仕組み入門 - namespaceとcgroupsから理解するDocker
カーネルの挙動を実験で確かめられる定番書。
コンテナ運用の基礎を手を動かして学べる。
プロセスとリソース管理の実装を深く追える。
当サイトは Amazon.co.jp を宣伝しリンクすることで紹介料を得る手段を提供する、Amazonアソシエイト・プログラムの参加者です。価格・在庫はリンク先の最新情報をご確認ください。
docker runと打つと、数百ミリ秒で「別のOS」が立ち上がったように見えます。ホストとは違うホスト名、psを打てば自分しかいないプロセス一覧、/の下にはホストとまったく違うファイル群。この体験から「コンテナは軽量な仮想マシンだ」と説明されることがよくありますが、これは仕組みの理解としては正確ではありません。実際に起きているのは、ホストのカーネル上で動くごく普通のプロセスが、いくつかのカーネル機能によって「見えるもの」と「使えるもの」を制限されているだけです。この記事では、namespace・cgroups・capabilities・seccomp・overlayfsというカーネル側の部品を一つずつ積み上げ、最後にDockerのコマンドが裏で何をしているのかまでを整理します。
コンテナは軽量な仮想マシンではない
まず押さえておきたいのは、Linuxカーネルには「コンテナ」という単一の機能が存在しないという事実です。man 7 namespacesは冒頭でこう述べています。「namespaceはグローバルなシステムリソースを抽象化で包み、そのnamespace内のプロセスからは、そのグローバルリソースの隔離されたインスタンスを自分が持っているように見せる」。そして「namespaceの用途の一つがコンテナの実装である」と続きます。つまりコンテナは、カーネルが提供する汎用の隔離機能をユーザー空間のツールが組み合わせて作り上げた合成物です。
仮想マシンとの違いを整理すると次のようになります。
| 観点 | 仮想マシン(VM) | コンテナ |
|---|---|---|
| カーネル | ゲストごとに独立したカーネルが動く | ホストのカーネルを全コンテナで共有する |
| 隔離の実装 | ハイパーバイザによるハードウェア仮想化 | namespace/cgroups等のカーネル機能 |
| プロセスの実体 | ゲストOS内のプロセス。ホストからは見えない | ホストのpsに普通に見えるプロセス |
| 起動時間 | カーネルブートを伴うため相対的に長い | プロセス起動と同等 |
| 隔離の強度 | 攻撃面はハイパーバイザ境界 | 攻撃面はホストカーネルのシステムコール全面 |
| 別カーネルの利用 | 可能(WindowsゲストやLinuxの別バージョン) | 不可。ホストカーネルの機能に依存する |
この「ホストのプロセスである」という点は実際に確認できます。
docker run -d --name demo alpine sleep 3600
ps -eo pid,ppid,comm | grep sleep
# 例:
# 48213 48192 sleepコンテナ内でpsを打つとPID 1に見えるプロセスが、ホストからは48213のような普通のPIDで見えます。同じプロセスが、どのnamespaceから見るかによって違う番号で見えているだけです。プロセスとスレッドの区別があやふやなら、並行と並列・プロセスとスレッドを先に読むと以降の話が入りやすくなります。
namespace - 「見えるもの」を区切る
namespaceは、あるグローバルなリソースをプロセスごとに別インスタンスに見せるカーネル機能です。namespaces(7)が挙げているnamespaceは次の8種類です。各行の「導入」は、clone(2)およびunshare(2)のマニュアルに記載された当該フラグのバージョンです。
| namespace | フラグ | 隔離する対象 | 導入 |
|---|---|---|---|
| Mount | CLONE_NEWNS | マウントポイント | Linux 2.4.19 |
| UTS | CLONE_NEWUTS | ホスト名とNISドメイン名 | Linux 2.6.19 |
| IPC | CLONE_NEWIPC | System V IPC、POSIXメッセージキュー | Linux 2.6.19 |
| PID | CLONE_NEWPID | プロセスID | Linux 2.6.24 |
| Network | CLONE_NEWNET | ネットワークデバイス、スタック、ポート等 | Linux 2.6.24 |
| User | CLONE_NEWUSER | ユーザーIDとグループID | Linux 3.8で完成 |
| Cgroup | CLONE_NEWCGROUP | cgroupのルートディレクトリ | Linux 4.6 |
| Time | CLONE_NEWTIME | boot clockとmonotonic clock | Linux 5.6 |
Mount namespaceのフラグだけCLONE_NEWMOUNTではなくCLONE_NEWNSという名前なのは、これが最初に導入されたnamespaceで、当時は他の種類が想定されていなかったためです。mount_namespaces(7)のHISTORYには「Linux 2.4.19」とあり、コンテナという言葉が流行するはるか前から部品は少しずつ揃えられていたことが分かります。
User namespaceの経緯は少し複雑です。clone(2)は「このフラグがclone()にとって意味を持つようになったのはLinux 2.6.23、現在のclone()のセマンティクスがマージされたのはLinux 3.5、そしてuser namespaceを完全に使えるものにする最後のピースがマージされたのはLinux 3.8」と説明しています。この3.8という数字は重要で、namespaces(7)は「Linux 3.8以降、user namespaceの作成に特権は不要である」と明記しています。他の種類のnamespaceの作成には原則CAP_SYS_ADMINが必要ですが、user namespaceだけが例外で、これが後述するrootlessコンテナを可能にしています。
/proc/[pid]/ns で自分の所属を見る
プロセスがどのnamespaceに属しているかは、/proc/[pid]/ns/以下のシンボリックリンクで確認できます。
ls -l /proc/$$/ns | awk '{print $9, $10, $11}'
# 例:
# cgroup -> cgroup:[4026531835]
# ipc -> ipc:[4026531839]
# mnt -> mnt:[4026531840]
# net -> net:[4026531969]
# pid -> pid:[4026531836]
# user -> user:[4026531837]
# uts -> uts:[4026531838]
readlink /proc/$$/ns/uts
# uts:[4026531838]角括弧の中の数字はinode番号です。namespaces(7)は「2つのプロセスが同じnamespaceにいるなら、それらの/proc/[pid]/ns/xxxシンボリックリンクのデバイスIDとinode番号は同じになる」と述べています。つまりこの数字を比べるだけで同じnamespaceにいるかを判定でき、コンテナのプロセスとホストのシェルで見比べれば、隔離されているnamespaceだけ値が違うことが一目で分かります(Linux 3.7以前はハードリンク、3.8以降はシンボリックリンクとして見えます)。
unshare で自分の手で作ってみる
概念だけでは腹落ちしないので、unshare(1)で実際にnamespaceを作ってみます。まずはUTS namespaceです。
# 新しいUTS namespaceでシェルを起動し、ホスト名を変えてみる
sudo unshare --uts bash
hostname isolated-box
hostname
# isolated-box
exit
# 元のシェルに戻ると、ホスト側のホスト名は変わっていない
hostname
# original-hosthostnameコマンドはホスト名を書き換えますが、その効果はそのnamespaceの中だけに閉じます。次はPID namespaceで、unshare(1)のEXAMPLESに載っている例をそのまま実行できます。
sudo unshare --fork --pid --mount-proc readlink /proc/self
# 1readlink /proc/selfは自分自身のPIDを返すので、出力が1ということは、このプロセスが新しいPID namespaceでのPID 1になっているという意味です。--forkが必要なのは、unshare(2)のCLONE_NEWPIDが呼び出し元プロセス自身を新namespaceへ移さず、以降に作られる子プロセスを新namespaceに置く仕様だからです。--mount-procは同時に新しいmount namespaceを作り、そこにprocをマウントし直します。
# --mount-proc あり: 自分しか見えない
sudo unshare --fork --pid --mount-proc ps -ef
# UID PID PPID C STIME TTY TIME CMD
# root 1 0 0 10:00 pts/0 00:00:00 ps -ef
# --mount-proc なし: /proc がホストのままなので全プロセスが見えてしまう
sudo unshare --fork --pid ps -ef | wc -l
# 312この「PID namespaceを分けても/procを張り替えないとpsが正しく見えない」という点は、隔離が自動では完成せず、部品を一つずつ組み合わせる必要があることをよく表しています。
PID namespaceのPID 1には特別な意味があります。pid_namespaces(7)は次の2点を明記しています。
- PID namespaceのinitプロセスが終了すると、カーネルはSIGKILLでそのnamespace内の全プロセスを終了させる
- initプロセスがシグナルハンドラを登録しているシグナルしか、同じnamespaceの他のメンバーからは送れない(特権プロセスであっても同様)
2点目は、docker stopが送るSIGTERMをアプリケーションが自分でハンドリングしていないと素通りしてしまい、結局タイムアウト後のSIGKILLで強制終了される、という現象の理由になります。SIGKILLとSIGSTOPだけは例外で、祖先namespaceから送られた場合は強制的に配送されます。
lsns と nsenter で調べる・入る
システム上のnamespaceを一覧するにはlsns(8)が使えます。
lsns --type pid
# 例:
# NS TYPE NPROCS PID USER COMMAND
# 4026531836 pid 298 1 root /sbin/init
# 4026532561 pid 1 48213 root sleep 3600
# 特定プロセスの所属namespaceだけ見る
lsns --task 48213lsnsのマニュアルには「デフォルト出力は変更される可能性があるため、スクリプトでは--treeか--listと--outputで列を明示せよ」という注意があります。自動化に組み込むなら守りたい点で、この種のスクリプトを堅牢に書く作法はbashのset -euo pipefailも参考になります。
既存のnamespaceに入るには、内部でsetns(2)を呼ぶnsenter(1)を使います。
# コンテナのホスト側PIDを取得
PID=$(docker inspect --format '{{.State.Pid}}' demo)
# コンテナのネットワークnamespaceだけに入り、ホストのipコマンドで中を見る
sudo nsenter --target "$PID" --net ip addr show
# mount/uts/ipc/net/pid をまとめて借りてシェルを起動する
sudo nsenter --target "$PID" --mount --uts --ipc --net --pid /bin/shこの--netだけを借りるやり方は実務で非常に便利です。コンテナイメージにipやtcpdumpが入っていなくても、ホスト側のツールをコンテナのネットワークnamespaceで実行できるためです。「コンテナの中に入る」とは、実際には「ホストのプロセスに、コンテナと同じnamespaceを割り当てて起動する」ことなのだと分かると、この技が自然に見えてきます。
User namespace とUID変換
User namespaceは、namespace内のUID/GIDを外側の別のUID/GIDへ写像します。/proc/[pid]/uid_mapと/proc/[pid]/gid_map(Linux 3.5以降)がその対応表で、1行が「namespace内側の開始ID」「外側の開始ID」「範囲のサイズ」という3つの数値で構成されます。
# 非特権ユーザーのままuser namespaceを作り、中ではrootになる
unshare --user --map-root-user bash
id
# uid=0(root) gid=0(root) groups=0(root)
cat /proc/self/uid_map
# 0 1000 1
exit0 1000 1は「内側のUID 0は外側のUID 1000に対応する。範囲は1個」という意味です。中ではidがuid=0(root)と表示されますが、外から見ればただのUID 1000のプロセスであり、ホスト上のファイルへの権限も1000のままです。この「中ではroot、外ではただの一般ユーザー」という写像が、rootlessコンテナの中核です(unshare(1)には--map-user、--map-group、--map-root-user、--map-current-user、--map-auto、--map-subidsといったマッピング用オプションがあります)。
cgroups - 「使える量」を区切る
namespaceが「見えるものを区切る」機能だとすれば、cgroups(control groups)は「使える量を区切る」機能です。CPU時間・メモリ・PID数・ブロックI/Oといった資源を、プロセスのグループ単位で制限・計測・優先度付けします。
cgroups(7)によれば、cgroupsの最初のリリースはLinux 2.6.24です。しかしコントローラーが無秩序に追加された結果、コントローラー間の不整合と階層管理の複雑さという問題を抱えるようになりました。そこでLinux 3.10から新しい直交的な実装の開発が始まり、当初は-o __DEVEL__sane_behaviorマウントオプションの裏に隠された実験的機能でしたが、Linux 4.5のリリースで正式版となりました。これがcgroup v2です。
v1とv2の違い
| 観点 | cgroup v1 | cgroup v2 |
|---|---|---|
| 階層 | コントローラーごとに別々の階層を持てる | すべてのコントローラーが単一の統一階層に乗る |
| マウント | mount -t cgroup -o memory ...のようにコントローラー単位 | mount -t cgroup2 none /sys/fs/cgroup |
| コントローラー有効化 | マウント時に指定 | cgroup.subtree_controlへの書き込みで親が子に配る |
| プロセスの配置 | 中間ノードにもプロセスを置ける | 「内部プロセス禁止」ルールでルート以外は葉のみ |
| メモリ上限 | memory.limit_in_bytes | memory.max(ハード上限)とmemory.high(スロットル) |
| CPU上限 | cpu.cfs_quota_usとcpu.cfs_period_us | cpu.max(1ファイルに「上限 周期」を書く) |
v2の「内部プロセス禁止」ルールは、cgroups(7)によれば「ルートcgroupを除き、プロセスは葉ノード(子cgroupを持たないcgroup)にのみ存在できる」という規則です。cgroup Aのメンバーであるプロセスと、Aの子cgroupのプロセスとの間で資源をどう分けるか、という曖昧さを回避するための設計です。
コントローラーがv2で使えるようになった時期はcgroups(7)に記載があり、io・memory・pidsがLinux 4.5、perf_eventとrdmaが4.11、cpuが4.15、cpusetが5.0、freezerが5.2、hugetlbが5.6です。v2のcpuはv1のcpuとcpuacctを統合した後継にあたります。一方、v1のnet_clsとnet_prioに直接対応するv2コントローラーはなく、cgroup v2のパスにフックするeBPFフィルタをiptables(8)で扱う形に置き換えられました。v2のdevicesもインターフェースファイルを持たず、BPF_CGROUP_DEVICEプログラムをcgroupにアタッチする方式です。
自分の環境がどちらか確かめる
# cgroup2fs ならv2、tmpfs ならv1(Kubernetes公式ドキュメントの判定方法)
stat -fc %T /sys/fs/cgroup/
# cgroup2fs
# v2ならこのファイルが存在する
cat /sys/fs/cgroup/cgroup.controllers
# cpuset cpu io memory hugetlb pids rdma miscKubernetesの公式ドキュメントは、cgroup v2の要件として「カーネル5.8以降」「ディストリビューションがcgroup v2を有効にしていること」「コンテナランタイムが対応していること(containerd v1.4以降、cri-o v1.20以降)」「kubeletとランタイムがsystemd cgroupドライバを使うこと」を挙げ、デフォルトでcgroup v2を使うディストリビューションとしてContainer Optimized OS(M97以降)、Ubuntu(21.10以降、22.04以降を推奨)、Debian(11 bullseye以降)、Fedora(31以降)、Arch Linux(2021年4月以降)、RHEL系(9以降)を列挙しています。
2026年時点の状況としては、同ドキュメントにKubernetes v1.35でcgroup v1が非推奨(deprecated)になったことが記載されています。「kubeletはcgroup v1ノードではデフォルトで起動しなくなる。無効化するにはkubelet設定ファイルでfailCgroupV1をfalseに設定する」とあり、v2への移行はほぼ既定路線と言えます。
cgroup v2で実際に制限してみる
kernel.orgのcgroup-v2ドキュメントに沿って、手でcgroupを作って制限をかけてみます。
# 親cgroupで子にmemoryとpidsコントローラーを配り、葉のcgroupを作る
sudo mkdir -p /sys/fs/cgroup/demo
echo "+memory +pids" | sudo tee /sys/fs/cgroup/demo/cgroup.subtree_control
sudo mkdir -p /sys/fs/cgroup/demo/app
# メモリ上限50MiB、PID数上限20、CPUは50%(100000マイクロ秒あたり50000マイクロ秒)
echo 52428800 | sudo tee /sys/fs/cgroup/demo/app/memory.max
echo 20 | sudo tee /sys/fs/cgroup/demo/app/pids.max
echo "50000 100000" | sudo tee /sys/fs/cgroup/demo/app/cpu.max
# 現在のシェルをこのcgroupへ移し、使用量を確認する
echo $$ | sudo tee /sys/fs/cgroup/demo/app/cgroup.procs
cat /sys/fs/cgroup/demo/app/memory.current
cat /sys/fs/cgroup/demo/app/cpu.statcpu.maxの書式は"$MAX $PERIOD"で、デフォルトはmax 100000です。memory.maxのデフォルトはmax(無制限)で、これを超えるとOOM killerが発動します。一方memory.highはOOM killerを呼ばずにプロセスをスロットルする、より穏やかな制限です。メモリ不足時にOOM killerが動く背景は、仮想メモリとページングの仕組みで扱ったスワップとスラッシングの話とつながります。
Dockerのオプションは、最終的にこれらのファイルへ書き込まれます。
docker run -d --name limited --memory 512m --cpus 1.5 --pids-limit 100 alpine sleep 3600
ID=$(docker inspect --format '{{.Id}}' limited)
# systemd cgroupドライバ + cgroup v2 の場合のパス
cat "/sys/fs/cgroup/system.slice/docker-${ID}.scope/memory.max"
# 536870912
cat "/sys/fs/cgroup/system.slice/docker-${ID}.scope/cpu.max"
# 150000 100000Docker公式ドキュメントによれば、cgroup v2でcgroupfsドライバなら/sys/fs/cgroup/docker/配下、systemdドライバなら/sys/fs/cgroup/system.slice/配下の.scopeディレクトリになります(v1ならコントローラー名がパスに挟まります)。--cpus 1.5がcpu.maxの150000 100000、つまり周期100000マイクロ秒あたり150000マイクロ秒に変換されているのが分かります。
NOTE
コンテナ内からnprocやfreeを実行すると、cgroupの制限ではなくホストの値が返ることがあります。これらはcgroupではなく/proc/cpuinfoや/proc/meminfoを見ており、これらのファイルは標準ではcgroupの制限を反映しないためです。JVMやNode.jsの新しめのバージョンがcgroup制限を読むようになっているのは、まさにこの問題への対処です。
capabilities - rootの権限を分割する
capabilities(7)によれば、Linux 2.2以降、Linuxは伝統的にスーパーユーザーに結びつけられていた特権を、独立して有効化・無効化できる複数の単位に分割しました。この単位がcapabilityで、スレッド単位の属性です。
コンテナランタイムはこれを積極的に使います。コンテナのプロセスがUID 0(root)であっても、持っているcapabilityを絞れば実質的にできることは大きく制限できるからです。Dockerがデフォルトで付与するcapabilityは、Mobyのソースコード(daemon/pkg/oci/caps/defaults.go)で次の14個と定義されています。
| capability | 主な用途 |
|---|---|
CAP_CHOWN / CAP_FOWNER / CAP_FSETID | ファイル所有者・モード・setuidビット関連の操作 |
CAP_DAC_OVERRIDE | ファイルの読み書き実行の権限チェックを迂回 |
CAP_SETUID / CAP_SETGID | UID/GIDの任意操作 |
CAP_SETPCAP / CAP_SETFCAP | capabilityの付け替えとファイルへの設定 |
CAP_NET_BIND_SERVICE | 1024番未満の特権ポートへのbind |
CAP_NET_RAW | RAWソケットとPACKETソケットの使用 |
CAP_MKNOD | 特殊ファイルの作成 |
CAP_SYS_CHROOT | chroot(2)の実行 |
CAP_KILL | 権限を持たないプロセスへのシグナル送信 |
CAP_AUDIT_WRITE | カーネル監査ログへの書き込み |
注目すべきは、この一覧にCAP_SYS_ADMINが入っていないことです。capabilities(7)はCAP_SYS_ADMINを「過負荷である(overloaded)」と注記したうえで、カーネル開発者への助言としてこう書いています。「可能な限りCAP_SYS_ADMINを選ぶな。既存のcapabilityチェックの大部分がこれに紐づいている。一方では広範な権限を与え、他方ではその適用範囲の広さゆえに多くの特権プログラムが必要としてしまうため、これは新しいrootと呼んでも差し支えない」。実際CAP_SYS_ADMINはmount(2)、umount(2)、pivot_root(2)、swapon(2)、sethostname(2)、setns(2)などを一手に引き受けており、これを持つコンテナはホストのroot相当と考えるべきです。
実運用では、必要最小限まで削るのが定石です。
# すべて落として、必要なものだけ足す
docker run --rm --cap-drop=ALL --cap-add=NET_BIND_SERVICE nginx:alpine
# コンテナ内で実際のcapabilityを確認する
docker run --rm --cap-drop=ALL alpine sh -c 'apk add -q libcap && capsh --print'
# Current: =
# Bounding set =capabilities(7)が説明するbounding set(境界集合)も重要です。Linux 2.6.25以降、bounding setはスレッド単位の属性で、execve(2)のたびにファイルの継承可能capabilityとANDが取られます。つまりbounding setから外したcapabilityは、setuidされたバイナリを実行しても取り戻せません。--cap-dropはこの性質に支えられています。
seccomp - システムコールを絞る
capabilitiesが「何ができるか」を分割するのに対し、seccompは「どのシステムコールを呼べるか」を直接絞ります。seccomp(2)のHISTORYには「Linux 3.17」とあり、システムコールとしてのseccomp()はこのバージョンで追加されました。
モードは2つあります。
SECCOMP_SET_MODE_STRICT: 呼び出しスレッドが実行できるシステムコールはread(2)、write(2)、_exit(2)(exit_group(2)は不可)、sigreturn(2)のみ。他を呼ぶとスレッドが終了、単一スレッドならSIGKILLでプロセス全体が終了しますSECCOMP_SET_MODE_FILTER: BPF(Berkeley Packet Filter)プログラムで、任意のシステムコールとその引数をフィルタできます
コンテナで使われるのは後者です。フィルタモードを使うには、呼び出しスレッドが自分のuser namespaceでCAP_SYS_ADMINを持っているか、あるいはno_new_privsビットが立っている必要があります(立っていなければprctl(PR_SET_NO_NEW_PRIVS, 1)を先に呼びます)。これは、非特権プロセスが悪意あるフィルタを仕込んだうえでsetuidバイナリを実行し、そのバイナリを騙す攻撃を防ぐための要件です。フィルタの戻り値にはSECCOMP_RET_KILL_PROCESS(Linux 4.14以降、プロセス全体を殺す)、SECCOMP_RET_KILL_THREAD、SECCOMP_RET_ERRNO、SECCOMP_RET_TRAPなどがあります。
Docker公式ドキュメントによれば、Dockerのデフォルトseccompプロファイルは300以上あるシステムコールのうち約44個を無効化します。プロファイルはdefaultActionにSCMP_ACT_ERRNO(Permission Deniedを返す)を設定し、許可するシステムコールだけをSCMP_ACT_ALLOWで上書きする許可リスト方式です。
# カーネルがseccompをサポートしているか確認(Docker公式ドキュメント記載の方法)
grep CONFIG_SECCOMP= "/boot/config-$(uname -r)"
# CONFIG_SECCOMP=y
# デフォルトプロファイルを外す(切り分け用途のみ)
docker run --rm -it --security-opt seccomp=unconfined debian:latest \
unshare --map-root-user --user sh -c whoami
# 自作プロファイルを適用する
docker run --rm --security-opt seccomp=/path/to/profile.json alpine shseccomp=unconfinedは「このエラーはseccompのせいか」を切り分けるのに便利ですが、本番で常用するものではありません。生のBPFを手書きするのは大変なので、seccomp(2)のNOTESもlibseccompライブラリの利用を勧めています。
overlayfs - イメージレイヤの正体
コンテナイメージが「レイヤ」で構成され、共通部分がキャッシュされるという話は誰もが聞いたことがあります。その実体はunion filesystem、Linuxでは主にoverlayfsです。
kernel.orgのoverlayfsドキュメントによれば、基本のマウントは次の形です。
mount -t overlay overlay \
-olowerdir=/lower,upperdir=/upper,workdir=/work /mergedlowerdir: 下層。読み取り専用でよい(「下位ファイルシステムは書き込み可能である必要はない」)upperdir: 上層。書き込みはここに落ちるworkdir: overlayfsが内部作業に使う空ディレクトリ。upperdirと同じファイルシステム上にある必要があるmerged: 両者を重ねた統合ビューが見えるマウントポイント
複数の下層はコロンで区切って指定します。
mount -t overlay overlay -olowerdir=/lower1:/lower2:/lower3 /mergedドキュメントは「レイヤは右端から左へ向かって積まれる」と説明しており、この例ではlower1が最上位、lower3が最下位です。イメージレイヤの重ね順がそのままこれにあたります。
書き込みが起きたときの挙動がcopy_upです。下層のファイルを書き換えようとすると、overlayfsは上層に親ディレクトリを作り、所有者・モード・mtimeなどのメタデータを複製し、データ本体をコピーし、拡張属性を移します。以降の書き込みは上層のコピーに対して行われます。
削除の表現も面白い仕組みです。下層は変更できないので、上層にwhiteoutを置いて「これは消えている」と示します。ドキュメントによれば、whiteoutは「デバイス番号0/0のキャラクタデバイス、またはxattr trusted.overlay.whiteoutを持つサイズ0の通常ファイル」として作られます。ディレクトリ全体を隠す場合は、xattr trusted.overlay.opaqueにyを設定したopaqueディレクトリで同名の下層ディレクトリを無視させます。
Dockerのoverlay2ドライバ
Docker公式ドキュメントによれば、overlay2ストレージドライバはlowerdir・upperdir・merged・workdirの構成をそのまま使い、ネイティブで最大128の下位overlayfsレイヤを扱えます。レイヤの実体は/var/lib/docker/overlay2/配下に置かれ、各レイヤのディレクトリには中身を格納するdiff、短縮識別子のlink(実体は/var/lib/docker/overlay2/l/)、親レイヤ参照のlower、マウントポイントのmerged、内部作業用のworkが並びます。
docker info --format '{{.Driver}}'
# overlay2
# コンテナのoverlayマウント(LowerDir/UpperDir/MergedDir/WorkDir)を見る
docker inspect --format '{{json .GraphDriver.Data}}' demo | python3 -m json.tooloverlay2の制約として、Docker公式ドキュメントは次の2点を挙げています。
open(2)のPOSIXセマンティクスが崩れる: 連続する2回のopenの間にcopy_upが起きると、期待した挙動にならない場合があるrename(2)の制約: ソースとデスティネーションの両方が最上層にある場合しか動作せず、そうでなければEXDEVエラーを返す
また、copy_upはブロック単位ではなくファイル丸ごとのコピーなので、巨大なファイルに1バイト書き込むだけでもファイル全体がコピーされます。ログや大きなデータをコンテナ層ではなくボリュームに置け、と言われる技術的な理由がここにあります。なおuser_namespaces(7)は、user namespace内でマウントできるファイルシステムの一覧にoverlayfsを「Linux 5.11以降」として挙げており、rootlessコンテナがoverlayfsを素直に使えるようになったのはかなり最近です。
OCI仕様とランタイム - runcとcontainerdの関係
ここまでの部品を「どう組み合わせるか」を標準化したのがOCI(Open Container Initiative)の仕様群です。大きく2つあります。
- Image Specification(image-spec): イメージの形式を定義します。image manifest、image index、filesystem layers、image configurationから構成され、各要素はダイジェストによるcontent-addressableな識別を持ちます。「名前で発見され、ダウンロードされ、ハッシュで検証され、署名で信頼される」という流れを実現します
- Runtime Specification(runtime-spec): 展開されたrootfsと
config.jsonからなるfilesystem bundleを、どう実行するかを定義します
config.jsonのLinux固有セクションには、namespaces(型としてpid、network、mount、ipc、uts、user、cgroup、timeが指定できます)、uidMappings/gidMappings、devices、cgroupsPath、resources(memory、cpu、pids、blockIOなど)、seccomp、rootfsPropagation、maskedPaths、readonlyPathsといった項目が並びます。この記事でここまで見てきたカーネル機能が、そのままJSONのフィールドとして現れているのが分かります。
コンテナの状態はcreating(作成中)、created(createが完了し、まだユーザー指定のプログラムを実行していない)、running(実行中)、stopped(終了)の4つ。標準の操作はstate、create、start、kill、deleteの5つで、その間にprestart / createRuntime / createContainer / startContainer / poststart / poststopといったフックが定義されています。
実装の側を見ると、階層はおおむね次のようになっています。
docker CLI
| (REST API over unix socket)
dockerd (Docker Engine デーモン)
| (gRPC)
containerd (イメージ管理・コンテナ監督)
| (shim プロセス経由)
runc (OCIランタイム。実際にnamespace/cgroups/capabilities/seccompを設定して execve する)
|
コンテナプロセス
runcのREADMEは自身を「OCI仕様に従ってLinux上でコンテナを生成・実行するCLIツール」と説明し、「エンドユーザーではなく上位のコンテナソフトウェアから使われることを想定した低レベルツール」と位置づけています。containerdは公式サイトで「シンプルさ・堅牢性・可搬性を重視した業界標準のコンテナランタイム」を名乗り、「イメージの転送と保存からコンテナの実行と監督、低レベルストレージ、ネットワークアタッチメントまで、ホストシステムのコンテナライフサイクル全体を管理する」と述べています。containerdはCNCFで2019年2月28日にGraduatedステータスへ到達しました。
2026年8月時点でGitHubのリリース情報から確認できる各プロジェクトの最新版は、runcがv1.5.1(2026年7月14日)、containerdがv2.3.3(2026年7月10日)、moby/mobyがdocker-v29.7.1(2026年7月31日)、OCI runtime-specがv1.3.0(2025年11月4日)、OCI image-specがv1.1.1(2025年3月3日)です。仕様側の更新頻度が低く実装側が活発なのが分かります。仕様が安定しているからこそ、Docker以外にもPodmanやcri-oといった実装が相互運用できているわけです。
docker run が裏でやっていること
ここまでの部品が揃うと、docker run -it alpine shの裏側を筋道立てて説明できます。
- イメージの取得: レジストリからmanifestを取り、各レイヤをダイジェストで検証しながらダウンロードする
- レイヤの展開とマウント: 各レイヤを
/var/lib/docker/overlay2/に展開し、overlayfsで重ねてmergedを作り、最上層に書き込み可能なコンテナ層を置く - config.jsonの生成: イメージのconfigとユーザーの指定(
--memory、--cap-dropなど)を合成し、OCI bundleを組み立てる - プロセスの生成:
clone(2)をCLONE_NEWNS・CLONE_NEWPID・CLONE_NEWNET・CLONE_NEWUTS・CLONE_NEWIPC付きで呼ぶ。user namespaceを使う構成ならuid_map/gid_mapも書き込む - cgroupへの登録: cgroupを作り、
memory.maxやcpu.maxを設定してcgroup.procsにPIDを書く - ルートの入れ替え:
mergedを新しいrootとしてpivot_root(2)を呼び、/proc・/sys・/devをマウントし、maskedPathsやreadonlyPathsを適用する - 権限の縮小: 不要なcapabilityをbounding setから外し、
no_new_privsを立て、seccompフィルタをロードする execve(2): 最後にユーザー指定のコマンド(この例ではsh)を実行する
6番目のpivot_root(2)はchroot(2)とよく比較されます。chrootは呼び出しプロセスのルートディレクトリを変えるだけで元のルートのマウントは残りますが、pivot_rootはmount namespace内のルートマウント自体を差し替えるので、古いルートをumountして見えなくできます。コンテナランタイムがchrootではなくpivot_rootを使うのは、この差が隔離の質に直結するからです。
そして最後のexecve(2)が示すように、コンテナのプロセスは普通のプロセスとしてexecされるだけで、「仮想化」に相当するステップは一つもありません。
セキュリティ - コンテナは境界として弱い
仕組みを理解すると、セキュリティ上の性質も自然に見えてきます。すべてのコンテナはホストと同じカーネルを共有しているのですから、カーネルのシステムコール実装に脆弱性があれば、それは全コンテナから到達可能な攻撃面になります。VMのようにハイパーバイザという別レイヤの壁があるわけではありません。実際、Linuxカーネルの権限昇格脆弱性は継続的に見つかっており、bad-epollによるLinuxカーネル権限昇格(CVE-2026-46242)のような事例は、コンテナ隔離の前提を直接揺さぶります。AIエージェントの実行環境でもサンドボックス脱出の事例が報告されており、「隔離されているはずの環境」を信用しすぎる設計は危険です。
コンテナ内のrootは、デフォルトではホストのrootと同じUID
user namespaceを使わない標準構成では、コンテナ内のUID 0はホストのUID 0そのものです。capabilitiesとseccompとAppArmor/SELinuxが「rootだができることは限られている」状態を作っているだけで、UIDそのものは分離されていません。ホストのディレクトリをbind mountすると、この事実がすぐ問題になります。
# これは非常に危険。ホストの / をコンテナに書き込み可能で渡している
docker run -v /:/host alpine sh -c 'echo pwned >> /host/etc/passwd'同じ理由で、-v /var/run/docker.sock:/var/run/docker.sockもCI用途などでよく見かけますが、その中のプロセスは--privilegedなコンテナを起動できる、つまり実質的にホストのroot権限を持つことになります。認証情報の扱いも含め、環境変数とdotenvによるシークレット管理で扱った原則はコンテナ環境でも変わりません。
--privileged は隔離を実質的に外す
Docker公式ドキュメントは--privilegedについて、Linuxカーネルのcapabilityをすべて有効にし、デフォルトのseccompプロファイルとAppArmorプロファイルを無効化し、SELinuxプロセスラベルを外し、すべてのホストデバイスへのアクセスを与え、/sysとcgroupのマウントを読み書き可能にする、と説明しています。そのうえで警告としてこう明記しています。
WARNING
--privilegedを付けたコンテナは、安全にサンドボックス化されたプロセスではありません。このモードのコンテナはホスト上でrootシェルを取得し、システムを支配できます。
ドキュメント自身が「ほとんどのユースケースでこのフラグを優先すべきではない」と述べ、必要な権限だけを--cap-addで明示的に付与することを推奨しています。
rootlessモードという選択肢
Docker公式ドキュメントによれば、rootlessモードはDockerデーモンとコンテナを非特権ユーザーで動かし、デーモン自体をuser namespace内で実行します(デーモン自身はrootのまま動くuserns-remapモードとはこの点が異なります)。前提としてnewuidmapとnewgidmap(uidmapパッケージ)が必要で、「/etc/subuidと/etc/subgidにそのユーザーの従属UID/GIDが少なくとも65,536個含まれている」ことが求められます。この65,536という数字は、コンテナ内でUID 0からUID 65535までを使えるようにするためで、前述のuser namespaceのマッピング機構がそのまま製品機能として現れています。
実務上の最小限のチェックリスト
--cap-drop=ALLを出発点にし、必要なcapabilityだけ--cap-addする--read-onlyと--tmpfsで書き込み可能な場所を絞る--security-opt no-new-privilegesでsetuidによる権限昇格経路を塞ぐUSERディレクティブでコンテナ内のUIDを非rootにする--memory、--cpus、--pids-limitを必ず設定する(fork bombはpids.maxで止まります)--privilegedとDocker socketのマウントは、代替手段を検討し尽くしてから- ホストカーネルのセキュリティ更新を適用し続ける。これがコンテナ隔離の土台そのものです
docker run -d --name hardened \
--cap-drop=ALL --cap-add=NET_BIND_SERVICE \
--security-opt no-new-privileges \
--read-only --tmpfs /tmp:rw,noexec,nosuid,size=64m \
--memory 512m --cpus 1 --pids-limit 128 \
--user 10001:10001 \
myapp:latest真に強い隔離が必要なら、コンテナだけに頼らずマイクロ仮想マシンやユーザー空間カーネルを組み合わせる選択肢もあります(個別製品の詳細はこの記事では未確認です)。
まとめ
- コンテナはカーネルの単一機能ではなく、namespace・cgroups・capabilities・seccomp・overlayfsといった既存機能の組み合わせで作られた合成物です
- namespaceは8種類あり、Mount(2.4.19)から始まりTime(5.6)まで20年近くかけて揃いました。user namespaceだけはLinux 3.8以降、特権なしで作成できます
- cgroupsは2.6.24で導入され、v2がLinux 4.5で正式版になりました。単一階層・
cgroup.subtree_controlによる委譲・内部プロセス禁止ルールがv2の柱です - capabilitiesはLinux 2.2以来のroot権限の分割機構で、Dockerはデフォルトで14個だけを付与します。
CAP_SYS_ADMINはマニュアル自身が「新しいroot」と呼ぶ危険なcapabilityです - seccompは
seccomp(2)(Linux 3.17)によるシステムコールのBPFフィルタで、Dockerのデフォルトプロファイルは300以上のうち約44個を無効化します - overlayfsのlowerdir/upperdir/workdirがイメージレイヤとコピーオンライトの実体で、copy_upはファイル丸ごとのコピーになります
- OCIのimage-specとruntime-specが形式を標準化し、containerdとruncがそれを実装します。
config.jsonのフィールドは、この記事で見たカーネル機能とほぼ一対一で対応します - カーネルを共有する以上、コンテナはVMほど強い境界ではありません。
--privilegedはDocker公式ドキュメント自身が「安全にサンドボックス化されたプロセスではない」と警告しています
unshareとlsnsとnsenter、そして/proc/self/ns/と/sys/fs/cgroup/。この5つを自分の手で触ってみると、docker runが魔法ではなく手順であることが実感できるはずです。トラブルシューティングの引き出しも、そこから一段深くなります。
参考リンク
- namespaces(7) / user_namespaces(7) / pid_namespaces(7) / mount_namespaces(7)
- clone(2) / unshare(2) / pivot_root(2) / seccomp(2)
- unshare(1) / nsenter(1) / lsns(8)
- cgroups(7) / capabilities(7)
- Control Group v2 - The Linux Kernel documentation
- Overlay Filesystem - The Linux Kernel documentation
- OCI Runtime Specification / config-linux.md / OCI Image Specification
- opencontainers/runc / containerd
- Docker - Use the OverlayFS storage driver
- Docker - Seccomp security profiles for Docker
- Docker - Rootless mode / docker container run / Runtime metrics
- Kubernetes - About cgroup v2
- 仮想メモリとページングの仕組み(当ブログ) / 並行と並列・プロセスとスレッド(当ブログ)


