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

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

SBOMとCosign署名【CKS第14回】

公開

新卒インフラエンジニア向け「Kubernetes 実践教科書 ③ CKS セキュリティ・ハードニング編」(全19回)の第14回です。前回はイメージ・ソース・設定ファイルの 3 方向にスキャンをかけ、指摘を仕分けて CI ゲートを 4 ステージにしました。今回も D5 サプライチェーンセキュリティ(配点 20%) の続きで、D5 の最終回です。

本回の主題は 3 つあります。SBOM でイメージの中身を明細化し、Cosign で署名し、ImagePolicyWebhook で出所を絞ります。 そして 3 つを貫く軸が 「どこで何を守るか」です。CI ゲートは「成果物が自分たちの鍵で署名されているか」を見て、Admission は「そのイメージがどこから来たか」を見ます。守る場所も、守るものも違います。

そして本回も、道具が 1 度も動かないところから始まります。 第13回の Trivy は DB の配布元が whitelist に無いところが入口でした。本回の cosign v3.1.2 は、鍵ペアを自分で持っているのに外部サービスを見に行き、既定のフラグだけでは 1 度も署名できません。 しかも検索して出てくる回避策は、v3 では通りません。 この 1 件を解くところが本回の入口です。

  • ラボ Kubernetes v1.36.3
  • CKS 試験環境 v1.35
  • cosign v3.1.2
  • bom v0.7.1
  • OpenSSL 3.5.5
  • Python 3.12.13
  • Trivy v0.70.0
  • Cilium v1.19.6
  • containerd v2.2.6
  • AlmaLinux 10.2
  • ArgoCD v3.4 系
  • 確認日 2026-08-01

イメージのビルドと docker save に使う Docker は 29.6.2 です(第13回と同じです)。

cosign は v3.1.2(2026-07-17 リリース)、bom は v0.7.1(2025-09-26 リリース)を使います。どちらもバージョンをピン留めして取得します。 cosign は v2 系と v3 系で既定の挙動が変わっており、本回で扱う「動かない」も v3 固有です。手元のバージョンが違うと再現しません。

目次
  1. 第14回のスコープ・今ここマップ
    1. 既習範囲 / 本回で上書きする観点
  2. この回のゴール
    1. 本回の起点
    2. 本回で行き来するホスト
  3. どこで何を守るか —— CI ゲートと Admission、同じ制限を書ける 3 つの手段
    1. 4 つの関門
    2. 本回で初めて出る 3 つの用語
    3. 第8回の約束を回収する —— 同じ制限を書ける 3 つの手段
  4. 準備: cosign v3 は既定のままでは 1 度も動かない
    1. 手順 1: cosign と bom を導入する
    2. 手順 2: 鍵ペアを作る(本回で作る鍵はこの 1 回だけ)
    3. 手順 3: 既定のまま署名して失敗を見る
    4. 手順 4: 世に出回っている回避策は v3 では通らない
    5. 手順 5: no_proxy の穴を埋める
    6. 手順 6: 署名が成立する最小の組み合わせ
  5. やってみよう①:SBOM を生成し、読み、イメージに署名する
    1. ステップ1:bom generate –image は本ラボでは通らない
    2. ステップ2:docker save でアーカイブにして渡す
    3. ステップ3:生成した SBOM を bom document outline で読む
    4. ステップ4:ダイジェストを取って署名する
    5. ステップ5:検証する
    6. ステップ6:SBOM を attestation として添付する
  6. SBOM に jar が 1 件も載らない —— 「作った」と「把握した」は別
    1. bom generate のログを読む
    2. 検索して確かめる
    3. 第13回の結果と突き合わせる
    4. -a(深く見る)は落ちる
    5. 同じイメージの SBOM を Trivy にも出させる —— 55 対 423
    6. では SBOM をどう使うのか
  7. 署名はレジストリにどう置かれ、verify は何を返すか
    1. 署名するとレジストリのタグが 1 つ増える
    2. referrers API に非対応でも成立する
    3. verify の終了コードは失敗の種類で違う
    4. 終了コードは 4 値
    5. 表示の落とし穴: tlog を無視しているのに「検証した」と出る
  8. やってみよう②:ゲートに 5 ステージ目を足し、frontend も対象にする
    1. ステップ1:第13回のゲートには穴がある
    2. ステップ1b:ステージ④は広げない —— その判断を数字で書く
    3. ステップ2:5 ステージ目を書く
    4. ステップ3:未署名で落ちることを確認する
    5. ステップ4:frontend にも署名して通す
    6. ステップ5:所要を測る
    7. ステップ6:Git に反映する
  9. ImagePolicyWebhook —— 3 ファイル整合と「署名検証をしない」仕組み
    1. 何をする仕組みか
    2. 3 ファイルの整合
    3. 公式の AdmissionConfiguration
    4. 4 つのパラメータ —— 単位が揃っていない
    5. 3 点セット(フラグ + volumeMounts + volumes)
    6. バックエンドの構成
    7. openssl の導入(Control Plane 3 台)
    8. defaultAllow の意味
  10. やってみよう③:許可レジストリ制限を Control Plane 3 台に適用する
    1. ステップ0:絞る前に、いま何が動いているかを数える
    2. ステップ0b:接頭辞なしのイメージが 7 種ある
    3. ステップ0c:許可リストを決める
    4. ステップ1:バックエンドを立てる(cp-01)
    5. ステップ2:3 ファイルを配置し、apiserver に適用する
    6. ステップ3:動作を確認する(cp-01 だけの状態)
    7. ステップ4:1 台だけでは効かないことを確認する
    8. ステップ5:残り 2 台にも適用する
    9. 演習③のゴール確認
  11. 発展(演習の外): バックエンドに署名検証を足す —— 「誰の署名か」を見る
    1. ステップ1:バックエンドに cosign verify を足す
    2. ステップ2:判定の順序 —— 条件分岐は必須である
    3. ステップ3:この節の締め(試験との距離)
  12. 絞りすぎると Control Plane が kubectl から消える —— 2 つの経路
    1. 静的 Pod のミラーも ImageReview を通る
    2. 絞った直後は何も起きない
    3. 実証: ミラー Pod を削除して kubelet を再起動する
    4. 復旧には kubelet の再起動が要る
    5. 経路②: 署名検証の対象を絞らなかった場合
    6. 直しても約 64 秒は元に戻らない
    7. 2 つの経路のまとめ
  13. やってみよう④:fail-open / fail-closed と判定キャッシュ
    1. ステップ0:apiserver を確実に再起動する方法
    2. ステップ0b:本演習は cp-01 だけで行い、cp-01 を直接指して確認する
    3. ステップ1:fail-closed(defaultAllow: false)でバックエンドを止める
    4. ステップ2:fail-open(defaultAllow: true)でバックエンドを止める
    5. ステップ3:バックエンドを戻して両方を確認する
    6. ステップ4:判定はイメージ単位でキャッシュされる
    7. ステップ5:どちらを選ぶか
    8. 演習後の状態を確定させる —— ImagePolicyWebhook を撤去する
  14. 暗記必須コマンドと、ラボ固有の事情の切り分け
    1. 試験中に参照できるものと、できないもの
    2. 暗記必須コマンド
    3. alias k=kubectl について
    4. keyless 方式(記法の紹介のみ)
    5. 第15回への 1 行
  15. まとめ
  16. 理解度チェック(○×形式・全 9 問)
  17. 次回予告

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

D5 は 4 つのコンピテンシーで構成されます。本回で 4 つすべてが埋まります。

公式コンピテンシー(D5 Supply Chain Security・20%)担当
Minimize base image footprint第12回
Understand your supply chain (e.g. SBOM, CI/CD, artifact repositories)第13回(CI/CD・artifact repositories)+ 本回(SBOM)
Secure your supply chain (permitted registries, sign and validate artifacts, etc.)本回(全体)
Perform static analysis of user workloads and container images (e.g. Kubesec, KubeLinter)第12回 + 第13回

第13回は Understand your supply chainCI/CDartifact repositories を担当しました。本回は残る SBOM を回収し、さらに Secure your supply chain を丸ごと担当します。 これで D5 の 4 コンピテンシーがすべて埋まります。

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

既習どこで本回で上書きする観点
「レジストリは OCI アーティファクトも置ける」(Trivy の DB)第13回今度は自分たちの署名と SBOM を置きます。 置かれ方(タグ名・OCI Image Index)まで確認します
「タグは動く・ダイジェストは動かない」(0.4.0 を作り直さなかった理由)第13回署名の対象をダイジェストで指定する理由になります。 タグ指定でも検証は通りますが、出力の docker-reference にはタグがそのまま入ります
4 ステージの CI ゲート(scan-gate.sh第13回5 ステージ目を足します。 そして対象を backend だけから backend + frontend の 2 つに広げます(第13回の宿題)
終了コードの作法(Trivy は既定 0 / kubesec は既定 2)第12回第13回3 つ目です。cosign は 4 値を返します——成功 = 0 / 署名が 1 つも無い = 10 / 新形式の署名が別の鍵のもの = 1 / .sig 形式の署名がこの鍵では検証できない = 12
ファイル参照フラグの 3 点セット(フラグ + volumeMounts + volumes第5回--authentication-config)・第9回--encryption-provider-config3 回目です。 今回は --admission-control-config-file型が同じなので、手が覚えている前提で進めます
Admission による拒否(PSA / Gatekeeper / VAP)第8回apiserver 本体の Admission プラグインを有効化する側に回ります。 起点の --enable-admission-pluginsNodeRestriction のみです
HA では 1 台だけの設定が効かない(CIS 1.2.5)第2回同じ構図が Admission でも起きます。 12 回中 4 回しか拒否されない実測で回収します
外部から持ち込むバイナリの扱い第2回(SHA256 検証)・第12回・第13回今度は「持ち込むもの」ではなく「出すもの」に署名する側に立ちます

先に 3 つ、本回で覆る前提を予告します。

①「cosign generate-key-pair して cosign sign --key cosign.key <img> すれば署名できる」——v3 ではできません。 鍵ペア方式でも TUF を見に行き、signing config の取得で止まります。 しかも世に出回っている回避策 --tlog-upload=false も、単独では通りません。 ②「SBOM を生成すれば、そのイメージの中身を把握したことになる」——なりません。 本ラボの backend で bom generate が拾ったのは alpine の OS パッケージ 44 件だけで、jar は 1 件も載りません。 第13回で Trivy が見つけた jar 由来の CVE は、この SBOM には現れません。③「許可レジストリを絞れば安全側に倒れる」——倒れません。 許可リストに registry.k8s.io/ を入れ忘れると、クラスタ自身の Control Plane 4 つが kubectl から消えます。 しかも絞った直後には何も起きないので、安全だと誤読しやすいところです。

第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)
    第10回: マルチテナンシー分離 + サンドボックス(gVisor/RuntimeClass)(D4)
    第11回: Cilium 透過暗号化 + L7 NetworkPolicy(Calico → Cilium 移行)(D4)

第5部 サプライチェーンセキュリティ(D5)
    第12回: 最小イメージ + 静的解析(Kubesec + KubeLinter)(D5)
    第13回: Trivy イメージスキャン + CI/CD パイプラインセキュリティ統合(D5)
  ★ 第14回: SBOM(bom)+ Cosign イメージ署名 + 許可レジストリ制限(D5)  ← 今ここ

第6部 監視・ログ・ランタイムセキュリティ(D6)
    第15回: Falco ランタイム検知(D6)

この回のゴール

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

  • bom generate で SPDX 形式の SBOM を生成し、bom document outline で中身を確認できる
  • SBOM に載らないものがあることを知ったうえで、SBOM を「照合の材料」として扱える
  • cosign で鍵ペアを作り、イメージに署名し、検証できる(鍵ペア方式と keyless の違いを説明できる)
  • 署名がレジストリにどう置かれるか(タグ / OCI Image Index)を説明できる
  • cosign verify の終了コード 4 値で失敗の種類を切り分けられる(成功 = 0 / 署名が無い = 10 / 新形式で鍵違い = 1 / 旧 .sig 形式で鍵違い = 12
  • 「署名されているか」ではなく「誰の署名か」を見ているのだと説明できるregistry.k8s.io のイメージは署名されていますが、自分の鍵では通りません)
  • CI ゲートに署名検証を足せる(対象を 1 つに限定しない)
  • ImagePolicyWebhook を 3 ファイル整合で設定し、許可レジストリ以外を拒否できる
  • ImagePolicyWebhook 本体が署名検証をしないことと、バックエンドが任意実装であることを区別して説明できる
  • defaultAllow の fail-open / fail-closed を選び、その代償を説明できる
  • Admission の判定がキャッシュされることを知り、実験結果を誤読しない

本回の起点

起点は第13回の完了状態です。

項目
ノード5/5 Ready・Kubernetes v1.36.3・containerd v2.2.6・AlmaLinux 10.2
Pod111 個・異常 0・15 namespace
fanclubbackend 0.4.1 × 3 / frontend 1.0.0 × 2 / db-0 / logcollector 2
HTTPShttps://fanclub.local/api/members200・会員 3 名
ArgoCDfanclub-api-prodSynced / Healthy・HEAD は 59b6b6f手元では別の値になります第13回の注記を参照)
Helmfanclub revision 8
etcdRAFT TERM 26 で 3 台一致
alma-proxy の whitelist44 行本回も 1 行も足しません
レジストリのタグbackend 6 個0.2.00.4.1)/ frontend 2 個0.1.0 / 1.0.0
GitOps リポジトリ~/gitops/gitops-fanclubscan-gate.sh4 ステージ版

本回で押さえておく起点条件が 4 つあります。

  1. cosign / bom / syft / crane / oras / regctl / skopeo は 1 つも入っていません。 本回で使う 2 つを取得するところから始めます
  2. openssl コマンドが 9 VM のどこにも入っていません。 openssl-libs はありますが、コマンドは別パッケージです
  3. apiserver の --enable-admission-pluginsNodeRestriction のみです
  4. Gatekeeper 本体は残っていますが、ConstraintTemplate は 0 件・ValidatingAdmissionPolicy も 0 件です(第8回の演習後に撤去済みです)

本回で行き来するホスト

ホスト本回での役割
k8s-opsSBOM 生成・署名・検証・CI ゲート。cosign と bom を入れるのはここです
k8s-registry署名と SBOM の置き場所(コマンドは k8s-ops から)
k8s-cp-01 / 02 / 03ImagePolicyWebhook のバックエンドと apiserver の設定。3 台すべてに同じものを配ります
alma-proxy触りません。 whitelist は 44 行のままです

どこで何を守るか —— CI ゲートと Admission、同じ制限を書ける 3 つの手段

演習に入る前に、本回の 4 本の演習がどこに置かれるかを 1 枚で確認します。本回は 2 つの場所に防御を置きます。守るものは違います。

置く場所いつ効くか守るもの本回での実装
CI ゲートscan-gate.shビルドした後・デプロイする前成果物が自分たちの鍵で署名されているか演習②(5 ステージ目)
Admission(ImagePolicyWebhook)クラスタの入口(Pod オブジェクトが作られるとき)どこから来たイメージか(出所)演習③

4 つの関門

第12回から本回までで、コードを書いてからクラスタで動き出すまでのあいだに 4 つの関門が並びます。

#関門効くタイミング見ているもの本巻での回
1静的解析(kubesec / kube-linter / trivy config書いた直後マニフェスト・Dockerfile のテキスト第12回・第13回
2イメージスキャンtrivy imageビルドの直後イメージの中身(CVE)第13回
3署名検証cosign verifyデプロイの直前(CI)成果物が自分たちの鍵で署名されているか本回・演習②
4Admission(ImagePolicyWebhook)クラスタの入口イメージの出所(レジストリ)本回・演習③
サプライチェーンの 4 つの関門とそれぞれが守るものを示す図。左から①静的解析(kubesec / kube-linter / trivy config・書いた直後・マニフェストと Dockerfile のテキスト・第12回と第13回)②イメージスキャン(trivy image・ビルドの直後・イメージの中身の CVE・第13回)③署名検証(cosign verify・デプロイの直前の CI・成果物が自分たちの鍵で署名されているか・本回の演習②)④Admission(ImagePolicyWebhook・クラスタの入口・イメージの出所であるレジストリ・本回の演習③)の 4 つを並べる。①から③は CI ゲート(scan-gate.sh 5 ステージ)に属し、守るものは「自分たちのパイプラインを通ったものが署名されているか」。④だけが Admission で、パイプラインの外から入るものを止め、守るものは「どこから来たか(出所)」。下段に、ImagePolicyWebhook 本体は署名検証をせず、apiserver はイメージ参照を渡すだけで署名の概念を持たないこと、バックエンドは任意実装なので署名検証を足すこともできることを注記
図1:サプライチェーンの 4 つの関門と、それぞれが守るもの

①〜③はすべて「自分たちのパイプラインを通ったもの」にしか効きません。 CI を通さずに誰かが kubectl run --image=docker.io/... を打てば、①〜③は 1 つも動きません。④だけが「パイプラインの外から入ってくるもの」を止められます。

逆に、④は「自分たちのレジストリから来た」ことしか確かめません。 そのイメージが署名されているか、CVE がいくつあるかは見ていません。どちらか一方では足りません。

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

用語説明
SBOM(Software Bill of Materials)イメージに含まれるパッケージ・ライブラリの明細です。新しい CVE が公表されたときに「どのイメージが該当ライブラリを含むか」を照合するための材料になります
SPDXSBOM の代表的な標準形式のひとつです。bom が出力するのはこの形式で、本ラボの実測は SPDX-2.3 でした
attestation(in-toto Statement)「このイメージについて、私はこう主張する」という署名付きの言明です。SBOM をイメージに紐づけるときに使う形です

第8回の約束を回収する —— 同じ制限を書ける 3 つの手段

先に第8回の指示を解除します。

第8回の末尾で「Gatekeeper 本体は残します。手元の環境を軽くしたい場合は helm uninstall で消せますが、その場合は第14回で入れ直してください」と書きました。本回は Gatekeeper を使いません。第8回で helm uninstall した読者は、入れ直さなくて構いません。 本回は記法を並べて比べるところまでで、Gatekeeper の実行は伴いません。

「許可レジストリ以外を拒否する」という制限は、本巻で扱った手段だけでも 3 通りで書けます。 ここでは k8s-registry:5000/ 以外を拒否する制限を、3 つの記法で並べます。

① OPA Gatekeeper(K8sAllowedRepos の Constraint)

apiVersion: constraints.gatekeeper.sh/v1beta1
kind: K8sAllowedRepos
metadata:
  name: allowed-registries
spec:
  match:
    kinds:
      - apiGroups: [""]
        kinds: ["Pod"]
  parameters:
    repos:
      - "k8s-registry:5000/"

② ValidatingAdmissionPolicy(CEL・第8回で構文は既習)

apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingAdmissionPolicy
metadata:
  name: allowed-registries
spec:
  failurePolicy: Fail
  matchConstraints:
    resourceRules:
      - apiGroups: [""]
        apiVersions: ["v1"]
        operations: ["CREATE", "UPDATE"]
        resources: ["pods"]
  validations:
    - expression: "object.spec.containers.all(c, c.image.startsWith('k8s-registry:5000/'))"
      message: "image must come from k8s-registry:5000"

③ ImagePolicyWebhook(本回で実装するもの) —— apiserver のフラグ 2 つ + AdmissionConfiguration + kubeconfig + 外部バックエンドの組み合わせです。YAML 1 枚では済みません。

手段設定が置かれる場所判定ロジックの置き場所許可レジストリ制限署名検証本巻での扱い
① Gatekeeperクラスタ内のリソースkubectl applyRego(ConstraintTemplate)×第8回で本体を導入。本回では使いません
② VAPクラスタ内のリソースkubectl applyCEL 式(apiserver 組み込み)×第8回で latest 禁止を実演。構文は既習
③ ImagePolicyWebhook(本回)apiserver のフラグ + ノード上のファイル自作のバックエンド(任意実装)本体は ×(バックエンドの実装しだいで ○)本回で実装します
④ sigstore policy-controller / Connaisseurクラスタ内のリソース専用コントローラ本書スコープ外(名前のみ紹介)

同じ制限を 3 通りで書けます。違うのは「設定がどこに置かれるか」です。

  • ①②はクラスタの中に置きます。 kubectl apply で入り、kubectl get で見え、RBAC で守られ、GitOps に乗ります
  • ③はノードのファイルシステムと apiserver の起動オプションに置きます。 kubectl からは見えず、Git にも乗らず、3 台に手で配ります

運用のしやすさだけを見れば①②が上です。 それでも CKS が ImagePolicyWebhook を問うのは、これが「apiserver の Admission 設定ファイルを自分で書く」唯一の題材だからです。第5回・第9回で 2 回やったファイル参照フラグの 3 点セットが、ここで 3 回目として出てきます。

そして③だけが「判定ロジックを自分で書ける」手段です。 ①②はポリシー言語(Rego / CEL)の表現力の範囲でしか判定できませんが、③のバックエンドは任意のコードです。だから署名検証を足せます(本回の発展の節で実装します)。この自由さが、そのまま事故の原因にもなります——絞りすぎたときに何が起きるかは、後半の節で実測を見ます。

「第8回で実現済み」ではありません。

第8回は VAP で latest タグ禁止を実演し、Gatekeeper は本体だけを残しました。 現在このクラスタには ConstraintTemplate が 0 件・VAP も 0 件です。つまり許可レジストリ制限は「実現済み」ではなく「同じことを 3 通りで実現できる」という状態です。

準備: cosign v3 は既定のままでは 1 度も動かない

本回の 1 つ目の山です。 演習にはしませんが、手順は全部見せます。6 手順あります。

手順 1: cosign と bom を導入する

先にどのノードに何を入れるかを決めます。

ノードcosignbomopenssl用途
k8s-ops不要SBOM 生成・署名・検証・CI ゲート
k8s-cp-01 / 02 / 03発展の節を実施する場合のみ要不要Admission バックエンドと証明書の作成

whitelist の追加は不要です。 github.com / objects.githubusercontent.com / release-assets.githubusercontent.com は第2巻までに登録済みで、本回も whitelist は 44 行のままです。 第12回は gcr.io を 1 行足しましたが、第13回も本回も 1 行も足しません。

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

$ curl -sSL -O https://github.com/sigstore/cosign/releases/download/v3.1.2/cosign-linux-amd64
$ curl -sSL -O https://github.com/kubernetes-sigs/bom/releases/download/v0.7.1/bom-amd64-linux
$ ls -l cosign-linux-amd64 bom-amd64-linux

取得したものの実測値です。

ツールバージョン公開日ファイル名バイナリサイズ取得時間
cosignv3.1.22026-07-17cosign-linux-amd64141,150,460 バイト(141MB)3,849 ms
bomv0.7.12025-09-26bom-amd64-linux27,682,593 バイト(27.7MB)1,163 ms

サイズは 2 回測っても 1 バイトも変わりませんが、取得時間は変わります。 別の日に同じ 2 本を取ったときは cosign 14,820 ms / bom 3,426 ms でした。4 倍近い差は回線とミラーの状況によるものなので、手元の値が一致しなくて構いません——確かめてほしいのはサイズが一致することのほうです。

実行結果:

-rw-r--r--. 1 developer developer  27682593  8月  1 18:48 bom-amd64-linux
-rw-r--r--. 1 developer developer 141150460  8月  1 18:48 cosign-linux-amd64

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

$ sudo install -m 0755 cosign-linux-amd64 /usr/bin/cosign
$ sudo install -m 0755 bom-amd64-linux /usr/bin/bom
$ cosign version
$ bom version

実行結果(アスキーアートは省いています):

cosign: A tool for Container Signing, Verification and Storage in an OCI registry

GitVersion:    v3.1.2
GitCommit:     193d2153431f8bb0d945a4c1ee721872f73add67
GitTreeState:  clean
BuildDate:     2026-07-17T14:32:20Z
GoVersion:     go1.26.4
Compiler:      gc
Platform:      linux/amd64

bom: A tool for working with SPDX manifests

GitVersion:    v0.7.1
GitCommit:     1ab6284
GitTreeState:  clean
BuildDate:     2025-09-26T06:24:16Z
GoVersion:     go1.25.1
Compiler:      gc
Platform:      linux/amd64

GoVersion の行が、本回のいくつかの挙動の理由になります。 どちらも Go で書かれているので、プロキシ除外は NO_PROXY(大文字)を優先して読みます。準備の手順 5 でつまずいたのはこれが理由です。

/usr/local/bin ではなく /usr/bin に置きます。 sudosecure_path/usr/local/bin が含まれていないため、/usr/local/bin に置くと sudo cosign ... が「コマンドが見つからない」になります。第2回の kube-bench・第12回の Kubesec / KubeLinter と同じ作法です。install -m 0755 は「コピー + パーミッション設定」を 1 コマンドで行います(cp してから chmod するのと同じです)。

cosign のバイナリは 141MB あります。 本回の発展の節で Control Plane 3 台にも配る場合、合計 564MBになります。発展を実施するかどうかで、必要なディスクが変わります。

実行コマンド(k8s-ops・developer・導入の前後で 1 回ずつ):

$ df -h /

/ の使用量は導入前 11G、導入後 12G でした。 増えたのは 2 本のバイナリ(約 169MB)だけではありません——このあと演習①で docker save165MB の tar を作ります。SBOM の生成には、いったんイメージ 1 個分のディスクが要ります。

本番ガードレール①: 署名する道具そのものも、素性を確かめてから使います。

第2回でプラットフォームバイナリの SHA256 検証を扱ったのと同じです。「署名を検証する道具」が改ざんされていたら、保証されるのは「検証が通った」ということだけになります。

GitHub Release には checksums ファイルが併せて置かれています。取得 → SHA256 を照合 → 配置の順にしてください。

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

$ curl -sSL -O https://github.com/sigstore/cosign/releases/download/v3.1.2/cosign_checksums.txt
$ curl -sSL -O https://github.com/kubernetes-sigs/bom/releases/download/v0.7.1/checksums.txt
$ sha256sum -c --ignore-missing cosign_checksums.txt

実行結果:

cosign-linux-amd64: 完了

終了コードは 0 です。ファイル名が 2 つの Release で違います——cosign は cosign_checksums.txtbom は checksums.txt です。bom_0.7.1_checksums.txt のような名前を推測して取りに行くと 404 になります。チェックサムのファイル名は Release ごとに確認してください。

出力が日本語の「完了」になっているのは、ラボのロケールが日本語だからです。 試験環境は英語ロケールなので OK と出ます。 第2回でも同じ注意をしました——成功の文字列で判定するスクリプトは、ロケールが変わると壊れます。 判定は終了コードで行ってください。

ここで止める判断もあります。 checksums ファイル自体の署名を検証しようとすると、cosign がまだ無い状態で cosign の署名を検証するという入れ子になります。本書は SHA256 の照合までで止めます。

手順 2: 鍵ペアを作る(本回で作る鍵はこの 1 回だけ)

本回の鍵・SBOM・tar はすべて ~/cosign-lab/ に置きます。/tmp は使いません——再起動で消えるため、後の節で同じ鍵を参照できなくなります。

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

$ mkdir -p ~/cosign-lab
$ cd ~/cosign-lab
$ cosign generate-key-pair
$ ls -l cosign.key cosign.pub

実行結果(ls -l のパーミッション部分):

600 cosign.key
644 cosign.pub

秘密鍵は 600(本人だけが読み書きできる)、公開鍵は 644(誰でも読める)で作られます。 cosign が最初から用途どおりのパーミッションを付けますので、chmod を足す必要はありません。

パスフレーズは対話で聞かれます。 本演習では空のまま Enter で進めて構いません(本番での扱いは演習①のガードレール③に書きます)。スクリプトや CI から非対話で実行すると、プロンプトを出せずに失敗します。

Error: inappropriate ioctl for device

「端末が無い」という意味のエラーです。 鍵の内容やレジストリとは関係ありません。CI で鍵を作るなら COSIGN_PASSWORD を環境変数で渡します——ただし本書は鍵を CI で作りません(ガードレール③のとおり、鍵は人が作って安全な場所に置きます)。

cosign.key が秘密鍵、cosign.pub が公開鍵です。これ以降、署名には cosign.key、検証には cosign.pub を使います。検証する側に配るのは cosign.pub だけでよく、cosign.key は配りません。

cosign generate-key-pair を打つのは本回でこの 1 回だけです。

演習①でも同じ鍵を使います。同じディレクトリで 2 回目を打つと、上書きしてよいかを対話で聞かれます。

WARNING: File cosign.key already exists. Overwrite?
Are you sure you would like to continue? [y/N]

既定は N(上書きしない)です。 ここで y と答えると新しい鍵で上書きされ、それまでに付けた署名は古い公開鍵でしか検証できなくなります——同じ画面で Enter を押す癖がついていると、鍵を作り直したことに気づかないまま「検証できない」に悩むことになります。

手順 3: 既定のまま署名して失敗を見る

実行コマンド(k8s-ops・developer・~/cosign-lab で):

$ cosign sign --key cosign.key k8s-registry:5000/fanclub-backend:0.4.1

実行結果:

WARNING: Could not fetch trusted_root.json from the TUF repository. Continuing with individual targets. Error from TUF: error getting live trusted root: failed to create TUF client failed to load metadata: tuf refresh failed: Get "https://tuf-repo-cdn.sigstore.dev/15.root.json": Forbidden
Error: error getting signing config from TUF: error getting signing config from TUF: failed to load metadata: tuf refresh failed: Get "https://tuf-repo-cdn.sigstore.dev/15.root.json": Forbidden
error during command execution: error getting signing config from TUF: error getting signing config from TUF: failed to load metadata: tuf refresh failed: Get "https://tuf-repo-cdn.sigstore.dev/15.root.json": Forbidden

3 行目は 2 行目の繰り返しです。 cosign はエラーを 2 回書きますError:error during command execution:)。同じ内容なので、2 行目まで読めば十分です。

読むべき点が 3 つあります。

  1. 鍵ペア方式なのに TUF を見に行きます。 「鍵を自分で持っているのだから外部サービスは要らない」という直感が外れます
  2. 1 行目は WARNING で続行し、2 行目でエラーになります。 WARNING と Error を読み分けてください。trusted root が取れないだけなら続行しますが、signing config が取れないと続行できません
  3. sigstore の公開サービスは 3 つとも本ラボから到達できません(下表)
URLHTTP コード
https://rekor.sigstore.dev/api/v1/log000(接続失敗)
https://fulcio.sigstore.dev/api/v1/rootCert000
https://tuf-repo-cdn.sigstore.dev/root.json000

TUF(The Update Framework)とは。

「配布物のメタデータを署名で守る枠組み」です。sigstore はこれを使って「どのサービスを信頼するか」の一覧(trusted root / signing config)を配っています。cosign v3 は、鍵ペアを使う場合でも既定でこの一覧を取りに行きます。

手順 4: 世に出回っている回避策は v3 では通らない

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

$ cosign sign --key cosign.key --tlog-upload=false k8s-registry:5000/fanclub-backend:0.4.1

実行結果:

Flag --tlog-upload has been deprecated, prefer using a --signing-config file with no transparency log services
Error: --tlog-upload=false is not supported with --signing-config or --use-signing-config.

検索して出てくる cosign sign --key cosign.key --tlog-upload=false <img> は、v3 では動きません。 理由は --use-signing-config の既定が true で、これが TUF から signing config を取りに行くからです。フラグ同士が衝突しています。

第13回の Trivy と同じ構図です。 あちらは「ヘルプの 4 語(in order of priority)からは条件が読み取れず、ドキュメント側に書いてあった」でした。こちらはエラーメッセージ自身が代替を 2 つ提示しています——cosign signing-config create を使うか、公開インスタンスの JSON から .rekorTlogUrls を削除するか。エラーメッセージを最後まで読むだけで、次の手が分かります。

手順 5: no_proxy の穴を埋める

--use-signing-config=false を足すと TUF は見に行かなくなりますが、今度はレジストリへのアクセスがプロキシに吸われます。

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

$ cosign sign --key cosign.key --use-signing-config=false --yes k8s-registry:5000/fanclub-backend:0.4.1

実行結果:

WARNING: Image reference k8s-registry:5000/fanclub-backend:0.4.1 uses a tag, not a digest, to identify the image to sign.
    This can lead you to sign a different image than the intended one. Please use a
    digest (example.com/ubuntu@sha256:abc123...) rather than tag
    (example.com/ubuntu:latest) for the input to cosign. The ability to refer to
    images by tag will be removed in a future release.

Error: signing [k8s-registry:5000/fanclub-backend:0.4.1]: accessing entity: Get "https://k8s-registry:5000/v2/": Forbidden

ここで cosign 自身が「タグではなくダイジェストを使え」と言ってきます。 本回が署名・検証をダイジェストで行う理由は、cosign の作者がそう書いているからでもあります——タグで参照する機能は将来のリリースで削除されると明記されています。

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

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

2 行目を省くと、この先の cosign はすべて失敗します。

小文字の no_proxy だけでは cosign に効きません。

実測です。/etc/profile.d/proxy.shno_proxyNO_PROXY両方を同じ値で export しています。ここで小文字だけを書き換えると、大文字は古い値のまま残ります。

書き換えたものcurl http://k8s-registry:5000/v2/cosign verify
no_proxy だけ200失敗(Squid の ERR_ACCESS_DENIEDexit 1
no_proxyNO_PROXY の両方200成功exit 0
NO_PROXY だけ(小文字は古いまま)成功exit 0

curl は小文字で足り、cosign は大文字を見ます。 cosign は Go で書かれており、Go の標準ライブラリは大文字を優先して読みますcurl で疎通を確かめたから大丈夫」が成り立ちません。

これは第13回で踏んだ落とし穴と同じ形です。 あちらは「フラグを付けて確認したら、確認したかった設定が参照されていなかった」でした。こちらは「別の道具で疎通を確かめたら、その道具は別の設定を読んでいた」です。 確かめるときは、確かめたい当人に確かめさせてください。

/etc/profile.d/proxy.shno_proxy には k8s-registry が入っていません。 列挙されているのは k8s-lb / k8s-cp-01〜03 / k8s-wl-01〜02 / alma-proxy です。一方、apiserver の静的 Pod マニフェストの NO_PROXY には k8s-registry が入っています(第2巻で追加済みです)。同じ「名前解決の除外設定」が、片方だけ更新されて片方が取り残されています。

docker push が通っているのに cosign sign が通らないのは、docker デーモンと cosign が別の設定を読んでいるからです。docker は /etc/systemd/system/docker.service.d/ 配下の環境変数を、cosign はログインシェルの環境変数を読みます。プロキシ除外は 1 箇所ではありません。

本書は恒久修正しません。

本回は演習中の export に留めます。 読者の環境は第1巻・第2巻の手順どおりに構築されており、/etc/profile.d/proxy.shk8s-registry が無い状態が「正しい」からです。第14回の都合で過去巻の前提を書き換えません。

これは第13回と同じ姿勢です。 第13回は「whitelist に 1 行足せば動く」場面で、足さずに済む配布元を探して解決しました。 本回も同じで、1 行の export で済むものを、環境の恒久設定に昇格させません。

毎回打つのが煩わしければ、/etc/profile.d/proxy.shno_proxyk8s-registry を足す方法があります。ただし本書の手順は export で通します。 そして演習②で書く scan-gate.sh の中では、スクリプト自身が明示的に export します——読者の環境は変えませんが、スクリプトは自分の前提を自分で用意します。

手順 6: 署名が成立する最小の組み合わせ

動く組み合わせだけを見せずに、1 つずつフラグを足していく過程を見せます。 失敗は 4 段階あり、5 つ目で成立します。

#組み合わせ結果所要
1既定(--key のみ)FAIL(TUF の signing config が取れない)
21 + --use-signing-config=falseFAILhttps://k8s-registry:5000/v2/ が Forbidden)18 ms
32 + --allow-http-registryno_proxy 未修正)FAIL(Squid の ERR_ACCESS_DENIED の HTML が返る)18 ms
43 + no_proxyNO_PROXY の両方を修正FAILPost "https://rekor.sigstore.dev/api/v1/log/entries": Forbidden2 回リトライ136 ms
54 + --tlog-upload=false成功Pushing signature to: k8s-registry:5000/fanclub-backend227 ms
6--signing-config <サービス無しの JSON> + --allow-http-registry成功(ただし TUF の WARNING は出る)231 ms
cosign v3 の署名が成立するまでの 6 段階を示す図。失敗が 4 段階続き 5 つ目で成立する。①既定(--key のみ)は TUF の signing config が取れず FAIL、失敗の場所は tuf-repo-cdn.sigstore.dev。②--use-signing-config=false を足すと https://k8s-registry:5000/v2/ が Forbidden で FAIL、18 ミリ秒、失敗の場所はプロキシ。③--allow-http-registry を足すと Squid の ERR_ACCESS_DENIED の HTML が返って FAIL、18 ミリ秒、no_proxy が未修正のまま。④no_proxy と NO_PROXY の両方を修正すると rekor.sigstore.dev への POST が Forbidden で FAIL、136 ミリ秒、2 回リトライ、失敗の場所は Rekor。⑤--tlog-upload=false を足して成功、227 ミリ秒、署名が k8s-registry:5000/fanclub-backend へ push される。⑥別解として --signing-config にサービス無しの JSON を渡す方法も成功、231 ミリ秒、TUF の WARNING は出る。失敗の場所が TUF・プロキシ・プロキシ・Rekor と変わっていくので、同じエラーが出続けているのか別のエラーに変わったのかを毎回読み分ける
図2:cosign v3 の署名が成立するまでの 6 段階(4 段階の失敗を経て)

失敗の場所が 3 回とも違います。 #1 は TUF、#2 と #3 はプロキシ(社内レジストリ k8s-registry:5000 への経路)、#4 は Rekor です。1 つ潰すと次が出てくるので、「同じエラーが出続けている」のか「別のエラーに変わった」のかを毎回読み分けてください。 変わっているなら前進しています。

成立する最小のコマンドです。

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

$ export no_proxy="$no_proxy,k8s-registry"
$ export NO_PROXY="$no_proxy"
$ cosign sign --key cosign.key --use-signing-config=false --allow-http-registry --tlog-upload=false --yes k8s-registry:5000/fanclub-backend@sha256:b8113a77a17d7c7ad5ac794cce2edef9b7fa99c474154e7573c29b191529b016

#6 は別解です。 「サービスを 1 つも使わない」signing config を自分で作る方法で、エラーメッセージが提示していた代替のひとつです。

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

$ cosign signing-config create --no-default-fulcio --no-default-rekor --no-default-oidc --no-default-tsa --out sc-none.json
$ cat sc-none.json

実行結果(104 バイトの JSON):

{"mediaType":"application/vnd.dev.sigstore.signingconfig.v0.2+json","rekorTlogConfig":{},"tsaConfig":{}}

本書は #5 の組み合わせで進めます。 フラグが 4 つ並びますが、どれも「何のために足したか」が言えるものです。#6 は「同じことを設定ファイルで書ける」ことを知っておく用途にとどめます。

名前の似た 2 つのフラグは別物です。

フラグ意味本ラボで必要か
--allow-http-registryHTTP 平文のレジストリを許可する要りますk8s-registry:5000 は HTTP 平文)
--allow-insecure-registryHTTPS の証明書検証をスキップする要りません

自己署名証明書のレジストリなら後者、平文なら前者です。名前が似ているので、エラーが変わらないときはフラグを取り違えていないか確認してください。

本番ガードレール②: 本回で足したフラグのうち 2 つは「本番では足さないもの」です。

--allow-http-registry —— 本番のレジストリを HTTP 平文で運用しません。 本ラボがこのフラグを必要とするのは、学習用に平文のレジストリを立てているからです。平文のレジストリでは、署名を push する経路そのものが改ざんできます。 署名で守ろうとしているものが、署名を運ぶ経路で崩れます。本番では TLS を張り、このフラグを使う場面を作りません。

--tlog-upload=false —— 透明性ログに記録を残さないという選択です。 Rekor(透明性ログ)は「いつ・誰が・何に署名したか」を第三者が後から検証できる追記専用の記録です。これを切ると、鍵が漏れたときに「その鍵でいつ何が署名されたか」を外部の記録から辿れなくなります。 本ラボは Rekor に到達できないので切っていますが、インターネットに出られる本番では切りません。 切るなら、社内に Rekor を立てるか、署名の記録を別の方法で残します——どちらもしないなら、それは「鍵ペア署名で改ざん検知だけをする」という限定された防御であると認識して使ってください。

共通する読み方は 1 つです。 「動かすために足したフラグ」は、たいてい何かの保護を外しています。 足すときに「何を外したか」を書き留めてください。書き留めていないものは、そのまま本番へ持って行かれます。

やってみよう①:SBOM を生成し、読み、イメージに署名する

所要時間の目安: 12 分docker save の待ちを含みます)。SBOM を生成し、bom document outline で読み、イメージに署名し、検証し、SBOM を attestation として添付します。試験相当の作業(SBOM を生成して読み、署名・検証する)なら 6 分です。

鍵ペアは準備の手順 2 で作成済みです(~/cosign-lab/cosign.keycosign.pub)。本演習は同じディレクトリで続けます。cosign generate-key-pair をもう一度打たないでください——上書き確認が出ますが、誤って y と答えるとそれまでの署名が検証できなくなります

ステップ1:bom generate --image は本ラボでは通らない

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

$ bom generate --image k8s-registry:5000/fanclub-backend:0.4.1 --output backend-sbom.spdx.json --format json

実行結果:

level=fatal msg="... fetching remote descriptor: Get \"https://k8s-registry:5000/v2/\": http: server gave HTTP response to HTTPS client"

レジストリが HTTP 平文なのに、bom は HTTPS で話しかけています。 --image はここで fatal になり、SBOM は 1 バイトも生成されません。

同じ「HTTP 平文のレジストリ」という条件に対して、cosign は逃げ道を持ち、bom は持ちません。 cosign には --allow-http-registry がありますが、bom generate の全フラグを確認しても相当するものはありません。

道具ごとに「想定している環境」が違います。 bom は「レジストリは TLS で立っているもの」を前提に作られています。前提が合わないときは、道具を責めるのではなく別の入口を探してください。

ステップ2:docker save でアーカイブにして渡す

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

$ cd ~/cosign-lab
$ docker save k8s-registry:5000/fanclub-backend:0.4.1 -o backend-0.4.1.tar
$ bom generate --image-archive backend-0.4.1.tar --name fanclub-backend-0.4.1 --output backend-sbom.spdx.json --format json
$ ls -l backend-0.4.1.tar backend-sbom.spdx.json

実測値です。

手順所要サイズ
docker save2,406 ms165,674,496 バイト
bom generate --image-archive --name2,359 ms生成物 69,273 バイト

生成物の属性は SPDX-2.3 / creators: ["Tool: bom-v0.7.1"] / licenseListVersion: 3.27 です。

--name を付けている理由があります。付けないと、SBOM が記述する対象の名前が SBOM-SPDX-<UUID> という自動生成の文字列になります。 実測で並べると次のとおりです。

--nameSPDX の name所要生成物
付けないSBOM-SPDX-<UUID>(毎回変わる)2,382 ms69,298 バイト
付けるfanclub-backend-0.4.12,359 ms69,273 バイト

差は 25 バイトと、名前の文字数のぶんだけです。 それでも付けます——SBOM は後から照合するための材料なので、何の部品表なのかが名前から分かる状態にしておきます。 SBOM-SPDX-<UUID> のまま何十個も保管すると、どれがどのイメージのものか、中身を開くまで分かりません。

ステップ3:生成した SBOM を bom document outline で読む

本巻のカリキュラムが掲げる学習目標は「bom で SBOM を生成・検証できる」です。 生成しただけでは半分しか満たしていません。

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

$ bom document outline backend-sbom.spdx.json

実行結果(抜粋):

 📂 SPDX Document fanclub-backend-0.4.1
   │
   ├ 📦 DESCRIBES 1 Packages
   │
   ├ ...
   │  └ CONTAINS PACKAGE acl-libs@2.3.2-r1
   │  └ ...

出力は 73 行です。 1 行目に --name で付けた fanclub-backend-0.4.1 が出て、その下に CONTAINS PACKAGE <名前>@<バージョン> の形で 44 件が並びます。

bom document outline は「SBOM を読む」ためのサブコマンドです。生成された SPDX-JSON は 69,273 バイトの JSON で、そのまま開いても構造が追えません。outline は「この文書は何を記述しているか」「その中に何が含まれるか」を木構造で見せます。

bom は試験中に参照できる数少ないツールですkubernetes-sigs.github.io/bom/cli-reference/)。フラグは引けますが、サブコマンドの存在を知らなければ引きようがありません。 generatedocument outline の 2 つは手で触っておいてください。

このツリーに何が並んでいて、何が並んでいないかは、次の節でもう一度扱います。44 件を眺めるだけでは「無いもの」に気づけません。

ステップ4:ダイジェストを取って署名する

準備の手順 2 で作った鍵をそのまま使います。

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

$ docker inspect --format '{{json .RepoDigests}}' k8s-registry:5000/fanclub-backend:0.4.1

実行結果:

["k8s-registry:5000/fanclub-backend@sha256:b8113a77a17d7c7ad5ac794cce2edef9b7fa99c474154e7573c29b191529b016"]

push 済みのイメージなら RepoDigests に入っています。 レジストリ側に問い合わせる方法(マニフェストの Docker-Content-Digest ヘッダを読む)でも同じ値が返ることを確認しました。2 つの経路で同じダイジェストが出ることが確認できていれば、どちらを使っても構いません——演習②の scan-gate.shローカルに pull していないイメージでも動くように、レジストリ API 側を使います。

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

$ export no_proxy="$no_proxy,k8s-registry"
$ export NO_PROXY="$no_proxy"
$ cosign sign --key cosign.key --use-signing-config=false --allow-http-registry --tlog-upload=false --yes k8s-registry:5000/fanclub-backend@sha256:b8113a77a17d7c7ad5ac794cce2edef9b7fa99c474154e7573c29b191529b016

実行結果(末尾):

Pushing signature to: k8s-registry:5000/fanclub-backend

終了コードは 0、所要は 216 ms です(準備の手順 6 の表では 227 ms でした。ミリ秒の差は実行ごとに出ます)。

なぜタグではなくダイジェストで署名するのか。 第13回0.4.0 を作り直さずに 0.4.1 を新規に作りました。理由は「タグは動く。ダイジェストは動かない」でした。

署名はこの性質の上に成り立ちます。 :0.4.1 というタグに署名しても、そのタグが別のイメージに付け替えられたら署名は無意味になります。ダイジェストはイメージの内容から計算されるので、中身が 1 バイトでも違えば別のダイジェストになります。 だから署名の対象はダイジェストで指定します。

ただし verify はタグ指定でも通ります。 そのとき出力の docker-reference にはタグがそのまま入ります。「通った」ことと「何を検証したか」は別なので、CI ではダイジェストを使います。

本番ガードレール③: cosign.key はパスフレーズ付きで作り、リポジトリに入れません。

本演習では手元に置きますが、本番では鍵を CI の Secret ストアか KMS に置きます。 cosign は --key awskms://... のように KMS 参照も受け付けます。

鍵が漏れると「自分たちが署名した」と主張できるイメージを誰でも作れます——署名は「鍵を守れている限りにおいて」意味を持ちます。そして cosign.pub は逆に、広く配って構いません。 検証する側が全員持っている必要があります。

ステップ5:検証する

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

$ cosign verify --key cosign.pub --allow-http-registry --insecure-ignore-tlog k8s-registry:5000/fanclub-backend@sha256:b8113a77a17d7c7ad5ac794cce2edef9b7fa99c474154e7573c29b191529b016

終了コードは 0、所要は 28 ms です。署名が 1 要素返ります(この時点ではまだ attestation を付けていないので 1 つです)。署名の検証は、署名を作るより速く終わります——鍵で照合するだけで、レジストリから取るのは署名そのものだけだからです。

実行結果:

WARNING: Skipping tlog verification is an insecure practice that lacks transparency and auditability verification for the signature.

Verification for k8s-registry:5000/fanclub-backend@sha256:b8113a77a17d7c7ad5ac794cce2edef9b7fa99c474154e7573c29b191529b016 --
The following checks were performed on each of these signatures:
  - The cosign claims were validated
  - Existence of the claims in the transparency log was verified offline
  - The signatures were verified against the specified public key

[{"critical":{"identity":{"docker-reference":"k8s-registry:5000/fanclub-backend@sha256:b8113a77a17d7c7ad5ac794cce2edef9b7fa99c474154e7573c29b191529b016"},"image":{"docker-manifest-digest":"sha256:b8113a77a17d7c7ad5ac794cce2edef9b7fa99c474154e7573c29b191529b016"},"type":"https://sigstore.dev/cosign/sign/v1"},"optional":{}}]

5 行目をそのまま信じないでください。

  - Existence of the claims in the transparency log was verified offline

「透明性ログの記録を検証した」と読めますが、--insecure-ignore-tlog を付けて透明性ログの検証を飛ばしています。 1 行目の WARNING がそのことを言っています。同じ出力の中で、警告と「検証した」が並んでいます。

ツールの出力は、そのツールが何を確かめたかを常に正確に述べるとは限りません。 本回で足したフラグが何を外したかを覚えておくことが、この行を正しく読む唯一の方法です。

--insecure-ignore-tlog を外すと、検証はできません。

実行結果:

Error: setting trusted material: getting trusted root from TUF for new bundle verification: error getting live trusted root: failed to create TUF client failed to load metadata: tuf refresh failed: Get "https://tuf-repo-cdn.sigstore.dev/15.root.json": Forbidden

署名するときと同じ TUF です。 署名も検証も、既定では sigstore の信頼リストを取りに行きます——本ラボはそこへ到達できないので、署名側では --use-signing-config=false、検証側では --insecure-ignore-tlog で外しています。

検証にも 2 つのフラグが要ります。 --allow-http-registry は署名時と同じ理由(HTTP 平文のレジストリ)、--insecure-ignore-tlog は「透明性ログの検証をしない」という指定です。署名時に --tlog-upload=false で記録を残していないので、検証側も見に行かないという対応関係になります。

ステップ6:SBOM を attestation として添付する

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

$ cosign attest --predicate backend-sbom.spdx.json --type spdxjson --key cosign.key --use-signing-config=false --allow-http-registry --tlog-upload=false --yes k8s-registry:5000/fanclub-backend@sha256:b8113a77a17d7c7ad5ac794cce2edef9b7fa99c474154e7573c29b191529b016
$ cosign verify-attestation --key cosign.pub --type spdxjson --allow-http-registry --insecure-ignore-tlog k8s-registry:5000/fanclub-backend@sha256:b8113a77a17d7c7ad5ac794cce2edef9b7fa99c474154e7573c29b191529b016

実行結果(cosign attest の 1 行目):

Using payload from: backend-sbom.spdx.json

終了コードは 0、所要は 194 ms です。このあと cosign tree を打つと、artifact が 2 つ並びます——https://sigstore.dev/cosign/sign/v1(署名)https://spdx.dev/Document(添付した SBOM)です。verify-attestationin-toto Statement(https://in-toto.io/Statement/v0.1を base64 の payload として返します。

ここで、レジストリに置かれているものが 3 種類になりました。 イメージ本体・署名・SBOM の attestation です。第13回で「レジストリは Trivy の DB のような OCI アーティファクトも置ける」と確認しましたが、今度は自分たちが 2 種類を置いた側です。

実行結果(cosign tree):

📦 Supply Chain Security Related artifacts for an image: k8s-registry:5000/fanclub-backend@sha256:b8113a77a17d7c7ad5ac794cce2edef9b7fa99c474154e7573c29b191529b016
└── 🔗 https://spdx.dev/Document artifacts via OCI referrer: k8s-registry:5000/fanclub-backend@sha256:6ee47d68dcbb9da2b6a37d6242809a324c37540de0930d232ebeceab409fe2e9
   └── 🍒 sha256:b45839df13d9b21864af3685b9472a79a9a04a038c2ff1f100de3abb491032f8
└── 🔗 https://sigstore.dev/cosign/sign/v1 artifacts via OCI referrer: k8s-registry:5000/fanclub-backend@sha256:e6a3cf83eb98902e543f1e44ac5bcc6781354428c540ec79937de3e271fac4e5
   └── 🍒 sha256:43edbeda60854565665244803836fe16d38e46534c430d2cf91181284973228b

https://spdx.dev/Document が SBOM の attestation、https://sigstore.dev/cosign/sign/v1 が署名です。ダイジェストは実行のたびに変わりますので、値そのものではなく「2 種類が並ぶこと」を確認してください。

「via OCI referrer」と表示されていますが、本ラボのレジストリは referrers API に対応していません/v2/<name>/referrers/<digest>404 を返します)。cosign はタグベースの方式へ自動的に切り替えています——だから tags/listsha256-... というタグが見えるのです。

実行結果(cosign verify-attestation・先頭のみ):

WARNING: Skipping tlog verification is an insecure practice that lacks transparency and auditability verification for the attestation.

Verification for k8s-registry:5000/fanclub-backend@sha256:b8113a77a17d7c7ad5ac794cce2edef9b7fa99c474154e7573c29b191529b016 --
The following checks were performed on each of these signatures:
  - The cosign claims were validated
  - Existence of the claims in the transparency log was verified offline
  - The signatures were verified against the specified public key
{"payload":"eyJfdHlwZSI6Imh0dHBzOi8vaW4tdG90by5pby9TdGF0ZW1lbnQvdjAuMSIs...(以下 base64 が続く)

payload は base64 です。 デコードすると in-toto Statement"_type":"https://in-toto.io/Statement/v0.1")が出てきて、その predicateSBOM がそのまま入っていますSBOM は「イメージに添えられた署名付きの主張」として保存されている——これが attestation の形です。

旧来の cosign attach sbom は v3.1.2 でも残っていますが、ヘルプに DEPRECATED: と明記されています。 削除はされていないので打てば動きますが、現行の作法は cosign attest(in-toto attestation)です。違いは、attach sbom が「SBOM をただ添付する」のに対し、attest は「この SBOM はこのイメージのものである、と署名付きで言明する」点にあります。添付されているだけの SBOM は、誰が付けたか分かりません。

試験ではこう問われる。

指定されたイメージの SBOM を生成し、指定のファイルに出力せよ」「指定された鍵でイメージに署名し、検証せよ」という粒度で出ます。手が覚えているべきは bom generate --image <img> --output <file> --format json と、cosign generate-key-paircosign sign --key cosign.key <img>cosign verify --key cosign.pub <img> の 3 コマンドです。

bom は Resources Allowed に含まれますkubernetes-sigs.github.io/bom/cli-reference/ 配下・サブパス限定)ので、フラグは試験中に引けます。 一方 Cosign は参照不可なので暗記対象です。

本回でラボが必要とした --allow-http-registry / --use-signing-config=false / --tlog-upload=false / --insecure-ignore-tlog / --image-archive / no_proxy は、いずれも試験では不要です。 混同しないよう、本回の後半に専用の枠を置いて整理します。

SBOM に jar が 1 件も載らない —— 「作った」と「把握した」は別

本回の 2 つ目の山です。 演習①のステップ 3 で bom document outline のツリーを見ました。並んでいたものは確かに読めました。 ここでは並んでいなかったものを確かめます。「無いもの」は、眺めているだけでは見つかりません。探しに行く必要があります。

bom generate のログを読む

実行結果(bom generate のログから 2 行):

level=info msg="Image manifest lists 10 layers"
level=info msg="Scan of container image returned 44 OS packages in layer #2"

2 行目に答えが書いてあります。 44 OS packages ——OS パッケージが 44 件です。10 レイヤあるうち、拾ったのは layer #2 だけです。内訳は musl / busybox / openssl / libcrypto3 / zlib / fontconfig といった alpine のパッケージです。

検索して確かめる

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

$ grep -c -i 'jar' backend-sbom.spdx.json
$ grep -c -i 'payara' backend-sbom.spdx.json
$ grep -c -i 'postgres' backend-sbom.spdx.json
$ grep -c -i -E 'openjdk|temurin' backend-sbom.spdx.json

実行結果:

検索語ヒット数
jar0
payara0
postgres0
openjdk / temurin0

4 つとも 0 件です。 grep -c は該当行数を数えるので、0 は「1 行も無い」という意味です。SBOM 全体では 55 件のパッケージ要素があり、そのうち 44 件が alpine の OS パッケージです。残る 11 件はイメージのレイヤ 10 件と backend-0.4.1.tar 本体 1 件——jar は 1 つも含まれていません。

第13回の結果と突き合わせる

第13回trivy imageopt/payara/app.waropt/payara/payara-micro.jar に jar 由来の CVE を検出しました。 pom.xml の pgJDBC を上げて 0.4.1 を作り、その一部を消したところです。

その jar が、この SBOM には 1 件も載っていません。

道具見つけたもの見なかったもの
trivy image(第13回)OS パッケージの CVE + jar の CVE
bom generate(本回)OS パッケージ 44 件のみjar / Payara / JDBC ドライバ

SBOM に無いものは、新しい CVE が公表されても照合できません。 「pgJDBC 42.7.x に新しい脆弱性が出た。うちのイメージは該当するか」を SBOM で調べようとすると、この SBOM は「該当しない」と答えます。 実際には入っているのにです。

「SBOM を作った」ことと「中身を把握した」ことは別です。

-a(深く見る)は落ちる

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

$ bom generate -a --image-archive backend-0.4.1.tar --output backend-sbom-deep.spdx.json --format json

実行結果:

level=info msg="Scanning layer with distroless"
level=fatal msg="... checking if layer can be handled with distroless: reading the image tarfile: archive/tar: invalid tar header"

-a--analyze-images)はレイヤの中身を深く解析するオプションですが、本ラボの tar では FATAL になります。 「フラグを足せば載る」という話ではありません。

TLS 付きのレジストリから --image で読んだ場合に -a が通るかどうかは、本ラボでは確かめていません。 ただし通るかどうかにかかわらず、この節の結論は変わりません——SBOM の網羅範囲は、道具と入力の組み合わせで決まります。 手元の組み合わせで何が載るかを、毎回自分で数える必要があります。

同じイメージの SBOM を Trivy にも出させる —— 55 対 423

「bom が見ていないだけで、そもそもイメージから jar は読めないのではないか」——ここを確かめないと、bom の網羅範囲の話が「SBOM とはそういうもの」で終わってしまいます。同じイメージを、第13回で使った Trivy にも SBOM で出させます。

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

$ trivy image --config ~/gitops/gitops-fanclub/trivy.yaml --format spdx-json --output backend-trivy-sbom.spdx.json k8s-registry:5000/fanclub-backend:0.4.1
$ python3 -c "import json;print(len(json.load(open('backend-sbom.spdx.json'))['packages']))"
$ python3 -c "import json;print(len(json.load(open('backend-trivy-sbom.spdx.json'))['packages']))"
$ grep -c -i 'jar' backend-trivy-sbom.spdx.json
$ grep -c -i 'payara' backend-trivy-sbom.spdx.json

実行結果(同じイメージ・同じ日の 2 つの SBOM を並べます):

ツールSBOM のパッケージ数jar のヒットpayara のヒットファイルサイズ
bom v0.7.155(うち OS パッケージ 44 / レイヤ 10 / tar 本体 1)0069,273 バイト
Trivy v0.70.0--format spdx-json423377442684,677 バイト

パッケージ数は packages 配列の要素数(上の python3 の 2 行)で、55 と 423 です。jarpayara のヒット数は grep -c の行数なので厳密な件数ではありませんが、0 と 377 という差を見るには十分です。

パッケージ数が 7 倍以上違います。 postgresql の検索も Trivy 側では 2 件ヒットします(bom 側は 0 件です)。読めないのではありません。読む道具なら読めます。

そして、ここが本節でいちばん据わりの悪い事実です。 CKS の試験中に参照できる唯一の SBOM ツール(bom)のほうが、中身を見ません。 参照できない Trivy のほうが、第13回で jar 由来の CVE を 19 件見つけた道具です。

試験で問われる道具と、現場で使う道具は同じとは限りません。 本書は両方を扱います——試験のために bom generatebom document outline を手に覚えさせ、現場のために「その SBOM に何が載っていないか」を数える習慣を付けます。 どちらか片方だけでは、「SBOM を出しました」で止まる運用になります。

では SBOM をどう使うのか

SBOM は「網羅している」前提で使うものではなく、「何を網羅しているか」を確かめてから使うものです。

本ラボの SBOM は OS レイヤについては正確な明細です。alpine の 44 パッケージについて、新しい CVE が出たときの照合には使えます。アプリ層については別の手段が要ります——第13回の trivy fspom.xml を見る)と trivy image(jar を見る)がそれです。

Trivy 自身も SBOM を出せます--format cyclonedx / --format spdx-json)。アプリ層まで載せたいなら、そちらのほうが目的に合います——実測で 423 対 55 でした。本書が bom も扱うのは、CKS の Resources Allowed に kubernetes-sigs.github.io/bom/cli-reference/ が入っており、試験中に参照できる唯一の SBOM ツールが bom だからです。試験のための道具と、照合のための道具を、同じものだと思わないでください。

本番ガードレール④: SBOM を「作った」だけで管理台帳にしません。

SBOM の価値は、新しい CVE が出た日に照合できることにあります。生成して保管しただけでは、その日に誰も見ません。

「どこに置くか」「誰が照合するか」「どの周期で再生成するか」を決めてから作ってください。 そしてその SBOM が何を網羅していないかを、同じ場所に書き添えます。 網羅範囲を知らずに「SBOM があるから大丈夫」と判断するのが、いちばん危険な使い方です。

署名はレジストリにどう置かれ、verify は何を返すか

ここから演習②(CI ゲートに署名検証を足す)の前提知識を揃えます。署名がどこに置かれるかと、verify が何を返すかの 2 点です。

署名するとレジストリのタグが 1 つ増える

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

$ curl -s http://k8s-registry:5000/v2/fanclub-backend/tags/list

実行結果:

{"name":"fanclub-backend","tags":["0.2.0","0.3.0","0.3.1","0.3.2","0.4.0","0.4.1","sha256-b8113a77a17d7c7ad5ac794cce2edef9b7fa99c474154e7573c29b191529b016"]}

7 つ目のタグが署名です。 イメージのダイジェスト sha256:b8113a77... のコロンをハイフンに置き換えた sha256-b8113a77... というタグが増えています。

形式署名の置かれ方本ラボでの確認
旧形式sha256-<digest>.sig.sig サフィックス付きのタグ)registry.k8s.io/pause:3.10 で実物を確認できます(後の節で扱います)
新形式(cosign v3 が作るもの)sha256-<digest>.sig サフィックスが無い)。中身は OCI Image Index で、artifactType: application/vnd.dev.sigstore.bundle.v0.3+json の manifest が並ぶ本回で自分たちが作るのはこちら

署名を重ねるたびに Image Index の要素が増えます。 同じイメージに 2 回署名すれば、要素が 2 つ並びます。

「旧形式」「新形式」という呼び分けをしています。

本ラボに導入したのは cosign v3.1.2 のみで、v2 の挙動は実測していません。 「v2 では .sig サフィックス付きのタグだった」は公式・コミュニティの説明に基づく記述です。

ただし旧形式のタグが実在することは、本ラボで確認できます——registry.k8s.io/pause:3.10 の署名タグが sha256-ee6521f2....sig です。「v2 がこうだった」より「旧形式の署名は今も世の中にある」のほうが、実測に忠実で、読者にとっても重要です。

referrers API に非対応でも成立する

確認結果
/v2/fanclub-backend/referrers/<digest>404
レジストリのヘッダDocker-Distribution-Api-Version: registry/2.0
cosign tree の表示「via OCI referrer」と表示される

cosign tree は「via OCI referrer」と表示しますが、本ラボのレジストリは referrers API に対応していません(404 が返ります)。実体はタグベースのフォールバックです。

表示を鵜呑みにしないでください。 「OCI referrer 経由」と出ているからといって、レジストリが referrers API を実装しているとは限りません。確かめる方法は 1 つ——/v2/<name>/referrers/<digest> を叩いてみることです。

実務上の意味もあります。 referrers API 非対応の古いレジストリでも cosign の署名は成立します。 「レジストリを入れ替えないと署名を始められない」わけではありません。

verify の終了コードは失敗の種類で違う

同じ cosign verify を条件を変えて 8 通り実行した実測です。

#対象出力終了コード
1署名済み(--insecure-ignore-tlog なし)Error: setting trusted material: getting trusted root from TUF ... Forbidden1
2署名済み(--insecure-ignore-tlog あり)検証成功・JSON 出力0
3タグ指定:0.4.1成功(docker-reference にタグがそのまま入る)0
4未署名fanclub-frontend:1.0.0Error: no signatures found10
5別の鍵accepted signatures do not match threshold, Found: 0, Expected 11
6署名していないタグ0.4.0Error: no signatures found10
7.sig 形式の署名を持つ外部イメージregistry.k8s.io/pause:3.10Error: no matching signatures: invalid signature when validating ASN.1 encoded signature12(所要 1,706 ms
8署名のない外部イメージdocker.io/library/busybox:latestError: no signatures found10

終了コードは 4 値

本節でいちばん大事な表です。

意味メッセージ次にやること
0検証成功
10署名が 1 つも無いno signatures found署名すれば通ります
1新形式(v3 bundle)の署名が、別の鍵のものno matching attestations: ... accepted signatures do not match threshold, Found: 0, Expected 1鍵を確かめます。署名し直しても、その鍵でなければ通りません
12.sig 形式(keyless 証明書付き)の署名が、この鍵では検証できないno matching signatures: invalid signature when validating ASN.1 encoded signature同上。署名の形式が違うだけで、原因は同じです

112 を分けているのは「署名の形式」です。 新形式(v3 の bundle)か、旧 .sig タグ形式か。「鍵が合わない」という点では同じでも、cosign は形式ごとに別の値を返します。

メッセージは統一されていません。 attestation を持たない frontend:1.0.0 を別の鍵で検証しても no matching attestations と出ます(attestation の話ではないのにです)。メッセージの語だけで判断せず、終了コードと合わせて読んでください。

署名検証は「署名があるか」ではなく「誰の署名か」を見ています。

これが本回の主題です。

registry.k8s.io/pause:3.10 は署名されています。 それも Kubernetes プロジェクトによる、素性のはっきりした署名です(後の節で cosign tree の実物を見ます)。それでも自分たちの cosign.pub では検証が通りません。 通らないのが正しい挙動です——自分たちが署名したものではないからです。

「署名されているイメージだから安全」は成り立ちません。 判定は「自分が信頼する鍵で署名されているか」だけです。署名を検証するとは、鍵を選ぶことです。

exit 12 は、この事実をいちばん分かりやすい形で見せてくれる終了コードです。

誤読しやすい点を先に潰しておきます。 exit 12 は「レジストリに届かなかった」ではありません。 本ラボの Squid whitelist には registry.k8s.io が登録済みであり(そもそも本クラスタはそこから pull して構築されています)、プロキシ環境変数を全部外して実行しても結果は同じです(EXIT=12・1,706 ms)。1.7 秒かかるのは、外部レジストリへ実際に往復して署名を取ってきているからであって、失敗を待っているのではありません。署名は取れています。取れた署名が、この鍵のものではなかっただけです。

これは 3 つ目のツールの終了コードの作法でもあります。 第12回の kubesec は既定で 2 を返し、第13回の Trivy は --exit-code を付けない限り 0 でした。cosign は既定で、失敗の種類ごとに違う値を返します。 ゲートを組むときは、ツールごとに既定の姿勢を確かめないと、組んだつもりのゲートが素通りします。

そして exit 12 は、本回の後半で「クラスタを壊す側」に回ります。 覚えておいてください。

表示の落とし穴: tlog を無視しているのに「検証した」と出る

実行結果(cosign verify --insecure-ignore-tlog のチェック項目から 1 行):

  - Existence of the claims in the transparency log was verified offline

--insecure-ignore-tlog を付けたときも、この行が出ます。 直前には WARNING(Skipping tlog verification is an insecure practice ...)が出ています。

2 行を並べて読んでください。 上に「tlog 検証をスキップするのは安全でない慣行である」という WARNING が出ていて、下のチェック項目に「transparency log was verified offline」と出ます。「verified」の 1 語だけを拾うと、透明性ログを検証したように読めます。

ツールの出力は、上から下まで読みます。 成功の行だけを grep するスクリプトは、この WARNING を落とします。

本番ガードレール⑤: cosign verify の判定は、終了コードだけで行います。

①出力を文字列で判定しません。 grep 'Verified OK' のような実装は、直前の WARNING を落とします(tlog をスキップした事実が消えます)。文字列で判定する必要があるなら、WARNING 行も同時に拾ってください。

②署名の「件数」で判定しません。 1 つのイメージにぶら下がるものは 1 つとは限りません——本回の手順どおりに進めると、0.4.1 には 署名が 1 つと SBOM の attestation が 1 つ付き、cosign tree には 2 つの artifacthttps://sigstore.dev/cosign/sign/v1https://spdx.dev/Document)が並びます。同じイメージに署名を重ねれば、その数だけ増えます。 「1 件以上あれば OK」という実装は、attestation まで署名として数えかねません。

終了コードは 4 値で、意味が分かれています(0 / 10 / 1 / 12)。それ以上の情報を出力から読み取ろうとしないでください。

やってみよう②:ゲートに 5 ステージ目を足し、frontend も対象にする

所要時間の目安: 8 分(手を動かす時間)。スクリプトを 5 ステージに拡張し、未署名の frontend で落ちることを確認し、署名して通します。試験相当の作業(署名を検証するコマンドを書く)なら 4 分です。

ステップ1:第13回のゲートには穴がある

第13回の申し送りにこう書いてあります——「演習④のゲートは frontend を見ていない(対象は backend のみ)」。

第13回のステージ④(trivy image)は IMAGE="k8s-registry:5000/fanclub-backend:0.4.1" の 1 つだけを対象にしていました。frontend は 1 度もゲートを通っていません。

ゲートは「対象に入っていないもの」を見逃します。 これは閾値の話でも道具の話でもなく、列挙の漏れです。閾値をどれだけ厳しくしても、対象に入っていなければ何も起きません。

本回の 5 ステージ目は、最初から 2 イメージを対象にして書きます。

ステップ1b:ステージ④は広げない —— その判断を数字で書く

「frontend が漏れていたのだから、ステージ④も広げればよい」と考えるところです。広げるとどうなるかを先に測ります。

実行コマンド(k8s-ops・developer・パイプを挟まずに終了コードを取ります):

$ trivy image --config ~/gitops/gitops-fanclub/trivy.yaml --severity CRITICAL --ignore-unfixed --exit-code 1 k8s-registry:5000/fanclub-frontend:1.0.0 > /dev/null; echo "EXIT=$?"
$ trivy image --config ~/gitops/gitops-fanclub/trivy.yaml --severity CRITICAL --ignore-unfixed --exit-code 1 k8s-registry:5000/fanclub-backend:0.4.1 > /dev/null; echo "EXIT=$?"

実行結果:

対象--severity CRITICAL --ignore-unfixed --exit-code 1CRITICAL 件数
fanclub-frontend:1.0.0EXIT=12
fanclub-backend:0.4.1EXIT=00
現行 4 ステージゲート全体EXIT=0

frontend の CRITICAL 2 件の中身です。

CVEパッケージInstalledFixedStatus
CVE-2026-31789libcrypto33.3.3-r03.3.7-r0fixed
CVE-2026-31789libssl33.3.3-r03.3.7-r0fixed

2 件は同じ CVE で、パッケージが 2 つ(libcrypto3libssl3)に分かれているだけです。直す作業としては「alpine の OpenSSL を上げる」の 1 件で、ゲートの見た目は 2 件になります。

--ignore-unfixed を付けても消えません。 2 件とも Status: fixed(修正版が公開されている)だからです。第13回--ignore-unfixed は「無視ではなく後回し」だと書きましたが、後回しにできるのは修正版が無いものだけです。これは「直せるのに直していないもの」なので、フラグでは消えません。

本書はステージ④を広げません。理由を書きます。

広げると、ゲートは今日から赤くなります。 そして赤を消すには nginx:1.27-alpine のベースを更新するしかありません——新しいタグを選び、frontend を再ビルドし、ブラウザで表示を確認し、GitOps で反映します。それは本回の主題(SBOM と署名と許可レジストリ)ではありません。

第13回は同じ判断をしています。 あのとき「HIGH は残るが CRITICAL で落とす」という閾値にしたのも、「今日直せない在庫」でゲートを赤いままにしないためでした。第14回の都合でその判断を覆す理由がありません。

だから宿題として明示的に残します。 「ステージ④を frontend に広げると EXIT=1 になる。CRITICAL 2 件はいずれも Status: fixed なので、ベースを 3.3.7-r0 以降を含むものへ更新すれば消える」——ここまで書いて残します。 数字と直し方まで書いてある宿題は、いつか誰かが着手できます。「あとでやる」とだけ書かれた宿題は、誰も着手しません。

本節でいちばん大事な対比は、同じスクリプトの隣り合う 2 ステージに現れます。

ステージ対象何を見ているか
trivy imagebackend 1 つだけfrontend を見ていません(宿題として残します)
cosign verifybackend + frontend の 2 つ両方を見ています

ゲートは「何を通すか(閾値)」だけでなく、「何を見ているか(対象)」で決まります。 閾値をどれだけ厳しくしても、対象に入っていないものには 1 度も触れません。 第13回の frontend の見落としは閾値の問題ではなく、列挙の漏れでした。

そして本書は、片方を回収し、片方を宿題にしました。 意図して残した穴と、気づかず開いていた穴は違います。違うのは「書いてあるかどうか」だけです。

ステップ2:5 ステージ目を書く

第13回版からの差分は 3 つです。

差分①: 既存 4 ステージのラベルを N/4 から N/5 に付け替えます。

第13回版第14回版
=== 1/4 kubesec ====== 1/5 kubesec ===
=== 2/4 kube-linter ====== 2/5 kube-linter ===
=== 3/4 trivy config ====== 3/5 trivy config ===
=== 4/4 trivy image ====== 4/5 trivy image ===
(なし)=== 5/5 cosign verify ===

ラベルの付け替えを忘れると、5 本目を足したのに「4 分の 4 で終わった」と表示されます。 実害はありませんが、ログを見た人が「全部通った」と読む根拠がずれます。

差分②: スクリプト自身が no_proxy を設定します。

export no_proxy を実行者のシェルに任せません。 本ラボの /etc/profile.d/proxy.sh には k8s-registry が入っておらず、しかも本書はそれを恒久修正しない方針です。その結果、このスクリプトは「export を打った人のシェルでしか通らない」ものになります。

スクリプトは Git にコミットされ、他の人が実行します。 だからスクリプトの先頭で自分で設定します。

export no_proxy="${no_proxy:-},k8s-registry"
export NO_PROXY="${no_proxy}"

環境変数に依存するスクリプトは、その環境変数を自分で設定します。 これは本節のガードレール⑥と対になる弱点です——⑥の①は「見る対象が足りない」、こちらは「動く前提が足りない」。どちらも、書いた本人の環境では気づけません。

差分③: 5 ステージ目(cosign verify)を足します。 対象は backend と frontend の 2 つです。

~/gitops/gitops-fanclub/scan-gate.sh(5 ステージ版・全量):

#!/bin/bash
# 静的解析 + イメージスキャン + 署名検証のゲート。1 つでも失敗があれば非ゼロで終了する。
# set -e は使わない(理由は第12回本文)。
set -uo pipefail
shopt -s nullglob

# /etc/profile.d/proxy.sh の no_proxy には k8s-registry が入っていない。
# 実行者のシェル設定に依存させず、スクリプト自身で除外を足す。
# cosign は Go 製で大文字の NO_PROXY を見るため、両方を揃える。
export no_proxy="${no_proxy:-},k8s-registry"
export NO_PROXY="${no_proxy}"

REPO_DIR="$(cd "$(dirname "$0")" && pwd)"
BASE_DIR="${REPO_DIR}/base"
KL_CONFIG="${REPO_DIR}/.kube-linter.yaml"
TRIVY_CONFIG="${REPO_DIR}/trivy.yaml"
IGNORE_FILE="${REPO_DIR}/.trivyignore"
APP_SRC="${HOME}/app-src/fanclub-api"
IMAGE="k8s-registry:5000/fanclub-backend:0.4.1"
COSIGN_PUB="${REPO_DIR}/cosign.pub"
REGISTRY="k8s-registry:5000"
IMAGES=(
  "k8s-registry:5000/fanclub-backend:0.4.1"
  "k8s-registry:5000/fanclub-frontend:1.0.0"
)
FAILED=0

# レジストリのマニフェスト API から Docker-Content-Digest を読む。
# ローカルに pull していないイメージでも同じ手順で取れる。
get_digest() {
  local ref="$1"
  local path="${ref#${REGISTRY}/}"
  local repo="${path%%:*}"
  local tag="${path##*:}"
  curl -sI \
    -H "Accept: application/vnd.oci.image.index.v1+json" \
    -H "Accept: application/vnd.docker.distribution.manifest.list.v2+json" \
    -H "Accept: application/vnd.docker.distribution.manifest.v2+json" \
    "http://${REGISTRY}/v2/${repo}/manifests/${tag}" \
    | awk -F': ' 'tolower($1) == "docker-content-digest" { print $2 }' | tr -d '\r'
}

echo "=== 1/5 kubesec ==="
for f in "${BASE_DIR}"/*-deployment.yaml; do
  if ! kubesec scan "$f"; then
    echo "KUBESEC FAILED: $f"
    FAILED=1
  fi
done

echo "=== 2/5 kube-linter ==="
if ! kube-linter lint --config "${KL_CONFIG}" "${BASE_DIR}"; then
  echo "KUBELINTER FAILED"
  FAILED=1
fi

echo "=== 3/5 trivy config ==="
for d in "${BASE_DIR}" "${APP_SRC}"; do
  if ! trivy config --config "${TRIVY_CONFIG}" --ignorefile "${IGNORE_FILE}" \
       --severity MEDIUM,HIGH,CRITICAL --exit-code 1 "$d"; then
    echo "TRIVY CONFIG FAILED: $d"
    FAILED=1
  fi
done

echo "=== 4/5 trivy image ==="
trivy image --config "${TRIVY_CONFIG}" --ignorefile "${IGNORE_FILE}" \
  --severity HIGH,CRITICAL --exit-code 0 "${IMAGE}"
# 2 回目は同じ実行内で DB を二度取りに行かないための --skip-db-update。
# 「更新をさぼるため」の常用とは別物(第13回本文を参照)。
if ! trivy image --config "${TRIVY_CONFIG}" --ignorefile "${IGNORE_FILE}" \
     --skip-db-update --severity CRITICAL --ignore-unfixed --exit-code 1 "${IMAGE}"; then
  echo "TRIVY IMAGE FAILED: ${IMAGE}"
  FAILED=1
fi

echo "=== 5/5 cosign verify ==="
for img in "${IMAGES[@]}"; do
  digest="$(get_digest "$img")"
  if [ -z "${digest}" ]; then
    echo "DIGEST NOT FOUND: ${img}"
    FAILED=1
    continue
  fi
  cosign verify --key "${COSIGN_PUB}" --allow-http-registry --insecure-ignore-tlog \
    "${img%:*}@${digest}" > /dev/null 2>&1
  rc=$?
  if [ "${rc}" -eq 0 ]; then
    echo "SIGNED: ${img}"
  else
    echo "COSIGN VERIFY FAILED: ${img} (exit ${rc})"
    FAILED=1
  fi
done

exit "${FAILED}"

ここに載せたのは実機で動かしたものそのものです(96 行・3,280 バイト)。get_digest はレジストリ API を使います——演習①で docker inspectRepoDigests と同じ値が返ることを確認しましたが、スクリプトはローカルに pull していないイメージでも動く必要があるためです。

終了コードは一時変数に受けてから判定していますrc=$?)。メッセージに終了コードを載せるためです。第13回で「パイプ越しに終了コードを読んでいた」という間違いを踏んだので、読みたい終了コードが誰のものかを、書いた時点で明示します。

終了コードで判定し、件数を数えません。

演習①で見たとおり、0.4.1 には署名のほかに SBOM の attestation が付いていますcosign tree2 つの artifact)。ぶら下がっているものの数は、署名を重ねたか・attestation を付けたかで変わります。

「署名が 1 件以上あれば OK」という実装にすると、attestation を署名として数えます。 本回のゲートは終了コードだけで判定します——ガードレール⑤(grep 'Verified OK' で判定しない)と同じ理由です。

ステップ3:未署名で落ちることを確認する

実行の前に、公開鍵をリポジトリへ置きます。 ステップ2 のスクリプトは COSIGN_PUB="${REPO_DIR}/cosign.pub" を参照するので、これが無いとステージ⑤は 2 件とも落ちます。

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

$ cp ~/cosign-lab/cosign.pub ~/gitops/gitops-fanclub/cosign.pub

この 1 行を飛ばすと、終了コードの読み分けが崩れます。

cosign.pub が無い状態でゲートを打つと、backend も frontend も exit 1 で落ちます(実測)。1 は「鍵が合わない」であって「鍵が読めない」ではないので、本節で見たい exit 10(署名が 1 つも無い)と混ざります。

本回が扱ってきた終了コード 4 値の読み分けが、そのまま自分のゲートで問われます。 期待どおりの SIGNED / exit 10 が出ないときは、まず鍵ファイルの有無を疑ってください。

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

$ ~/gitops/gitops-fanclub/scan-gate.sh; echo "gate exit=$?"

実行結果(各ステージの見出しと末尾):

=== 1/5 kubesec ===
=== 2/5 kube-linter ===
=== 3/5 trivy config ===
=== 4/5 trivy image ===
=== 5/5 cosign verify ===
SIGNED: k8s-registry:5000/fanclub-backend:0.4.1
COSIGN VERIFY FAILED: k8s-registry:5000/fanclub-frontend:1.0.0 (exit 10)
gate exit=1

上は見出し行と最後の 3 行だけを抜いたものです。 実機のログは 360 行あり、見出しのあいだに各ステージの出力(kubesec のスコア 13・kube-linter の結果・Trivy のレポート)が入ります。ゲート全体の所要は 2,018 ms でした。

読者が確かめる点は 2 つです。 === N/5 === の見出しが 5 本すべて出ていることと、最後の gate exit= の値です。見出しが 4 本しか出ていなければ、5 本目の前で止まっています——そのときは終了コードが 1 でも、理由がステージ⑤とは限りません。

frontend で返っているのは 10(署名が 1 つも無い)です。 終了コード 4 値の表でいちばん素直な失敗であり、署名すれば通る種類のものです。112 なら「鍵が違う」を疑うところですが、10 は「まだやっていない」だけです。

ステップ4:frontend にも署名して通す

演習①のステップ 4 と同じコマンドです。対象のイメージ名とダイジェストだけが変わります。

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

$ cd ~/cosign-lab
$ export no_proxy="$no_proxy,k8s-registry"
$ export NO_PROXY="$no_proxy"
$ cosign sign --key cosign.key --use-signing-config=false --allow-http-registry --tlog-upload=false --yes k8s-registry:5000/fanclub-frontend@sha256:508f164f40462f3731543a04b21f3394b8a42e62de2a1b542623926b56ea7468

実行結果(末尾):

Pushing signature to: k8s-registry:5000/fanclub-frontend

終了コードは 0、所要は 230 ms です。署名後のタグ一覧は ["0.1.0","1.0.0","sha256-508f164f4046..."] になります。これで backend と frontend の 2 つに署名が付きました。

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

$ ~/gitops/gitops-fanclub/scan-gate.sh; echo "gate exit=$?"

実行結果(ステージ⑤と末尾):

=== 5/5 cosign verify ===
SIGNED: k8s-registry:5000/fanclub-backend:0.4.1
SIGNED: k8s-registry:5000/fanclub-frontend:1.0.0
gate exit=0

2 件とも SIGNED になり、ゲートは gate exit=0 で通ります。 このときの所要は 2,242 ms でした。1 回目(未署名で落ちたとき)の 2,018 ms との差は、frontend の署名を 1 つ余分に検証しているぶんです。

ステップ5:所要を測る

cosign verify 単体の所要は 48 / 48 / 51 ms(3 回計測)です。

ゲートに 1 ステージ足しても、所要はほとんど変わりません。 第13回のステージ④(trivy image を 2 回)が数秒かかっていたのに対し、署名検証は 50 ms 前後です。検証は「鍵で署名を照合する」だけなので、スキャンのように DB を引く必要がありません。

ゲート全体でも秒の単位に収まります。 第13回の 4 ステージ版と並べます。

所要備考
第13回・4 ステージ(1 回目)4,328 msTrivy のスキャンキャッシュが無い状態
第13回・4 ステージ(続けて 2 回目)2,295 msキャッシュが効いた状態
本回・5 ステージ(未署名で落ちた回)2,018 msステージ⑤で exit 10
本回・5 ステージ(2 件とも署名済み)2,242 msgate exit=0

ステージが 1 本増えても、全体の所要は増えていません。 支配的なのは Trivy のスキャンとキャッシュの有無で、署名検証はその中に埋もれます。 「ゲートが重くなるから署名検証は後回し」という理由は、少なくとも本ラボの規模では成り立ちません。

ステップ6:Git に反映する

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

$ cd ~/gitops/gitops-fanclub
$ git add scan-gate.sh cosign.pub
$ git commit -m "Add cosign verify stage to the scan gate for backend and frontend"
$ git push origin main

cosign.pub はステップ3 で既にリポジトリへ置いてありますので、ここでは git add するだけです。scan-gate.sh を push してよい理由は第13回と同じです。ArgoCD が見ているのは overlays/prod 配下だけ(Application の source.path)なので、リポジトリ直下のスクリプトは同期対象になりません。

公開鍵はリポジトリに置いて構いません。 検証する側が全員持っている必要があり、秘密ではないからです。むしろ Git に置くことで「いつ鍵を入れ替えたか」が履歴に残ります。 置いてはいけないのは cosign.key のほうです。

本書は cosign.pub をリポジトリ直下に置きます。 スクリプトが COSIGN_PUB="${REPO_DIR}/cosign.pub"自分のいる場所からの相対で参照できるからです。${HOME} 配下を絶対パスで指すと、実行者ごとに違うファイルを見ます——誰が実行しても同じ鍵で検証されることが、ゲートの前提です。cosign.key は置きません。

本番ガードレール⑥: ゲートが静かに無力化される経路は 2 つあります。どちらも書いた本人の環境では気づけません。

①対象の列挙が足りない。 本ラボはイメージが 2 つなので配列に列挙しています。イメージが増えたときに配列を更新し忘れると、その 1 つだけが検査されないまま通ります。 マニフェストから image: 行を抽出して対象を組み立てる形にすると漏れが減ります。第13回の frontend の見落としがこれです。

②動く前提が足りない。 no_proxy のように、「実行した人のシェルにたまたま設定があったから通った」ものは、他の人が実行すると落ちます。環境変数に依存するスクリプトは、その環境変数を自分で設定します。

①は「見えていないものがある」、②は「動かないことがある」。 ①は緑のまま素通りするのでより危険です。ゲートを書いたら、わざと落ちるものを 1 つ通して、本当に落ちることを確かめてください——本演習のステップ 3(未署名の frontend で gate exit=1)がそれです。

試験ではこう問われる。

CI で署名されていないイメージを検出して失敗させるコマンドを書け」という粒度で出ます。手が覚えているべきは cosign verify --key cosign.pub <image> と、終了コードで判定することです。--allow-http-registry--insecure-ignore-tlog は本ラボ固有の事情であり、試験では要りません。

ImagePolicyWebhook —— 3 ファイル整合と「署名検証をしない」仕組み

ここから Admission 側に移ります。3 ファイルの役割と、仕組みの守備範囲を先に固めます。

何をする仕組みか

ImagePolicyWebhook は、Pod が作られるときにイメージの参照(レジストリ / リポジトリ / タグ)を外部のバックエンドに問い合わせ、許可・拒否を返してもらう Admission プラグインです。

apiserver がバックエンドに送るのは imagepolicy.k8s.io/v1alpha1ImageReview で、中身はイメージ参照の一覧と namespace とアノテーションです。署名の情報は含まれません。

ここは書き分けが要ります。

ImagePolicyWebhook 本体は署名検証をしません。 apiserver はイメージ参照を渡すだけで、署名の概念を持ちません。

しかしバックエンドは任意実装です。 「イメージ参照を受け取って許可・拒否を返す」という約束さえ守れば、中で何をしても構いません。だから cosign verify を呼ぶこともできます(発展の節で実装します)。

「仕組みが署名検証をしない」ことと「署名検証を組み込めない」ことは別です。

3 ファイルの整合

#場所役割
1kube-apiserver のフラグ--enable-admission-plugins=NodeRestriction,ImagePolicyWebhook--admission-control-config-file=/etc/kubernetes/imagepolicy/admission-config.yaml
2AdmissionConfigurationImagePolicyWebhook の設定(kubeconfig の場所・allowTTL / denyTTL / retryBackoff / defaultAllow
3kubeconfigバックエンドへの接続情報(URL・CA)

3 つのうち 1 つでも食い違うと apiserver が起動しません。 フラグが指すパスに AdmissionConfiguration が無い、AdmissionConfiguration が指す kubeconfig が無い、という連鎖です。

公式の AdmissionConfiguration

Kubernetes 公式の Admission Controllers リファレンス(https://kubernetes.io/docs/reference/access-authn-authz/admission-controllers/)が示す記載例です。このページは CKS 試験中に参照できます。

apiVersion: apiserver.config.k8s.io/v1
kind: AdmissionConfiguration
plugins:
  - name: ImagePolicyWebhook
    configuration:
      imagePolicy:
        kubeConfigFile: <path-to-kubeconfig-file>
        allowTTL: 50
        denyTTL: 50
        retryBackoff: 500
        defaultAllow: true

4 つのパラメータ —— 単位が揃っていない

キー公式のコメント単位例の値意味
allowTTL# time in s to cache approval50許可の判定をキャッシュする時間
denyTTL# time in s to cache denial50拒否の判定をキャッシュする時間
retryBackoff# time in ms to wait between retriesミリ秒500再試行の待ち時間
defaultAllow# determines behavior if the webhook backend failstrueバックエンドが応答しないときの既定の挙動

強調しておきたい点が 3 つあります。

  1. 同じブロックに並んでいるのに単位が違います。 allowTTL / denyTTLretryBackoff だけがミリ秒です。retryBackoff: 500 は 500 秒ではなく 0.5 秒であり、逆に denyTTL: 500 と書けば 500 秒(8 分以上)判定が固定されます。 試験では admission-config.yaml を修正させる形で問われるので、単位を取り違えると意図どおりに効きません
  2. 50 / 500 は既定値ではなく、公式ページの記載例の値です。 公式はこれらの既定値を明示していません。「50 が既定」と覚えないでください。 本書ラボもこの例の値をそのまま使います(denyTTL: 50 の 50 秒が、後の節で出てくる「64 秒戻らない」の正体です
  3. 公式の例は defaultAllow: true(fail-open)です。 公式のサンプルをそのまま貼ると fail-open になります。 演習④で扱うとおり、fail-open は「ポリシーが無いのと同じ」状態を作りえます。サンプルの既定が自分たちの意図と一致しているかを、貼る前に確かめてください

公式が触れているもう 1 つの前提があります。 同じページは「the API Server must enable the imagepolicy.k8s.io/v1alpha1 API extensions group (--runtime-config=imagepolicy.k8s.io/v1alpha1=true)」と書いています。ところが本ラボの実機(v1.36.3)は、この --runtime-config を付けずにフラグ 2 つだけで動作しました。

公式ドキュメントと実機が食い違う 1 点。

公式は --runtime-config=imagepolicy.k8s.io/v1alpha1=true必要だと書いていますが、本書ラボ(Kubernetes v1.36.3)では、このフラグを付けずに動きました。 本回の演習③・④で拒否も許可もキャッシュも観測できていますが、apiserver に付けたフラグは 2 つだけです。

本書は実機で動いたほうを手順として書きます。 ただし公式にそう書いてある事実も消しません——理由は 2 つです。①試験環境のバージョンが違えば必要になる可能性があります(CKS 試験は v1.35 で、ラボは v1.36.3 です)。②試験中に参照できるのはこの公式ページなので、読者はそこで --runtime-config の記述を目にします。

試験で ImagePolicyWebhook を設定して動かないときは、このフラグを足してみる価値があります。 足しても害はありません(そのグループを明示的に有効化するだけです)。本ラボで不要だったことは、「どこでも不要」を意味しません。

3 点セット(フラグ + volumeMounts + volumes

第5回--authentication-config)・第9回--encryption-provider-config)に続いて 3 回目です。静的 Pod の apiserver はコンテナの中で動いているので、ホスト上のファイルをフラグで指しても、マウントしていなければ見えません。 フラグだけ足して volumes を忘れると、apiserver は「ファイルが無い」で起動に失敗します。

失敗の形を 1 度だけ見ておきます。 フラグ 2 行だけを足して volumeMountsvolumes を書かずに保存すると、こうなります。

実行結果(/livez を 90 秒観測):

t+5s   000
t+10s  000
(中略・以降 90 秒まですべて 000)
t+90s  000

200 に戻りません。 適用が成功したときは 45 秒前後で戻るので、1 分待って戻らなければ失敗を疑います。

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

# crictl ps -a --name kube-apiserver
# crictl logs $(crictl ps -a --name kube-apiserver -q | head -1)

実行結果(末尾のみ):

CONTAINER      IMAGE          CREATED          STATE    NAME             ATTEMPT
6e0ef25e8f2d6  3400718b4dd57  15 seconds ago   Exited   kube-apiserver   3

E0801 10:48:00.059760       1 run.go:72] "command failed" err="failed to apply admission: failed to read plugin config: unable to read admission control configuration from \"/etc/kubernetes/imagepolicy/admission-config.yaml\" [open /etc/kubernetes/imagepolicy/admission-config.yaml: no such file or directory]"

このエラーの読み方が本節の要点です。

no such file or directory と出ていますが、ホスト上にはファイルが存在します。 ls -l /etc/kubernetes/imagepolicy/admission-config.yaml を打てば、確かにあります。

見えていないのは apiserver コンテナの中からです。 volumes を書いていないので、そのディレクトリはコンテナにマウントされていません。「ファイルが無い」と言われて ls で確認し、あるので混乱する——これが 3 点セットを忘れたときの典型的な詰まり方です。

kubelet 側は CrashLoopBackOff を出しますback-off 40s restarting failed container=kube-apiserver)。ATTEMPT の数字が増えていくのが再起動を繰り返している証拠です。

復旧はバックアップを戻すだけです。 実測では戻して 20 秒で 200 に復帰しました。HA なので、この間も cp-02 / cp-03 が応答を返し続けます——だからこそ1 台ずつ作業します(ガードレール⑧)。

バックエンドの構成

項目
待ち受け127.0.0.1:8081(各 Control Plane のローカル)
常駐systemdimage-policy-webhook.service
証明書自己署名subjectAltName = IP:127.0.0.1, DNS:localhost
kubeconfig の certificate-authorityそのサーバ証明書自身を指定すれば通ります
クライアント証明書不要
Python3.12.13(全ノードに導入済み・cryptography モジュールは無し)
openssl9 VM のどこにも入っていません。 dnf install -y openssl3.5.5 が入ります

Python の標準ライブラリだけで書けますhttp.server + ssl)。追加のパッケージは要りません。

openssl の導入(Control Plane 3 台)

実行コマンド(k8s-cp-01 / 02 / 03・root):

# rpm -q openssl
# dnf install -y openssl
# openssl version

実行結果:

確認結果
rpm -q openssl(導入前)「インストールされていません」
dnf install -y openssl の結果openssl 3.5.5openssl-libs も 3.5.5 へアップグレードされる)
パッケージ openssl はインストールされていません

インストール済み:
  openssl-1:3.5.5-6.el10_2.alma.1.x86_64

完了しました!

OpenSSL 3.5.5 27 Jan 2026 (Library: OpenSSL 3.5.5 27 Jan 2026)

openssl-libs は入っていますが、openssl コマンドは別パッケージです。導入時に openssl-libs もアップグレードされます(3.5.5 へ)。3 台とも同じ手順が要ります。

openssl-libs はあるのに openssl コマンドがありません。 ライブラリとコマンドが別パッケージだからです。第7回でホスト OS の攻撃面を最小化したときの方針がここに効いています——必要なものだけを入れる構成では、「あるはずのコマンド」がありません。

openssl-libs も一緒に上がる点に注意してください。 依存パッケージのバージョンが動くので、Control Plane に対する変更として記録します。

本番ガードレール⑦: Control Plane に何を置き、何を置き続けるかを決めます。

①開発ツールを恒久的に置きません。 本演習では証明書を作るために openssl を、発展の節では cosign(141MB)を Control Plane に入れます。証明書を作り終えたら、そのノードに openssl を置き続ける理由があるかを考えてください。 第7回で削ったものを演習のたびに戻していくと、削った意味が薄れます。入れたものは記録し、要らなくなったら消します。

②自己署名証明書と 127.0.0.1 待ち受けは、学習用の簡略化です。 本番では (a) バックエンドの可用性を決める(1 台で落ちたら fail-closed のときにクラスタが止まります)(b) TLS の証明書を組織の CA で発行する (c) クライアント証明書で apiserver からの接続だけを受ける——の 3 点を検討します。127.0.0.1 を使うこと自体は本番でも成立する設計(apiserver と同じノードにあるので最短経路で確実に届きます)ですが、「1 台落ちたときどうなるか」は別に決める必要があります。

③本回で Control Plane に加えた変更は、演習④の最後で全部戻します。 本書はこの状態を第15回へ持ち越しません。

defaultAllow の意味

呼び名バックエンドに届かないとき備考
truefail-open許可する公式ドキュメントの記載例はこちら
falsefail-closed拒否する本書ラボの演習③はこちらで進めます

どちらが正しいという話ではありません。 演習④で両方を実測し、何を失うかを測ってから決めます。

やってみよう③:許可レジストリ制限を Control Plane 3 台に適用する

所要時間の目安: 12 分3 台分の適用と apiserver の再起動待ちを含みます)。棚卸しをし、バックエンドを立て、3 ファイルを配置し、apiserver に 3 点セットを足し、拒否を確認します。試験相当の作業(1 台に 3 ファイルを整合させて適用する)なら 8 分です。

ステップ0:絞る前に、いま何が動いているかを数える

この順序を崩さないでください。許可リストを作るより先に、棚卸しをします。 「自社イメージと Control Plane の 2 つを許可すればよい」と考えて絞ると、Cilium / Longhorn / Traefik / ArgoCD / Prometheus / PostgreSQL がすべて許可されません。 しかも「絞った直後には何も起きない」ので、演習中は気づかないまま、第15回以降に事故が出ます。

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

$ kubectl get pods -A -o jsonpath='{range .items[*]}{range .spec.containers[*]}{.image}{"\n"}{end}{end}' | sort -u
$ kubectl get pods -A -o jsonpath='{range .items[*]}{range .spec.containers[*]}{.image}{"\n"}{end}{end}' | cut -d/ -f1 | sort | uniq -c | sort -rn

この 2 本が見ているのは spec.containers だけです。initContainers を使っているワークロードがあるなら、{range .spec.initContainers[*]} の行を足してください——Admission は initContainers のイメージも ImageReview に載せます。 棚卸しに入っていないものは、許可リストにも入りません。

実行結果(クラスタ内のユニークイメージは 55 件、レジストリは 12 種):

レジストリ件数(コンテナ数ベース)
quay.io75
docker.io37
registry.k8s.io21
busybox:1.37(接頭辞なし)8
velero(接頭辞なし)7
k8s-registry:50005
grafana(接頭辞なし)5
openpolicyagent(接頭辞なし)4
redis:8.2.3-alpine / postgres:18 / rancher(接頭辞なし)各 1
ghcr.io1

自社イメージ(k8s-registry:5000)は 5 件で、全体のごく一部です。 クラスタの中身の大半は、外部レジストリから来ています。

ステップ0b:接頭辞なしのイメージが 7 種ある

棚卸しの結果に、レジストリ名で始まっていないイメージ参照が 7 種混ざっています。

busybox:1.37
grafana/fluent-bit-plugin-loki:2.1.0-amd64
openpolicyagent/gatekeeper:v3.23.0
postgres:18
rancher/local-path-provisioner:v0.0.30
redis:8.2.3-alpine
velero/velero:v1.18.2

同じ Docker Hub のイメージが、2 通りの書かれ方でクラスタの中に共存しています。

書かれ方
完全修飾docker.io/library/busybox:1.37.0
接頭辞なしbusybox:1.37

どちらも同じ Docker Hub のイメージです。 コンテナランタイムは接頭辞が無ければ Docker Hub を補うので、動作としては等価になります。しかし ImageReview に載るのは「マニフェストに書かれた文字列そのもの」です。

文字列の前方一致でレジストリを判定するバックエンドは、この 7 種を「docker.io/ で始まらない」として扱います。 許可リストに docker.io/ を入れても、postgres:18 は拒否されます。

第8回で同じ形の落とし穴を見ています。 あのとき VAP の CEL 式で image.contains(':') を使うと、レジストリのポート番号(k8s-registry:5000)のコロンとタグのコロンを取り違えました。 イメージ参照を文字列として判定することの脆さが、本回でも別の形で出ています。

イメージ参照は「見た目より複雑な文字列」です。 レジストリ・ポート・名前空間・リポジトリ・タグ・ダイジェストが 1 本の文字列に詰まっており、しかも省略が許されます。 ポリシーを文字列一致で書くなら、省略形を必ず数えてから書いてください。

ステップ0c:許可リストを決める

許可リストは、棚卸しの結果から作ります。 /etc/kubernetes/imagepolicy/allowed-registries.txt に 1 行 1 プレフィックスで書きます。

# 許可するレジストリのプレフィックス。1 行 1 件。
# ステップ 0 の棚卸し(ユニーク 55 件・レジストリ 12 種)から作った。
k8s-registry:5000/
registry.k8s.io/
quay.io/
docker.io/
ghcr.io/

このリストを前方一致だけで使うと、ステップ 0b の 7 種が拒否されます。 postgres:18docker.io/ で始まっていないからです。だからバックエンド側で、省略された接頭辞を補ってから判定します(ステップ 1 のソースの normalize() がそれです)。

ただし、その「補う処理」も素直に書くと間違えます。 12 種のイメージ参照で 3 つの実装を比べた実測です。

判定の実装拒否される件数拒否されるもの
正規化なし(前方一致だけ)7接頭辞なしの 7 種すべて
素朴な正規化(先頭要素に .: があればレジストリと見なす)3busybox:1.37 / postgres:18 / redis:8.2.3-alpine
正しい正規化(スラッシュの有無を先に見る)0

真ん中の実装が落とすのは、スラッシュを含まない 3 種です。postgres:18/ で切ると先頭要素は postgres:18 そのもので、ここに含まれるコロンはタグの区切りなのに、レジストリのポート番号だと解釈されます。 velero/velero:v1.18.2 のようにスラッシュがあるものは、先頭要素が velero なので正しく判定できます。

第8回とまったく同じ間違いです。 あちらは CEL 式の image.contains(':') でポート番号とタグを取り違えました。こちらは Python で同じ取り違えをしています。 言語も道具も違うのに、間違え方だけが同じです——イメージ参照という文字列の構造が、そう間違えるようにできています。

だから normalize() は「スラッシュが無ければ Docker Hub の公式イメージ」を先に判定します。 順序を入れ替えるだけで直ります。そして順序が逆でも、7 種のうち 4 種は通ってしまいます——半分通るのが、いちばん気づきにくい壊れ方です。

本回の最終形は「上の 5 プレフィックス + 正しい正規化」です。 許可リストに接頭辞なしの 7 種を書き足す方法も採れますが、そちらは新しいマニフェストが増えるたびに書き足すことになります。 正規化はイメージ参照の書き方そのものを揃えるので、増えても効き続けます。

本番ガードレール⑨: 絞る前に、クラスタ自身が使っているレジストリを数えます。

「自社イメージだけ許可すればよい」という発想で書くと、クラスタ自身が許可されません。

そしてこの事故は、絞った瞬間には起きません。 次にノードや kubelet が再起動したときに起きます(後の節で実証します)。変更した日と事故が起きる日が離れているので、原因に辿り着きにくくなります。

絞った後に意図的に kubelet を再起動して、何も壊れないことを確認する——これが唯一の確かめ方です。

ステップ1:バックエンドを立てる(cp-01)

cp-01 の手順を全量で示します。 cp-02 / cp-03 はステップ 5 で「同じ手順を繰り返す」形にします。

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

# mkdir -p /etc/kubernetes/imagepolicy
# openssl req -x509 -newkey rsa:2048 -nodes -days 365 \
    -keyout /etc/kubernetes/imagepolicy/webhook.key \
    -out /etc/kubernetes/imagepolicy/webhook.crt \
    -subj "/CN=image-policy-webhook" \
    -addext "subjectAltName = IP:127.0.0.1, DNS:localhost"
# chmod 600 /etc/kubernetes/imagepolicy/webhook.key

subjectAltNameIP:127.0.0.1 を入れるのが要点です。 apiserver は kubeconfig の server: https://127.0.0.1:8081/ へ接続するので、証明書の SAN に IP が入っていないと TLS 検証で弾かれます。 CN だけでは通りません。

/etc/kubernetes/imagepolicy/image-policy-webhook.py(全量):

#!/usr/bin/env python3
"""ImagePolicyWebhook のバックエンド。

許可レジストリのプレフィックス一覧を外部ファイルから読み、
ImageReview のイメージ参照がそのいずれかに一致するかだけを判定する。
"""
import json
import ssl
from http.server import BaseHTTPRequestHandler, HTTPServer

ALLOWED_FILE = "/etc/kubernetes/imagepolicy/allowed-registries.txt"
CERT_FILE = "/etc/kubernetes/imagepolicy/webhook.crt"
KEY_FILE = "/etc/kubernetes/imagepolicy/webhook.key"
LISTEN_ADDR = ("127.0.0.1", 8081)

def load_allowed():
    prefixes = []
    with open(ALLOWED_FILE) as f:
        for line in f:
            line = line.strip()
            if line and not line.startswith("#"):
                prefixes.append(line)
    return prefixes

def normalize(image):
    """接頭辞が省略されたイメージ参照に docker.io を補う。

    postgres:18 -> docker.io/library/postgres:18
    velero/velero:v1.18.2 -> docker.io/velero/velero:v1.18.2

    スラッシュが無い参照を先に処理する。先に head を調べると、
    postgres:18 の ":18" をレジストリのポート番号と取り違える。
    """
    if "/" not in image:
        return "docker.io/library/" + image
    head = image.split("/", 1)[0]
    if "." in head or ":" in head or head == "localhost":
        return image
    return "docker.io/" + image

def review(images):
    prefixes = load_allowed()
    for image in images:
        target = normalize(image)
        if not any(target.startswith(p) for p in prefixes):
            return False, "image is not from an allowed registry: %s" % image
    return True, ""

class Handler(BaseHTTPRequestHandler):
    def do_POST(self):
        length = int(self.headers.get("Content-Length", 0))
        body = json.loads(self.rfile.read(length) or "{}")
        spec = body.get("spec", {})
        images = [c.get("image", "") for c in spec.get("containers", [])]
        allowed, reason = review(images)
        print("ImageReview ns=%s images=%s allowed=%s"
              % (spec.get("namespace", ""), images, allowed), flush=True)
        response = {
            "apiVersion": "imagepolicy.k8s.io/v1alpha1",
            "kind": "ImageReview",
            "status": {"allowed": allowed},
        }
        if not allowed:
            response["status"]["reason"] = reason
        payload = json.dumps(response).encode()
        self.send_response(200)
        self.send_header("Content-Type", "application/json")
        self.send_header("Content-Length", str(len(payload)))
        self.end_headers()
        self.wfile.write(payload)

    def log_message(self, fmt, *args):
        return

def main():
    context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
    context.load_cert_chain(CERT_FILE, KEY_FILE)
    server = HTTPServer(LISTEN_ADDR, Handler)
    server.socket = context.wrap_socket(server.socket, server_side=True)
    server.serve_forever()

if __name__ == "__main__":
    main()

これも実機で動かしたものそのものです。 外部ライブラリは使っていませんので、pip も要りません——Control Plane に開発環境を持ち込まずに済みます(ガードレール⑦)。

許可リストをソースの中の定数にせず、外部ファイルから読んでいます。 理由は 2 つあります。

  1. バックエンドを再起動するだけで許可リストを変えられます(後の節の実証と演習④の流れが追いやすくなります)
  2. 「許可レジストリの一覧」と「判定ロジック」が分かれます——発展の節のガードレール⑩(許可リストと署名検証の対象は別に持つ)と同じ考え方です

/etc/systemd/system/image-policy-webhook.service(全量):

[Unit]
Description=ImagePolicyWebhook backend for kube-apiserver
After=network-online.target
Wants=network-online.target

[Service]
ExecStart=/usr/bin/python3 /etc/kubernetes/imagepolicy/image-policy-webhook.py
Restart=always
RestartSec=2

[Install]
WantedBy=multi-user.target

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

# systemctl daemon-reload
# systemctl enable --now image-policy-webhook.service
# systemctl is-active image-policy-webhook.service

active と返れば起動しています。 このあとバックエンドが受けた ImageReviewjournalctl -u image-policy-webhook に 1 リクエスト 1 行で流れます(ソースの print(...) がそれです)——拒否の切り分けは、まずこのログを見ます。

apiserver はまだこのバックエンドを呼びません(3 点セットの適用はステップ 2 です)。いま動いているかどうかは、バックエンドに直接リクエストを投げて確かめます。

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

# curl -s --cacert /etc/kubernetes/imagepolicy/webhook.crt -X POST https://127.0.0.1:8081/ \
    -H 'Content-Type: application/json' \
    -d '{"apiVersion":"imagepolicy.k8s.io/v1alpha1","kind":"ImageReview","spec":{"containers":[{"image":"k8s-registry:5000/fanclub-backend:0.4.1"}],"namespace":"default"}}'
# curl -s --cacert /etc/kubernetes/imagepolicy/webhook.crt -X POST https://127.0.0.1:8081/ \
    -H 'Content-Type: application/json' \
    -d '{"apiVersion":"imagepolicy.k8s.io/v1alpha1","kind":"ImageReview","spec":{"containers":[{"image":"gcr.io/distroless/static:nonroot"}],"namespace":"default"}}'

実行結果:

{"apiVersion": "imagepolicy.k8s.io/v1alpha1", "kind": "ImageReview", "status": {"allowed": true}}
{"apiVersion": "imagepolicy.k8s.io/v1alpha1", "kind": "ImageReview", "status": {"allowed": false, "reason": "image is not from an allowed registry: gcr.io/distroless/static:nonroot"}}

apiserver に繋ぐ前に、バックエンド単体で許可と拒否が返ることを確認できました。 この順序が大事です——apiserver に適用してから動かないと、「バックエンドが悪いのか、3 点セットが悪いのか」の切り分けに戻れなくなります。

接頭辞なしのイメージも試しておくと、normalize() が効いていることを確認できますpostgres:18velero/velero:v1.18.2allowed: true になります)。ステップ 0b で数えた 7 種すべてを通しておくと安心です。

ステップ2:3 ファイルを配置し、apiserver に適用する

先にバックアップを取ります。

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

# cp /etc/kubernetes/manifests/kube-apiserver.yaml /root/kube-apiserver.yaml.bak-14

/etc/kubernetes/imagepolicy/admission-config.yaml(全量):

apiVersion: apiserver.config.k8s.io/v1
kind: AdmissionConfiguration
plugins:
  - name: ImagePolicyWebhook
    configuration:
      imagePolicy:
        kubeConfigFile: /etc/kubernetes/imagepolicy/kubeconfig.yaml
        allowTTL: 50
        denyTTL: 50
        retryBackoff: 500
        defaultAllow: false

公式の記載例から変えたのは defaultAllow の 1 行だけです。 公式は true(fail-open)ですが、演習③は false(fail-closed)で進めます。 切り替えて比べるのは演習④です。

/etc/kubernetes/imagepolicy/kubeconfig.yaml(全量):

apiVersion: v1
kind: Config
clusters:
  - name: image-policy-webhook
    cluster:
      certificate-authority: /etc/kubernetes/imagepolicy/webhook.crt
      server: https://127.0.0.1:8081/
users:
  - name: kube-apiserver
contexts:
  - name: image-policy-webhook
    context:
      cluster: image-policy-webhook
      user: kube-apiserver
current-context: image-policy-webhook

certificate-authority にサーバ証明書自身を指定しています。 自己署名なので、その証明書が自分の CA を兼ねます。 users にクライアント証明書を書いていないのは、本ラボのバックエンドがクライアント証明書を要求しないためです(本番での扱いはガードレール⑦を参照してください)。

/etc/kubernetes/manifests/kube-apiserver.yaml への差分は 3 箇所です。

command のフラグ 2 行(既存の --enable-admission-plugins=NodeRestriction を書き換え、1 行追加します):

    - --enable-admission-plugins=NodeRestriction,ImagePolicyWebhook
    - --admission-control-config-file=/etc/kubernetes/imagepolicy/admission-config.yaml

volumeMounts に 3 行

    - name: imagepolicy
      mountPath: /etc/kubernetes/imagepolicy
      readOnly: true

volumes に 4 行

  - name: imagepolicy
    hostPath:
      path: /etc/kubernetes/imagepolicy
      type: DirectoryOrCreate

3 ファイルはいずれも実機に配置して動かしたものです(admission-config.yaml307 バイトkubeconfig.yaml376 バイト、許可リストが 234 バイト)。インデントは apiserver マニフェストの既存の並びに合わせてください——volumeMountsvolumes は同じ名前(imagepolicy)で対になっている必要があります。

--enable-admission-plugins は既存の値を消さないでください。 起点は NodeRestriction のみです。置き換えるのではなく、カンマで足します。

実行コマンド(k8s-cp-01・root・保存後に /livez を数秒おきに見ます):

# curl -sk -o /dev/null -w '%{http_code}\n' https://127.0.0.1:6443/livez

実行結果(実測):

項目実測
/livez が 200 に戻るまで45 秒000 が 35 秒続き、500 を 1 回挟んでから 200

500 が 1 回返ります。 これを見て「壊れた」と判断して戻すと、正常な起動過程を中断することになります。apiserver は起動の途中で 500 を返す時間帯があります。 第5回/livez を見る作法を学びましたが、1 回の応答で判断しないでください——数秒おきに繰り返して、200 が続くことを確認します。

ステップ3:動作を確認する(cp-01 だけの状態)

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

$ kubectl run ipw-allowed --image=k8s-registry:5000/fanclub-backend:0.4.1 --restart=Never
$ kubectl run ipw-denied --image=gcr.io/distroless/static:nonroot --restart=Never
$ kubectl run ipw-denied2 --image=mcr.microsoft.com/dotnet/runtime:8.0 --restart=Never

実行結果:

対象結果
k8s-registry:5000/fanclub-backend:0.4.1created
gcr.io/distroless/static:nonroot拒否
mcr.microsoft.com/dotnet/runtime:8.0拒否

この表は「cp-01 に当たったとき」の結果です。手元では created になることがあります。

この時点で 3 点セットを適用したのは cp-01 の 1 台だけで、kubectl は k8s-lb(HAProxy)を経由します。つまり拒否されるのは、リクエストが cp-01 へ振られたときだけです。実測でも gcr.io のほうが created になり、mcr.microsoft.com だけが拒否される、という出方をしました。

手順を間違えたわけではありません。 なぜそうなるかは、次のステップ4 で 12 回打って数えるところで確定させます。ここで created が出た人は、そのまま読み進めてください——それ自体がステップ4 の答えです。

拒否のメッセージ(cp-01 に当たった場合):

Error from server (Forbidden): pods "ipw-denied" is forbidden: image policy webhook backend denied one or more images: image is not from an allowed registry: gcr.io/distroless/static:nonroot

拒否の実演に docker.io/library/nginx:1.27 を使わない理由があります。 本演習の許可リストは棚卸しから作ったので、docker.io/ を含んでいます——nginx正しく許可されます。 拒否を見たいなら、棚卸しに出てこなかったレジストリを選びますgcr.iomcr.microsoft.com がそれです)。

ここで許可リストを一時的に k8s-registry:5000/ だけへ絞って試す、という手もあります。 本書はそれを採りません——絞った状態のまま次のステップへ進むと、そのあとで起きる事故(Control Plane が観測から消える)と、演習の意図した拒否とが混ざるからです。許可リストは棚卸し版のまま動かさず、対象のイメージだけを変えて確かめます。

拒否の理由の文字列は、バックエンドが返した status.reason がそのまま出ています。 つまりメッセージの分かりやすさはバックエンドの実装しだいです。「forbidden」とだけ返すこともできますが、何が駄目だったかを書いておくと、拒否された人が自分で直せます。 拒否の設計には、メッセージの設計が含まれます。

ステップ4:1 台だけでは効かないことを確認する

cp-01 だけに適用した状態で、k8s-ops から HAProxy 経由で 12 回連続実行します。 対象は拒否されるはずのイメージgcr.io/distroless/static:nonroot)で、1 回ごとに違う Pod 名を使います——同じ名前だと 2 回目以降が「既にある」で落ち、Admission の判定を見ていないことになります。

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

$ for i in $(seq 1 12); do
    out=$(kubectl run ipw-ha-$i --image=gcr.io/distroless/static:nonroot -n default --restart=Never --dry-run=server 2>&1)
    if echo "$out" | grep -q Forbidden; then echo "$i: DENIED"; else echo "$i: allowed"; fi
  done

--dry-run=server を使うので、Pod は実際には作られません。 Admission だけを通して判定を見ています第8回で PSS の違反を数えたときと同じ手です)。

実行結果:

1: allowed / 2: DENIED / 3: allowed / 4: allowed / 5: DENIED / 6: allowed
7: allowed / 8: DENIED / 9: allowed / 10: allowed / 11: DENIED / 12: allowed
→ allowed=8 denied=4

12 回中 4 回しか拒否されません。 HAProxy が 3 台の apiserver に振り分けており、設定を入れた 1 台に当たったときだけ効きます。

第2回の CIS 1.2.5 と同じ構図です。 あのときも「1 台だけ直して kube-bench が PASS になった」ように見えて、実際には 3 台のうち 1 台しか直っていませんでした。

セキュリティ設定を 1 台だけに入れるのは、入れていないのとほぼ同じです。 「3 回に 1 回は止まる」ゲートは、攻撃者にとっては 3 回試せばよいだけです。しかも運用者からは「たまに拒否される謎の不具合」に見えます——効いていないことよりも、一貫しないことのほうが厄介です。

ImagePolicyWebhook の 3 ファイル整合と HA での効き方を示す図。上段は cp-01 上の参照の連鎖で、apiserver コンテナ(静的 Pod)の中の kube-apiserver のフラグ 2 行が 3 点セット(マウント)を経て admission-config.yaml を指し、そこから kubeconfig.yaml、さらに 127.0.0.1:8081 のバックエンド(systemd の image-policy-webhook.service・Python の http.server と ssl)へつながる。3 点セット(フラグ + volumeMounts + volumes)が無いと「no such file or directory」になり、ホストには admission-config.yaml があるのに apiserver コンテナの中からは見えない。下段は HA での効き方で、k8s-ops から HAProxy を経て cp-01・cp-02・cp-03 の 3 台に振り分けられる。cp-01 だけに適用した場合は 12 回中 allowed=8 / denied=4 で、設定した cp-01 に当たったときだけ拒否される。3 台すべてに適用した場合は 12 回中 allowed=0 / denied=12 で、どの apiserver に当たっても同じ答えが返る。セキュリティ設定を 1 台だけに入れるのは入れていないのとほぼ同じで、3 回に 1 回は止まるゲートは攻撃者にとっては 3 回試せばよいだけ
図3:ImagePolicyWebhook の 3 ファイル整合と、HA での効き方

ステップ5:残り 2 台にも適用する

cp-02 と cp-03 でやることは、cp-01 と 1 文字も違いません。 ステップ 1・2 をそのまま繰り返します。違うのは、間に /livez の確認を挟むことだけです。同じ手順を 3 回読むより、「同じである」と明示して 1 回で書くほうが、間の確認手順が目立ちます。

3 台に配るもののチェックリストです。

#配るもの置き場所
1サーバ証明書と秘密鍵/etc/kubernetes/imagepolicy/
2バックエンドのスクリプト/etc/kubernetes/imagepolicy/
3許可リスト(allowed-registries.txt/etc/kubernetes/imagepolicy/
4systemd ユニット(image-policy-webhook.service/etc/systemd/system/
5admission-config.yaml/etc/kubernetes/imagepolicy/
6kubeconfig.yaml/etc/kubernetes/imagepolicy/
7apiserver マニフェストの差分(フラグ 2 + volumeMounts 3 + volumes 4)/etc/kubernetes/manifests/kube-apiserver.yaml

証明書は各ノードで作ります。 SAN が IP:127.0.0.1 なので 1 枚を 3 台で共用することもできますが、共用すると秘密鍵をネットワーク越しに配ることになります。 作るのは 1 コマンドです——配る手間より、作り直す手間のほうが小さいときは、配りません。

/livez の復帰は 1 台ずつ確認します。実測は次のとおりでした。

ノード/livez が 200 に戻るまで
k8s-cp-0145 秒000 が 7 回 → 500 を 1 回 → 200
k8s-cp-0250 秒
k8s-cp-0345 秒

3 台合わせて 2 分半ほど、待つ時間だけで消えます。 これが「Control Plane に手で配る」方式の代償です——本回の前半で並べた 3 つの手段のうち、①②(Gatekeeper / VAP)なら kubectl apply の 1 回で 3 台に効きます。

3 台に適用したあと、ステップ 4 と同じ 12 回実行をもう一度行います。

実行結果:

→ allowed=0 denied=12

12 回とも拒否されました。 1 台だけのときの 8 対 4 が、3 台では 0 対 12 になります。 「どの apiserver に当たっても同じ答えが返る」——これがポリシーが効いている状態です。

本番ガードレール⑧: Control Plane への変更は 1 台ずつ、/livez を確認しながら行います。

3 台同時に apiserver のマニフェストを書き換えると、設定に誤りがあったときに 3 台とも同時に落ちます。 1 台ずつなら、1 台が落ちても残り 2 台が受け付けます。

第5回で確立した作法をそのまま使います——バックアップ → 1 台適用 → /livez が 200 で安定 → 次の 1 台。

演習③のゴール確認

確認実測
3 台とも /livez が 200200 / 200 / 200
image-policy-webhook.service が 3 台で active3 台とも active
許可レジストリの Pod(k8s-registry:5000/fanclub-backend:0.4.1created
接頭辞なしのイメージpostgres:18 など)created(正規化が効いています)
棚卸しに無いレジストリの Pod(gcr.io/distroless/static:nonroot12 回連続で 12 回とも拒否
ノードと Pod5/5 Ready・異常 0
https://fanclub.local/api/members200 + 会員 3 名
ArgoCDSynced / Healthy

接頭辞なしのイメージが created になることを、必ず確認してください。 ここが拒否されるなら normalize() が効いていません——そのまま進むと、次にそれらの Pod が作り直された日に止まります。

試験ではこう問われる。

ImagePolicyWebhook を有効化し、指定のバックエンドを使うよう apiserver を設定せよ」「与えられた admission-config.yaml を修正し、Webhook 不達時に拒否するようにせよ」という粒度で出ます。手が覚えているべきは 3 ファイルの関係と、--enable-admission-plugins / --admission-control-config-file の 2 フラグ、そして volumeMounts / volumes を足すことです。

kubernetes.io/docs は試験中に参照できますので、AdmissionConfiguration の YAML 構造(apiserver.config.k8s.io/v1 / plugins[].name: ImagePolicyWebhook / imagePolicy の 5 キー)は引けます。引けないのは「どのファイルに何を書くか」の全体像なので、そこを覚えてください。単位(allowTTL / denyTTL は秒・retryBackoff はミリ秒)も引けますが、引く時間が惜しいので覚えておきます。

発展(演習の外): バックエンドに署名検証を足す —— 「誰の署名か」を見る

この節は演習ではありません。 読んで理解する節で、手を動かすかは読者に任せます。前節で書いた「バックエンドは任意実装である」を、実装で確かめます。

なぜ演習にしないのか。この節の内容は、試験には出ません。 ImagePolicyWebhook のバックエンドを自分で書く設問は CKS にはありません。それでもここに置くのは、「Admission が何を見ているか」を最も具体的に示せるからです。手を動かさずに読むだけでも、次節の事故が理解できます。

ステップ1:バックエンドに cosign verify を足す

バックエンドは任意実装なので、status.allowed を返す前に cosign verify を呼べます。 cp-01 に cosign v3.1.2 と cosign.pub を置き、Python から subprocess.run で呼ぶ形に十数行の拡張で実装できます。

image-policy-webhook.py への追加(ステップ 1 の全量に足す差分):

import subprocess

COSIGN_PUB = "/etc/kubernetes/imagepolicy/cosign.pub"
# 署名検証の対象は自社レジストリだけ。ここを外すとクラスタが壊れる(本文)。
SIGNED_PREFIX = "k8s-registry:5000/"

def verify_signature(image):
    result = subprocess.run(
        ["/usr/bin/cosign", "verify", "--key", COSIGN_PUB,
         "--allow-http-registry", "--insecure-ignore-tlog", image],
        stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
    return result.returncode

def review(images):
    prefixes = load_allowed()
    for image in images:
        target = normalize(image)
        if not any(target.startswith(p) for p in prefixes):
            return False, "image is not from an allowed registry: %s" % image
        if not image.startswith(SIGNED_PREFIX):
            continue
        rc = verify_signature(image)
        if rc != 0:
            return False, "signature verification failed (exit %d): %s" % (rc, image)
    return True, ""

実測値です。

対象結果所要cosign を呼んだか
署名済み fanclub-backend:0.4.1created157 ms呼んだ
未署名 fanclub-backend:0.4.0拒否signature verification failed (exit 10)104 ms呼んだ
署名済み fanclub-frontend:1.0.0created127 ms呼んだ
docker.io/library/nginx:1.27created66 ms呼ばない
registry.k8s.io/pause:3.10created68 ms呼ばない

下 2 行が、条件分岐②が効いていることの証拠です。 どちらも許可レジストリなので①は通りk8s-registry:5000/ ではないので②で止まりcosign は呼ばれずに許可されます。 所要が 66〜68 ms と、cosign を呼ぶ行(104〜157 ms)より短いこと自体が「呼んでいない」ことを示しています——時間の差で、通った経路が読めます。

とくに registry.k8s.io/pause:3.10 が通ることに注目してください。 このイメージには署名が実在しますが、自分たちの鍵では検証できません(終了コード 12)。②が無ければ、ここで拒否されていました。 次のステップがその話です。

cp-01 上の cosign verify 単体は 101 / 87 / 78 ms(3 回計測)です。

Admission の同期パスで外部プロセスを起動しても、実用の範囲に収まります。 Pod の作成が 100 ms 前後遅くなるだけです。ただし「1 リクエストあたり 1 プロセス起動」はスケールしません——大量に Pod が作られる環境では、検証結果をバックエンド側でキャッシュするか、cosign のライブラリを直接呼ぶ実装にします。本ラボは仕組みを見せるための最小実装です。

ステップ2:判定の順序 —— 条件分岐は必須である

「署名検証を足せた」で終わらせないでください。 ここを読まずに実装すると、許可レジストリのイメージすべてに cosign verify を掛ける実装を書くことになります。それはクラスタを壊します(次節で実証します)。

本ラボのバックエンドの判定順序です。

① 許可レジストリか? ──── No ──→ 拒否(reason: registry not allowed)
        │ Yes
        ▼
② k8s-registry:5000/ か? ── No ──→ 許可(署名検証しない)
        │ Yes
        ▼
③ cosign verify ──── 失敗 ──→ 拒否(reason: signature verification failed)
        │ 成功
        ▼
      許可

②が無いとどうなるか。 registry.k8s.io/ は①を通ります(許可レジストリだからです)ので、そのまま③へ進みます。 そして registry.k8s.io のイメージには Kubernetes プロジェクトの署名が実在しますが、自分たちの鍵では検証できません(終了コード 12)。結果として拒否されます。

ここが本回の主題と結びつきます。 ②を外した実装は、「署名されていれば通す」つもりで書かれています。しかし cosign が判定しているのは「自分の鍵の署名か」です。 registry.k8s.io のイメージは署名されているのに通りません。 「署名検証を全部に掛ければ安全になる」は成り立ちません——鍵を持っていない相手の署名は、検証できないだけです。

本番ガードレール⑩: Admission に検査を足すときは、「何を検査できるか」を先に数え、「呼ぶ条件」を別に持ちます。

①署名検証を足す前に、署名していないものを棚卸しします。 本ラボで署名しているのは backend と frontend の 2 つだけで、クラスタ上で動いている残り(PostgreSQL / Longhorn / Cilium / Traefik / ArgoCD / Prometheus / registry.k8s.io のコンポーネント)はすべて自分たちの鍵では検証できません。 全イメージに署名検証を掛けると、それらが 1 つも起動しなくなります。

②「許可レジストリ」と「署名検証の対象」を同じリストで兼ねません。 兼ねると、レジストリを 1 つ許可に足した瞬間に、それが署名検証の対象にもなります。 2 つは目的の違う設定なので、別のファイル・別の変数で持ちます(本ラボは allowed-registries.txtSIGNED_PREFIX に分けています)。

③検査の対象範囲は、検査できるものの範囲と一致させます。 段階的に入れるなら、まず自社イメージだけを対象にし、対象を広げるたびに棚卸しをやり直してください。

ステップ3:この節の締め(試験との距離)

CKS で問われるのは「ImagePolicyWebhook を有効化して、与えられたバックエンドにつなぐ」ところまでです。 バックエンドを書く設問はありません。それでもこの節を読む価値は、「Admission の判定は誰かが書いたコードである」ことが分かる点にあります。

PSA も Gatekeeper も VAP も、どこかで誰かが書いた判定ロジックが動いています。 それが何を見て、何を見ていないかを知らずに使うと、通ったことを「安全だ」と読んでしまいます。

絞りすぎると Control Plane が kubectl から消える —— 2 つの経路

本回の 3 つ目の山です。 「安全側に倒したつもりが、観測を失う」という形の事故を 2 通り扱います。どちらも「絞りすぎ」ですが、絞ったものが違います。

#絞ったもの何が抜けていたか該当
許可レジストリ許可リストに registry.k8s.io/ を入れ忘れた演習③(ステップ 0 の棚卸しを飛ばすと起きます)
署名検証の対象k8s-registry:5000/ に限定する条件分岐が無かった発展の節

①は「許可しなかった」、②は「許可したうえで、検証できないものを検証しようとした」。 原因は違いますが、見える結果は同じです。

静的 Pod のミラーも ImageReview を通る

バックエンドのログに、こちらが何もしていないのに次が現れました。

ImageReview ns=kube-system images=['registry.k8s.io/kube-apiserver:v1.36.3'] allowed=True

apiserver 自身のミラー Pod が ImageReview にかかっています。 静的 Pod は kubelet がマニフェストから直接起動しますが、その存在を API に登録するための「ミラー Pod」というオブジェクトを kubelet が作ります。 そのオブジェクトの作成が Admission を通ります。

つまり Admission は、クラスタ自身のコンポーネントにも効きます。

絞った直後は何も起きない

許可リストを k8s-registry:5000/ だけに絞って apiserver を再起動しても、ミラー Pod は消えませんでした。

理由は、コンテナの作り直しではミラー Pod オブジェクトが再作成されないからです。オブジェクトが既に存在するので、ImageReview 自体が発生しません。

「絞ったのに何も起きなかった」を「安全だ」と読んではいけません。 起きるのは Pod オブジェクトが作り直される時だけです。ノードを再起動したとき、kubelet を再起動したとき、あるいは何かの拍子にミラー Pod が消えたとき——設定を変えてから何日も経った後に、忘れた頃に起きます。

これは Admission 全般に言える性質です。 第8回の PSA も同じで、既に動いている Pod は再作成されるまで新しいポリシーの影響を受けません。

実証: ミラー Pod を削除して kubelet を再起動する

kubelet のログ:

kubelet[6224]: I  "Creating a mirror pod for static pod" pod="kube-system/kube-apiserver-k8s-cp-01"
kubelet[6224]: E  "Failed creating a mirror pod" err="pods \"kube-apiserver-k8s-cp-01\" is forbidden: image policy webhook backend denied one or more images: image is not from an allowed registry: registry.k8s.io/kube-apiserver:v1.36.3"

4 コンポーネントすべてが拒否されましたkube-apiserver / kube-controller-manager / kube-scheduler / etcd:3.6.8-0)。

観測実体
kubectl get pods -n kube-system から Control Plane 4 つが消えるcrictl ps では 13 コンテナが Running のままクラスタは動き続ける

消えたのは「Pod オブジェクト」であって、動いているプロセスではありません。 apiserver も etcd も動き続けており、kubectl も応答します。しかし kubectl get pods -n kube-system には Control Plane が映りません。

これは「壊れた」よりも厄介です。 動いているのに観測から消えるので、監視は「Pod が無い」と言い、実体は正常に動いています。 どちらを信じるかで対応が変わります。

確かめる方法は 1 つ——ノードに入って crictl ps を見ることです。kubectl は API サーバ経由の「登録された情報」を見せますが、crictl はコンテナランタイムに直接聞きます。2 つの視点を持っておいてください。

復旧には kubelet の再起動が要る

許可リストを戻して 30 秒待ってもミラー Pod は復活せず、kubelet を再起動して初めて 4 つとも戻りました。

原因を取り除いても、自動では戻りません。 kubelet はミラー Pod の作成を 1 度失敗すると、そのまま再試行しない状態になります。「設定を戻したのに直らない」ときに、何を再起動すればよいかを知っているかどうかで復旧時間が変わります。

順序はこうです。 ①許可リストにレジストリを足す ②バックエンドを再起動する ③kubelet を再起動するkubectl get pods -n kube-system で 4 つ戻ったことを確認する。

この経路の予防策は、演習③のステップ 0 で書いたガードレール⑨(絞る前に数える)です。 ここでは「数えなかったら実際にこうなる」を見せています。

経路②: 署名検証の対象を絞らなかった場合

発展の節の判定順序から②(k8s-registry:5000/ に限定する条件分岐)を外した実装で、同じことが起きます。

対象結果終了コード所要
registry.k8s.io/pause:3.10拒否signature verification failed122,149 ms(バックエンド越しの計測値)
registry.k8s.io/kube-apiserver:v1.36.3(ミラー Pod)拒否apiserver のミラー Pod が作れない

exit 12 の意味を取り違えないでください。exit 12 は「レジストリに到達できなかった」ではありません。

  • 本ラボの Squid whitelist には registry.k8s.io が登録済みです(第2巻で登録しており、そもそも本クラスタはそこから pull して構築されています)
  • プロキシ環境変数を全部外して実行しても、結果は同じです(EXIT=12・1,706 ms)
  • registry.k8s.io/pause:3.10 には署名が実在します——cosign tree を打つと registry.k8s.io/pause:sha256-ee6521f2....sig という.sig 形式のタグが見え、cosign download signatureCert 付きの keyless 署名org.kubernetes.kpromo.version: kpromo-v4.0.4-483-gf08f0f2(Kubernetes プロジェクトのリリース自動化 kpromo が付けたもの)が取れます

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

$ cosign tree registry.k8s.io/pause:3.10

実行結果:

📦 Supply Chain Security Related artifacts for an image: registry.k8s.io/pause:3.10
└── 🔐 Signatures for an image tag: registry.k8s.io/pause:sha256-ee6521f2....sig
   └── 🍒 sha256:a0297626f913da65ad1fef50a2b95093ad0a0e5c0ca5414da51f8d526ce7d3c4

署名は取れています。取れた署名が、自分たちの鍵のものではなかっただけです。 1.7 秒かかるのは、外部レジストリへ実際に往復して署名を取ってきているからであって、失敗を待っているのではありません。

この経路がいちばんよく効く教材です。

registry.k8s.io/pause:3.10 は署名されています。 それも Kubernetes プロジェクトという素性のはっきりした相手による署名です。それでも拒否されます。

「署名検証を全部に掛ければ安全になる」は成り立ちません。 検証しているのは「自分が信頼する鍵の署名か」であって、「署名があるか」ではありません。鍵を持っていない相手の署名は、検証できないだけです。

第2回でバイナリの SHA256 を照合したときと同じ構図です。 ハッシュが一致しないのは「改ざんされた」ときだけではありません——そもそも別のものを照合しているときにも一致しません。照合とは、比べる相手を選ぶことです。

バックエンドのメッセージは、原因を正しく指していません。 拒否の理由は signature verification failed と返りますが、読者が知りたいのは「なぜ失敗したか」です。 exit 10(署名が無い)と exit 12(この鍵では検証できない)を同じ文言で返してしまうと、「署名すれば通る」と誤解されます。 自前のバックエンドを書くときは、終了コードごとに理由を書き分けてください——これは自前実装の弱点であり、同時に自前実装の自由でもあります。

直しても約 64 秒は元に戻らない

条件分岐あり版に戻して kubelet を再起動しても、apiserver のミラー Pod だけ戻らない時間があります。

実測のタイムライン:

17:24:22  拒否(条件分岐なし版)
17:24:48  etcd / controller-manager / scheduler は allowed=True で復活
17:24:49  kube-apiserver だけ「is forbidden ... exit 12」
17:25:53  Creating a mirror pod → 復活(拒否から 64 秒後)

原因を取り除いても、判定はすぐには戻りません。 admission-config.yamldenyTTL: 50(秒)が効いており、拒否の判定がイメージ単位でキャッシュされています。

3 つは 26 秒後に戻ったのに、apiserver だけ 64 秒かかりました。 差はキャッシュに載ったタイミングです。「同じ操作をしたのに、コンポーネントによって戻る時刻が違う」という形で現れます。

ここで焦って別の手を打つと、切り分けができなくなります。 直したらまず denyTTL の秒数だけ待ってください。 それでも戻らないなら、はじめて別の原因を疑います。denyTTL の単位が秒だと先に確認しておいたのは、このためです。

このキャッシュは、演習④でもう 1 度出てきます。 同じ仕組みが、違う顔で 2 度読者を惑わせます。

2 つの経路のまとめ

#絞ったもの直し方直してから戻るまで
許可レジストリ(registry.k8s.io/ の入れ忘れ)許可リストに足す → バックエンド再起動 → kubelet 再起動実測: kubelet 再起動で 4 つとも復活
署名検証の対象(条件分岐の欠落)条件分岐を戻す → バックエンド再起動 → kubelet 再起動denyTTL の分だけ遅れる(実測 64 秒)

どちらも「絞った瞬間には何も起きません」。 起きるのは Pod オブジェクトが作り直されるときです。そして直してもすぐには戻りません——kubelet の再起動が要り、さらに denyTTL の分だけ待たされます。

Admission は、変更の効果も、修正の効果も、遅れて現れます。 「変えたのに何も起きない」も「直したのに戻らない」も、どちらも正常な挙動です。 焦って別の変更を重ねると、何が効いたのか分からなくなります。

やってみよう④:fail-open / fail-closed と判定キャッシュ

所要時間の目安: 12 分apiserver の再起動が複数回入ります)。defaultAllow を切り替え、バックエンドを止め、結果の違いを測ります。試験相当の作業(defaultAllow の値を決めて設定する)なら 3 分です。

ステップ0:apiserver を確実に再起動する方法

touch では再起動しません。 kubelet は静的 Pod のマニフェストの内容のハッシュを見ているので、タイムスタンプだけ変えても再起動しません。

確実な方法は「マニフェストを退避 → 12 秒待つ → 戻す」です。コンテナ ID の変化で再起動を確認します。

項目実測
退避 → 復帰 → /livez 200 まで38 秒 / 37 秒(2 回計測。退避 12 秒 + 復帰 25 秒前後)

実行コマンド(k8s-cp-01・root・退避と復帰):

# crictl ps --name kube-apiserver
# mv /etc/kubernetes/manifests/kube-apiserver.yaml /root/kube-apiserver.yaml.tmp
# sleep 12
# mv /root/kube-apiserver.yaml.tmp /etc/kubernetes/manifests/kube-apiserver.yaml
# crictl ps --name kube-apiserver

前後の crictl ps でコンテナ ID が変わっていれば、再起動しています。 touch で試したときは 33910140a1ba のまま 20 秒待っても変わりませんでした——ID が同じなら、それは同じプロセスです。 設定は読み直されていません。

退避先を /etc/kubernetes/manifests/ の中にしないでください。 kubelet はこのディレクトリを丸ごと見ているので、拡張子を変えただけでは退避になりません。 /root のような外へ出します。

「設定を変えたのに反映されない」の半分は、再起動していないことが原因です。 実験の前に「本当に再起動したか」を確かめる手段を用意しておかないと、測った結果が何の結果なのか分からなくなります。

ステップ0b:本演習は cp-01 だけで行い、cp-01 を直接指して確認する

演習③ ステップ 4 で見たことが、そのままここに効いてきます。

設定を変えるのは cp-01 だけにしますが、そうすると k8s-ops から kubectl を打っても 3 回に 1 回しか cp-01 に当たりません。 表の結果を読者が再現できません。

だから本演習の確認は、cp-01 の apiserver を直接指して行います。 HAProxy を経由しないので、1 回打てば必ず cp-01 の答えが返ります。

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

# kubectl --kubeconfig=/etc/kubernetes/admin.conf --server=https://127.0.0.1:6443 --insecure-skip-tls-verify get nodes

以降の確認はすべてこの形で打ちます(長いので、alias k='kubectl --kubeconfig=/etc/kubernetes/admin.conf --server=https://127.0.0.1:6443 --insecure-skip-tls-verify' と置くと楽です)。--insecure-skip-tls-verify が要るのは、apiserver の証明書に 127.0.0.1 が入っていないからです。

3 台すべてで同じことをするならdefaultAllow の変更と apiserver の再起動(1 台あたり 37〜38 秒)を 3 回繰り返します。本書は演習の所要を抑えるため 1 台で行い、cp-01 を直接指す形にします。 本番で fail-open / fail-closed を切り替えるときは、当然 3 台すべてに同じ値を入れます——ここが揃っていないクラスタは、応答するノードによって挙動が変わります。

ステップ1:fail-closed(defaultAllow: false)でバックエンドを止める

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

# systemctl stop image-policy-webhook.service
# kubectl --kubeconfig=/etc/kubernetes/admin.conf --server=https://127.0.0.1:6443 --insecure-skip-tls-verify \
    run fc-1 --image=k8s-registry:5000/fanclub-backend:0.4.0 -n default --restart=Never --dry-run=server

実行結果:

#defaultAllowバックエンド対象結果
R1false停止許可レジストリ拒否dial tcp 127.0.0.1:8081: connect: connection refused)・107 ms

fail-closed では、バックエンドが落ちた瞬間に Pod が 1 つも作れなくなります。 正しいイメージであっても拒否されます。107 ms で拒否が返ります——待たされるのではなく、即座に断られますretryBackoff: 500 はミリ秒=0.5 秒の待ちなので、107 ms という値は再試行が 1 度も入っていないことを示します)。

これは「安全」ですが「止まります」。 Deployment がスケールしようとしても、ノードが落ちて Pod が移ろうとしても、すべて止まります。

ステップ2:fail-open(defaultAllow: true)でバックエンドを止める

admission-config.yamldefaultAllowtrue に変え、ステップ 0 の方法で apiserver を再起動してから、同じことを試します。

#defaultAllowバックエンド対象結果
R2atrue停止許可レジストリcreated
R2btrue停止棚卸しに無いレジストリmcr.microsoft.com/dotnet/sdk:9.0created

R2b が本節の核心です。 fail-open のままバックエンドが落ちていると、許可リストに 1 文字も書いていない mcr.microsoft.com/dotnet/sdk:9.0 が通ります。 つまりポリシーが無いのと同じ状態になります。

そして誰も気づきません。 Pod は作られ、アプリは動き、エラーも出ません。「ポリシーが効いているかどうか」は、拒否されるものを試さないと分かりません。

fail-open は「壊れても止まらない」のではなく、「壊れても止まらず、守られてもいない」です。

ステップ3:バックエンドを戻して両方を確認する

#defaultAllowバックエンド対象結果
R3afalse起動棚卸しに無いレジストリ拒否(backend denied)
R3bfalse起動許可レジストリcreated

4 通りの組み合わせのうち、意図どおりに効いているのは R3a と R3b だけです。ポリシーが効くのは「バックエンドが生きているとき」に限られます。

ステップ4:判定はイメージ単位でキャッシュされる

admission-config.yamldenyTTL: 50(秒)での実測です。

手順結果
gcr.io/distroless/base:latest を拒否させる(バックエンド稼働中)backend denied
バックエンドを停止し、同じ gcr.io/distroless/base:latestbackend denied(=バックエンドは死んでいるのに理由が同じ)
バックエンド停止中に gcr.io/distroless/cc:latest(別イメージ)connection refused

ここでも gcr.io を使います。 redis:8.2.3-alpine のような接頭辞なしのイメージは、棚卸し版の許可リストと normalize() の組み合わせで許可されてしまうので、拒否の実験には使えません(ステップ 0b で数えた 7 種がそれです)。

「バックエンドを止めたのに同じ理由で拒否された」を、バックエンドが生きている証拠だと読んではいけません。

apiserver は allowTTL / denyTTL で判定結果をキャッシュします。 キャッシュのキーはイメージ参照なので、同じイメージなら同じ答えが denyTTL の間ずっと返ります。

実験するときは、毎回違うイメージ参照を使ってください。 同じイメージで繰り返すと、2 回目以降はバックエンドに問い合わせていません。

この仕組みは本回で 2 度目です。 前節の経路②で、条件分岐を直して kubelet を再起動しても apiserver のミラー Pod だけ 64 秒戻らなかったのも、同じ denyTTL: 50)です。

#どこでどう見えたか誤読しやすい読み方
1経路②(前節)直したのに戻らない(64 秒)「直し方が間違っている」
2演習④(本節)止めたのに拒否される「バックエンドは生きている」

同じ仕組みが、正反対の顔で 2 度出ます。 一方は「効いてほしいのに効かない」、もう一方は「止まってほしいのに動いて見える」。どちらも、判定がイメージ単位で denyTTL の間キャッシュされているだけです。

Admission の挙動を測るときは、まず TTL の値を確認します。 allowTTL / denyTTL を知らずに実験すると、測った結果が「今の設定の結果」なのか「さっきの設定の残り」なのか分かりません。

ステップ5:どちらを選ぶか

観点fail-open(truefail-closed(false
バックエンド障害時Pod は作れる。ポリシーは効かないPod が 1 つも作れない
気づけるか気づけない(エラーが出ない)すぐ気づく(全部止まる)
向く場面ポリシーが「あれば望ましい」段階ポリシーが「無ければ出荷しない」段階

本番ガードレール⑪: defaultAllow を決める前に、バックエンドの可用性を決めます。

fail-closed を選ぶなら、バックエンドはクラスタと同じかそれ以上の可用性が要ります。 本ラボは各 Control Plane の 127.0.0.1 に置いているので「apiserver が動いていればバックエンドも同じノードにいる」形になっており、これは妥当な設計です。外部のサービスに問い合わせる形にすると、そのサービスがクラスタの単一障害点になります。

そして fail-open を選ぶなら、バックエンドの死活監視を必ず入れてください。 fail-open は静かに壊れます。「拒否されるはずのものを定期的に投げて、拒否されることを確認する」——ポリシーが効いていることを能動的に測る仕組みが要ります。これは第17回の「検知の穴を 6 面で点検する」に直接つながります。

試験ではこう問われる。

Webhook が応答しないときに Pod の作成を拒否するよう設定せよ」という粒度で出ます。手が覚えているべきは defaultAllow: false が fail-closed であることと、それが AdmissionConfigurationplugins[].configuration.imagePolicy の下に書かれることです。allowTTL / denyTTL / retryBackoff の 3 つも同じ場所にありますので、位置関係で覚えてください。単位は allowTTL / denyTTL が秒、retryBackoff がミリ秒です。

演習後の状態を確定させる —— ImagePolicyWebhook を撤去する

ここまでで、Control Plane 3 台の apiserver を書き換え、自作のバックエンドを常駐させた状態になっています。しかも演習④では defaultAllow を何度も切り替えました。 どの状態で終えるのかを決めずに次回へ進むと、読者は不確定な状態のまま第15回に入ります。

本書は、演習の最後に ImagePolicyWebhook を撤去します。 第10回の gVisor と同じ「適用 → 証拠 → 代償 → 撤去」で完結させます。理由は 3 つです。

  1. バックエンドは自作の Python で、単一障害点です。 fail-closed のまま残すと、プロセスが落ちた瞬間にクラスタが Pod を 1 つも作れなくなります(実測 R1)。第15回以降(Falco / 監査ログ / インシデント対応)は Pod を作る演習が続くので、巻き添えになります
  2. 許可リストの棚卸しが完全でないと、事故は遅れて出ます(前節で実証しました)。第15回で新しいツールを入れたときに、そのイメージが許可されていなければ止まります
  3. 本回の学習目標は「許可レジストリ以外を拒否できる」ことであり、それは演習③〜④で達成済みです。残しておく必要はありません

CI ゲートの 5 ステージ目は残します。 あちらは壊れません——スクリプトが落ちてもクラスタは動きます。第12回・第13回からの連続性も保たれます。

「入れて、効くことを確かめて、代償を数えて、外す」は、本巻で 3 例目です第8回 Gatekeeper Constraint と ValidatingAdmissionPolicy / 第10回 gVisor / 本回)。第11回の mutual authentication は「入れずに記法だけを扱う」判断なので、こちらとは別の型です——後述の keyless と同じ側に数えます。セキュリティ機構は、入れることより「入れ続けられるか」で採否が決まります。

撤去の手順(3 台分)

Control Plane 1 台ずつ、/livez を確認しながら行います(ガードレール⑧)。

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

# cp /root/kube-apiserver.yaml.bak-14 /etc/kubernetes/manifests/kube-apiserver.yaml

実行コマンド(k8s-cp-01・root・200 が続くまで数秒おきに繰り返します):

# curl -sk -o /dev/null -w '%{http_code}\n' https://127.0.0.1:6443/livez

復帰までの実測は cp-01 が 45 秒・cp-02 が 50 秒・cp-03 が 40 秒でした(cp-02 では 500 を 1 回挟みました)。適用時とほぼ同じ時間がかかります——外すのも、入れるのと同じだけ待ちます。

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

# systemctl stop image-policy-webhook.service
# systemctl disable image-policy-webhook.service

ここまでを cp-02・cp-03 でも繰り返します。 1 台ずつ、/livez が 200 で安定してから次へ進んでください。

/etc/kubernetes/imagepolicy/ の扱いは次のとおりです。

選択肢判断
残す本書はこちらを採ります。 設定ファイル一式が残っていても、apiserver がフラグで参照していなければ何も起きません。第19回の復習で「何をどこに置いたか」を見返せます
消す環境を完全に戻したい場合です。rm -rf /etc/kubernetes/imagepolicy/ と、openssl / cosign のアンインストールまで行います

opensslcosign をどうするか。 ガードレール⑦で「入れたものは記録し、要らなくなったら消す」と書きました。本書は openssl を残します(他の演習で使う場面がありうる小さなパッケージです)が、Control Plane の cosign(141MB × 3 台)は消します。 判断の基準は「サイズ」ではなく「そのノードでそれを使う理由が残っているか」です——Control Plane で署名検証をしなくなったなら、cosign を置く理由もありません。

撤去後の確認

確認実測
3 台の apiserver マニフェストに ImagePolicyWebhook無い3 台とも 0 件grep が空)
--enable-admission-plugins の値NodeRestriction のみ(起点と同じ)
3 台とも /livez200 / 200 / 200
image-policy-webhook.service3 台とも inactive かつ disabled
棚卸しに無いレジストリの Pod が作れる(12 回連続)allowed=12 / denied=0(=ポリシーが外れている)
実際に Pod を動かす(docker.io/library/nginx:1.271/1 Running
ノードと Pod5/5 Ready・111 Pod・異常 0
https://fanclub.local/api/members200 + 会員 3 名・ArgoCD Synced / Healthy
~/gitops/gitops-fanclub/scan-gate.shgate exit=0・2,528 ms(5 ステージすべて実行・2 件とも SIGNED

確認に使ったテスト Pod は消してください。 「ポリシーが外れていること」を確かめるために作ったもので、動かし続ける理由はありません。

最後の行が、撤去の意味を示しています。 クラスタ側のポリシーは外れましたが、CI ゲートは 5 ステージのまま緑です。本回で足した守りのうち、残ったのは「壊れないほう」だけです。

本回で作ったもののうち、残るのは 3 つです——fanclub-backend:0.4.1fanclub-frontend:1.0.0 の署名 ②SBOM ③5 ステージになった scan-gate.shクラスタ側には何も残りません。

それでよいのです。 本回で学んだのは「Admission で出所を絞れる」という設計の選択肢であって、この実装を本番に持って行くことではありません。 本番で同じことをするなら、Gatekeeper か VAP(本回の前半で並べた 3 つの手段)を選ぶほうが運用しやすいです。ImagePolicyWebhook を手で組んだのは、「Admission の設定ファイルを自分で書く」という CKS の題材を通るためです。

暗記必須コマンドと、ラボ固有の事情の切り分け

試験中に参照できるものと、できないもの

CKS の試験中に参照できるのは 8 件だけで、うち 3 件はサブパス限定です。

参照先可否
kubernetes.io/docs / kubernetes.io/blog
falco.org/docs / etcd.io/docs
kubernetes-sigs.github.io/bom/cli-reference/ / kubernetes.github.io/ingress-nginx/user-guide/nginx-configuration/ / docs.cilium.io/en/stable可(サブパス限定・この 3 件)
istio.io/latest/docs/
sigstore.dev / docs.sigstore.dev / github.com/sigstore/cosign不可
trivy.dev / kubesec.io / docs.kubelinter.io不可(第12回・第13回で確認済み)
helm.sh/docs不可(CKAD / CKA では可・CKS では不可

本回は珍しい構成です。 bom は参照できて、Cosign は参照できません。 同じ回で扱う 2 つの道具の、試験中の扱いが正反対です。

  • bom: kubernetes-sigs.github.io/bom/cli-reference/ 配下が参照可。フラグは引けます
  • Cosign: 参照不可。generate-key-pairsign --keyverify --key の 3 つは手が覚えているべきです

暗記必須コマンド

用途コマンド / 記法補足
SBOM 生成bom generate --image <img> --output <file> --format json試験中に参照可
SBOM 生成(アーカイブから)bom generate --image-archive <tar> --name <name> --output <file> --format jsonラボ固有の逃げ道
SBOM を読むbom document outline <file>試験中に参照可。生成とセットで覚えます
鍵ペア生成cosign generate-key-pair暗記。同じディレクトリで 2 回打つと上書き確認が出ます(既定は N
署名cosign sign --key cosign.key <img>@sha256:...暗記。ダイジェスト指定
検証cosign verify --key cosign.pub <img>暗記
SBOM の添付cosign attest --predicate <sbom> --type spdxjson --key cosign.key <img>attach sbomDEPRECATED
attestation の検証cosign verify-attestation --key cosign.pub --type spdxjson <img>in-toto Statement が返ります
署名の一覧cosign tree <img>署名と attestation の両方が並びます
終了コード(4 値)
※暗記対象ではありません
成功 = 0 / 署名が 1 つも無い = 10 / 新形式の署名が別の鍵 = 1 / 旧 .sig 形式の署名が別の鍵 = 12試験は実技なので、終了コードの値そのものを答える形式はありません。 ゲートを書くときに要る実務知識です(値は cosign のバージョンで変わりえます)
対話プロンプトを飛ばす--yessign / attest確認プロンプトで止まらないようにするフラグです。ラボ固有ではありませんが、試験で必須でもありません
Admission プラグイン有効化--enable-admission-plugins=NodeRestriction,ImagePolicyWebhook既存の値を消さないでください
Admission 設定ファイル--admission-control-config-file=<path>3 点セット(+ volumeMounts + volumes
AdmissionConfiguration の型apiVersion: apiserver.config.k8s.io/v1 / kind: AdmissionConfiguration / plugins[].name: ImagePolicyWebhook / plugins[].configuration.imagePolicykubernetes.io/docs で引けますが、位置関係は覚えます
fail-closeddefaultAllow: falseimagePolicy 配下。公式の記載例は true(fail-open)
キャッシュと再試行allowTTL)/ denyTTL)/ retryBackoffミリ秒同じブロックにあるのに単位が違います。 50 / 500 は既定値ではなく公式の記載例の値です

--yes は、本回の組み合わせでは実際には要りません。

外して実行してもプロンプトは出ず、そのまま署名されます(実測で確認しました)。

プロンプトが出るのは、透明性ログに記録を残すときだけです。sigstore の利用規約(署名の記録が公開ログに永続的に残ることへの同意)を確認する画面が出ます。本回は --tlog-upload=false で記録を残さないので、聞かれることがありません。

それでも本書は --yes を書きます。 理由は 2 つです。--tlog-upload=false を外した構成(=本番)では必要になります②CI で対話待ちに入るのは最悪の詰まり方だからです——ログには何も出ず、ジョブがタイムアウトするまで止まります。

ラボ固有の事情(試験では 1 つも要りません)。

本回で何度も出てきた次のフラグと設定は、すべて「本書ラボの事情」です。

使ったもの本ラボで必要な理由試験では
--allow-http-registryk8s-registry:5000HTTP 平文だから不要(試験のレジストリは TLS)
--use-signing-config=falsesigstore の TUF(tuf-repo-cdn.sigstore.dev)に到達できないから(このホストは whitelist 外です。registry.k8s.io とは別の話です)不要(外部に出られる)
--tlog-upload=falseRekor に到達できないから不要
--insecure-ignore-tlog(verify 側)同上不要
export no_proxy="$no_proxy,k8s-registry"export NO_PROXY="$no_proxy"2 行Squid プロキシ環境だから。2 行必要なのは、cosign が Go 製で大文字の NO_PROXY を優先して読むからです不要(プロキシが無い)
bom generate --image-archivebom が HTTP レジストリを読めないから不要--image が使える)
docker save同上不要
自作の Python バックエンドImagePolicyWebhook のバックエンドは「別途用意されているもの」で、Kubernetes は実装を提供しません。本書は仕組みを見せるために自分で書きました書きません(試験ではバックエンドは用意されている前提で、問われるのはapiserver 側の 3 ファイル整合です)
--server=https://127.0.0.1:6443 --insecure-skip-tls-verifyHA 3 台のうち cp-01 だけを変えた状態で確認するため(演習④)。TLS 検証を外すのは、apiserver の証明書に 127.0.0.1 が入っていないからです不要TLS 検証を外す操作は本番でも行いません。演習の都合です)
ArgoCD / Git 経由の反映本書ラボの GitOps 構成だから不要(その場で kubectl apply

試験で打つのは、次の 3 行だけです。

cosign generate-key-pair
cosign sign --key cosign.key <image>
cosign verify --key cosign.pub <image>

ラボの事情を暗記対象と取り違えないでください。 本書がフラグを足しているのは、閉じたネットワークで同じことを再現するためです。試験環境では素の形が動きます。 ただし「なぜこのフラグが要るのか」を理解しておくことには意味があります——実務では、プロキシの内側や TLS の無いレジストリに出くわします。

alias k=kubectl について

試験では kubectl を打つ場面も多くあります。 CKS の SSH ホストには alias k=kubectl と bash 補完が設定済みなので、k -n kube-system get pods のように打てます。本文では読みやすさのために kubectl と全綴りで書いていますが、試験ではエイリアスを使ってください——1 タスクあたり数十秒の差が、最後の見直し時間になります。加えて export do="--dry-run=client -o yaml" を開始直後に設定しておくと、マニフェストの雛形生成が速くなります。

keyless 方式(記法の紹介のみ)

keyless 方式は長期鍵を持たず、OIDC で身元を証明して短期証明書(Fulcio)を受け取り、署名記録を透明性ログ(Rekor)に残す方式です。鍵の管理が要らないのが利点で、CI(GitHub Actions 等)の OIDC トークンと組み合わせて使われます。

記法(本ラボでは実行しません):

$ cosign sign <image>
$ cosign verify --certificate-identity=<identity> --certificate-oidc-issuer=<issuer> <image>

本ラボでは実行しません。 Fulcio / Rekor / TUF の 3 つとも到達できないためです。whitelist に 3 ホストを足せば動く可能性はありますが、足しません——第13回で「足す前に、足さずに済む方法を調べる」という判断をしました。鍵ペア方式で本回の学習目標はすべて達成できます。

これは「入れない判断」の 4 例目です(第11回 mutual authentication は記法のみ / 第12回 distroless 不採用 / 第13回 trivy k8s 不実行 / 本回 keyless 不実行)。第10回の gVisor は「入れて外す」側なので、こちらには数えません。入れれば学べることは増えますが、入れた分だけ環境が本題から遠ざかります。 何を入れないかも、設計の一部です。

第15回への 1 行

本回で Admission の入口を絞り、その代償を数え、撤去しました。 絞れることは確かめました。それでも「通ったものが、実行中に何をするか」は分かりません。 第15回からは D6 に入り、動いているコンテナの中で起きることを syscall のレベルで捉えます。

まとめ

  • CI ゲートと Admission は違うものを守ります。 CI は「自分たちのパイプラインを通ったものが署名されているか」、Admission は「クラスタに入ってくるものがどこから来たか」を見ます
  • cosign v3 は既定のままでは 1 度も動きません。 鍵ペア方式でも TUF から signing config を取りに行きます。--tlog-upload=false 単独は v3 では通りません--use-signing-config の既定が true で衝突します)
  • 本ラボでは no_proxyk8s-registry が入っていません。 apiserver の静的 Pod の NO_PROXY には入っているのに、ノードの環境変数だけが取り残されています。本書は恒久修正せず、演習中の export と、スクリプト自身による export で通します
  • no_proxy(小文字)だけでは cosign に効きません。 cosign は Go 製で、大文字の NO_PROXY を優先して読みます/etc/profile.d/proxy.sh は両方を export しているので、片方だけ書き換えると古い値が残りますcurl で疎通を確かめても、cosign が同じ設定を見ているとは限りません
  • 署名の対象はダイジェストで指定します。 タグは動き、ダイジェストは動きません
  • v3 の署名は .sig の付かないタグ + OCI Image Index として置かれます。 referrers API 非対応のレジストリでもタグベースで成立します(本ラボの /v2/<name>/referrers/<digest> は 404 です)。.sig 形式の署名も世の中には残っていますregistry.k8s.io/pause:3.10
  • cosign verify の終了コードは 4 値です。 成功 = 0 / 署名が 1 つも無い = 10 / 新形式の署名が別の鍵 = 1 / .sig 形式の署名が別の鍵 = 121 と 12 を分けるのは「署名の形式」であり、どちらも「鍵が合わない」です。kubesec(既定 2)・Trivy(既定 0)に続く 3 つ目のツールの作法です
  • 署名検証は「署名があるか」ではなく「誰の署名か」を見ています。 registry.k8s.io/pause:3.10 には Kubernetes プロジェクトの keyless 署名が実在しますcosign tree.sig タグが見え、org.kubernetes.kpromo.version まで取れます)。それでも自分たちの鍵では通りません。「署名されているから安全」は成り立ちません
  • --insecure-ignore-tlog を付けても「transparency log was verified offline」と表示されます。 直前の WARNING と併せて読みます
  • bom は HTTP レジストリを読めません。 cosign は --allow-http-registry を持つのに、bom は相当するフラグを持ちません。docker save--image-archive で回避し、--name で対象の名前を上書きします
  • 生成された SBOM に jar が 1 件も載りません。 44 件はすべて alpine の OS パッケージで、第13回で Trivy が見つけた jar の CVE はこの SBOM に現れません。「作った」と「把握した」は別です
  • bom document outline で SBOM を読みます。 生成だけでは学習目標の半分です。bom は試験中に参照できる唯一の SBOM ツールです
  • ImagePolicyWebhook 本体は署名検証をしません。 ただしバックエンドは任意実装なので、cosign verify を呼ぶ拡張はできます(実測 104〜157 ms)
  • 署名検証の対象は k8s-registry:5000/ に限定する条件分岐が必須です。 外すと registry.k8s.io/pause:3.10exit 12 で拒否され、apiserver のミラー Pod が作れなくなります。 「許可レジストリ」と「署名検証の対象」は別の設定として持ちます
  • 直しても約 64 秒は戻りませんdenyTTL: 50 のキャッシュ)。etcd / controller-manager / scheduler は 26 秒で戻ったのに、apiserver だけ 64 秒かかりました
  • allowTTL / denyTTL は秒、retryBackoff はミリ秒です。 同じブロックにあるのに単位が違います。50 / 500 は既定値ではなく公式の記載例の値であり、公式の例の defaultAllowtrue(fail-open)です
  • 許可リストを作る前に棚卸しをします。 実測でクラスタ内はユニークイメージ 55 件・レジストリ 12 種。しかも接頭辞なしのイメージが 7 種あり、前方一致で判定すると取りこぼします(第8回の VAP の image.contains(':') と同じ構図です)
  • 本回で入れた ImagePolicyWebhook は、演習の最後に撤去します。 自作バックエンドが単一障害点になり、fail-closed のまま残すと第15回以降が巻き添えになります。CI ゲートの 5 ステージ目は残します
  • ゲートは「何を通すか(閾値)」だけでなく「何を見ているか(対象)」で決まります。 5 ステージ目は 2 イメージを見ていますが、ステージ④(trivy image)は今も backend 1 つしか見ていません——広げると fanclub-frontend:1.0.0 が EXIT=1 になります(CRITICAL 2 件・Status: fixed なので --ignore-unfixed でも消えません)。数字と直し方まで書いた宿題として残しました
  • 設定は 3 ファイルの整合です。 フラグ + AdmissionConfiguration + kubeconfig。加えて volumeMounts / volumes の 3 点セット(第5回・第9回に続いて 3 回目)
  • HA で 1 台だけ設定すると 12 回中 4 回しか拒否されません。 第2回の CIS 1.2.5 と同じ構図です
  • 許可リストに registry.k8s.io/ を入れ忘れると、Control Plane 4 つが kubectl から消えます。 実体は crictl ps で動いています。復旧には kubelet の再起動が要ります
  • 絞った直後には何も起きません。 ミラー Pod が作り直されるときに初めて効きます
  • fail-open は「ポリシーが無いのと同じ」です。 バックエンドが落ちていると外部レジストリのイメージが通り、しかもエラーが出ません
  • 判定はイメージ単位でキャッシュされますdenyTTL)。実験するときは毎回違うイメージ参照を使います
  • touch では静的 Pod は再起動しません。 退避 → 12 秒 → 復帰で約 37 秒です
  • 本回も whitelist を 1 行も足していません(44 行のままです)。第12回は gcr.io を 1 行足しましたが、第13回と本回は足さずに済みました

第19回で復習する項目。

sigstore.dev が試験中に参照できない以上、Cosign は全体が暗記対象です。第19回に送るのは ①Cosign の 3 コマンド(generate-key-pair / sign --key / verify --key)②bom generatebom document outline(こちらは参照可)③ImagePolicyWebhook の 3 ファイルと 3 点セット④defaultAllow と TTL の単位の 4 点です。終了コードの 4 値は暗記対象に含めません——試験は実技で、値そのものを答える形式が無いからです。覚えるのは「失敗の種類で分かれる」という性質のほうで、これはゲートを書くときに効きます

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

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

  1. cosign v3 で鍵ペア方式を使う場合、外部サービスに一切アクセスしないため、閉じたネットワークでも既定のフラグだけで署名できる
  2. registry.k8s.io/pause:3.10 のように署名が付与されているイメージであれば、自分で作った公開鍵を --key に指定した cosign verify でも検証に成功する
  3. 署名・検証の対象はタグで指定するほうが、ダイジェストで指定するよりも厳密である
  4. bom generate が生成した SPDX の SBOM には、イメージに同梱された jar などの言語パッケージも必ず含まれる
  5. ImagePolicyWebhook は、Pod のイメージに付与された Cosign 署名を apiserver 自身が検証する仕組みである
  6. ImagePolicyWebhook の設定には、apiserver のフラグ・AdmissionConfiguration・バックエンド接続用の kubeconfig の 3 つの整合が要る
  7. defaultAllow: false(fail-closed)にすると、Webhook バックエンドに到達できないときに Pod の作成が拒否される
  8. 許可レジストリの一覧に registry.k8s.io/ を含めなくても、Control Plane の静的 Pod は kubelet が直接起動するため影響を受けない
  9. apiserver は Webhook の判定結果をイメージ単位でキャッシュするため、バックエンドを停止しても直前に判定済みのイメージは同じ結果を返すことがある
解答と解説

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

  • 問1: v3 は鍵ペア方式でも TUF から signing config を取りに行きます。 --use-signing-config の既定が true だからです。加えて、v2 時代の定番の回避策 --tlog-upload=false は単独では通りません--use-signing-config と衝突します)。閉じたネットワークで署名するには --use-signing-config=false を併用します
  • 問2(本回の核心): 署名検証は「署名があるか」ではなく「誰の署名か」を見ています。 registry.k8s.io/pause:3.10 には Kubernetes プロジェクトの keyless 署名が実在しますcosign treesha256-ee6521f2....sig という.sig 形式のタグが見え、cosign download signatureCertorg.kubernetes.kpromo.version が取れます)。それでも自分の鍵では検証できず、exit 12 になります。
    終了コードは 4 値です——成功 = 0 / 署名が 1 つも無い = 10 / 新形式(v3 bundle)の署名が別の鍵 = 1 / .sig 形式の署名が別の鍵 = 121 と 12 の差は「署名の形式」であって、どちらも「鍵が合わない」です。
    12 を「レジストリに到達できない」と読まないでください。 本ラボの whitelist には registry.k8s.io が登録済みで、プロキシ変数を外しても結果は変わりません(EXIT=12・1,706 ms)。署名は取れています。取れた署名が、自分の鍵のものではなかっただけです。
    第12回の kubesec(既定 2)・第13回の Trivy(既定 0)に続く 3 つ目のツールの終了コードの作法でもあります
  • 問3: タグは付け替えられます。ダイジェストはイメージの内容から計算されるので動きません。 署名はこの性質の上に成り立つため、対象はダイジェストで指定します。verify はタグ指定でも通りますが、そのとき検証しているのは「そのタグが今指しているもの」です
  • 問4: 本ラボの実測では、44 件すべてが alpine の OS パッケージで、jar は 1 件も載りませんでした。 第13回で Trivy が opt/payara/app.waropt/payara/payara-micro.jar に検出した CVE は、この SBOM では照合できません。SBOM は「何を網羅しているか」を確かめてから使います
  • 問5: apiserver がバックエンドに渡す ImageReview にはイメージ参照しか入っておらず、署名の情報は含まれません。 ただしバックエンドは任意実装なので、その中で cosign verify を呼ぶ拡張は可能です(本回の発展の節で実装しました)。「仕組みが署名検証をしない」ことと「署名検証を組み込めない」ことは別です。 ただし署名検証の対象は k8s-registry:5000/ に限定する条件分岐が必須で、これを外すと registry.k8s.io/pause:3.10exit 12署名はあるが自分の鍵のものではない)で拒否され、apiserver のミラー Pod が作れなくなります
  • 問6: 3 ファイルの整合が要ります。 apiserver のフラグ 2 つ(--enable-admission-plugins--admission-control-config-file)・AdmissionConfiguration・kubeconfig です。加えて静的 Pod なので volumeMountsvolumes も要ります(第5回・第9回と同じ 3 点セットで、本回が 3 回目です)
  • 問7: defaultAllow: false が fail-closed です。 実測では、バックエンドを停止した状態で許可レジストリのイメージを指定しても 107 ms で拒否されました(dial tcp 127.0.0.1:8081: connect: connection refused)。「安全」ですが「止まります」——だから defaultAllow を決める前に、バックエンドの可用性を決めます
  • 問8: 静的 Pod のミラー Pod オブジェクトの作成が Admission を通ります。 許可リストに registry.k8s.io/ が無いと、kubelet がミラー Pod を作れず、kubectl get pods -n kube-system から Control Plane 4 つが消えます。 コンテナ自体は動き続けます(crictl ps で 13 個 Running)。復旧には kubelet の再起動が要ります
  • 問9: allowTTL / denyTTL でキャッシュされます。 キーはイメージ参照なので、同じイメージなら denyTTL の間ずっと同じ答えが返ります。 「バックエンドを止めたのに拒否された」を「バックエンドが生きている」と読むと切り分けを誤ります。実験では毎回違うイメージ参照を使ってください。 本回はこのキャッシュが 2 度出ました——演習④の gcr.io/distroless/base:latest(止めたのに拒否される)と、経路②(直したのに apiserver のミラー Pod だけ 64 秒戻らない)です。Admission の挙動を測る前に、TTL の値と単位を確認しますallowTTL / denyTTLretryBackoffミリ秒

次回予告

第15回「Falco ランタイム検知」から D6(監視・ログ・ランタイムセキュリティ)に入ります。

第12回から本回まで、D5 の 4 つの関門を作ってきました——書いた直後の静的解析、ビルド直後のスキャン、デプロイ直前の署名検証、クラスタ入口の Admission。ここまではすべて「動き出す前」の守りです。

第15回からは、動き出した後を見ます。 4 つの関門をすべて通ったコンテナの中でシェルが起動したとき、それを捉えるのが Falco です。本回で ImagePolicyWebhook が「出所」しか見なかったのと同じで、入口の検査には限界があります——入口を通った後に何が起きるかは、実行中に見るしかありません。

→ 詳しくは第15回 Falco ランタイム検知

前の記事
次の記事