Skip to content

コンテナ(namespace と cgroup でつくる隔離)

「コンテナ」の正体を Go でモデル化する。Docker の中身は Linux カーネルの 2 つの機能の組み合わせだ。namespace は、PID やポートなど本来 1 つしかない資源を、コンテナごとに別々に見せる。cgroup は、メモリやプロセス数の上限を予算として持ち、超えたら拒否する。コンテナは VM ではなく、カーネルを共有したまま制限を課したものだ。

この章で作るもの

「コンテナは軽量な VM」という説明をよく聞くが、これは正確ではない。VM はカーネルもハードウェアも丸ごと仮想化するが、コンテナはホストのカーネルをそのまま共有する。ではなぜ隔離されているように見えるのか。答えは Linux カーネルの2つの機能、namespacecgroup だけにある。

        ┌──────────────── 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│   │
        │   └────────────────────┘      └───────────────────┘   │
        └──────────────────────────────────────────────────────┘
コンテナは VM ではない。1つのカーネルを共有したまま、プロセスに『制限された見え方(namespace)』と『資源の予算(cgroup)』を与えたもの。web と db はどちらも自分の init を PID 1 だと思っており、どちらも :80 を掴めるが、その下では host が1つの PID 空間・1つのメモリプールを握っている

順に見ていく。

  1. namespace = グローバル資源の独立した見え方: PID・ポート・マウント・ホスト名を、コンテナごとに別インスタンスに見せる
  2. cgroup = 階層的な資源の予算: メモリとプロセス数の上限を木で持ち、超えたら全か無かで拒否する
  3. 組み立て = 共有カーネル + namespace 一式 + 子 cgroup: コンテナは VM ではなく、1つのカーネルの上に見え方と予算を重ねたもの

① namespace: 見え方を翻訳する

隔離の第一歩は「見え方」を分けること。ここで大事なのは、namespace は情報を消すのではなく、別の見え方に翻訳するという点だ。一番わかりやすいのが PID 名前空間になる。

ホストのカーネルは全プロセスに通し番号(global PID)を振る。だが PID 名前空間の中では、プロセスは 1 から数え直した local PID で見える。新しい名前空間の最初のプロセスは必ず PID 1 で、これがコンテナの init だ。global と local を相互に翻訳する写像を持つだけでいい:

go

// 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 だけが衝突する):

go

// 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 に別々のボリュームを見られる:

go

// 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)するたびに、自分から先祖までさかのぼって「どこかの上限を超えないか」を確認し、超えるなら何も変えずに拒否する(全か無か)。使用量は親にロールアップされるので、親は配下の合計を常に把握できる:

go

// 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 を束ねたもの:

go

// 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つのカーネルを共有している証拠だ。

デモcontainer(namespace + cgroup)host: 4 プロセス
webhostname: web:80
PID 名前空間(コンテナ内の見え方)
1initPID 14MiB
2nginx40MiB
cgroup メモリ(44 / 128MiB)
dbhostname: db:80
PID 名前空間(コンテナ内の見え方)
1initPID 18MiB
2postgres90MiB
cgroup メモリ(98 / 128MiB)
host(共有カーネル)から見た global PID — 通し番号 = 1つの PID 空間
1000web/init1001db/init1002web/nginx1003db/postgres
両コンテナの init はどちらも local PID 1、両方が :80 を bind — namespace が資源を別インスタンスに見せているdb は cgroup 上限 128MiB に当たると OOM。web は無事 — 資源は cgroup で区切られている

設計の観点: コンテナ 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 によるイメージの積層は別の話題(マウント名前空間の応用)

参考資料