コンテナ(namespace と cgroup でつくる隔離)
「コンテナ」の正体を Go でモデル化する。Docker の中身は Linux カーネルの 2 つの機能の組み合わせだ。namespace は、PID やポートなど本来 1 つしかない資源を、コンテナごとに別々に見せる。cgroup は、メモリやプロセス数の上限を予算として持ち、超えたら拒否する。コンテナは VM ではなく、カーネルを共有したまま制限を課したものだ。
この章で作るもの
「コンテナは軽量な VM」という説明をよく聞くが、これは正確ではない。VM はカーネルもハードウェアも丸ごと仮想化するが、コンテナはホストのカーネルをそのまま共有する。ではなぜ隔離されているように見えるのか。答えは Linux カーネルの2つの機能、namespace と cgroup だけにある。
┌──────────────── Host(1つのカーネル)────────────────┐
│ global PID を通し番号で発番 / ルート cgroup で総量管理 │
│ │
│ ┌── Container: web ──┐ ┌── Container: db ──┐ │
│ │ PID : init = 1 │ │ PID : init = 1 │ │
│ │ net : :80 │ │ net : :80 │ │
│ │ mount: /data = volA│ │ mount: /data = volB│ │
│ │ cgroup: mem 256MiB │ │ cgroup: mem 256MiB│ │
│ └────────────────────┘ └───────────────────┘ │
└──────────────────────────────────────────────────────┘順に見ていく。
- namespace = グローバル資源の独立した見え方: PID・ポート・マウント・ホスト名を、コンテナごとに別インスタンスに見せる
- cgroup = 階層的な資源の予算: メモリとプロセス数の上限を木で持ち、超えたら全か無かで拒否する
- 組み立て = 共有カーネル + namespace 一式 + 子 cgroup: コンテナは VM ではなく、1つのカーネルの上に見え方と予算を重ねたもの
① namespace: 見え方を翻訳する
隔離の第一歩は「見え方」を分けること。ここで大事なのは、namespace は情報を消すのではなく、別の見え方に翻訳するという点だ。一番わかりやすいのが PID 名前空間になる。
ホストのカーネルは全プロセスに通し番号(global PID)を振る。だが PID 名前空間の中では、プロセスは 1 から数え直した local PID で見える。新しい名前空間の最初のプロセスは必ず PID 1 で、これがコンテナの init だ。global と local を相互に翻訳する写像を持つだけでいい:
// PIDNamespace は「プロセス番号(PID)の独立した見え方」を表す。
// 新しい名前空間では最初のプロセスが必ず local PID 1(init)になり、
// 中のプロセスは同じ名前空間のプロセスしか番号で参照できない。
// host が付ける global PID とは別に、コンテナごとの local PID を割り振る。
type PIDNamespace struct {
nextLocal int
local map[int]int // global PID -> local PID
}
// NewPIDNamespace は空の名前空間を作る。最初の Add は local PID 1 を返す。
func NewPIDNamespace() *PIDNamespace {
return &PIDNamespace{nextLocal: 1, local: map[int]int{}}
}
// Add は host が割り当てた global PID を名前空間に登録し、local PID を返す。
func (ns *PIDNamespace) Add(global int) int {
l := ns.nextLocal
ns.nextLocal++
ns.local[global] = l
return l
}
// Remove は global PID を名前空間から外す(プロセスの回収)。
func (ns *PIDNamespace) Remove(global int) {
delete(ns.local, global)
}
// Local は global PID に対応する local PID を返す(未登録なら false)。
func (ns *PIDNamespace) Local(global int) (int, bool) {
l, ok := ns.local[global]
return l, ok
}
// Globals は名前空間に見えている global PID を local PID の昇順で返す。
func (ns *PIDNamespace) Globals() []int {
gs := make([]int, 0, len(ns.local))
for g := range ns.local {
gs = append(gs, g)
}
sort.Slice(gs, func(i, j int) bool { return ns.local[gs[i]] < ns.local[gs[j]] })
return gs
}同じ仕組みが他の資源にも効く。ネットワーク名前空間は独立したポート表を持つので、別々のコンテナが同時に :80 を bind できる(同じ名前空間内での二重 bind だけが衝突する):
// NetNamespace は独立したネットワークスタックの見え方。
// 別々の名前空間なら同じポート番号を同時に使える——だからコンテナごとに
// それぞれ :80 を bind できる。同じ名前空間内での二重 bind だけが衝突する。
type NetNamespace struct {
ports map[int]string // port -> 使用者(プロセス名など)
}
// NewNetNamespace は何も bind されていないネットワーク名前空間を作る。
func NewNetNamespace() *NetNamespace {
return &NetNamespace{ports: map[int]string{}}
}
// Bind はポートを確保する。同じ名前空間で既に使われていればエラー。
func (ns *NetNamespace) Bind(port int, who string) error {
if owner, ok := ns.ports[port]; ok {
return fmt.Errorf("ポート %d は %q が使用中", port, owner)
}
ns.ports[port] = who
return nil
}
// Ports は bind 済みのポートを昇順で返す。
func (ns *NetNamespace) Ports() []int {
ps := make([]int, 0, len(ns.ports))
for p := range ns.ports {
ps = append(ps, p)
}
sort.Ints(ps)
return ps
}マウント名前空間は独立したマウント表を持ち、パスを最長一致で実体に解決する。だから2つのコンテナが同じ /data に別々のボリュームを見られる:
// MountNamespace は独立したファイルシステムの見え方。
// マウント表(マウント先パス -> 実体)を持ち、パスは最長一致するマウントの
// 実体に解決される。既定ではすべてがコンテナ自身の rootfs に載る。
// だから 2つのコンテナが同じ /data に別々のボリュームをマウントできる。
type MountNamespace struct {
mounts map[string]string // マウント先 -> ソース(実体の名前)
}
// NewMountNamespace は / に rootfs だけがある名前空間を作る。
func NewMountNamespace() *MountNamespace {
return &MountNamespace{mounts: map[string]string{"/": "rootfs"}}
}
// Mount は target のパスに source(実体)を割り当てる。
func (ns *MountNamespace) Mount(target, source string) {
ns.mounts[target] = source
}
// Resolve は path を、最長一致するマウントの実体に解決する。
func (ns *MountNamespace) Resolve(path string) string {
best, bestLen := "rootfs", -1
for mp, src := range ns.mounts {
if covers(mp, path) && len(mp) > bestLen {
best, bestLen = src, len(mp)
}
}
return best
}
// covers は path がマウント先 mp の配下かどうかを、セグメント境界で判定する。
// "/data" は "/data/x" を覆うが "/database" は覆わない。
func covers(mp, path string) bool {
if mp == "/" {
return true
}
return path == mp || strings.HasPrefix(path, mp+"/")
}ホスト名(UTS 名前空間)も同じ発想の最小版で、コンテナごとに1つの文字列を持つだけ。本来グローバルな1つの資源を、コンテナごとに別インスタンスに見せる。これが namespace の一貫した正体だ。
② cgroup: 資源に予算をつける
見え方を分けても、資源の奪い合いは防げない。1つのコンテナがメモリを食い尽くせば、同じカーネルを共有する全員が巻き添えになる。そこで cgroup(control group)が資源に上限をつける。
重要なのは階層であることだ。cgroup は木構造で、親の制限は子孫すべてに効く。メモリを計上(charge)するたびに、自分から先祖までさかのぼって「どこかの上限を超えないか」を確認し、超えるなら何も変えずに拒否する(全か無か)。使用量は親にロールアップされるので、親は配下の合計を常に把握できる:
// CGroup は資源(メモリ・プロセス数)の階層的な予算。
// 親の制限は子孫すべてに効く。charge のたびに自分から先祖までさかのぼって
// どこかの上限を超えないか確認し、超えるなら「全か無か」で拒否する。
// 使用量は先祖にロールアップされるので、親は配下の合計を常に把握できる。
type CGroup struct {
name string
parent *CGroup
memLimit int64 // バイト。0 は無制限
memUsage int64 // このグループ配下の合計メモリ
pidsLimit int // 0 は無制限
pidsUsage int // このグループ配下の合計プロセス数
}
// NewCGroup はルート(制限の根)となる cgroup を作る。
func NewCGroup(name string, memLimit int64, pidsLimit int) *CGroup {
return &CGroup{name: name, memLimit: memLimit, pidsLimit: pidsLimit}
}
// NewChild は自分の下にぶら下がる子 cgroup を作る。子の使用量は親にも積まれる。
func (cg *CGroup) NewChild(name string, memLimit int64, pidsLimit int) *CGroup {
return &CGroup{name: name, parent: cg, memLimit: memLimit, pidsLimit: pidsLimit}
}
// Charge はメモリを計上する。自分から先祖までのどれかの上限を超えるなら
// 何も変えずにエラーを返す(OOM = 全か無か)。
func (cg *CGroup) Charge(bytes int64) error {
for c := cg; c != nil; c = c.parent {
if c.memLimit > 0 && c.memUsage+bytes > c.memLimit {
return fmt.Errorf("cgroup %q: メモリ上限超過 (%d + %d > %d)", c.name, c.memUsage, bytes, c.memLimit)
}
}
for c := cg; c != nil; c = c.parent {
c.memUsage += bytes
}
return nil
}
// Uncharge は計上したメモリを戻す(プロセスの終了)。先祖まで減らす。
func (cg *CGroup) Uncharge(bytes int64) {
for c := cg; c != nil; c = c.parent {
c.memUsage -= bytes
if c.memUsage < 0 {
c.memUsage = 0
}
}
}
// AddProcess はプロセス数を 1 増やす。pids 上限を超えるなら拒否する。
func (cg *CGroup) AddProcess() error {
for c := cg; c != nil; c = c.parent {
if c.pidsLimit > 0 && c.pidsUsage+1 > c.pidsLimit {
return fmt.Errorf("cgroup %q: プロセス数の上限 (%d)", c.name, c.pidsLimit)
}
}
for c := cg; c != nil; c = c.parent {
c.pidsUsage++
}
return nil
}
// RemoveProcess はプロセス数を 1 減らす。先祖まで減らす。
func (cg *CGroup) RemoveProcess() {
for c := cg; c != nil; c = c.parent {
if c.pidsUsage > 0 {
c.pidsUsage--
}
}
}
// MemUsage はこのグループ配下の合計メモリ使用量(バイト)。
func (cg *CGroup) MemUsage() int64 { return cg.memUsage }
// MemLimit はこのグループのメモリ上限(バイト、0 は無制限)。
func (cg *CGroup) MemLimit() int64 { return cg.memLimit }
// PidsUsage はこのグループ配下の合計プロセス数。
func (cg *CGroup) PidsUsage() int { return cg.pidsUsage }この「先祖まで確認してから、先祖まで加算する」の2周ループが中心になる。1周目で全階層の上限をチェックし、通ったら2周目で全階層に反映する。途中で失敗しても何も汚さない。プロセス数(pids.max)もまったく同じロジックで上限をかけられる。
③ 組み立て: Host と Container
Host は1つの共有カーネル。global PID を全コンテナ通しで発番し、ルート cgroup でマシン全体のメモリを束ねる。Container は namespace 一式と、host 配下の子 cgroup を束ねたもの:
// NewContainer は host の上に新しいコンテナを作る。それぞれ独立した
// PID / ネットワーク / マウント名前空間と、host 配下の子 cgroup を持つ。
func (h *Host) NewContainer(cfg Config) *Container {
return &Container{
Name: cfg.Name,
host: h,
pids: NewPIDNamespace(),
net: NewNetNamespace(),
mnt: NewMountNamespace(),
cg: h.root.NewChild(cfg.Name, cfg.MemLimit, cfg.PidsLimit),
hostname: cfg.Hostname,
}
}
// Spawn は新しいプロセスを起こす。host から global PID を取り、PID 名前空間に
// 登録して local PID を得て、cgroup にプロセス数とメモリを計上する。
// pids 上限やメモリ上限を超えると起動を拒否し、計上は元に戻す(全か無か)。
func (c *Container) Spawn(name string, memBytes int64) (*Process, error) {
if err := c.cg.AddProcess(); err != nil {
return nil, err
}
if err := c.cg.Charge(memBytes); err != nil {
c.cg.RemoveProcess()
return nil, err
}
global := c.host.nextPID
c.host.nextPID++
local := c.pids.Add(global)
p := &Process{GlobalPID: global, LocalPID: local, Name: name, MemBytes: memBytes}
c.host.procs[global] = p
return p, nil
}
// Kill は local PID のプロセスを終了させ、cgroup の計上を戻して回収する。
func (c *Container) Kill(localPID int) error {
for _, g := range c.pids.Globals() {
if l, _ := c.pids.Local(g); l == localPID {
p := c.host.procs[g]
c.cg.Uncharge(p.MemBytes)
c.cg.RemoveProcess()
c.pids.Remove(g)
delete(c.host.procs, g)
return nil
}
}
return fmt.Errorf("PID %d は存在しない", localPID)
}
// Processes はコンテナから見えるプロセスを local PID の昇順で返す。
// 他のコンテナのプロセスは見えない(PID 名前空間の隔離)。
func (c *Container) Processes() []*Process {
globals := c.pids.Globals()
out := make([]*Process, 0, len(globals))
for _, g := range globals {
if p, ok := c.host.procs[g]; ok {
out = append(out, p)
}
}
return out
}Spawn の atomic 性に注目してほしい。プロセス数とメモリを順に計上し、途中でどちらかの上限に当たったら、それまでの計上を戻して起動そのものを失敗させる。半端に資源を掴んだまま失敗することがない。これが「OOM でコンテナ内のプロセス起動が弾かれる」の正体だ。
動かす
下のデモは、この隔離モデルをそのままブラウザで動かしている(Go 実装の考え方を JS に移植)。2つのコンテナ web / db にプロセスを起こし、メモリ上限まで積んでいける。両方の init が PID 1 なこと、両方が :80 を掴めること、そして片方がメモリ上限に当たっても(OOM バッジ)もう片方は無事なことを確かめてほしい。下段には host から見た global PID の通し番号が並ぶ。これが1つのカーネルを共有している証拠だ。
設計の観点: コンテナ vs VM、そして隔離の強さ
- コンテナ ≠ VM: VM はハイパーバイザの上でカーネルごと動かす(強い隔離・重い)。コンテナはホストのカーネルを共有し、namespace + cgroup で隔離する(軽い・起動が速い・が隔離は相対的に弱い)。「軽量 VM」という比喩は起動の軽さを指すが、仕組みは根本的に違う
- 隔離の弱点: カーネルを共有する以上、カーネルの脆弱性を突かれるとコンテナの壁を越えられる(コンテナエスケープ)。だから信頼境界をまたぐマルチテナントでは、gVisor(ユーザ空間カーネル)や Firecracker / Kata(軽量 VM)で「カーネルも分ける」層を足すことがある
- なぜ起動が速いか: OS を起動しない。namespace を作ってプロセスを1つ
execするだけ。ミリ秒〜秒で立ち上がる。これがオートスケールや FaaS(サーバレス)を支える - イメージのレイヤ: 本章では触れないが、コンテナイメージは overlay ファイルシステムの積層(読み取り専用レイヤ + 書き込み可能な最上層)。マウント名前空間の応用で、起動を軽く・ディスクを共有する
メリット・デメリットと実例
| 隔離方式 | 仕組み | 隔離の強さ | 起動 | 実例 |
|---|---|---|---|---|
| プロセスのみ | 同じ OS で普通に起動 | 弱い | 最速 | 従来のデーモン |
| コンテナ | namespace + cgroup、カーネル共有 | 中 | 速い(ms〜s) | Docker、containerd、Kubernetes Pod |
| 軽量 VM | 最小化した VM を1つずつ | 強い | 中(〜100ms) | Firecracker(AWS Lambda)、Kata |
| フル VM | ハイパーバイザ + 独立カーネル | 最強 | 遅い(秒〜) | EC2、VMware |
裏どり:
- Docker / containerd:
clone(2)にCLONE_NEWPIDなどの flag を渡して namespace を作り、/sys/fs/cgroupに書いて資源制限をかける。本章のSpawnはその atomic な計上をモデル化したもの - Kubernetes の Pod: 複数コンテナで PID/ネットワーク名前空間を共有する単位。同じ Pod 内なら
localhostで通信でき、:80を1つのコンテナしか使えない。本章の「同一名前空間の二重 bind は衝突」がそのまま効く - AWS Lambda / Fargate: 起動の速さと隔離の強さを両立するため、コンテナではなく Firecracker 軽量 VM を使う。マルチテナントで「カーネルも分ける」判断の実例
- cgroup v2 の pids.max / memory.max: 本章の
PidsLimit/MemLimitと同じ。fork 爆弾を pids.max で止め、OOM を memory.max で制御する
簡略化したこと
- syscall を使わない: 本物は
clone/unshareと/sys/fs/cgroupで実現する。ここはその仕組みのモデルで、実プロセスは作らない - namespace は4種類だけ: PID・ネットワーク・マウント・UTS(ホスト名)。user / IPC / time / cgroup 名前空間は省略
- cgroup は memory と pids のみ: CPU シェア・ブロック I/O・帯域制限は無し
- メモリは宣言値: プロセスが使う量を引数で受け取る。実測・ページング・スワップは無し
- ネットワークは bind のみ: veth・ブリッジ・ルーティング・NAT は扱わない
- イメージ・レイヤ FS なし: overlayfs によるイメージの積層は別の話題(マウント名前空間の応用)
参考資料
- Linux man pages:
namespaces(7)/cgroups(7)— 一次資料 - Liz Rice, Container Security(O'Reilly)/ "Building a container from scratch in Go"(GOTO 講演) — Go でゼロから作る名講演
- opencontainers/runc — namespace と cgroup を実際に叩く低レベルランタイム
- 実装: foundations/container