Dockerコンテナが消費するCPUリソース制限の仕組み

結論として、Dockerコンテナが消費するCPUリソースはホストマシンの`cgroup`によって制御されています。
今回はあえて`cgroup`の設定ファイルを直接操作することでDockerのリソース制御の仕組みを検証していきます。
簡略化のために、無限ループ処理するだけのプログラムをコンテナ内で動かした状態で検証を進めていきます。

# 検証に使用するコンテナの設定

``` Dockerfile
FROM ruby:3.2.2-slim-bullseye

WORKDIR /usr/src/app

COPY . .
```

``` docker-compose.yml
version: "3.9"
services:
  ruby:
    build: .
    container_name: ruby
    volumes:
      - .:/usr/src/app
    tty: true
    stdin_open: true
```

# 検証

まず`docker-compose up -d`でコンテナを立ち上げた状態で、`docker-compose exec ruby bash`でコンテナのシェルに入り、`top`でリソースを確認してみます。

```
$ top
(一部省略)
PID USER      PR  NI    VIRT    RES    SHR S  %CPU  %MEM     TIME+ COMMAND
  1 root      20   0  102396  23212   7328 S   0.0   0.2   0:00.15 irb
  7 root      20   0    6048   3756   3316 S   0.0   0.0   0:00.01 bash
 13 root      20   0    8880   3540   3072 R   0.0   0.0   0:00.00 top
```

当然CPUには十分空きがあります。
ではCPUリソースを消費するために、無限ループするだけの以下のプログラムを実行してみましょう。

``` test.rb
while true
end
```

`ruby test.rb &`でバックグラウンド実行した状態で再度`top`でリソースを確認すると以下のように`test.rb`がCPUを100%消費していることがわかります。
``` sh
$ ruby test.rb &
# [1] 14

# PIDを指定して無限ループのプロセスだけ表示
$ top -p 14
PID USER      PR  NI    VIRT    RES    SHR S  %CPU  %MEM     TIME+ COMMAND
 14 root      20   0   95820  16408   6936 R 100.0   0.1   0:03.11 ruby
```


Dockerコンテナはデフォルトでは消費できるリソースに制限がかかっていないので、このように闇雲にCPUを消費することができてしまいます。

では`cgroup`によってコンテナが消費できるCPUリソースを制限してみましょう。
まずは、当該コンテナがどのコントロールグループに属しているか、`systemd-cgtop`で確認します。

```
Control Group                                                                    Procs   %CPU   Memory  Input/s Output/s
/                                                                                    -  107.4     8.8G        -        -
/docker                                                                              -   99.7    27.5M        -        -
/docker/f1af8ea44304a30abe393bc4937e76de3fe8f8c749a8fea297620db709aaf3a8             -   99.7    26.7M        -        -
/podruntime                                                                          -    1.5     6.1G        -        -
/podruntime/docker                                                                   -    1.5     6.1G        -        -
/volume-contents                                                                     -    0.1    25.4M        -        -

~~~ (省略) ~~~
```

`docker/f1af8ea44304a30abe393bc4937e76de3fe8f8c749a8fea297620db709aaf3a8`というコントロールグループに属しているようなので、実際に`cgroup`の設定ファイルを確認しましょう。

``` sh
$ cat /sys/fs/cgroup/cpu/docker/f1af8ea44304a30abe393bc4937e76de3fe8f8c749a8fea297620db709aaf3a8/cpu.cfs_period_us
# 100000

$ cat /sys/fs/cgroup/cpu/docker/f1af8ea44304a30abe393bc4937e76de3fe8f8c749a8fea297620db709aaf3a8/cpu.cfs_quota_us
# -1
```
以上の設定ファイルの内容の意味は、100ミリ秒あたり無制限にCPUを使えるという意味です。
（単位がマイクロ秒なので、100,000マイクロ秒 = 100ミリ秒となります。）


# 検証（リソース制限あり）

では、100ミリ秒あたり70ミリ秒までしか使えないように制限してみましょう。

``` sh
$ echo 70000 | sudo tee  /sys/fs/cgroup/cpu/docker/f1af8ea44304a30abe393bc4937e76de3fe8f8c749a8fea297620db709aaf3a8/cpu.cfs_quota_us
# 70000
```
すると以下のように、無限ループを行っているプログラムのCPU使用率が約70%となりました。
```
$ top
PID USER      PR  NI    VIRT    RES    SHR S  %CPU  %MEM     TIME+ COMMAND
 14 root      20   0   95820  16408   6936 R  69.7   0.1   4:08.84 ruby
```

# 複数プロセスの場合

では無限ループプログラムをもう1つバックグラウンドで起動し、CPUコアを2つ使うようにするとどうなるでしょうか。
``` sh
$ ruby test.rb &
# [2] 19

$ top
PID USER      PR  NI    VIRT    RES    SHR S  %CPU  %MEM     TIME+ COMMAND
 14 root      20   0   95820  16408   6936 R  31.4   0.1   6:59.76 ruby
 19 root      20   0   95804  16404   6936 R  38.0   0.1   0:19.69 ruby 
```

`ruby test.rb`のプロセスが2つ実行されており、合計でCPUを約70％使用しています。
3つ4つとプロセスを追加で実行しても、合計のCPU使用率は70%までに制限されます。
なぜなら、あるコンテナの中で複数のプロセスが実行されていても、Dockerホストの`cgroup`によってコンテナ単位でCPUリソースが制御されるからです。

2プロセスまではCPUを100%利用できるようにするには、コンテナが100ミリ秒あたり200ミリ秒CPUを利用できるように`cpu.cfs_quota_us`に`200000`を書き込みます。
``` sh
$ echo 200000 | sudo tee /sys/fs/cgroup/cpu/docker/f1af8ea44304a30abe393bc4937e76de3fe8f8c749a8fea297620db709aaf3a8/c
pu.cfs_quota_us
# 200000
```

```
$ top
PID USER      PR  NI    VIRT    RES    SHR S  %CPU  %MEM     TIME+ COMMAND
 14 root      20   0   95820  16408   6936 R 100.0   0.1   9:05.90 ruby
 19 root      20   0   95804  16404   6936 R 100.0   0.1   2:25.83 ruby
```
各プロセスが100%CPUを利用できています。
100ミリ秒あたり200ミリ秒というと矛盾しているように感じますが、1プロセス100ミリ秒 * 2プロセス = 200ミリ秒と考えるとわかりやすいです。

この状態で`docker stats`でコンテナのCPU使用率を確認すると合計で約200%となっています。
```
CONTAINER ID   NAME      CPU %     MEM USAGE / LIMIT     MEM %     NET I/O       BLOCK I/O   PIDS
f1af8ea44304   ruby      199.52%    35.92MiB / 12.43GiB   0.28%     1.66kB / 0B   0B / 0B     3
```

# リソース制御をDockerの仕組みを使って行う

`cgroup`によるコンテナのリソース制御を理解したところで、Dockerが提供している機能で同じことをより簡単に行ってみましょう。

``` docker-compose.yml
version: "3.9"
services:
  ruby:
    build: .
    container_name: ruby
    volumes:
      - .:/usr/src/app
    tty: true
    stdin_open: true
    cpu_quota: 70000 # この行を追加
```
ここまでの検証でこの意味は簡単に理解できますね。
CPUリソースの消費を100ミリ秒あたり70ミリ秒に制限するという意味です。

ではこの設定を反映した新しいコンテナを立ち上げて検証してみましょう。

``` sh
$ docker-compose down
$ docker-compose up -d
$ docker-compose exec ruby bash
```
``` sh
$ ruby test.rb &
# [1] 13

$ top -p 13
PID USER      PR  NI    VIRT    RES    SHR S  %CPU  %MEM     TIME+ COMMAND
 13 root      20   0   95812  16304   6840 R  69.7   0.1  14:36.17 ruby           
```
`cgroup`で直接操作した場合と同様にCPU使用率が約70%に制限されています。
実際に`cgroup`の設定ファイルにも反映されているか確認しましょう。

``` sh
$ systemd-cgtop
Control Group                                                                    Procs   %CPU   Memory  Input/s Output/s
/                                                                                    -   70.9     8.8G        -        -
/docker                                                                              -   69.8    28.2M        -        -
/docker/f1f9e2f477364022641ecb5520aea8a7f2447e0b615c34f73b9363d1d15dfa3a             -   69.8    27.4M        -        -

$ cat /sys/fs/cgroup/cpu/docker/f1f9e2f477364022641ecb5520aea8a7f2447e0b615c34f73b9363d1d15dfa3a/cpu.cfs_quota_us
# 70000
```
`cpu.cfs_quota_us`に反映されています。
つまりDockerはリソース制御を`cgroup`で行っているということです。