Skip to content
Published on

コンテナとVMは何が違うのか — namespace、cgroup、そしてカーネル共有の請求書

シェア
Authors

はじめに — 「軽量なVM」という説明が残す誤った直感

コンテナを初めて学ぶとき、ほぼ全員が同じ図を見ます。左側にはハイパーバイザーの上にゲストOSが3つ積まれ、右側にはDockerエンジンの上にコンテナが3つ積まれている図です。説明はこう締めくくられます。「コンテナはゲストOSがないので軽いです」。

間違いではありませんが、この図から得た直感は実務で繰り返し裏切ります。コンテナを小さなVMとして理解した人は、次のような場面で行き詰まります。コンテナの中でuname -rを打つとホストのカーネルバージョンが出てきて、コンテナでsysctl -w vm.max_map_countがホスト全体に影響するかそもそも拒否され、free -mがコンテナのメモリ制限ではなくホスト全体のメモリを表示し、JVMがCPUコア数をホスト基準で拾ってスレッドプールを過大に作ります。

正確な文はこれです。コンテナはホストカーネルの上で動く普通のプロセスであり、ただ見えるものと使えるものが制限されているだけです。この一文から上のすべての現象が導かれます。

コンテナは隔離されたプロセスにすぎない

一番早い確認方法は、ホストでプロセス一覧を見ることです。

docker run -d --name web nginx:1.27
ps -eo pid,user,comm | grep nginx
  48213 root     nginx
  48261 systemd+ nginx
  48262 systemd+ nginx

ホストのpsにそのまま見えます。VMであれば、ゲストの中のプロセスがホストのプロセステーブルに現れるはずがありません。コンテナのプロセスは最初からホストのプロセスです。

コンテナの中から見ると、同じプロセスの番号が違います。

docker exec web ps -eo pid,comm
    PID COMMAND
      1 nginx
     29 nginx
     30 nginx

ホストで48213のプロセスが、コンテナの中では1番です。プロセスが2つあるのではなく、同じプロセスを別の名前空間から見たものです。

カーネルバージョンは隠せません。

uname -r
docker run --rm alpine:3.20 uname -r
docker run --rm ubuntu:24.04 uname -r
6.8.0-52-generic
6.8.0-52-generic
6.8.0-52-generic

alpineコンテナもubuntuコンテナもホストのカーネルを使います。イメージが提供するのはカーネルではなく、ユーザー空間のファイルツリーです。MacやWindowsのDocker Desktopでこの値が見慣れないものになるなら、それはDockerがLinuxのVMを1つ起動しておいて、その中でコンテナを動かしているからです。その環境で皆さんが使っているのは、コンテナであると同時にVMの中です。

namespace — 何が見えなくなるのか

コンテナを作るカーネル機能は魔法ではなく、いくつかのシステムコールです。cloneunsharesetnsがすべてです。プロセスごとにどの名前空間に属しているかはファイルとして公開されます。

ls -l /proc/self/ns
lrwxrwxrwx 1 root root 0 cgroup -> 'cgroup:[4026531835]'
lrwxrwxrwx 1 root root 0 ipc    -> 'ipc:[4026531839]'
lrwxrwxrwx 1 root root 0 mnt    -> 'mnt:[4026531841]'
lrwxrwxrwx 1 root root 0 net    -> 'net:[4026531840]'
lrwxrwxrwx 1 root root 0 pid    -> 'pid:[4026531836]'
lrwxrwxrwx 1 root root 0 time   -> 'time:[4026531834]'
lrwxrwxrwx 1 root root 0 user   -> 'user:[4026531837]'
lrwxrwxrwx 1 root root 0 uts    -> 'uts:[4026531838]'

角括弧の中の数字が名前空間の識別子です。コンテナを起動して同じファイルを見ると、ほとんどの数字が変わります。

docker run --rm alpine:3.20 ls -l /proc/self/ns | awk '{print $9, $11}'
cgroup cgroup:[4026532561]
ipc ipc:[4026532499]
mnt mnt:[4026532497]
net net:[4026532562]
pid pid:[4026532500]
time time:[4026531834]
user user:[4026531837]
uts uts:[4026532496]

timeuserだけがホストと同じ値です。Dockerが既定ではユーザー名前空間を使わないからです。この一行が次節のセキュリティ議論の全体を決めます。

各名前空間が担当するのはこうです。

pidはプロセス番号の空間を分離します。コンテナの最初のプロセスが1番になり、コンテナの中からは他のコンテナやホストのプロセスが見えません。1番になるという事実そのものが、シグナル処理とゾンビ回収の責任も併せて課します。

mntはマウントテーブルを分離します。イメージのルートファイルシステムがコンテナの/として見えるのは、この名前空間とpivot_rootの結果です。

netはネットワークスタック全体を複製します。インターフェース、ルーティングテーブル、iptablesのルール、ソケット、ポート番号の空間がすべて別々です。だからコンテナ2つがそれぞれ80番ポートを開けます。

utsはホスト名とドメイン名を分離します。コンテナの中でhostnameを変えてもホストに影響がない理由です。

ipcはSystem V IPCとPOSIXメッセージキューを分離します。共有メモリを使うプロセスを2つのコンテナに分けると互いに見えなくなる、その地点です。

userはUIDとGIDをマッピングします。コンテナの中の0番をホストの0番でなくする唯一の手段です。

cgroupはcgroup階層における自分の位置をルートとして見せます。

自分で作ってみると感覚がつかめます。

sudo unshare --pid --fork --mount-proc --uts --net --mount \
  /bin/bash -c 'hostname mini; hostname; ps -ef; ip link'
mini
UID          PID    PPID  C STIME TTY          TIME CMD
root           1       0  0 14:22 pts/0    00:00:00 /bin/bash -c hostname mini; ...
root           4       1  0 14:22 pts/0    00:00:00 ps -ef
1: lo: <LOOPBACK> mtu 65536 qdisc noop state DOWN mode DEFAULT group default

イメージもなくDockerもなしに、コンテナの半分ができました。残りの半分がcgroupです。

cgroup — 何を使えなくするのか

名前空間は視界を遮るだけで資源を分けません。メモリ制限とCPUの配分はすべてcgroupの担当です。最近のディストリビューションはcgroup v2が既定で、コントローラが1つの統合階層に付きます。

docker run -d --name limited --memory=256m --cpus=0.5 nginx:1.27
CID=$(docker inspect -f '{{.Id}}' limited)
cat /sys/fs/cgroup/system.slice/docker-$CID.scope/memory.max
cat /sys/fs/cgroup/system.slice/docker-$CID.scope/cpu.max
cat /sys/fs/cgroup/system.slice/docker-$CID.scope/pids.max
268435456
50000 100000
max

memory.maxが256MiB、cpu.maxが100ms周期あたり50msです。この値を超えるとどうなるかが重要です。CPUはスロットリングされ、メモリは死にます。

docker run --rm --memory=64m python:3.12-slim \
  python -c "x = bytearray(200 * 1024 * 1024)"
echo "exit=$?"
exit=137

137は128に9を足した値、すなわちSIGKILLです。カーネルのOOMキラーがcgroupの上限超過を理由にプロセスを終了させました。アプリケーションは何の例外も受け取りません。ですからコンテナが静かに再起動を繰り返すなら、アプリケーションのログではなくカーネルのログを見るべきです。

dmesg -T | grep -i 'memory cgroup out of memory' | tail -2
[Sun Jul 26 14:31:07 2026] Memory cgroup out of memory: Killed process 51882 (python) total-vm:412508kB, anon-rss:66120kB

ここで有名な罠が出てきます。cgroupは制限をかけますが/procを遮りはしません。/proc/meminfo/proc/cpuinfoは依然としてホストの値を見せます。

docker run --rm --memory=256m --cpus=0.5 debian:bookworm-slim \
  sh -c 'free -m | head -2; nproc'
               total        used        free      shared  buff/cache   available
Mem:           64228        9112       41003         512       14113       54216
32

256MBに制限したコンテナが64GBを見て、0.5コアに制限したコンテナが32コアを見ます。この値をそのまま信じるランタイムは、スレッドプールとヒープをホスト基準で決めてすぐOOMで死にます。JVMはUseContainerSupportでcgroupを直接読んでこの問題を解決し、Nodeのos.cpus()やGoのruntime.NumCPU()は依然としてホストの値を返すので、GOMAXPROCSを明示するかautomaxprocsのようなライブラリを使う必要があります。lxcfsを付けて/procを偽装で埋める方法もありますが、アプリケーション側でcgroupを読むほうがはるかに堅牢です。

カーネルを共有するということの実際の請求書

ここまでが構造で、ここからが結果です。

第一に、カーネルバージョンに依存します。イメージの中のglibcがどれだけ新しくても、カーネルが対応していないシステムコールは使えません。古いカーネルの上で最新のベースイメージを動かすと、io_uringや新しいseccompフィルタを使うコードが失敗します。逆方向もあります。Docker 20.10未満とCentOS 7系で最新のglibcイメージを動かすとき、既定のseccompプロファイルがclone3を遮断してコンテナが即死する事故は長く繰り返されました。

第二に、カーネルパラメータが共有されます。sysctlは名前空間化されたものとそうでないものに分かれます。

# net.* のほとんどはnet名前空間に属するのでコンテナごとに設定可能
docker run --rm --sysctl net.ipv4.tcp_syncookies=0 alpine:3.20 \
  sysctl net.ipv4.tcp_syncookies
net.ipv4.tcp_syncookies = 0
# vm.* は名前空間化されていない
docker run --rm --sysctl vm.max_map_count=262144 alpine:3.20 true
docker: Error response from daemon: failed to create task for container:
sysctl "vm.max_map_count" is not in a separate kernel namespace: unknown.

Elasticsearchをコンテナで起動するときに出会う、あのエラーです。解決策はコンテナの設定ではなく、ホストでsysctl -w vm.max_map_count=262144を実行することです。つまりこの値はそのノードのすべてのコンテナが共有します。VMならゲストごとに独立して設定していたはずの値です。

第三に、セキュリティ境界の性質が違います。VMでゲストがホストを掌握するには、ハイパーバイザーか仮想デバイスモデルのバグを突破しなければなりません。コンテナではカーネルのシステムコールインターフェース全体が攻撃面です。Linuxカーネルのローカル権限昇格の脆弱性1つが、そのままコンテナエスケープにつながります。

実際の事例が出続けています。CVE-2019-5736はコンテナの中から/proc/self/exeを通じてホストのruncバイナリを上書きする攻撃であり、CVE-2024-21626はruncがファイルディスクリプタを閉じないままコンテナを開始する問題を利用して、WORKDIRの操作だけでホストのファイルシステムにアクセスしました。どちらもカーネルではなくランタイムのバグでしたが、コンテナとホストが同じカーネルの下にあるからこそ成立した攻撃です。

ですからコンテナの隔離は名前空間だけで完成せず、何層も積み重ねます。Dockerの既定値がすでにかなりの部分をやっています。

docker run --rm alpine:3.20 sh -c 'grep CapEff /proc/self/status'
CapEff:	00000000a80425fb
capsh --decode=00000000a80425fb
0x00000000a80425fb=cap_chown,cap_dac_override,cap_fowner,cap_fsetid,cap_kill,
cap_setgid,cap_setuid,cap_setpcap,cap_net_bind_service,cap_net_raw,
cap_sys_chroot,cap_mknod,cap_audit_write,cap_setfcap

40個を超えるcapabilityのうち14個だけが残ります。ここに約44個のシステムコールを塞ぐ既定のseccompプロファイルと、AppArmorまたはSELinuxのプロファイルが加わります。運用ではここからさらに削るのが定石です。

docker run -d \
  --cap-drop=ALL --cap-add=NET_BIND_SERVICE \
  --security-opt no-new-privileges:true \
  --read-only --tmpfs /tmp \
  --user 10001:10001 \
  api:v312

逆に、この防御を一度に崩すオプションもあります。--privilegedはすべてのcapabilityを与え、seccompとAppArmorを切り、ホストのデバイスを露出します。Dockerソケットをコンテナにマウントするのも事実上同じ意味です。そのソケットにアクセスできれば、ホストのルートファイルシステムをマウントした特権コンテナを新たに起動できます。

ユーザー名前空間は、この構図を根本から変える唯一のスイッチです。

// /etc/docker/daemon.json
{
  "userns-remap": "default"
}

こうするとコンテナの中のUID 0がホストの非特権UIDにマッピングされ、脱出に成功してもただちにrootにはなりません。代わりにボリュームの所有権や一部のネットワーク機能で制約が生じます。

VMは何を多く与え、何を多く受け取るのか

VMの隔離境界はシステムコールではなくハードウェア仮想化です。ゲストは自分のカーネルを持ち、ゲストカーネルが処理するシステムコールはホストカーネルに届きません。ゲストからホストへ越えるにはVM exitの経路と仮想デバイスのエミュレーションを突破する必要があり、そのインターフェースはシステムコール300余りよりはるかに狭いです。

対価は明確です。ゲストごとにカーネルと初期化の過程が必要なので起動に数十秒かかり、メモリはゲストカーネルとページキャッシュだけで数百MBが先に出ていきます。ゲストに割り当てたメモリは実際に使わなくてもおおむね確保されたままで、IOは仮想デバイスをもう一層通ります。

密度で比べると差ははっきりします。32GBのノードで512MBのコンテナは50個以上起動できますが、同じノードでVMはゲストのオーバーヘッドまで考えるとその半分も難しいです。

項目コンテナVMマイクロVM(Firecracker)gVisor
カーネルホストと共有ゲスト専用ゲスト専用(軽量)ユーザー空間カーネルが代行
起動時間数十ms数十秒約125ms数百ms
メモリオーバーヘッドほぼなし数百MB約5MB数十MB
隔離境界システムコールハードウェア仮想化ハードウェア仮想化システムコールの横取り
別のカーネルの実行不可可能可能不可
システムコール性能ネイティブネイティブに近いネイティブに近い遅い
適した境界同じ信頼ドメイン別の組織/テナントサーバーレスのマルチテナンシー信頼できないコードの実行

中間地帯 — gVisor、Kata Containers、Firecracker

マルチテナント環境で顧客が提出したコードを実行しなければならないなら、2つの軸のあいだのどこかが必要です。そこで3つのアプローチが定着しました。

gVisorはユーザー空間にLinuxのシステムコールインターフェースを再実装します。Sentryというプロセスがコンテナのシステムコールを横取りして自前で処理し、ホストカーネルにはごく狭い部分集合だけを渡します。ホストカーネルの攻撃面が劇的に減りますが、システムコールが頻繁なワークロードは目に見えて遅くなり、一部のシステムコールはそもそも対応していません。

docker run --rm --runtime=runsc alpine:3.20 dmesg | head -3
[    0.000000] Starting gVisor...
[    0.310495] Preparing for the zombie uprising...
[    0.582901] Setting up VFS2...

Kata Containersはコンテナごとに軽量VMを起動し、その中でOCIコンテナを実行します。runcの代わりにkata-runtimeを使えばよく、KubernetesではRuntimeClassでワークロードごとに選択できます。

apiVersion: node.k8s.io/v1
kind: RuntimeClass
metadata:
  name: kata
handler: kata
---
apiVersion: v1
kind: Pod
metadata:
  name: untrusted-job
spec:
  runtimeClassName: kata
  containers:
    - name: job
      image: registry.example.com/untrusted:latest

FirecrackerはAWSがLambdaとFargateのために作ったVMMです。仮想デバイスをネットワーク、ブロック、シリアル、キーボード程度まで減らして起動を約125msまで下げ、VMあたりのメモリオーバーヘッドを5MB水準にしました。コンテナの密度とVMの隔離を同時に狙った設計であり、実際にサーバーレスプラットフォームの標準的な答えになりました。

選択基準は結局のところ信頼境界です。同じチームが作ったサービスを1つのノードに集めるのであれば名前空間による隔離で十分で、むしろseccompとcapabilityを締めることに時間を使うほうがよいです。異なる顧客のコードや任意のユーザー提出コードを1つのノードで動かすなら、カーネル共有は許容できないリスクなので、マイクロVMやgVisorを載せるか、いっそテナントごとにノードを分けるべきです。別のカーネルバージョンや別のOSが必要なら、議論の余地なくVMです。

おわりに — 隔離の強さではなく隔離の種類が違う

コンテナとVMは、同じ軸の上の重い点と軽い点ではありません。コンテナはカーネルを共有してシステムコールを境界とし、VMはカーネルを分けてハードウェアを境界とします。種類が違うのです。

この1つを基準に置くと実務の判断が単純になります。カーネルバージョンが違うか信頼できないコードを隔離する必要があればVM系、デプロイ単位を軽く速く回すことが目的ならコンテナ、両方必要ならKataかFirecrackerです。そしてコンテナを選んだ瞬間からは、隔離をカーネルが勝手にやってくれると期待する代わりに、capability、seccomp、読み取り専用のルート、非特権ユーザーを自分で締めなければなりません。