Skip to content

OOM killer

実装: foundations/oom/ / 実行: go test ./foundations/oom/

メモリが足りないときの素直な答えは断ることだが、Linux は既定で断らない。予約の合計が物理の1.6倍でも通り、帳尻は実際にページを触った時点で合わせる。そこで選ばれるのは足りなくした本人ではなく、殺して空く量がいちばん大きい相手になる。本人を殺す方式と比べると、空く量は100と800で8倍違った。

この章で作るもの

コンテナの章で cgroup を作ったとき、メモリの上限は「超えたら断る」だった。 実物の Linux は、既定ではそうしない。断らずに通しておいて、あとで殺す

なぜそんな設計になっているのかと、殺す相手をどう選んでいるのかを作って測る。

順に見ていく。

  1. 申し込みと使用はずれる: 予約の合計が物理を超えても通る(オーバーコミット)
  2. 殺す相手は本人とは限らない: 選ぶ基準は「誰のせいか」ではなく「殺して空く量」
  3. 免除すると効きが落ちる: 大物を守ると、より小さい相手を何度も殺すことになる

① 申し込みと使用はずれる

go

// Proc は1つのプロセス。
type Proc struct {
	Name string
	// Reserved は確保を申し込んだ量。まだ触っていないぶんを含む。
	Reserved int
	// Touched は実際に触った量。物理メモリを消費しているのはこちらだけ。
	Touched int
	// ScoreAdj は oom_score_adj。-1000 なら決して選ばれない。
	ScoreAdj int
	// Dead は殺されたかどうか。
	Dead bool
}

// Score は oom_score。使っている量が物理メモリに占める割合を 0〜1000 で表し、
// oom_score_adj を足す。
//
// 見ているのは Touched であって Reserved ではない。**申し込んだ量ではなく、
// 実際に抱えている量**で選ぶ。殺して効くのはそこだけだからになる。
func Score(p *Proc, total int) int {
	if p.ScoreAdj <= -1000 {
		return -1000 // 免除
	}
	return p.Touched*1000/total + p.ScoreAdj
}

見ているのは Touched であって Reserved ではない。申し込んだ量ではなく、 実際に抱えている量で選ぶ。殺して効くのはそこだけだからだ。

go

// Reserve は確保を申し込む。
//
// オーバーコミットを切っていれば、予約の合計が物理を超えた時点で断る。
// 入れていれば通る。**通ったことは、あとで触れることを何も約束しない**。
func (s *System) Reserve(name string, n int) error {
	p := s.find(name)
	if p == nil || p.Dead {
		return fmt.Errorf("oom: %s は居ない", name)
	}
	if !s.Overcommit && s.Reserved()+n > s.Total {
		return fmt.Errorf("oom: 予約を断る (%d + %d > %d)", s.Reserved(), n, s.Total)
	}
	p.Reserved += n
	return nil
}

// Touch は予約したぶんを実際に触る。ここで初めて物理メモリを使う。
//
// 足りなければ、足りるまで殺す。1つ殺せば済むとは限らない。
func (s *System) Touch(name string, n int) error {
	p := s.find(name)
	if p == nil || p.Dead {
		return fmt.Errorf("oom: %s は居ない", name)
	}
	for s.Touched()+n > s.Total {
		victim := s.pick(p)
		if victim == nil {
			// 殺せる相手が居ない。ここまで来ると打つ手が無い。
			return fmt.Errorf("oom: 殺せる相手が居ない (%d + %d > %d)", s.Touched(), n, s.Total)
		}
		s.Kills = append(s.Kills, Kill{
			Requester: p.Name,
			Victim:    victim.Name,
			Score:     Score(victim, s.Total),
			Freed:     victim.Touched,
		})
		victim.Dead = true
		victim.Touched, victim.Reserved = 0, 0
		if p.Dead {
			return fmt.Errorf("oom: %s が殺された", p.Name)
		}
	}
	p.Touched += n
	if p.Touched > p.Reserved {
		p.Reserved = p.Touched
	}
	return nil
}

// pick は殺す相手を選ぶ。
func (s *System) pick(requester *Proc) *Proc {
	if s.Policy == Requester {
		if requester.ScoreAdj <= -1000 || requester.Touched == 0 {
			return nil
		}
		return requester
	}
	var best *Proc
	for _, p := range s.procs {
		if p.Dead || p.ScoreAdj <= -1000 || p.Touched == 0 {
			continue
		}
		if best == nil || Score(p, s.Total) > Score(best, s.Total) {
			best = p
		}
	}
	return best
}

func (s *System) find(name string) *Proc {
	for _, p := range s.procs {
		if p.Name == name {
			return p
		}
	}
	return nil
}

物理 1000 に、4つのプロセスがそれぞれ 400 を申し込む筋書きで測った。

結果殺した数
申し込みを断る3つめで断られた0
断らず、あとで殺す4つとも通った。予約は 1600(物理の1.6倍)触り始めると発生

断る側は、断られた本人がそれを知って諦められる。エラーが返るだけで誰も死なない。 断らない側は、通ったことが、あとで触れることを何も約束していない

なぜ断らないのか。プロセスは触らないメモリを大量に申し込むからだ。 fork した直後の子は親と同じだけ申し込んだ形になるし、確保しただけで使わない領域も多い。 正直に断ると、実際には空いているのに動かせないプログラムが出る。 過去の実測から「申し込みの多くは触られない」と分かっているので、賭けている

② 殺す相手は本人とは限らない

賭けに負けたときが問題になる。触った瞬間に足りなければ、誰かを殺してでも場所を作る。 ここで、足りなくした本人を殺す方式と、殺して空く量がいちばん大きい相手を殺す方式を 並べて測った。

  物理 1000
  ┌────────────────────────────────────────┐
  │ 大物 800                       │小物100│  ← ここへ 200 を触りに来る
  └────────────────────────────────────────┘

  本人を殺す      小物 †       空いた 100    要求した本人が死んだので仕事は進まない
  効く相手を殺す  大物 †       空いた 800    1回で足りて、要求も通る
800 抱えている相手が居るところに、小さいプロセスが 100 と 200 を触りに来る。誰を殺すかで、空く量も、要求が通るかも変わる
殺した数空いた量要求は通ったか
本人を殺す1100通らない(本人が死んだ)
効く相手を殺す1800通る

8倍違う。しかも本人を殺す方式では、要求を出した当人が消えるので仕事が進まない。 Linux が oom_score で選ぶのはこのためだ。

代償もはっきりしている。実測では、足りなくしたのは小物なのに、死んだのは大物だった。 4つが 400 ずつ申し込む筋書きでも、c が触ろうとした時点で a が殺されている。 申し込みを断る方式なら、影響は申し込んだ本人だけで閉じていた。 断らない代わりに、誰に当たるか分からない形で払っている。

1つ殺して足りなければ、足りるまで殺す。900 使っている状態で 700 を要求すると、 テストでは 2つ殺した

③ 免除すると効きが落ちる

oom_score_adj を下げると選ばれにくくなり、-1000 にすると決して選ばれない。 データベースのように落としたくないものを守る使い方になる。

db(600 抱えている)web(300)殺されたのは空いた量
adj = 0score 600score 300db600
adj = -1000score -1000score 300web300

守ったぶん、殺す相手が小さくなるので空く量が半分になる。テストで、 免除したときのほうが空く量が減ることを固定した。 守るというのは、代わりに誰かが余計に死ぬということになる。

全員を免除すると、殺せる相手が居なくなって打つ手が無くなる。 テストで、この状態でエラーになることも固定した。

動かす

左の切り替えで、断る側と断らない側を比べられる。断らない側では、 殺す相手の選び方も変えられる。

デモOOM killer2 個殺した
申し込みを断る断らず、あとで殺す効く相手を殺す本人を殺す

物理 1000 ・ 4 つのプロセスがそれぞれ 400 を申し込み、順に触る

申し込んだ量800
実際に触った量800
プロセス申し込み抱えているoom_score状態
a殺された
b殺された
c400400400生きている
d400400400生きている
c が触ろうとして、a が殺された。足りなくした本人と、殺された相手が違う

申し込みを断る側は、断られた本人がそれを知って諦められる。断らない側は、申し込みが通ったあとで 実際にページを触った瞬間に足りなくなり、そこで誰かが死ぬ。足りなくした本人が死ぬとは限らないのは、 殺す相手を「誰のせいか」ではなく「殺してどれだけ空くか」で選んでいるためになる。

設計の観点

  • 断る時点と払う時点を分けない: 申し込みで断れば影響は本人に閉じる。触る時点まで延ばすと、当たる相手を選べなくなる
  • 賭ける根拠を持つ: オーバーコミットは「申し込みの多くは触られない」という実測に賭けている。賭けである自覚が要る
  • 責任ではなく効果で選ぶ: 誰のせいかで選ぶと、空く量が足りずに何度も殺すことになる
  • 抱えている量で測る: 申し込んだ量で選ぶと、触っていない相手を殺して何も空かない
  • 守ると誰かが余計に死ぬ: 免除は総量を増やさない。誰が死ぬかを付け替えるだけになる
  • 殺した記録を残す: 「なぜ落ちたか」は後から分からなくなるので、誰が要求して誰が死んだかを記録する

対照と実例

足りないとき影響が及ぶ相手断る時点
Linux の既定殺す選ばれた誰か触った時点
overcommit_memory=2断る申し込んだ本人申し込みの時点
cgroup のメモリ上限そのグループの中で殺すグループ内の誰か触った時点
Kubernetes の limits.memoryコンテナを殺して再起動するそのコンテナ触った時点
CPU スロットリング止める(殺さない)枠を共有する全員期間の途中
Go の GOMEMLIMITGC を強める(殺さない)自分のプロセス近づいた時点

裏どり:

  • 既定は断らない: vm.overcommit_memory の既定は 0 で、明らかに無茶な要求だけ弾く発見的な判定になっている。2 にすると overcommit_ratio で厳密に断るようになるが、そうすると動かなくなるプログラムが出るので既定にはなっていない
  • score の作り: oom_score は使用量が全体に占める割合を 0〜1000 で表したもので、oom_score_adj を足して出す。/proc/<pid>/oom_score で読める。oom_score_adj = -1000 は完全な免除で、カーネル文書にそう書いてある
  • 殺される順は直感と合わない: JVM やデータベースのように大きく抱えるものが真っ先に選ばれる。いちばん抱えている相手が、たいてい落としたくない相手になるので、oom_score_adj を下げる運用が広く行われている
  • cgroup v2 の memory.oom.group: グループ内の1つを殺すのではなく、グループごと全部殺す設定がある。バラバラに殺されて中途半端な状態になるより、まとめて落として作り直すほうが直しやすいという判断になる
  • Kubernetes では再起動になる: limits.memory を超えたコンテナは殺されて OOMKilled として再起動する。ノード全体が足りない場合は、Pod の QoS クラスに応じた oom_score_adj が効く

簡略化したこと

  • ページ単位で扱わない: 実物はページごとに触るかどうかが決まる。ここは量だけで数える
  • 共有メモリなし: fork した親子が同じページを共有する話は扱わない。実物の score はここを差し引く
  • 回収を試みない: 実物は殺す前に、キャッシュを捨てたりスワップへ追い出したりする
  • cgroup の階層なし: コンテナの cgroup は親子で効いたが、ここは1段だけ
  • 殺す順の細かい規則なし: 実物は子プロセスを優先するなどの調整が入る
  • スワップなし: 触った量がそのまま物理メモリの消費になる

参考資料