仮想化通信

日本仮想化技術株式会社の公式エンジニアブログ

Kubernetes 1.25でデフォルト有効化された「SeccompDefault」機能で「RuntimeDefault」を試してみる

本記事について

この記事で書かれている内容はRestrict a Container's Syscalls with seccompをベースにしていました。このドキュメントのうち前半の指定した任意のSeccompプロファイルを使ってPodを作成する機能は1.25でデフォルトで使えるようになった機能ではなく、以前から使えるものです。

タイトルが紛らわしいと思いますので、「旧タイトル: Kubernetes 1.25でデフォルト有効化された「SeccompDefault」を試してみる」から「Kubernetes 1.25でデフォルト有効化された「SeccompDefault」機能で「RuntimeDefault」を試してみる」に変更しています。

初稿で不足している部分を「8/25追記 もう少し深堀り」という後で追記しています。


Kubernetes 1.25がリリースされましたね!

Kubernetes 1.25がリリースされたので、kubeadmとCRI-Oの組み合わせで評価環境を作ってみることにしました。 CRI-OはKubernetesのリリースに合わせて同じバージョンのCRI-Oをリリースしていますが、現時点ではCRI-O 1.25がまだ利用可能ではなかったので、CRI-O 1.24を利用しています。

kubeadmなどのインストールについてはいつものようにInstalling kubeadm、ランタイムのインストールについてはContainer Runtimesに従ってセットアップしています。

早速kubeadmでクラスタ作成!

早速クラスターを作成してみます。

$ sudo kubeadm init --kubernetes-version 1.25.0 --pod-network-cidr=10.244.0.0/16  --control-plane-endpoint=172.16.214.190
[init] Using Kubernetes version: v1.25.0
[preflight] Running pre-flight checks
    [WARNING Swap]: swap is enabled; production deployments should disable swap unless testing the NodeSwap feature gate of the kubelet
    [WARNING FileExisting-tc]: tc not found in system path
    [WARNING SystemVerification]: missing optional cgroups: blkio
error execution phase preflight: [preflight] Some fatal errors occurred:
    [ERROR FileContent--proc-sys-net-bridge-bridge-nf-call-iptables]: /proc/sys/net/bridge/bridge-nf-call-iptables does not exist
    [ERROR FileContent--proc-sys-net-ipv4-ip_forward]: /proc/sys/net/ipv4/ip_forward contents are not set to 1
[preflight] If you know what you are doing, you can make a check non-fatal with `--ignore-preflight-errors=...`
To see the stack trace of this error execute with --v=5 or higher

失敗です!

1つ目の問題「SWAP」

ワーニングなのでそのままでも動くっぽいですが、Fedora 33以降の場合はZRAMを使っているので、次のドキュメントにあるように場合によっては無効化したほうが良いかもしれません。

fedoraproject.org

今回はちょっとメモリの割当が小さいVM(vCPU2、メモリー4GB)で動かしてしまったので、想定したように動かない限りはオンのままにしようと思いました。

2つ目の問題「tcコマンドがない」

入っていなければ入れれば良いです。以上。

$ sudo dnf provides tc
Last metadata expiration check: 0:02:58 ago on Wed 24 Aug 2022 02:20:45 AM UTC.
iproute-tc-5.15.0-2.fc36.x86_64 : Linux Traffic Control utility
Repo        : fedora
Matched from:
Filename    : /usr/sbin/tc
Provide    : /sbin/tc

$ sudo dnf install iproute-tc

3つ目の問題「ブリッジ周りの設定」

Forwarding IPv4 and letting iptables see bridged traffic」のところの設定をするのを忘れていたので、実施しました。

cat <<EOF | sudo tee /etc/modules-load.d/k8s.conf
overlay
br_netfilter
EOF

sudo modprobe overlay
sudo modprobe br_netfilter

# sysctl params required by setup, params persist across reboots
cat <<EOF | sudo tee /etc/sysctl.d/k8s.conf
net.bridge.bridge-nf-call-iptables  = 1
net.bridge.bridge-nf-call-ip6tables = 1
net.ipv4.ip_forward                 = 1
EOF

# Apply sysctl params without reboot
sudo sysctl --system

もう一度クラスタ作成をトライ

ここまで設定した上で、もう一度クラスター作成を試しました。

$ sudo kubeadm init --kubernetes-version 1.25.0 --pod-network-cidr=10.244.0.0/16  --control-plane-endpoint=172.16.214.190

[init] Using Kubernetes version: v1.25.0
[preflight] Running pre-flight checks
    [WARNING Swap]: swap is enabled; production deployments should disable swap unless testing the NodeSwap feature gate of the kubelet
    [WARNING SystemVerification]: missing optional cgroups: blkio
...
[kubelet-check] It seems like the kubelet isn't running or healthy.
[kubelet-check] The HTTP call equal to 'curl -sSL http://localhost:10248/healthz' failed with error: Get "http://localhost:10248/healthz": dial tcp [::1]:10248: connect: connection refused.
[kubelet-check] It seems like the kubelet isn't running or healthy.
[kubelet-check] The HTTP call equal to 'curl -sSL http://localhost:10248/healthz' failed with error: Get "http://localhost:10248/healthz": dial tcp [::1]:10248: connect: connection refused.
[kubelet-check] It seems like the kubelet isn't running or healthy.
[kubelet-check] The HTTP call equal to 'curl -sSL http://localhost:10248/healthz' failed with error: Get "http://localhost:10248/healthz": dial tcp [::1]:10248: connect: connection refused.
[kubelet-check] It seems like the kubelet isn't running or healthy.
[kubelet-check] The HTTP call equal to 'curl -sSL http://localhost:10248/healthz' failed with error: Get "http://localhost:10248/healthz": dial tcp [::1]:10248: connect: connection refused.

healthzがどうのこうのというエラーが出る場合は、Kubeletが正常に動いていないようです。CRI-Oの設定を以下を確認して見直しました。

kubernetes.io

ランタイム側とKubeletのcgroup_managerの設定を合わせる必要があるのですが、上のドキュメントをみて何も考えずにcgroupfsを設定して、数十分ウンウン唸る事になってしまいました。

最終的には次のような設定にしました。

$ sudo vi /etc/crio/crio.conf

[crio.runtime]
conmon_cgroup = "pod"
cgroup_manager = "systemd"

[crio.image]
pause_image="registry.k8s.io/pause:3.6"

またこのあともう一度kubeadm initを実行したのですがうまく行かず、journalctlでkubeletの状況を確認したら「SWAPをオフにしてくれ!」とkubeletが叫んでいました。kubeadm initのときにそう言っていただければ やっぱりオフにしたほうが良さそうです。

$ journalctl -xeu kubelet

kubelet[5474]: E0824 03:26:42.012686    5474 run.go:74] "command failed" err="failed to run Kubelet: running with swap on is not supported, please disable swap! or set --fail-swap-on flag to false. /proc/swaps contained

Fedoraの場合、スワップオフの方法は次に書かれているように、sudo dnf remove zram-generator-defaultsを実行すれば良いそうです。

fedoraproject.org

クラスター作成3度目

設定変更により、ようやくクラスター作成に成功しました。

$ sudo kubeadm init --kubernetes-version 1.25.0 --pod-network-cidr=10.244.0.0/16  --control-plane-endpoint=172.16.214.190
...
Your Kubernetes control-plane has initialized successfully!

To start using your cluster, you need to run the following as a regular user:

  mkdir -p $HOME/.kube
  sudo cp -i /etc/kubernetes/admin.conf $HOME/.kube/config
  sudo chown $(id -u):$(id -g) $HOME/.kube/config

Alternatively, if you are the root user, you can run:

  export KUBECONFIG=/etc/kubernetes/admin.conf

You should now deploy a pod network to the cluster.
Run "kubectl apply -f [podnetwork].yaml" with one of the options listed at:
  https://kubernetes.io/docs/concepts/cluster-administration/addons/
...

ネットワークアドオンの適用

うまくクラスターを作れたので、あとは画面出力にあるように設定を順にやっていきます。 ネットワークアドオンはFlannelにしました。

github.com

$ kubectl apply -f https://raw.githubusercontent.com/flannel-io/flannel/master/Documentation/kube-flannel.yml

コントロールプレーンノードの分離

今回はちょっと動かすだけなので、シングルノードで動かすためのいつもの設定を行います。以前のバージョンとちょっと指定するオプションが変わっているので注意です。

kubernetes.io

実行するコマンドは次の通りです。例のように「node/** untainted」のように出力があれば成功です。

$ kubectl taint nodes --all node-role.kubernetes.io/control-plane-
node/k8s-cplane.novalocal untainted

$ kubectl get node
NAME                            STATUS   ROLES              AGE       VERSION
k8s-cplane.novalocal   Ready     control-plane   9m59s   v1.25.0

基本動作の確認

適当にアプリを動かしてみます。問題なければアプリは実行できるはずです。

$ kubectl create deployment hello-node --image=k8s.gcr.io/echoserver:1.4
deployment.apps/hello-node created

$ kubectl get deployment
NAME         READY   UP-TO-DATE   AVAILABLE   AGE
hello-node   1/1     1            1           102s

「seccompを使用したコンテナのSyscall制限」を試す

こちらを参考に、コンテナランタイムのデフォルトのseccompプロファイルを使用してPodを作成してみましょう。

このマニフェストを使ってPodを作成してみます。

apiVersion: v1
kind: Pod
metadata:
  name: default-pod
  labels:
    app: default-pod
spec:
  securityContext:
    seccompProfile:
      type: RuntimeDefault
  containers:
  - name: test-container
    image: hashicorp/http-echo:0.2.3
    args:
    - "-text=just made some more syscalls!"
    securityContext:
      allowPrivilegeEscalation: false

ちなみにCRI-Oの場合は/usr/share/containers/seccomp.jsonがランタイムのデフォルトプロファイルです。 ちなみにCRI-Oの場合はデフォルトはCRI-O内蔵のseccompプロファイルが使われるようです。内容的には何もいじっていないPodmanをインストールしたときに/usr/share/containers/seccomp.jsonにコピーされるものと一緒の内容になると思います。

Podを作成してみます。

$ kubectl create -f default-pod.yaml 
pod/default-pod created

$ kubectl get -f default-pod.yaml 
NAME          READY   STATUS    RESTARTS   AGE
default-pod   1/1     Running   0          6s

問題なくPodを作成できました。securityContextでseccompProfileのタイプをRuntimeDefaultに設定しています。 アプリケーションは、ランタイムのSeccomp設定で定義したシステムコールのルールに従って実行されています。

8/25追記 もう少し深堀り

CRI-Oの設定は/etc/crio/crio.confです。/etc/containers/user/share/containersはPodmanやBuildah、Skopeoなどが主に使う設定ファイルです。CRI-Oの設定を開くとわかりますが、ストレージの設定は/etc/containers/storage.confを参照するようです。

というわけで、/etc/crio/crio.confの設定を今一度確認してみようと思います。

設定の「seccomp_profile」は、seccompファイルを指定する設定です。 指定していない場合は、内蔵のデフォルトseccompファイルが使われるようです。次がデフォルトの設定でした。

# Path to the seccomp.json profile which is used as the default seccomp profile
# for the runtime. If not specified, then the internal default seccomp profile
# will be used. This option supports live configuration reload.
# seccomp_profile = ""

ここは空のseccompファイルを指定した場合、無制限になるのを デフォルトプロファイルに置き換えるという設定です。それにより、セキュリティが向上する可能性があるそうです。次がデフォルトの設定でした。

# Changes the meaning of an empty seccomp profile. By default
# (and according to CRI spec), an empty profile means unconfined.
# This option tells CRI-O to treat an empty profile as the default profile,
# which might increase security.
# seccomp_use_default_when_empty = true

さらに下が許可するケーパビリティとシステムコールの設定です。 あえてここに"SYS_CHROOT"を追加しない限り、デフォルト設定のままだとchrootはこちらで阻止されます。

# List of default capabilities for containers. If it is empty or commented out,
# only the capabilities defined in the containers json file by the user/kube
# will be added.
# default_capabilities = [
#       "CHOWN",
#       "DAC_OVERRIDE",
#       "FSETID",
#       "FOWNER",
#       "SETGID",
#       "SETUID",
#       "SETPCAP",
#       "NET_BIND_SERVICE",
#       "KILL",
# ]

# List of default sysctls. If it is empty or commented out, only the sysctls
# defined in the container json file by the user/kube will be added.
# default_sysctls = [
# ]

今回検証のために、次のような設定を仕込みました。設定の優先順(?)の都合で、ホストのケーパビルティでは許可して、seccompファイルでchrootの実行を阻止するような設定にしています。

seccomp_profile = "/etc/crio/dropchroot.json"
seccomp_use_default_when_empty = false
default_capabilities = [
       "CHOWN",
       "SYS_CHROOT",       #追加したケーパビリティ
       "DAC_OVERRIDE",
       "FSETID",
       "FOWNER",
       "SETGID",
       "SETUID",
       "SETPCAP",
       "NET_BIND_SERVICE",
       "KILL",
]

上で指定したdropchroot.jsonは、デフォルトのSeccompと思われるファイル(/usr/share/containers/seccomp.json)に次を追記しただけのファイルです。

         "syscalls": [
                {
                        "names": [
                                "bdflush",
                                "chroot",          #追記した行
 ...
                        ],
                        "action": "SCMP_ACT_ERRNO",     #これらのsyscall要求は阻止
                        "args": [],
                        "comment": "",
                        "includes": {},
                        "excludes": {},
                        "errnoRet": 1,
                        "errno": "EPERM"
                },

次のようなマニフェストでPodを作成してみます。 profiles/はこの環境ではフルパスとして/var/lib/kubelet/seccomp/profiles/が参照されるようです。

$ cat vol-waru5.yaml 
apiVersion: v1
kind: Pod
metadata:
  name: pv-waru5
  namespace: default
spec:
  restartPolicy: Never
  securityContext:
    seccompProfile:
      type: Localhost
      localhostProfile: profiles/dropchroot.json
  volumes:
  - name: vol5
    hostPath:
      path: /
  containers:
  - name: pv-waru5
    image: "k8s.gcr.io/busybox"
    tty: true
    volumeMounts:
    - name: vol5
      mountPath: /rootfs

想定通り、chrootの実行は阻止してくれます。

$ kubectl create -f  vol-waru5.yaml 
$ kubectl exec -it   pv-waru5 -- chroot /rootfs
chroot: can't change root directory to '/rootfs': Operation not permitted
command terminated with exit code 1

次にこのようなマニフェストファイルを作成します。

$ cat vol-waru6.yaml 
apiVersion: v1
kind: Pod
metadata:
  name: pv-waru6
  namespace: default
spec:
  restartPolicy: Never
  securityContext:
    seccompProfile:
      type: RuntimeDefault
  volumes:
  - name: vol6
    hostPath:
      path: /
  containers:
  - name: pv-waru6
    image: "k8s.gcr.io/busybox"
    tty: true
    volumeMounts:
    - name: vol6
      mountPath: /rootfs

これも同様に阻止してくれます。

$ kubectl create -f  vol-waru6.yaml 
$ kubectl exec -it   pv-waru6 -- chroot /rootfs
chroot: can't change root directory to '/rootfs': Operation not permitted
command terminated with exit code 1

次にこのようなマニフェストファイルを作成します(securityContextを記述していないマニフェスト)。

 $ cat vol-waru7.yaml 
apiVersion: v1
kind: Pod
metadata:
  name: pv-waru7
  namespace: default
spec:
  restartPolicy: Never
  volumes:
  - name: vol7
    hostPath:
      path: /
  containers:
  - name: pv-waru7
    image: "k8s.gcr.io/busybox"
    tty: true
    volumeMounts:
    - name: vol7
      mountPath: /rootfs

pv-waru6と同じ結果を期待していましたが、この場合は阻止してくれませんでした。

$ kubectl create -f  vol-waru7.yaml 
$ kubectl exec -it pv-waru7 -- chroot /rootfs
Failed to connect to bus: No data available
basename: missing operand
Try 'basename --help' for more information.
[root@pv-waru7 /]# 

Podに対してどのようなケーパビリティが設定されているかはcrictl inspectpで確認できます。

$ sudo crictl pods
POD ID              CREATED             STATE               NAME                                           NAMESPACE           ATTEMPT             RUNTIME
254ab4917621f       4 minutes ago       Ready               pv-waru7                                       default             0                   (default)
2a3adfa877a49       38 minutes ago      Ready               pv-waru5                                       default             0                   (default)
a868e5b96d270       39 minutes ago      Ready               pv-waru6                                       default             0                   (default)
76605e78bc572       3 hours ago         Ready               coredns-565d847f94-t9mnc                       kube-system         0                   (default)
4671f6d19e376       3 hours ago         Ready               coredns-565d847f94-hgp8w                       kube-system         0                   (default)
15dd2f5bb2afc       3 hours ago         Ready               kube-proxy-ptmb4                               kube-system         0                   (default)
055f55bbb0e7f       3 hours ago         Ready               kube-flannel-ds-ts4cn                          kube-flannel        0                   (default)
617e6274eaa1c       3 hours ago         Ready               etcd-k8s-cplane.novalocal                      kube-system         0                   (default)
31591004ece02       3 hours ago         Ready               kube-scheduler-k8s-cplane.novalocal            kube-system         0                   (default)
1cf4de5c81987       3 hours ago         Ready               kube-controller-manager-k8s-cplane.novalocal   kube-system         0                   (default)
ba5c6071d80c2       3 hours ago         Ready               kube-apiserver-k8s-cplane.novalocal            kube-system         0                   (default)

例えばつぎのように...

$ sudo crictl inspectp  2a3adfa877a49     #pv-waru5
$ sudo crictl inspectp  a868e5b96d270   #pv-waru6
$ sudo crictl inspectp  254ab4917621f    #pv-waru7

各Podのコンフィグについておさらいすると...

  • pv-waru5は任意のseccompファイルを指定してPodを作成
  • pv-waru6 はRuntimeDefaultを設定
  • pv-waru7 はseccompProfileを指定していない

crictl inspectpを実行すると、Podのいろいろな情報の中にcapabilitieseffectivepermittedsyscallsなどの情報が含まれています。 pv-waru7にもcapabilities、effective、permitted、syscallsなどは設定されていたものの、なぜかchroot /rootfsは成功してしまいました。

しかしCRI-Oのデフォルト設定であればセキュアなので、今回テストのために設定変更した各項目をもとの設定に戻せば抜けてしまったパターンでも問題が再現することはないでしょう。

$ sudo vi /etc/crio/crio.conf
...
seccomp_profile = ""
...
seccomp_use_default_when_empty = true
...
default_capabilities = [
       "CHOWN",
       "DAC_OVERRIDE",
       "FSETID",
       "FOWNER",
       "SETGID",
       "SETUID",
       "SETPCAP",
       "NET_BIND_SERVICE",
       "KILL",
]
...

設定変更後、crioとkubeletサービスを再起動します。

$  sudo systemctl restart crio kubelet

もう一度pv-waru7 を実行してみましょう。今度は阻止してくれました。これはCRI-Oのケーパビリティのほうが効いている影響だと思います。

$ kubectl create -f  vol-waru7.yaml 
$ kubectl exec -it pv-waru7 -- chroot /rootfs
chroot: can't change root directory to '/rootfs': Operation not permitted
command terminated with exit code 1

同様に、crictl inspectpでPodの状況を確認してみましょう。

$ sudo crictl pods
POD ID              CREATED              STATE               NAME                                           NAMESPACE           ATTEMPT             RUNTIME
7ed614b8103c9       About a minute ago   Ready               pv-waru7                                       default             0                   (default)
...
$ sudo crictl inspectp 7ed614b8103c9
...
            {
              "names": [
                "chroot"
              ],
              "action": "SCMP_ACT_ERRNO",
              "errnoRet": 1
            },

chrootのケーパビリティはPodに対してきちんと設定されており、想定通り動作するのを確認しました。 ただ、正直言ってseccomp_profileをカスタマイズしてセキュリティを担保するのは一般のユーザーには難しいのではないかと思います(もちろん私も)。CRI-Oのようにケーパビリティで設定できたほうが良いと感じます。ここは他のランタイムにはあんまりないんですよね。そういう意味では基本的な設計が同一のPodmanは安心して使えそうです。

(以上加筆終わり)

seccomp を使用してコンテナの Syscall を制限する利点

「コンテナはLinux kernelをホストと共有するので、仮想マシンのようなオーバーヘッドが少ないというメリットがある」を裏返すと、「仮想マシンのようにホストと完全に隔離していないので、コンテナアプリケーションだったりLinux kernel、コンテナなどの脆弱性をついて情報の漏洩、悪用ができてしまう可能性がある」といえます。

この対策としてLinuxのセキュリティ機能(AppArmorやSELinux)や seccomp(セキュアコンピューティングモード)などを使うという方法があります。あとはrootユーザーでコンテナアプリケーションを実行しないというのは前々からTipsとしてありましたね。

これらの機能はランタイム側はすでに実装されている(注1)ものの、Kubernetesではあくまでオプション機能という位置づけであり、デフォルトでは利用されませんでした。Kubernetesのセキュリティ周りは管理者のポリシーに委ねられていました。

Kubernetes 1.22で初期実装され、今回1.25のリリースでデフォルトで有効化されたわけです。

ランタイム開発者が安全にコンテナーを実行するための設定をランタイム側で実装しているため、今回Kubernetes 1.25でこの機能が有効化されたことで、Kubernetesでしばしば報告される潜在的なエクスプロイトを無意味にできます。


注1: CRI-Oとコードの多くを共有しているPodmanでは、SELinuxが有効のマシン上でSELinuxを有効にした状態でコンテナーを動かすことができ、SELinuxが対応できるエクスプロイトはブロックできます。Seccompもデフォルトで有効化されていますし、もう少し簡単な方法でシステムコールの許可/不可を設定できます。DockerやcontainerdはSeccompについてはデフォルトが定義されていたはずですが、設定変更の方法やSELinux関連については情報を見つけられていません。