[Kubernetes]Dockerからcontainerdへ変更後node-exporterのファイル関連のメトリクスが不正確になった事例紹介

# はじめに

Kubernetes v1.24では[dockershimが削除](https://github.com/kubernetes/kubernetes/pull/107309)されます。そのため、このタイミングでContainer RuntimeをDockerから別のものへ切り替える人もいるのではないでしょうか。
Container RuntimeをDockerからcontainerdへ変更した際に、私達のチームではnode-exporterで取得するPVC/PVのファイルシステムのサイズが正しく取得できない事象が発生しました。
そこで、本ドキュメントでは、本事象の原因調査し対応した事例をベースに、Mount Propagationの挙動を紹介します。

なお、本事象はprometheus/node_exporterの[Issue #474](https://github.com/prometheus/node_exporter/issues/474)で修正済みのため、本IssueがFixされる前のnode-exporterのManifestを利用している場合や設定値のアップデートで漏れていた場合などで発生します。prometheus-community/helm-chartsでは[この修正コミット](https://github.com/prometheus-community/helm-charts/commit/56006431524a2106a751bd0d2ff1b6e0ac7132b1)、prometheus-operatorでは[PR #1806](https://github.com/prometheus-operator/prometheus-operator/pull/1806)にて修正済みです。

# 発生事象

Dockerからcontainerdへ切り替えた際、ルートディレクトリ(`/`)のMount Propagationの挙動で注意が必要です。
例えば、Podにてnode-exporterを実行しており、ルートディレクトリ(`/`)をマウントする際、以下のManifest(node-exporterのPodのManifest(一部抜粋))のように`containers.volumeMounts.mountPropagation`の指定を省略している場合、


```YAML
...
      containers:
      - name: node-exporter
        image: prom/node-exporter:v1.3.1
...
        args:
...
        - --path.rootfs
        - "/rootfs"
...
        volumeMounts:
        - mountPath: /rootfs
          name: root-volume
          readOnly: true
...
      volumes:
      - name: root-volume
        hostPath:
          path: /
...
```

PVC/PVでマウントした外部ストレージのボリューム上のファイルシステムのメトリクス(e.g. `node_filesystem_size_bytes`など)の値が間違った値となります。


(例: 間違ったメトリクスの値)

- ノードのルートディレクトリに30Giのディスクを割り当て
- 同じノード上のPodにPVC/PVで50Giのボリュームを割り当て

上記環境において、PVC/PVのボリュームのサイズについて、上述のManifestのnode-exporterが返す`node_filesystem_size_bytes`を確認すると以下の値がそれぞれ返ってきます。

- Docker: 50Gi
- containerd: 30Gi

Dockerは正しくPVC/PVのボリュームの値が取得できますが、containerdは間違った値(ルートディレクトリの値)となります。

※ 説明のため簡略化しサイズを示していますが、メトリクスの正確な値はGiをBytesに変換且つFileSystemの管理情報サイズなどが引かれた値となります


# 対象方法

node-exporterの場合、ルートディレクトリ(`/`)のマウントオプションに`mountPropagation: HostToContainer`を追加すると正しいメトリクスが取得できます。

```diff
@@ -156,6 +156,7 @@
           name: sys-volume
           readOnly: true
         - mountPath: /rootfs
+          mountPropagation: HostToContainer
           name: root-volume
           readOnly: true
         - mountPath: /var/run/dbus/system_bus_socket
```

# なぜ、このような現象が発生するのか

本現象は、containerdに限らず、Docker以外のContainer Runtimeで発生する可能性があります。
この現象の解説では、PVC/PVを使いコンテナにボリュームをマウントする際、どのようにマウントされているのかの構成の理解と、マウントにおけるbindマウントのMount Propagationの理解が前提知識として必要となります。
コンテナにどのようにPVC/PVで作られたボリュームがマウントされているのかの構成については[「Kubernetesにおけるストレージ関連のメトリクス一覧 : PVのマウントと監視範囲」](https://qiita.com/ysakashita/items/cb471a561501b344534c#pvのマウントと監視範囲)をご参照ください。
Mount Propargationについては、以下に簡単な説明を行います。

## (前提知識) Mount Propagation とは

Kubernetesにおける[Mount propergation](https://kubernetes.io/docs/concepts/storage/volumes/#mount-propagation)とは、コンテナによってマウントされたボリュームを、同じPod内の他コンテナまたは同じノード上の他Podにマウント情報を共有できる機能です。
Kubernetesでは、Manifestの`containers.volumeMounts` の `mountPropagation`フィールドにて設定できます。
Linux Kernelでは[Shared Subtrees](https://www.kernel.org/doc/Documentation/filesystems/sharedsubtree.txt)とも呼ばれています。
ここでは、Kubernetesにて指定できるオプションのうち、良く利用するであろうデフォルトの`mountPropagation: None` (Linuxでは`private`オプション)と`mountPropagation: HostToContainer` (Linuxでは`rslave`オプション)について、その挙動の違いをUbuntu20.04上で動作させつつ解説します。

まず、事前に`/mnt`ディレクトリ以下に、以下のディレクトリ/ファイルを準備します。

```
/mnt# tree
.
├── bar
│   ├── d
│   ├── e
│   └── f
├── baz
├── foo
│   ├── a
│   ├── b
│   └── c
└── qux
```

次に、`/mnt/foo`を`/mnt/baz`,`/mnt/qux`にbindマウントします。

```
/mnt# mount --bind /mnt/foo /mnt/baz
/mnt# mount --bind /mnt/foo /mnt/qux
/mnt# tree
.
├── bar
│   ├── d
│   ├── e
│   └── f
├── baz
│   ├── a
│   ├── b
│   └── c
├── foo
│   ├── a
│   ├── b
│   └── c
└── qux
    ├── a
    ├── b
    └── c
```

ここまでは、bindマウントの基本的な動作になります。
この時のPropagationを見てみます。

```
/mnt# findmnt -o TARGET,PROPAGATION |grep /mnt
├─/mnt/baz                            shared
├─/mnt/qux                            shared
```

`shared`となっています。このモードは、全てのマウント情報が伝播されるモードです。

<details>
<summary>shared/rsharedとKubernetesでの注意点</summary>

Kubernetesでは、`mountPropagation: Bidirectional`を指定した場合、上記で説明した`shared`時のマウント情報の伝播をサブツリーまで再帰的に伝播させるオプションである`rshared`の動きとなります。
ただし、このモードはbindマウントしたディレクトリ(コンテナからアクセスできるディレクトリ)からホストの元のディレクトリへ情報が双方向に伝播(Propagtion)されるため、ホスト(ノード)を破壊する恐れがあります。そのため、利用する際は十分に気をつける必要があります。
[Kubernetesの公式ドキュメントでも以下の注意書きが記載](https://kubernetes.io/docs/concepts/storage/volumes/#mount-propagation)されています。

```
Warning: Bidirectional mount propagation can be dangerous. It can 
damage the host operating system and therefore it is allowed only in 
privileged containers. Familiarity with Linux kernel behavior is strongly 
recommended. In addition, any volume mounts created by containers in pods 
must be destroyed (unmounted) by the containers on termination.
```

</details>

次に、`private`と`rslave`の動作を確認します。
`/mnt/baz`を`private`に、`/mnt/qux`を`rslave`(`slave`を再帰的にサブツリーに伝播するオプション)に設定し、マウント情報の伝播について確認します。

```
/mnt# mount --make-private /mnt/baz
/mnt# mount --make-rslave /mnt/qux

/mnt# findmnt -o TARGET,PROPAGATION |grep /mnt
├─/mnt/baz                            private
├─/mnt/qux                            private,slave

# /mnt/fooの配下にbarディレクトリを作成しbindマウントします
/mnt# mkdir /mnt/foo/bar
/mnt# tree
.
├── bar
│   ├── d
│   ├── e
│   └── f
├── baz
│   ├── a
│   ├── b
│   ├── bar
│   └── c
├── foo
│   ├── a
│   ├── b
│   ├── bar
│   └── c
└── qux
    ├── a
    ├── b
    ├── bar
    └── c

# /mnt/bar を /mnt/foo/bar へbindマウントし /mnt/baz (private), /mnt/qux (rslave) へのマウント情報の伝播を確認します
/mnt# mount --bind /mnt/bar /mnt/foo/bar
/mnt# tree
.
├── bar
│   ├── d
│   ├── e
│   └── f
├── baz
│   ├── a
│   ├── b
│   ├── bar
│   └── c
├── foo
│   ├── a
│   ├── b
│   ├── bar
│   │   ├── d
│   │   ├── e
│   │   └── f
│   └── c
└── qux
    ├── a
    ├── b
    ├── bar
    │   ├── d
    │   ├── e
    │   └── f
    └── r
```

このように`private`オプションの`/mnt/baz`ではbarディレクトリのマウント情報が一切伝達されず、`rslave`オプションの`/mnt/qux`ではbarディレクトリのマウント情報が伝播されます。

## Dockerとcontainerdでのnode-exporterの挙動の違い

Kubernetesでは、`mountPropagation`を省略した場合、デフォルト値として `mountPropagation: None` (`private`オプション)となります。
上記のMount Propagationの説明より、containerdではnode-exporterのようなPodにてルートディレクトリをマウントする際、`rslave`オプション(`mountPropagation: HostToContainer`)の付与が必要になります。
もし、今回の事例のように`private`オプションとしてしまった場合、[node-exporterでファイルシステムの情報を取得しているSystem Callのstatfs](https://github.com/prometheus/node_exporter/blob/v1.3.1/collector/filesystem_linux.go#L80)からは、新たにbindマウントされたマウントの情報(PVでマウントされるボリュームの情報)が伝播されずルートディレクト自身の情報が返ってしまうためです。
一方、Dockerでは後方互換の維持のためルートディレクトリをマウントする際、[自動的に`rslave`オプションの指定となるスペシャルな実装](https://github.com/moby/moby/blob/v20.10.2/daemon/oci_linux.go#L606-L610)が入っています。
そのため、DockerをContainer Runtimeとして利用している場合は、node-exporterでルートディレクトリをマウントする際、Podにて`mountPropagation`を省略してもDocker内では`rslave`オプションでマウントされるため、問題が発生しません。

# 感想

今回、`mountPropagation`の設定の有無で、node-exporterのようにルートディレクトリをマウントする場合においては、Dockerとcontainerdで挙動に違いが出る点を紹介しました。
私達のチームではcontainerdしか試していませんが、本現象はDocker以外のContainer Runtimeでも発生する問題と推察します。
また、node-exporter以外でもルートディレクトリをマウントしているようなPodの場合でも発生する可能性があります。
もし、bindマウントを行なっているKubernetesのPVC/PVのような環境で、ファイルシステムの情報が正しくないと思った際に疑ってみるポイントの一つですので、Mount Propagationというものがあるという知識はあると良いかもしれません。
また、本ドキュメントでは、必要な箇所に限定しMount Propagationの説明を簡単に紹介していますが、このMount Propagationは他にもオプションがあり奥が深いものの一つです。
興味のある方はLinux Kernelの[Shared Subtrees](https://www.kernel.org/doc/Documentation/filesystems/sharedsubtree.txt)を読むと良いでしょう。