OOM killer
実装:
foundations/oom// 実行:go test ./foundations/oom/
メモリが足りないときの素直な答えは断ることだが、Linux は既定で断らない。予約の合計が物理の1.6倍でも通り、帳尻は実際にページを触った時点で合わせる。そこで選ばれるのは足りなくした本人ではなく、殺して空く量がいちばん大きい相手になる。本人を殺す方式と比べると、空く量は100と800で8倍違った。
この章で作るもの
コンテナの章で cgroup を作ったとき、メモリの上限は「超えたら断る」だった。 実物の Linux は、既定ではそうしない。断らずに通しておいて、あとで殺す。
なぜそんな設計になっているのかと、殺す相手をどう選んでいるのかを作って測る。
順に見ていく。
- 申し込みと使用はずれる: 予約の合計が物理を超えても通る(オーバーコミット)
- 殺す相手は本人とは限らない: 選ぶ基準は「誰のせいか」ではなく「殺して空く量」
- 免除すると効きが落ちる: 大物を守ると、より小さい相手を何度も殺すことになる
① 申し込みと使用はずれる
// 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 ではない。申し込んだ量ではなく、 実際に抱えている量で選ぶ。殺して効くのはそこだけだからだ。
// 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回で足りて、要求も通る| 殺した数 | 空いた量 | 要求は通ったか | |
|---|---|---|---|
| 本人を殺す | 1 | 100 | 通らない(本人が死んだ) |
| 効く相手を殺す | 1 | 800 | 通る |
8倍違う。しかも本人を殺す方式では、要求を出した当人が消えるので仕事が進まない。 Linux が oom_score で選ぶのはこのためだ。
代償もはっきりしている。実測では、足りなくしたのは小物なのに、死んだのは大物だった。 4つが 400 ずつ申し込む筋書きでも、c が触ろうとした時点で a が殺されている。 申し込みを断る方式なら、影響は申し込んだ本人だけで閉じていた。 断らない代わりに、誰に当たるか分からない形で払っている。
1つ殺して足りなければ、足りるまで殺す。900 使っている状態で 700 を要求すると、 テストでは 2つ殺した。
③ 免除すると効きが落ちる
oom_score_adj を下げると選ばれにくくなり、-1000 にすると決して選ばれない。 データベースのように落としたくないものを守る使い方になる。
| db(600 抱えている) | web(300) | 殺されたのは | 空いた量 | |
|---|---|---|---|---|
| adj = 0 | score 600 | score 300 | db | 600 |
| adj = -1000 | score -1000 | score 300 | web | 300 |
守ったぶん、殺す相手が小さくなるので空く量が半分になる。テストで、 免除したときのほうが空く量が減ることを固定した。 守るというのは、代わりに誰かが余計に死ぬということになる。
全員を免除すると、殺せる相手が居なくなって打つ手が無くなる。 テストで、この状態でエラーになることも固定した。
動かす
左の切り替えで、断る側と断らない側を比べられる。断らない側では、 殺す相手の選び方も変えられる。
物理 1000 ・ 4 つのプロセスがそれぞれ 400 を申し込み、順に触る
| プロセス | 申し込み | 抱えている | oom_score | 状態 |
|---|---|---|---|---|
| a | — | — | — | 殺された |
| b | — | — | — | 殺された |
| c | 400 | 400 | 400 | 生きている |
| d | 400 | 400 | 400 | 生きている |
申し込みを断る側は、断られた本人がそれを知って諦められる。断らない側は、申し込みが通ったあとで 実際にページを触った瞬間に足りなくなり、そこで誰かが死ぬ。足りなくした本人が死ぬとは限らないのは、 殺す相手を「誰のせいか」ではなく「殺してどれだけ空くか」で選んでいるためになる。
設計の観点
- 断る時点と払う時点を分けない: 申し込みで断れば影響は本人に閉じる。触る時点まで延ばすと、当たる相手を選べなくなる
- 賭ける根拠を持つ: オーバーコミットは「申し込みの多くは触られない」という実測に賭けている。賭けである自覚が要る
- 責任ではなく効果で選ぶ: 誰のせいかで選ぶと、空く量が足りずに何度も殺すことになる
- 抱えている量で測る: 申し込んだ量で選ぶと、触っていない相手を殺して何も空かない
- 守ると誰かが余計に死ぬ: 免除は総量を増やさない。誰が死ぬかを付け替えるだけになる
- 殺した記録を残す: 「なぜ落ちたか」は後から分からなくなるので、誰が要求して誰が死んだかを記録する
対照と実例
| 足りないとき | 影響が及ぶ相手 | 断る時点 | |
|---|---|---|---|
| Linux の既定 | 殺す | 選ばれた誰か | 触った時点 |
overcommit_memory=2 | 断る | 申し込んだ本人 | 申し込みの時点 |
| cgroup のメモリ上限 | そのグループの中で殺す | グループ内の誰か | 触った時点 |
Kubernetes の limits.memory | コンテナを殺して再起動する | そのコンテナ | 触った時点 |
| CPU スロットリング | 止める(殺さない) | 枠を共有する全員 | 期間の途中 |
Go の GOMEMLIMIT | GC を強める(殺さない) | 自分のプロセス | 近づいた時点 |
裏どり:
- 既定は断らない:
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段だけ
- 殺す順の細かい規則なし: 実物は子プロセスを優先するなどの調整が入る
- スワップなし: 触った量がそのまま物理メモリの消費になる
参考資料
- Control Group v2(カーネル文書) —
memory.maxとmemory.oom.group - Overcommit Accounting(カーネル文書) — 断るか断らないかの3つの設定
- 実装: foundations/oom