docker/kubernetes で sysctl を設定する

この記事は docker Advent Calendar 2021 の 16 日目です。

コンテナで特定のカーネルパラメータを変更する方法を調査する。

調査したバージョン

Ubuntu 20.04.02 LTS に docker.io 20.10.7-0ubuntu5~20.04.2 をインストールして検証する。

# uname -a
Linux ip-172-31-21-0 5.4.0-1045-aws #47-Ubuntu SMP Tue Apr 13 07:02:25 UTC 2021 x86_64 x86_64 x86_64 GNU/Linux

# docker --version
Docker version 20.10.7, build 20.10.7-0ubuntu5~20.04.2

コンテナでカーネルパラメータを変更するときの問題点

通常(--privileged オプションを付与していなければ)、コンテナ内では sysctl の実行に失敗する。

# docker run --rm -it --name testcontainer ubuntu /bin/bash

root@beff4fe761ca:/# sysctl net.ipv4.icmp_echo_ignore_all
net.ipv4.icmp_echo_ignore_all = 0

root@beff4fe761ca:/# sysctl -w net.ipv4.icmp_echo_ignore_all=1
sysctl: setting key "net.ipv4.icmp_echo_ignore_all", ignoring: Read-only file system

これは、ホストの /proc/sys が ro(read only) でマウントされているため。

# grep /proc/sys /proc/mounts
proc /proc/sys proc ro,nosuid,nodev,noexec,relatime 0 0
proc /proc/sysrq-trigger proc ro,nosuid,nodev,noexec,relatime 0 0

--privileged オプションを付与すれば /proc が rw(read write) でマウントされるため書き込みができるようになる。

# docker run --rm -it --privileged --name testcontainer ubuntu /bin/bash
root@27376c5dc122:/# grep /proc /proc/mounts
proc /proc proc rw,nosuid,nodev,noexec,relatime 0 0

// コンテナから ICMP Echo Reply が返ってくることを確認する
# docker inspect testcontainer  | jq ".[].NetworkSettings.Networks.bridge.IPAddress"
"172.17.0.2"
# ping 172.17.0.2
PING 172.17.0.2 (172.17.0.2) 56(84) bytes of data.
64 bytes from 172.17.0.2: icmp_seq=1 ttl=64 time=0.086 ms

root@27376c5dc122:/# sysctl -w net.ipv4.icmp_echo_ignore_all=1
net.ipv4.icmp_echo_ignore_all = 1

// コンテナから ICMP Echo Reply が返ってこなくなる
# ping 172.17.0.2
PING 172.17.0.2 (172.17.0.2) 56(84) bytes of data.
^C
--- 172.17.0.2 ping statistics ---
3 packets transmitted, 0 received, 100% packet loss, time 2029ms

しかし、--privilegedはデカすぎる権限をコンテナに与えることになり、セキュリティ上問題がある。 そして、特権に目をつむれば上記のように対応してよいのかというと、そういうわけでもない。 その理由は、コンテナごとに分離されているカーネルパラメータと、分離されてないカーネルパラメータが存在するため

特権をつけたコンテナがコンテナごとに分離されていないカーネルパラメータを変更すると、 隔離されていたはずの他コンテナやホストに影響を与えてしまう。以下例。

// あるコンテナで vm.swappiness の値を変更すると…
# docker run --rm -it --privileged --name testcontainer ubuntu /bin/bash
root@5cb624fb7d82:/# sysctl vm.swappiness=0
vm.swappiness = 0

// ホストの設定が変わる
# sysctl vm.swappiness
vm.swappiness = 0

// 起動中の別のコンテナの設定も変わる
# docker run --rm -it  ubuntu /bin/bash
root@3bd4393a2a0f:/# sysctl vm.swappiness
vm.swappiness = 0

じゃあ docker だと、どうするか?

docker は run コマンドに --sysctl オプションが存在するため、これを利用する。 このオプションは、コンテナごとに(厳密には namespaces ごとに)隔離されたカーネルパラメータかどうかを検証したうえで設定してくれる。 参考. docker run --sysctl オプション

// 隔離されたカーネルパラメータのため、実行できる
# docker run --rm -it --sysctl net.ipv4.icmp_echo_ignore_all=1 --name testcontainer ubuntu /bin/
bash
root@d0553047d097:/# sysctl net.ipv4.icmp_echo_ignore_all
net.ipv4.icmp_echo_ignore_all = 1

// 隔離されたカーネルパラメータではないため、実行できない
# docker run --rm -it --sysctl vm.swappiness=60 --name testcontainer ubuntu /bin/bash
invalid argument "vm.swappiness=60" for "--sysctl" flag: sysctl 'vm.swappiness=60' is not whitelisted
See 'docker run --help'.

どのような仕組みなのか?

https://github.com/moby/moby/pull/19265 を見ると、初期実装っぽいものが確認できる。

(CLI が分離されたりしてるっぽいので)最新の実装を確認すると、 ValidateSysctl()で、 カーネルパラメータをホワイトリスト形式でチェックしているのが分かる。

dockerd を debug モードにしてログを眺めると、docker cli から struct serviceOptions の sysctls 経由で moby/moby の /containers/create API に HostConfig > Sysctls を経由して設定値を渡しているのが分かる。

Dec 15 14:18:28 ip-172-31-21-0 dockerd[6495]: time="2021-12-15T14:18:28.218775479Z" level=debug msg="Calling HEAD /_ping"
Dec 15 14:18:28 ip-172-31-21-0 dockerd[6495]: time="2021-12-15T14:18:28.220152657Z" level=debug msg="Calling POST /v1.41/containers/create?name=testcontainer"
Dec 15 14:18:28 ip-172-31-21-0 dockerd[6495]: time="2021-12-15T14:18:28.220399042Z" level=debug msg="form data: {\"AttachStderr\":true,\"AttachStdin\":true,\"AttachStdout\":true,\"Cmd\":[\"/bin/bash\"],\"Domainname\":\"\",\"Entrypoint\":null,\"Env\":null,\"HostConfig\":{\"AutoRemove\":true,\"Binds\":null,\"BlkioDeviceReadBps\":null,\"BlkioDeviceReadIOps\":null,\"BlkioDeviceWriteBps\":null,\"BlkioDeviceWriteIOps\":null,\"BlkioWeight\":0,\"BlkioWeightDevice\":[],\"CapAdd\":null,\"CapDrop\":null,\"Cgroup\":\"\",\"CgroupParent\":\"\",\"CgroupnsMode\":\"\",\"ConsoleSize\":[0,0],\"ContainerIDFile\":\"\",\"CpuCount\":0,\"CpuPercent\":0,\"CpuPeriod\":0,\"CpuQuota\":0,\"CpuRealtimePeriod\":0,\"CpuRealtimeRuntime\":0,\"CpuShares\":0,\"CpusetCpus\":\"\",\"CpusetMems\":\"\",\"DeviceCgroupRules\":null,\"DeviceRequests\":null,\"Devices\":[],\"Dns\":[],\"DnsOptions\":[],\"DnsSearch\":[],\"ExtraHosts\":null,\"GroupAdd\":null,\"IOMaximumBandwidth\":0,\"IOMaximumIOps\":0,\"IpcMode\":\"\",\"Isolation\":\"\",\"KernelMemory\":0,\"KernelMemoryTCP\":0,\"Links\":null,\"LogConfig\":{\"Config\":{},\"Type\":\"\"},\"MaskedPaths\":null,\"Memory\":0,\"MemoryReservation\":0,\"MemorySwap\":0,\"MemorySwappiness\":-1,\"NanoCpus\":0,\"NetworkMode\":\"default\",\"OomKillDisable\":false,\"OomScoreAdj\":0,\"PidMode\":\"\",\"PidsLimit\":0,\"PortBindings\":{},\"Privileged\":false,\"PublishAllPorts\":false,\"ReadonlyPaths\":null,\"ReadonlyRootfs\":false,\"RestartPolicy\":{\"MaximumRetryCount\":0,\"Name\":\"no\"},\"SecurityOpt\":null,\"ShmSize\":0,\"Sysctls\":{\"net.ipv4.icmp_echo_ignore_all\":\"1\"},\"UTSMode\":\"\",\"Ulimits\":null,\"UsernsMode\":\"\",\"VolumeDriver\":\"\",\"VolumesFrom\":null},\"Hostname\":\"\",\"Image\":\"ubuntu\",\"Labels\":{},\"NetworkingConfig\":{\"EndpointsConfig\":{}},\"OnBuild\":null,\"OpenStdin\":true,\"Platform\":null,\"StdinOnce\":true,\"Tty\":true,\"User\":\"\",\"Volumes\":{},\"WorkingDir\":\"\"}"
Dec 15 14:18:28 ip-172-31-21-0 dockerd[6495]: time="2021-12-15T14:18:28.275984398Z" level=debug msg="container mounted via layerStore: &{/var/lib/docker/overlay2/97f613ddbbdedf8f98ea2fc858d178ccaffe96bda008cf7c780f178625dd2419/merged 0x5652ba219400 0x5652ba219400}" container=18a840711d3d4dab79e323101997bd7701057c34727aa258d4e86fd4ad047489
Dec 15 14:18:28 ip-172-31-21-0 dockerd[6495]: time="2021-12-15T14:18:28.300838434Z" level=debug msg="Calling POST /v1.41/containers/18a840711d3d4dab79e323101997bd7701057c34727aa258d4e86fd4ad047489/attach?stderr=1&stdin=1&stdout=1&stream=1"
Dec 15 14:18:28 ip-172-31-21-0 dockerd[6495]: time="2021-12-15T14:18:28.301203369Z" level=debug msg="attach: stdin: begin"
Dec 15 14:18:28 ip-172-31-21-0 dockerd[6495]: time="2021-12-15T14:18:28.301263915Z" level=debug msg="attach: stdout: begin"
Dec 15 14:18:28 ip-172-31-21-0 dockerd[6495]: time="2021-12-15T14:18:28.301302011Z" level=debug msg="attach: stderr: begin"
Dec 15 14:18:28 ip-172-31-21-0 dockerd[6495]: time="2021-12-15T14:18:28.301445023Z" level=debug msg="Calling POST /v1.41/containers/18a840711d3d4dab79e323101997bd7701057c34727aa258d4e86fd4ad047489/wait?condition=removed"
Dec 15 14:18:28 ip-172-31-21-0 dockerd[6495]: time="2021-12-15T14:18:28.302091465Z" level=debug msg="Calling POST /v1.41/containers/18a840711d3d4dab79e323101997bd7701057c34727aa258d4e86fd4ad047489/start"
Dec 15 14:18:28 ip-172-31-21-0 dockerd[6495]: time="2021-12-15T14:18:28.306113672Z" level=debug msg="container mounted via layerStore: &{/var/lib/docker/overlay2/97f613ddbbdedf8f98ea2fc858d178ccaffe96bda008cf7c780f178625dd2419/merged 0x5652ba219400 0x5652ba219400}" container=18a840711d3d4dab79e323101997bd7701057c34727aa258d4e86fd4ad047489
Dec 15 14:18:28 ip-172-31-21-0 dockerd[6495]: time="2021-12-15T14:18:28.306507993Z" level=debug msg="Assigning addresses for endpoint testcontainer's interface on network bridge"
Dec 15 14:18:28 ip-172-31-21-0 dockerd[6495]: time="2021-12-15T14:18:28.306540606Z" level=debug msg="RequestAddress(LocalDefault/172.17.0.0/16, <nil>, map[])"
Dec 15 14:18:28 ip-172-31-21-0 dockerd[6495]: time="2021-12-15T14:18:28.306567447Z" level=debug msg="Request address PoolID:172.17.0.0/16 App: ipam/default/data, ID: LocalDefault/172.17.0.0/16, DBIndex: 0x0, Bits: 65536, Unselected: 65533, Sequence: (0xc0000000, 1)->(0x0, 2046)->(0x1, 1)->end Curr:0 Serial:false PrefAddress:<nil> "
Dec 15 14:18:28 ip-172-31-21-0 dockerd[6495]: time="2021-12-15T14:18:28.322870016Z" level=debug msg="Assigning addresses for endpoint testcontainer's interface on network bridge"
Dec 15 14:18:28 ip-172-31-21-0 dockerd[6495]: time="2021-12-15T14:18:28.330476645Z" level=debug msg="Programming external connectivity on endpoint testcontainer (07d3a9648979484f7f26a6d0211bf006cd76bcfe0f69fa1cbf4a3cb3458c3dc1)"
Dec 15 14:18:28 ip-172-31-21-0 dockerd[6495]: time="2021-12-15T14:18:28.333175015Z" level=debug msg="EnableService 18a840711d3d4dab79e323101997bd7701057c34727aa258d4e86fd4ad047489 START"
Dec 15 14:18:28 ip-172-31-21-0 dockerd[6495]: time="2021-12-15T14:18:28.333222984Z" level=debug msg="EnableService 18a840711d3d4dab79e323101997bd7701057c34727aa258d4e86fd4ad047489 DONE"
Dec 15 14:18:28 ip-172-31-21-0 dockerd[6495]: time="2021-12-15T14:18:28.338312043Z" level=debug msg="bundle dir created" bundle=/var/run/docker/containerd/18a840711d3d4dab79e323101997bd7701057c34727aa258d4e86fd4ad047489 module=libcontainerd namespace=moby root=/var/lib/docker/overlay2/97f613ddbbdedf8f98ea2fc858d178ccaffe96bda008cf7c780f178625dd2419/merged
Dec 15 14:18:28 ip-172-31-21-0 dockerd[6495]: time="2021-12-15T14:18:28.571323694Z" level=debug msg="sandbox set key processing took 110.588626ms for container 18a840711d3d4dab79e323101997bd7701057c34727aa258d4e86fd4ad047489"
Dec 15 14:18:28 ip-172-31-21-0 dockerd[6495]: time="2021-12-15T14:18:28.600515249Z" level=debug msg=event module=libcontainerd namespace=moby topic=/tasks/create
Dec 15 14:18:28 ip-172-31-21-0 dockerd[6495]: time="2021-12-15T14:18:28.612223014Z" level=debug msg=event module=libcontainerd namespace=moby topic=/tasks/start
Dec 15 14:18:28 ip-172-31-21-0 dockerd[6495]: time="2021-12-15T14:18:28.620682877Z" level=debug msg="Calling POST /v1.41/containers/18a840711d3d4dab79e323101997bd7701057c34727aa258d4e86fd4ad047489/resize?h=54&w=117"

また containerd では TaskCreate イベントが発生しているのが分かる。

Dec 15 14:18:28 ip-172-31-21-0 containerd[6041]: time="2021-12-15T14:18:28.598767642Z" level=debug msg="event forwarded" ns=moby topic=/tasks/create type=containerd.events.TaskCreate
Dec 15 14:18:28 ip-172-31-21-0 containerd[6041]: time="2021-12-15T14:18:28.611898958Z" level=debug msg="event forwarded" ns=moby topic=/tasks/start type=containerd.events.TaskStart

で、なんやかんやあって(追いきれなかったので詳細はまたこんど)、runc に渡す config.json に、設定値が書き込まれる。 config.json は runtime-spec という仕様で定めている、OCI ランタイム(コンテナの実体)の動作を制御するためのファイル。
参考. rutnime-spec の sysctl パラメータ

# cat /var/run/containerd/io.containerd.runtime.v2.task/moby/ebaf751efb39e513a230b4ba3e539ed13ca53913aab93a39e172c0229630f0ce/config.json | jq ".linux.sysctl"
{
  "net.ipv4.icmp_echo_ignore_all": "1",
  "net.ipv4.ip_unprivileged_port_start": "0"
}

net.ipv4.ip_unprivileged_port_start は設定してないけど勝手に設定されたもの。 moby/moby や containerd の CRI でも、 Sysctl を微妙に調整している。上記は moby/moby 側で対応してるものと思われる。

なお、runc に渡す config.json を直接書き換えて runc を実行してみると、実行時に namespace で分離されたカーネルパラメータかどうかをチェックしているのがわかる。docker-cli 側と二重チェックじゃんと思ったが、早期にチェックすることはいいことですからね。
参考. runc

# mkdir /mycontainer
# cd /mycontainer

# create the rootfs directory
mkdir rootfs

# export busybox via Docker into the rootfs directory
docker export $(docker create busybox) | tar -C rootfs -xvf -

# runc spec

// config.json に以下の行を追加
        "linux": {
                "sysctl": {
                        "vm.swappiness": "0"
                },

# # runc run x
ERRO[0000] sysctl "vm.swappiness" is not in a separate kernel namespace

やっぱり runc 側でもホワイトリスト形式で明示的にチェックしてる(validator.go) ようで、ユーザ空間のソフトウェアとカーネルバージョンとの対応取るのが難しそうな印象を持ったんだけど(たとえばカーネルが namespaced なパラメータ追加してるのにユーザ空間ソフトウェアが古いから設定できない、や、その逆など)、まあ、カーネル側で明示的に namespace で分離されてるかどうかを確認する仕組みがない(はず)ので、仕方ないんだろうなと思った。

kubernetes だとどうするか?

上記までで sysctl をコンテナに設定できるのは runtime-spec で定義された仕組みと分かった。 ということで、コンテナを利用する上位のソフトウェアに設定項目さえあれば、kubernetes でも sysctl を設定できる。 kubernetes では SecurityContext で sysctl を設定できる。詳細はリファレンス Using sysctls in a Kubernetes Cluster | Kubernetes に記載されていたため、省略する。