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

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

PSS RestrictedとAdmission【CKS第8回】

公開

新卒インフラエンジニア向け「Kubernetes 実践教科書 ③ CKS セキュリティ・ハードニング編」(全19回)の第8回です。第7回まではノードを 1 台ずつ手で締めてきました。本回から向きが変わります。クラスタの入口で、違反した Pod をそもそも作らせない側に立ちます。

本回の主題は 1 文で書けます。namespace にラベルを 1 行貼るだけで、Pod のセキュリティ水準を強制できる。 ただし、その 1 行がいつ・誰を・どう壊すかを知らずに貼ると、壊れたことにすら気づけません。

本回から D4(Minimize Microservice Vulnerabilities)に入ります。扱うのは PSS / PSA(組込・ラベルだけ)、ValidatingAdmissionPolicy(組込・CEL)、OPA Gatekeeper(外部・Rego)の 3 つです。実機作業は k8s-ops からの kubectl が中心で、やってみよう④ のみ Control Plane 3 台に触ります。

  • ラボ Kubernetes v1.36.3
  • kubectl クライアント v1.35.6
  • containerd v2.2.6
  • AlmaLinux 10.2
  • OPA Gatekeeper v3.23.0(Helm chart 3.23.0
  • ValidatingAdmissionPolicyadmissionregistration.k8s.io/v1・組込)
  • CKS 試験環境 v1.35
  • 確認日 2026-07-27
目次
  1. 第8回のスコープ・今ここマップ
    1. 既習範囲 / 本回で上書きする観点
    2. 本回の起点
    3. 演習の進め方
  2. この回のゴール
  3. Pod Security Standards の 3 プロファイルと PSA の 3 モード
    1. Restricted が要求するもの(第6回の primitive の束)
  4. 既存 Pod の違反を壊さずに数える — enforce + –dry-run=server だけが列挙する
    1. warn と audit の dry-run では 1 行も出ない
    2. baseline で当てると距離が分かる
    3. warn の本当の使いどころ — Deployment を更新した瞬間に警告する
  5. やってみよう①:fanclub の違反を壊さずに数え、warn を貼る
    1. ステップ1:現状のラベルを確認する
    2. ステップ2:baseline と restricted の 2 段階で dry-run する
    3. ステップ3:何が足りないかを 1 つずつ数える
    4. ステップ4:warn と audit を貼る(enforce はまだ貼らない)
    5. ステップ5:warn が効くことを確かめる
    6. ステップ6:後片付け(環境変数を戻す)
  6. enforce を貼った後、何がいつ壊れるか
    1. Pod 単体の拒否メッセージ(読み方の練習)
    2. rollout restart は成功を返すのに Pod が作られない
  7. やってみよう②:enforce を貼って空振りを体験し、外して戻す
    1. ステップ1:貼る前の状態を記録する
    2. ステップ2:enforce=restricted を貼る
    3. ステップ3:既存 Pod が死んでいないことを確かめる
    4. ステップ4:Pod を単体で作ろうとして拒否される
    5. ステップ5:rollout restart が空振りすることを確かめる
    6. ステップ6:describe rs で原因を見る
    7. ステップ7:ラベルを外す
    8. ステップ8:回復を確認する
    9. ステップ9:後片付けの確認
  8. やってみよう③:クラスタ全 namespace を PSS で棚卸しする
    1. ステップ1:全 namespace に baseline を dry-run で当てる
    2. ステップ2:同じことを restricted で当てる
    3. ステップ3:結果を表にまとめる
    4. ステップ4:すでに chart がラベルを貼っている namespace を確認する
  9. namespace ラベルは貼り忘れる — AdmissionConfiguration でクラスタ既定を決める
    1. 現状の確認
    2. 書くファイル(全量)
    3. HA では 3 台すべてに配る
    4. 適用前に必ず済ませておくこと
  10. やってみよう④:3 台の Control Plane にクラスタ全体デフォルトを配る
    1. ステップ1:特権が必要な namespace へ明示ラベルを先に貼る
    2. ステップ2:3 台の Control Plane すべてに設定ファイルを置く
    3. ステップ3:kubeadm-config ConfigMap に登録する(正本を先に更新する)
    4. ステップ4:3 台の static Pod マニフェストへ反映する
    5. ステップ5:3 台それぞれに直接当てて、同じ結果が返ることを確かめる
    6. ステップ6:ラベルの無い namespace で warn の既定も効くことを確かめる
    7. ステップ7:kube-system の除外が効いていることを確かめる
    8. ステップ8:後片付け(必ず実施する)
  11. 誰が Pod spec の正本を持っているか — Helm と ArgoCD の二重管理
  12. fanclub を Restricted へ上げる実測レシピ
    1. frontend(nginx)— /var/run の emptyDir が要る。NET_BIND_SERVICE は要らない
    2. db(PostgreSQL 18)— fsGroup: 999 が無いと initdb が失敗する
    3. backend — 足りないのは initContainer 2 本
    4. logcollector と CronJob — 制約が緩い 2 つ
    5. 見落としやすい 6 つ目 —— Helm フックの Job
  13. やってみよう⑤:fanclub を Restricted 化して enforce を貼る
    1. ステップ1:正本を確認する
    2. ステップ2:Git 側(backend / frontend)を直す
    3. ステップ3:Helm chart 側(db / logcollector / CronJob)を直す
    4. ステップ4:全 Pod が Restricted 準拠になったことを dry-run で確認する
    5. ステップ5:enforce を貼る
    6. ステップ6:壊れていないことを確かめる
    7. ステップ7:弾かれることを確かめる
    8. ステップ8:後片付け
  14. ValidatingAdmissionPolicy(CEL)— 素朴な式は取りこぼす
    1. まず素朴に書いてみる
    2. 取りこぼしを潰した式(実機で全ケース検証済み)
    3. 評価順序の罠
    4. 型チェックと Mutating 版
  15. OPA Gatekeeper — apply が通ってもポリシーが効いていないことがある
    1. 導入
    2. 最大の落とし穴 — apply は通るのにポリシーが効いていない
    3. 取りこぼしを潰した Rego(実機で全ケース検証済み)
    4. 効いているかを確かめる 2 つのコマンド
    5. fail-open が既定
    6. Gatekeeper にしかできないこと — audit(既存リソースの棚卸し)
  16. やってみよう⑥:latest タグ禁止を VAP と Gatekeeper の両方で実装する
    1. ステップ1:検証用の namespace を作る(PSS ラベルは貼らない)
    2. ステップ2:VAP を素朴な式で作り、取りこぼしを確認する
    3. ステップ3:式を差し替えて全ケースを潰す
    4. ステップ4:Gatekeeper を導入して同じルールを Rego で書く
    5. ステップ5:わざと import rego.v1 を書いて壊れ方を見る
    6. ステップ6:enforced と violations を確認する
    7. ステップ7:後片付け
  17. PSS で足りない制約と、ポリシーエンジン 4 種の比較
    1. PSS で表現できないもの
    2. 4 種の比較表
    3. Kyverno の位置づけ(記法の対比のみ・ラボには導入しない)
  18. 暗記必須コマンド(PSA / AdmissionConfiguration / VAP / Gatekeeper)
    1. ラボ v1.36.3 と試験 v1.35 で何が変わるか
  19. まとめ
  20. 理解度チェック(○×形式・全 9 問)
  21. 次回予告

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

本回から CKS ドメイン D4: Minimize Microservice Vulnerabilities(配点 20%) です。D4 は 4 つのコンピテンシーからなり、本回が担当するのは 1 つ目です。残る 3 つは第9回・第10回・第11回で埋めます。

公式コンピテンシー(D4 Minimize Microservice Vulnerabilities・20%)担当対応セクション
Use appropriate Pod Security Standards本回(第8回)PSS / PSA の全節 + やってみよう①〜⑤
Manage Kubernetes secrets第9回
Understand and implement isolation techniques(multi-tenancy / sandboxed containers)第10回
Implement Pod-to-Pod encryption using Cilium第11回

本書独自の補強(公式コンピテンシー名ではありません)

本回の後半で扱うカスタム Admission(VAP / Gatekeeper)は、公式 D4 の 4 項目のどれにも単独の項目としては存在しません。位置づけは 2 つです。①Use appropriate Pod Security Standards を補完する(PSS で表現できない制約を足す)②許可レジストリ制限として第14回の D5(Supply Chain Security)にも効く。上の公式 4 項目と同じ表には並べていません。

既習範囲 / 本回で上書きする観点

既習どこで本回で上書きする観点
Admission Controller の概念・OPA Gatekeeper と Kyverno の名前紹介(「本格的な実装は第3巻で扱う」と読者に約束済み)第1巻第15回本回で約束を果たします。 PSS 3 プロファイル / PSA 3 モード / Gatekeeper 実装 / VAP(CEL)/ 4 種比較
capabilities.drop: ["ALL"] を fanclub-backend に適用第1巻第15回Restricted プロファイルという「束」として namespace 単位で強制する側へ視点を移します
ResourceQuota 超過で Pod が作られず Deployment が UP-TO-DATE 0 になる診断第2巻第16回PSA の enforce も同じ診断構造であることを接続します
seccomp / capabilities / runAsNonRoot / SELinux の primitive第6回primitive を Restricted プロファイルが束ねる構造として読み直します。ただし第6回が触ったのは backend の Deployment のみ
apiServer.extraArgs / extraVolumeskube-system の ConfigMap が正本という作法第5回同じ枠組みに 1 フラグ + 1 extraVolume を足してクラスタ全体デフォルトを実装します
ノード上の hostPath / hostNetwork が持つ意味第7回その特権を要求する namespace は PSS で Restricted に上げられないという形で回収します

先に 1 つ、本回で覆る前提を予告しておきます。

warn を貼って違反を洗い出してから enforce へ」——という段階適用の手順書をよく見かけます。実機で確かめると、warn では既存 Pod の違反が 1 行も出ませんでした。 では何を貼れば数えられるのか。これは次の次の節で扱います。

第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)

第4部 ワークロード防御(D4)
  ★ 第8回: Pod Security Standards + Admission(D4)  ← 今ここ
    第9回: Secret 管理(etcd 暗号化 / SealedSecrets / ExternalSecrets)(D4)

本回の起点

起点は第7回の完了状態です。第2巻から引き継いだ 9 VM 構成(Control Plane Node 3 台 + Workload Node 2 台 + 作業端末・レジストリ・LB・プロキシ)が稼働している前提で進めます。ラボの Kubernetes が v1.36.3 なのは、第5回のパッチアップグレード演習を通した後の状態だからです。

項目
ノード5/5 Ready・Kubernetes v1.36.3・containerd 2.2.6
Pod98 個・異常 0
fanclub8 Pod すべて Runninghttps://fanclub.local/200/api/members が JSON を返す)
Longhorn4 ボリューム attached healthy
ArgoCDfanclub-api-prodSynced / Healthy
Gatewayfanclub-gateway traefik 192.168.1.200 True
etcdRAFT TERM 12 で 3 台一致・leader は k8s-cp-01・DB 123 MB
fanclub の PSA ラベル無し(本回で初めて貼ります)
AdmissionConfiguration無し--admission-control-config-file は未設定)

演習の進め方

本回は「やってみよう」が 6 本あり、通しで実施すると約 105 分(15 / 10 / 10 / 25 / 25 / 20 分)かかります。やってみよう④(クラスタ全体デフォルト)は Control Plane 3 台の API Server 設定を触ります。 途中で止めると 3 台のうち一部だけ設定が入った状態になり、後続の演習で「同じコマンドが通ったり通らなかったりする」原因になります。開始したら削除まで通してください。 やってみよう②は enforce を一時的に貼って外す往復なので、こちらも途中で止めないでください(貼りっぱなしにすると、以降の Pod 作成がすべて拒否されます)。①③⑤⑥ はそれぞれ独立しているので、日を分けても構いません。

この回のゴール

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

  • PSS の 3 プロファイルと PSA の 3 モードを説明し、namespace ラベルで設定できる
  • 既存 Pod の違反を壊さずに数える手順enforce ラベル + --dry-run=server)を実行できる
  • enforce を貼った後にどこが壊れ、どのコマンドで原因が見えるかdescribe rsFailedCreate)を説明できる
  • AdmissionConfiguration でクラスタ全体の既定を決め、HA の 3 台すべてに配れる
  • fanclub の 5 ワークロードを Restricted へ上げられる(正本が Git か Helm chart かを見分けたうえで
  • PSS で表現できない制約を VAP(CEL)と Gatekeeper(Rego)で書き分け、fail-open / fail-closed の差で選択できる

Pod Security Standards の 3 プロファイルと PSA の 3 モード

Pod Security Standards(PSS) は、Pod のセキュリティ水準を 3 段階で定義した公式標準です。かつての PodSecurityPolicy(PSP)は v1.25 で削除済みで、現在は PSS(標準の定義)と、それを強制する Pod Security Admission(PSA)の 2 本立てになっています。

ここで Admission(アドミッション)という語を使いました。認証・認可を通った API リクエストが etcd に書かれる直前に、内容を検査して通す / 拒む / 書き換える段階のことです。PSA はその段階で動く Kubernetes 組込の Admission プラグインで、namespace のラベルを見て判定します。

プロファイル水準用途
Privileged制限なしシステム・特権ワークロード(CNI / CSI / 監視エージェント)
Baseline既知の特権昇格を防ぐ最小限一般アプリの下限
Restricted現行のハードニング推奨をすべて要求本番の標準(CKS の到達目標)

PSA は namespace のラベルで適用します。ラベルは pod-security.kubernetes.io/<mode>pod-security.kubernetes.io/<mode>-version の 2 種類です。

モードラベル効果
enforcepod-security.kubernetes.io/enforce違反 Pod の作成を拒否する
warnpod-security.kubernetes.io/warn違反時に kubectl の標準エラーへ警告を返す(作成は通る)
auditpod-security.kubernetes.io/audit違反を監査ログの annotation に記録する(作成は通る)

audit モードが書き込む先は API Server の監査ログです。何がどう記録されるかは第16回で Audit Policy とあわせて扱うので、本回では「記録される」ところまでにします。

  • -version ラベルはプロファイル定義のバージョンを指定します。latest にすると Kubernetes をアップグレードした瞬間に判定が厳しくなることがあります。本番では v1.36 のようにマイナーで固定するのが安全です(本書ラボは学習のため latest を使います)
  • 3 モードは併用できますenforce=baseline + warn=restricted + audit=restricted のように、「止めるのは baseline まで、restricted 違反は知らせるだけ」という組み方が定石です

レベル名は検証されますが、バージョンは検証されません

(実測)。enforce: strict のように存在しないレベルを書くと、ラベル付与そのものが Invalid value: "strict": must be one of privileged, baseline, restricted で拒否されます。ところが enforce-version: v1.99 のような存在しないバージョンそのまま受理され、拒否メッセージも restricted:v1.99 と表示されます。タイプミスに気づけません。 -versionv1.35 と書けば CKS 試験環境と同じプロファイル定義に固定できますが、綴りが 1 文字違っても誰も教えてくれないことは覚えておいてください。

Restricted が要求するもの(第6回の primitive の束)

Restricted は次を要求します。いずれも 第6回で個別に扱った部品です。

要件フィールド第6回での扱い
非 root 実行runAsNonRoot: true(+ 実際の runAsUser扱済み
特権昇格の禁止allowPrivilegeEscalation: false扱済み
seccomp 既定seccompProfile.type: RuntimeDefault または Localhost扱済み(fanclub には RuntimeDefault を適用しました)
capability の最小化capabilities.drop: ["ALL"]扱済み
特権・ホスト名前空間の禁止privileged: false / hostNetwork / hostPID / hostIPC / hostPath / hostPort が不可第7回でノード側から見ました
ボリューム種別の制限configMap / secret / emptyDir / projected / downwardAPI / PVC などに限定

capabilities.add について(暗記対象)

Restricted が唯一 add を許すのは NET_BIND_SERVICE だけです。1024 番未満のポートを非 root で bind するための例外で、第2巻で扱った Traefik が実例でした。ただし本書ラボの fanclub-frontend では不要です(実測を後の節で示します)。「非 root にしたら 80 番が bind できないはずだから NET_BIND_SERVICE を足す」という思い込みは、実機で確かめると外れます。

API リクエストが認証・認可・Admission を通って etcd に書かれるまでの流れと、PSS の 3 プロファイル(Privileged / Baseline / Restricted)および PSA の 3 モード(enforce / warn / audit)が効くタイミングを示した図
図1:PSS 3 プロファイル × PSA 3 モードと、判定が走る位置

既存 Pod の違反を壊さずに数える — enforce + --dry-run=server だけが列挙する

ラベルを貼る前に「いま何件違反しているか」を知りたい。壊さずに数える方法は 1 つしかありません。

ここで --dry-run=server を使います。API Server に「実際に書き込む直前まで」処理させて結果だけ返させるオプションで、Admission は通るので、拒否や警告はそのまま出ます。

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

$ kubectl label ns fanclub pod-security.kubernetes.io/enforce=restricted pod-security.kubernetes.io/enforce-version=latest --dry-run=server

実行結果:

Warning: existing pods in namespace "fanclub" violate the new PodSecurity enforce level "restricted:latest"
Warning: fanclub-backend-b4578bc-22gxg (and 2 other pods): allowPrivilegeEscalation != false, unrestricted capabilities, runAsNonRoot != true
Warning: fanclub-db-0 (and 4 other pods): allowPrivilegeEscalation != false, unrestricted capabilities, runAsNonRoot != true, seccompProfile
namespace/fanclub labeled (server dry run)

Pod 名のハッシュ部(fanclub-backend-b4578bc-22gxg)は Pod が作り直されるたびに変わります。手元の値が本書と一致しなくても問題ありません。

まとめられた Pod の数を足すと 3 + 5 = 8 で、kubectl -n fanclub get pod の 8 件と一致します。

出力の読み方を 3 点に分解します。

  1. (and N other pods) —— 同じ違反の組み合わせを持つ Pod をまとめています。違反の「組み合わせ」ごとに 1 行にまとめられるので、行数は Pod 数と一致しません
  2. backend の違反が 3 項目、他が 4 項目なのは、backend だけ Pod レベルに seccompProfile があるからです(第6回で入れました)。seccompProfile の 1 項目だけが差になっています
  3. namespace/fanclub labeled (server dry run) —— 末尾のこの 1 行が「実際には書き込んでいない」証拠です

warnaudit の dry-run では 1 行も出ない

同じことを warn で試すと、結果が変わります。

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

$ kubectl label ns fanclub pod-security.kubernetes.io/warn=restricted pod-security.kubernetes.io/warn-version=latest --dry-run=server

実行結果:

namespace/fanclub labeled (server dry run)

警告は 1 行も出ません。同じ restricted を指定しているのに、enforce のときは 3 行出て、warn では 0 行です。

なぜ差が出るのか

既存 Pod の一括評価が走るのは、namespace の enforce レベルが変わるときだけです。warn / audit ラベルは「これから作られる Pod をどう扱うか」の設定であり、すでに動いている Pod を評価し直すきっかけになりません。

つまり、「まず warn を貼って違反を洗い出す」という手順は、Deployment を更新するまで何も教えてくれません。 既存の状況を今すぐ知りたいなら、enforce ラベルを --dry-run=server で当てるのが正解です。書き込まないので何も壊れません。

baseline で当てると距離が分かる

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

$ kubectl label ns fanclub pod-security.kubernetes.io/enforce=baseline pod-security.kubernetes.io/enforce-version=latest --dry-run=server

実行結果:

namespace/fanclub labeled (server dry run)

fanclub には hostPath / hostNetwork / hostPID / hostIPC が 1 つもありません。したがって Baseline には既に適合しており、Restricted までの距離だけが残っています

fanclub の Pod が使っているボリュームの種別を数えると configMap / emptyDir / persistentVolumeClaim の 3 つだけです。この 3 つは Restricted が許すボリューム種別に含まれます。Baseline / Restricted が拒むのは hostPath のようにノードのファイルシステムへ直接触れる種別で、PVC は Longhorn 経由でも問題になりません

この 2 回の dry-run で分かること

Baseline は警告 0 行、Restricted は警告 3 行です。ただし 3 行のうち 1 行は見出しexisting pods in namespace "fanclub" violate ...)なので、違反の組み合わせは 2 種類です。どこまで上げられるかを、1 Pod も壊さずに測れました。 ハードニングの計画は、この 2 つの数字から始めます。

warn の本当の使いどころ — Deployment を更新した瞬間に警告する

warn が無意味なわけではありません。効くタイミングが違います。

状態kubectl set env deploy/fanclub-frontend ... の出力
warn=restricted ありWarning: would violate PodSecurity "restricted:latest": ... が出る
ラベル無し何も出ない

ラベルが無い状態での実行結果:

deployment.apps/fanclub-frontend env updated

warn=restricted を貼った状態での実行結果:

Warning: would violate PodSecurity "restricted:latest": allowPrivilegeEscalation != false (container "frontend" must set securityContext.allowPrivilegeEscalation=false), unrestricted capabilities (container "frontend" must set securityContext.capabilities.drop=["ALL"]), runAsNonRoot != true (pod or container "frontend" must set securityContext.runAsNonRoot=true), seccompProfile (pod or container "frontend" must set securityContext.seccompProfile.type to "RuntimeDefault" or "Localhost")
deployment.apps/fanclub-frontend env updated

どちらも env updated で成功しています。警告は更新を止めません。 止めないからこそ、enforce に上げる前の準備期間に貼っておく価値があります

整理

enforce は Pod が作られる瞬間に効き、warn は Deployment を書き換えた瞬間に効きます。 enforce だけを貼っていると、Deployment の更新は素通りし、ReplicaSet が Pod を作ろうとして初めて失敗します(次の節で扱います)。両方貼るのが定石なのは、この 2 つが別のタイミングを見ているからです。

やってみよう①:fanclub の違反を壊さずに数え、warn を貼る

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

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

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

公式コンピテンシー Use appropriate Pod Security Standards に照らすと、ステップ3〜4 の形式(指定 namespace に指定プロファイルのラベルを貼る)の設問を想定できます。ステップ2(dry-run での棚卸し)とステップ5(回帰確認)は、本番で必要な前後の作法です。

対象は k8s-ops 上の kubectl だけです。Control Plane Node には触りません。

本文のコマンドは読みやすさのため kubectl と書いていますが、試験では alias k=kubectl が最初から設定されています(bash 補完も入っています)。本回は kubectl の比重が高いので、手元でも alias k=kubectl を設定しておくと写経が速くなります。

2 つの --dry-run を混同しないでください。

本回は --dry-run=server を多用します。--dry-run=client とはまったく別物です。

オプションどこまで行くか用途
--dry-run=clientAPI Server に届きません。 ローカルで YAML を組み立てて表示するだけ雛形作り。 kubectl run probe --image=nginx --dry-run=client -o yaml > pod.yaml
--dry-run=serverAPI Server が認証・認可・Admission まで通してから捨てます判定を見る。 PSA の警告・拒否がそのまま返る

試験での最短手順はこの 2 つの合わせ技です。--dry-run=client -o yaml で雛形を作る → ②kubectl apply して拒否メッセージを読む → ③メッセージが指示するフィールドをそのまま YAML に写す(次の節で見るとおり、PSA の拒否メッセージは「どのコンテナが」「どのフィールドを」「何に設定すべきか」まで書いてあります)。試験開始直後に export do="--dry-run=client -o yaml" を設定しておくと、kubectl run probe --image=nginx $do と打てます。

ステップ1:現状のラベルを確認する

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

$ kubectl get ns fanclub -o jsonpath='{.metadata.labels}'

実行結果:

{"app.kubernetes.io/managed-by":"Helm","kubernetes.io/metadata.name":"fanclub"}

pod-security.kubernetes.io/ で始まるラベルは 1 つもありません。PSA が何も効いていない状態が出発点です。

ステップ2:baselinerestricted の 2 段階で dry-run する

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

$ kubectl label ns fanclub pod-security.kubernetes.io/enforce=baseline pod-security.kubernetes.io/enforce-version=latest --dry-run=server

実行結果:

namespace/fanclub labeled (server dry run)

警告は 0 行です。Baseline なら今すぐ貼っても 1 Pod も壊れません。

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

$ kubectl label ns fanclub pod-security.kubernetes.io/enforce=restricted pod-security.kubernetes.io/enforce-version=latest --dry-run=server

実行結果:

Warning: existing pods in namespace "fanclub" violate the new PodSecurity enforce level "restricted:latest"
Warning: fanclub-backend-b4578bc-22gxg (and 2 other pods): allowPrivilegeEscalation != false, unrestricted capabilities, runAsNonRoot != true
Warning: fanclub-db-0 (and 4 other pods): allowPrivilegeEscalation != false, unrestricted capabilities, runAsNonRoot != true, seccompProfile
namespace/fanclub labeled (server dry run)

まとめられた Pod 数を足すと 3 + 5 = 8 で、fanclub の Pod 総数と一致します。Pod 名のハッシュ部は環境ごとに変わります。

ステップ3:何が足りないかを 1 つずつ数える

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

$ kubectl -n fanclub get pod -o custom-columns='NAME:.metadata.name,RUNASNONROOT:.spec.securityContext.runAsNonRoot,SECCOMP:.spec.securityContext.seccompProfile.type'

実行結果:

NAME                                RUNASNONROOT   SECCOMP
fanclub-backend-b4578bc-22gxg       <none>         RuntimeDefault
fanclub-backend-b4578bc-bdw5n       <none>         RuntimeDefault
fanclub-backend-b4578bc-v6fpl       <none>         RuntimeDefault
fanclub-db-0                        <none>         <none>
fanclub-frontend-76b8b6b9c5-8mz6l   <none>         <none>
fanclub-frontend-76b8b6b9c5-b4xmr   <none>         <none>
fanclub-logcollector-r5vjs          <none>         <none>
fanclub-logcollector-sh5tq          <none>         <none>

SECCOMP 列に値があるのは backend の 3 Pod だけです。第6回で Pod レベルに seccompProfile: RuntimeDefault を入れたのが、そのまま見えています。RUNASNONROOT 列が全部 <none> なのは、backend の runAsNonRoot が Pod レベルではなくコンテナレベルに書かれているからです。この列は Pod レベルだけを見ています。

ステップ4:warnaudit を貼る(enforce はまだ貼らない)

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

$ kubectl label ns fanclub pod-security.kubernetes.io/warn=restricted pod-security.kubernetes.io/warn-version=latest pod-security.kubernetes.io/audit=restricted pod-security.kubernetes.io/audit-version=latest

実行結果:

namespace/fanclub labeled

ここで enforce を恒久的に貼らない理由

貼ってもこの瞬間は何も起きないからです。 既存の 8 Pod は動き続けます。壊れるのは「次に Pod が作り直されるとき」で、それがいつ来るかは分かりません(ノード再起動・rollout restart・HPA のスケール)。時限式の障害を仕込むことになるので、先に Pod spec を直してから貼ります(やってみよう⑤)。

ただし、その「時限式」が具体的にどう表面化するかは、一度見ておく価値があります。 次のやってみよう②で、enforce を一時的に貼って現象を確認し、すぐ外します(所要 10 分・fanclub は完全に復旧します)。

ステップ5:warn が効くことを確かめる

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

$ kubectl -n fanclub set env deploy/fanclub-frontend PSS_PROBE=1

実行結果:

Warning: would violate PodSecurity "restricted:latest": allowPrivilegeEscalation != false (container "frontend" must set securityContext.allowPrivilegeEscalation=false), unrestricted capabilities (container "frontend" must set securityContext.capabilities.drop=["ALL"]), runAsNonRoot != true (pod or container "frontend" must set securityContext.runAsNonRoot=true), seccompProfile (pod or container "frontend" must set securityContext.seccompProfile.type to "RuntimeDefault" or "Localhost")
deployment.apps/fanclub-frontend env updated

警告は出ますが、更新自体は成功していますenv updated)。warn は止める仕組みではありません。

ステップ6:後片付け(環境変数を戻す)

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

$ kubectl -n fanclub set env deploy/fanclub-frontend PSS_PROBE-

実行結果:

Warning: would violate PodSecurity "restricted:latest": allowPrivilegeEscalation != false (container "frontend" must set securityContext.allowPrivilegeEscalation=false), unrestricted capabilities (container "frontend" must set securityContext.capabilities.drop=["ALL"]), runAsNonRoot != true (pod or container "frontend" must set securityContext.runAsNonRoot=true), seccompProfile (pod or container "frontend" must set securityContext.seccompProfile.type to "RuntimeDefault" or "Localhost")
deployment.apps/fanclub-frontend env updated

環境変数を消す操作にも同じ警告が出ます。 warn は「この Pod テンプレートは Restricted を満たさない」と言っているだけなので、変更の中身に関係なく、更新のたびに出ます

ArgoCD の selfHeal に注意

fanclub-frontendArgoCD の管理下にあり(syncPolicy.automated.selfHeal=true)、kubectl set env の変更は数十秒で自動的に巻き戻ります(第6回の実測で約 80 秒)。ステップ5 の警告を確認するのが目的なので巻き戻って構いませんが、「なぜ消えたのか」を知らないと混乱します。この二重管理は後の節で正面から扱います。

enforce を貼った後、何がいつ壊れるか

この節は読み取りの解説です。

ここに出てくるコマンドは、この節では打ちません。 掲載しているのは「enforce=restricted を貼るとどうなるか」を先に地図として渡すためのもので、実際に手を動かすのは次のやってみよう②(10 分・貼って外す往復)です。先に現象の名前を知ってから手を動かすほうが、演習中に何を見ればよいかが分かります。 本書では、本文の解説セクションで環境に変更を加えるコマンドは打ちません。

enforce=restricted を貼ると何が起きるか。4 つの時点に分けて整理します。

時点起きることやってみよう②での対応ステップ
ラベルを貼った直後既存 8 Pod はすべて Running のまま。 警告が返るだけで、削除も再起動もされないステップ1〜2
Pod を単体で作ろうとしたとき即座に Forbidden で拒否されるステップ4
Deployment を作り直したときrollout restart は成功を返すのに、新 Pod が 1 つも作られないステップ5〜6
ラベルを外したとき数十秒で新 Pod が作られ、完全に復旧する(実測 25 秒)ステップ7〜8

Pod 単体の拒否メッセージ(読み方の練習)

やってみよう② ステップ4 で、次のコマンドを打つと拒否されます。

コマンド(この節では打ちません):

$ kubectl -n fanclub run probe-frontend --image=k8s-registry:5000/fanclub-frontend:1.0.0

そのときの実行結果:

Error from server (Forbidden): pods "probe-frontend" is forbidden: violates PodSecurity "restricted:latest": allowPrivilegeEscalation != false (container "probe-frontend" must set securityContext.allowPrivilegeEscalation=false), unrestricted capabilities (container "probe-frontend" must set securityContext.capabilities.drop=["ALL"]), runAsNonRoot != true (pod or container "probe-frontend" must set securityContext.runAsNonRoot=true), seccompProfile (pod or container "probe-frontend" must set securityContext.seccompProfile.type to "RuntimeDefault" or "Localhost")

このメッセージは親切です。

4 つの違反それぞれに 「どのコンテナが」「どのフィールドを」「何に設定すべきか」 が書かれています。試験では、このメッセージをそのまま YAML に写せば通ります。 pod or container と書かれている項目(runAsNonRoot / seccompProfile)は Pod レベルに 1 回書けば全コンテナに効くので、そちらに書くほうが短く済みます。

rollout restart は成功を返すのに Pod が作られない

ここが本回で最も診断しにくい状態です。

コマンド(この節では打ちません・やってみよう② ステップ5):

$ kubectl -n fanclub rollout restart deploy/fanclub-frontend

そのときの実行結果:

deployment.apps/fanclub-frontend restarted

成功したように見えます。 ところが新しい Pod は 1 つも作られません。しかも kubectl get deploy は健全に見えます。

コマンド(やってみよう② ステップ5):

$ kubectl -n fanclub get deploy fanclub-frontend

そのときの実行結果:

NAME               READY   UP-TO-DATE   AVAILABLE   AGE
fanclub-frontend   2/2     0            2           42h

READY 2/2 は「古い Pod が 2 つ生きている」という意味でしかありません。

新しい ReplicaSet は Pod を作れずにいるのに、古い ReplicaSet の Pod が動いているので READY は満たされます。 異常を示しているのは UP-TO-DATE 0 の 1 箇所だけです。監視が READY しか見ていないと、この状態は永久に検知されません。

本当の状態は ReplicaSet 側にあります。

コマンド(やってみよう② ステップ6):

$ kubectl -n fanclub get rs -l app=fanclub-frontend

そのときの実行結果:

NAME                          DESIRED   CURRENT   READY   AGE
fanclub-frontend-5dc7d8dd78   0         0         0       2m8s
fanclub-frontend-658b5cd5b9   1         0         0       30s
fanclub-frontend-76b8b6b9c5   2         2         2       27h
fanclub-frontend-7d9b4667f    0         0         0       42h
fanclub-frontend-d846969dc    0         0         0       49s

ReplicaSet は Pod テンプレートが変わるたびに増えます。やってみよう① で set env を 2 回打っているので、手元でも同じくらいの本数が並びます。読むべきは AGE が最も新しい 1 行で、ここでは fanclub-frontend-658b5cd5b9(30s)です。DESIRED 1 なのに CURRENT 0、つまり「1 つ作りたいのに 0 個しか作れていない」状態です(ローリング更新なので最初は 1 つずつ増やそうとします)。2 行目の 76b8b6b9c52/2/2 で生き残っているので、サービスは止まりません。

コマンド(<新しい RS 名> は上の出力から取ります):

$ kubectl -n fanclub describe rs <新しい RS 名>

実行結果(Events 節の抜粋):

Events:
  Type     Reason        Age   From                   Message
  ----     ------        ----  ----                   -------
  Warning  FailedCreate  30s   replicaset-controller  Error creating: pods "fanclub-frontend-658b5cd5b9-8wdxk" is forbidden: violates PodSecurity "restricted:latest": allowPrivilegeEscalation != false (container "frontend" must set securityContext.allowPrivilegeEscalation=false), unrestricted capabilities (container "frontend" must set securityContext.capabilities.drop=["ALL"]), runAsNonRoot != true (pod or container "frontend" must set securityContext.runAsNonRoot=true), seccompProfile (pod or container "frontend" must set securityContext.seccompProfile.type to "RuntimeDefault" or "Localhost")
  Warning  FailedCreate  30s   replicaset-controller  Error creating: pods "fanclub-frontend-658b5cd5b9-wbz79" is forbidden: violates PodSecurity "restricted:latest": allowPrivilegeEscalation != false (container "frontend" must set securityContext.allowPrivilegeEscalation=false), unrestricted capabilities (container "frontend" must set securityContext.capabilities.drop=["ALL"]), runAsNonRoot != true (pod or container "frontend" must set securityContext.runAsNonRoot=true), seccompProfile (pod or container "frontend" must set securityContext.seccompProfile.type to "RuntimeDefault" or "Localhost")

ReplicaSet 名と Pod 名のハッシュ部、経過秒数は実行のたびに変わります。同じ内容の FailedCreate が何行も並ぶのは、ReplicaSet コントローラが作成を諦めずに再試行しているからです。

第2巻第16回と同じ診断構造です。

第2巻のトラブルシュート回で、ResourceQuota を超えた Deployment が UP-TO-DATE 0 のまま Pod を作れずにいるという状態を扱いました。症状も診断手順も同じです。

「Deployment は正常に見えるのに Pod が増えない」ときは、Deployment ではなく ReplicaSet の Events を見る。 これは quota でも PSA でも NetworkPolicy でも変わらない、Kubernetes の一般的な診断作法です。Pod を実際に作るのは Deployment ではなく ReplicaSet コントローラなので、拒否されたときのエラーは ReplicaSet に記録されます。

復旧は速いです。ラベルを外すと数十秒で新しい Pod が作られます(本書ラボの実測では UP-TO-DATE が戻るまで 25 秒)。ReplicaSet コントローラは失敗をバックオフしながら再試行し続けているので、拒否の原因が消えた瞬間に成功します。「壊れたら戻せる」ことを確かめておくと、本番でラベルを貼る手が軽くなります。

実際の復旧の様子(やってみよう② ステップ7〜8 で確認します):

NAME                                READY   STATUS              RESTARTS      AGE
fanclub-frontend-658b5cd5b9-fhb66   0/1     ContainerCreating   0             1s
fanclub-frontend-658b5cd5b9-rs2zz   1/1     Running             0             2s
fanclub-frontend-76b8b6b9c5-8mz6l   1/1     Running             6 (14m ago)   11h
fanclub-frontend-76b8b6b9c5-b4xmr   0/1     Completed           6 (14m ago)   11h
enforce ラベル付与から rollout restart、新 ReplicaSet の FailedCreate、kubectl get deploy の READY 2/2 と UP-TO-DATE 0 までを時系列で並べ、UP-TO-DATE 0 だけが異常であることを強調したタイムライン図
図2:enforce を貼った後に何がいつ壊れるか

やってみよう②:enforce を貼って空振りを体験し、外して戻す

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

「Deployment は正常に見えるのに Pod が増えない」という状態を自分の手で作って、自分の手で戻す演習です。CKS では「壊れている Deployment を直せ」という形で出題されうるので、describe rs まで降りる手順は暗記対象です。

この演習は必ず ステップ8 まで通してください。

ステップ2 で貼るラベルを外さないまま放置すると、fanclub namespace では以降どの Pod も作られなくなります。 既存の 8 Pod は動き続けるので、kubectl get pod を見ただけでは気づけません。貼る → 見る → 外すの 3 点が 1 セットです。所要 10 分のうち、ラベルが貼られている時間は 5 分程度です。

対象は k8s-ops 上の kubectl だけです。Control Plane Node には触りません。前提はやってみよう① を実施済みであること(warn / audit は貼ってあり、enforce は貼っていない状態)です。

ステップ1:貼る前の状態を記録する

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

$ kubectl -n fanclub get pod

実行結果:

NAME                                READY   STATUS    RESTARTS      AGE
fanclub-backend-b4578bc-22gxg       2/2     Running   4 (16m ago)   11h
fanclub-backend-b4578bc-bdw5n       2/2     Running   4 (16m ago)   11h
fanclub-backend-b4578bc-v6fpl       2/2     Running   4 (16m ago)   11h
fanclub-db-0                        1/1     Running   0             10m
fanclub-frontend-76b8b6b9c5-8mz6l   1/1     Running   6 (12m ago)   11h
fanclub-frontend-76b8b6b9c5-b4xmr   1/1     Running   6 (12m ago)   11h
fanclub-logcollector-r5vjs          1/1     Running   2 (16m ago)   11h
fanclub-logcollector-sh5tq          1/1     Running   4 (16m ago)   27h

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

$ kubectl -n fanclub get deploy

実行結果:

NAME               READY   UP-TO-DATE   AVAILABLE   AGE
fanclub-backend    3/3     3            3           42h
fanclub-frontend   2/2     2            2           42h

8 Pod・UP-TO-DATE は 2 つとも満たされている——この 2 つの数字を覚えておいてください。ステップ5 で比べます。

ステップ2:enforce=restricted を貼る

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

$ kubectl label ns fanclub pod-security.kubernetes.io/enforce=restricted pod-security.kubernetes.io/enforce-version=latest

実行結果:

Warning: existing pods in namespace "fanclub" violate the new PodSecurity enforce level "restricted:latest"
Warning: fanclub-backend-b4578bc-22gxg (and 2 other pods): allowPrivilegeEscalation != false, unrestricted capabilities, runAsNonRoot != true
Warning: fanclub-db-0 (and 4 other pods): allowPrivilegeEscalation != false, unrestricted capabilities, runAsNonRoot != true, seccompProfile
namespace/fanclub labeled

dry-run のときと同じ警告が返り、末尾だけが (server dry run) の無い namespace/fanclub labeled に変わっています。 今度は本当に書き込まれました。

ステップ3:既存 Pod が死んでいないことを確かめる

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

$ kubectl -n fanclub get pod

実行結果:

NAME                                READY   STATUS    RESTARTS      AGE
fanclub-backend-b4578bc-22gxg       2/2     Running   4 (16m ago)   11h
fanclub-backend-b4578bc-bdw5n       2/2     Running   4 (16m ago)   11h
fanclub-backend-b4578bc-v6fpl       2/2     Running   4 (16m ago)   11h
fanclub-db-0                        1/1     Running   0             10m
fanclub-frontend-76b8b6b9c5-8mz6l   1/1     Running   6 (12m ago)   11h
fanclub-frontend-76b8b6b9c5-b4xmr   1/1     Running   6 (12m ago)   11h
fanclub-logcollector-r5vjs          1/1     Running   2 (16m ago)   11h
fanclub-logcollector-sh5tq          1/1     Running   4 (16m ago)   27h

ステップ1 と 1 行も変わりません。 RESTARTS も増えていません。

ここが最初の驚きどころです。

ラベルを貼った瞬間に何かが止まると身構えていると、何も起きません。 PSA は Admission(これから作られるものの検査)であって、すでに etcd にあるものを消す仕組みではありません。

ステップ4:Pod を単体で作ろうとして拒否される

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

$ kubectl -n fanclub run probe-frontend --image=k8s-registry:5000/fanclub-frontend:1.0.0

実行結果:

Error from server (Forbidden): pods "probe-frontend" is forbidden: violates PodSecurity "restricted:latest": allowPrivilegeEscalation != false (container "probe-frontend" must set securityContext.allowPrivilegeEscalation=false), unrestricted capabilities (container "probe-frontend" must set securityContext.capabilities.drop=["ALL"]), runAsNonRoot != true (pod or container "probe-frontend" must set securityContext.runAsNonRoot=true), seccompProfile (pod or container "probe-frontend" must set securityContext.seccompProfile.type to "RuntimeDefault" or "Localhost")

ステップ5:rollout restart が空振りすることを確かめる

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

$ kubectl -n fanclub rollout restart deploy/fanclub-frontend

実行結果:

Warning: would violate PodSecurity "restricted:latest": allowPrivilegeEscalation != false (container "frontend" must set securityContext.allowPrivilegeEscalation=false), unrestricted capabilities (container "frontend" must set securityContext.capabilities.drop=["ALL"]), runAsNonRoot != true (pod or container "frontend" must set securityContext.runAsNonRoot=true), seccompProfile (pod or container "frontend" must set securityContext.seccompProfile.type to "RuntimeDefault" or "Localhost")
deployment.apps/fanclub-frontend restarted

warn ラベルのおかげで警告は出ますが、最後の行は restarted——成功です。

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

$ kubectl -n fanclub get deploy fanclub-frontend

実行結果(30 秒ほど待ってから):

NAME               READY   UP-TO-DATE   AVAILABLE   AGE
fanclub-frontend   2/2     0            2           42h

kubectl get deploy の 4 つの列を、ここで 1 つずつ読んでください。

この状態での値意味
READY2/2古い Pod が 2 つ生きているというだけ
UP-TO-DATE0新しい定義の Pod が 0 個。ここだけが異常
AVAILABLE2READY と同じく古い Pod を数えている
AGE(環境による)Deployment の作成からの経過時間

監視が READY しか見ていないと、この状態は永久に検知されません。

ステップ6:describe rs で原因を見る

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

$ kubectl -n fanclub get rs -l app=fanclub-frontend

実行結果:

NAME                          DESIRED   CURRENT   READY   AGE
fanclub-frontend-5dc7d8dd78   0         0         0       2m8s
fanclub-frontend-658b5cd5b9   1         0         0       30s
fanclub-frontend-76b8b6b9c5   2         2         2       27h
fanclub-frontend-7d9b4667f    0         0         0       42h
fanclub-frontend-d846969dc    0         0         0       49s

AGE が最も新しい行(ここでは fanclub-frontend-658b5cd5b9)が、rollout restart で作られた ReplicaSet です。DESIRED 1 に対して CURRENT 0 ——作りたいのに作れていません。

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

$ kubectl -n fanclub describe rs <新しい RS 名>

実行結果(Events 節の抜粋):

Events:
  Type     Reason        Age   From                   Message
  ----     ------        ----  ----                   -------
  Warning  FailedCreate  30s   replicaset-controller  Error creating: pods "fanclub-frontend-658b5cd5b9-8wdxk" is forbidden: violates PodSecurity "restricted:latest": allowPrivilegeEscalation != false (container "frontend" must set securityContext.allowPrivilegeEscalation=false), unrestricted capabilities (container "frontend" must set securityContext.capabilities.drop=["ALL"]), runAsNonRoot != true (pod or container "frontend" must set securityContext.runAsNonRoot=true), seccompProfile (pod or container "frontend" must set securityContext.seccompProfile.type to "RuntimeDefault" or "Localhost")

試験でも実務でも、ここまで降りる癖を付けてください。

DeploymentReplicaSetPod の 3 段のうち、Pod を実際に作るのは ReplicaSet コントローラです。拒否されたときのエラーは、その担当者のところ(ReplicaSet の Events)に記録されます。

ステップ7:ラベルを外す

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

$ kubectl label ns fanclub pod-security.kubernetes.io/enforce- pod-security.kubernetes.io/enforce-version-

実行結果:

namespace/fanclub unlabeled

ラベル名の末尾に付けたハイフンが「そのラベルを削除する」という意味です。= を書かずにハイフンで終える、という書き方は kubectl labelkubectl annotate に共通です。

ステップ8:回復を確認する

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

$ kubectl -n fanclub get pod -w

実行結果(抜粋):

NAME                                READY   STATUS              RESTARTS      AGE
fanclub-frontend-658b5cd5b9-fhb66   0/1     ContainerCreating   0             1s
fanclub-frontend-658b5cd5b9-rs2zz   1/1     Running             0             2s
fanclub-frontend-76b8b6b9c5-8mz6l   1/1     Running             6 (14m ago)   11h
fanclub-frontend-76b8b6b9c5-b4xmr   0/1     Completed           6 (14m ago)   11h

本書ラボでは、ラベルを外してから UP-TO-DATE が 2 に戻るまで 25 秒でした。ReplicaSet コントローラが再試行を続けていたので、拒否の原因が消えた瞬間に成功します。

-w は変化を監視し続けるオプションなので、新しい Pod が Running になったら Ctrl+C で抜けてください。

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

$ kubectl -n fanclub get deploy fanclub-frontend

実行結果:

NAME               READY   UP-TO-DATE   AVAILABLE   AGE
fanclub-frontend   2/2     2            2           42h

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

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

実行結果:

200

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

ArgoCD の selfHeal との相互作用

fanclub-frontend は ArgoCD の管理下にあります。rollout restart は Pod テンプレートに annotation を打つ操作なので、selfHeal がこれを「Git との差分」と見なして巻き戻す可能性があります。

本書ラボの実測では、rollout restart の直後に ArgoCD は Synced / Progressing になりました。 Progressing は「新しい Pod が揃うのを待っている」という意味で、ラベルを外すまでこの状態が続きます。 巻き戻しが先に走って UP-TO-DATE 0 を観察できない、ということは起きませんでした。

ただし selfHeal は rollout restart が打った annotation をいずれ消します。その結果、ラベルを外した後にもう一度 Pod が作り直されることがあります(第6回でも同じ現象を扱いました)。最終的に Synced / Healthy に落ち着けば正常です。

ステップ9:後片付けの確認

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

$ kubectl get ns fanclub -o jsonpath='{.metadata.labels}'

実行結果:

{"app.kubernetes.io/managed-by":"Helm","kubernetes.io/metadata.name":"fanclub","pod-security.kubernetes.io/audit":"restricted","pod-security.kubernetes.io/audit-version":"latest","pod-security.kubernetes.io/warn":"restricted","pod-security.kubernetes.io/warn-version":"latest"}

enforce 系の 2 つが消え、やってみよう① で貼った warn / audit の 4 つだけが残っています。 これが次のやってみようの出発点です。

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

$ kubectl -n fanclub get pod probe-frontend

実行結果:

Error from server (NotFound): pods "probe-frontend" not found

拒否された Pod は存在しません(作成そのものが止められたため)。削除は不要です。

やってみよう③:クラスタ全 namespace を PSS で棚卸しする

所要 10 分(全体が「読み取り」であり、クラスタに変更を加える操作はありません)。

ただしこの棚卸しがやってみよう④ の設計根拠になるので飛ばさないでください。CKS で直接問われる形式ではありませんが、本番でクラスタ全体デフォルトを決める前に必ず通る作業です。

「どの namespace を Restricted にできるか」は、実測しないと決められません。1 Pod も壊さずに、クラスタ全体の距離を測ります。

ステップ1:全 namespace に baseline を dry-run で当てる

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

$ for ns in $(kubectl get ns -o jsonpath='{.items[*].metadata.name}'); do echo "=== $ns ==="; kubectl label ns $ns pod-security.kubernetes.io/enforce=baseline pod-security.kubernetes.io/enforce-version=latest --dry-run=server --overwrite 2>&1 | grep -c '^Warning:'; done

実行結果:

=== argocd ===
0
=== cert-manager ===
0
=== default ===
0
=== fanclub ===
0
=== kube-node-lease ===
0
=== kube-public ===
0
=== kube-system ===
3
=== local-path-storage ===
0
=== longhorn-system ===
4
=== metallb-system ===
2
=== monitoring ===
3
=== traefik ===
0
=== velero ===
2

--overwrite を付ける理由

すでに PSA ラベルが貼られている namespace(本書ラボでは velero。chart が自分で privileged を貼っています)と、やってみよう① で warn / audit を貼った fanclub があると、--overwrite 無しでは already has a value で失敗します。dry-run なので上書きしても実害はありません(server dry run) の行が「書き込んでいない」証拠です)。

ステップ2:同じことを restricted で当てる

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

$ for ns in $(kubectl get ns -o jsonpath='{.items[*].metadata.name}'); do echo "=== $ns ==="; kubectl label ns $ns pod-security.kubernetes.io/enforce=restricted pod-security.kubernetes.io/enforce-version=latest --dry-run=server --overwrite 2>&1 | grep -c '^Warning:'; done

実行結果:

=== argocd ===
0
=== cert-manager ===
0
=== default ===
0
=== fanclub ===
3
=== kube-node-lease ===
0
=== kube-public ===
0
=== kube-system ===
6
=== local-path-storage ===
2
=== longhorn-system ===
5
=== metallb-system ===
3
=== monitoring ===
5
=== traefik ===
0
=== velero ===
3

ステップ3:結果を表にまとめる

namespacebaseline の警告行数restricted の警告行数何が引っかかっているか
argocd00既に Restricted 適合
cert-manager00同上
traefik00同上
default / kube-public / kube-node-lease00Pod が無い
fanclub03Baseline 適合済み。Restricted のみ不足
local-path-storage02provisioner が非 root でない
kube-system36calico-node / kube-proxy(privileged・host namespaces・hostPath)/ etcd・apiserver 等の static Pod / coredns
longhorn-system45csi-plugin が privileged + hostPath + 非既定 capability
metallb-system23speaker が hostNetwork + hostPort + 非既定 capability
monitoring35node-exporter(host namespaces / hostPort / hostPath)・fluent-bit(hostPath)・grafana(runAsUser=0
velero23node-agent が hostPath + runAsUser=0

この表は、本回の後半でもう 1 行増えます。 やってみよう⑥ で OPA Gatekeeper を入れると gatekeeper-system が生まれますが、それは最初から Restricted 適合の状態で入ってきます(後の節で確認します)。

この表から読み取れる二極化

新しめの chart(ArgoCD / cert-manager / Traefik)は、最初から Restricted に適合しています。 開発側が PSS を前提に設計しているからです。

一方、インフラ系(CNI / CSI / LoadBalancer / 監視 / バックアップ)は Baseline すら満たせません。 第7回で見たとおり、これらはノードのネットワーク名前空間やホストのファイルシステムに触ることが仕事だからです。node-exporter がホストの /proc を読めなければメトリクスは取れませんし、csi-plugin がホストにマウントできなければボリュームは attach できません。

「全 namespace を Restricted にする」は達成できない目標です。 現実の目標は 「アプリケーションの namespace を Restricted に、インフラの namespace を Privileged に明示して、その中間を無くす」 ことです。

ステップ4:すでに chart がラベルを貼っている namespace を確認する

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

$ kubectl get ns -o custom-columns='NAME:.metadata.name,ENFORCE:.metadata.labels.pod-security\.kubernetes\.io/enforce,VERSION:.metadata.labels.pod-security\.kubernetes\.io/enforce-version'

実行結果:

NAME                 ENFORCE      VERSION
argocd               <none>       <none>
cert-manager         <none>       <none>
default              <none>       <none>
fanclub              <none>       <none>
kube-node-lease      <none>       <none>
kube-public          <none>       <none>
kube-system          <none>       <none>
local-path-storage   <none>       <none>
longhorn-system      <none>       <none>
metallb-system       <none>       <none>
monitoring           <none>       <none>
traefik              <none>       <none>
velero               privileged   latest

13 の namespace のうち、enforce ラベルを持っているのは velero だけです。これは読者が貼ったものではなく、Velero の Helm chart が自分で貼ったものです。第2巻第14回で Velero を入れた時点から付いていました。

chart が自分でラベルを貼っていることがあります。

velero の chart は、node-agent が hostPath を使い runAsUser=0 で動くことを自分で知っています。だから namespace に pod-security.kubernetes.io/enforce=privileged を自分で貼り、「うちは Baseline すら満たさないので除外してほしい」と宣言しています。

これは正しい設計です。 特権が必要な理由を一番よく知っているのは chart の作者であって、クラスタ管理者ではありません。宣言があれば、管理者は「なぜこの namespace だけ privileged なのか」を調べずに済みます。

手で貼る前に、既に貼られていないかを必ず確認してください。 kubectl label は既存の値があると失敗し、--overwrite を付けると chart の意図を上書きします。次の helm upgrade で戻るので、恒久的に変えたいなら chart の values で指定します。

namespace ラベルは貼り忘れる — AdmissionConfiguration でクラスタ既定を決める

PSA の namespace ラベル方式には、はっきりした穴があります。

ラベルを貼り忘れた namespace は、何の制限も受けません。

新しい namespace が作られるたびに人がラベルを貼る運用は、必ずどこかで抜けます。 開発者が kubectl create ns を打った namespace は、何も貼られていない = privileged 相当です。

これを塞ぐのが AdmissionConfiguration による既定値です。API Server の起動時に読ませる設定ファイルで、Admission プラグインごとの既定値を書けます。PSA の場合は「ラベルが無い namespace に何を適用するか」を決めます。

現状の確認

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

$ sudo grep -n "enable-admission-plugins\|admission-control-config-file" /etc/kubernetes/manifests/kube-apiserver.yaml

実行結果:

19:    - --enable-admission-plugins=NodeRestriction

--admission-control-config-file は 1 行もヒットしません。 つまり クラスタ全体の既定は「ラベルが無ければ何でも通る」という状態です。

PodSecurity--enable-admission-plugins に書かれていないのは正常です。

PodSecurity は v1.25 以降デフォルトで有効な Admission プラグインなので、明示する必要がありません。書かれていないからといって無効ではありません(前の節で実際に警告が返ったのがその証拠です)。

書くファイル(全量)

/etc/kubernetes/admission/admission-config.yaml の内容です。

apiVersion: apiserver.config.k8s.io/v1
kind: AdmissionConfiguration
plugins:
  - name: PodSecurity
    configuration:
      apiVersion: pod-security.admission.config.k8s.io/v1
      kind: PodSecurityConfiguration
      defaults:
        enforce: "baseline"
        enforce-version: "latest"
        warn: "restricted"
        warn-version: "latest"
        audit: "restricted"
        audit-version: "latest"
      exemptions:
        usernames: []
        runtimeClasses: []
        namespaces: ["kube-system"]

設計の要点は 3 つです。

  1. defaults は「ラベルが無い namespace に適用される値」です。ラベルがあればラベルが勝ちます。既定を厳しくしても、明示ラベルで個別に緩められます
  2. enforce: baseline + warn/audit: restricted の組み合わせが実務の落としどころです。止めるのは baseline まで、restricted 違反は知らせるだけという設計になります
  3. exemptions は 3 種類(usernames / runtimeClasses / namespaces)です。kube-system は必ず外します(static Pod が privileged なので、外さないと Control Plane の再作成が止まります)

exemptions は RBAC とは別の抜け道です(第4回との接続)

usernames に書くのはユーザ名または ServiceAccount 名で、「この主体からのリクエストは PSA を通さない」という意味です。迂回されるのは PSA の判定だけで、RBAC は迂回されません。 Pod を作る権限が RBAC で無ければ、exemptions に書かれていても Pod は作れません。

逆に言えば、RBAC で Pod を作れる主体をここに書くと、その主体だけ PSS の判定が丸ごと無くなります。 第4回で扱った「過剰権限の棚卸し」の対象に、この 3 行も入れてください。 RBAC を見るだけでは、この抜け道は見つかりません。

exemptions.runtimeClasses について

3 つ目の除外軸は RuntimeClass 単位です。本書ラボはまだ RuntimeClass を使っていないので空のままですが、第10回で gVisor(サンドボックス)を RuntimeClass として導入すると、この行が意味を持ちます。 「カーネルから隔離されたランタイムで動くなら PSS を緩めてよいか」という設計判断は第10回で扱います。本回では「そういう軸がある」ことだけ覚えて、空のままにしてください。

HA では 3 台すべてに配る

cp-01 だけに設定を入れて実測した結果です。

経由した API Serverdefault namespace に hostPath Pod を作った結果
k8s-cp-01(設定あり)Error from server (Forbidden): ... is forbidden: violates PodSecurity "baseline:latest": hostPath volumes (volume "h")
k8s-cp-02(設定なし)pod/hostpath-probe created (server dry run)(通ってしまう)

コマンド(cp-01 を経由する場合・この節では打ちません):

$ kubectl --server=https://192.168.1.125:6443 --insecure-skip-tls-verify -n default apply -f hostpath-pod.yaml --dry-run=server

そのときの実行結果:

Error from server (Forbidden): pods "hostpath-probe" is forbidden: violates PodSecurity "baseline:latest": hostPath volumes (volume "h")

コマンド(cp-02 を経由する場合・この節では打ちません):

$ kubectl --server=https://192.168.1.126:6443 --insecure-skip-tls-verify -n default apply -f hostpath-pod.yaml --dry-run=server

そのときの実行結果:

pod/hostpath-probe created (server dry run)

--dry-run=server を付けているので、どちらも Pod を作っていません。 それでも Admission の判定は本番と同じように走るので、「拒否されなかった」ことの証拠になります。 何も作らずにポリシーの効き方だけを比べられる、この節にちょうどよい確かめ方です。

本書ラボの kubectl は k8s-lb(HAProxy)を経由しており、roundrobin で 3 台の Control Plane に振り分けられます。

つまり cp-01 だけに設定を入れると、同じ kubectl apply が成功したり失敗したりします。

しかも kubectl get では何も異常が見えません。 ノードは 5/5 Ready、Pod もすべて Running、kubectl get ns のラベルも 3 台で同じです。「ポリシーがたまに効かない」という現象だけが残ります。

第5回で確立した 2 つの作法を、ここでもう一度使います。①設定ファイルは 3 台すべてに配る ②kubeadm-config ConfigMap にも書く(書かないと次のアップグレードで消えます)。kubeadm クラスタでは、ノード上のファイルは結果であって正本ではありません。

namespace にラベルがあればラベルが勝ち、無ければ AdmissionConfiguration の defaults が適用されるという判定の流れと、HA クラスタで設定を cp-01 だけに置いた場合と 3 台すべてに置いた場合の比較図。HAProxy の roundrobin により同じコマンドの結果が経路ごとに変わる before と、3 台とも Forbidden に揃う after を並べた図
図3:AdmissionConfiguration を 1 台だけに配るとポリシーは「たまに効く」状態になる

適用前に必ず済ませておくこと

enforce: baseline を既定にすると、前節の棚卸しで baseline を満たせなかった namespace(longhorn-system / metallb-system / monitoring / velero / local-path-storage)が baseline 判定になります。

既存 Pod はその瞬間には壊れません

(前の節のとおりです)。壊れるのは、再作成された瞬間です。 ノード再起動・DaemonSet の更新・Helm upgrade——いずれかが来たときに、csi-plugin や node-exporter が作られなくなります。

したがって、クラスタ既定を入れる前に、特権が必要な namespace へ明示ラベルを貼っておくのが必須の順序です。

exemptions.namespaces に並べるより、明示ラベルのほうが後から読めます(本書の推奨)。exemptions は API Server の設定ファイルの中にあるので、クラスタを触る人が kubectl からは見えません。 一方、kubectl get ns --show-labels は誰でも打てます。「なぜこの namespace は privileged なのか」を後任者が追える形にしておいてください。exemptions に書くのは kube-system のように「ラベルを貼る前に API Server が起動する必要があるもの」に限ります。

やってみよう④:3 台の Control Plane にクラスタ全体デフォルトを配る

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

公式コンピテンシー Use appropriate Pod Security Standards の中でも、クラスタレベルの適用は出題されうる形式です(kubernetes.io/docs の “Apply Pod Security Standards at the Cluster Level” が試験中に参照できます)。ただし試験は単一 Control Plane の可能性が高く、「3 台に配る」は本番固有の作法です。

対象は k8s-cp-01 / k8s-cp-02 / k8s-cp-03 です。本回で唯一、Control Plane に変更を加える演習になります。

開始前の確認

本演習は API Server の起動引数を変更します。設定を誤ると API Server が起動せず、kubectl が一切通らなくなります。 開始前に本回開始時点のスナップショットが取得済みであることを確認し、1 台ずつ順に実施して、そのつど kubectl get node が返ることを確かめてから次の台へ進んでください。3 台同時に触ってはいけません。

ステップ1:特権が必要な namespace へ明示ラベルを先に貼る

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

$ kubectl label ns longhorn-system metallb-system monitoring local-path-storage pod-security.kubernetes.io/enforce=privileged pod-security.kubernetes.io/enforce-version=latest --overwrite

実行結果:

namespace/longhorn-system labeled
namespace/metallb-system labeled
namespace/monitoring labeled
namespace/local-path-storage labeled

velerochart が既に privileged を貼っているので対象外にしています(やってみよう③ ステップ4 で確認しました)。kube-system は次のステップの exemptions で除外するので、ここでは貼りません。

ステップ2:3 台の Control Plane すべてに設定ファイルを置く

ここは 3 台分やってから次のステップへ進んでください。

設定ファイルが無いノードの API Server は起動に失敗します。 hostPathDirectoryOrCreate はディレクトリを作ってくれますが、中の YAML は作ってくれません。 cp-01 だけ作ってステップ4 に進むと、cp-02 / cp-03 で kubectl が通らなくなります。

実行コマンド(k8s-cp-01 / cp-02 / cp-03 それぞれの上・root):

# mkdir -p /etc/kubernetes/admission

実行結果:

(出力はありません)

mkdir -p は、すでにディレクトリがあっても何も言わずに成功します。3 台とも同じです。

同じく 3 台それぞれで、設定ファイルを heredoc で作成します。

実行コマンド(k8s-cp-01 / cp-02 / cp-03 それぞれの上・root):

# cat > /etc/kubernetes/admission/admission-config.yaml <<'EOF'
apiVersion: apiserver.config.k8s.io/v1
kind: AdmissionConfiguration
plugins:
  - name: PodSecurity
    configuration:
      apiVersion: pod-security.admission.config.k8s.io/v1
      kind: PodSecurityConfiguration
      defaults:
        enforce: "baseline"
        enforce-version: "latest"
        warn: "restricted"
        warn-version: "latest"
        audit: "restricted"
        audit-version: "latest"
      exemptions:
        usernames: []
        runtimeClasses: []
        namespaces: ["kube-system"]
EOF

<<'EOF' とシングルクォートで囲む理由

クォートを付けないと、シェルが中身の $ やバッククォートを展開します。 今回の YAML に $ は入っていませんが、第9回の EncryptionConfiguration(base64 の鍵)や第16回の Audit Policy では実害が出ます。 <<'EOF' は「中身を 1 文字も解釈せずそのまま書け」という指示なので、設定ファイルを heredoc で作るときは常にクォートを付けるのが安全です。

実行コマンド(3 台それぞれの上・root):

# ls -l /etc/kubernetes/admission/admission-config.yaml

実行結果(3 台とも同じ):

-rw-r--r--. 1 root root 512  7月 27 21:27 /etc/kubernetes/admission/admission-config.yaml

実行コマンド(3 台それぞれの上・root):

# cat /etc/kubernetes/admission/admission-config.yaml

3 台の内容が同じであることは md5sum で機械的に確かめられます。

実行コマンド(3 台それぞれの上・root):

# md5sum /etc/kubernetes/admission/admission-config.yaml

実行結果(3 台とも同じハッシュになること):

b16f534d91ec742f9988576b67a0ec57  /etc/kubernetes/admission/admission-config.yaml

ハッシュが 1 台でも違ったら、その 1 台だけポリシーが変わります。 見た目で確認せず、必ず機械で突き合わせてください。

ステップ3:kubeadm-config ConfigMap に登録する(正本を先に更新する)

編集方式は第5回で確立した作法をそのまま使います。バックアップを取ってから kubectl editapiServer: ブロックを全量置き換える、という手順です。kubectl patch は使いません(配列の一部だけを差し替えると既存の 5 フラグを壊しやすいためです)。

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

$ kubectl -n kube-system get cm kubeadm-config -o jsonpath='{.data.ClusterConfiguration}'

実行結果:

apiServer:
  extraArgs:
  - name: authentication-config
    value: /etc/kubernetes/auth/anonymous.yaml
  - name: kubelet-certificate-authority
    value: /etc/kubernetes/pki/ca.crt
  - name: profiling
    value: "false"
  - name: service-account-extend-token-expiration
    value: "false"
  - name: service-account-max-token-expiration
    value: 24h
  extraVolumes:
  - hostPath: /etc/kubernetes/auth
    mountPath: /etc/kubernetes/auth
    name: auth-config
    pathType: DirectoryOrCreate
    readOnly: true
apiVersion: kubeadm.k8s.io/v1beta4
caCertificateValidityPeriod: 87600h0m0s
certificateValidityPeriod: 8760h0m0s
certificatesDir: /etc/kubernetes/pki
clusterName: kubernetes
controlPlaneEndpoint: k8s-lb:6443
controllerManager: {}
dns: {}
encryptionAlgorithm: RSA-2048
etcd:
  local:
    dataDir: /var/lib/etcd
imageRepository: registry.k8s.io
kind: ClusterConfiguration
kubernetesVersion: v1.36.3
networking:
  dnsDomain: cluster.local
  podSubnet: 10.244.0.0/16
  serviceSubnet: 10.96.0.0/12
proxy: {}
scheduler: {}

apiServer.extraArgs第5回で足した 5 フラグextraVolumesauth-config の 1 つがあります。ここへ 1 フラグと 1 ボリュームを足すのが本ステップです。

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

$ kubectl get cm kubeadm-config -n kube-system -o yaml > ~/kubeadm-config.bak-08.yaml

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

$ kubectl edit cm kubeadm-config -n kube-system

apiServer: ブロックを、次の内容で丸ごと置き換えます。第5回の 5 フラグ + 1 extraVolume に、本回の 1 フラグ + 1 extraVolume を足したマージ後の全量です。

apiServer:
  extraArgs:
  - name: authentication-config
    value: /etc/kubernetes/auth/anonymous.yaml
  - name: kubelet-certificate-authority
    value: /etc/kubernetes/pki/ca.crt
  - name: profiling
    value: "false"
  - name: service-account-extend-token-expiration
    value: "false"
  - name: service-account-max-token-expiration
    value: 24h
  - name: admission-control-config-file
    value: /etc/kubernetes/admission/admission-config.yaml
  extraVolumes:
  - name: auth-config
    hostPath: /etc/kubernetes/auth
    mountPath: /etc/kubernetes/auth
    readOnly: true
    pathType: DirectoryOrCreate
  - name: admission-config
    hostPath: /etc/kubernetes/admission
    mountPath: /etc/kubernetes/admission
    readOnly: true
    pathType: DirectoryOrCreate

書式の注意点は第5回で説明したとおりです。extraArgskubeadm.k8s.io/v1beta4 から name / value のリスト形式で、フラグ名の先頭の -- は書きません。

保存すると configmap/kubeadm-config replaced と表示されます。もう一度読み出すと、追記した 1 フラグと 1 ボリュームが入っていることを確認できます。

実行結果(apiServer: ブロックの抜粋):

apiServer:
  extraArgs:
  - name: authentication-config
    value: /etc/kubernetes/auth/anonymous.yaml
  - name: kubelet-certificate-authority
    value: /etc/kubernetes/pki/ca.crt
  - name: profiling
    value: 'false'
  - name: service-account-extend-token-expiration
    value: 'false'
  - name: service-account-max-token-expiration
    value: 24h
  - name: admission-control-config-file
    value: /etc/kubernetes/admission/admission-config.yaml
  extraVolumes:
  - hostPath: /etc/kubernetes/auth
    mountPath: /etc/kubernetes/auth
    name: auth-config
    pathType: DirectoryOrCreate
    readOnly: true
  - hostPath: /etc/kubernetes/admission
    mountPath: /etc/kubernetes/admission
    name: admission-config
    pathType: DirectoryOrCreate
    readOnly: true

ここで ConfigMap を直しても、動いている API Server は変わりません。 ConfigMap は「次に kubeadm がマニフェストを作り直すときの材料」です。いま効かせるには、次のステップで 3 台のマニフェストを直接書き換えます。

なぜ ConfigMap を先に直すのか(第5回の再確認)

kubeadm upgrade apply/etc/kubernetes/manifests/kube-apiserver.yaml を作り直します。 作り直す元は kubeadm-config ConfigMap であって、いま動いているマニフェストではありません。ConfigMap に書いていないフラグは、次のアップグレードで静かに消えます。 「ノード上のファイルは結果であって正本ではない」——第5回で確立した原則がそのまま効きます。

ステップ4:3 台の static Pod マニフェストへ反映する

編集前に必ずバックアップを取ってください。

本ステップは API Server の起動引数を書き換えます。 YAML のインデントを 1 つ間違えると API Server が起動せず、kubectl が一切通らなくなります。 第2回・第5回と同じ 3 点セット(フラグ + volumeMounts + volumes)と、編集前バックアップの作法をそのまま使ってください。

実行コマンド(各 Control Plane Node 上・developer・編集する前に必ず):

$ sudo cp -p /etc/kubernetes/manifests/kube-apiserver.yaml /root/kube-apiserver.yaml.bak-08

実行結果(成功すると何も表示されません):

(出力はありません)

実行コマンド(各 Control Plane Node 上・developer):

$ sudo ls -l /root/kube-apiserver.yaml.bak-08

実行結果(cp-01 の例):

-rw-------. 1 root root 4788  7月 26 22:03 /root/kube-apiserver.yaml.bak-08

置けたことを確かめてから次へ進みます。cp -p-p更新時刻とパーミッションを保つオプションです。

退避先を /etc/kubernetes/manifests/ の中にしないこと。

このディレクトリは kubelet が監視しており、.bak を置くと「もう 1 つの static Pod の定義」として読もうとします。 ポートが衝突して、意図しない Pod が作られたり CrashLoop したりします。必ずディレクトリの外(本書は /root/)へ退避してください。

戻し方: sudo cp -p /root/kube-apiserver.yaml.bak-08 /etc/kubernetes/manifests/kube-apiserver.yaml と書き戻すと、kubelet がファイルの変更を検知して API Server を作り直します(30 秒程度)。systemctl の操作は不要です。

各 Control Plane Node で /etc/kubernetes/manifests/kube-apiserver.yaml に次の 3 箇所を足します。

command に追加する 1 行:

    - --admission-control-config-file=/etc/kubernetes/admission/admission-config.yaml

volumeMounts に追加する 1 項目:

    - mountPath: /etc/kubernetes/admission
      name: admission-config
      readOnly: true

volumes に追加する 1 項目:

  - hostPath:
      path: /etc/kubernetes/admission
      type: DirectoryOrCreate
    name: admission-config

実行コマンド(各 Control Plane Node 上・root・1 台ずつ):

# crictl ps --name kube-apiserver

実行結果(cp-01 の例):

CONTAINER           IMAGE               CREATED             STATE               NAME                ATTEMPT             POD ID              POD                        NAMESPACE
646eb586cb754       3400718b4dd57       2 seconds ago       Running             kube-apiserver      0                   d6db5ead2f457       kube-apiserver-k8s-cp-01   kube-system

CREATED が数秒前になっていれば、作り直された新しいコンテナです。ATTEMPT0 のままなのは、1 回で起動できた証拠です。ここが増えていく場合は起動に失敗して再試行しています。

実行コマンド(各 Control Plane Node 上・root・1 台ずつ):

# curl -sk https://127.0.0.1:6443/livez

実行結果(3 台とも):

ok

本書ラボの実測では、マニフェストを保存してから /livezok を返すまで 42〜45 秒でした(3 台の実測値)。

再起動まで 30 秒程度かかります。crictl ps に出てこない間は待ってください。 /etc/kubernetes/admission/ は 3 台すべてに作られている必要があります(hostPathDirectoryOrCreate でディレクトリは作られますが、中の YAML は作られません)。

ステップ5:3 台それぞれに直接当てて、同じ結果が返ることを確かめる

ここで --overrides を使います。kubectl run が作る Pod の spec に JSON で書いた内容を上書き・追加するオプションで、マニフェストファイルを作らずに 1 行で「hostPath を持つ Pod」を作れるので、ポリシーの動作確認に向いています。

実行コマンド(k8s-ops 上・developer・<CP の IP>192.168.1.125 / 192.168.1.126 / 192.168.1.127):

$ kubectl --server=https://<CP の IP>:6443 --insecure-skip-tls-verify -n default run hostpath-probe --image=busybox:1.37 --overrides='{"spec":{"volumes":[{"name":"h","hostPath":{"path":"/etc"}}]}}' --command -- sleep 3600

実行結果(設定済みの Control Plane を経由した場合):

Error from server (Forbidden): pods "hostpath-probe" is forbidden: violates PodSecurity "baseline:latest": hostPath volumes (volume "h")

3 台すべてで同じ拒否メッセージが返れば合格です。本書ラボでは 192.168.1.125 / 192.168.1.126 / 192.168.1.127 の 3 台とも、上と 1 文字も違わない出力になりました。

--server で IP を直接指定しているので --insecure-skip-tls-verify を併用します(証明書は k8s-lb 向けに発行されているため、IP 直指定では名前が一致しません)。

ステップ6:ラベルの無い namespace で warn の既定も効くことを確かめる

default namespace には PSA ラベルが 1 つもありません。ラベルが無いので既定値(enforce: baseline / warn: restricted)が適用されます。

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

$ kubectl -n default run warn-probe --image=busybox:1.37 --command -- sleep 3600

実行結果:

Warning: would violate PodSecurity "restricted:latest": allowPrivilegeEscalation != false (container "warn-probe" must set securityContext.allowPrivilegeEscalation=false), unrestricted capabilities (container "warn-probe" must set securityContext.capabilities.drop=["ALL"]), runAsNonRoot != true (pod or container "warn-probe" must set securityContext.runAsNonRoot=true), seccompProfile (pod or container "warn-probe" must set securityContext.seccompProfile.type to "RuntimeDefault" or "Localhost")
pod/warn-probe created

Restricted 違反の警告が出たうえで、Pod は作られています。 既定の enforcebaseline なので、hostPath を使っていないこの Pod は止められません止めるのは baseline まで、restricted 違反は知らせるだけ——設計どおりの挙動です。

実行コマンド(確認できたら削除します):

$ kubectl -n default delete pod warn-probe

実行結果:

pod "warn-probe" deleted from default namespace

ステップ7:kube-system の除外が効いていることを確かめる

kube-systemexemptions.namespaces に書いたので、既定の baseline が適用されません。ここでは 実際には作らず --dry-run=server で確かめます(kube-system に不要な Pod を残さないため)。

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

$ kubectl -n kube-system run hostpath-probe-ks --image=busybox:1.37 --dry-run=server --overrides='{"spec":{"volumes":[{"name":"h","hostPath":{"path":"/etc"}}]}}' --command -- sleep 3600

実行結果:

pod/hostpath-probe-ks created (server dry run)

同じ hostPath Pod が、default では拒否され kube-system では通りました。 exemptions が効いている証拠です。Control Plane の static Pod は privileged で hostPath を使うので、ここを外さないとクラスタが自分を作り直せなくなります。

ステップ8:後片付け(必ず実施する)

  1. 3 台の /etc/kubernetes/manifests/kube-apiserver.yaml から追加した 3 箇所を削除し、API Server の復帰を 1 台ずつ確認する
  2. kubeadm-config ConfigMap から追加した 2 項目(admission-control-config-fileadmission-config の extraVolume)を削除する
  3. hostpath-probe を削除する
  4. ステップ1 で貼った privileged ラベルは残します(次回以降の環境として妥当なため)

後片付け後の確認(k8s-ops 上・developer):

$ kubectl get nodes

実行結果:

NAME        STATUS   ROLES           AGE    VERSION
k8s-cp-01   Ready    control-plane   2d1h   v1.36.3
k8s-cp-02   Ready    control-plane   47h    v1.36.3
k8s-cp-03   Ready    control-plane   47h    v1.36.3
k8s-wl-01   Ready    <none>          47h    v1.36.3
k8s-wl-02   Ready    <none>          47h    v1.36.3

本書ラボでは 5 ノード Ready・98 Pod で異常 0・Longhorn 4 ボリューム attached healthyhttps://fanclub.local/ が 200・ArgoCD Synced / Healthy に戻りました。マニフェストを書き戻してから /livezok を返すまでは 1 台あたり数十秒です。1 台ずつ戻し、ok を確認してから次の 1 台へ進んでください。

本番ではこの演習をそのまま使えます。

違うのは「後片付けをしない」ことだけです。クラスタ既定を入れるのは 1 回きりの作業で、以後は namespace のラベルだけで運用が回ります。ただし kubeadm でアップグレードするたびに kube-apiserver.yaml は再生成されるので、kubeadm-config ConfigMap への登録(ステップ3)を省略するとアップグレード後に静かに消えます。

誰が Pod spec の正本を持っているか — Helm と ArgoCD の二重管理

fanclub namespace の 5 つのワークロードは、2 つの経路で管理されています。fanclub を Restricted 化する前に、どこを直せば反映されるのかを確定させます。

リソースHelm リリース fanclubArgoCD fanclub-api-prod実際に編集すべき場所
Deployment fanclub-backendGit リポジトリ ~/gitops/gitops-fanclub/base/backend-deployment.yaml
Deployment fanclub-frontendGit リポジトリ ~/gitops/gitops-fanclub/base/frontend-deployment.yaml
StatefulSet fanclub-dbHelm chart ~/fanclub-chart/templates/db-statefulset.yaml
DaemonSet fanclub-logcollectorHelm chart ~/fanclub-chart/templates/daemonset.yaml
CronJob fanclub-reportHelm chart ~/fanclub-chart/templates/cronjob.yaml

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

$ kubectl -n fanclub get deploy,sts,ds,cronjob -o custom-columns='KIND:.kind,NAME:.metadata.name,HELM:.metadata.annotations.meta\.helm\.sh/release-name,ARGOCD:.metadata.annotations.argocd\.argoproj\.io/tracking-id'

実行結果:

KIND          NAME                   HELM      ARGOCD
Deployment    fanclub-backend        fanclub   fanclub-api-prod:apps/Deployment:fanclub/fanclub-backend
Deployment    fanclub-frontend       fanclub   fanclub-api-prod:apps/Deployment:fanclub/fanclub-frontend
StatefulSet   fanclub-db             fanclub   <none>
DaemonSet     fanclub-logcollector   fanclub   <none>
CronJob       fanclub-report         fanclub   <none>

backend と frontend だけが 2 つの annotation を持っています。 Helm が作り、ArgoCD が後から採用した(adopt した)——第2巻第14回で GitOps 化したときの経緯がそのまま残っています。

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

$ kubectl -n argocd get application fanclub-api-prod -o jsonpath='{.spec.source.repoURL}{"\n"}{.spec.source.path}{"\n"}{.spec.syncPolicy}'

実行結果:

{"path":"overlays/prod","repoURL":"http://k8s-registry:3000/developer/gitops-fanclub.git","targetRevision":"main"}
{"automated":{"prune":true,"selfHeal":true}}

selfHeal: true なので、kubectl edit で backend / frontend を直しても数十秒で Git の内容に戻されます。直す先は Git です。

selfHeal: true の意味

ArgoCD は Git の内容とクラスタの状態が食い違うと、Git 側へ戻します。 backend / frontend を kubectl edit で直しても、数十秒後には元に戻ります(第6回の実測で約 80 秒)。

これは ArgoCD の不具合ではなく設計です。 「クラスタの正本は Git である」という GitOps の前提を、selfHeal が実行しています。ハードニングの変更も Git に入れなければ、恒久化しません。

本番でハードニングが「反映されなかった」という報告の多くは、この構造の見落としです。 手を入れる前に 「このリソースの正本はどこか」 を確認してください。確認するコマンドは上の 2 つです。

Git の base/ には 6 ファイルしかありません(backend-deployment / frontend-deployment / configmap / services / resourcequota / kustomization)。db / logcollector / CronJob は Git に無いので、Helm chart 側を直して helm upgrade します。同じ namespace の中で、リソースごとに手順が変わります。

Git と chart にはすでに乖離があります。

第6回は Git リポジトリの base/backend-deployment.yaml にだけ Pod レベルの seccompProfile: RuntimeDefault を足しました。~/fanclub-chart/templates/backend-deployment.yamlその変更を持っていません。

したがって、この状態のまま helm upgrade を打つと backend の Pod レベル seccomp が一度消え、ArgoCD の selfHeal が数十秒かけて戻します。 一時的とはいえ、ハードニングが外れる時間が生まれます。次の演習では ①Git を直して ArgoCD の sync 完了を待つ → ②chart 側も同じ内容へそろえる → ③helm upgrade → ④巻き戻っていないことを確認という順序で進めます。②を飛ばすと、この一時的な巻き戻りが起きます。

fanclub を Restricted へ上げる実測レシピ

「Restricted にする」を、5 つのワークロードそれぞれの具体で示します。実測でしか分からない落とし穴が 3 つ/var/runfsGroup・initContainer)含まれます。

現状の securityContext は次のとおりです。第6回が触ったのは backend の Deployment だけで、他の 4 つは空です。

ワークロードPod レベルコンテナレベル
fanclub-backendseccompProfile: RuntimeDefault(第6回で追加)backend のみ runAsNonRoot: true / runAsUser: 10001 / readOnlyRootFilesystem: true / allowPrivilegeEscalation: false / capabilities.drop: ["ALL"]
↑ initContainer wait-for-dbbusybox:1.37
↑ initContainer log-shipperbusybox:1.37
fanclub-frontend(uid 0 で稼働)
fanclub-db(uid 0 で稼働)
fanclub-logcollector(uid 0)
fanclub-report(CronJob)

ここで書く securityContext は、第12回で扱う静的解析(Kubesec / KubeLinter)が kubectl apply する前に検査する対象でもあります。本回は「弾かれてから直す」、第12回は「apply する前に気づく」——同じフィールドを別の角度から見ることになります。

frontend(nginx)— /var/run の emptyDir が要る。NET_BIND_SERVICE は要らない

fanclub-frontendnginx を uid 0 で動かしておりnginx.confuser nginx; / listen 80 が書かれています。Restricted 準拠にするために足すものは次のとおりです(実測で Running・uid 101・:80 応答を確認済み)。

      securityContext:
        seccompProfile:
          type: RuntimeDefault
      containers:
        - name: frontend
          securityContext:
            runAsNonRoot: true
            runAsUser: 101
            allowPrivilegeEscalation: false
            capabilities:
              drop: ["ALL"]
          volumeMounts:
            - name: nginx-cache
              mountPath: /var/cache/nginx
            - name: nginx-run
              mountPath: /var/run
      volumes:
        - name: nginx-cache
          emptyDir: {}
        - name: nginx-run
          emptyDir: {}

マージ後の ~/gitops/gitops-fanclub/base/frontend-deployment.yaml の全量:

---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: fanclub-frontend
  labels:
    app: fanclub-frontend
spec:
  replicas: 1
  selector:
    matchLabels:
      app: fanclub-frontend
  template:
    metadata:
      labels:
        app: fanclub-frontend
    spec:
      securityContext:
        seccompProfile:
          type: RuntimeDefault
      containers:
      - name: frontend
        image: k8s-registry:5000/fanclub-frontend:1.0.0
        ports:
        - containerPort: 80
        securityContext:
          runAsNonRoot: true
          runAsUser: 101
          allowPrivilegeEscalation: false
          capabilities:
            drop: ["ALL"]
        resources:
          requests:
            memory: "32Mi"
            cpu: "50m"
          limits:
            memory: "128Mi"
            cpu: "200m"
        volumeMounts:
        - name: config
          mountPath: /usr/share/nginx/html/config.js
          subPath: config.js
        - name: nginx-cache
          mountPath: /var/cache/nginx
        - name: nginx-run
          mountPath: /var/run
      volumes:
      - name: config
        configMap:
          name: fanclub-frontend-config
      - name: nginx-cache
        emptyDir: {}
      - name: nginx-run
        emptyDir: {}

replicas: 1 は base の値で、prod overlay が 2 に patch します(第2巻第14回で作った構成)。ここを 2 に書き換える必要はありません。

試した構成結果
上記のままRunningiduid=101(nginx)wget http://127.0.0.1/ が HTML を返す
capabilities.add: [NET_BIND_SERVICE]外す問題なく Running
/var/run の emptyDir を外すErrornginx: [emerg] open() "/run/nginx.pid" failed (13: Permission denied)
/var/cache/nginx の emptyDir起動には必須ではないが、リクエスト処理時の書き込みに効く。両方入れる

NET_BIND_SERVICE が要らない理由(実測で確かめられます)

Pod の中で /proc/sys/net/ipv4/ip_unprivileged_port_start を読むと 0 が返ります。この値より小さいポートだけが特権ポート扱いなので、0 ならすべてのポートを非 root で bind できます。 Kubernetes は Pod のネットワーク名前空間でこの sysctl を 0 に設定しており、コンテナの中では「1024 番未満は root だけ」という Linux の常識が成立しません。

ただし Restricted の規則としては、NET_BIND_SERVICE だけが capabilities.add に許される唯一の値であることを覚えておいてください(試験対象です)。

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

$ kubectl -n fanclub exec deploy/fanclub-frontend -- cat /proc/sys/net/ipv4/ip_unprivileged_port_start

実行結果:

0

実行ユーザも同じ形で確認できますが、いまここで打つと uid=0(root) が返ります(frontend はまだ直していません)。やってみよう⑤ を終えたあとに打つと、次のように変わります。

コマンド(やってみよう⑤ ステップ6 で打ちます):

$ kubectl -n fanclub exec deploy/fanclub-frontend -- id

そのときの実行結果:

uid=101(nginx) gid=101(nginx) groups=101(nginx)

起動ログの警告は無害です。[warn] the "user" directive makes sense only if the master process runs with super-user privileges, ignored in /etc/nginx/nginx.conf:2 が出ますが、user nginx; の行が無視されるだけです。すでに uid 101 で動いているので、変更する相手がいません。

db(PostgreSQL 18)— fsGroup: 999 が無いと initdb が失敗する

fanclub-db は StatefulSet で、PGDATA=/var/lib/postgresql/data/pgdata・Longhorn の PVC をマウントしています。

構成結果
runAsUser: 999 / runAsGroup: 999 のみ(新規 PVC)Error: mkdir: cannot create directory '/var/lib/postgresql/data/pgdata': Permission denied
fsGroup: 999Running(initdb 完走 → database system is ready to accept connections)。iduid=999(postgres)・マウント先が drwxrwsr-x root postgres になる

Restricted 準拠の securityContext(Pod レベル):

      securityContext:
        runAsNonRoot: true
        runAsUser: 999
        runAsGroup: 999
        fsGroup: 999
        seccompProfile:
          type: RuntimeDefault

コンテナレベル:

          securityContext:
            allowPrivilegeEscalation: false
            capabilities:
              drop: ["ALL"]

マージ後の ~/fanclub-chart/templates/db-statefulset.yaml の全量:

apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: fanclub-db
  namespace: {{ .Values.namespace }}
spec:
  serviceName: fanclub-db
  replicas: 1
  selector:
    matchLabels:
      app: fanclub-db
  template:
    metadata:
      labels:
        app: fanclub-db
    spec:
      securityContext:
        runAsNonRoot: true
        runAsUser: 999
        runAsGroup: 999
        fsGroup: 999
        seccompProfile:
          type: RuntimeDefault
      containers:
      - name: postgres
        image: {{ .Values.db.image.repository }}:{{ .Values.db.image.tag }}
        ports:
        - containerPort: 5432
        securityContext:
          allowPrivilegeEscalation: false
          capabilities:
            drop: ["ALL"]
        env:
        - name: POSTGRES_DB
          value: fanclubdb
        - name: POSTGRES_USER
          value: appuser
        - name: POSTGRES_PASSWORD
          value: apppassword
        - name: PGDATA
          value: /var/lib/postgresql/data/pgdata
        volumeMounts:
        - name: data
          mountPath: /var/lib/postgresql/data
        - name: initdb
          mountPath: /docker-entrypoint-initdb.d
        resources:
          requests:
            memory: "128Mi"
            cpu: "100m"
          limits:
            memory: "512Mi"
            cpu: "500m"
      volumes:
      - name: initdb
        configMap:
          name: fanclub-db-init
  volumeClaimTemplates:
  - metadata:
      name: data
    spec:
      accessModes: ["ReadWriteOnce"]
      storageClassName: {{ .Values.db.storageClass }}
      resources:
        requests:
          storage: {{ .Values.db.storage }}

本書ラボの既存 PVC は pgdata の所有者がすでに 999 なので、fsGroup が無くてもファイルシステムとしては動きます。それでも書くのは、読者が新規に構築したときに再現するためです。なお PSS の判定としては runAsNonRoot: true が別途必須で、runAsUser を非 0 にするだけでは Restricted を満たしません(拒否メッセージの runAsNonRoot != true がそれです)。

fsGroup は Restricted の要件ではありません。

Restricted が要求するのは runAsNonRoot などの項目で、fsGroup はそこに含まれていません。それでも書かないと動きません。

fsGroup は「マウントしたボリュームの所有グループを指定の GID にし、setgid ビットを立てる」指示です。これが無いと、ボリュームは root:root のままマウントされ、uid 999 で動く postgres がディレクトリを作れません。

既存の fanclub-db-0 は pgdata の所有者が既に 999 なので、runAsUser: 999 だけでも動く見込みです。 それでも fsGroup: 999 を書くのは、読者が新規にクラスタを作り直したときにも再現するためです。「うちでは動いた」で済ませない書き方が、手順書の価値を決めます。

fsGroup のコストと、StatefulSet を触るときの覚悟

fsGroupマウントのたびにボリューム全体を再帰的に舐めて所有権を書き換えます。 データが数百 GB あると起動が目に見えて遅くなります。fsGroupChangePolicy: OnRootMismatch を指定すると、ルートディレクトリの所有権が期待どおりなら再帰処理を省略します。本書ラボはデータが小さいので既定のままにしますが、本番のデータベースでは検討対象です。

もう 1 点。StatefulSet の securityContext を変更すると Pod テンプレートが変わるので、DB が一度落ちて作り直されます。 fanclub-db はレプリカ 1 なので、その間 /api/members は応答しません。本番では計画停止として扱ってください。

backend — 足りないのは initContainer 2 本

backend の Deployment には、第6回で入れた Pod レベルの seccompProfile: RuntimeDefault と、backend コンテナの securityContextrunAsNonRoot: true / runAsUser: 10001 / readOnlyRootFilesystem: true / allowPrivilegeEscalation: false / capabilities.drop: ["ALL"])が既にあります。

足りないのは initContainers 2 本のコンテナレベル指定です(実測で両方とも空)。

initContainerイメージ性質
wait-for-dbbusybox:1.37DB の待ち合わせ。起動して終了する通常の init
log-shipperbusybox:1.37restartPolicy: Always = ネイティブサイドカー。emptyDir applog を backend と共有して tail -F し続ける

ネイティブサイドカーは、initContainersrestartPolicy: Always を付けたコンテナのことです。init として先に起動し、本体と並走し続けます(v1.29 で安定化しました)。

両方に足す securityContext:

          securityContext:
            runAsNonRoot: true
            runAsUser: 10001
            allowPrivilegeEscalation: false
            capabilities:
              drop: ["ALL"]

log-shipper に uid 10001 を指定する理由は、backend(uid 10001)が emptyDir applog へ書いたファイルを tail -F するからです。同じ uid でないと読めません。 「非 root にする」だけでは足りず、どの uid にするかがボリューム共有の成否を決めます。

ネイティブサイドカーも PSS の評価対象です。

initContainers に置かれていても、PSA は containers / initContainers / ephemeralContainers の 3 種類すべてを評価します。 「init だから除外される」ということはありません。

これは後半で扱う CEL 式が initContainers を取りこぼす話と裏表の関係にあります。 組込の PSA は取りこぼさず、自作のポリシーは取りこぼす。 「自分で書くほうが柔軟」の裏側に、「自分で書くと抜ける」が張り付いています。

マージ後の ~/gitops/gitops-fanclub/base/backend-deployment.yaml のうち、initContainers の部分:

      securityContext:
        seccompProfile:
          type: RuntimeDefault
      initContainers:
      - name: wait-for-db
        image: busybox:1.37
        command: ["sh","-c","until nslookup fanclub-db.fanclub.svc.cluster.local >/dev/null 2>&1; do echo waiting; sleep 2; done; echo resolved"]
        securityContext:
          runAsNonRoot: true
          runAsUser: 10001
          allowPrivilegeEscalation: false
          capabilities:
            drop: ["ALL"]
      - name: log-shipper
        image: busybox:1.37
        restartPolicy: Always
        command: ["sh","-c","echo sidecar; tail -F /var/log/app/app.log 2>/dev/null || sleep infinity"]
        securityContext:
          runAsNonRoot: true
          runAsUser: 10001
          allowPrivilegeEscalation: false
          capabilities:
            drop: ["ALL"]
        volumeMounts:
        - name: applog
          mountPath: /var/log/app

Pod レベルの securityContextseccompProfile)は第6回で入れたものがそのまま残ります。足すのは initContainers 2 本のブロックだけです。containersbackend は第1巻第15回の指定がそのまま Restricted を満たしています。

logcollector と CronJob — 制約が緩い 2 つ

ワークロード性質必要なもの
DaemonSet fanclub-logcollectorボリューム無しsleep のループを実行するだけ任意の非 root uid で動く
CronJob fanclub-reportpostgres:18psql を叩くだけ(データディレクトリに触らない)・restartPolicy: OnFailure任意の非 root uid で動く

DaemonSet fanclub-logcollector に足すもの(Pod レベルとコンテナレベル):

    spec:
      securityContext:
        runAsNonRoot: true
        runAsUser: 10001
        seccompProfile:
          type: RuntimeDefault
      containers:
      - name: logcollector
        image: busybox:1.37
        command:
        - sh
        - -c
        - 'echo "log collector started on $(hostname)"; while true; do sleep 3600; done'
        securityContext:
          allowPrivilegeEscalation: false
          capabilities:
            drop: ["ALL"]

CronJob fanclub-report に足すもの:

        spec:
          restartPolicy: OnFailure
          securityContext:
            runAsNonRoot: true
            runAsUser: 999
            runAsGroup: 999
            seccompProfile:
              type: RuntimeDefault
          containers:
          - name: report
            image: {{ .Values.db.image.repository }}:{{ .Values.db.image.tag }}
            securityContext:
              allowPrivilegeEscalation: false
              capabilities:
                drop: ["ALL"]

CronJob の uid を 999 にしているのは、イメージが postgres:18 だからです(psql を実行するだけなので他の uid でも動きますが、イメージに存在するユーザに合わせておくほうが素直です)。

見落としやすい 6 つ目 —— Helm フックの Job

~/fanclub-chart/templates/db-migrate-job.yaml を忘れると、次の helm upgrade が失敗します。

この Job は helm.sh/hook: post-install,post-upgrade が付いた Helm フックで、kubectl get job にも普段は現れません(hook-delete-policy で成功後に消えるため)。しかし upgrade のたびに Pod を作ります。

「Pod を作るもの」を数え上げるとき、Deployment / StatefulSet / DaemonSet / CronJob だけでは足りません。 Job・Helm フック・Operator が動的に作る Pod も PSA の対象です。本書ラボでは、この 1 ファイルを直し忘れると enforce 後の helm upgrade がフックの Pod 作成で止まります。

Helm フックの Job に足すもの:

    spec:
      restartPolicy: Never
      securityContext:
        runAsNonRoot: true
        runAsUser: 999
        runAsGroup: 999
        seccompProfile:
          type: RuntimeDefault
      containers:
      - name: migrate
        image: {{ .Values.db.image.repository }}:{{ .Values.db.image.tag }}
        securityContext:
          allowPrivilegeEscalation: false
          capabilities:
            drop: ["ALL"]

DaemonSet であることに注意してください。

fanclub-logcollector は 2 つの Workload Node で動くので、Restricted 違反があると 2 Pod とも作られなくなります。 DaemonSet は「Pod が減った」ことが Deployment より見えにくいので、kubectl get dsDESIREDREADY を必ず突き合わせてください。

CronJob は次の起動時刻まで壊れたことが分かりません。 fanclub-report の失敗は Job の Events に出ます。kubectl -n fanclub get jobCOMPLETIONS 0/1 のまま残っている Job があれば、PSA に弾かれた可能性を疑ってください第16回の監査ログでも同じ拒否が追えます)。

やってみよう⑤:fanclub を Restricted 化して enforce を貼る

所要 25 分(うち待ち時間が 12 分ほど)。

公式コンピテンシー Use appropriate Pod Security Standards の中心にあたる内容です。試験では 「この Deployment を Restricted 準拠に直せ」 という形が想定できます。

ただし、この演習の所要時間をそのまま試験の目安にしないでください。 ステップ2 だけで ArgoCD の同期 76 秒 + backend のローリング更新 約 10 分 の待ちがあり、Git へ commit / push して ArgoCD 経由で反映する経路も、helm upgrade も、試験には存在しません。 これらは本番運用の作法として通しています。

試験で同じ課題を解くなら 1 本道です(6〜8 分の想定)。

$ k -n fanclub get deploy fanclub-frontend -o yaml > d.yaml
$ vi d.yaml
$ k apply -f d.yaml
$ k -n fanclub get pod -l app=fanclub-frontend

直すべきフィールドは、拒否メッセージがそのまま教えてくれます。 新しく書くときは k run tmp --image=nginx:1.27-alpine --dry-run=client -o yaml > p.yaml で骨格を出してから securityContext を足すのが速い書き方です。本演習で身につけるのは「どのフィールドを書けば通るか」であって、経路の長さではありません。

対象は k8s-ops 上の Git リポジトリと Helm chart です。

ステップ1:正本を確認する

前の節の 2 コマンド(custom-columns による annotation の確認と、ArgoCD Application の jsonpath)を実行し、backend / frontend は Git、db / logcollector / CronJob は Helm chart であることを確かめます。

ステップ2:Git 側(backend / frontend)を直す

~/gitops/gitops-fanclub/base/backend-deployment.yamlfrontend-deployment.yaml に、前の節の securityContext と emptyDir を足して commit / push します。

最初に git の名前とメールアドレスを設定してください。

k8s-ops の developer には ~/.gitconfig がありません。設定せずに git commit を打つと Author identity unknown と表示されて失敗します。

実行コマンド(k8s-ops 上・developer・初回のみ):

$ git config --global user.name "developer"
$ git config --global user.email "developer@example.com"

実行コマンド(k8s-ops 上・developer・~/gitops/gitops-fanclub に移動してから):

$ export no_proxy="$no_proxy,k8s-registry"

no_proxyk8s-registry を足さないと Gitea への push が Squid 経由になり 403 で止まります(第6回で確定した作法です)。

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

$ git add base/backend-deployment.yaml base/frontend-deployment.yaml
$ git commit -m "Apply PSS Restricted securityContext to backend initContainers and frontend"
$ git push origin main

実行結果:

[main 77114fe] Apply PSS Restricted securityContext to backend initContainers and frontend
 2 files changed, 29 insertions(+)
remote: . Processing 1 references
remote: Processed 1 references in total
To http://k8s-registry:3000/developer/gitops-fanclub.git
   9b487a9..77114fe  main -> main

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

$ kubectl -n argocd get application fanclub-api-prod

実行結果(同期が終わったあと):

NAME               SYNC STATUS   HEALTH STATUS
fanclub-api-prod   Synced        Healthy

本書ラボでは push から frontend の runAsUser: 101 がクラスタに現れるまで 76 秒でした(ArgoCD の既定ポーリング間隔)。backend はローリング更新に時間がかかり、3 Pod が 2/2 に揃うまで約 10 分です(Payara の起動時間 + maxUnavailable: 0)。

実行コマンド(完了まで待つ場合・k8s-ops 上・developer):

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

実行結果:

deployment "fanclub-backend" successfully rolled out

次のステップへ進む前に、ここで Synced / Healthy になるのを待ってください。 順序には理由があります(このあとのガードレール枠で説明します)。

ステップ3:Helm chart 側(db / logcollector / CronJob)を直す

~/fanclub-chart/templates/ の 3 ファイル(db-statefulset.yaml / daemonset.yaml / cronjob.yaml)に、前の節の securityContext を足します。

あわせて ~/fanclub-chart/templates/backend-deployment.yamlfrontend-deployment.yaml も、Git 側と同じ内容へそろえます。

chart 側をそろえずに helm upgrade を打つとどうなるか

chart の backend-deployment.yaml は第6回の変更(Pod レベルの seccompProfile)を持っていません。そのまま helm upgrade を打つと、backend の Pod レベル seccomp が一度消えます。 ArgoCD の selfHeal が数十秒かけて戻すので最終状態は同じですが、その数十秒間はハードニングが外れています。

「同じリソースを 2 つの経路が管理している」ときは、両方を同じ内容にそろえてから片方を動かす——これが二重管理を安全に扱う順序です。本演習では、その順序を守って壊さずに進めます。

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

$ cd ~

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

$ helm upgrade fanclub ./fanclub-chart --namespace fanclub --set gateway.enabled=true --set frontend.image.tag=1.0.0 --set backend.replicaCount=3 --set frontend.replicaCount=2

実行結果(この形では失敗します):

Error: UPGRADE FAILED: conflict occurred while applying object fanclub/allow-frontend-from-gateway networking.k8s.io/v1, Kind=NetworkPolicy: Apply failed with 1 conflict: conflict with "kubectl-client-side-apply" using networking.k8s.io/v1: .spec.ingress && conflict occurred while applying object fanclub/fanclub-quota /v1, Kind=ResourceQuota: Apply failed with 6 conflicts: conflicts with "argocd-controller" using v1:
- .spec.hard.limits.cpu
- .spec.hard.limits.memory
- .spec.hard.persistentvolumeclaims
- .spec.hard.pods
- .spec.hard.requests.cpu
- .spec.hard.requests.memory

これが二重管理の正体です。

Helm v4 は Server-Side Apply でリソースを適用します。Server-Side Apply はフィールドごとに「誰が書いたか」(field manager)を記録しており、別の管理者が書いたフィールドを黙って上書きしません。

エラーが名指ししているのは 2 人です。argocd-controller(ResourceQuota の 6 フィールド)と kubectl-client-side-apply(NetworkPolicy の .spec.ingress)。「二重管理」という抽象的な話が、field manager 名という具体的な形で画面に出てきます。

しかも upgrade は途中まで適用されています。 本書ラボでは StatefulSet と DaemonSet に新しい securityContext が入り、db-0 と logcollector が作り直された状態で失敗しました。helm listSTATUSfailed になります。「失敗したから何も起きていない」ではありません。

衝突を承知で Helm に書かせるには --force-conflicts を付けます。

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

$ helm upgrade fanclub ./fanclub-chart --namespace fanclub --set gateway.enabled=true --set frontend.image.tag=1.0.0 --set backend.replicaCount=3 --set frontend.replicaCount=2 --force-conflicts

実行結果:

Release "fanclub" has been upgraded. Happy Helming!
NAME: fanclub
LAST DEPLOYED: Mon Jul 27 22:00:46 2026
NAMESPACE: fanclub
STATUS: deployed
REVISION: 7
DESCRIPTION: Upgrade complete
TEST SUITE: None

--force-conflicts は「他人が書いたフィールドを奪う」オプションです。

本書ラボでは chart と Git の該当フィールドの値が一致しているので実害はありません(fanclub-quota は両方とも limits.cpu: 8 ほかで同じ値です)。

値が違う場合は、Helm が書いた直後に ArgoCD の selfHeal が書き戻し、両者が押し合い続けます。 本番で --force-conflicts を使う前に、奪おうとしているフィールドの値が両方で一致しているかを必ず確かめてください。根本の解決は 「1 つのリソースを 1 つの経路だけが管理する」 ように整理することです。

レプリカ数を明示している理由

helm get values fanclub -n fanclub の USER-SUPPLIED VALUES は frontend.image.taggateway.enabled の 2 つだけです。一方、chart の既定は backend 1 / frontend 1 で、ArgoCD の prod overlay が backend 3 / frontend 2 に patch しています。素の helm upgrade を打つとレプリカ数まで既定値へ戻り、selfHeal が直すまでの間ワークロードが縮みます。overlay と同じ値を明示するのが、二重管理下での helm upgrade の作法です。

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

$ kubectl -n fanclub get deploy,sts,ds

実行結果:

NAME                               READY   UP-TO-DATE   AVAILABLE   AGE
deployment.apps/fanclub-backend    3/3     3            3           43h
deployment.apps/fanclub-frontend   2/2     2            2           43h

NAME                          READY   AGE
statefulset.apps/fanclub-db   1/1     43h

NAME                                  DESIRED   CURRENT   READY   UP-TO-DATE   AVAILABLE   NODE SELECTOR   AGE
daemonset.apps/fanclub-logcollector   2         2         2       2            2           <none>          43h

backend 3 / frontend 2 / db 1 / logcollector 2 が揃っています。 レプリカ数を明示したので巻き戻りは起きませんでした。

db を作り直すと backend が 1/2 で止まることがあります。

StatefulSet に securityContext を足すと db-0 が作り直されます。そのとき backend は DB との接続を失い、Pod によっては 1/2 Running のまま数分戻らないことがあります(本書ラボで 1 つ発生しました)。

対処は該当 Pod の削除です(kubectl -n fanclub delete pod <Pod 名>)。作り直された Pod は 3 分ほどで 2/2 になります。第2巻第12回・第14回でも同じ現象を扱いました。

ステップ4:全 Pod が Restricted 準拠になったことを dry-run で確認する

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

$ kubectl label ns fanclub pod-security.kubernetes.io/enforce=restricted pod-security.kubernetes.io/enforce-version=latest --dry-run=server

実行結果:

namespace/fanclub labeled (server dry run)

警告 0 行。 やってみよう① で 3 行出ていたものが消えました。ここが本演習の合格条件です。0 行を確認せずに次のステップへ進まないでください。

この 1 コマンドが本演習の採点基準です。

「直したつもり」で enforce を貼ると、UP-TO-DATE 0 の状態に落ちます。貼る前に必ず dry-run で 0 行を確認してください。

ステップ5:enforce を貼る

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

$ kubectl label ns fanclub pod-security.kubernetes.io/enforce=restricted pod-security.kubernetes.io/enforce-version=latest

実行結果:

namespace/fanclub labeled

警告が 1 行も出ません。 やってみよう② では警告が 3 行出ていました。違反が無くなった証拠です。

ステップ6:壊れていないことを確かめる

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

$ kubectl -n fanclub get pod

実行結果:

NAME                                READY   STATUS    RESTARTS   AGE
fanclub-backend-67dc44fc95-6pwnr    2/2     Running   0          5m22s
fanclub-backend-67dc44fc95-qzdsj    2/2     Running   0          20m
fanclub-backend-67dc44fc95-vv2hn    2/2     Running   0          23m
fanclub-db-0                        1/1     Running   0          17m
fanclub-frontend-69d8fc6d88-59w7t   1/1     Running   0          103s
fanclub-frontend-69d8fc6d88-9xf6w   1/1     Running   0          104s
fanclub-logcollector-jxrf9          1/1     Running   0          16m
fanclub-logcollector-t7qbd          1/1     Running   0          16m

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

$ kubectl -n fanclub get deploy,sts,ds

実行結果:

NAME                               READY   UP-TO-DATE   AVAILABLE   AGE
deployment.apps/fanclub-backend    3/3     3            3           43h
deployment.apps/fanclub-frontend   2/2     2            2           43h

NAME                          READY   AGE
statefulset.apps/fanclub-db   1/1     43h

NAME                                  DESIRED   CURRENT   READY   UP-TO-DATE   AVAILABLE   NODE SELECTOR   AGE
daemonset.apps/fanclub-logcollector   2         2         2       2            2           <none>          43h

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

$ kubectl -n longhorn-system get volumes.longhorn.io

実行結果:

NAME                                       DATA ENGINE   STATE      ROBUSTNESS   SCHEDULED   SIZE         NODE        AGE
pvc-21281d08-c435-4033-a67d-c95bce442936   v1            attached   healthy                  5368709120   k8s-wl-02   43h
pvc-226c8f31-f346-4327-aec7-4f3f39707a4b   v1            attached   healthy                  1073741824   k8s-wl-02   43h
pvc-e9e00c36-64a9-4ff2-b3b0-f21511a2257e   v1            attached   healthy                  5368709120   k8s-wl-02   43h
pvc-f342237e-464a-4be2-99c9-84cc71ea7d17   v1            attached   healthy                  2147483648   k8s-wl-02   43h

4 ボリュームすべて attached healthy です。fsGroup: 999 を足しても既存のデータには影響していません。

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

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

実行結果:

200

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

$ curl -sk --noproxy '*' https://fanclub.local/api/members

実行結果(先頭のみ):

[{"createdAt":"2026-07-25T14:28:03.572932","email":"hanako@example.com","id":1,"name":"鈴木花子","plan":"standard"}

会員データが読めています。 DB を uid 999 で動かしても、Longhorn の既存データはそのまま使えました。

rollout restart で本当に作り直せることを確かめてください。

ここまでの確認は「既存 Pod が生きている」ことしか示していません。既存 Pod が Running であることは、Restricted 準拠の証明になりません。

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

$ kubectl -n fanclub rollout restart deploy/fanclub-frontend

実行結果:

deployment.apps/fanclub-frontend restarted

40 秒ほど待って確認します。

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

$ kubectl -n fanclub get pod -l app=fanclub-frontend

実行結果:

NAME                                READY   STATUS    RESTARTS   AGE
fanclub-frontend-69d8fc6d88-59w7t   1/1     Running   0          39s
fanclub-frontend-69d8fc6d88-9xf6w   1/1     Running   0          40s

やってみよう② では 1 つも作られなかった Pod が、今度は 40 秒で 2 つとも Running になりました。 UP-TO-DATE2 のままです。同じ enforce=restricted の下で、結果が反対になりました。

ステップ7:弾かれることを確かめる

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

$ kubectl -n fanclub run probe-frontend --image=k8s-registry:5000/fanclub-frontend:1.0.0

実行結果:

Error from server (Forbidden): pods "probe-frontend" is forbidden: violates PodSecurity "restricted:latest": allowPrivilegeEscalation != false (container "probe-frontend" must set securityContext.allowPrivilegeEscalation=false), unrestricted capabilities (container "probe-frontend" must set securityContext.capabilities.drop=["ALL"]), runAsNonRoot != true (pod or container "probe-frontend" must set securityContext.runAsNonRoot=true), seccompProfile (pod or container "probe-frontend" must set securityContext.seccompProfile.type to "RuntimeDefault" or "Localhost")

同じイメージでも、securityContext を書かずに kubectl run で作った Pod は拒否されます。 守られているのは namespace であって、イメージではありません。

Deployment 経由で作られる Pod は通り、素の kubectl run は弾かれる。 これが「namespace 全体を Restricted に上げた」状態です。

ステップ8:後片付け

本演習の変更(fanclub の Restricted 化)は残します。第9回以降のラボはこの状態を前提にします。

ValidatingAdmissionPolicy(CEL)— 素朴な式は取りこぼす

PSS は「Pod のセキュリティ姿勢」を強制します。しかし 「latest タグを禁止する」「許可レジストリ以外を拒否する」といった組織固有のルールは表現できません(後の節で整理します)。

ValidatingAdmissionPolicy(VAP) は Kubernetes 組込の Admission ポリシーで、CEL で条件を書きます。外部コンポーネントが要りません。

CEL(Common Expression Language)は Google 発の式言語で、副作用の無い短い判定式を書くために設計されています。Kubernetes は VAP や CRD の検証などで採用しています。

まず素朴に書いてみる

apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingAdmissionPolicy
metadata:
  name: disallow-latest-tag
spec:
  failurePolicy: Fail
  matchConstraints:
    resourceRules:
      - apiGroups: [""]
        apiVersions: ["v1"]
        operations: ["CREATE", "UPDATE"]
        resources: ["pods"]
  validations:
    - expression: "object.spec.containers.all(c, !c.image.endsWith(':latest'))"
      message: "latest タグの使用は禁止されています"
apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingAdmissionPolicyBinding
metadata:
  name: disallow-latest-tag-binding
spec:
  policyName: disallow-latest-tag
  validationActions: ["Deny"]
  matchResources:
    namespaceSelector:
      matchLabels:
        kubernetes.io/metadata.name: policy-probe

ポリシー本体(ValidatingAdmissionPolicy)と適用先(ValidatingAdmissionPolicyBinding)が分かれているのが VAP の構造です。ポリシーを書いただけでは何も起きません。Binding で validationActions: ["Deny"] を書いて初めて拒否になります。

上の Binding は policy-probe という namespace だけを対象にしています。この namespace は演習用に作りますkubectl create ns policy-probe)。PSA ラベルは貼りません——理由はこの節の最後で説明します。

この素朴な式を実機で試すと、次のようになりました。

試したイメージ結果
nginx:latest拒否(想定どおり)
nginx(タグ省略 = 実体は latest)通過(取りこぼし)
initContainersbusybox:latest通過spec.containers しか見ていない)

拒否されたときの実行結果:

The pods "vap-latest" is invalid: : ValidatingAdmissionPolicy 'disallow-latest-tag' with binding 'disallow-latest-tag-binding' denied request: latest タグの使用は禁止されています

メッセージの形が PSA と違うことに注目してください。

PSA は Error from server (Forbidden) で始まりますが、VAP は The pods "..." is invalid: : で始まります。 コロンが 2 つ続くのは実機どおりで、誤植ではありません。 どのポリシーに弾かれたのかを判別する手がかりになるので、両方の形を覚えておいてください。

取りこぼしを潰した式(実機で全ケース検証済み)

ポリシー本体を次の形へ差し替えます。variablesvalidations が変わり、それ以外は同じです。

apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingAdmissionPolicy
metadata:
  name: disallow-latest-tag
spec:
  failurePolicy: Fail
  matchConstraints:
    resourceRules:
      - apiGroups: [""]
        apiVersions: ["v1"]
        operations: ["CREATE", "UPDATE"]
        resources: ["pods"]
  variables:
    - name: allContainers
      expression: "object.spec.containers + (has(object.spec.initContainers) ? object.spec.initContainers : [])"
  validations:
    - expression: "variables.allContainers.all(c, c.image.split('/')[c.image.split('/').size() - 1].contains(':') && !c.image.endsWith(':latest'))"
      message: "イメージはタグを明示し latest を使わないこと"

metadata / failurePolicy / matchConstraints は素朴版と同じです。variables ブロックを足し、validations の式を差し替えるだけで、上の YAML 全量をそのまま kubectl apply -f できます(Binding は変更不要です)。

実測結果は次のとおりです。6 ケースすべて検証済みです。

イメージ結果
nginx:latest拒否
nginx拒否(タグ省略を検出)
nginx:1.27-alpine通過
k8s-registry:5000/fanclub-backend:0.3.2通過(レジストリのポート番号 :5000 を誤検知しない)
k8s-registry:5000/fanclub-backend拒否
initContainersbusybox:latest拒否

式の設計を 3 点に分解します。

  1. variables —— containersinitContainers を連結した配列を作ります。has() で存在確認しないと、initContainers が無い Pod で式がエラーになります
  2. split('/') の最後の要素だけを見る —— イメージ名は <レジストリ>/<パス>/<名前>:<タグ> の形なので、タグがあるとすれば最後のスラッシュより後ろにあります
  3. contains(':') だけで判定してはいけません —— 本書ラボのレジストリは k8s-registry:5000 で、ホスト名にコロンが含まれます。 素朴に image.contains(':') と書くと、タグを省略した k8s-registry:5000/fanclub-backend を「タグあり」と誤判定します

この誤りは本書ラボで必ず露見します。

ポート番号付きのレジストリを使っていない環境では、image.contains(':') で当面は動いてしまいます。動いているうちは誰も気づかず、社内レジストリにポート番号を付けた日に破綻します。 「今の環境で通るか」ではなく「どういう入力で破綻するか」を考える——これが CEL に限らずポリシーを書くときの作法です。

この式にも、まだ 2 つの抜けが残っています。

「6 ケース検証済み」は「完全」という意味ではありません。

  • ephemeralContainers を見ていません。 kubectl debug で後から差し込むコンテナは containers にも initContainers にも入りません。塞ぐには matchConstraintsresources["pods", "pods/ephemeralcontainers"] にしたうえで、式にも ephemeralContainers を足す必要があります
  • ダイジェスト指定(image@sha256:...)は通ります。 これは通してよい抜けです。ダイジェストはタグより厳密にイメージを固定するので、latest 禁止の意図には反しません。第14回の Cosign では、むしろダイジェスト指定が推奨になります

「どこまで検証したか」を書けるポリシーだけが、レビューできるポリシーです。 自作のポリシーには、必ずこの一覧を添えてください。

適用範囲の絞り方は VAP と Gatekeeper で書く場所が違います。

VAP は Binding の matchResources.namespaceSelector で絞り、Gatekeeper は Constraint の spec.match で絞ります。Gatekeeper の Constraint はクラスタ全体スコープのリソース(namespace に属しません)なので、-n を付けてもエラーにはならず、黙って無視されますmetadata.namespace は空のまま作られます)。絞りたいなら spec.match.namespaces に書くしかありません。 どちらも「ポリシー本体」と「適用先」が分かれている点は同じで、書く場所の名前だけが違います。

評価順序の罠

enforce=restricted の namespace で VAP を試すと、PodSecurity が先に拒否して VAP のメッセージが表示されません(実測)。

VAP の動作確認は、PSS ラベルの無い namespace で行ってください。

組込の Admission プラグイン(PodSecurity)は VAP より先に評価されます。「VAP を書いたのにメッセージが出ない」ときは、VAP が壊れているのではなく、その手前で別のポリシーが弾いている可能性を先に疑ってください。

型チェックと Mutating 版

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

$ kubectl get validatingadmissionpolicy disallow-latest-tag -o jsonpath='{.status.typeChecking}'

実行結果:

{}

空のオブジェクトは「型エラー無し」という意味です。式の書き方を間違えると、ここにエラーが入ります。

status.typeChecking は VAP の強みです。 CEL 式に型の誤りがあると、Pod を作る前に、ポリシーを apply した時点で分かります。 Gatekeeper の Rego にはこの仕組みがありません(次の節の落とし穴に直結します)。

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

$ kubectl api-resources | grep -i mutatingadmissionpolicy

実行結果:

mutatingadmissionpolicies                              admissionregistration.k8s.io/v1      false        MutatingAdmissionPolicy
mutatingadmissionpolicybindings                        admissionregistration.k8s.io/v1      false        MutatingAdmissionPolicyBinding

v1.36 のラボには MutatingAdmissionPolicy / MutatingAdmissionPolicyBindingadmissionregistration.k8s.io/v1 で存在します(実測)。CEL 系が「拒む」だけでなく「書き換える」まで組込で持つようになりました。 外部コンポーネントを入れないと mutate できない、という前提が 1 つ崩れたという意味で、選択の地図が変わりつつあります。

これはラボ(v1.36.3)の話で、CKS 試験(v1.35)には当てはまりません。

公式ドキュメントの MutatingAdmissionPolicy のページは Feature State を Kubernetes v1.36 [stable] と表示しています。試験環境の v1.35 では GA していません。

試験で「既定値を注入せよ」という課題が出たら、組込の MutatingAdmissionPolicy ではなく MutatingWebhookConfiguration か Kyverno を前提に考えてください。 本回では扱いません。ラボが試験より新しいときは、ラボで見えたものをそのまま試験の答えにしない——このマイナー 1 つ分の差は、本書を通して意識してほしい点です。

OPA Gatekeeper — apply が通ってもポリシーが効いていないことがある

GatekeeperConstraintTemplate(Rego でルールを定義し、専用の CRD を生やす)と Constraint(生えた CRD のインスタンスとして、どこに適用するかを書く)の二段構えです。

ルールを書く言語は Rego です。OPA(Open Policy Agent)のポリシー記述言語で、宣言的ですが独自文法を持ち、v0 と v1 で書き方が変わっています。 この「変わっている」ことが、本節最大の落とし穴の正体です。

導入

この節は読み取りの解説です。

ここに出てくるコマンドは、この節では打ちません。 実際に手を動かすのは やってみよう⑥ ステップ4 です。helm install を 2 回打つと cannot re-use a name that is still in use で失敗し、whitelist へ 2 回追記すると同じ行が 2 つ並びます。

コマンド(alma-proxy 上・root・この節では打ちません):

# grep -c "open-policy-agent.github.io" /etc/squid/whitelist.txt

そのときの実行結果:

0

0 = 1 行もありません。このままでは helm repo add が Squid に拒否されます。

whitelist に 1 行足す作業が本書ラボでは毎回必要です。

Helm chart のリポジトリは https://open-policy-agent.github.io/gatekeeper/charts にあり、イメージ自体は Docker Hubopenpolicyagent/gatekeeper / openpolicyagent/gatekeeper-crds)なので、registry-1.docker.io は登録済みです。chart の index.yaml を取りに行くところだけが通りません。

コマンド(alma-proxy 上・root・この節では打ちません):

# cp /etc/squid/whitelist.txt /etc/squid/whitelist.txt.pre08.bak
# echo "open-policy-agent.github.io" >> /etc/squid/whitelist.txt
# systemctl reload squid

コマンド(確認・alma-proxy 上・root・この節では打ちません):

# grep -c "open-policy-agent.github.io" /etc/squid/whitelist.txt

そのときの実行結果:

1

コマンド(k8s-ops 上・developer・この節では打ちません):

$ helm repo add gatekeeper https://open-policy-agent.github.io/gatekeeper/charts

そのときの実行結果:

"gatekeeper" has been added to your repositories

コマンド(k8s-ops 上・developer・この節では打ちません):

$ helm install gatekeeper gatekeeper/gatekeeper --namespace gatekeeper-system --create-namespace --version 3.23.0

そのときの実行結果:

NAME: gatekeeper
LAST DEPLOYED: Mon Jul 27 22:24:41 2026
NAMESPACE: gatekeeper-system
STATUS: deployed
REVISION: 1
DESCRIPTION: Install complete
TEST SUITE: None

コマンド(k8s-ops 上・developer・この節では打ちません):

$ kubectl -n gatekeeper-system get pod

そのときの実行結果:

NAME                                            READY   STATUS    RESTARTS   AGE
gatekeeper-audit-b9d5f797b-nm5xp                1/1     Running   0          28s
gatekeeper-controller-manager-5b6f9b6b9-469tq   1/1     Running   0          28s
gatekeeper-controller-manager-5b6f9b6b9-pqnb5   1/1     Running   0          28s
gatekeeper-controller-manager-5b6f9b6b9-s69nv   0/1     Running   0          28s

本書ラボでは 4 Pod(controller-manager 3 + audit 1)がすべて Running になるまで 40 秒でした。0/1 Running の Pod は readiness を待っているだけなので、数秒待てば揃います。

--version 3.23.0 は必ず付けてください。先頭に v は付けません(chart バージョンの表記は 3.23.0 です)。バージョンを省くとその時点の最新が入り、記事の出力と食い違います。

ここで棚卸し表が 1 行増えます。

Gatekeeper を入れると gatekeeper-system namespace が生まれます。やってみよう③ ステップ4 のコマンドをもう一度打つと、この namespace には enforce=restricted(version v1.24)が chart によって最初から貼られていることが分かります。ポリシーエンジン自身が Restricted 適合で入ってくる——先ほど見た「新しめの chart は最初から適合している」の実例が 1 つ増えました。

コマンド(k8s-ops 上・developer・やってみよう③ ステップ4 と同じもの):

$ kubectl get ns -o custom-columns='NAME:.metadata.name,ENFORCE:.metadata.labels.pod-security\.kubernetes\.io/enforce,VERSION:.metadata.labels.pod-security\.kubernetes\.io/enforce-version'

そのときの実行結果(ラベルが付いている行の抜粋):

NAME                 ENFORCE      VERSION
fanclub              restricted   latest
gatekeeper-system    restricted   v1.24
local-path-storage   privileged   latest
longhorn-system      privileged   latest
metallb-system       privileged   latest
monitoring           privileged   latest
velero               privileged   latest

gatekeeper-systemrestricted / v1.24 が入っています。latest ではなく v1.24 と固定されているのは、chart の作者が「この版で検証した」と宣言しているからです。バージョンを固定するほうが、クラスタを上げたときに勝手に厳しくならない——ここは設計判断が分かれる点です。

最大の落とし穴 — apply は通るのにポリシーが効いていない

まず素朴なルールを書きます。ConstraintTemplate(Rego v0 記法)です。

apiVersion: templates.gatekeeper.sh/v1
kind: ConstraintTemplate
metadata:
  name: k8sdisallowlatesttag
spec:
  crd:
    spec:
      names:
        kind: K8sDisallowLatestTag
  targets:
    - target: admission.k8s.gatekeeper.sh
      rego: |
        package k8sdisallowlatesttag
        violation[{"msg": msg}] {
          container := input.review.object.spec.containers[_]
          endswith(container.image, ":latest")
          msg := sprintf("コンテナ %v は latest タグを使用しています", [container.name])
        }

Constraint(テンプレートが生やした CRD のインスタンス):

apiVersion: constraints.gatekeeper.sh/v1beta1
kind: K8sDisallowLatestTag
metadata:
  name: disallow-latest-tag
spec:
  match:
    kinds:
      - apiGroups: [""]
        kinds: ["Pod"]

Constraint は namespace に属さないクラスタスコープのリソースです。-n を付けても無視されます——エラーは出ず、metadata.namespace が空のまま作られるので、「namespace を指定したつもり」で全 namespace に効いてしまいます。 上の例は spec.match.namespaces を書いていないのですべての namespace の Pod が対象になります。絞るときは必ず spec.match.namespaces に書いてください。

ここで rego: の 2 行目に import rego.v1 を足すと何が起きるか。実測では次のようになりました。

  • kubectl apply は成功する
  • しかしポリシーは 1 件も効かない
  • エラーは status.byPod[].errors にしか出ない

コマンド(k8s-ops 上・developer・この節では打ちません):

$ kubectl get constrainttemplate k8sdisallowlatesttag -o jsonpath='{.status}'

実行結果(import rego.v1 を書いた場合・抜粋):

"errors": [{"code": "ingest_error",
  "message": "Could not ingest Rego: invalid ConstraintTemplate: 2 errors occurred:\ntemplate:3: rego_parse_error: `if` keyword is required before rule body\ntemplate:3: rego_parse_error: `contains` keyword is required for partial set rules"}]

実行結果(正しい場合・抜粋):

{
    "byPod": [
        {
            "id": "gatekeeper-audit-b9d5f797b-nm5xp",
            "observedGeneration": 1,
            "operations": [
                "audit",
                "generate",
                "mutation-status",
                "status"
            ],
            "templateUID": "1f5cc8f2-c7c3-4765-8e87-ed93cead4837"
        }
    ]
}

実行結果(import rego.v1 を書いた場合byPod[0] のみ):

{
    "errors": [
        {
            "code": "ingest_error",
            "message": "Could not ingest Rego: invalid ConstraintTemplate: 2 errors occurred:\ntemplate:3: rego_parse_error: `if` keyword is required before rule body\ntemplate:3: rego_parse_error: `contains` keyword is required for partial set rules"
        }
    ],
    "id": "gatekeeper-audit-b9d5f797b-nm5xp",
    "observedGeneration": 1,
    "operations": [
        "audit",
        "generate",
        "mutation-status",
        "status"
    ],
    "templateUID": "782db48c-6135-4c20-ae8f-e23be23cafe1"
}

違いは errors の有無だけです。kubectl apply はどちらも created を返します。

「apply が通った = 守られている」ではありません。

これが Gatekeeper で最も注意すべき点です。API Server から見れば ConstraintTemplate は単なるカスタムリソースなので、中の Rego が正しいかどうかを検証しません。 Rego をコンパイルするのは Gatekeeper 側で、失敗しても status にそっと書かれるだけです。

VAP は status.typeChecking で apply 時に型エラーを教えてくれます。 同じ「ポリシーを書く」でも、間違いに気づける速さが違います。

Gatekeeper v3.23.0 は Rego v0 記法で書きます。 OPA 本体は v1 記法(import rego.v1 / if / contains)へ移行していますが、Gatekeeper はまだ v0 です。 検索して出てきた OPA のサンプルをそのまま貼ると、apply は通ってポリシーは効かないという最悪の形で失敗します。

エラーの中身も読んでおきます。 import rego.v1 という行そのものが拒否されたのではありません——import は受理され、その結果として v1 の構文が要求され、v0 記法で書いた本体がパースエラーになっています`if` keyword is required before rule body / `contains` keyword is required for partial set rules)。つまり 1 行足しただけで、ファイル全体の文法が切り替わります。 直し方は「その 1 行を消す」か「本体ごと v1 記法へ書き換える」かのどちらかで、Gatekeeper v3.23.0 では前者が正解です。

取りこぼしを潰した Rego(実機で全ケース検証済み)

素朴な Rego は、CEL の素朴版とまったく同じ取りこぼし方をします。タグ省略(nginx)と initContainers が素通りします。 CEL 版と同じ判定精度を Rego で実装すると次のようになります。

apiVersion: templates.gatekeeper.sh/v1
kind: ConstraintTemplate
metadata:
  name: k8srequireexplicittag
spec:
  crd:
    spec:
      names:
        kind: K8sRequireExplicitTag
  targets:
    - target: admission.k8s.gatekeeper.sh
      rego: |
        package k8srequireexplicittag
        violation[{"msg": msg}] {
          container := input.review.object.spec[containers][_]
          containers_field(containers)
          not has_explicit_tag(container.image)
          msg := sprintf("コンテナ %v のイメージ %v はタグが明示されていないか latest です", [container.name, container.image])
        }
        containers_field("containers")
        containers_field("initContainers")
        has_explicit_tag(image) {
          parts := split(image, "/")
          last := parts[count(parts) - 1]
          contains(last, ":")
          not endswith(image, ":latest")
        }

対応する Constraint(kindK8sRequireExplicitTag に変わります):

apiVersion: constraints.gatekeeper.sh/v1beta1
kind: K8sRequireExplicitTag
metadata:
  name: require-explicit-tag
spec:
  match:
    kinds:
      - apiGroups: [""]
        kinds: ["Pod"]
    namespaces: ["policy-probe"]

Rego の書き方を 3 点に分解します。

  1. input.review.object.spec[containers][_] —— containers を変数にして spec のキーを動的に選びます
  2. containers_field("containers")containers_field("initContainers") の 2 行が、その変数に入りうる値を定義します。この 2 行だけで両方を走査できます
  3. has_explicit_tag は CEL 版と同じ考え方で、split(image, "/")最後の要素にコロンがあるかを見ます
イメージ結果
nginx:latest拒否
nginx拒否
nginx:1.27-alpine通過
k8s-registry:5000/fanclub-backend:0.3.2通過(ポート番号を誤検知しない)
k8s-registry:5000/fanclub-backend拒否
initContainersbusybox:latest拒否
  • spec[containers][_]containers_field("containers") / containers_field("initContainers") の 2 本の規則で、containersinitContainers の両方を走査します(Rego v0 の書き方です)
  • split(image, "/")最後の要素だけを見るのは CEL 版と同じ考え方です
  • CEL 版と同じ 2 つの抜けephemeralContainers とダイジェスト指定)はこちらにも残っています

効いているかを確かめる 2 つのコマンド

コマンド(k8s-ops 上・developer・この節では打ちません):

$ kubectl get k8sdisallowlatesttag disallow-latest-tag -o jsonpath='{.status.byPod[0].enforced}'

実行結果:

true

enforced: true になって初めて効きます。 Constraint を作った直後は false や空のことがあるので、20 秒ほど待ってから確認してください。

実際に拒否されたときの実行結果:

Error from server (Forbidden): admission webhook "validation.gatekeeper.sh" denied the request: [disallow-latest-tag] コンテナ gk-latest は latest タグを使用しています

3 つの拒否メッセージの形を並べて覚えてください。 試験でも実務でも、メッセージの形だけで「どの層に弾かれたか」が分かります。

弾いた層メッセージの書き出し
PSAError from server (Forbidden): pods "..." is forbidden: violates PodSecurity ...
VAPThe pods "..." is invalid: : ValidatingAdmissionPolicy '...' with binding '...' denied request: ...
GatekeeperError from server (Forbidden): admission webhook "validation.gatekeeper.sh" denied the request: [制約名] ...

fail-open が既定

fail-open / fail-closed は、ポリシー判定の仕組みが落ちたときにどちらへ倒れるかの設計です。通す側に倒れるのが fail-open、拒む側に倒れるのが fail-closed です。

コマンド(k8s-ops 上・developer・この節では打ちません):

$ kubectl get validatingwebhookconfiguration gatekeeper-validating-webhook-configuration -o custom-columns='WEBHOOK:.webhooks[*].name,FAILUREPOLICY:.webhooks[*].failurePolicy'

実行結果:

WEBHOOK                                                     FAILUREPOLICY
validation.gatekeeper.sh,check-ignore-label.gatekeeper.sh   Ignore,Fail

custom-columns[*] は複数の値をカンマで並べるので、2 つの webhook が並んだ 1 行として返ります。整理すると次のようになります。

webhookfailurePolicynamespaceSelector
validation.gatekeeper.shIgnoreadmission.gatekeeper.sh/ignore が無い かつ gatekeeper-system 以外
check-ignore-label.gatekeeper.shFailgatekeeper-system 以外

Gatekeeper が落ちると、ポリシーは黙って素通りします。

これが failurePolicy: Ignore(fail-open)の意味です。攻撃者から見れば「Gatekeeper の Pod を落とせばポリシーが消える」ということになります。

なぜ既定が fail-open なのか。 Fail にすると、Gatekeeper が落ちた瞬間にクラスタ全体で Pod が作れなくなるからです。ポリシーエンジンの障害がクラスタ全体の停止に直結する——それを避けるための既定です。可用性とセキュリティのトレードオフが、既定値として表現されています。

VAP は組込なので、この問題がありません。 API Server 自身が評価するので、「VAP だけが落ちる」という状態が存在しません。failurePolicy: Fail を安心して書けます。

この非対称性が「まず PSS / VAP、足りない分だけ Gatekeeper」という実務判断の根拠です。

本番で Gatekeeper を Fail に変えるなら、可用性を先に設計してください。chart の既定で controller-manager は 3 レプリカです(実測で 4 Pod のうち 3 つ)。そのうえで次の 3 点を決めます。

  • PodDisruptionBudget —— ノードのドレイン時に全レプリカが同時に落ちないようにする
  • topologySpreadConstraints —— 3 レプリカが同じノードに寄らないようにする。1 ノードに 3 つ載っていれば、レプリカ数は気休めにしかならない
  • webhook の namespaceSelector —— kube-system のような復旧に必要な namespace を除外しておく。ここを閉じたまま Gatekeeper が落ちると、Gatekeeper を直すための Pod も作れません

Gatekeeper にしかできないこと — audit(既存リソースの棚卸し)

Gatekeeper の audit は、すでにクラスタにあるリソースを Constraint に照らして定期的に評価し、違反を status.violations に書きます。VAP にはこの機能がありません(VAP は Admission のタイミングでしか評価しません)。

コマンド(k8s-ops 上・developer・この節では打ちません):

$ kubectl get k8srequireexplicittag require-explicit-tag -o jsonpath='{.status.violations}'

実行結果(違反が 1 件ある状態・本書ラボで nginx:latest の Pod を先に作って確認したもの):

[{"enforcementAction":"deny","group":"","kind":"Pod","message":"コンテナ legacy-app のイメージ nginx:latest はタグが明示されていないか latest です","name":"legacy-app","namespace":"gk-audit","version":"v1"}]

audit で拾わせるには、Constraint を貼るに違反リソースが存在している必要があります。

順序を逆にすると、Admission が先に拒否して違反 Pod を作れません。

Error from server (Forbidden): error when creating "legacy.yaml": admission webhook "validation.gatekeeper.sh" denied the request: [disallow-latest-tag] コンテナ legacy-app は latest タグを使用しています

本書ラボで上の violations を再現した手順は次の 4 段です。①この節までに作った Constraint(disallow-latest-tag / require-explicit-tag)をいったん両方削除する ②gk-audit namespace に nginx:latest の Pod を作る(ポリシーが無いので通る)③require-explicit-tagspec.match.namespaces: ["gk-audit"] 付きで作る ④60 秒前後待つこれが audit の性格そのものです——audit は「これから作られるもの」ではなく「もう在るもの」を数える機能なので、ポリシーより先に対象が居なければ、数えるものがありません。

件数だけを見るなら status.totalViolations が使えます。本書ラボの平常時は 0 件です(クラスタ内に :latest の Pod がありません)。違反 Pod を作ってから totalViolations1 に変わるまで 50 秒かかりました(audit の既定間隔は 60 秒)。

反映は即座ではありません。 audit の既定間隔が 60 秒なので、「違反リソースを作った直後に見ても 0 のまま」という時間差があります。ダッシュボードに出すときは、この遅延を前提に読んでください。

「違反 0 件」を見せることに意味があります。

latest 禁止ポリシーをクラスタ全体に当てても既存ワークロードを壊さない、という判断根拠がこの 1 コマンドで得られます。PSA の --dry-run=server と同じ役割を、Gatekeeper は継続的に果たします。

PSA の棚卸しは dry-run で 1 回だけ、Gatekeeper の棚卸しは常時。 「今どれだけ違反しているか」をダッシュボードに出したいなら、それが Gatekeeper を選ぶ理由になります。記法の差は学習コストの差にすぎませんが、audit の有無は機能そのものの差です。

やってみよう⑥:latest タグ禁止を VAP と Gatekeeper の両方で実装する

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

Gatekeeper は試験中に公式ドキュメントを参照できません(許可 8 サイトに open-policy-agent.github.io は含まれていません)。ConstraintTemplate と Constraint の骨格は暗記対象です。VAP は kubernetes.io/docs に載っているので参照できます。

対象は k8s-ops 上の kubectl と検証用 namespace です。

ステップ1:検証用の namespace を作る(PSS ラベルは貼らない)

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

$ kubectl create ns policy-probe

実行結果:

namespace/policy-probe created

PSS ラベルを貼らない理由は、enforce=restricted の namespace では PodSecurity が先に拒否して VAP や Gatekeeper のメッセージが出ないからです。ポリシー単体の動作を確かめるには、手前に何も無い namespace が要ります。

ステップ2:VAP を素朴な式で作り、取りこぼしを確認する

前の節の「素朴な式」の VAP と Binding を apply し、3 パターンを試します。

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

$ kubectl -n policy-probe run vap-latest --image=nginx:latest

実行結果:

The pods "vap-latest" is invalid: : ValidatingAdmissionPolicy 'disallow-latest-tag' with binding 'disallow-latest-tag-binding' denied request: latest タグの使用は禁止されています

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

$ kubectl -n policy-probe run vap-notag --image=nginx

実行結果:

pod/vap-notag created

通ってしまいました。 nginx の実体は nginx:latest です。

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

$ kubectl -n policy-probe run vap-init --image=nginx:1.27-alpine --overrides='{"spec":{"initContainers":[{"name":"init","image":"busybox:latest","command":["sh","-c","true"]}]}}'

実行結果:

pod/vap-init created

これも通ってしまいました。 object.spec.containers しか見ていないので、initContainers は素通りです。

ステップ3:式を差し替えて全ケースを潰す

前の節の「取りこぼしを潰した式」へ variablesvalidations を差し替え、6 パターンnginx:latest / nginx / nginx:1.27-alpine / k8s-registry:5000/fanclub-backend:0.3.2 / k8s-registry:5000/fanclub-backend / initContainersbusybox:latest)を試します。

実行結果(6 パターン):

nginx:latest
The pods "vap-t" is invalid: : ValidatingAdmissionPolicy 'disallow-latest-tag' with binding 'disallow-latest-tag-binding' denied request: イメージはタグを明示し latest を使わないこと

nginx
The pods "vap-t" is invalid: : ValidatingAdmissionPolicy 'disallow-latest-tag' with binding 'disallow-latest-tag-binding' denied request: イメージはタグを明示し latest を使わないこと

nginx:1.27-alpine
pod/vap-t created

k8s-registry:5000/fanclub-backend:0.3.2
pod/vap-t created

k8s-registry:5000/fanclub-backend
The pods "vap-t" is invalid: : ValidatingAdmissionPolicy 'disallow-latest-tag' with binding 'disallow-latest-tag-binding' denied request: イメージはタグを明示し latest を使わないこと

initContainers に busybox:latest
The pods "vap-i" is invalid: : ValidatingAdmissionPolicy 'disallow-latest-tag' with binding 'disallow-latest-tag-binding' denied request: イメージはタグを明示し latest を使わないこと

k8s-registry:5000/fanclub-backend:0.3.2 が通ることが本演習の要です。レジストリのポート番号のコロンを、タグのコロンと取り違えていないことを、この 1 行が証明しています。

ステップ4:Gatekeeper を導入して同じルールを Rego で書く

前の節の手順(whitelist 追加 → helm repo addhelm install --version 3.23.0 → ConstraintTemplate → Constraint)を実施します。

Gatekeeper を試す前に、VAP の Binding を外してください。

ステップ3 までで作った VAP がまだ効いていると、Gatekeeper の拒否メッセージが 1 度も表示されません。 実機で確かめたところ、nginx:latest を作ろうとしても返るのは VAP のメッセージでした。

Admission の評価順は「組込プラグイン(PodSecurity)→ VAP → Webhook(Gatekeeper)」です。Gatekeeper は一番後ろなので、手前で誰かが拒否すると出番が来ません。「Gatekeeper が効いていない」と悩む前に、手前を疑ってください。

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

$ kubectl delete validatingadmissionpolicybinding disallow-latest-tag-binding

実行結果:

validatingadmissionpolicybinding.admissionregistration.k8s.io "disallow-latest-tag-binding" deleted

素朴な Rego(k8sdisallowlatesttag)を適用したときの実行結果:

nginx:latest
Error from server (Forbidden): admission webhook "validation.gatekeeper.sh" denied the request: [disallow-latest-tag] コンテナ gk-latest は latest タグを使用しています

nginx
pod/gk-notag created

initContainers に busybox:latest
pod/gk-init created

CEL の素朴版とまったく同じ取りこぼし方です。言語が違っても、見ている場所が同じなら結果も同じになります。

取りこぼしを潰した Rego(k8srequireexplicittag)へ差し替えたときの実行結果(6 パターン):

nginx:latest
Error from server (Forbidden): admission webhook "validation.gatekeeper.sh" denied the request: [require-explicit-tag] コンテナ gkr のイメージ nginx:latest はタグが明示されていないか latest です

nginx
Error from server (Forbidden): admission webhook "validation.gatekeeper.sh" denied the request: [require-explicit-tag] コンテナ gkr のイメージ nginx はタグが明示されていないか latest です

nginx:1.27-alpine
pod/gkr created

k8s-registry:5000/fanclub-backend:0.3.2
pod/gkr created

k8s-registry:5000/fanclub-backend
Error from server (Forbidden): admission webhook "validation.gatekeeper.sh" denied the request: [require-explicit-tag] コンテナ gkr のイメージ k8s-registry:5000/fanclub-backend はタグが明示されていないか latest です

initContainers に busybox:latest
Error from server (Forbidden): admission webhook "validation.gatekeeper.sh" denied the request: [require-explicit-tag] コンテナ i のイメージ busybox:latest はタグが明示されていないか latest です

CEL 版とまったく同じ 6 つの判定結果になりました。最後の 1 行では、違反しているコンテナ名が i(initContainer の名前)と出ています。どのコンテナが原因かまで分かるのは、Rego の msgcontainer.name を入れているからです。

ステップ5:わざと import rego.v1 を書いて壊れ方を見る

ConstraintTemplate の rego: の 2 行目に import rego.v1 を足して apply し、apply は成功するのにポリシーが効かないことを確認します。

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

$ kubectl get constrainttemplate k8sdisallowlatesttag -o jsonpath='{.status}'

実行結果(byPod[0] のみ):

{
    "errors": [
        {
            "code": "ingest_error",
            "message": "Could not ingest Rego: invalid ConstraintTemplate: 2 errors occurred:\ntemplate:3: rego_parse_error: `if` keyword is required before rule body\ntemplate:3: rego_parse_error: `contains` keyword is required for partial set rules"
        }
    ],
    "id": "gatekeeper-audit-b9d5f797b-nm5xp",
    "observedGeneration": 1,
    "operations": [
        "audit",
        "generate",
        "mutation-status",
        "status"
    ],
    "templateUID": "782db48c-6135-4c20-ae8f-e23be23cafe1"
}

kubectl applycreated を返していました。 エラーはここにしか出ません。

「apply は通るのに効かない」が成立するのは、新規作成のときです。

本ステップのように、すでに動いている ConstraintTemplate を更新(configured)した場合は、挙動が違います。 ingest に失敗しても直前まで取り込まれていた Rego が有効なまま残るので、ポリシーは拒否を返し続けます。 「効かなくなる」のではなく「古い判断のまま動き続ける」という壊れ方です。

まっさらな状態から import rego.v1 付きで作った場合は、そもそも Constraint 用の CRD が生えません。 続けて Constraint を apply すると no matches for kind で失敗します。どちらの経路でも「apply は成功したのに意図どおりでない」ことに変わりはありませんが、症状の出方が違うので、status.byPod[].errors を見る手順だけは共通で必要です。

この 1 ステップだけは「壊す」ことが目的です。

本番で同じ失敗をすると、「ポリシーを入れたつもりで無防備」という最悪の状態になります。ここで一度見ておく価値があります。

次のステップへ進む前に必ず戻してください。 import rego.v1 の 1 行を削って apply し直します。

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

$ kubectl apply -f k8sdisallowlatesttag.yaml

実行結果:

constrainttemplate.templates.gatekeeper.sh/k8sdisallowlatesttag configured

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

$ kubectl get constrainttemplate k8sdisallowlatesttag -o jsonpath='{.status.byPod[0].errors}'

実行結果(何も返らなければ復旧しています):

(出力はありません)

errors が空になったことを確認してから次へ進んでください。 壊れたままだと、次のステップの enforcedtrue になりません。

ステップ6:enforcedviolations を確認する

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

$ kubectl get k8sdisallowlatesttag disallow-latest-tag -o jsonpath='{.status.byPod[0].enforced}'

実行結果:

true

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

$ kubectl get k8sdisallowlatesttag disallow-latest-tag -o jsonpath='{.status.totalViolations}'

実行結果:

0

0 です。 ステップ2 で作った vap-notag(イメージ nginx)はまだ policy-probe に残っていますが、この Constraint は素朴版で :latest で終わる文字列しか見ていないので、違反として数えられません。「audit の件数が 0 だから安全」ではなく、「そのポリシーが見ている範囲で 0」という読み方をしてください。

ステップ7:後片付け

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

$ kubectl delete ns policy-probe

ValidatingAdmissionPolicyBindingdisallow-latest-tag-binding)は、ステップ4 の冒頭ですでに削除しています——Gatekeeper の拒否メッセージを見るために、手前で拒否していた VAP の Binding を外しました。ここで改めて打つ必要はありません。 打っても NotFound が返るだけで、この後の実行結果にも現れません。

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

$ kubectl delete validatingadmissionpolicy disallow-latest-tag

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

$ kubectl delete k8sdisallowlatesttag disallow-latest-tag

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

$ kubectl delete constrainttemplate k8sdisallowlatesttag

ステップ4 で「取りこぼしを潰した Rego」を別テンプレートとして適用した場合は、次の 2 つも削除します。

$ kubectl delete k8srequireexplicittag require-explicit-tag
$ kubectl delete constrainttemplate k8srequireexplicittag

実行結果(本書ラボでまとめて実行したもの):

k8srequireexplicittag.constraints.gatekeeper.sh "require-explicit-tag" deleted
constrainttemplate.templates.gatekeeper.sh "k8srequireexplicittag" deleted
constrainttemplate.templates.gatekeeper.sh "k8sdisallowlatesttag" deleted
validatingadmissionpolicy.admissionregistration.k8s.io "disallow-latest-tag" deleted
namespace "policy-probe" deleted

実行コマンド(残っていないことの確認・k8s-ops 上・developer):

$ kubectl get constrainttemplate
$ kubectl get validatingadmissionpolicy
$ kubectl get validatingadmissionpolicybinding

実行結果:

No resources found
No resources found
No resources found

Gatekeeper 本体は残します。

第14回の許可レジストリ制限で、ImagePolicyWebhook と Gatekeeper / VAP の実装を比較するためです。手元の環境を軽くしたい場合は helm uninstall gatekeeper -n gatekeeper-system で消せますが、その場合は第14回で入れ直してください。

PSS で足りない制約と、ポリシーエンジン 4 種の比較

PSS で表現できないもの

やりたいことPSS で表現できるか手段
非 root で動かす / capability を落とす / seccomp を要求するできるPSS / PSA
hostPath / hostNetwork / privileged を禁じるできるPSS / PSA
latest タグを禁じるできないVAP / Gatekeeper
許可レジストリ以外を拒否するできないVAP / Gatekeeper / ImagePolicyWebhook(第14回
必須ラベル(owner / cost-center)を要求するできないVAP / Gatekeeper
resources.limits の指定を必須にするできないVAP / Gatekeeper(LimitRange は既定値を入れるだけで、必須化はできない)
Ingress のホスト名重複を防ぐできないGatekeeper(他のリソースを参照する必要があるため VAP では書きにくい)
既定値を注入する(mutate)できないKyverno / MutatingWebhookConfiguration(組込の MutatingAdmissionPolicy は v1.36 で GA。試験の v1.35 では使えません

順序を固定してください。

まず PSS。足りない分だけ VAP。VAP で書けないものだけ Gatekeeper。 順番を逆にすると、PSS で 1 行のラベルで済むことを Rego で書き直すことになります。

4 種の比較表

手段追加コンポーネントルールの書き方既存リソースの棚卸し障害時の既定向いている場面
PSS / PSA不要(K8s 組込)namespace ラベルを貼るだけenforce + --dry-run=server で 1 回だけ組込(落ちない)Pod のセキュリティ姿勢。まずこれ
ValidatingAdmissionPolicy(VAP)不要(K8s 組込)CELできないFail を安心して書ける外部依存を増やさずカスタム制約を足したいとき
OPA Gatekeeper必要(Operator・4 Pod)Rego v0(ConstraintTemplate + Constraint)audit で常時status.violationsIgnore(fail-open)が既定複雑な条件・継続的な監査・既存の Rego 資産があるとき
Kyverno必要(Operator)YAML(Rego 不要)ポリシーレポートポリシーごとに指定Rego を書きたくない場合。validate に加えて mutate / generate も得意

Kyverno の位置づけ(記法の対比のみ・ラボには導入しない)

  • ポリシーを YAML で書けるのが最大の特徴です。Rego の学習コストが不要で、Kubernetes ネイティブな書き味になります
  • mutate(既定値の注入)と generate(付随リソースの自動生成)を標準で持ち、Gatekeeper より「直す」方向が得意です
  • CNCF プロジェクトとして広く使われていますが、CKS 公式コンピテンシーは Kyverno を名指ししていません。 試験対策としては PSS + VAP + Gatekeeper で足ります

Kyverno のポリシー例です。記法の対比のためだけに載せており、本書ラボには導入していません。実機検証もしていません。

apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
  name: disallow-latest-tag
spec:
  validationFailureAction: Enforce
  rules:
    - name: require-image-tag
      match:
        any:
          - resources:
              kinds:
                - Pod
      validate:
        message: "latest タグは使用できません"
        pattern:
          spec:
            containers:
              - image: "!*:latest"

試験対策上の優先度は PSS > VAP・Gatekeeper > Kyverno です。Kyverno は「そういう選択肢がある」ところまで知っていれば足ります。

同じ「latest タグ禁止」を 3 通りで書きました。

PSS のラベル 1 行では書けず、VAP は CEL 式 2 行、Gatekeeper は Rego 5 行、Kyverno は YAML パターン 1 行。 記法の違いは学習コストの違いであって、実現できることの差ではありません。 選択の軸は「外部コンポーネントを増やしてよいか」「既存リソースの監査が要るか」「障害時にどちらへ倒したいか」の 3 つです。

暗記必須コマンド(PSA / AdmissionConfiguration / VAP / Gatekeeper)

本回で扱ったポリシーの手段は 4 つで、参照可が 2 つ、参照不可が 2 つに分かれます。

PSS / PSA(AdmissionConfiguration を含む)と VAPkubernetes.io/docs に載っているので参照できますOPA Gatekeeperopen-policy-agent.github.io)と Kyverno は許可 8 サイトに含まれていないため参照できません。ConstraintTemplate と Constraint の骨格は暗記対象です。

参照できるページは次の 6 つです。どのページに何が書いてあるかを事前に知っておくと、試験中の検索時間が大きく変わります。

ページ中身
kubernetes.io/docs/concepts/security/pod-security-standards/3 プロファイルの完全な要件表(写せば Restricted の YAML が書ける)
kubernetes.io/docs/concepts/security/pod-security-admission/PSA の仕組み・ラベルの一覧
kubernetes.io/docs/tasks/configure-pod-container/enforce-standards-namespace-labels/ラベルを貼るコマンドの実例
kubernetes.io/docs/tasks/configure-pod-container/enforce-standards-admission-controller/AdmissionConfiguration の YAML 全量
kubernetes.io/docs/reference/access-authn-authz/validating-admission-policy/VAP と Binding の YAML 全量・CEL の例
kubernetes.io/docs/tutorials/security/cluster-level-pss/クラスタ既定を敷く手順のチュートリアル。 AdmissionConfiguration の例が本回で書いたものと同じ値enforce: baseline / warnaudit: restricted)で載っている

6 ページとも 2026 年 7 月時点で存在し、上の「中身」と一致することを確認しています。4 つ目のページには AdmissionConfiguration の YAML が全量あり、defaults の各行に「取りうる値」がコメントで書かれています(バージョン指定は latest または v1.36 のような具体的な版)。ただしそのページの例は 3 モードとも privileged / exemptions が空なので、本回の設計にするには書き換えが要ります。6 つ目のチュートリアルなら、本回とほぼ同じ値がそのまま載っています。

Restricted の要件を丸暗記する必要はありません。 1 番目のページに完全な表があるので、そこを開いて写すのが最短です。暗記すべきは「どのページを開けばよいか」のほうです。

用途コマンド / 設定試験中参照補足
PSS ラベルを貼るkubectl label ns <ns> pod-security.kubernetes.io/enforce=restricted pod-security.kubernetes.io/enforce-version=latest-version も必ずセットで
違反を壊さずに数えるkubectl label ns <ns> pod-security.kubernetes.io/enforce=<level> --dry-run=serverwarn / audit の dry-run では出ない
雛形を作るkubectl run probe --image=nginx --dry-run=client -o yamlclient は API に届かないexport do= で短縮する
3 モードを併用するwarn=restricted / audit=restricted を同時に貼るwarn は Deployment 更新時に効く
ラベルを外すkubectl label ns <ns> pod-security.kubernetes.io/enforce-末尾のハイフンが削除
ラベルを一覧するkubectl get ns --show-labels / -o custom-columns=...棚卸しの起点
拒否の真因を見るkubectl describe rs <RS 名> の Events(FailedCreateget deployREADY では気づけない
クラスタ既定--admission-control-config-file=<path> + AdmissionConfiguration / PodSecurityConfigurationdefaults / exemptions。HA では 3 台すべてに配る
kubeadm への登録kubeadm-config ConfigMap の apiServer.extraArgs / extraVolumes可(kubernetes.io/docs書かないとアップグレードで消える
VAPValidatingAdmissionPolicy + ValidatingAdmissionPolicyBindingvalidationActions: ["Deny"] を忘れない
VAP の型チェックkubectl get validatingadmissionpolicy <name> -o jsonpath='{.status.typeChecking}'apply 時に型エラーが分かる
CEL の定石object.spec.containers.all(c, ...) / has(...) ? ... : [] / image.split('/')initContainers とタグ省略を取りこぼさない
Gatekeeper 導入helm install gatekeeper gatekeeper/gatekeeper -n gatekeeper-system --create-namespace --version 3.23.0不可chart リポジトリは open-policy-agent.github.io/gatekeeper/charts
ConstraintTemplatetemplates.gatekeeper.sh/v1 / spec.crd.spec.names.kind / targets[].rego不可Rego v0 記法。import rego.v1 は ingest エラー
Constraintconstraints.gatekeeper.sh/v1beta1 / kind: <テンプレートの kind> / spec.match.kinds不可テンプレートが生やした CRD のインスタンス。クラスタ全体スコープ
Gatekeeper の健全性kubectl get constrainttemplate <name> -o jsonpath='{.status}'不可apply が通っても ingest_error が出ていることがある
Gatekeeper が効いているかkubectl get <ConstraintKind> <name> -o jsonpath='{.status.byPod[0].enforced}'不可true を確認する
Gatekeeper の auditkubectl get <ConstraintKind> <name> -o jsonpath='{.status.violations}'不可VAP に無い強み
fail-open の確認kubectl get validatingwebhookconfiguration gatekeeper-validating-webhook-configuration -o yaml不可validation.gatekeeper.sh の既定は Ignore
Kyvernokyverno.io/v1 ClusterPolicy / validate.pattern不可本書はラボに導入しない

ラボ v1.36.3 と試験 v1.35 で何が変わるか

本書ラボは Kubernetes v1.36.3、CKS 試験環境は v1.35 です。本回の内容に関わる差分は 2 点あります。

  • enforce-version: latest の解決先が違います。 ラボで latest と書くと v1.36 のプロファイル定義に、試験環境で書くと v1.35 の定義になります。本回で扱った範囲の要件は両者で変わりませんが、「latest はクラスタのバージョンに引きずられる」ことは意識してください。試験で明示的にバージョンを指定するよう求められた場合は、enforce-version: v1.35 のように書けます(ラボで受理されることは実測済みです)
  • MutatingAdmissionPolicy は v1.36 のラボで admissionregistration.k8s.io/v1 として確認したものです。 試験環境(v1.35)で同じ API バージョンで存在するかは確認していません。 試験の解答に使わないでください

LF は Kubernetes のリリースから概ね 4〜8 週間で試験環境を最新マイナーへ整合させます。受験前に必ず公式の試験バージョンを確認してください。

まとめ

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

  • PSP は v1.25 で削除済み。 現在は PSS(3 プロファイル)+ PSA(3 モード・組込 Admission)の 2 本立て
  • 既存 Pod の違反を壊さずに数えられるのは enforce ラベル + --dry-run=server だけ。 warn / audit の dry-run では 1 行も出ない
  • warn は Deployment を書き換えた瞬間に効く。 enforce は Pod が作られる瞬間に効く。見ているタイミングが違うので両方貼る
  • レベル名は検証されるが、バージョンは検証されない。 enforce: strict は拒否されるのに enforce-version: v1.99 は受理される
  • enforce を貼っても既存 Pod は死なない。次の作り直しで死ぬ。 rollout restart は成功を返し、kubectl get deployREADY 2/2 に見え、異常は UP-TO-DATE 0 の 1 箇所だけ。本当の状態は describe rsFailedCreate に出る
  • これは第2巻第16回の quota 超過と同じ診断構造。 「Deployment は正常に見えるのに Pod が増えない」ときは ReplicaSet の Events を見る
  • クラスタ全体を棚卸しすると二極化が見える。 ArgoCD / cert-manager / Traefik / Gatekeeper は Restricted 適合済み、CNI / CSI / MetalLB / 監視 / Velero は Baseline すら満たせない
  • ラベルは貼り忘れる。 塞ぐのは --admission-control-config-fileAdmissionConfiguration。第5回の extraArgs / extraVolumes1 フラグ + 1 extraVolume を足すだけ
  • HA では 3 台すべての Control Plane に配らないと「たまに効く」状態になる。 HAProxy は roundrobin なので、同じ kubectl apply が成功したり失敗したりする。しかも kubectl get では何も見えない
  • 既定を厳しくする前に、特権が要る namespace へ明示ラベルを貼る。 exemptions に並べるより、kubectl get ns --show-labels で読める形のほうが後任者に届く
  • fanclub は Helm と ArgoCD の二重管理。 backend / frontend の正本は Git(selfHeal で巻き戻る)、db / logcollector / CronJob の正本は Helm chart手を入れる前に「このリソースの正本はどこか」を確認する
  • Restricted 化には実測でしか分からない追加が要る。 frontend は /var/run の emptyDirNET_BIND_SERVICE は不要)、db は fsGroup: 999、backend は initContainer 2 本への securityContext
  • PSS では latest タグもレジストリも必須ラベルも表現できない。 そこは VAP(CEL)か Gatekeeper(Rego)で足す
  • 素朴な CEL 式は取りこぼす。 タグ省略(nginx)と initContainers を見落とし、contains(':') だけだとレジストリのポート番号(k8s-registry:5000)を誤検知する
  • 潰した式にもまだ抜けがある。 ephemeralContainers は見ておらず、ダイジェスト指定は通る(こちらは通してよい)
  • Gatekeeper は apply が通ってもポリシーが効いていないことがある。 import rego.v1 のエラーは status.byPod[].errors にしか出ないv3.23.0 は Rego v0 記法
  • Gatekeeper の既定は fail-open(failurePolicy: Ignore)。 落ちればポリシーは黙って素通りする。VAP は組込なので Fail を安心して書ける
  • Gatekeeper にしかできないのは audit(既存リソースの常時棚卸し)。 記法の差は学習コストの差だが、audit の有無は機能そのものの差。これが要るかどうかが選択の分かれ目
  • 一貫した順序は「まず PSS、足りない分だけ VAP、VAP で書けないものだけ Gatekeeper」

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

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

  1. warn=restricted ラベルを --dry-run=server で当てると、その namespace の既存 Pod の違反が一覧できる
  2. namespace に enforce=restricted を貼ると、すでに動いている違反 Pod はその場で削除される
  3. enforce 適用後に rollout restart した Deployment は、kubectl get deployREADY 列だけを見ても異常に気づけない
  4. AdmissionConfiguration を 1 台の Control Plane Node に置けば、HA クラスタ全体に効く
  5. Gatekeeper の ConstraintTemplate は kubectl apply が成功すれば、ポリシーが有効になったと判断してよい
  6. Gatekeeper の validation.gatekeeper.sh は既定で failurePolicy: Ignore であり、Gatekeeper が停止するとポリシーは素通りする
  7. object.spec.containers.all(c, !c.image.endsWith(':latest')) という CEL 式は、タグを省略した nginx も拒否できる
  8. PSS の Restricted が capabilities.add に許す値は NET_BIND_SERVICE だけである
  9. Restricted 準拠の PostgreSQL を新規ボリュームで起動するには、runAsUser に加えて fsGroup の指定が要る
解答と解説

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

問1 は誤りで、本回の核心です。既存 Pod の一括評価が走るのは enforce レベルが変わるときだけで、warn / audit の dry-run では 1 行も出ません。壊さずに数えるなら enforce ラベル + --dry-run=server です。書き込まないので何も壊れません。

問2 は誤りです。削除されません。 既存 8 Pod はすべて Running のまま残ります。壊れるのは次に Pod が作り直されたときで、それがいつ来るかは分かりません。ラベルを貼るだけの操作が、時限式の障害を仕込むことになります。

問3 は正しいです。READY 2/2 は「古い Pod が 2 つ生きている」という意味でしかありません。 異常を示すのは UP-TO-DATE 0 だけで、真因は kubectl describe rs <新 RS>FailedCreate にあります。第2巻第16回の quota 超過と同じ診断構造です。

問4 は誤りです。効きません。 k8s-lb の HAProxy は roundrobin なので、設定のあるノードに当たったときだけ拒否され、無いノードに当たると通ります。 kubectl get では何も異常が見えないため、「たまに効く」という最も追いにくい状態になります。3 台すべてに配り、kubeadm-config ConfigMap にも書いてください。

問5 は誤りです。判断してはいけません。 API Server から見れば ConstraintTemplate は単なるカスタムリソースなので、中の Rego を検証しません。import rego.v1 を書いた場合、apply は成功し、status.byPod[].errorsingest_error が出るだけです。Gatekeeper v3.23.0 は Rego v0 記法で書きます。

問6 は正しいです。fail-open が既定です。可用性を優先した設計で、「Gatekeeper を落とせばポリシーが消える」ことを意味します。VAP は組込なので failurePolicy: Fail を安心して書けます——この非対称性が「まず PSS / VAP」の根拠です。

問7 は誤りです。拒否できません。 nginx:latest で終わらないので endsWith を素通りします。initContainersspec.containers を見ているだけなので取りこぼします。split('/') の最後の要素を見て、タグの有無を確かめる式が必要です。さらに contains(':') だけだとレジストリのポート番号(k8s-registry:5000)を誤検知します。

問8 は正しいです。Restricted が capabilities.add に許す唯一の値です。ただし本書ラボの nginx には不要で、Pod 内の /proc/sys/net/ipv4/ip_unprivileged_port_start0 なので非 root でも 80 番を bind できます。規則としては覚え、実機では確かめる——この 2 段構えが本回の姿勢です。

問9 は正しいです。fsGroupRestricted の要件ではありませんが、無いとボリュームが root:root のままマウントされ、uid 999 の postgres が pgdata を作れません(mkdir: cannot create directory ... Permission denied)。既存ボリュームでは動いてしまうので、新規構築で初めて露見する種類の欠陥です。

次回予告

第9回「Secret 管理(etcd 暗号化 / SealedSecrets / ExternalSecrets)」では、D4 の 2 つ目のコンピテンシーへ進みます。本回は「危険な Pod を作らせない」入口の話でしたが、次回はすでにクラスタの中にあるもの——etcd に平文で保存されている Secret を扱います。

base64 は暗号化ではないという前提から、保存時暗号化(Encryption at Rest)、Git に置ける SealedSecrets、外部ストア連携の ExternalSecrets まで進み、fanclub の DB パスワードを GitOps セーフな形にします。第2回で保留した kube-bench の WARN(1.2.27 / 1.2.28)も、そこで回収します。

→ 詳しくは 第9回 Secret 管理(etcd 暗号化 / SealedSecrets / ExternalSecrets)

前の記事
次の記事