インフラエンジニアの羅針盤

インフラエンジニア1〜3年目のための技術ガイド

seccompとSELinuxでノード堅牢化【CKS第6回】

公開

新卒インフラエンジニア向け「Kubernetes 実践教科書 ③ CKS セキュリティ・ハードニング編」(全19回)の第6回です。第5回までは、リクエストが API Server に届いてから何が起きるかを扱ってきました。本回はそこから一段下へ降り、コンテナがカーネルに対して何を要求できるかを絞ります。

本回の主題は 1 文で書けます。ホストが Enforcing でも、コンテナが SELinux で拘束されているとは限りません。その差を実機で確認し、埋めます。

扱うのは カーネル層(LSM / syscall / capabilities)に限定します。ノード OS そのものの攻撃面削減——稼働サービスの棚卸し、sudo と SSH の権限設計、firewalld によるノード外通信の制限——は第7回の担当です。同じ D3 ドメインを 2 回に分け、本回が「カーネルが何を止めるか」、次回が「そもそも何を動かさないか」を受け持ちます。

  • ラボ Kubernetes v1.36.3
  • containerd v2.2.6
  • AlmaLinux 10.2(kernel 6.12・SELinux Enforcing・container-selinux 2.246.0-1.el10
  • CKS 試験環境 v1.35
  • 確認日 2026-07-27
目次
  1. 第6回のスコープ・今ここマップ
    1. 本回の起点
    2. 本回で初めて出てくる 2 つの用語
    3. AppArmor をどう扱うか——本書の 3 段構え
    4. 演習の進め方
  2. この回のゴール
  3. なぜ D3 を D4 より前に置くのか(部品 → 束)
    1. 上流がすでに入れている設定を見る
  4. seccomp(syscall フィルタ)
    1. 効いていることをどう確認するか
    2. privileged との関係
    3. Localhost プロファイルの記法
    4. プロファイルが無いときの失敗の形
    5. ノード側で底上げする選択肢
  5. Linux capabilities(drop ALL + 最小 add)
    1. 現状を確認する
  6. SELinux の基礎(ラベルの読み方)
    1. ラベルの四層構造
  7. Enforcing なのにコンテナは拘束されていない
    1. 原因は containerd の設定
    2. 「拒否が出ていない」ことの意味
  8. Kubernetes から SELinux を操作する 2 つの入口
    1. 自動リラベルの対象と対象外
    2. level を指定しないとどうなるか
  9. AppArmor(記法と、非対応ノードでの挙動)
    1. 最も明快な証拠
    2. 新フィールド記法(v1.31 GA 以降の正式)
    3. 旧アノテーション記法(v1.30 で非推奨)
    4. Killer.sh(Ubuntu)で行う実機演習
  10. やってみよう①:containerd の SELinux 連携を有効にする
    1. ステップ1:現状を確認する
    2. ステップ2:設定を書き換える
    3. ステップ3:containerd を再起動する
    4. ステップ4:Pod を作り直してラベルの変化を見る
    5. ステップ5:既存ワークロードを作り直して壊れないことを確かめる
    6. ステップ6:本番ではどうするか
  11. やってみよう②:拒否を意図的に起こして AVC を読み解く
    1. ステップ0:道具を入れる
    2. 前半:拒否を起こして AVC を読む
    3. ステップ1:ホスト側に検証用ディレクトリを作る
    4. ステップ2:hostPath でマウントした Pod から読んでみる
    5. ステップ3:書いてみる
    6. ステップ4:AVC を読む
    7. 後半:AVC に出ない拒否と、恒久的な対処
    8. ステップ5:AVC に出ない拒否を確認する(dontaudit)
    9. ステップ6:正しい対処でアクセスできるようにする
    10. ステップ7:後片付け
  12. やってみよう③:fanclub-backend に seccomp を入れる(GitOps 経由)
    1. ステップ1:現状を測る
    2. ステップ2:変更してよい場所を先に確かめる
    3. ステップ3:Git を直す
    4. ステップ4:commit して push し、同期を待つ
    5. ステップ5:効いたことを確認する
    6. ステップ6:アプリが壊れていないことを確認する
  13. やってみよう④:Localhost seccomp プロファイルを配って適用する
    1. ステップ1:まず k8s-wl-02 だけに置く
    2. ステップ2:置いたノードの Pod に適用して拒否を確認する
    3. ステップ3:配っていないノードに載せてみる
    4. ステップ4:k8s-wl-01 にも配って作り直す
    5. ステップ5:後片付け
  14. やってみよう⑤:AppArmor 非対応ノードでの Pod 拒否を実機再現する
    1. ステップ1:ノードの LSM 対応状況を調べる
    2. ステップ2:AppArmor プロファイルを要求する Pod を投入する
    3. ステップ3:拒否のされ方を観察する
    4. ステップ4:試験環境との差分を理解する
    5. ステップ5:後片付け
  15. なぜ Control Plane には入れないのか(実測に基づく境界)
    1. etcd が起動しなくなる
    2. scheduler と controller-manager も落ちる
    3. 原因はラベルの不一致
    4. もし試してしまったら
  16. 暗記必須コマンド(seccomp / SELinux / AppArmor)
  17. まとめ
  18. 理解度チェック(○×形式・全 9 問)
  19. 次回予告

第6回のスコープ・今ここマップ

本回から CKS ドメイン D3: System Hardening(配点 10%) に入ります。D3 は 4 つのコンピテンシーからなり、そのうち本回が担当するのは 1 つだけです。

公式コンピテンシー(D3 System Hardening・10%)担当対応セクション
Use Host OS footprint reduction to reduce attack surface第7回
Use least-privilege identity and access management第7回
Minimize external access to the network第7回
Appropriately use kernel hardening tools such as AppArmor, seccomp本回(第6回)seccomp / capabilities / SELinux / AppArmor の全節

公式文言に SELinux の名前はありません。ただし such as が示すとおり AppArmor と seccomp は「カーネル堅牢化ツール」の例示です。SELinux は同じ強制アクセス制御(MAC)の別実装であり、本書ラボ(AlmaLinux)ではこちらが実機の主役になります。試験環境(Ubuntu)では AppArmor が動きます。この差の扱い方は、本節の後半で 3 段構えとして整理します。

第3巻 19 回のうち、現在位置は次のとおりです。

第1部 第3巻オリエンテーション
    第1回: 第3巻スコープ + CKA 境界 + 4C/脅威モデル + Kubestronaut

第2部 クラスタ堅牢化(D1/D2)
    第2回: kube-bench + CIS ベンチマーク修復 + バイナリ検証(D1)
    第3回: NetworkPolicy 完全設計 + ノードメタデータ保護 + TLS Ingress(D1)
    第4回: RBAC 監査 + ServiceAccount トークン管理(D2)
    第5回: API Server 堅牢化 + kubeadm アップグレード(D2)

第3部 OS / ノード堅牢化(D3)
  ★ 第6回: カーネル層の強制アクセス制御(SELinux/seccomp/capabilities/AppArmor)(D3)  ← 今ここ
    第7回: ホスト OS の攻撃面最小化と権限管理(D3)

本回の起点

起点は第5回の完了状態です。第2巻から引き継いだ 9 VM 構成(Control Plane Node 3 台 + Workload Node 2 台 + 作業端末・レジストリ・LB・プロキシ)が稼働している前提で進めます。

項目
ノード5/5 Ready・Kubernetes v1.36.3・containerd 2.2.6
OSAlmaLinux 10.2(kernel 6.12)・9 VM すべて Enforcing
containerd の SELinux 連携enable_selinux = false本回で Workload Node 2 台を true にします
fanclubhttps://fanclub.local/ が 200・/api/members が JSON を返す

本回で初めて出てくる 2 つの用語

第3巻の第1回から第5回までで 1 度も使っていない語が 2 つあります。以降の説明の前提になるので、先に定義します。

LSM(Linux Security Module)

とは、カーネルにアクセス制御の仕組みを差し込むための枠組みです。SELinux も AppArmor もこの枠組みに乗った実装で、どちらが組み込まれているかはカーネルのビルド時に決まります。後から入れ替えることはできません。

AVC(Access Vector Cache)

とは、SELinux がアクセス可否を判定した結果を記録するカーネル内のキャッシュです。拒否したときに監査ログへ avc: denied として残るため、SELinux の拒否ログのことを慣用的に AVC と呼びます

もう 1 つ、shim という語が やってみよう① のステップ3 で出てきます。そちらは実物を見ながら定義したほうが分かりやすいので、その場で説明します。

AppArmor をどう扱うか——本書の 3 段構え

本回のタイトルには AppArmor が入っていますが、AlmaLinux ラボでは AppArmor のプロファイルを適用できません。カーネルの LSM が SELinux だけだからです。AppArmor はカーネル機能であり、コンテナはホストのカーネルを共有します。したがって Docker を挟んでも kind を挟んでも、ホストカーネルに AppArmor が無い以上は動きません。試験環境(Ubuntu)では動きます。

この差を曖昧にしないために、本書は次の 3 段構えを取ります。

何を、どこで
1非対応ノードでの拒否挙動を実機再現する(やってみよう⑤)。「動かない」がどういう形で現れるかを自分の目で確認します
2MAC の実機体験は SELinux で担保する(やってみよう①・②)。ラベル・拒否・対処という MAC の骨格は、実装が違っても同じです
3プロファイルの作成とロードは Killer.sh(Ubuntu)で行うapparmor_parseraa-status を打つのはそちらです

分量は SELinux に厚く割きます。ただし試験で手を動かすのは AppArmor と seccomp であり、SELinux は「同じ MAC を実機で理解するための代役」という位置づけです。第19回で AppArmor を重点復習に指定し、Killer.sh(Ubuntu)で補完します。

演習の進め方

本回は「やってみよう」が 5 本あり、通しで実施すると 1 時間強かかります。やってみよう①(containerd の SELinux 連携を有効にする)だけは途中で止めないでください——containerd を書き換えてから既存ワークロードの回帰確認までが 1 セットです。やってみよう② は前半(拒否を起こして AVC を読む)と後半(dontaudit と恒久対処)で区切れます。やってみよう③・④・⑤ はそれぞれ独立しているので、日を分けても構いません。

この回のゴール

本回を終えると、次のことができるようになります。到達できたかは記事末の「やってみよう」と「理解度チェック」で確認します。

  • seccomp(RuntimeDefault / Localhost)で syscall を絞り、/proc/1/statusSeccomp で効果を確認できる
  • enable_selinux の有無でコンテナが container_tspc_t かが変わることを実機で確認し、拘束を有効化できる
  • 意図的に拒否を起こし、AVC の scontext / tcontext / tclass を読んで正しい対処を選べる(dontaudit により AVC に出ない拒否があることも知っている)
  • Kubernetes のボリュームのラベル付けは kubelet の自動リラベルで行われ、hostPath は対象外であることを説明できる
  • AppArmor の新旧記法と、非対応ノードで Pod がどう失敗するかを説明できる
  • Control Plane に同じ設定を入れると何が壊れるかを理由とともに説明できる

なぜ D3 を D4 より前に置くのか(部品 → 束)

CKS のドメイン順で言えば D4(Minimize Microservice Vulnerabilities)は D3 の次です。本書もその順に並べていますが、それは単に番号順だからではありません。本回で扱うものが「部品」で、第8回で扱う Pod Security Standards が「束」だからです。

4C モデル(Cloud → Cluster → Container → Code)で言えば、本回は Container と Node の境界にある OS 層を固めます。そして第8回の PSS Restricted は、seccompProfile: RuntimeDefault / capabilities.drop: [ALL] / runAsNonRoot: true / allowPrivilegeEscalation: false といった個々の設定を要件として束ねたものです。

部品を先に理解しておくと、第8回で「Restricted がなぜその組み合わせを要求するのか」を暗記ではなく理由で説明できます。逆に束から入ると、PSS の要件表を丸暗記することになります。CKS が問うのはそこではありません。

seccomp・capabilities・SELinux・runAsNonRoot という個々の部品が、第8回で扱う Pod Security Standards の Restricted プロファイルという 1 つの要件の束にまとめられる対応関係を示した図
図1:本回で扱う「部品」と、第8回の PSS Restricted という「束」の対応

上流がすでに入れている設定を見る

「部品が実務で使われている」ことは、自分のクラスタで確かめられます。kubeadm v1.36 が生成する Control Plane の static Pod には、すでに securityContext.seccompProfile が入っています

実行コマンド(k8s-cp-01 上・developer):

$ sudo grep -A2 "securityContext" /etc/kubernetes/manifests/etcd.yaml

実行結果:

  securityContext:
    seccompProfile:
      type: RuntimeDefault

同じものが kube-apiserver.yaml にも入っています。上流のインストーラが既定で入れている= CKS が求める最低ラインだと考えてください。自分たちのワークロードにこれが入っていないなら、それは Control Plane より緩い状態にあるということです。

本回の やってみよう③ で、fanclub-backend に同じ RuntimeDefault を入れます。上流に追いつく作業だと考えると位置づけが分かりやすくなります。

seccomp(syscall フィルタ)

seccomp(secure computing mode)は、プロセスが発行できる syscall(システムコール)を制限するカーネル機能です。コンテナが「そもそもカーネルに何を頼めるか」を減らします。Kubernetes では securityContext.seccompProfile で指定します。

type意味
RuntimeDefaultコンテナランタイム既定の安全なプロファイルを使う(推奨の出発点)
Localhostノード上の独自プロファイル(/var/lib/kubelet/seccomp/ 配下)を使う
Unconfined制限なし(Kubernetes の既定はこれ

最小の書き方は次の 3 行です。

securityContext:
  seccompProfile:
    type: RuntimeDefault

注意したいのは、Kubernetes の既定が Unconfined である点です。何も書かなければ syscall フィルタは掛かりません。Docker で docker run したときの既定とは違います。

効いていることをどう確認するか

マニフェストに書いたかどうかではなく、プロセスに実際に掛かっているかを見ます。Linux は seccomp の状態を /proc/<pid>/status に出しているので、コンテナの PID 1 を読みます。

確認用の Pod を 1 つ用意します。seccomp-test.yaml(k8s-ops 上で作成):

apiVersion: v1
kind: Pod
metadata:
  name: seccomp-test
spec:
  nodeName: k8s-wl-02
  securityContext:
    seccompProfile:
      type: RuntimeDefault
  containers:
  - name: app
    image: busybox:1.37
    command: ["sleep", "3600"]

実行コマンド(k8s-ops 上・developer):

$ kubectl apply -f seccomp-test.yaml
$ kubectl exec seccomp-test -- grep -E "Seccomp|NoNewPrivs" /proc/1/status

実行結果(seccompProfile: RuntimeDefault を付けた検証 Pod):

NoNewPrivs:	0
Seccomp:	2
Seccomp_filters:	1

Seccomp: 2filter mode(SECCOMP_MODE_FILTER)です。値の意味は次のとおりです。

Seccomp の値意味
0seccomp なし(プロファイルを書いていない Pod はこれ
1strict mode(ほぼ使われない)
2filter mode。BPF フィルタが掛かっている

本書ラボの fanclub-backend は現状 Seccomp: 0 です。やってみよう③ でここを 2 にします。

確認が済んだら消しておきます。

実行コマンド(k8s-ops 上・developer):

$ kubectl delete pod seccomp-test

NoNewPrivs の行について。

上の検証 Pod は allowPrivilegeEscalation を設定していないので 0 です。やってみよう③ で見る fanclub-backend は allowPrivilegeEscalation: false が入っているので 1 になります。seccomp と no-new-privs は別々の設定なので、片方だけ立っていることがあります。同じ行を grep で並べて見るのは、両方の状態を 1 回で読むためです。

privileged との関係

privileged: true のコンテナでは seccomp が無視されます。 プロファイルを書いても掛かりません。特権コンテナは「カーネルへの要求を制限しない」ことを目的にした設定なので、syscall フィルタと同居しないという理解でよいです。

したがって seccomp は特権を落とすこととセットで設計します。「seccomp を入れたから安全」ではなく、「特権を落としたうえで seccomp を入れたから安全」です。この関係は 後述の やってみよう① でも calico-node の実例として出てきます。privileged なコンテナは SELinux でも同じく拘束されません。

Localhost プロファイルの記法

ランタイム既定では足りず、自分で syscall を選びたい場合は Localhost を使います。プロファイルの JSON を各ノードの /var/lib/kubelet/seccomp/profiles/<name>.json に置き、次のように参照します。

securityContext:
  seccompProfile:
    type: Localhost
    localhostProfile: profiles/deny-chmod.json

localhostProfile/var/lib/kubelet/seccomp/ からの相対パスです。先頭に / は付けません。ここは試験でも間違えやすい箇所です。実演は やってみよう④ で行います。

プロファイルが無いときの失敗の形

指定したプロファイルがそのノードに無いと、Pod は起動しません。どういうステータスで止まるかを先に知っておくと、切り分けが速くなります。

NAME                   READY   STATUS                 RESTARTS   AGE
seccomp-missing-test   0/1     CreateContainerError   0          25s

イベントには理由がそのまま出ます。

  Warning  Failed  kubelet  Error: failed to create containerd container:
  cannot load seccomp profile "/var/lib/kubelet/seccomp/profiles/nonexistent.json":
  open /var/lib/kubelet/seccomp/profiles/nonexistent.json: no such file or directory

kubectl get pod の STATUS 列に出るのは Pending でも ImagePullBackOff でもなく CreateContainerError です。コンテナが 1 つも起動していないので status.phase 自体は Pending のまま、という組み合わせになります。試験で「Pod が起動しない」原因を切り分けるとき、このステータス名から seccomp を疑えるようにしておいてください。実物は やってみよう④ のステップ3 で出します。

ノード側で底上げする選択肢

Pod ごとに seccompProfile を書いて回るのは漏れます。kubelet の KubeletConfigurationseccompDefault: true を設定すると、seccompProfile を書いていない Pod のクラスタ既定を RuntimeDefault にできます。1 Pod ずつ書く運用より確実です。

ただし、既存ワークロードが必要としている syscall が既定プロファイルで塞がれていると壊れます。段階導入(一部ノードから、あるいは Unconfined を明示した例外を用意しながら)が要ります。本書ラボでは有効化しません。 設定項目は kubernetes.io/docs に載っているので試験中も参照できます。

なお第5回で確認したとおり、kubeadm クラスタで kubelet 設定の正本は kubelet-config ConfigMap です。/var/lib/kubelet/config.yaml だけを直しても kubeadm upgrade node で消えます。seccompDefault を本番で入れるなら ConfigMap 側から入れてください。

Linux capabilities(drop ALL + 最小 add)

root 権限は 1 枚岩ではなく、capabilities という単位に細分化されています。「ネットワーク設定を変えられる」「他ユーザーのプロセスに signal を送れる」「1024 番未満のポートに bind できる」といった能力が個別のフラグになっています。

CKS の定石はすべて drop してから必要なものだけ add することです。

securityContext:
  capabilities:
    drop:
      - ALL
    add:
      - NET_BIND_SERVICE

NET_BIND_SERVICE が要るのは 1024 番未満の特権ポートに bind するときです。fanclub-backend は 8080 番で待ち受けるので不要で、drop: [ALL] だけで動きます。「とりあえず NET_BIND_SERVICE を足す」のは、要件を確認していないだけの add です。

この節は既習範囲の確認です。

capabilities.drop: [ALL]第1巻第15回で fanclub-backend にすでに適用済みです(runAsNonRoot: true / runAsUser: 10001 / readOnlyRootFilesystem: true / allowPrivilegeEscalation: false とセット)。本回で新規に足すのは seccomp だけです。ここでは「すでに入っている設定を確認し、seccomp と組み合わせると何が変わるか」に力点を置きます。

現状を確認する

実行コマンド(k8s-ops 上・developer):

$ kubectl get deploy fanclub-backend -n fanclub -o jsonpath='{range .spec.template.spec.containers[*]}{.name}{" => "}{.securityContext}{"\n"}{end}'

実行結果:

backend => {"allowPrivilegeEscalation":false,"capabilities":{"drop":["ALL"]},"readOnlyRootFilesystem":true,"runAsNonRoot":true,"runAsUser":10001}

第1巻第15回で入れた 5 項目がそのまま残っています。seccompProfile が無いことも、この 1 行から読めます。

ここで気づいてほしいこと。

いま読んだのは containers[*]securityContext です。この Deployment には initContainerswait-for-db と、ネイティブサイドカーの log-shipper もあります。その 2 つには securityContext が 1 つも設定されていません。 jsonpath の対象を containers[*] にしている限り、この点は視界に入りません。

やってみよう③ で seccomp を Pod レベルに置く理由がここにあります。

init コンテナとサイドカーを含めて見るなら、次のように 3 つのリストを並べて出します。

実行コマンド(k8s-ops 上・developer):

$ kubectl get deploy fanclub-backend -n fanclub -o jsonpath='{range .spec.template.spec.initContainers[*]}{.name}{" => "}{.securityContext}{"\n"}{end}'

実行結果(securityContext が無いので名前の後ろが空になります):

wait-for-db =>
log-shipper => 

log-shipperinitContainers 側に出てくるのは、これが restartPolicy: Always を持つネイティブサイドカーだからです。Kubernetes のネイティブサイドカーは initContainers に書きつつ、Pod の生存中ずっと動き続けます。Pod が 2/2 に見えるのはこのサイドカーの分です。

SELinux の基礎(ラベルの読み方)

ここから SELinux に入ります。第2巻で AlmaLinux を Enforcing のまま構築してきたので、本書ラボの 9 VM はすべて有効な状態です。まず現状を読みます。

実行コマンド(k8s-wl-02 上・developer):

$ getenforce

実行結果:

Enforcing

実行コマンド(k8s-wl-02 上・developer):

$ sudo sestatus

実行結果:

SELinux status:                 enabled
SELinuxfs mount:                /sys/fs/selinux
SELinux root directory:         /etc/selinux
Loaded policy name:             targeted
Current mode:                   enforcing
Mode from config file:          enforcing
Policy MLS status:              enabled
Policy deny_unknown status:     allowed
Memory protection checking:     actual (secure)
Max kernel policy version:      33

読むべき行は 3 つです。SELinux status: enabled(機能が有効)、Current mode: enforcing(違反を遮断する)、Mode from config file: enforcing(再起動後も enforcing)。この 3 つが揃って初めて「保護が働いている」と言えます。

3 つ目が重要なのは、setenforce 0 による変更が再起動で元に戻るからです。「動かないから setenforce 0 して直った」という対処は、次の再起動で再発します。しかもその間、原因は記録されていません。

モード違反したとき保護
Enforcing遮断するし、記録もする働いている
Permissive記録するだけで遮断しない働いていない
Disabled何もしない働いていない

Permissive は「ポリシーを作り込む途中で、何が拒否されるかを一通り集める」ための一時的な状態です。恒久的にそこへ置くのは、SELinux を切っているのと保護の面では同じです。

公式手順との違いを押さえてください。

Kubernetes 公式の kubeadm インストール手順には、いまも setenforce 0 で permissive にする記述があります。幅広い環境で確実に動かすための保守的な案内です。本書は第2巻第2回でこれに従わず、Enforcing のまま構築しました(container-selinux パッケージがコンテナ向けのポリシーを提供するためです)。

「公式手順に書いてあるから緩める」ではなく「自分の環境で本当に必要か検証して判断する」という姿勢は、CKS が問う実務力そのものです。

ラベルの四層構造

SELinux はプロセスにもファイルにもラベルを付け、その組み合わせで可否を決めます。まずプロセス側を見ます。

実行コマンド(k8s-wl-02 上・developer):

$ ps -eZ | grep -E "containerd|kubelet"

実行結果(抜粋):

system_u:system_r:container_runtime_t:s0        containerd
system_u:system_r:kubelet_t:s0-s0:c0.c1023      kubelet

次にファイル側です。ls -Z でラベルが出ます。

実行コマンド(k8s-wl-02 上・developer):

$ sudo ls -Zd /etc/kubernetes /var/lib/containerd /var/lib/kubelet /var/lib/longhorn

実行結果(ls は引数の順ではなくソート順で出力します。ラベル列は右揃えになるため /etc/kubernetes の行だけ先頭が字下げされて見えます):

  system_u:object_r:kubernetes_file_t:s0 /etc/kubernetes
system_u:object_r:container_var_lib_t:s0 /var/lib/containerd
system_u:object_r:container_var_lib_t:s0 /var/lib/kubelet
system_u:object_r:container_var_lib_t:s0 /var/lib/longhorn

ラベルは user:role:type:level の 4 つ組です。

要素役割
usersystem_u / unconfined_uSELinux 上のユーザー。Linux のユーザーとは別物
rolesystem_r / object_rプロセスは system_r、ファイルなどのオブジェクトは object_r
typecontainer_t / usr_t / kubernetes_file_t判定の主役。プロセスの type とオブジェクトの type の組み合わせで可否が決まる
levels0 / s0:c9,c240MCS(Multi-Category Security)。同じ type どうしをさらに分離する

判定に効くのは主に type です。プロセスの type とオブジェクトの type の組み合わせがポリシーで許可されているかどうかで、通るか弾かれるかが決まります。この仕組みを type enforcement と呼びます。

末尾の level(MCS)は、同じ type どうしの分離に使います。container_t のコンテナが 2 つあっても、カテゴリが c9,c240c337,c338 のように違えば互いのファイルを読めません。この性質は第10回のマルチテナンシー分離につながります。

なお /var/lib/longhorncontainer_var_lib_t になっている点は、次節以降で扱う CSI ボリュームの自動リラベルへの伏線です。

実例として第2巻を振り返ります。

第2巻第3回で k8s-lb の HAProxy が 6443 番へ接続できず、setsebool -P haproxy_connect_any 1 で解決しました。あれは Enforcing が働いていた証拠です。「動かないから SELinux を切る」ではなく「何が拒否されたかを読んで、必要な 1 つだけを許可する」という対処の実例でした。

本回の やってみよう② では、同じ流れをコンテナに対して行います。

Enforcing なのにコンテナは拘束されていない

本回の核心です。ホストが Enforcing であることと、コンテナが SELinux で拘束されていることは別問題です。手元のクラスタで確かめます。

実行コマンド(k8s-wl-02 上・developer):

$ ps -eZ | grep calico-node

実行結果:

system_u:system_r:spc_t:s0        39859 ?        00:00:00 calico-node
system_u:system_r:spc_t:s0        39862 ?        00:00:32 calico-node
system_u:system_r:spc_t:s0        39863 ?        00:00:00 calico-node

1 つのコンテナだけを見ても全体は分かりません。ノード上の全プロセスを type 別に集計します。

実行コマンド(k8s-wl-02 上・developer):

$ ps -eZ | cut -d" " -f1 | cut -d: -f3 | sort | uniq -c | sort -rn | head

実行結果(プロセス数は起動のたびに変わります。本書ラボでも別の起動では 137 kernel_t / 10 unconfined_t になりました。見るのは絶対数ではなく「spc_t が並び、container_t が 1 つも無い」という並びです):

    139 kernel_t
     96 spc_t
     16 container_runtime_t
     11 unconfined_t

すべてのコンテナが spc_t(super privileged container)で動いており、container_t は 1 つもありません。

spc_t は事実上ドメイン制約のない type で、type enforcement による封じ込めが効きません。ホストは Enforcing なのに、コンテナは拘束されていない状態です。

原因は containerd の設定

コンテナに SELinux ラベルを割り当てるのはコンテナランタイムの仕事です。containerd 側でその機能が無効なら、kubelet が渡す SELinux 情報は使われません。

Workload Node には crictl が入っていません(第2巻第15回で 3 台の Control Plane Node にのみ導入しました)。そこで containerd config dump で実効値を読みます。設定ファイルではなく、containerd が実際に採用している値が出ます。

実行コマンド(k8s-wl-02 上・developer):

$ sudo containerd config dump | grep -i selinux

実行結果:

    enable_selinux = false
    selinux_category_range = 1024

containerd v2 の既定は enable_selinux = false です。 この状態では、コンテナは spc_t で起動します。つまり seLinuxOptions を Pod に書いても効かず、後述する kubelet の自動リラベルも起きません。SELinux 関連の Pod 設定がすべて無効化されていると考えてください。

selinux_category_range = 1024 は、MCS カテゴリを何個の範囲から払い出すかの設定です。有効化した後に c9,c240 のような値が出てくるのはこの範囲からの割り当てです。

「拒否が出ていない」ことの意味

構図をはっきりさせるために、Control Plane Node のファイルラベルを見ます。

実行コマンド(k8s-cp-01 上・developer):

$ ls -Zd /var/lib/etcd

実行結果:

unconfined_u:object_r:var_lib_t:s0    /var/lib/etcd

コンテナ用の type(container_var_lib_tcontainer_file_t)ではなく、汎用の var_lib_t です。それでも etcd コンテナが読み書きできているのは、そのプロセスが spc_t だからにほかなりません。拘束されていないので、ラベルが噛み合っていなくても通ってしまいます。

「AVC 拒否が 0 件だからハードニング済み」ではありません。「拘束していないから拒否が出ていない」だけ

です。この 2 つは監査ログの見た目が同じになります。ログの件数ではなく、ps -eZ の type を見てください。

第2回で kube-bench を回したとき、SELinux 関連の項目は FAIL になりませんでした。CIS Benchmark が見るのは「ホストの SELinux が有効か」までだからです。コンテナが spc_t で動いているかどうかは検査項目にありません。

第5回でも「ベンチマークが通ることと安全であることは別」という論点が出ました。本回はそれを SELinux で、しかも数値ではなくプロセスラベルという形で確認しています。ベンチマークは出発点であって到達点ではありません。

containerd の enable_selinux が false のときコンテナが spc_t で拘束されず、true にするとコンテナが container_t になりボリュームも container_file_t にリラベルされる対比と、Workload Node には入れ Control Plane Node には入れないという境界を示した図
図2:enable_selinux の false / true でラベルがどう変わるか、そして適用範囲の境界

Kubernetes から SELinux を操作する 2 つの入口

コンテナを container_t で動かせるようになったとき、Pod 側から指定できるのは何かを整理します。先に誤解を 1 つ潰しておきます。

よくある誤解::z:Z

これらは Docker / Podman の -v /host:/ctr:Z という記法であって、Kubernetes の Pod spec には存在しません。CKS の学習で Podman の記事を読むと混同しやすいので、ここで切り分けます。Pod の YAML に :z を書ける場所はありません。

Kubernetes での入口は 2 つです。

機構何をするか指定
securityContext.seLinuxOptionsPod / コンテナのプロセスに割り当てるラベル(user / role / type / level)を明示指定する手で書く
kubelet の自動リラベルPod の SELinux コンテキストに合わせて、対応するボリュームの実体にラベルを付け替える不要(自動)

seLinuxOptions は次のように書きます。

spec:
  securityContext:
    seLinuxOptions:
      level: "s0:c100,c200"

4 つ組のうち user / role / type / level を個別に指定できます。実務で書くことが多いのは level(MCS カテゴリの固定)です。type を変えるのは、専用のポリシーモジュールを用意している場合に限られます。

自動リラベルの対象と対象外

次の Pod で、プロセスとボリュームの実体を実測しました(YAML 全量)。

この節の実測は、やってみよう① を終えた後の状態で採ったものです。

いま同じ Pod を作っても、enable_selinux = false のままなのでプロセスは spc_t になり、seLinuxOptions も自動リラベルも効きません(1 つ前の節で確認したとおりです)。手を動かすのは やってみよう① の後にしてください。ここでは「有効化するとこうなる」という結果だけ先に見ておきます。

やってみよう① を終えてから試す場合は、この YAML を selinux-level-test.yaml として保存し、kubectl apply -f selinux-level-test.yaml で作成してください。プロセスは k8s-wl-02 上の ps -eZ | grep sleep、emptyDir の実体は sudo find /var/lib/kubelet/pods -maxdepth 4 -type d -path "*/volumes/*" -name scratch -exec ls -Zd {} \; で確認できます。確認が終わったら kubectl delete pod selinux-level-test で片付けます。

apiVersion: v1
kind: Pod
metadata:
  name: selinux-level-test
spec:
  nodeName: k8s-wl-02
  securityContext:
    seLinuxOptions:
      level: "s0:c100,c200"
  containers:
  - name: app
    image: busybox:1.37
    command: ["sleep", "3600"]
    volumeMounts:
    - name: scratch
      mountPath: /scratch
    - name: hostdir
      mountPath: /hostdir
  volumes:
  - name: scratch
    emptyDir: {}
  - name: hostdir
    hostPath:
      path: /opt
      type: Directory

結果は次のとおりです。

対象実測ラベル判定
プロセスsystem_u:system_r:container_t:s0:c100,c200指定どおり
emptyDir のホスト側実体system_u:object_r:container_file_t:s0:c100,c200自動リラベルされる
hostPath の実体unconfined_u:object_r:usr_t:s0リラベルされない

自動リラベルの対象になるのは emptyDir / Secret / ConfigMap / SELinux マウントに対応した CSI ドライバです。本書ラボの Longhorn は CSI 側で対応しているので、やってみよう① で db-0 を作り直したときにマウント点がリラベルされます。

hostPath は対象外です。ホストのディレクトリをそのまま見せる仕組みなので、kubelet が勝手にラベルを書き換えるとホスト側の他の利用者に影響が出ます。必要ならホスト側で chcon / semanage fcontext するしかありません。やってみよう② はこのケースを扱います。

有効化する前から存在していたボリュームは、そのままでは直りません。

enable_selinux = false の時代に作られた Pod のマウント点は unlabeled_t のまま残っているのを実測で確認しました。自動リラベルはPod を作るときに走る処理だからです。有効化した後で ps -eZcontainer_t を返しても、古いボリュームが噛み合っていない可能性があります。Pod を作り直すところまでが 1 セットだと考えてください。

level を指定しないとどうなるか

seLinuxOptions を書かない場合、kubelet と containerd が Pod ごとにランダムな MCS カテゴリ 2 つを払い出します。実測では s0:c9,c240s0:c337,c338 のような値になりました。

これにより、同じ container_t どうしでも Pod 間でファイルを覗けません。type が同じでもカテゴリが違えば通らないという MCS の性質が、Pod 単位の分離としてそのまま使われています。何も書かなくても分離が効くのはこのためです。

あえて明示指定するのは、テナントごとにカテゴリを固定したいような場合です。同じテナントの Pod どうしは共有させ、別テナントとは分ける、という設計ができます。この使い方は第10回のマルチテナンシー分離で扱います。

1 つのサイドカーと本体コンテナは同じ MCS カテゴリになります。

カテゴリの払い出し単位は Pod です。やってみよう① で確認しますが、fanclub-backend の backendlog-shipper は同じカテゴリで動きます(本書ラボの実測では c143,c590 でしたが、Pod を作るたびに変わるので手元では別の数字になります)。共有 emptyDir 経由でログを渡せるのはこのためです。Pod は SELinux から見ても 1 つの単位だと考えてください。

AppArmor(記法と、非対応ノードでの挙動)

AppArmor は、プロファイルでプロセスのファイルアクセス・capability・ネットワーク等を制限する LSM です。SELinux がラベルで判定するのに対し、AppArmor はパスで判定するのが大きな違いです。Ubuntu / Debian 系では既定で有効ですが、RHEL 系(AlmaLinux)はカーネルが SELinux のみを LSM として組み込んでいます

最も明快な証拠

実行コマンド(k8s-wl-02 上・developer):

$ cat /sys/kernel/security/lsm

実行結果(AlmaLinux 10.2):

lockdown,capability,yama,selinux,bpf,landlock,ima,evm

apparmor が含まれていません。 Ubuntu なら同じファイルに apparmor が並びます。カーネルに組み込まれていない以上、パッケージを入れても Pod に指定しても動きません。この 1 行が、AppArmor が使えるかどうかの最短の判定方法です。

新フィールド記法(v1.31 GA 以降の正式)

securityContext:
  appArmorProfile:
    type: Localhost
    localhostProfile: k8s-apparmor-example-deny-write
type意味
RuntimeDefaultランタイム既定の AppArmor プロファイル
Localhostノードにロード済みの独自プロファイル。localhostProfile で名前を指定する
Unconfined制限なし

seccomp と同じ 3 種類ですが、localhostProfile に書くのはファイルパスではなくプロファイル名である点が違います。seccomp は /var/lib/kubelet/seccomp/ からの相対パス、AppArmor は apparmor_parser でロード済みのプロファイル名です。この非対称は試験でも間違えやすい箇所なので、並べて覚えてください。

旧アノテーション記法(v1.30 で非推奨)

container.apparmor.security.beta.kubernetes.io/<container>: localhost/<profile>

アノテーションなので metadata.annotations に書き、コンテナ名をキーに含めるのが特徴です。値の localhost/ という接頭辞も新記法にはありません。既存のマニフェストや古い教材にはこちらが出てくるので、新旧の対比は CKS でも問われ得ます。新規に書くときは新フィールド記法を使ってください。

観点旧(アノテーション)新(フィールド)
書く場所metadata.annotationssecurityContext.appArmorProfile
コンテナの指定キーの末尾にコンテナ名コンテナの securityContext に書く(Pod レベルも可)
プロファイル名localhost/<profile>localhostProfile: <profile>
状態v1.30 で非推奨v1.31 で GA

Killer.sh(Ubuntu)で行う実機演習

AlmaLinux ラボではプロファイルを適用できないので、プロファイルの作成とロードは Ubuntu 環境で行ってください。手順の形は次の 4 段です。予習として頭に入れておくと、Killer.sh で迷いません。

  1. プロファイルを Workload Node にロードする(apparmor_parser -q /etc/apparmor.d/<profile>
  2. ロード済みであることを確認する(aa-status または cat /sys/kernel/security/apparmor/profiles
  3. Pod に appArmorProfiletype: Localhost + プロファイル名)を指定する
  4. プロファイルが禁止する操作がブロックされることを確認する

ロードが先で、Pod の投入が後です。未ロードのプロファイル名を指定すると Pod は起動しません。seccomp の Localhost でファイルを置き忘れたときと同じ構図で、こちらも「ノードごとのローカルな準備」が前提になります。

本書ラボでは、代わりに非対応ノードでの拒否のされ方を やってみよう⑤ で確認します。同じマニフェストが Ubuntu では通り、AlmaLinux では通らない。その差がどう表示されるかを知っておくことは、試験環境と手元環境を行き来する学習では実用的です。

やってみよう①:containerd の SELinux 連携を有効にする

本回の 5 つの演習には、所要時間と「試験相当の粒度はどこか」を併記しています。

LF は出題数・各問の配点・試験クラスタのノード構成を公表していません。本書が書く「試験ではここが 1 問(何分)」は試験時間 120 分から逆算した本書独自の目安であり、公式情報ではありません。数字そのものではなく、ラボで手を動かす範囲と試験で問われる範囲の差を読み取ってください。

所要 20 分(うち試験相当の粒度は ステップ2〜4 の 5 分)。

試験で問われる粒度に近いのはステップ2〜4 です。ステップ1(現状確認)とステップ5〜6(既存ワークロードの回帰確認)は、本番運用で必要な前後の作法です。

なお、この作業がそのまま試験に出るという意味ではありません。試験環境の MAC は AppArmor なので、enable_selinux を書き換える設問は想定しにくいところです。ここで身につけてほしいのは「MAC を有効にする → 効いたことをプロセスのラベルで測る」という型で、これは AppArmor のプロファイル適用でもまったく同じ手順になります。

この演習を始める前に

  • 対象は Workload Node 2 台(k8s-wl-01 / k8s-wl-02)だけです。Control Plane Node には入れません。理由は本回の後半(Control Plane に入れない境界の節)で実測とともに示します
  • 起点は第5回完了状態(K8s v1.36.3・全 9 VM が Enforcingenable_selinux = false)です
  • この演習は途中で止めないでください。 containerd の設定を書き換えてから、既存ワークロードを作り直して回帰確認するまでが 1 セットです
  • Pod を作り直す操作を含みます。fanclub は一時的にアプリ断が発生します
  • 方針として、壊れたら AVC を読んで個別に直します。setenforce 0 にも enable_selinux = false にも戻しません

ステップ1:現状を確認する

k8s-wl-01 と k8s-wl-02 の両方で確認します。3 点を揃えて見ておくと、後で「何が変わったか」を言葉にできます。

実行コマンド(k8s-wl-01 / k8s-wl-02 上・developer):

$ getenforce
$ sudo containerd config dump | grep -i selinux
$ ps -eZ | grep calico-node

実行結果:

Enforcing
    enable_selinux = false
    selinux_category_range = 1024
system_u:system_r:spc_t:s0         4072 ?        00:00:08 calico-node
system_u:system_r:spc_t:s0         4074 ?        00:00:00 calico-node
system_u:system_r:spc_t:s0         4076 ?        00:00:00 calico-node

Enforcing / enable_selinux = false / spc_t の 3 点セットです。「ホストは守られているのにコンテナは拘束されていない」という状態が、この 3 行に集約されています。

ステップ2:設定を書き換える

k8s-wl-01 と k8s-wl-02 の両方で行います。まずバックアップを取ってから sed で 1 行だけ差し替えます。

実行コマンド(k8s-wl-01 / k8s-wl-02 上・developer):

$ sudo cp /etc/containerd/config.toml /etc/containerd/config.toml.bak-06
$ sudo sed -i 's/^    enable_selinux = false/    enable_selinux = true/' /etc/containerd/config.toml
$ sudo containerd config dump | grep -i selinux

実行結果:

    enable_selinux = true
    selinux_category_range = 1024

sed ではなくエディタで直す場合は、/etc/containerd/config.tomlvi で開き、[plugins.'io.containerd.cri.v1.runtime'] セクションの直下にある行を書き換えます。本書ラボでは 59 行目付近です。

  [plugins.'io.containerd.cri.v1.runtime']
    enable_selinux = true
    selinux_category_range = 1024

インデントの 4 スペースまで含めて置換しているのは、TOML のセクション構造を壊さないためです。

enable_selinux という文字列はこのファイルに 1 箇所しかありませんが、^ (行頭 + 4 スペース)を付けておくと、意図しない行に当たる余地がなくなります。置換後は必ず containerd config dump で読み直して確認してください。 TOML が壊れていると、この時点でパースエラーが出ます。

ステップ3:containerd を再起動する

設定ファイルを書き換えただけでは containerd は読み直しません。k8s-wl-01 と k8s-wl-02 の両方で再起動します。

実行コマンド(k8s-wl-01 / k8s-wl-02 上・developer):

$ sudo systemctl restart containerd
$ systemctl is-active containerd kubelet

実行結果:

active
active

shim(containerd-shim

とは、containerd と個々のコンテナの間に立つ小さな常駐プロセスです。コンテナ 1 つにつき 1 つ起動し、コンテナの生死を実際に見張っているのは containerd 本体ではなくこの shim です。

この構造のおかげで、containerd を再起動しても既存のコンテナは巻き添えになりません。実際に数えてみます。

実行コマンド(k8s-wl-02 上・developer):

$ pgrep -c containerd-shim

実行結果(k8s-wl-02 の場合。数そのものはノードに載っているコンテナの数で決まるので、k8s-wl-01 では別の値になります。本書ラボの実測は wl-02 が 15、wl-01 が 50 でした):

15

ここで押さえる点:稼働中のコンテナは 1 つも落ちません。

containerd-shim のプロセス数は再起動の前後とも同じでした(実測: k8s-wl-02 は 15 個・k8s-wl-01 は 50 個で、いずれも前後で変化なし)。containerd はコンテナのライフサイクルを shim に委ねているため、デーモンを入れ替えても既存コンテナは生き続けます。

ただし再起動中の数秒間は CRI が応答しません。その間に来た新規 Pod の作成・kubectl exec・イメージ pull は失敗し、タイミングによっては kubelet が一時的に NotReady を報告します。既存コンテナは生き続けますが、完全な無停止ではないので、本番では 1 ノードずつ、負荷の低い時間帯に実施してください。

つまりこの時点では何も変わっていません。新しい設定は「これから作るコンテナ」にだけ効きます。

ここを理解していないと、次の 2 つで判断を誤ります。1 つは「設定したのに ps -eZspc_t のままだ、失敗した」と早合点すること。もう 1 つは、本番で「再起動してもコンテナが落ちないなら無停止で入れられる」と考えて、実際には Pod を作り直すまで効果が無いことを見落とすことです。

ステップ4:Pod を作り直してラベルの変化を見る

素の Pod を 1 つ作って確認します。nodeName を必ず指定してください。 有効化したノードに載らないと container_t にはなりません。

se-test.yaml(k8s-ops 上で作成):

apiVersion: v1
kind: Pod
metadata:
  name: se-test
spec:
  nodeName: k8s-wl-02
  containers:
  - name: app
    image: busybox:1.37
    command: ["sleep", "3600"]

試験では雛形を速く作るのが効きます。

第1回で用意した export do="--dry-run=client -o yaml" を使って k run se-test --image=busybox:1.37 $do --command -- sleep 3600 > se-test.yaml としてから nodeName を足すのが最短です。--command を付けないと args として生成されるので、掲載 YAML の command と揃えるために付けます(第2巻第10回と同じ記法です)。

実行コマンド(k8s-ops 上・developer):

$ kubectl apply -f se-test.yaml
$ kubectl get pod se-test -o wide

実行結果:

pod/se-test created
NAME      READY   STATUS    RESTARTS   AGE   IP              NODE        NOMINATED NODE   READINESS GATES
se-test   1/1     Running   0          15s   10.244.119.130   k8s-wl-02   <none>           <none>

ノード側でプロセスのラベルを読みます。

実行コマンド(k8s-wl-02 上・developer):

$ ps -eZ | grep -E "sleep|pause" | grep container_t

実行結果:

system_u:system_r:container_t:s0:c168,c271 21193 ? 00:00:00 pause
system_u:system_r:container_t:s0:c168,c271 21225 ? 00:00:00 sleep

container_t + Pod ごとのランダムな MCS カテゴリ 2 つで起動しました。カテゴリの値(ここでは c168,c271)は Pod を作るたびに変わるので、手元では別の数字になります。pause(Pod のインフラコンテナ)と sleep(アプリのプロセス)が同じカテゴリである点を確認してください。カテゴリの払い出し単位が Pod であることが、ここでも確認できます。

後片付けをします。

実行コマンド(k8s-ops 上・developer):

$ kubectl delete pod se-test

ステップ5:既存ワークロードを作り直して壊れないことを確かめる

設定を入れただけで終わらせないのが本番の作法です。DaemonSet・StatefulSet・Deployment を代表的に作り直し、container_t になっても動くことを確認します。

db-0 を作り直すときの順序を守ってください。

順序を間違えると backend が 1/2 のまま止まります。

  1. kubectl delete pod fanclub-db-0 -n fanclub
  2. kubectl get pod fanclub-db-0 -n fanclub -w1/1 Running になるまで待つ(Longhorn の再アタッチ待ち)
  3. その後で kubectl delete pod -n fanclub -l app=fanclub-backend
  4. kubectl get pod -n fanclub で backend 3 本が 2/2 になるのを確認する
  5. curlhttps://fanclub.local/ が 200 を返すことを確認する

手順 2 の待ち時間は、本書ラボの実測で 10 秒ほどでした。Longhorn のボリュームは同じノードへ付け直されるだけなので短時間で済みます。ContainerCreating が数分続く場合は Longhorn 側のアタッチが滞っているので、kubectl describe pod fanclub-db-0 -n fanclub の Events を確認してください。

なぜ db が先で backend が後なのか。

db-0 を作り直すと backend が 1/2 のまま自力復帰しません(Payara の DB 接続プールが再接続しないためです。第4回でも出た現象です)。backend を先に消しても、その後で db を消せば同じ状態に戻ってしまいます。db が 1/1 になってから backend を作り直してください。 この順序なら backend も同時に container_t になります。

まず、privileged な DaemonSet が container_t にならないことを先に確かめます。

実行コマンド(k8s-ops 上・developer):

$ kubectl delete pod -n kube-system -l k8s-app=calico-node --field-selector spec.nodeName=k8s-wl-02
$ kubectl delete pod -n fanclub -l app=fanclub-logcollector --field-selector spec.nodeName=k8s-wl-02

どちらも DaemonSet なので、削除するとすぐ同じノードに作り直されます。1 分ほど待ってからノード側でラベルを見ます。

実行コマンド(k8s-wl-02 上・developer):

$ ps -eZ | grep calico-node
$ ps -eZ | grep container_t | grep sleep

実行結果(pid は作り直すたびに変わります。見るのは 1 列目の type だけです):

system_u:system_r:spc_t:s0        82674 ?        00:00:00 calico-node
system_u:system_r:spc_t:s0        82675 ?        00:00:01 calico-node
system_u:system_r:spc_t:s0        82677 ?        00:00:00 calico-node

system_u:system_r:container_t:s0:c10,c875 83681 ? 00:00:00 sleep

作り直したのに calico-nodespc_t のままです。privileged: true のコンテナはランタイムが意図的に spc_t で起動するためで、SELinux で封じ込める対象から外れます。一方 fanclub-logcollector(busybox で sleep しているだけの DaemonSet)は container_t になりました。「有効化すれば全部拘束される」わけではないことを、ここで押さえてください。

次に db-0 を作り直します。

実行コマンド(k8s-ops 上・developer):

$ kubectl delete pod fanclub-db-0 -n fanclub
$ kubectl get pod fanclub-db-0 -n fanclub -w

1/1 Running になったら Ctrl+C-w を抜け、backend と frontend を作り直します。

実行コマンド(k8s-ops 上・developer):

$ kubectl delete pod -n fanclub -l app=fanclub-backend
$ kubectl delete pod -n fanclub -l app=fanclub-frontend
$ kubectl get pod -n fanclub -o wide

本番では kubectl rollout restart を使ってください。

ここで delete pod -l を使っているのは、演習として「全部を確実に作り直したことを 1 コマンドで担保する」ためです。本番では kubectl rollout restart deployment fanclub-backend -n fanclub のほうが安全です。この chart は maxUnavailable: 0 なので、rollout restart なら新しい Pod が Ready になってから古い Pod を落とす順序が守られ、アプリ断が起きません(そのぶん完了までの時間は長くなります。やってみよう③ で見るとおり 3 本で 9 分ほどです)。StatefulSet の db-0 だけは delete pod が正しい手順です。

作り直した結果を、対象ごとに表で整理します。

対象ノード実測結果
calico-node(DaemonSet・privileged)k8s-wl-02正常起動。ラベルは spc_t のまま(privileged は confine されない)
fanclub-logcollector(DaemonSet)k8s-wl-02正常起動。container_t:s0:c10,c875
fanclub-db-0(StatefulSet・Longhorn CSI)k8s-wl-02約 10 秒で正常起動。プロセスは container_t:s0:c151,c177ボリュームのマウント点も同じカテゴリに自動リラベルされた
fanclub-backend(Deployment × 3)k8s-wl-01 / k8s-wl-02java が container_t:s0:c143,c5902/2 Running。サイドカー log-shippertail同じ MCSpayara-tmpapplog の emptyDir が container_file_t:s0:c143,c590再帰的にリラベルされ、readOnlyRootFilesystem: true とも両立
fanclub-frontend(Deployment × 2・nginx 1.27-alpine)k8s-wl-01 / k8s-wl-02nginx が container_t:s0:c49,c7691/1 Running
https://fanclub.local/200 を維持/api/members の JSON も確認)

ノード側でラベルを確認します。

実行コマンド(k8s-wl-01 上・developer):

$ ps -eZ | grep -E "java|nginx" | grep container_t

実行結果:

system_u:system_r:container_t:s0:c143,c590 35715 ? 00:00:23 java
system_u:system_r:container_t:s0:c49,c769 35838 ? 00:00:00 nginx
system_u:system_r:container_t:s0:c49,c769 35879 ? 00:00:00 nginx
system_u:system_r:container_t:s0:c49,c769 35880 ? 00:00:00 nginx
system_u:system_r:container_t:s0:c49,c769 35881 ? 00:00:00 nginx
system_u:system_r:container_t:s0:c49,c769 35882 ? 00:00:00 nginx

nginx は master と worker で複数プロセスに分かれますが、同じ Pod なのでカテゴリは全部同じです。サイドカーも同じ Pod のカテゴリを共有します。

実行コマンド(k8s-wl-01 上・developer):

$ ps -eZ | grep -E "tail|java" | grep container_t

実行結果:

system_u:system_r:container_t:s0:c143,c590 35664 ? 00:00:00 tail
system_u:system_r:container_t:s0:c143,c590 35715 ? 00:00:23 java

emptyDir が自動リラベルされていることも確認できます。

実行コマンド(k8s-wl-01 上・developer):

$ sudo find /var/lib/kubelet/pods -maxdepth 4 -type d -path "*/volumes/*" -name payara-tmp -exec ls -Zd {} \;

実行結果(Pod の UID は環境ごとに異なります):

system_u:object_r:container_file_t:s0:c143,c590 /var/lib/kubelet/pods/938e7b78-7496-4707-9b1c-60f8efb79c1d/volumes/kubernetes.io~empty-dir/payara-tmp

-path "*/volumes/*" を付けている理由。

これを外すと、同じ名前のディレクトリが plugins/ 配下にも見つかり、そちらは container_var_lib_t:s0(リラベルされていない)として出ます。コンテナに実際にマウントされるのは volumes/ 配下なので、そちらだけを見るように絞っています。

配下の runtime / lib / docroot / config まで再帰的にリラベルされています。サイドカーと共有している applog の emptyDir も同じ c143,c590 です(-name applog に変えて同じコマンドを打つと確認できます)。Pod 単位でカテゴリが払い出されることが、ここでも確認できます。

Longhorn の CSI ボリュームを使っている db-0 も見ておきます。

実行コマンド(k8s-wl-02 上・developer):

$ ps -eZ | grep postgres | head -1

実行結果:

system_u:system_r:container_t:s0:c151,c177 23055 ? 00:00:00 postgres

マウント点も見ておきます。

実行コマンド(k8s-wl-02 上・developer):

$ sudo find /var/lib/kubelet/pods -maxdepth 6 -type d -path "*/volumes/kubernetes.io~csi/*" -name mount -exec ls -Zd {} \;

実行結果(db-0 のボリュームだけが postgres と同じカテゴリになっています。有効化する前に作られた他のボリュームは unlabeled_t のままです):

system_u:object_r:container_file_t:s0:c151,c177 /var/lib/kubelet/pods/.../volumes/kubernetes.io~csi/pvc-226c8f31-f346-4327-aec7-4f3f39707a4b/mount
system_u:object_r:unlabeled_t:s0 /var/lib/kubelet/pods/.../volumes/kubernetes.io~csi/pvc-21281d08-c435-4033-a67d-c95bce442936/mount

マウント点も同じカテゴリへ自動リラベルされていますcontainer_file_t:s0:c151,c177)。「対応した CSI ドライバなら自動リラベルの対象になる」という説明が、Longhorn で成立していることの実証です。hostPath だけが対象外という切り分けを、ここで再確認してください。

ステップ5 の冒頭で確認したとおり、calico-node は有効化した後に作り直しても spc_t のままでした。

privileged: true のコンテナは confine されない——seccomp の節で書いたのと同じ性質です。CNI やストレージのエージェントのように privileged が要る DaemonSet は、SELinux でも seccomp でも拘束できません。それらは「拘束できないもの」として把握し、別の層(イメージの出所・RBAC・NetworkPolicy)で守る対象に回します。

読むときの注意。

作り直した直後は、古い Pod のプロセス(spc_t)と新しい Pod のプロセス(container_t)が数分間混在して見えますps -eZ | grep nginx で両方出ます)。Terminating が消えてから読んでください。混在に気づかずに spc_t の行を見て「有効化に失敗した」と判断するのは、実機でよくある誤りです。

なぜ k8s-wl-01 側もここで確認するのか。

この演習で backend / frontend を一度 container_t にしておくと、やってみよう③ で seccomp を入れたときに変わる変数が seccomp だけになります。順序を逆にすると、壊れたときに SELinux 起因か seccomp 起因かを切り分けられません。1 度に 1 つずつ変えるのは、ハードニングの基本的な進め方です。

本番ガードレール:消すのは Pod だけです。

手順で打つのは kubectl delete pod fanclub-db-0 -n fanclub です。PVC は消さないでください。 Longhorn のボリューム実体は PVC に紐づいており、PVC を消すと Reclaim Policy に従ってデータごと失われます。また本番の DB Pod 再作成はアプリ断を伴うため、計画停止として扱ってください

最後に HTTPS が返ることを確認します。

実行コマンド(k8s-ops 上・developer):

$ curl -sk --noproxy '*' -o /dev/null -w '%{http_code}\n' https://fanclub.local/

実行結果:

200

k8s-ops からは --noproxy '*' が必須です。付け忘れると Squid 経由になり 000 が返ります。第5回で確定した作法です。

ステップ6:本番ではどうするか

ここで書き換えた 2 行はノードごとのローカル設定なので、kubeadm join で足した新しいノードには入りません。ノードを増やすたびに穴が開くということです。本番では構成管理(Ansible / cloud-init / イメージ焼き込み)でノード構築手順そのものに含め、containerd config dump | grep enable_selinux を受け入れテストに入れてください。

あわせて、containerd をパッケージ更新した際に /etc/containerd/config.toml.rpmnew が出ていないかも確認します。手で書き換えた設定は、パッケージ更新のたびに取り残される可能性があります。

「1 台ずつ手で直した設定が、ノード追加とパッケージ更新の 2 経路で失われる」という構図は、やってみよう④ の Localhost seccomp プロファイルでも同じ形で出てきます。ノードローカルな設定は構成管理に載せる——本回で 2 度出てくる論点です。

やってみよう②:拒否を意図的に起こして AVC を読み解く

所要 20 分(うち試験相当の粒度は 前半の 8 分)。

「コンテナから読めないファイルがある。原因を特定して直せ」という設問に近い粒度なのが前半(ステップ1〜4)です。後半(ステップ5〜7)は dontaudit という落とし穴と恒久対処で、実務で効きます。前半と後半は独立しているので、途中で中断しても構いません。

こちらも道具は環境によって変わります。SELinux なら ausearchchcon / semanage、AppArmor なら dmesg / journalctl の拒否ログとプロファイルの修正です。共通しているのは「拒否のログから、主体(誰が)・客体(何に)・操作(何を)を読み取り、どちらを直すか決める」という手順で、試験で問われるのはこの判断のほうです。

やってみよう① でコンテナを container_t にしました。拘束が効いているということは、これまで通っていた操作が拒否され得るということでもあります。その拒否をどう読み、どう直すかが本節です。対象は k8s-wl-02 です。

ステップ0:道具を入れる

永続的なラベル設定に使う semanage は、AlmaLinux 10.2 の既定では入っていません。chcon / restorecon / ausearch / semodule は導入済みです。

実行コマンド(k8s-wl-02 上・developer):

$ sudo dnf install -y policycoreutils-python-utils
$ command -v semanage

実行結果(末尾):

/usr/sbin/semanage

本書ラボの dnf は alma-proxy(Squid・whitelist 方式)経由です。Forbidden で止まる場合は、whitelist に AppStream のミラーが含まれているかを確認してください。第2巻で構築したプロキシの設定がそのまま効いています。

なお setroubleshoot-serverjournalctl -t setroubleshoot で人間向けの解説を出すツール)は入れません。本回で必要な情報は ausearch の生の AVC で足ります。ノードに入れるパッケージは少ないほどよく、これは第7回で扱う攻撃面削減の考え方と同じです。「便利だから入れる」ではなく「必要だから入れる」で判断してください。

前半:拒否を起こして AVC を読む

ステップ1:ホスト側に検証用ディレクトリを作る

実行コマンド(k8s-wl-02 上・developer):

$ sudo mkdir -p /opt/se-demo
$ echo "top-secret-data" | sudo tee /opt/se-demo/secret.txt
$ ls -Zd /opt/se-demo
$ ls -Z /opt/se-demo/secret.txt

実行結果(末尾 2 行):

unconfined_u:object_r:usr_t:s0 /opt/se-demo
unconfined_u:object_r:usr_t:s0 /opt/se-demo/secret.txt

/opt 配下なので既定ラベルは usr_t です。何も細工していない、ごく普通のディレクトリである点を押さえてください。この後で起きる拒否は、特別な仕掛けの結果ではありません。

ステップ2:hostPath でマウントした Pod から読んでみる

sedeny-test.yaml(k8s-ops 上で作成):

apiVersion: v1
kind: Pod
metadata:
  name: sedeny-test
spec:
  nodeName: k8s-wl-02
  containers:
  - name: app
    image: busybox:1.37
    command: ["sleep", "3600"]
    volumeMounts:
    - name: demo
      mountPath: /data
  volumes:
  - name: demo
    hostPath:
      path: /opt/se-demo
      type: Directory

この YAML の 2 箇所は演習の成立条件です。

nodeName: k8s-wl-02 が無いと /opt/se-demo の無いノードに載る可能性があります。hostPath の type: Directory が無いと、パスが存在しなくても黙って空ディレクトリが作られてマウントされ、「読めるはずのファイルが無い」という別の混乱が起きます。hostPath には type を書くのは実務でも守ってください。

実行コマンド(k8s-ops 上・developer):

$ kubectl apply -f sedeny-test.yaml
$ kubectl exec sedeny-test -- cat /data/secret.txt

実行結果:

pod/sedeny-test created
top-secret-data

なぜ読めるのか。

container_t はシステムの読み取り専用ファイル型(usr_t を含む)への read をポリシーで許可されています。「SELinux が有効=何も読めない」ではありません。 許可されている組み合わせは通り、許可されていない組み合わせだけが弾かれます。

ステップ3:書いてみる

実行コマンド(k8s-ops 上・developer):

$ kubectl exec sedeny-test -- sh -c 'echo x > /data/w.txt'

実行結果:

sh: can't create /data/w.txt: Permission denied
command terminated with exit code 1

拒否されました。ここで注意したいのは、Permission denied という文言だけでは SELinux が原因だと分からない点です。通常のパーミッション(rwx)でも同じメッセージが出ます。ls -l で権限を見て問題が無いのに拒否される、というときに AVC を疑います。

ステップ4:AVC を読む

実行コマンド(k8s-wl-02 上・developer):

$ sudo ausearch --input-logs -m AVC -ts recent

--input-logs を省くと、AVC があっても 1 件も出ません。

AlmaLinux 10.2 の audit-4.0.3-5.el10 では、ausearch に既定の入力ログが渡りません。sudo ausearch -m AVC -ts recent と打つと <no matches> になり、拒否が起きているのに「何も無い」と読めてしまいます——本節がこの後で扱う誤診(「ausearch に出ない → SELinux ではない」)を、コマンドの側で踏むことになります。--input-logs(または -if /var/log/audit/audit.log)を必ず付けてください。 試験環境のディストリビューションでは省いても動くことがありますが、付けて困ることはありません。

実行結果(該当部分):

type=AVC msg=audit(1785106053.092:1132): avc:  denied  { write } for  pid=33621 comm="sh"
  name="se-demo" dev="dm-0" ino=68106886
  scontext=system_u:system_r:container_t:s0:c486,c575
  tcontext=unconfined_u:object_r:usr_t:s0 tclass=dir permissive=0

1 行ずつ読みます。この 6 要素だけ押さえれば、AVC はほぼ読めます。

要素読み方
denied { write }拒否された操作。read / write / open / execute など
comm="sh"実行していたコマンド。どのプロセスが引っ掛かったかの手がかり
scontext=...container_t:s0:c486,c575主体(source context)。操作しようとしたコンテナのプロセス
tcontext=...usr_t:s0客体(target context)。書き込もうとした先のラベル
tclass=dir客体の種類dir ならディレクトリ、file なら個別ファイル
permissive=0実際に遮断された1 なら記録のみで通している

この 1 行から言えることは、container_t のプロセスが、usr_t のディレクトリに書こうとして遮断された」です。直すべき対象は tcontexttclass が指しています——ディレクトリ(dir)のラベル(usr_tです。ファイルのラベルを直しても、この拒否は消えません。

AVC ログ 1 行の各要素(denied の操作・scontext の主体・tcontext の客体・tclass の客体種別・permissive の遮断有無)の読み方と、dontaudit により拒否が記録されない分岐を示した図
図3:AVC 1 行の読み方と、dontaudit で見えない拒否がある分岐

後半:AVC に出ない拒否と、恒久的な対処

ステップ5:AVC に出ない拒否を確認する(dontaudit)

今度は読み取りを拒否させます。ファイルのラベルを、コンテナに許可されていない type に変えます。

実行コマンド(k8s-wl-02 上・developer):

$ sudo chcon -t admin_home_t /opt/se-demo/secret.txt
$ ls -Z /opt/se-demo/secret.txt

実行結果:

unconfined_u:object_r:admin_home_t:s0 /opt/se-demo/secret.txt

実行コマンド(k8s-ops 上・developer):

$ kubectl exec sedeny-test -- cat /data/secret.txt

実行結果:

cat: can't open '/data/secret.txt': Permission denied
command terminated with exit code 1

期待どおり拒否されました。では AVC を見ます。

実行コマンド(k8s-wl-02 上・developer):

$ sudo ausearch --input-logs -m AVC -ts recent

実行結果(ステップ4 の write の 1 件しか出ません。read の拒否は記録されていません):

type=AVC msg=audit(1785106053.092:1132): avc:  denied  { write } for  pid=33621 comm="sh"
  name="se-demo" dev="dm-0" ino=68106886
  scontext=system_u:system_r:container_t:s0:c486,c575
  tcontext=unconfined_u:object_r:usr_t:s0 tclass=dir permissive=0

なぜ出ないのか。

SELinux ポリシーには dontaudit ルールがあり、「起きても記録しない」拒否が定義されています。ノイズを減らすための仕組みですが、トラブルシュート中は事実を隠します。

一時的に無効化して、同じ操作をやり直します。

実行コマンド(k8s-wl-02 上・developer):

$ sudo semodule -DB

実測で 6 秒ほどかかりました。ポリシーを再構築しているので、瞬時には終わりません。

実行コマンド(k8s-ops 上・developer):

$ kubectl exec sedeny-test -- cat /data/secret.txt

実行結果(拒否されるのは同じです):

cat: can't open '/data/secret.txt': Permission denied
command terminated with exit code 1

実行コマンド(k8s-wl-02 上・developer):

$ sudo ausearch --input-logs -m AVC -ts recent

実行結果(今度は read の拒否が記録されています):

type=AVC msg=audit(1785106098.499:1261): avc:  denied  { read } for  pid=34729 comm="cat"
  name="secret.txt" dev="dm-0" ino=68132881
  scontext=system_u:system_r:container_t:s0:c486,c575
  tcontext=unconfined_u:object_r:admin_home_t:s0 tclass=file permissive=0

先ほどとの違いは tcontextadmin_home_ttclassfile である点です。直す対象がファイル単体だと、この 1 行から分かります。

確認が済んだら必ず戻します

実行コマンド(k8s-wl-02 上・developer):

$ sudo semodule -B

この節の要点。

Permission denied なのに ausearch に何も出ない → SELinux ではない」は誤診です。semodule -DB で dontaudit を外してから確認してください。本回で最も実務に効く発見はここです。

本番での注意。

semodule -DB診断中だけの状態です。戻し忘れると監査ログが恒常的に膨らみ、/var/log/audit を圧迫するうえ、本当に見たい AVC がノイズに埋もれます。無効化中は無関係の AVC(sshd 関連など)も大量に出ます。作業手順書に「semodule -B で戻す」を必ずセットで書いてください。

ステップ6:正しい対処でアクセスできるようにする

まず一時的な修正です。chcon でファイルのラベルをコンテナ用の type に変えます。

実行コマンド(k8s-wl-02 上・developer):

$ sudo chcon -t container_file_t /opt/se-demo/secret.txt

実行コマンド(k8s-ops 上・developer):

$ kubectl exec sedeny-test -- cat /data/secret.txt

実行結果:

top-secret-data

読めるようになりました。ただしディレクトリへの書き込みは依然として拒否されます。直したのはファイルのラベルだけで、ディレクトリはまだ usr_t だからです。

実行コマンド(k8s-ops 上・developer):

$ kubectl exec sedeny-test -- sh -c 'echo x > /data/w.txt'

実行結果:

sh: can't create /data/w.txt: Permission denied
command terminated with exit code 1

これが AVC の tcontexttclass を見て、どのオブジェクトを直すかを決めることの実演です。tclass=file の拒否はファイルを直せば消え、tclass=dir の拒否はディレクトリを直さないと消えません。「とりあえず全部 container_file_t にする」ではなく、拒否ログが指している対象だけを直すのが最小権限の考え方です。

chcon が「一時的」である理由。

chconその場でラベルを上書きするだけで、ポリシー側の既定は変わりません。誰かが restorecon を打つか、ファイルシステム全体の再ラベル(/.autorelabel)が走った瞬間に元へ戻ります。恒久的に変えるなら semanage fcontext既定そのものを登録してから restorecon で反映します。

永続的な修正を行います。ディレクトリ配下すべてを対象にします。

実行コマンド(k8s-wl-02 上・developer):

$ sudo semanage fcontext -a -t container_file_t '/opt/se-demo(/.*)?'
$ sudo restorecon -Rv /opt/se-demo
$ ls -Zd /opt/se-demo

実行結果:

Relabeled /opt/se-demo from unconfined_u:object_r:usr_t:s0 to unconfined_u:object_r:container_file_t:s0
/opt/se-demo/secret.txt not reset as customized by admin to unconfined_u:object_r:container_file_t:s0
unconfined_u:object_r:container_file_t:s0 /opt/se-demo

2 行目に注目してください。ステップ6 の前半で chcon を打った secret.txt は「管理者が手で変更したもの」と見なされ、restorecon の対象から外されます。今回は結果的に同じ type なので実害はありませんが、chcon で場当たり的に直したファイルが後の restorecon で揃わない、という食い違いの原因になります。chcon を打ったら restorecon -F(強制)で揃え直すか、最初から semanage fcontext で登録するのが確実です。

'/opt/se-demo(/.*)?' は正規表現で「このディレクトリ自身と、その配下すべて」を意味します。semanage fcontext -aポリシーへの登録restorecon登録内容を実ファイルへ反映する、という 2 段構えです。片方だけでは効きません。

これで書き込みも通ります。

実行コマンド(k8s-ops 上・developer):

$ kubectl exec sedeny-test -- sh -c 'echo x > /data/w.txt && cat /data/w.txt'

実行結果:

x

setenforce 0 は解決策ではありません。

この拒否を setenforce 0 で消すこともできますが、それは対処ではありません。理由は 3 つあります。

  • 全プロセスの保護が消える。 直したかった 1 箇所のために、ノード上の拘束をまとめて外すことになります
  • 再起動で戻る。 Mode from config file: enforcing のままなので、「直った」ように見えて次回起動時に再発します
  • CIS でも FAIL になる。 ベンチマークが見ているのはこの状態そのものです

拒否が出たら、ausearch で読み、tcontexttclass が指す対象だけを直してください。

ステップ7:後片付け

実行コマンド(k8s-ops 上・developer):

$ kubectl delete pod sedeny-test

実行コマンド(k8s-wl-02 上・developer):

$ sudo semanage fcontext -d '/opt/se-demo(/.*)?'
$ sudo rm -rf /opt/se-demo

semanage fcontext -d を忘れると、ディレクトリを消してもポリシーに登録が残ります。 残っていても直ちに害はありませんが、semanage fcontext -l -C(ローカル定義の一覧)にゴミが溜まります。演習用の登録は消してください。あわせて semodule -B を打ち忘れていないかも、ここで確認しておきます。

やってみよう③:fanclub-backend に seccomp を入れる(GitOps 経由)

所要 15 分(うち試験相当の粒度は ステップ3 の 3 分)。

試験で問われるのは「Deployment に seccompProfile を足せ」という編集そのものです。ステップ2(管理対象の確認)とステップ4(Git 経由の反映)は、GitOps 運用のクラスタで正しく変更を届けるための作法です。

ただし seccompProfile を 3 行足すだけの設問が単独で出るとは限りません。runAsNonRoot / readOnlyRootFilesystem / capabilities.drop などと一緒に「この Deployment を要件どおりに直せ」という形で問われるほうが自然で、その場合は 8〜10 分かかります。3 分というのは「seccomp の部分だけを取り出すとこのくらい」という意味です。

試験クラスタに ArgoCD はありません。 試験では k -n <namespace> edit deploy <name> で直接直すか、k patch で当てるのが最短です。ステップ2 とステップ4 は「GitOps で運用されているクラスタでは live を直しても巻き戻る」という実務側の作法なので、試験の手順と混ぜないでください。

ここまでは検証用の Pod で確認してきました。ここからは実際に動いているアプリケーションに手を入れます。

既習範囲を明示しておきます。

runAsNonRoot: true / runAsUser: 10001 / readOnlyRootFilesystem: true(書き込み先は /opt/payara/tmp の emptyDir)/ allowPrivilegeEscalation: false / capabilities.drop: [ALL]第1巻第15回で適用済みです。本回で新たに足すのは seccompProfile だけです。

ステップ1:現状を測る

実行コマンド(k8s-ops 上・developer):

$ kubectl exec -n fanclub deploy/fanclub-backend -c backend -- grep -E "Seccomp|NoNewPrivs" /proc/1/status

実行結果:

NoNewPrivs:	1
Seccomp:	0
Seccomp_filters:	0

NoNewPrivs: 1allowPrivilegeEscalation: false の効果です。第1巻で入れた設定が実際にプロセスへ届いていることが読めます。一方で Seccomp: 0——syscall フィルタは掛かっていません。ここを 2 にするのが本節の目的です。

ステップ2:変更してよい場所を先に確かめる

fanclub は第2巻第14回で ArgoCD の管理下に入っています。手を付ける前に、対象が管理対象かどうかを確認します。

実行コマンド(k8s-ops 上・developer):

$ kubectl get application fanclub-api-prod -n argocd -o jsonpath='{.spec.syncPolicy}'

実行結果:

{"automated":{"prune":true,"selfHeal":true}}

実行コマンド(k8s-ops 上・developer):

$ kubectl get application fanclub-api-prod -n argocd -o jsonpath='{range .status.resources[*]}{.kind}{"/"}{.name}{"\n"}{end}'

実行結果:

ConfigMap/fanclub-config
ConfigMap/fanclub-db-init
ConfigMap/fanclub-frontend-config
ResourceQuota/fanclub-quota
Service/fanclub-backend
Service/fanclub-frontend
Deployment/fanclub-backend
Deployment/fanclub-frontend

selfHeal: true であり、管理対象 8 リソースに Deployment/fanclub-backend が含まれています

第4回との対比。

第4回で ServiceAccount/default を直したときは管理対象外だったので巻き戻りませんでした。今回は逆のケースです。live を kubectl editkubectl patch で直しても Git に引き戻されるので、Git を直します。

第4回で示した順序——手を付ける前に status.resources で管理対象かを確かめる——を、ここで実践しています。この確認を飛ばすと、「編集したのに数分後に元へ戻っている」という原因の分かりにくい現象に時間を取られます。

ステップ3:Git を直す

k8s-ops 上の ~/gitops/gitops-fanclub/base/backend-deployment.yaml を編集します。spec.template.specPod レベルsecurityContext を追加します。

編集後の該当箇所(initContainers の直前に 3 行を挿入します):

    spec:
      serviceAccountName: fanclub-backend
      automountServiceAccountToken: false
      securityContext:
        seccompProfile:
          type: RuntimeDefault
      initContainers:

なぜコンテナレベルではなく Pod レベルに置くのか(本回の設計判断)。

この Deployment には wait-for-db(init コンテナ)と log-shipperrestartPolicy: Always のネイティブサイドカー)があり、どちらにも securityContext がありません。コンテナレベルに書くと backend しか守られません。

Pod レベルに置けば、init コンテナとサイドカーを含む全コンテナに継承されます(必要ならコンテナ側で上書きもできます)。第8回の PSS Restricted はすべてのコンテナに要件を課すため、この置き方が前提になります。

Pod レベルとコンテナレベルの関係を整理すると次のようになります。

書く場所効く範囲優先順位
spec.securityContext(Pod レベル)init コンテナ・サイドカー・通常コンテナの全部低(既定値として働く)
spec.containers[].securityContext(コンテナレベル)そのコンテナだけ高(Pod レベルを上書きする)

fanclub-backend の場合、backend コンテナには既にコンテナレベルの securityContext がありますが、そこに seccompProfile は書かれていません。キー単位で上書きされるので、Pod レベルの seccompProfilebackend にも継承されます。capabilities.drop: [ALL] などの既存設定はそのまま残ります。

ただし Pod レベルに書けるフィールドは限られています。

seccompProfile / appArmorProfile / runAsUser / runAsNonRoot / seLinuxOptions / fsGroup などは Pod レベルにありますが、allowPrivilegeEscalation / capabilities / privileged / readOnlyRootFilesystem はコンテナレベルにしかありません。Pod レベルに書いても黙って無視されるので(--validate=strict を付けたときだけ unknown field で気づけます)、これらは init コンテナとサイドカーを含む全コンテナに 1 つずつ書くしかありません。今回 seccomp を Pod レベルに置けるのは、seccompProfile が Pod レベルに存在するフィールドだからです。

ステップ4:commit して push し、同期を待つ

実行コマンド(k8s-ops 上・developer):

$ cd ~/gitops/gitops-fanclub
$ git add base/backend-deployment.yaml
$ git commit -m "Add RuntimeDefault seccomp profile at pod level"
$ git push

git push が 403 で止まったら。

Gitea は k8s-registry 上で動いていますが、ログインシェルの no_proxyk8s-registry が入っていないため、push が Squid を経由して弾かれます。

実行結果(この状態のとき):

fatal: unable to access 'http://k8s-registry:3000/developer/gitops-fanclub.git/': The requested URL returned error: 403

実行コマンド(k8s-ops 上・developer):

$ export no_proxy="$no_proxy,k8s-registry"
$ export NO_PROXY="$no_proxy"
$ git push

恒久的に直すなら /etc/profile.d/proxy.shno_proxyk8s-registry を足します。クラスタ内のホスト名をプロキシ除外に入れ忘れると、レジストリや Git のような内部サービスへのアクセスだけが静かに失敗します。 第7回で扱うノードの通信制御でも同じ論点が出てきます。

push が通ったら同期を待ちます。自動 sync は既定ポーリング任せで、本書ラボの実測では約 80 秒でした(ポーリング間隔の都合で最大 3 分ほどかかることもあります)。待てない場合は手動で同期できます。

実行コマンド(k8s-ops 上・developer):

$ argocd app sync fanclub-api-prod
$ kubectl get pod -n fanclub -l app=fanclub-backend -w

Deployment の spec.template が変わるので、ArgoCD が同期した時点でローリング更新が走ります。3 本の Pod が順に置き換わり、それぞれ 2/2 Running になるまで待ってください。Pod が作り直されるからこそ seccomp が掛かる——やってみよう① で確認した「設定は次に作るコンテナから効く」と同じ原則です。

この chart は maxUnavailable: 0 で、backend の startupProbe が Payara の起動を待つため、3 本すべての入れ替えに 9 分ほどかかります。完了を確実に待つには次のコマンドを使います。

実行コマンド(k8s-ops 上・developer):

$ kubectl rollout status deploy/fanclub-backend -n fanclub --timeout=600s

実行結果(末尾):

deployment "fanclub-backend" successfully rolled out

ロールアウトの完了を待たずに次のステップへ進まないでください。

入れ替えの途中は新旧の Pod が混在しており、次のステップで使う kubectl exec deploy/fanclub-backendどの Pod に当たるか選べません。古い Pod に当たると Seccomp: 0 が返り、「設定が効いていない」と誤解します(実際に踏みました)。rollout status が完了を返してから確認してください。

ステップ5:効いたことを確認する

本体コンテナとサイドカーの両方を確認します。Pod レベルに置いた効果を測るのが目的なので、片方だけでは足りません。

実行コマンド(k8s-ops 上・developer):

$ kubectl exec -n fanclub deploy/fanclub-backend -c backend -- grep -E "Seccomp|NoNewPrivs" /proc/1/status
$ kubectl exec -n fanclub deploy/fanclub-backend -c log-shipper -- grep -E "Seccomp|NoNewPrivs" /proc/1/status

実行結果(上 3 行が backend、空行をはさんで下 3 行が log-shipper):

NoNewPrivs:	1
Seccomp:	2
Seccomp_filters:	1

NoNewPrivs:	0
Seccomp:	2
Seccomp_filters:	1

NoNewPrivs の値が違う点にも注目してください。backend1log-shipper0 です。allowPrivilegeEscalation: falsebackend のコンテナレベルにしか書かれていないため、サイドカーには効いていません。Pod レベルに置いた seccompProfile だけが両方に届いている——この 1 画面が、置き場所の違いをそのまま示しています。第8回で PSS Restricted を適用するときは、allowPrivilegeEscalation: falselog-shipperwait-for-db にも 1 つずつ書き足す必要があります。このフィールドは Pod レベルに存在しないので、seccompProfile のようにまとめて継承させることはできません。

両方で Seccomp: 2 になっていれば、Pod レベルに置いた狙いどおりです。もし log-shipper0 のままなら、コンテナレベルに書いてしまっていないかを確認してください。

ステップ6:アプリが壊れていないことを確認する

seccomp は syscall を止める仕組みなので、アプリが必要とする syscall を塞いでいないかを必ず確認します。RuntimeDefault は一般的なアプリで動くよう作られていますが、確認せずに本番へ入れる理由にはなりません。

実行コマンド(k8s-ops 上・developer):

$ curl -sk --noproxy '*' -o /dev/null -w '%{http_code}\n' https://fanclub.local/
$ curl -sk --noproxy '*' https://fanclub.local/api/members

実行結果(1 行目が 1 本目の curl、2 行目以降が会員一覧の JSON):

200
[{"createdAt":"2026-07-25T14:28:03.572932","email":"hanako@example.com","id":1,"name":"鈴木花子","plan":"standard"},{"createdAt":"2026-07-25T14:28:03.572932","email":"ichiro@example.com","id":2,"name":"佐藤一郎","plan":"free"},{"createdAt":"2026-07-25T18:00:53.008964","email":"restore-test@example.com","id":11,"name":"復元テスト太郎","plan":"premium"}]

k8s-ops からは --noproxy '*' が必須です(無いと Squid 経由で 000 になります)。第5回で確定した作法です。

既存の不変性が効いていることも、あわせて確認します。

実行コマンド(k8s-ops 上・developer):

$ kubectl exec -n fanclub deploy/fanclub-backend -c backend -- touch /test-file

実行結果:

touch: cannot touch '/test-file': Read-only file system
command terminated with exit code 1

ルートファイルシステムへの書き込みが弾かれるのは readOnlyRootFilesystem: true の効果で、これは第1巻第15回で入れたものです。seccomp を足しても既存の防御が外れていないことを、ここで確かめています。ハードニングは足し算なので、足したときに引き算が起きていないかを毎回見てください。

この演習に後片付けはありません。

ここで入れた seccompProfile: RuntimeDefault残す変更です。第8回で PSS Restricted を適用するとき、この設定が入っていることが前提になります。enable_selinux = true も同様に残します。本回の到達状態は「第5回完了状態 + Workload Node 2 台の enable_selinux = true + backend の Pod レベル seccomp」です。

やってみよう④:Localhost seccomp プロファイルを配って適用する

所要 8 分(全体が試験相当の粒度)。

CKS の seccomp 出題は「プロファイルを /var/lib/kubelet/seccomp/profiles/ に配置して Pod に適用せよ」の形が定番です。この演習はほぼそのまま 1 問に対応します。

seccomp の節で記法だけ示した Localhost を、実際に通します。あわせて「配り忘れ」がどう見えるかも実演します。

ステップ1:まず k8s-wl-02 だけに置く

/var/lib/kubelet/seccomp既定では存在しませんmkdir -p から始めます。この時点では k8s-wl-01 には置きません(ステップ3 で配り忘れを実演するためです)。

実行コマンド(k8s-wl-02 上・developer):

$ sudo mkdir -p /var/lib/kubelet/seccomp/profiles

/var/lib/kubelet/seccomp/profiles/deny-chmod.json を作成します。root 所有のディレクトリなので visudo 付きで開いてくださいsudo vi /var/lib/kubelet/seccomp/profiles/deny-chmod.json)。次の JSON を全量そのまま書きます。

{
  "defaultAction": "SCMP_ACT_ALLOW",
  "syscalls": [
    {
      "names": ["chmod", "fchmod", "fchmodat"],
      "action": "SCMP_ACT_ERRNO"
    }
  ]
}

読み方は 2 行だけです。defaultActionSCMP_ACT_ALLOW(既定は許可)で、syscalls に列挙した syscall だけ SCMP_ACT_ERRNO(エラーを返す)にしています。chmod の系統を 3 つ並べているのは、同じ操作に複数の syscall があるためです。

アクション意味
SCMP_ACT_ALLOW許可する
SCMP_ACT_ERRNOエラーを返す(プロセスは死なない。Operation not permitted になる)
SCMP_ACT_LOG許可したうえでログに残す(プロファイル作成の調査に使う)
SCMP_ACT_KILLそのスレッドだけを終了させる(プロセス全体ではない)
SCMP_ACT_KILL_PROCESSプロセス全体を終了させる

SCMP_ACT_KILLSCMP_ACT_KILL_PROCESS の違いは、マルチスレッドのアプリで効いてきます。fanclub-backend のような JVM アプリはスレッドを多数持つので、SCMP_ACT_KILL だと 1 スレッドだけが消えて、プロセスは中途半端な状態のまま生き残ります。止めたいなら SCMP_ACT_KILL_PROCESS を選んでください。

本番の最小化は逆に、SCMP_ACT_ERRNO を既定にして必要な syscall だけ許可します。ただしそれにはアプリが呼ぶ syscall を洗い出す作業が要ります。SCMP_ACT_LOG を既定にして一定期間動かし、ログから許可リストを作る、という進め方が現実的です。いきなり全部止めると起動すらしません。

置いたファイルのラベルを確認します。

実行コマンド(k8s-wl-02 上・developer):

$ ls -Z /var/lib/kubelet/seccomp/profiles/deny-chmod.json

実行結果:

unconfined_u:object_r:container_var_lib_t:s0 /var/lib/kubelet/seccomp/profiles/deny-chmod.json

/var/lib/kubelet 配下なので container_var_lib_t が自動で付きます。ここに置く限り、ラベルを手で直す必要はありません。 もし別の場所に置いてシンボリックリンクを張ると、ラベルが噛み合わずに読めなくなることがあります。素直に規定のパスへ置いてください。

ステップ2:置いたノードの Pod に適用して拒否を確認する

seccomp-local-test.yaml(k8s-ops 上で作成):

apiVersion: v1
kind: Pod
metadata:
  name: seccomp-local-test
spec:
  nodeName: k8s-wl-02
  securityContext:
    seccompProfile:
      type: Localhost
      localhostProfile: profiles/deny-chmod.json
  containers:
  - name: app
    image: busybox:1.37
    command: ["sleep", "3600"]

実行コマンド(k8s-ops 上・developer):

$ kubectl apply -f seccomp-local-test.yaml
$ kubectl exec seccomp-local-test -- grep -E "Seccomp|NoNewPrivs" /proc/1/status

実行結果:

NoNewPrivs:	0
Seccomp:	2
Seccomp_filters:	1

フィルタが掛かりました。RuntimeDefault のときと同じ Seccomp: 2 です。/proc/1/status からは「どのプロファイルか」までは分かりません——実際に禁止した操作を試して確かめます。

実行コマンド(k8s-ops 上・developer):

$ kubectl exec seccomp-local-test -- sh -c 'touch /tmp/f && chmod 777 /tmp/f'

実行結果:

chmod: /tmp/f: Operation not permitted
command terminated with exit code 1

touch は通り、chmod だけが弾かれました。 「全部止める」ではなく「特定の syscall だけ止める」という粒度が、実機で確認できます。SCMP_ACT_ERRNO なのでプロセスは死なず、chmod(2) がエラーを返しているだけです。

この Localhost プロファイルは、RuntimeDefault よりずっと緩いという点に注意してください。

defaultActionSCMP_ACT_ALLOW なので、chmod 系 3 つ以外はすべて素通しします。RuntimeDefault(危険な syscall を数十種類ブロックする)と入れ替えると、防御はむしろ弱くなりますLocalhost を使うのは「RuntimeDefault では足りない」ときであり、その場合はランタイム既定のプロファイルを土台にして追加で絞るのが定石です。「独自プロファイル=より安全」ではありません。

ステップ3:配っていないノードに載せてみる

同じ localhostProfile を指定し、nodeName だけを k8s-wl-01 に変えた Pod を投入します。

seccomp-local-wl01.yaml(k8s-ops 上で作成):

apiVersion: v1
kind: Pod
metadata:
  name: seccomp-local-wl01
spec:
  nodeName: k8s-wl-01
  securityContext:
    seccompProfile:
      type: Localhost
      localhostProfile: profiles/deny-chmod.json
  containers:
  - name: app
    image: busybox:1.37
    command: ["sleep", "3600"]

実行コマンド(k8s-ops 上・developer):

$ kubectl apply -f seccomp-local-wl01.yaml
$ kubectl get pod seccomp-local-wl01

実行結果:

NAME                 READY   STATUS                 RESTARTS   AGE
seccomp-local-wl01   0/1     CreateContainerError   0          30s

理由はイベントに出ます。

実行コマンド(k8s-ops 上・developer):

$ kubectl describe pod seccomp-local-wl01

実行結果(Events 部分):

  Type     Reason  Age               From     Message
  ----     ------  ----              ----     -------
  Normal   Pulled  2s (x5 over 30s)  kubelet  spec.containers{app}: Container image "busybox:1.37" already present on machine and can be accessed by the pod
  Warning  Failed  2s (x5 over 30s)  kubelet  spec.containers{app}: Error: failed to create containerd container: cannot load seccomp profile "/var/lib/kubelet/seccomp/profiles/deny-chmod.json": open /var/lib/kubelet/seccomp/profiles/deny-chmod.json: no such file or directory

seccomp の節で名前だけ示した CreateContainerError が、ここで実物として出ました。x5 over 30s から分かるとおり、kubelet はコンテナ作成を再試行し続けます

status.phase も見ておきます。

実行コマンド(k8s-ops 上・developer):

$ kubectl get pod seccomp-local-wl01 -o jsonpath='{.status.phase}'

実行結果:

Pending

STATUS 列は CreateContainerErrorstatus.phasePending。この組み合わせは やってみよう⑤ の AppArmor と対比する材料になります。

ステップ4:k8s-wl-01 にも配って作り直す

ステップ1 と同じ手順で deny-chmod.json を k8s-wl-01 に置きます。

実行コマンド(k8s-wl-01 上・developer):

$ sudo mkdir -p /var/lib/kubelet/seccomp/profiles

同じ内容の deny-chmod.json/var/lib/kubelet/seccomp/profiles/ に置いたら、Pod を作り直します。

実行コマンド(k8s-ops 上・developer):

$ kubectl delete pod seccomp-local-wl01
$ kubectl apply -f seccomp-local-wl01.yaml
$ kubectl get pod seccomp-local-wl01

実行結果:

NAME                 READY   STATUS    RESTARTS   AGE
seccomp-local-wl01   1/1     Running   0          6s

運用上の要点。

Localhost プロファイルはノードローカルなファイルなので、全 Workload Node に同じものを配る必要があります。本番では DaemonSet で配布するか、Ansible / イメージ焼き込みでノード構築手順に含めます。

ノードを足したときに配り忘れると、そのノードに載った Pod だけが起動しないという切り分けにくい障害になります。しかもスケジューラは配布状況を知らないので、再スケジュールのたびに成否が変わって見えます。やってみよう① のステップ6 で書いた enable_selinux と同じ「ノードごとのローカル設定は構成管理に載せる」という話です。

ステップ5:後片付け

実行コマンド(k8s-ops 上・developer):

$ kubectl delete pod seccomp-local-test seccomp-local-wl01

実行コマンド(k8s-wl-01 / k8s-wl-02 上・developer):

$ sudo rm -f /var/lib/kubelet/seccomp/profiles/deny-chmod.json

/var/lib/kubelet/seccomp/profiles ディレクトリは残しても害はありません。消す場合は rmdir で空であることを確認してから実行してください。他のプロファイルが同居している可能性があるので、rm -rf でディレクトリごと消さないでください。

やってみよう⑤:AppArmor 非対応ノードでの Pod 拒否を実機再現する

所要 5 分(全体が試験相当の粒度)。

「Pod が起動しない。原因を特定せよ」という切り分け問題の一形態として、そのまま出題され得ます。

「動かないこと」を実機で確かめる演習です。対象は k8s-wl-02 です。

ステップ1:ノードの LSM 対応状況を調べる

実行コマンド(k8s-wl-02 上・developer):

$ cat /sys/kernel/security/lsm
$ cat /sys/module/apparmor/parameters/enabled
$ cat /sys/kernel/security/apparmor/profiles
$ getenforce

実行結果:

lockdown,capability,yama,selinux,bpf,landlock,ima,evm
cat: /sys/module/apparmor/parameters/enabled: そのようなファイルやディレクトリはありません
cat: /sys/kernel/security/apparmor/profiles: そのようなファイルやディレクトリはありません
Enforcing

本書ラボは日本語ロケールです。英語ロケールの環境では No such file or directory と表示されます。

なお /sys/kernel/security/lsm末尾に改行がありません。1 行目の直後にプロンプトや次の出力がそのまま続くので、画面では evm と次の行がつながって見えます。読みにくければ cat /sys/kernel/security/lsm; echo のように echo を足してください。

Ubuntu なら 2 番目が Y、3 番目にロード済みプロファイルの一覧が返ります。このノードの MAC は SELinux であって AppArmor ではないことが、この 4 行で確定します。

確認先AlmaLinux 10.2(本書ラボ)Ubuntu(試験環境)
/sys/kernel/security/lsmapparmor含まないapparmor含む
/sys/module/apparmor/parameters/enabledファイルが無いY
/sys/kernel/security/apparmor/profilesファイルが無いロード済みプロファイル一覧

ステップ2:AppArmor プロファイルを要求する Pod を投入する

apparmor-test.yaml(k8s-ops 上で作成):

apiVersion: v1
kind: Pod
metadata:
  name: apparmor-test
spec:
  nodeName: k8s-wl-02
  containers:
  - name: app
    image: busybox:1.37
    command: ["sleep", "3600"]
    securityContext:
      appArmorProfile:
        type: RuntimeDefault

Deployment ではなく素の Pod で試す理由。

拒否された Pod は Failed になり、Deployment 配下だと ReplicaSet が次の Pod を作り続けますFailed の Pod が延々と積み上がるので、演習では素の Pod を使ってください。本番で同じ設定ミスをすると、この積み上がりが起きます。

実行コマンド(k8s-ops 上・developer):

$ kubectl apply -f apparmor-test.yaml

ステップ3:拒否のされ方を観察する

実行コマンド(k8s-ops 上・developer):

$ kubectl get pod apparmor-test

実行結果:

NAME            READY   STATUS     RESTARTS   AGE
apparmor-test   0/1     AppArmor   0          25s

ここが実機と一般的な説明の差です。

STATUS 列に AppArmor という見慣れない文字列が出ます。Pending ではありません。 「AppArmor 非対応だと Pending のまま留まる」という説明を見かけますが、本書ラボの実機はそうなりませんでした。

phase と reason と message を 1 行で読みます。

実行コマンド(k8s-ops 上・developer):

$ kubectl get pod apparmor-test -o jsonpath='{.status.phase}|{.status.reason}|{.status.message}'

実行結果:

Failed|AppArmor|Pod was rejected: Cannot enforce AppArmor: AppArmor is not enabled on the host

Pod was rejected という文言が示すとおり、kubelet が admit(受け入れ)の段階で弾いています。コンテナ作成にすら進みません。イベントも 1 行だけです。

実行コマンド(k8s-ops 上・developer):

$ kubectl describe pod apparmor-test

実行結果(Events 部分):

  Type     Reason    Age   From     Message
  ----     ------    ----  ----     -------
  Warning  AppArmor  25s   kubelet  Cannot enforce AppArmor: AppArmor is not enabled on the host

seccomp の CreateContainerErrorx5 over 30s と再試行していたのに対し、こちらは 1 回きりです。この違いも、admit で弾かれたかコンテナ作成で失敗したかの差から来ています。

ステップ4:試験環境との差分を理解する

同じマニフェストが CKS 試験環境(Ubuntu)では通ります。なぜラボでは通らないのかを、LSM の観点で説明できるようにしてください。要点は 2 つです。

  • AppArmor はカーネル機能である。ユーザー空間のパッケージを入れても、カーネルに組み込まれていなければ動かない
  • コンテナはホストのカーネルを共有する。したがってコンテナイメージが Ubuntu であっても、ホストが AlmaLinux なら AppArmor は使えない

2 つ目は、SELinux にも同じことが言えます。MAC はイメージではなくノードの性質で決まります。 「イメージを Ubuntu にすれば AppArmor が使える」は誤りです。

本回で見た 2 つの失敗を並べて整理します。この表は試験の切り分けでそのまま使えます。

何が足りないかkubectl get pod の STATUS 列status.phasekubelet の挙動
AppArmor が有効でないAppArmorFailedadmit されず終わる(再試行しない)
Localhost seccomp プロファイルが無いCreateContainerErrorPendingコンテナ作成を再試行し続ける

STATUS 列は phase そのものではなく、kubelet が付けた理由を優先して表示しますphase だけ見ても原因は分からないので、STATUS 列の固有名で当たりを付けるのが実務でも試験でも速い進め方です。AppArmorCreateContainerError という文字列を見た瞬間に describe を打つ、という反射を作ってください。

ステップ5:後片付け

実行コマンド(k8s-ops 上・developer):

$ kubectl delete pod apparmor-test

Failed の Pod は自動では消えませんので、明示的に削除してください。放置すると kubectl get pod の一覧に残り続け、次の演習で紛らわしくなります。

なぜ Control Plane には入れないのか(実測に基づく境界)

やってみよう① で enable_selinux = true にしたのは Workload Node 2 台だけでした。Control Plane Node 3 台を対象外にした理由を、実測とともに示します。「やらない」という判断にも根拠が要る——ここが本節の主題です。

同じ設定を Control Plane に入れると、次にコンテナが作り直されたタイミングで壊れます。検証環境(k8s-cp-03)で実際に確かめた結果です。

etcd が起動しなくなる

etcd コンテナのログ(実測):

{"level":"fatal","caller":"etcdmain/etcd.go:227","msg":"failed to list data directory",
 "dir":"/var/lib/etcd","error":"open /var/lib/etcd: permission denied"}

fatal なので即座に終了し、kubelet が作り直し、また落ちます。実測では 10 回以上 CrashLoop しました。

scheduler と controller-manager も落ちる

AVC(実測):

avc: denied { read } for pid=16899 comm="kube-scheduler" name="scheduler.conf"
  scontext=system_u:system_r:container_t:s0:c205,c830
  tcontext=unconfined_u:object_r:kubernetes_file_t:s0 tclass=file permissive=0

kube-controller-manager も同じ形で controller-manager.conf の read を拒否されました。やってみよう② で練習した AVC の読み方が、そのまま使えます。 scontextcontainer_ttcontextkubernetes_file_ttclassfile——「拘束されたプロセスが、コンテナ用でない type のファイルを読もうとした」と読めます。

原因はラベルの不一致

パスラベルcontainer_t からの操作
/var/lib/etcd/var/lib/etcd/memberunconfined_u:object_r:var_lib_t:s0書き込み不可
/etc/kubernetes/*.confsystem_u:object_r:kubernetes_file_t:s0読み取り不可

これまで動いていたのは、Control Plane のコンテナが spc_t だったからです。拘束を掛けた途端、噛み合っていなかったラベルが表に出ます。「今まで動いていたのに設定を変えたら壊れた」のではなく、「今まで検査していなかったものを検査し始めた」のです。

Control Plane で有効化するには、これらを semanage fcontext + restorecon でコンテナ用の type へ整える必要があります。技術的には可能です。しかし kubeadm が管理するパスに手を入れることになり、第5回で確立した「kubeadm クラスタではノード上のファイルは結果であって正本ではない」という原則とも衝突しますkubeadm upgrade がファイルを作り直したとき、ラベルがどうなるかまで面倒を見ることになります。

本書の判断:Workload Node の 2 台に限定します。

攻撃者が最初に触れるのはアプリのコンテナであり、そこを container_t で拘束できれば D3 の目的は達成できます。公式コンピテンシーは Appropriately use kernel hardening tools such as AppArmor, seccomp であって対象ノードを指定していませんが、kubeadm が管理するパスのラベルを組み替えてまで Control Plane を拘束することは、得られる保護より運用リスクのほうが大きいと本書は判断しました。

どこまでやるかを根拠を持って決めることがハードニングの実務であり、本回はその判断の実例でもあります。

残るリスクを認識しておいてください。

Control Plane のコンテナは spc_t のままなので、そこが侵害された場合に SELinux は止められません。ここは第2回の CIS(ファイル権限・API Server フラグ)と第4回の RBAC、第7回のノード OS 堅牢化で多層に守る、という分担です。

「入れない」ではなく「別の層で守る」と整理してください。防御の層をどこに置くかは選べますが、置かない選択をした層があることは把握しておく必要があります。

もし試してしまったら

実行コマンド(誤って有効化してしまった Control Plane Node 上・developer):

$ sudo sed -i 's/^    enable_selinux = true/    enable_selinux = false/' /etc/containerd/config.toml
$ sudo systemctl restart containerd
$ sudo containerd config dump | grep enable_selinux

本書は Control Plane に入れない手順なので、バックアップ(config.toml.bak-06)を取っていない前提sed で直接戻しています。ホスト名も、どのノードで踏むかは分からないので固定していません。

実行結果:

    enable_selinux = false

実測では数十秒で etcd / kube-scheduler / kube-controller-manager / kube-apiserver が spc_t で自動復帰しました。復旧後は etcd の 3 エンドポイントとも health が true、ノードも 5/5 Ready に戻っています。

やってみよう① では「containerd を再起動しても既存コンテナは落ちない」と書いたのに、ここでは数十秒で復帰する。矛盾するように見えますが、そうではありません。

ここで速やかに復帰するのは、壊れた Control Plane のコンテナが CrashLoop していて kubelet が作り直し続けているからです。次の再作成が新しい設定(enable_selinux = false)を拾って spc_t で立ち上がります。「設定は次に作るコンテナから効く」という同じ原則が、たまたま速く現れているだけです。

逆に言えば、正常稼働している Control Plane で enable_selinux = true にしても、その瞬間は何も起きません。壊れるのは次に static Pod が作り直されたとき——ノード再起動、kubelet 再起動、あるいは kubeadm upgrade のときです。設定変更とその影響が現れるタイミングが離れているため、原因追跡が難しくなります。これも Control Plane を対象外にする理由の 1 つです。

暗記必須コマンド(seccomp / SELinux / AppArmor)

試験中は kubernetes.io/docs を含む許可された 8 サイトを参照できます。そこに載っているものは覚えなくてよく、載っていないものは覚える——この線引きで表を分けます。

appArmorProfileseccompProfile の記法は kubernetes.io/docs に載っているので参照可能です。一方 SELinux 系のコマンド(semanage / semodule / ausearch / chcon / restorecon / setsebool)と apparmor_parser / aa-status は許可リスト外=暗記対象です。ただし試験ホスト上の man は使えますので、オプションの細部まで覚える必要はありません。

用途コマンド / 設定試験中参照補足
seccomp 適用seccompProfile: {type: RuntimeDefault}privileged: true だと無視される
seccomp LocalhostlocalhostProfile: profiles/<name>.json/var/lib/kubelet/seccomp/ からの相対パス
seccomp の効果確認grep Seccomp /proc/1/status2 が filter mode・0 は無効
クラスタ既定の底上げKubeletConfiguration の seccompDefault: true既存ワークロードへの影響を確認してから
AppArmor 記法(新)appArmorProfile: {type: Localhost, localhostProfile: <name>}v1.31 GA
AppArmor 記法(旧)container.apparmor.security.beta.kubernetes.io/<container>v1.30 非推奨・対比用
AppArmor プロファイル読み込みapparmor_parser -q /etc/apparmor.d/<profile>不可man 可)試験環境(Ubuntu)で実施する
AppArmor ロード状況aa-status / cat /sys/kernel/security/apparmor/profiles不可man 可)AlmaLinux には存在しない
LSM の確認cat /sys/kernel/security/lsm不可AppArmor があるかを 1 行で判定できる
SELinux 確認getenforce / sestatus / ls -Z / ps -eZ不可man 可)本書クラスタは全ノード Enforcing
コンテナの拘束状況containerd config dump | grep -i selinux / ps -eZcontainer_tspc_t不可spc_t なら拘束されていないcrictl info でも同じ値が読める
Pod へのラベル指定securityContext.seLinuxOptions(user / role / type / level)level(MCS)はテナント分離にも使う
ボリュームのラベルkubelet が自動リラベル(emptyDir / Secret / ConfigMap / 対応 CSI)hostPath は対象外:z / :Z は Docker / Podman の記法
ラベル修正chcon -t container_file_t <path>(一時)/ semanage fcontext -a -t container_file_t '<path>(/.*)?' + restorecon -Rv(永続)不可man 可)semanagepolicycoreutils-python-utils
AVC 拒否の確認ausearch --input-logs -m AVC -ts recent不可man 可)拒否の切り分けの起点
AVC に出ない拒否semodule -DB(dontaudit 無効化)→ 再現 → semodule -B で戻す不可man 可)診断中だけ
拒否の解消setsebool -P <bool> on / ラベル修正不可man 可)setenforce 0 は解決策ではない
capabilities 最小化capabilities: {drop: [ALL]}必要分のみ add する

この表で最も使う頻度が高いのは grep Seccomp /proc/1/statusausearch --input-logs -m AVC -ts recent の 2 つです。前者は「入れた設定が届いているか」、後者は「何が止められたか」を答えてくれます。設定した後に必ず測るという手順を、この 2 コマンドで習慣にしてください。

暗記の優先順位も付けておきます。 表の SELinux 系 8 行は本書ラボの MAC が SELinux だから積む道具で、試験環境の MAC は AppArmor です。試験本番で最優先に暗記すべきは apparmor_parser / aa-statusseccomp の記法で、SELinux 系は「同じ型を別の道具でやる」ための理解として持っておく、という配分になります。

本文のコマンドは読みやすさのため kubectl と書いていますが、試験では alias k=kubectl が最初から設定されています(bash 補完も入っています)。本回のように kubectl execkubectl get pod を何度も往復する作業では、k で打つだけで所要時間が縮みます。第1回で用意した export do="--dry-run=client -o yaml" と合わせて、開始直後に手が動く状態を作ってください。

まとめ

本回では次の点を確認しました。

  • seccomp は syscall を絞る。RuntimeDefault を出発点にし、/proc/1/statusSeccomp: 2 で効果を確認する。privileged: true では無視される
  • Localhost プロファイルは /var/lib/kubelet/seccomp/ 配下に置き、全 Workload Node に配る。無いノードに載った Pod は CreateContainerError で止まる
  • capabilities は drop ALL してから必要分のみ add する。8080 番で待ち受けるなら NET_BIND_SERVICE も不要
  • ホストが Enforcing でも、containerd の enable_selinux が false ならコンテナは spc_t で動き、拘束されない。 containerd config dumpps -eZ で必ず確認する
  • containerd の再起動では既存コンテナは落ちない。 shim がライフサイクルを持っているためで、設定はその後に作るコンテナから効く
  • Kubernetes に :z / :Z は無い。kubelet が emptyDir / Secret / ConfigMap / 対応 CSI を自動リラベルし、hostPath は対象外
  • 拒否が出たら ausearch -m AVC を読み、scontext / tcontext / tclass からどこを直すか決める。setenforce 0 は解決策ではない
  • Permission denied なのに AVC に出ないときは dontaudit を疑い、semodule -DB で確認して semodule -B で戻す
  • chcon は一時的、semanage fcontext + restorecon が永続。登録と反映は 2 段構えで、片方だけでは効かない
  • Control Plane に同じ設定を入れると etcd と scheduler が壊れる。 /var/lib/etcdvar_lib_t/etc/kubernetes/*.confkubernetes_file_t であるため。どこまでやるかを根拠を持って決めるのがハードニング
  • AppArmor は RHEL 系では動かない。非対応ノードでは phase: Failed / STATUS 列 AppArmor で拒否される。判定は cat /sys/kernel/security/lsm の 1 行で足りる
  • これらの部品が、第8回 PSS Restricted の要件を構成する

理解度チェック(○×形式・全 9 問)

次の各文が正しいか(○)誤りか(×)を判断してください。下の「解答と解説」を開くと答え合わせができます。

  1. fanclub-backend は 8080 番で待ち受けるので、capabilities.drop: [ALL] に加えて NET_BIND_SERVICEadd が必要である
  2. privileged: true のコンテナにも seccomp プロファイルは必ず適用される
  3. ホストの getenforce が Enforcing であれば、コンテナは必ず SELinux で拘束されている
  4. containerdenable_selinux を true にして再起動すると、稼働中のコンテナも即座に container_t になる
  5. Kubernetes の Pod spec では、ボリュームに :z / :Z を付けてラベルを付け替える
  6. hostPath でマウントしたディレクトリは kubelet が自動でリラベルしてくれる
  7. Permission denied が出ているのに ausearch -m AVC に何も出なければ、原因は SELinux ではないと断定してよい
  8. AlmaLinux(RHEL 系)で appArmorProfile を指定した Pod は Pending のまま留まる
  9. seccomp の Localhost プロファイルは /var/lib/kubelet/seccomp/ 配下に置く
解答と解説

1=×/2=×/3=×/4=×/5=×/6=×/7=×/8=×/9=○

問1 は誤りです。NET_BIND_SERVICE が要るのは 1024 番未満の特権ポートに bind するときです。8080 番は非特権ポートなので、drop: [ALL] だけで動きます。「動かないと困るから念のため足しておく」という add は、最小権限の原則から外れます。要件を確認してから add してください。

問2 は誤りです。privileged: true だと seccomp は無視されます。プロファイルを書いても効きません。特権を落とすこととセットで設計してください。 本回で確認したとおり、privileged なコンテナは SELinux でも spc_t のままで、こちらも拘束されません。

問3 は誤りで、本回の核心です。containerd の enable_selinux が false だと、コンテナは spc_t(実質無拘束)で起動します。ホストの getenforce はそれを教えてくれません。ps -eZ で type を見るまで、拘束されているかどうかは分かりません。

問4 は誤りです。containerd の再起動では既存コンテナは落ちません(shim がライフサイクルを持っているためで、実測でも containerd-shim は 15 個のまま変わりませんでした)。Pod を作り直して初めて container_t になります。この誤解があると、「設定したのに効いていない」と判断して不要な切り分けに時間を使います。

問5 と問6 はいずれも誤りです。:z / :ZDocker / Podman の -v オプションの記法で、Kubernetes の Pod spec には存在しません。Kubernetes では kubelet が emptyDir / Secret / ConfigMap / 対応 CSI を自動リラベルしますが、hostPath は対象外です。hostPath のラベルはホスト側で chcon または semanage fcontext + restorecon で直します。

問7 は誤りです。dontaudit の存在を知らないと、ここで誤診します。semodule -DB で dontaudit を無効化してから再現し、AVC が出るかどうかを確かめてください。確認したら semodule -B で必ず戻します。戻し忘れると監査ログが恒常的に膨らみます。

問8 は誤りです。実機は phase: Failed / reason: AppArmor で、kubectl get pod の STATUS 列に AppArmor と表示されます。Pending ではありません。Pending のまま留まるのは Localhost seccomp プロファイルが無いケース(STATUS 列は CreateContainerError)で、こちらは kubelet が再試行を続けます。2 つの失敗を並べて覚えてください。

問9 は正しく、/var/lib/kubelet/seccomp/ 配下に置きます。localhostProfile にはこのディレクトリからの相対パスを書き、先頭に / は付けません。AppArmor の localhostProfile がパスではなくプロファイル名である点との違いも、あわせて押さえてください。

次回予告

第7回「ホスト OS の攻撃面最小化と権限管理」では、D3 の後半としてカーネル層から一段上がり、ノード OS 自体の攻撃面を削ります。稼働サービス・パッケージ・開放ポートを棚卸しして不要なものを止め、OS ユーザと sudo・SSH の最小権限を設計し、firewalld でノードからの外部通信を絞ります。

本回は「動いているものをどう拘束するか」でしたが、次回は「そもそも動かさない」という方向の防御です。あわせて「閉じすぎてクラスタを壊さない境界」も扱います。本回で enable_selinux を Workload Node に限定したのと同じく、どこで止めるかの判断が主題になります。

→ 詳しくは 第7回 ホスト OS の攻撃面最小化と権限管理

前の記事
次の記事