Kubernetes クラスター全体を実働不可能にさせかねない不注意な memory-backed emptyDir の利用

# 本稿について

本稿の内容は、[Kubernetes Documentation / Concepts / Configuration / Resource Management for Pods and Containers - Considerations for memory backed emptyDir volumes](https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/#memory-backed-emptydir) に書いたものの詳細版です。前述のリンクもご参照いただければ幸いです。

体調を崩して登壇を断念した Open Source Summit Japan 2024 にて発表するはずだった内容でもあります。私事で恐縮ですが、ここで供養させていただきます。残念ながら、諸事情により調査・検証を完結できておらず、一部尻切れトンボな部分があることも併せてお詫びいたします。

# はじめに

最近のアプリケーションでは、ビデオや画像などの大容量ファイルの出力や、より包括的なログの生成がますます必要になっています。これらを素早く処理するために、従来の HDD や SSD よりも高速な、メモリ利用の一時ディスク（RAM ディスク）が使用されるようになってきました。

Kubernetes では、tmpfs ベースのメモリ利用一時ディスクを抽象化した [emptyDir](https://kubernetes.io/docs/concepts/storage/volumes/#emptydir) (memory-backed emptyDir、マニフェストで `emptyDir.medium` に `"Memory"` を指定する) によって RAM ディスクが実現されています。

ただし、不注意に使用すると、memory-backed emptyDir は Kubernetes クラスター全体の安定性に重大な影響を及ぼす可能性があります。

平たく言えば、RAM ディスクを使いすぎてメモリが枯渇し、ノードに重大な影響を及ぼしかねない、ということです。

# memory-backed emptyDir がノードクラッシュを引き起こす原因とは

それでは、Kubernetes に存在するノードクラッシュの原因となり得る要因を説明していきます。

## 1. デフォルトサイズ（サイズ未指定）の memory-backed emptyDir は、ほぼノードのメモリサイズになる

Kubernetes の memory-backed emptyDir では tmpfs を利用しているわけですが、[Kubernetes Documentation / Concepts / Storage / Volumes - emptyDir の 2 つ目の Note](https://kubernetes.io/docs/concepts/storage/volumes/#emptydir) によると、memory-backed EmptyDir のデフォルトサイズが ["Node Allocatable"](https://kubernetes.io/docs/tasks/administer-cluster/reserve-compute-resources/#node-allocatable)となっています。このサイズは、既存のコンテナーが使用済みのメモリ量に関係なく、ノードのメモリ量から "kube-reserved"、"system-reserved"、"eviction-threshold" の3つを引いた固定の値になります。前者2つはデフォルト値がなく（つまりゼロ）、"eviction-threshold" のデフォルト値が "100Mi" であり、デフォルト値のままのノードの場合、"Node Allocatable"　のサイズは、ノードのメモリ量 - "eviction-threshold" 100Mi であり、つまりは「デフォルトサイズの memory-backed emptyDir は、ほぼノードのメモリサイズになる」というわけです。

前述のドキュメントには書かれていませんが（他の場所には書いてあるかも？）、ホントは後述する通り、コンテナーのメモリ使用量上限が設定されていた場合、それが memory-backed emptyDir のデフォルトサイズになります。

## 2. デフォルトサイズ（サイズ未指定）の memory-backed emptyDir の作成時に、emptyDir のボリュームサイズを使用メモリ量として確保・管理・確認しない

Kubernetes は、デフォルトサイズ（サイズ未指定）の memory-backed emptyDir に割り当てたメモリ量を確保も管理もしていませんし、作成時に「ほぼノードのメモリ量」となるメモリ割り当て量が空いているかも確認しません。定期的に tmpfs の使用量を cgroup に問い合わせて把握するのみです。つまり、メモリ使用量上限が設定されていないコンテナーが作成できたなら、「ほぼノードのメモリ量」となるサイズだろうと tmpfs が作れてしまうわけです。そして、コンテナーが作成できる限り、tmpfs も同様に幾つでも作れてしまいます。

## 3. OOMkill されるコンテナーの削除とそのコンテナーが使っていた tmpfs の削除に数分間のタイムラグがある

メモリ利用の emptyDir (tmpfs) を消費しすぎたコンテナーがいた場合、OOMkiller がメモリを使いすぎたプロセス（コンテナー）としてこれを削除します。しかし、OOMkiller はそのプロセス（コンテナー）が使っていた tmpfs までも削除してくれるわけではないことに注意が必要です。Kubernetes においては、kubelet が tmpfs の削除の役割を担っています。コンテナーの OOMkiller による削除が行われてから、kubelet によってコンテナーの削除が検知され、コンテナーが利用していた tmpfs が削除されるまでの間にある程度の時間がかかります。このタイムラグは、kubelet がコンテナーの状態を見て回るサイクルに依存するわけですが、これが数分のオーダーになっています。つまり、tmpfs が削除されるまでの数分間、メモリが開放されない状態になる可能性があるわけです。

## 4. デフォルトサイズの memory-backed emptyDir をマウントするコンテナーが `ReplicaSet` によって再作成される

デフォルトサイズの memory-backed emptyDir をマウントするコンテナーが `pod` で作成する1個しかなければ問題ないかも知れません。しかしながら、`deployment` で `replicas` の設定がワーカーノード数の2〜3倍の数が設定されていたとしたらどうでしょう。たとえ、お行儀の悪い（初心者が作ってしまった、あるいは悪意を持った）コンテナーや tmpfs が一度は削除されたとしても、次々とこのようなコンテナーと tmpfs が作成されて、メモリの開放が追いつかなくなっていくわけです。そして、それがワーカーノードがいる限り続きます。もしすべてのワーカーノードのメモリが使い切った tmpfs で埋め尽くされたとしたら・・・。

# 結果的にどうなるか

このストーリーを辿ってしまうと、1. ほぼノードのメモリ量の tmpfs を使うコンテナーが、2. 幾つでも作れてしまうし、3. メモリ利用の tmpfs を消費しすぎたコンテナーが OOMkill されてもすぐには tmpfs が開放されないので、4. 別のコンテナーが同じようにメモリ利用の tmpfs を消費しすぎてしまったら、かつすべてのワーカーノードでこのような状況が起こったら、**すべてのワーカーノードのメモリが枯渇して、クラスタ全体が使用に耐えない状態になってしまう**わけです。

## メモリが枯渇したワーカーノードは実際にどうなったか

私が Ubuntu で構築したワーカーノードで確認された現象は以下の通りでした。

* ほぼノードのメモリサイズの tmpfs が幾つか残っている
  + 当然、その合計使用量はほぼノードのメモリサイズ
  + これらの tmpfs を使っていたはずのコンテナーはすでにいない
* kubelet は死んでいる
  + つまり、誰も tmpfs を削除しない状況になっている
* logind も再起動を繰り返している
  + SSH でログインできたりできなかったり、ログインできてもすぐにログアウトしてしまったりという状態になっている
  + そんなままならない状況の中、コンソールからログインして限りある時間で調査したところ、logind が OOMkill される、systemd によって再起動される、というログが観測できた
  + つまり、cgroup の systemd slice に属するサービスにも影響が出ている
    - cgroup v2 の動きはよく分からないまま、調査は終了している

もし、`namespace` でマルチテナントを実現している場合には、よそのテナントにまで迷惑を掛けてしまうことになります。恐ろしいことです・・・。

# 対策

_**結論：`Pod.spec.containers[].resources.limits.memory` を設定するよりほかありません。**_

どのような設定が対策として有効なのか調査・検証したところ、下記のことが判明しました。
* `emptyDir` の `sizeLimit` だけ指定してもダメでした
  もちろん memory-backed emptyDir のサイズに反映されるのですが、このサイズは Pod や emptyDir の作成時に Kubernetes にメモリ使用量としてチェックされず、管理対象にもならず、「コンテナーが作れれば tmpfs もとにかく作れる」という状況を回避できませんでした
* `Pod.spec.containers[].resources.limits.memory` のサイズが memory-backed emptyDir のデフォルトサイズになることが分かった
  このメモリ使用上限のサイズが、Pod や emptyDir の作成時に Kubernetes にチェックされて、メモリ使用量の管理対象になり、このサイズを確保できなければ Pod は作成されず、「コンテナーが作れれば tmpfs もとにかく作れる」という状況を回避できました
* `LimitRange` で `Pod.spec.containers[].resources.limits.memory` のデフォルト値を設定できるのでは？と思ったけど、ダメでした
  emptyDir のデフォルトサイズにはなるのですが、`sizeLimit` 同様、このサイズは Pod や emptyDir の作成時に Kubernetes にメモリ使用量としてチェックされず、管理対象にもならず、「コンテナーが作れれば tmpfs もとにかく作れる」という状況を回避できませんでした

では、アプリケーション開発者、名前空間管理者、クラスター管理者の観点から、どのような対策ができるか述べてみます。

## アプリケーション開発者として（CKAD 保持者相当）

※ここではアプリケーションのマニフェストを書く人を想定します

それはもう、自分のアプリケーションのマニフェストに`Pod.spec.containers[].resources.limits.memory` を設定するよりほかありません。自らのアプリケーションが他に影響を与えないようにしましょう。

## 名前空間管理者として

※ここではプロジェクト管理者を想定します

namespace へのメモリ使用量上限（`ResourceQuota` の `spec.hard.limits.memory`）を設定することで、`Pod.spec.containers[].resources.limits.memory` の設定を強制することが可能になります。

```
apiVersion: v1
kind: ResourceQuota
metadata:
  name: mem-limit
spec:
  hard:
    limits.memory: 512Mi
```

ただし、すべての `Pod` や memory-backed emptyDir へのメモリサイズ指定が必須になるため、アプリケーション開発者はマニフェストにいちいち設定を追加しないと新たなデプロイができなくなります。面倒だ！とアプリケーション開発者に怒られるかも知れませんが、アプリケーション開発者にメモリ管理を意識させることができるので、それはそれで良いかなとも思います。

## クラスター管理者として（CKA 保持者相当）

※ここではクラスターを構築して運用する人を想定します

`Admission Webhook` によるマニフェストの mutation や validation を実装できます。そんな実装がどこかに転がっていることを願います（探せていないですゴメンナサイ）。

# 検証環境情報

## ノードの情報

* VM ホスト
  + CPU: Core i7 4790
  + メモリ: 32GB
  + HDD: 500GB
  + OS: Ubuntu 22.04.2
* マスターノード1台、ワーカーノード3台
  + libvirtd v8.0.0 で作った VM 4台、すべて下記の同スペック
  + CPU: 2個
  + メモリ: 2GB
  + ディスク: 25GB
  + OS: Ubuntu 22.04.2

## クラスター情報

* Kubernetes: v1.27.4
  + kubeadm v1.27.4 で構築
* containerd: v1.7.2

```
root@master01:~# kubectl get nodes -owide
NAME       STATUS   ROLES           AGE    VERSION   INTERNAL-IP      EXTERNAL-IP   OS-IMAGE             KERNEL-VERSION      CONTAINER-RUNTIME
master01   Ready    control-plane   101m   v1.27.4   192.168.100.10   <none>        Ubuntu 22.04.2 LTS   5.15.0-76-generic   containerd://1.7.2
worker01   Ready    <none>          100m   v1.27.4   192.168.100.11   <none>        Ubuntu 22.04.2 LTS   5.15.0-76-generic   containerd://1.7.2
worker02   Ready    <none>          99m    v1.27.4   192.168.100.12   <none>        Ubuntu 22.04.2 LTS   5.15.0-76-generic   containerd://1.7.2
worker03   Ready    <none>          99m    v1.27.4   192.168.100.13   <none>        Ubuntu 22.04.2 LTS   5.15.0-76-generic   containerd://1.7.2
```

## すべてのワーカーノードのメモリを枯渇させる `Deployment`

```
apiVersion: apps/v1
kind: Deployment
metadata:
  creationTimestamp: null
  labels:
    app: memvol
  name: memvol
spec:
  # replicas をワーカーノード数の2〜3倍にしておく 
  replicas: 6
  selector:
    matchLabels:
      app: memvol
  strategy: {}
  template:
    metadata:
      creationTimestamp: null
      labels:
        app: memvol
    spec:
      containers:
      - image: busybox
        name: busybox
        command: ["sh"]
        args:
        - "-c"
        - "sleep 5 && dd if=/dev/zero of=/memvol/dump bs=1M"
        volumeMounts:
        - name: memvol
          mountPath: /memvol
      volumes:
      - name: memvol
        emptyDir:
          medium: Memory
```

# 補足１：この課題がどこからきて、どこへいくのか

* [ことの発端はこれでした](https://github.com/kubernetes/website/issues/40723#issuecomment-1652872604)
  + もともとドキュメントには memory-backed emptyDir のデフォルトサイズは、「**ノードのメモリサイズの半分**」と書かれていたのだが、実装は「**ほぼノードのメモリサイズ**」だったことで、ドキュメントと実装が違うじゃないか、という話でした
  + **そんなことより、どっちだろうと RAM ディスクのサイズとしては大きすぎることが問題だろう**、と思ったことが始まりです
* [このIssueで議論していました](https://github.com/kubernetes/kubernetes/issues/119611)
  + すぐにできることとして、ドキュメントで注意喚起をしようと考え、本稿の冒頭に記載したドキュメントを書きました
  + プロセスだろうと RAM ディスクだろうとメモリ管理としては同じじゃね？という議論については、後述の補足２を参照してください
* [今後はこのKEPがドライブしていくものと思われます](https://github.com/kubernetes/enhancements/pull/4812)
  安全に memory-backed emptyDir を利用できるようになることを祈ります

残念ながら、諸事情によりこの件について私がこれ以上追っていくことはありません。

# 補足２：プロセスが使うメモリの管理と同じなのでは？という議論

## ユースケースの違い

ストレージとしてメモリを使用する場合、アプリケーションによる一般的なメモリ使用とはユースケースが異なります。ストレージとして利用する場合、異なるアプリケーション間でこのストレージを共有したりすることになりますが、どちらのアプリケーションがファイルのガベージコレクションの責任を持つのかが不明瞭になる可能性があるわけです。どちらのアプリケーションが不要になったファイルを削除するのか、削除する側のアプリケーションが死んでしまったらどうするのか、ストレージがいっぱいになってしまったらどうするのか、ファイルを吐き出せなかったらアプリケーションはいっそのこと終了した方がいいのか、などアプリケーション側だけでなく、運用側の事情にもよるところが出てくるかと思います。特にログまわりで「全部吐き出せ、取りこぼしは許さない、勝手に消してくれるな」などと言われたりする場合は、運用側の事情が大きいですよね。

## プロセス（コンテナー）と tmpfs のメモリ管理が `OOMkiller` と `kubelet` に分かれている

どちらも同じものが管理できていればよかったのですが残念なことに、別々なのでどうしてもタイムラグができてしまいます。単純に `kubelet` による監視サイクルを短くすればよいのでは？とはなりませんし。性能やシステムリソースとの兼ね合いですね。

個人の見解ですが、プロセスが使うメモリの管理とは同じではない、ということですね。