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

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

NetworkPolicy監査で過剰許可を塞ぐ【CKS第3回】

公開

新卒インフラエンジニア向け「Kubernetes 実践教科書 ③ CKS セキュリティ・ハードニング編」(全19回)の第3回です。第2回では kube-bench で CIS ベンチマークを評価し、設定の抜けを塞ぎました。本回はそこから「通信の経路を塞ぐ」へ進みます。ただし本回は白紙から始めません。fanclub Namespace には第2巻の時点で NetworkPolicy が 9 本入っており、default-deny-all も適用済みです。本回の到達点は、既にあるポリシーを疑って読み、穴を見つけて締められることです。実務でも試験でも、白紙から書き起こす場面より「渡されたポリシーを直す」場面のほうが多くあります。

第2回の予告では「default-deny を起点に最小許可を設計する」という言い方をしましたが、実機を確認すると設計の土台はすでに出来上がっていました。そこで本回は設計ではなく監査から始めます。ノードメタデータの遮断についても、fanclub はすでに egress の default-deny が効いていて到達できない状態にあるため、専用の Namespace を用意して except の効き方を検証する形を取ります。予告とのズレを隠さず説明することも、本回の主題(思い込みで判断しない)と地続きです。

  • ラボ Kubernetes v1.36.2
  • CKS 試験環境 v1.35
  • Calico v3.32.0(NetworkPolicy を実際に適用する CNI)
  • cert-manager v1.20.0
  • Gateway API v1.4.0
  • Traefik v3.6.x
  • containerd v2.2.6
  • AlmaLinux 10.2
  • 確認日 2026-07-26
目次
  1. 第3回のスコープ・今ここマップ
    1. NetworkPolicy は三部作で 3 回目の登場になる
  2. この回のゴール
  3. 起点の確認 ——fanclub には既に NetworkPolicy が 9 本ある
    1. 第2巻の到達点をまず認める
    2. 「default-deny が入っている=安全」ではない
    3. 自分で書いた覚えのないポリシーも動いている
  4. NetworkPolicy の判定規則 ——許可リスト型・AND / OR・クロス Namespace
    1. 許可リスト型の判定順序
    2. default-deny の実体
    3. AND と OR の書き分け ——実在の 2 本で見る
    4. podSelector 単独は同一 Namespace のみに一致する
    5. Namespace の指定には kubernetes.io/metadata.name を使う
    6. クラスタ外を指す ipBlock
  5. やってみよう①:既存 9 本を棚卸しして「何が通るか」を表にする
    1. ステップ1:一覧を取り、対象 Pod ごとに束ねる
    2. ステップ2:1 本ずつ中身を読む
    3. ステップ3:選択されていない Pod がいないかを確認する
    4. fanclub-logcollector に専用ポリシーが無いのは穴なのか
    5. ステップ4:棚卸し表を作る
    6. ステップ5:表を見て違和感のある行を挙げる
  6. やってみよう②:過剰許可 2 件を締める
    1. namespaceSelector 単独が何を意味するか
    2. ステップ1:穴が実在することを実証する
    3. ステップ2:相手の Pod ラベルを調べる
    4. ステップ3:namespaceSelector + podSelector の AND に書き換える
    5. ステップ4:穴が閉じたことを確認する
    6. ステップ5:正規の通信が壊れていないことを 3 点で確認する
    7. この演習の到達点
    8. この修正は helm upgrade で巻き戻る
    9. 試験相当の粒度
    10. 白紙から 1 本書くドリル
  7. egress 側の点検 ——DNS 許可と「広く許可しないこと」の価値
    1. egress の default-deny が効いている状態
    2. DNS を許可し忘れると全部壊れる
    3. 最も強い保護は、広く許可しないこと
  8. ノードメタデータ(169.254.169.254)の保護
    1. 何が危険なのか
    2. 記法 ——ipBlock の cidr と except
    3. ラボには実メタデータ API が無い ——これを隠さない
    4. 実クラウドでの注意
  9. やってみよう③:except が効いていることを到達できる宛先で証明する
    1. ステップ1:検証用の Namespace と Pod を用意する
    2. ステップ2:パターン① ポリシーなしの状態を測る
    3. ステップ3:パターン② except にメタデータ IP だけを入れる
    4. ステップ4:パターン③ 到達できていた宛先を except に入れる
    5. ステップ5:後片付け
    6. fanclub 側には何もしない理由
  10. TLS Ingress ——試験の標準 Ingress とラボの Gateway API
    1. なぜラボは ingress-nginx を使わないのか
    2. 標準 Ingress の TLS 設定(試験形式)
    3. 試験中に参照できるドキュメント
    4. ラボの実装を読む ——HTTPRoute は「/」の 1 ルートだけ
    5. cert-manager が作っている Secret を見る
  11. やってみよう④:試験形式で TLS Ingress 一式を組み立てる
    1. この演習の到達点
    2. ステップ1:証明書ファイルを用意する
    3. ステップ2:kubernetes.io/tls タイプの Secret を作る
    4. ステップ3:Ingress を組み立てる
    5. ステップ4:適用して describe で読む
    6. ステップ5:後片付け
    7. 試験相当の粒度
  12. 暗記必須コマンド(第3回)
  13. まとめ
  14. 理解度チェック(○×形式・全 9 問)
  15. 次回予告

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

本回は CKS ドメイン D1: Cluster Setup(配点 15%) の後半にあたります。D1 は 5 つのコンピテンシーからなり、第2回で「CIS ベンチマークによるレビュー」と「プラットフォームバイナリの検証」の 2 つを扱いました。本回は残る 3 つ——NetworkPolicy によるクラスタレベルのアクセス制限 / TLS 付き Ingress の設定 / ノードメタデータとエンドポイントの保護——を担当します。ここまで終えると D1 は完了します。

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

起点となる環境は第2回の完了状態です。第2巻から引き継いだ 9 VM 構成(Control Plane Node 3 台 + Workload Node 2 台 + 作業端末・レジストリ・LB・プロキシ)が稼働し、CIS 修復を適用済みで、5 ノードが Ready・Kubernetes は v1.36.2 という前提で進めます。

NetworkPolicy は三部作で 3 回目の登場になる

NetworkPolicy そのものは、本シリーズで初めて扱うテーマではありません。同じ機能を 3 回に分けて積み上げてきたので、それぞれの文脈を先に整理します。

既習内容文脈
第1巻 第16回default-deny-all + 4 つの許可ポリシー + DNS 53/UDP の罠 + 適用順序通信を成立させる
第2巻 第10〜13回詳細設計 + Calico / Cilium の比較 + トラブルシュート。現在 fanclub に入っている 9 本はここで揃った(chart 同梱の 7 本が第10回、Gateway 用の 1 本が第11回、監視スクレイプ用の 1 本が第13回)通信を設計する
本回(第3巻 第3回)既存ポリシーの監査 / fromto の AND・OR の厳密理解 / ノードメタデータ保護 / TLS Ingress通信を疑う

本回は「同じ話の 3 周目」ではありません。第1巻と第2巻は通信を通すためにポリシーを書きました。本回は通してしまっているものを見つけるためにポリシーを読みます。書く技術と読む技術は別物で、CKS が問うのは後者の比重が大きくなります。

この回のゴール

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

  • 既存の NetworkPolicy 群を読み、「どの Pod へ、どこから、何が通るのか」を表に起こせる
  • from / to のリスト要素が AND になるか OR になるかを、書き方の違いで判別できる
  • namespaceSelector 単独の許可が「Namespace の箱ごと開ける」ことだと説明でき、podSelector との AND で締められる
  • 締めた後に正規の通信が壊れていないことを確認する手順を持てる
  • ipBlockcidrexcept の関係を説明でき、ノードメタデータ(169.254.169.254/32)への egress を遮断できる
  • 「最も強い保護は広く許可しないこと」であり、except が要るのは広い egress を許可せざるを得ない Pod だけだと説明できる
  • TLS Ingress を「試験の標準 Ingress」と「ラボの Gateway API HTTPRoute」の両形式で扱える

本回で扱わない範囲も先に確認します。NetworkPolicy に隣接するテーマは多いので、どこで回収するかを明示しておきます。

本回で扱わない領域理由回収する回
L7 制御(HTTP メソッド / パス単位)・CiliumNetworkPolicy標準 networking.k8s.io/v1 の NetworkPolicy は L3/L4 のみ第11回
Pod 間通信の暗号化(WireGuard 透過暗号化)NetworkPolicy は「通す / 通さない」であって暗号化はしない第11回
Namespace を「セキュリティ境界」として設計する話マルチテナンシーの文脈でまとめて扱う第10回
RBAC(誰が NetworkPolicy を書き換えられるか)権限の話第4回
インシデント時の NetworkPolicy による緊急隔離対応フローの中で扱う第18回

本回で扱う NetworkPolicy はすべて標準の networking.k8s.io/v1 です。本書ラボではこれを Calico v3.32.0 が実際に適用します。CiliumNetworkPolicyNetworkSet のような CRD は本回では使いません。CKS 試験でも、明示がない限り標準のリソースで解答します。

起点の確認 ——fanclub には既に NetworkPolicy が 9 本ある

まず現状を見ます。監査は現物の確認から始まります。

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

$ kubectl get networkpolicy -n fanclub

実行結果:

NAME                               POD-SELECTOR           AGE
allow-backend-from-frontend        app=fanclub-backend    11h
allow-backend-from-monitoring      app=fanclub-backend    11h
allow-db-from-backend              app=fanclub-db         11h
allow-dns                          <none>                 11h
allow-egress-backend-to-db         app=fanclub-backend    11h
allow-egress-batch-to-db           app=fanclub-batch      11h
allow-egress-frontend-to-backend   app=fanclub-frontend   11h
allow-frontend-from-gateway        app=fanclub-frontend   11h
default-deny-all                   <none>                 11h

AGE は環境の稼働時間で変わります。並び順は名前のアルファベット順で、重要度順ではありません。土台である default-deny-all が最下行に来ているのは、たまたま名前が d で始まるからです。出力の並びから構造を読み取ろうとしない——これが監査の最初の注意点になります。

9 本の内訳を整理すると次のようになります。default-deny-all が土台にあり、そこへ 8 本の許可ポリシーが重なる構成です。

ポリシー名POD-SELECTOR方向許可内容
default-deny-all<none>podSelector: {}Ingress + Egressなし(全遮断)
allow-dns<none>Egresskube-systemk8s-app: kube-dns へ UDP/TCP 53
allow-frontend-from-gatewayapp=fanclub-frontendIngressnamespaceSelector: traefik から 80/TCP
allow-backend-from-frontendapp=fanclub-backendIngresspodSelector: app=fanclub-frontend から 8080/TCP
allow-backend-from-monitoringapp=fanclub-backendIngressnamespaceSelector: monitoring から 8080/TCP
allow-db-from-backendapp=fanclub-dbIngressapp=fanclub-backend または app=fanclub-batch から 5432/TCP
allow-egress-frontend-to-backendapp=fanclub-frontendEgressbackend へ 8080/TCP
allow-egress-backend-to-dbapp=fanclub-backendEgressdb へ 5432/TCP
allow-egress-batch-to-dbapp=fanclub-batchEgressdb へ 5432/TCP

第2巻の到達点をまず認める

この 9 本は第2巻で段階的に揃えられたもので、設計としては正しい形です。第10回で chart 同梱の 7 本を設計し、第11回で Gateway 用の allow-frontend-from-gateway を、第13回で監視スクレイプ用の allow-backend-from-monitoring を足して 9 本になりました。default-deny を土台に置き、必要な経路だけを許可で足すという構造になっています。第2巻が「通信を成立させながら最小許可へ絞る」という課題を達成した結果がこれです。

ですから本回は、この 9 本を「間違い」として否定する回ではありません。第2巻と本回では文脈が違うだけです。第2巻は動かすために書き、本回は疑うために読みます。同じ Namespace の中に、締まっている書き方と粒度の粗い書き方が同居している——実務のポリシー群はたいていそうなります。だからこそ棚卸しが要ります。

「default-deny が入っている=安全」ではない

ここが本回の主題です。default-deny は「明示的に許可したものだけを通す」状態を作ります。しかし許可ルールの粒度が粗ければ、開けた穴はその粗さのまま残りますdefault-deny は出発点であって到達点ではありません。CKS が問うのは「default-deny があるか」ではなく「開けた穴が必要最小限か」です。

この視点で既存ポリシーを読むために、本回を貫く問いを 3 つに固定します。以降のセクションはすべて、この 3 つに答える形で進みます。

  1. どの Pod が、どのポリシーに選択されているか(選択されていない Pod は素通し)
  2. 開けた穴は、通す必要がある相手だけに絞られているか(Namespace 単位で開けていないか)
  3. egress 側は何を許可しているか(広く許可しているなら、そこにメタデータ IP への穴が空いている)

自分で書いた覚えのないポリシーも動いている

NetworkPolicy は fanclub Namespace だけのものではありません。クラスタ全体を数えます。

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

$ kubectl get networkpolicy -A --no-headers | awk '{print $1}' | sort | uniq -c

実行結果:

      6 argocd
      9 fanclub

実測で確認できているのは、fanclub に 9 本、argocd に 6 本という 2 点です。argocd の 6 本は ArgoCD の Helm chart が同梱しているもので、読者が書いたものではありません。自分で書いた覚えがないポリシーが動いているという状態は、実務では普通に起きます。chart が良かれと思って入れてくれることもあれば、前任者が入れて引き継ぎ資料に載っていないこともあります。だからこそ、まず何があるかを数えるところから始めます。

fanclubの実通信トポロジ(ブラウザからk8s-lb・Traefik Gateway・frontendのNginxを経てbackend・dbへ至る経路)に既存NetworkPolicy 9本を重ねた図
図1:fanclub の実通信トポロジと既存 NetworkPolicy 9 本の対応

NetworkPolicy の判定規則 ——許可リスト型・AND / OR・クロス Namespace

棚卸しの前に、読解に必要な判定規則を固めます。ここでは架空の app: ans-x ではなく、fanclub に実在する 9 本を教材として説明します。試験で問われるのも、この判定規則の理解そのものです。

許可リスト型の判定順序

  • ある Pod に NetworkPolicy が 1 つでも適用されると、その Pod の対象方向(Ingress / Egress)は「いずれかのポリシーが明示的に許可した通信だけ」に絞られ、それ以外は遮断される
  • 複数のポリシーは OR で重ね合わさる(許可が加算される)。「拒否ルール」は存在しないため、すでに 1 本以上のポリシーに選択されている Pod なら、そこへポリシーを足して通信が減ることはない。ただしまだどのポリシーにも選択されていない Pod に 1 本目を足すと、話は逆になる——その瞬間に「明示的に許可した通信だけ」へ切り替わり、書いていない通信は落ちる。default-deny-all がこれを狙って全 Pod に 1 本目を当てている
  • 逆に、どの NetworkPolicy にも選択されていない Pod は全通信が許可されたままである

3 つ目が監査の入口になります。ポリシーが何本あるかより、選択されていない Pod がいないかを先に見ます。10 本のポリシーで守られた Namespace に、どのポリシーにも一致しない Pod が 1 つ紛れていれば、その Pod は 10 本すべてを迂回します。

「拒否ルールが存在しない」という性質も、監査では効いてきます。ポリシーを追加して問題が解決するのは「通したいものが通らない」ときだけです。「通ってはいけないものが通る」場合は、既存のどれかを狭めるしかありません。本回の中心である やってみよう② が「新しく書き足す」ではなく「既存を書き換える」形になっているのは、この性質があるためです。

default-deny の実体

fanclub の default-deny-all は次の内容です。実機から取得したものを、metadata.managedFieldsstatus といった自動付与のフィールドを落として整形しています。

apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: default-deny-all
  namespace: fanclub
spec:
  podSelector: {}
  policyTypes:
    - Ingress
    - Egress

podSelector: {} は「この Namespace の全 Pod」を意味します。空のセレクタは「何も選択しない」ではなく「すべてを選択する」である、という点が最初の落とし穴です。そのうえで policyTypesIngressEgress を書き、ingress / egress のルールを 1 つも書いていません。結果として両方向とも全遮断になります。

ここで policyTypes を書き忘れると、ルールを書いていない方向は制御対象になりませんpolicyTypes を省略した場合、ingress ルールの有無から Ingress は自動的に対象になりますが、Egress は egress ルールを書いたときにしか対象になりません。つまり policyTypes なしで podSelector: {} だけを書いたポリシーは、egress を一切制御しません。「default-deny を入れたつもりで ingress しか塞げていない」というのは、試験でも実務でもよくある取りこぼしです。

AND と OR の書き分け ——実在の 2 本で見る

CKS で最も時間を失うのがここです。インデントの深さだけが AND と OR を決めます。文章で覚えるのではなく、2 つの形を目で覚えてください。

まず OR の実例です。allow-db-from-backendingress 部分を抜き出します(AND / OR の対比が目的なので該当箇所のみ掲載します)。

  ingress:
    - from:
        - podSelector:
            matchLabels:
              app: fanclub-backend
        - podSelector:
            matchLabels:
              app: fanclub-batch
      ports:
        - protocol: TCP
          port: 5432

- podSelector: が 2 つ並んでいます。- で並ぶリスト要素は OR なので、これは「backend または batch から 5432」という意味です。2 つ目の要素があるのは、CronJob fanclub-report(スケジュール 0 9 * * *)が生成する Pod がラベル app: fanclub-batch を持つためです。

次に AND の実例です。allow-dnsegress 部分を抜き出します。

  egress:
    - to:
        - namespaceSelector:
            matchLabels:
              kubernetes.io/metadata.name: kube-system
          podSelector:
            matchLabels:
              k8s-app: kube-dns
      ports:
        - protocol: UDP
          port: 53
        - protocol: TCP
          port: 53

こちらは - namespaceSelector: という 1 つのリスト要素の中に、podSelector:同じインデントレベルで入っています。同一リスト要素の中に並んだセレクタは AND なので、「kube-system Namespace かつ k8s-app: kube-dns のラベルを持つ Pod へ 53」という意味になります。

2 つの YAML を見比べてください。違いは podSelector: の前に - があるかないか、それだけです。

書き方意味本回での実例
- namespaceSelector: の次の行に - podSelector:- あり)OR:その Namespace の全 Pod または 同一 Namespace の該当 Pod締める前に間違えやすい形
- namespaceSelector: の中に podSelector:- なし・同一インデント)AND:その Namespace に属し、かつ そのラベルを持つ Podallow-dns/やってみよう② で作る 2 本

- を 1 つ付けただけで、絞ったつもりのポリシーがむしろ広くなるという点に注意してください。OR は許可の加算なので、Namespace 全体の許可がそのまま残ります。やってみよう② で書き換える 2 本は、ここを間違えると「apply は成功するのに何も締まっていない」という結果になります。

podSelector 単独は同一 Namespace のみに一致する

from / topodSelector だけを書くと、そのポリシーが存在する Namespace の Pod にしか一致しません。別 Namespace の相手(traefik の Gateway、monitoring の Prometheus)を許可したいのに podSelector だけを書くと、一致せず遮断されます。

allow-backend-from-frontend は frontend も backend も同じ fanclub Namespace にいるので、podSelector 単独で正しい書き方です。一方、traefik や monitoring を相手にするなら namespaceSelector が必要になります。同じ「Pod を指定する」でも、Namespace をまたぐかどうかで書き方が変わります

この非対称性は試験でも狙われます。「別 Namespace の特定 Pod からだけ許可せよ」という問題に podSelector 単独で答えると通信は通らず、namespaceSelector 単独で答えると通りすぎます。正解は両方を AND で書くことです。

Namespace の指定には kubernetes.io/metadata.name を使う

kubernetes.io/metadata.name は、API Server が全 Namespace へ自動付与するラベルです(v1.22 で GA)。値は Namespace 名そのものなので、namespaceSelector でこれを使えば、Namespace に手でラベルを付けなくても名前で指定できます。実際のラベルを確認します。

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

$ kubectl get namespace traefik monitoring kube-system --show-labels

実測値を LABELS 列だけ要約すると次のとおりです。

Namespaceラベル
traefikkubernetes.io/metadata.name=traefik / name=traefik
monitoringkubernetes.io/metadata.name=monitoring / name=monitoring
kube-systemkubernetes.io/metadata.name=kube-system のみ

traefik と monitoring には name= という手付けのラベルもあります。どちらを使ってもポリシーは書けますが、手付けのラベルは消えることも変わることもあります。Helm chart の更新や、Namespace の再作成で失われます。kubernetes.io/metadata.name は API Server が管理するので消えません。ポリシーでは後者を使う——これを本書の方針とします。

クラスタ外を指す ipBlock

podSelectornamespaceSelectorクラスタ内の相手を指すセレクタです。クラスタ外の宛先(外部 API・コンテナレジストリ・ノードメタデータ)は指定できません。それらは ipBlock の CIDR で書きます。

ipBlock には cidrexcept の 2 つのフィールドがあり、except に書く CIDR は同じ ipBlockcidr に含まれている必要があります。含まれない値を書くと API Server が拒否します。記法と意味論はノードメタデータ保護のセクションで詳しく扱うので、ここでは「クラスタ外は ipBlock」という対応関係だけ押さえてください。

やってみよう①:既存 9 本を棚卸しして「何が通るか」を表にする

この演習を始める前に

  • 本演習は読み取りのみです。クラスタの状態は変えません
  • 所要時間の目安は 10〜15 分です
  • 起点は第2回完了状態のクラスタです。fanclub の Pod がすべて Running になってから始めてください

CKS 実技の第一歩は、渡されたポリシーを読むことです。書く前に読めるようになってください。

ステップ1:一覧を取り、対象 Pod ごとに束ねる

kubectl get networkpolicy の既定の出力は名前・セレクタ・経過時間の 3 列だけで、方向が分かりません。-o custom-columns で必要な列を足します。

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

$ kubectl get networkpolicy -n fanclub -o custom-columns='NAME:.metadata.name,POD:.spec.podSelector.matchLabels,TYPES:.spec.policyTypes'

実行結果:

NAME                               POD                         TYPES
allow-backend-from-frontend        map[app:fanclub-backend]    [Ingress]
allow-backend-from-monitoring      map[app:fanclub-backend]    [Ingress]
allow-db-from-backend              map[app:fanclub-db]         [Ingress]
allow-dns                          <none>                      [Egress]
allow-egress-backend-to-db         map[app:fanclub-backend]    [Egress]
allow-egress-batch-to-db           map[app:fanclub-batch]      [Egress]
allow-egress-frontend-to-backend   map[app:fanclub-frontend]   [Egress]
allow-frontend-from-gateway        map[app:fanclub-frontend]   [Ingress]
default-deny-all                   <none>                      [Ingress Egress]

POD 列が map[...] というかたちで出るのは、matchLabels がマップ(キーと値の対)だからです。<none> の 2 本(allow-dnsdefault-deny-all)は podSelector: {} で、Namespace の全 Pod が対象という意味になります。TYPES 列を見ると default-deny-all だけが [Ingress Egress] の両方向で、残りは片方向であることも読み取れます。

この出力で「どのポリシーが、どのラベルの Pod の、どちらの方向を制御しているか」が一望できます。TYPES 列に Egress を含む行を数えると、egress を制御しているポリシーの本数が分かります。

ステップ2:1 本ずつ中身を読む

次に個別のポリシーを読みます。まず describe で当たりを付けます。

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

$ kubectl describe networkpolicy allow-frontend-from-gateway -n fanclub

実行結果:

Name:         allow-frontend-from-gateway
Namespace:    fanclub
Created on:   2026-07-26 03:03:09 +0900 JST
Labels:       app.kubernetes.io/managed-by=Helm
              velero.io/backup-name=fanclub-backup
              velero.io/restore-name=fanclub-backup-20260726030303
Annotations:  meta.helm.sh/release-name: fanclub
              meta.helm.sh/release-namespace: fanclub
Spec:
  PodSelector:     app=fanclub-frontend
  Allowing ingress traffic:
    To Port: 80/TCP
    From:
      NamespaceSelector: kubernetes.io/metadata.name=traefik
  Not affecting egress traffic
  Policy Types: Ingress

読むべきは From: のブロックです。ここに NamespaceSelector の 1 行しかありませんPodSelector の行が並んでいないことが、あとで問題になります。

Not affecting egress traffic という行にも注目してください。このポリシーは policyTypesIngress しか書いていないので、egress については何も言っていないという意味です。「egress を許可している」でも「egress を遮断している」でもありません。遮断しているのは default-deny-all のほうです。

LabelsAnnotations に Helm と Velero の痕跡が残っているのは、このポリシーが fanclub chart の一部として管理され、第2巻第14回のバックアップ・リストア演習を経ているためです。手で書き換えると次の helm upgrade で元に戻ります——本回の演習では手で直しますが、本番では chart 側を直すのが筋である、という点は後段のガードレールで扱います。

読み方を決めておきます。Allowing ingress traffic: の下に From:To Port: が並びます。NamespaceSelector だけが出ていて PodSelector が出ていないなら、それは Namespace 全体を許可しているということです。ここが本回の勘所になります。

ただし describe には限界があります。describe は AND と OR をインデントで表現しません。複数のセレクタが並んでいるとき、それが AND なのか OR なのかを describe の出力から確実に判別することはできません。したがってdescribe で当たりを付け、-o yaml で確定する」という 2 段構えで読みます。

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

$ kubectl get networkpolicy allow-frontend-from-gateway -n fanclub -o yaml

この使い分けは試験でも効きます。時間が限られる中で 9 本すべてを -o yaml で精読する余裕はありません。describe で怪しい 1〜2 本に絞ってから YAML を開きます。

ステップ3:選択されていない Pod がいないかを確認する

判定規則の 1 つ目の問いに答えます。default-deny-allpodSelector: {} なので fanclub の全 Pod を選択しています。したがって素通しの Pod は存在しません。ただし「存在しないはずだ」で終わらせず、実際の Pod とラベルを見て確認します。

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

$ kubectl get pod -n fanclub --show-labels

実行結果(LABELS 列のみ抜粋):

fanclub-backend-585549545f-7vnn8    app=fanclub-backend,pod-template-hash=585549545f
fanclub-backend-585549545f-dksms    app=fanclub-backend,pod-template-hash=585549545f
fanclub-backend-585549545f-gp252    app=fanclub-backend,pod-template-hash=585549545f
fanclub-db-0                        app=fanclub-db,apps.kubernetes.io/pod-index=0,...
fanclub-frontend-7d9b4667f-j88b8    app=fanclub-frontend,pod-template-hash=7d9b4667f,...
fanclub-frontend-7d9b4667f-jc6vl    app=fanclub-frontend,pod-template-hash=7d9b4667f,...
fanclub-logcollector-dlmkk          app=fanclub-logcollector,controller-revision-hash=86449768d6,...
fanclub-logcollector-fgnpg          app=fanclub-logcollector,controller-revision-hash=86449768d6,...

8 Pod の内訳は backend ×3 / db ×1(StatefulSet)/ frontend ×2 / logcollector ×2(DaemonSet)です。default-deny-allpodSelector: {} なので、この 8 個すべてが選択されています。素通しの Pod はありません。

ラベルは app: fanclub-backend / app: fanclub-db / app: fanclub-frontend / app: fanclub-logcollector の 4 種類が現れます。

ここで app: fanclub-batch出てこないことに注意してください。allow-db-from-backend の 2 つ目の from 要素と allow-egress-batch-to-db はこのラベルを指しているのに、対応する Pod が存在しません。これは CronJob fanclub-report0 9 * * *)が実行されたときにだけ生成される Pod のラベルなので、通常時の get pod には現れないのが正常です。

ポリシーが指す Pod が今いないことと、ポリシーが不要であることは別です。棚卸しで実行中の Pod だけを見て「使われていないから消そう」と即断すると、次の 09:00 に夜間バッチが DB へ繋がらなくなります。実行時にしか現れない Pod をポリシー設計から落とさない——これは見落としやすい観点です。

fanclub-logcollector に専用ポリシーが無いのは穴なのか

9 本のどれも app: fanclub-logcollector を対象にしていません。棚卸しをすると必ず気づく点なので、正面から扱います。

まず状態を確認します。default-deny-allpodSelector: {} なのでこの Pod も選択しています。専用の許可ポリシーが無いということは、allow-dns(これも podSelector: {} で全 Pod 対象)による DNS 以外、この Pod は外向きの通信ができない状態にあるということです。

これは穴ではありませんfanclub-logcollector の実体は busybox:1.37 で「log collector started」と出力するだけのダミーで、第1巻の DaemonSet 教材として置かれたものです。Loki へログを送信していないので、egress の許可が要りません。ポリシーが無いのは、必要が無いからです。

逆に、ここで「ポリシーが無い=設定漏れだ」と早合点して egress を開けると、不要な穴を自分で開けることになります。監査の作法として、その Pod が何をしているかを確認してから判断するという順序を守ってください。ポリシーの過不足は、アプリケーションの挙動を知らずには判定できません。

ステップ4:棚卸し表を作る

本演習の成果物です。9 本のポリシーを「対象 Pod ごと」に束ね直し、入ってくる通信と出ていく通信を 1 行にまとめます。ポリシー名の並びではなく守る対象の並びで書き直すのがポイントです。

対象 Pod入ってくる通信(許可元 / ポート)出ていく通信(許可先 / ポート)
fanclub-frontendtraefik Namespace(全 Pod)/ 80backend / 8080・CoreDNS / 53
fanclub-backendfrontend / 8080・monitoring Namespace(全 Pod)/ 8080db / 5432・CoreDNS / 53
fanclub-dbbackend または batch / 5432CoreDNS / 53 のみ
fanclub-batch(CronJob)なしdb / 5432・CoreDNS / 53
fanclub-logcollectorなしCoreDNS / 53 のみ

ステップ5:表を見て違和感のある行を挙げる

表を眺めてください。太字にした 2 箇所——fanclub-frontend の「traefik Namespace(全 Pod)」と fanclub-backend の「monitoring Namespace(全 Pod)」——だけが、個別の Pod ではなく Namespace 単位になっています。他の行はすべて「backend から」「frontend から」と Pod で書かれているのに、この 2 行だけ粒度が違います。

YAML を 9 本並べて読んでいるだけでは、この不揃いには気づきにくいものです。表に起こすと粒度の違いが目に見えます。これが棚卸しをする理由です。監査は「怪しいものを探す」作業ではなく、「揃っているべきものが揃っていない箇所を見つける」作業だと考えてください。

試験での粒度も確認しておきます。CKS では「与えられた Namespace の NetworkPolicy を読み、指定された通信が通るか答えよ」「通るように直せ」という形で問われます。読解だけなら 3〜5 分が目安です。本演習が 10〜15 分かかるのは 9 本を通しで扱っているためで、試験では 2〜3 本のポリシーが与えられます。

やってみよう②:過剰許可 2 件を締める

この演習を始める前に

  • 本演習は実際に稼働中のポリシーを書き換えます。スナップショット等で復元できる環境で実施してください
  • 変更前に kubectl get networkpolicy <name> -n fanclub -o yaml > <name>.yaml.bak でバックアップを取ってください(第2回でマニフェスト編集に適用した作法を、そのままポリシーにも適用します)
  • 戻すときは kubectl apply -f <name>.yaml.bak です。戻した後は kubectl get networkpolicy <name> -n fanclub -o yamlfrom の要素構造を目視で確認してください
  • どうなったら戻すかの基準を先に決めます。ステップ5 の 3 点確認のうち1 つでも失敗したら、先へ進まずバックアップから戻します
  • 締めた後に必ずステップ5 の 3 点確認を行ってください。正規経路を壊したまま先へ進むと、第4回以降の演習でブラウザ確認が失敗します

ここが本回の中心です。所要時間の目安は 20〜30 分。穴の実証から修正、そして正規経路の保全確認まで通します。

namespaceSelector 単独が何を意味するか

allow-backend-from-monitoring は「monitoring Namespace から backend:8080 を許可」します。では monitoring に何がいるのかを見ます。

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

$ kubectl get pod -n monitoring

実行結果:

NAME                                                        READY   STATUS    RESTARTS   AGE
alertmanager-kube-prometheus-stack-alertmanager-0           2/2     Running   8          12h
fluent-bit-fluent-bit-loki-2zctl                            1/1     Running   4          11h
fluent-bit-fluent-bit-loki-dtdq9                            1/1     Running   4          11h
fluent-bit-fluent-bit-loki-mrq7l                            1/1     Running   4          11h
fluent-bit-fluent-bit-loki-v8t7r                            1/1     Running   4          11h
fluent-bit-fluent-bit-loki-wzqgb                            1/1     Running   4          11h
kube-prometheus-stack-grafana-5b5f4fc89f-ksgt2              3/3     Running   0          4m36s
kube-prometheus-stack-kube-state-metrics-857ccb48f8-q5gdc   1/1     Running   8          12h
kube-prometheus-stack-operator-8568d847d5-phngf             1/1     Running   7          12h
kube-prometheus-stack-prometheus-node-exporter-kjjk7        1/1     Running   4          12h
kube-prometheus-stack-prometheus-node-exporter-m2nnx        1/1     Running   4          12h
kube-prometheus-stack-prometheus-node-exporter-v69mk        1/1     Running   4          12h
kube-prometheus-stack-prometheus-node-exporter-vffrg        1/1     Running   4          12h
kube-prometheus-stack-prometheus-node-exporter-x5jlr        1/1     Running   4          12h
loki-0                                                      2/2     Running   0          4m34s
loki-gateway-75fdc66c6f-crm2s                               1/1     Running   8          11h
prometheus-kube-prometheus-stack-prometheus-0               2/2     Running   0          4m34s

17 個います。 Grafana・Alertmanager・kube-state-metrics・Prometheus Operator・node-exporter が 5 台分・Loki と loki-gateway・fluent-bit が 5 台分——そして Prometheus 本体。allow-backend-from-monitoringnamespaceSelector だけで許可しているので、この 17 個すべてが backend の 8080 へ到達できます

実際に backend をスクレイプする必要があるのは prometheus-kube-prometheus-stack-prometheus-0 の 1 個だけです。1 個で足りるところに 17 個分の経路が開いている——これが「Namespace の箱ごと開けている」ということの具体的な意味になります。

同じことが allow-frontend-from-gateway にも言えます。traefik Namespace から frontend:80 を許可していますが、通す必要があるのは Traefik の Gateway Pod だけです。

この 2 件がなぜ危険なのかを、CKS の文脈で言い直します。Namespace 単位の許可は「その Namespace に Pod を作れる者は誰でも、この経路を使える」という意味になります。監視スタックや Ingress コントローラの Namespace は、Helm chart の更新やオペレータの動作で Pod が増える場所であり、開発者に権限が渡っていることもあります。侵害された Pod が 1 つでもそこに置かれれば、この穴がそのまま横移動(lateral movement)の経路になります。攻撃者は「監視用の Namespace だから安全」という運用側の思い込みを利用します。

ステップ1:穴が実在することを実証する

議論ではなく実測で示します。traefik Namespace にTraefik とは無関係な busybox Pod を立てて、fanclub の frontend を直接叩きます。

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

$ kubectl run np-test --rm -i --restart=Never -n traefik --image=busybox:1.37 -- sh -c 'wget -q -T 5 -O- http://fanclub-frontend.fanclub.svc.cluster.local/'

実行結果(冒頭のみ):

<!DOCTYPE html>
<html lang="ja">
<head>

HTML が返ってきました。この Pod は Traefik でも監視エージェントでもない、ただの busybox です。それでも namespaceSelector: traefik に一致するため、フロントエンドへの経路が開いています。

コマンドの補足です。--rm は終了時に Pod を消すオプション、--restart=Never は Deployment ではなく Pod として起動するための指定です。-T 5 は busybox 版 wget のタイムアウト秒数で、応答が無いときに待ち続けないために付けています。

出力の前に warning: couldn't attach to pod/np-test, falling back to streaming logs という行が出ることがあります。-i での接続が間に合わずログ取得に切り替わっただけで、結果には影響しません。ただしこの経路をたどると --rm が効かず Pod が残ることがあります。ステップ4 で同じ名前の Pod を立てるので、ここで確実に消しておきます。

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

$ kubectl delete pod np-test -n traefik --ignore-not-found

消し忘れたままステップ4 に進むと Error from server (AlreadyExists): pods "np-test" already exists で止まります。

なぜ既存 Pod への exec ではなく一時 Pod を立てるのか、という点も押さえてください。理由は 2 つあります。1 つ目——確かめたいのは「別 Namespace から fanclub へ届くか」なので、テストの起点は traefikmonitoring の側に要ります。 fanclub の Pod から打っても、その問いには答えられません。2 つ目——backend のイメージには curlwgetnc も入っていません(実測でいずれも exit 127、つまりコマンドが見つからない状態でした)。最小イメージを使っていれば当然のことで、これはセキュリティ上は望ましい状態です。ただし frontend(nginx alpine 系)には /usr/bin/curl/usr/bin/nc が入っています——同じ Namespace の中では疎通を打てますが、それでは 1 つ目の理由が満たせません。結局、疎通テストはクライアントを持った Pod を、確かめたい側の Namespace に自分で立てる形になります。CKS 試験でも同じ手が要る場面があります。

実務での応用も 1 つ添えます。本番環境では、読者自身が traefikmonitoring Namespace に Pod を作る権限を持たないことが多くあります。その場合はこの手順をそのまま真似できません。代わりに kubectl get networkpolicy -o yaml の読み取りと、kubectl auth can-i create pod -n traefik --as=<対象ユーザー> で「その Namespace に Pod を作れるのは誰か」を確かめます。これは第4回の RBAC 監査にそのままつながる確認です。

ステップ2:相手の Pod ラベルを調べる

締めるには「本当に通すべき Pod」のラベルが必要です。

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

$ kubectl get pod -n traefik --show-labels
$ kubectl get pod -n monitoring --show-labels

実行結果(1 コマンド目・LABELS 列のみ):

traefik-69f4cd68d8-tk7kq   app.kubernetes.io/instance=traefik-traefik,app.kubernetes.io/managed-by=Helm,app.kubernetes.io/name=traefik,helm.sh/chart=traefik-39.0.9,pod-template-hash=69f4cd68d8

実行結果(2 コマンド目・Prometheus と、比較用に Grafana / node-exporter を抜粋):

prometheus-kube-prometheus-stack-prometheus-0   app.kubernetes.io/instance=kube-prometheus-stack-prometheus,app.kubernetes.io/managed-by=prometheus-operator,app.kubernetes.io/name=prometheus,app.kubernetes.io/version=3.11.3,...

kube-prometheus-stack-grafana-5b5f4fc89f-ksgt2   app.kubernetes.io/instance=kube-prometheus-stack,app.kubernetes.io/name=grafana,app.kubernetes.io/version=13.0.1,...

kube-prometheus-stack-prometheus-node-exporter-kjjk7   app.kubernetes.io/component=metrics,app.kubernetes.io/instance=kube-prometheus-stack,app.kubernetes.io/name=prometheus-node-exporter,...

3 つを並べたのは、app.kubernetes.io/name の値が prometheus / grafana / prometheus-node-exporter別々になっていることを見せるためです。node-exporter の値は prometheus ではなく prometheus-node-exporter なので、app.kubernetes.io/name: prometheus で選べば node-exporter は含まれません。前方一致ではなく完全一致で判定される点を確認してください。

出力から拾うべきラベルは次の 2 つです。

対象ラベル
Traefik の Podapp.kubernetes.io/name: traefik
Prometheus の Podapp.kubernetes.io/name: prometheus

ラベルは推測しないでくださいapp / app.kubernetes.io/name / k8s-app のどれを使うかは chart によって違います。fanclub 自身は app: fanclub-backend という形なので、その感覚のまま app: traefik と書くと一致しません。必ず --show-labels で実物を見てから書く——これを作法として固めてください。ポリシーが一致しないと通信が落ちますが、エラーメッセージは「ラベルが違う」とは教えてくれません。

ステップ3:namespaceSelector + podSelector の AND に書き換える

2 本とも全量を掲載します。まず allow-frontend-from-gateway の修正後です。

apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: allow-frontend-from-gateway
  namespace: fanclub
spec:
  podSelector:
    matchLabels:
      app: fanclub-frontend
  policyTypes:
    - Ingress
  ingress:
    - from:
        - namespaceSelector:
            matchLabels:
              kubernetes.io/metadata.name: traefik
          podSelector:
            matchLabels:
              app.kubernetes.io/name: traefik
      ports:
        - protocol: TCP
          port: 80

続いて allow-backend-from-monitoring の修正後です。

apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: allow-backend-from-monitoring
  namespace: fanclub
spec:
  podSelector:
    matchLabels:
      app: fanclub-backend
  policyTypes:
    - Ingress
  ingress:
    - from:
        - namespaceSelector:
            matchLabels:
              kubernetes.io/metadata.name: monitoring
          podSelector:
            matchLabels:
              app.kubernetes.io/name: prometheus
      ports:
        - protocol: TCP
          port: 8080

変更点は 1 箇所だけです。- namespaceSelector: というリスト要素の中に、同じインデントレベルで podSelector: を足しました。spec.podSelector(守る側の指定)はそのままで、ingress.from の中身だけが変わっています。

ここで podSelector: の前に - を付けてしまうと OR になり、Namespace 全体の許可が残ったまま何も締まりません。判定規則のセクションで扱った AND / OR の書き分けが、そのまま結果を左右する場面です。apply は成功しますし、エラーも出ません。だから次のステップで実際に遮断されたことを測ります

2 本のマニフェストをそれぞれ allow-frontend-from-gateway.yaml / allow-backend-from-monitoring.yaml として保存し、適用します。

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

$ kubectl apply -f allow-frontend-from-gateway.yaml
$ kubectl apply -f allow-backend-from-monitoring.yaml

既存と同名なので configured として上書きされます。NetworkPolicy の変更は即座に CNI へ反映されるため、Pod の再起動は不要です。

ステップ4:穴が閉じたことを確認する

ステップ1 とまったく同じコマンドを打ちます。条件を 1 つだけ変えて結果を比べるのが検証の基本です。

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

$ kubectl run np-test --rm -i --restart=Never -n traefik --image=busybox:1.37 -- sh -c 'wget -q -T 5 -O- http://fanclub-frontend.fanclub.svc.cluster.local/'

実行結果:

wget: download timed out
pod "np-test" deleted from traefik namespace
pod traefik/np-test terminated (Error)

今度は HTML が返りません。wget: download timed out ——遮断されました。同じコマンド・同じ Namespace・同じイメージで、変えたのはポリシーの書き方だけです。それで結果が反転しました。

ここで遮断の見え方を説明します。NetworkPolicy による遮断は「接続拒否」ではなく、パケットが黙って落ちる形になります。TCP の RST が返るわけではないので、クライアントには即座にエラーが返らず、応答を待ち続けてタイムアウトします。-T 5 を付けている理由がここで効きます。付けていないと、読者は「固まった」と思って Ctrl-C を押すことになります。

つまり「応答が無い」ことが遮断の証拠になります。逆に Connection refused が返った場合、それは NetworkPolicy ではなく「宛先にサービスがいない」ことを意味します。この 2 つを混同すると切り分けを誤ります。

なお、タイムアウトを待たずに Ctrl-C で抜けると --rm が働かず、np-test Pod が traefik Namespace に残ります。念のため削除しておきます。

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

$ kubectl delete pod np-test -n traefik --ignore-not-found

Ingress コントローラの Namespace に検証用 Pod を放置しないこと自体が、このセクションで主張した脅威(その Namespace に Pod を作れる者は経路を使える)と首尾一貫しています。

ステップ5:正規の通信が壊れていないことを 3 点で確認する

締めることより、締めても壊れていないことを確かめるほうが難しい作業です。省略せず 3 つを順に確認してください。

① ブラウザ経路(Traefik → frontend → backend → db)

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

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

実行結果:

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

実測では HTTPS 200 が返り、/api/members は会員データの JSON を返します。--noproxy '*' を付けているのは、本書ラボが Squid のホワイトリスト方式でプロキシを経由する構成であり、環境変数のプロキシ設定を回避する必要があるためです(第2巻から継承した作法)。192.168.1.124 は k8s-lb の IP ですが、ラボ固有の値なのでそのまま覚えないでください。試験環境では別のアドレスになります。

/api/members を叩くことに意味があります/ だけの確認では frontend の静的 HTML が返るだけで、frontend → backend → db の経路を検証できません/api/members は Nginx の location /api/ を通って backend に届き、backend が db に問い合わせて JSON を返します。ここまで通って初めて「壊していない」と言えます。締めた 2 本のうち allow-backend-from-monitoring は backend への ingress を制御しているので、書き間違えれば backend への到達が変わる可能性があります。

② Prometheus の scrape が続いていること

締めたのは monitoring 側なので、監視が止まっていないかを確認します。fanclub Namespace には fanclub-backend という ServiceMonitor があり、Prometheus は job="fanclub-backend" として backend の 3 Pod を scrape しています。scrape は Prometheus 側から backend へ入る ingress なので、allow-backend-from-monitoring を締めると真っ先に壊れる箇所です。

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

$ kubectl -n monitoring exec sts/prometheus-kube-prometheus-stack-prometheus -c prometheus -- wget -q -O- 'http://localhost:9090/api/v1/query?query=up{namespace="fanclub"}'

実行結果:

{"status":"success","data":{"resultType":"vector","result":[{"metric":{"__name__":"up","container":"backend","endpoint":"http","instance":"10.244.215.248:8080","job":"fanclub-backend","namespace":"fanclub","pod":"fanclub-backend-585549545f-gp252","service":"fanclub-backend"},"value":[1785043035.499,"1"]},{"metric":{"__name__":"up",...,"pod":"fanclub-backend-585549545f-7vnn8",...},"value":[1785043035.499,"1"]},{"metric":{"__name__":"up",...,"pod":"fanclub-backend-585549545f-dksms",...},"value":[1785043035.499,"1"]}]}}

確認すべきは up{namespace="fanclub"} の値が 1 であることです。実測では backend の 3 Pod とも 1 を維持していました。0 になっていれば scrape が失敗しているので、ラベルの指定を見直してバックアップから戻します。Grafana の画面から同じ PromQL を実行しても構いません。

③ 締めた 2 本以外に影響が出ていないこと

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

$ kubectl get pod -n fanclub

実行結果:

NAME                               READY   STATUS    RESTARTS   AGE
fanclub-backend-585549545f-7vnn8   2/2     Running   6          10h
fanclub-backend-585549545f-dksms   2/2     Running   6          10h
fanclub-backend-585549545f-gp252   2/2     Running   6          10h
fanclub-db-0                       1/1     Running   0          5m59s
fanclub-frontend-7d9b4667f-j88b8   1/1     Running   8          11h
fanclub-frontend-7d9b4667f-jc6vl   1/1     Running   8          11h
fanclub-logcollector-dlmkk         1/1     Running   4          11h
fanclub-logcollector-fgnpg         1/1     Running   4          11h

8 Pod がすべて Running であることを確認します。NetworkPolicy の変更で Pod が落ちることは通常ありませんが、readiness probe が Service 経由で通信している構成だと影響が出ることがあります。ここは「変化していないこと」を確かめる確認です。

この演習の到達点

正規の通信は保ったまま、余計な経路だけを閉じられました。CKS の NetworkPolicy 問題は「通す」と「止める」の両方を同時に満たさないと得点になりません。片方だけなら簡単で、全部止めるなら default-deny 1 本で済みます。両立させることが難しい——ここが本回の実質です。

本番ガードレール 本番で同じ締め方をするときは、先に監視・Ingress 側の Pod ラベルを確認し、chart の更新でラベルが変わらないかを確かめてくださいapp.kubernetes.io/name は Helm chart の慣習ラベルなので比較的安定していますが、chart のメジャー更新で変わることがあります。ラベルが変わればポリシーは静かに一致しなくなり、通信が落ちます。エラーログには「ラベルが変わった」とは出ず、監視の欠測やタイムアウトとしてしか現れません。ラベル変更を検知する仕組み(監視のアラート・GitOps の diff)とセットで運用してください。

この修正は helm upgrade で巻き戻る

ステップ3 で kubectl apply した 2 本には、持ち主がいます。ステップ1 の手前で describe したときに見えた app.kubernetes.io/managed-by=Helmmeta.helm.sh/release-name: fanclub がそれです。この 2 本は fanclub chart の一部として管理されています。

つまり、次に helm upgrade fanclub ./fanclub-chart を打った瞬間、chart 内のテンプレートの内容で上書きされ、締めた 2 本は緩い状態へ戻ります。手で apply した変更は chart の側に存在しないので、Helm から見れば「意図しない差分」でしかありません。ArgoCD で GitOps 運用している場合はさらに早く、同期のタイミングで自動的に巻き戻ります

本番ガードレール chart が管理しているリソースを直すときは、手で kubectl apply するのではなく chart のテンプレートを直して helm upgrade するのが筋です。GitOps なら Git のマニフェストを直します。手で当てた修正は、次のデプロイまでしか生きません。しかも戻ったことに気づく仕組みがありません——ポリシーが緩くなっても通信は通り続けるので、監視にもエラーにも出ないからです。

本回で手で当てているのは、NetworkPolicy を書いて読んで検証する技術を身につけることが目的だからです。CKS 試験でも kubectl apply で直接当てて解答します。ただし「本番の運用としては chart 側を直す」という原則は、ここで区別して覚えておいてください。第3巻では以降も手で当てる場面が続きますが、いずれも同じ前提です。

なお本書ラボでは第4回以降もこの締めた状態を前提に進めます。helm upgrade を挟む予定はないため、巻き戻りは起きません。

試験相当の粒度

ステップ試験相当の所要時間
過剰許可の特定(describefrom を読む)約 3〜5 分
namespaceSelector + podSelector の AND へ書き換えて適用約 5〜7 分
正規経路が生きていることの確認(3 点)約 3〜5 分

試験では「このポリシーを、指定の Pod からのみ許可するように直せ」という 1 問として出題され、ポリシー 1 本あたり 5〜8 分が目安です。本演習が 20〜30 分かかるのは、2 本ぶんに加えて締めた後の 3 点確認を含むためです。試験では 3 点確認まで求められないことが多いのですが、実務では確認まで含めて 1 セットです。試験の型だけを覚えて現場に出ると、通信を止めたことに気づかないまま作業を終えることになります。

白紙から 1 本書くドリル

試験では「渡されたポリシーを直す」だけでなく「新しいポリシーを白紙から書く」形式も出ます。締める演習の直後に、短いドリルを 1 本挟みます。所要 4〜6 分です。

課題

fanclub Namespace の app: fanclub-db に対して、「app: fanclub-backend の Pod からの 5432/TCP だけを受け付ける」Ingress ポリシーを、既存の allow-db-from-backend を見ずに白紙から書いてください。書けたら kubectl get netpol allow-db-from-backend -n fanclub -o yaml と見比べます。

答え合わせの観点は 3 つです。

  • spec.podSelector の対象は「守る側」(この課題では db)である。許可元の backend を書いてしまう取り違えが最も多い
  • policyTypesIngress を明記する。省略しても ingress ルールがあれば Ingress は対象になるが、明記する癖を付けておくほうが安全である
  • ports を省くと全ポート許可になる。「5432 だけ」という条件を満たすには ports が必須

既存の allow-db-from-backend は batch からの許可も含んだ 2 要素の OR になっているので、そこだけは差異として読み取ってください。課題の条件は backend のみなので、1 要素で正解です。

namespaceSelector単独でNamespace全体が開いているBeforeと、podSelectorとのANDで必要なPodだけに絞ったAfterを対比した図
図2:過剰許可の Before / After ——Namespace の箱ごと開ける状態から、必要な Pod だけに絞った状態へ

egress 側の点検 ——DNS 許可と「広く許可しないこと」の価値

ここまでは ingress(入ってくる通信)の監査でした。ここから egress(出ていく通信)へ視点を移します。監査の 3 つ目の問い「egress 側は何を許可しているか」に答える段です。

egress の default-deny が効いている状態

fanclub の default-deny-allpolicyTypesEgress も含んでいます。9 本のうち egress 許可を持つのは 4 本だけです——allow-dns / allow-egress-frontend-to-backend / allow-egress-backend-to-db / allow-egress-batch-to-db

読み取りで確認します。ここは疎通ではなく「読んで分かる」ことに徹します。

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

$ kubectl describe networkpolicy allow-dns -n fanclub

実行結果(Spec: 以降):

Spec:
  PodSelector:     <none> (Allowing the specific traffic to all pods in this namespace)
  Not affecting ingress traffic
  Allowing egress traffic:
    To Port: 53/UDP
    To Port: 53/TCP
    To:
      NamespaceSelector: kubernetes.io/metadata.name=kube-system
      PodSelector: k8s-app=kube-dns
  Policy Types: Egress

ここが正しい書き方の実例です。To: の下に NamespaceSelectorPodSelector並んでいます。これが AND で、「kube-system Namespace の、かつ k8s-app: kube-dns ラベルを持つ Pod」だけに絞られています。先ほど見た allow-frontend-from-gatewayFrom: には NamespaceSelector しかありませんでした。同じ 9 本の中に、締まっている書き方と緩い書き方が混在しているわけです。

UDP と TCP の両方を許可している点も見てください。DNS は通常 UDP/53 ですが、応答が大きい場合や再送時に TCP/53 へフォールバックします。UDP だけだと名前解決が断続的に失敗するという、原因の掴みにくい障害になります。

やってみよう① のステップ1 で取った custom-columns の出力に戻り、TYPES 列に Egress を含む行を数えるのが早い確認方法です。default-deny-all を除けば 4 本、対象は frontend・backend・batch と、全 Pod 向けの DNS 許可だけです。

ここから言えることは、allow-dns 以外の egress 許可を持たない Pod(db と logcollector)は、DNS 以外の外向き通信ができないということです。インターネットにも、他のアプリケーションにも、ノードにも出られません。allow-dnspodSelector: {} で fanclub の全 Pod に効いているので、kube-system の CoreDNS へは出られます——ここは正確に押さえてください。「他 Namespace には一切出られない」ではなく、「CoreDNS への 53 番以外には出られない」です。

DNS を許可し忘れると全部壊れる

egress を絞ったときに最初に壊れるのは名前解決です。Pod は Service 名を解決するために CoreDNS(kube-system)へ問い合わせます。egress の default-deny だけを入れてこの問い合わせを許可しないと、アプリケーションは「名前解決できない」状態になります。しかも症状は「接続できない」ではなく「名前が引けない」なので、原因を NetworkPolicy に結びつけるまでに時間がかかります。

allow-dns の全量は判定規則のセクションで掲載したので、ここでは 2 点だけ確認します。

  • UDP と TCP の両方を許可する。DNS は通常 UDP/53 を使いますが、応答が大きい場合や再送時に TCP/53 へフォールバックします。UDP だけを許可していると、名前解決が断続的に失敗するという最も切り分けにくい症状になります
  • namespaceSelector + podSelector の AND で書かれているkube-system Namespace 全体ではなく k8s-app: kube-dns の Pod だけを許可しています

2 つ目に注目してください。allow-dns は既存 9 本の中で最も締まった書き方をしている 1 本です。やってみよう② で書き換えた 2 本は、この allow-dns と同じ形になりました。つまりお手本は最初から同じ Namespace の中にありました

これは第2巻の書き方が悪かったという話ではありません。同じ Namespace の中に、締まっている書き方と粒度の粗い書き方が同居していたということです。書いた時期・書いた人・そのときの目的が違えば、粒度は揃いません。実務のポリシー群はたいていそうなります。だから定期的な棚卸しが要ります。

最も強い保護は、広く許可しないこと

ここで本回のもう 1 つの主題を立てます。次のセクションで扱うノードメタデータ保護の前提になる考え方です。

ノードメタデータへのアクセスを塞ぎたいとき、真っ先に思いつくのは「メタデータ IP への egress を拒否するルールを書く」ことです。しかしNetworkPolicy に「拒否ルール」は存在しません。書けるのは許可だけです。したがって、メタデータを塞ぐ最も確実な方法は、そもそも外向きの egress を広く許可しないことになります。fanclub の Pod はすでにこの状態にあります。

言い換えると、egress の default-deny が効いている Pod に対して、メタデータ遮断のための追加ルールは必要ありません。むしろ「メタデータを塞ぐために except を書こう」と考えると、except を書くための土台として 0.0.0.0/0 の広い許可が必要になり、塞ぎたかった穴以外をすべて開けてしまいます

except が必要になるのは「広い egress を許可せざるを得ない Pod」に限られます。外部 API を叩くバッチ処理、レジストリからイメージを取得する処理、SaaS へログを送るエージェントなどです。それらは業務要件として外へ出る必要があるので、egress を閉じきれません。その場合の書き方を次のセクションで扱います。

ノードメタデータ(169.254.169.254)の保護

CKS D1 のコンピテンシーに「Protect node metadata and endpoints」という項目があります。ここから 2 セクションかけて扱います。

何が危険なのか

クラウド環境では、各ノードがメタデータエンドポイント(169.254.169.254を持ちます。ここからインスタンスの識別情報や、ノードに割り当てられた IAM ロールの一時クレデンシャルなどを取得できます。これはリンクローカルアドレスで、ノード上からしか到達できない代わりに、認証なしで応答する設計になっているものが多くあります。「ノード上にいる=そのノードの権限を持つはず」という前提に立った設計です。

問題は、このエンドポイントがリンクローカルアドレスとして、そのノード上のどのコンテナからも素通りで見えてしまうことです。Pod は自分の IP を持っていますが、169.254.169.254 宛のパケットはノードの外へ出ず、ノード自身のメタデータサービスへ届きます。侵害された Pod がこのエンドポイントを叩けると、ノードに割り当てられたクラウド権限を丸ごと奪えます。攻撃者にとっては、コンテナ 1 つの侵害からクラウドアカウント全体への足がかりになります。CKS が D1 でこれを名指ししているのはそのためです。

コンピテンシー名は「node metadata and endpoints」です。メタデータ以外にも、ノードが露出しているサービス面があります。本巻でどこが回収するかを先に示しておきます。

対象何が危険か本巻での回収
169.254.169.254(ノードメタデータ)クラウド権限の奪取につながる本回
kubelet API(10250)/ read-only port(10255)ノード上の Pod 一覧・ログ・exec第2回の CIS 評価 + 第5回(kubelet の認証認可)
etcd(2379-2380)クラスタの全データ第2回の CIS 評価 + 第7回(ホスト OS の攻撃面最小化)

NetworkPolicy で塞げるのは Pod から出ていく通信です。kubelet API や etcd をノード外から守るのはホスト側のファイアウォールと認証認可の仕事なので、担当する回が分かれます。「メタデータとエンドポイントの保護」は 1 つの機能ではなく、複数の層にまたがる要件だと理解してください。

記法 ——ipBlock の cidr と except

外部宛の egress を許可しつつ、メタデータだけを除外する形は次のように書きます。次のセクションの演習でそのまま使うマニフェストです。

apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: egress-external-except-metadata
  namespace: nptest
spec:
  podSelector:
    matchLabels:
      app: probe
  policyTypes:
    - Egress
  egress:
    - to:
        - ipBlock:
            cidr: 0.0.0.0/0
            except:
              - 169.254.169.254/32

押さえる点を 3 つに絞ります。

  • except に書く CIDR は、同じ ipBlockcidr に含まれている必要がある0.0.0.0/0 に対する 169.254.169.254/32 は含まれるので有効。含まれない値を書くと API Server が拒否する
  • exceptその ipBlock の中でだけ効く。別の - ipBlock: 要素で同じ範囲を広く許可していると、OR で重なって穴が復活する。ポリシーが複数本あるときも同じで、許可は加算される
  • 0.0.0.0/0 はクラスタ内の Pod IP も含む。この 1 本で「外部にもクラスタ内にも出られる」状態になるため、実務では except にプライベート CIDR やサービス CIDR も並べることが多い

2 つ目は監査の観点として重要です。「メタデータを except で塞いだ」と報告されていても、同じ Pod を対象にした別のポリシーが 0.0.0.0/0 を無条件で許可していれば、穴は開いたままです。except の有無ではなく、その Pod に効いているポリシーを合算した結果を見てください。

ラボには実メタデータ API が無い ——これを隠さない

ここで正直に書いておきます。本書ラボは Hyper-V 上の AlmaLinux であり、169.254.169.254 で待ち受けるサービスは存在しません。したがってポリシーが無い Pod から叩いても届きません(実測でも BLOCKED でした)。

これは検証設計として厄介な状況です。「遮断できた」ことを、届かなかったという結果だけで示すことはできません。もともと届かないのですから、ポリシーが効いているのかどうか区別がつきません

この制約をごまかして「遮断できました」と書くと、読者は動いた気になって理解していない状態になります。そこで次のセクションでは、到達できる宛先を使って except が効くことを証明します。これは実務の検証手法としても正しい進め方です——「効くはずの設定が効いているか」は、効いていれば結果が変わる条件で確かめます。

実クラウドでの注意

クラウドによっては IMDSv2(メタデータ取得の前にトークンの要求を必須にする方式)のような仕組みや、CNI 側でメタデータへのアクセスをブロックする機能が提供されています。NetworkPolicy はその一手段であって唯一の手段ではありません。多層で守るのが定石です。

もう 1 点、実装差にも触れておきます。ノード宛(リンクローカルアドレスを含む)のトラフィックをどう扱うかは CNI 実装によって差があります。本書ラボは Calico で、次のセクションで except が効くことを実測します。ただし「NetworkPolicy を書いたのだから、どの CNI でも同じように遮断される」とは考えないでください。採用している CNI で実際に測るのが確実です。

ただし試験で問われるのは NetworkPolicy による遮断の記法なので、ipBlock + except の形は手に覚えてください。

egressの3段階(許可なし・0.0.0.0/0の広い許可・0.0.0.0/0とexceptの組み合わせ)を強度の上下関係とともに並べた図
図3:egress の 3 段階 ——許可しないことが最も強く、except は広い許可を選ばざるを得ないときの緩和策

やってみよう③:except が効いていることを到達できる宛先で証明する

この演習を始める前に

  • 本演習は専用の Namespace nptest を作って行います。fanclub には手を入れません
  • 終わったらNamespace ごと削除します(ステップ5)
  • 所要時間の目安は 15〜20 分です

ステップ1:検証用の Namespace と Pod を用意する

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

$ kubectl create namespace nptest
$ kubectl run probe -n nptest --image=busybox:1.37 --labels=app=probe --command -- sleep 3600
$ kubectl wait --for=condition=Ready pod/probe -n nptest --timeout=120s

実行結果:

namespace/nptest created
pod/probe created
pod/probe condition met

やってみよう② では --rm の使い捨て Pod を使いましたが、ここでは同じ Pod に対して 3 回測るので、sleep 3600 で常駐させます。--labels=app=probe を付けているのは、この後の NetworkPolicy の spec.podSelector で選択するためです。ポリシーの対象になるラベルは、Pod を作るときに決めておきます

到達できる宛先として k8s-registry(192.168.1.123:5000) を使います。理由は 3 つあります。クラスタ外にあるので ipBlock で指定できること、実在する IP で応答が返ることが確認済みであること、そしてラボ内のホストなのでプロキシ(Squid のホワイトリスト)の影響を受けないことです。外部インターネットを宛先にすると、遮断されたのかプロキシに弾かれたのかが分からなくなります。検証では、測りたいもの以外の変数を減らします

もう 1 点、先に疑問を潰しておきます。egress の点検セクションで「DNS を許可し忘れると全部壊れる」と扱ったばかりなので、「このポリシーには DNS の許可が無いけれど大丈夫なのか」と気になるはずです。本演習では宛先を IP で直接指定するため、名前解決が発生しません。したがって allow-dns に相当する egress 許可は不要です。Service 名やホスト名で叩く設計なら、DNS 許可が別途必要になります

ステップ2:パターン① ポリシーなしの状態を測る

まず基準値を取ります。NetworkPolicy を 1 本も適用していない状態です。

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

$ kubectl exec -n nptest probe -- wget -q -T 5 -O- http://192.168.1.123:5000/v2/
$ kubectl exec -n nptest probe -- wget -q -T 5 -O- http://169.254.169.254/

結果は次のとおりです。

宛先結果
192.168.1.123:5000(k8s-registry)REACHABLE
169.254.169.254BLOCKED(宛先が存在しない)

ここで正直に読んでください。169.254.169.254 が届かないのは、ポリシーのおかげではなく、そこに何も無いからです。この行を「遮断できている」と読んではいけません。

ステップ3:パターン② except にメタデータ IP だけを入れる

前のセクションで示した egress-external-except-metadata をファイルに保存して適用し、同じ 2 つの宛先を測り直します。

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

$ kubectl apply -f egress-external-except-metadata.yaml
$ kubectl exec -n nptest probe -- wget -q -T 5 -O- http://192.168.1.123:5000/v2/
$ kubectl exec -n nptest probe -- wget -q -T 5 -O- http://169.254.169.254/

結果は次のとおりです。

宛先結果
192.168.1.123:5000REACHABLE
169.254.169.254BLOCKED

ここで一度立ち止まってください。パターン① と結果がまったく同じです。レジストリには届き、メタデータ IP には届かない。表だけを見れば「except が効いている」と言いたくなりますが、この時点では「ポリシーが効いているのか、もともと届かないだけなのか」を区別できません

実務でも試験でも、ここで満足して終わる人が多くいます。「設定した」「エラーが出ない」「期待どおりの結果に見える」——3 つ揃っていても、証明にはなっていません。

ステップ4:パターン③ 到達できていた宛先を except に入れる

決着を付けます。except192.168.1.123/32 を追加します。全量を掲載します。

apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: egress-external-except-metadata
  namespace: nptest
spec:
  podSelector:
    matchLabels:
      app: probe
  policyTypes:
    - Egress
  egress:
    - to:
        - ipBlock:
            cidr: 0.0.0.0/0
            except:
              - 169.254.169.254/32
              - 192.168.1.123/32

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

$ kubectl apply -f egress-external-except-metadata.yaml
$ kubectl exec -n nptest probe -- wget -q -T 5 -O- http://192.168.1.123:5000/v2/
$ kubectl exec -n nptest probe -- wget -q -T 5 -O- http://169.254.169.254/

結果は次のとおりです。

宛先結果
192.168.1.123:5000BLOCKED
169.254.169.254BLOCKED

到達できていた宛先が、except に入れた途端に遮断されました。3 パターンを並べて見ます。

ポリシー192.168.1.123:5000169.254.169.254
ポリシーなしREACHABLEBLOCKED(宛先が存在しない)
0.0.0.0/0 + except: [169.254.169.254/32]REACHABLEBLOCKED
0.0.0.0/0 + except: [169.254.169.254/32, 192.168.1.123/32]BLOCKEDBLOCKED

3 行目が決め手です。これで except は確かに穴を塞いでいると言えます。そして同じ仕組みが 169.254.169.254/32 にも働いています——実クラウドであれば、メタデータ API への到達も同じように遮断されます。ラボにメタデータ API が無いという制約は、これで迂回できました。

この検証手法は一般化できます。「効くはずの設定が効いているか」を確かめるには、効いていれば結果が変わる条件を用意します。届かないものが届かないままであることは、何の証拠にもなりません。これは NetworkPolicy に限らず、ファイアウォール・RBAC・Admission でも同じ考え方が使えます。第4回の RBAC 監査でも、kubectl auth can-i で「できないはずのことを試す」という同じ型が出てきます。

ステップ5:後片付け

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

$ kubectl delete namespace nptest

Namespace を消せば、中の Pod も NetworkPolicy もまとめて消えます。検証用リソースを残さないのは監査側の作法です。残った probe Pod は、次に誰かが棚卸ししたときに「これは何だ」という調査コストになります。自分が作ったものは自分で消してください。

fanclub 側には何もしない理由

ここまで演習をやると「同じことを fanclub にも適用しよう」と考えたくなります。適用しません。理由を明示します。

fanclub の Pod はすでに egress の default-deny が効いていて、allow-dns 以外の外向き許可を持たない Pod(db / logcollector)はそもそも外に出られません。メタデータ遮断のために except を書き足す必要はありません。書き足すと「広い egress を許可する」ことになり、かえって穴が増えます

egress の点検セクションで立てた「最も強い保護は広く許可しないこと」が、ここで回収されます。演習で覚えた記法を、必要のない場所へ持ち込まないでください。セキュリティ設定は、足すほど強くなるわけではありません

試験での粒度も確認します。「指定の Pod からノードメタデータへのアクセスを遮断せよ。ただし他の外部通信は維持すること」という形で問われうる内容です。ipBlock + except を書いて適用するまでで 5〜8 分が目安になります。「他の外部通信は維持」という条件があるからこそ 0.0.0.0/0 + except が正解になる、という条件の読み取りが得点の分かれ目です。

TLS Ingress ——試験の標準 Ingress とラボの Gateway API

D1 の残り 1 つ、「Properly set up Ingress with TLS」に入ります。ここは試験形式とラボ形式が違うので、最初に宣言しておきます。

試験(CKS)本書ラボ
リソース標準 Ingressnetworking.k8s.io/v1)+ spec.tlsGateway + HTTPRoute(Gateway API v1.4.0)
実装NGINX Ingress Controller 想定Traefik v3.6.x
証明書kubernetes.io/tls タイプの Secret を手で作るcert-manager v1.20.0 が発行・更新
ドキュメント参照kubernetes.io/docs 内なので参照可gateway-api.sigs.k8s.ioCKS では参照不可

4 行目が重要です。Gateway API のドキュメントは CKA では参照できますが、CKS では参照できません。本書ラボの実装が Gateway API であることに慣れきると、試験本番で手が止まります。試験では標準 Ingress で解答する——この方針をここで徹底しておきます。ラボで日常的に触るものと試験で書くものが違うのは、学習者にとって負担のある状況です。だからこそ、両方を扱えるようにしておくのが本セクションの目的になります。

なぜラボは ingress-nginx を使わないのか

ingress-nginx は 2026 年 3 月で EOL となったため、本シリーズでは採用していません。新規に本番環境を設計するなら、後継である Gateway API を選ぶのが妥当です。

一方、試験の許可ドキュメントに NGINX Ingress Controller が含まれるのは、試験環境の構成がそうであるためです。「試験で問われる形式」と「今から本番で選ぶべき実装」は別だと切り分けてください。本書が古い形式を教えているわけでも、試験対策を捨てているわけでもありません。試験は現時点の出題に合わせて解き、設計は現時点の推奨に合わせて選びます。この 2 つを両立させるために、本セクションでは両形式を並べます。

標準 Ingress の TLS 設定(試験形式)

試験で書く形の全量です。

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: exam-ingress
  namespace: fanclub
spec:
  tls:
    - hosts:
        - fanclub.local
      secretName: exam-tls
  rules:
    - host: fanclub.local
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: fanclub-frontend
                port:
                  number: 80

押さえる点は 3 つです。

  • spec.tls[].hostsspec.rules[].host両方書く。前者は「どのホスト名の TLS 終端に使うか」、後者は「どのホスト名のリクエストをこのルールで振るか」で、役割が違う。片方だけだと TLS 終端かルーティングのどちらかが機能しない
  • secretName が指す Secret は kubernetes.io/tls タイプでなければならない(Opaque では動かない)。キー名は tls.crttls.key で固定
  • pathType は必須。Prefix / Exact / ImplementationSpecific の 3 種があり、省略するとリソースの作成自体が失敗する

リソース名を exam-ingress / exam-tls にしている理由も書いておきます。fanclub-tls は cert-manager の管理下にある稼働中の証明書だからです。読者がこの YAML をそのまま適用しても本番経路を壊さないよう、演習用の名前で統一しています。

試験中に参照できるドキュメント

Ingress の記法は、許可 1 番の kubernetes.io/docs にあります。ingress-nginx のサイトも許可されていますが、kubernetes.github.io/ingress-nginx/user-guide/nginx-configuration/ というサブパス配下に限定されています。トップページや Examples 配下は開けません。

ここが実務的な注意点です。user-guide/nginx-configuration/ はアノテーションと ConfigMap の章なので、Ingress リソースの spec.tls サンプルは置かれていません。試験本番で Ingress の YAML をこのドメインから取ろうとすると空振りします。

  • Ingress の記法そのものは kubernetes.io/docs から取るのが正解ルート
  • ingress-nginx 側は「アノテーションで NGINX の挙動を変える」問題が出たときの参照先
  • Gateway API のドキュメント(gateway-api.sigs.k8s.io)は CKS では参照不可。HTTPRoute で答えないこと

ラボの実装を読む ——HTTPRoute は「/」の 1 ルートだけ

本書ラボで実際に HTTPS を終端しているのは Gateway API の側です。中身を確認します。

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

$ kubectl get httproute fanclub-route -n fanclub -o yaml

実行結果(spec: のみ抜粋):

spec:
  hostnames:
  - fanclub.local
  parentRefs:
  - group: gateway.networking.k8s.io
    kind: Gateway
    name: fanclub-gateway
  rules:
  - backendRefs:
    - group: ""
      kind: Service
      name: fanclub-frontend
      port: 80
      weight: 1
    matches:
    - path:
        type: PathPrefix
        value: /

rules1 つだけで、/ を frontend の 80 へ振っています。/api を backend へ振るルールはありません。では /api/members はどう届いているのかというと、frontend の Nginx が location /api/ { proxy_pass http://fanclub-backend:8080; } で中継しています。この事実が、allow-backend-from-frontendallow-egress-frontend-to-backend の 2 本が必須である根拠です。棚卸しのときに「Gateway から直接 backend へ行くはずだから frontend → backend は不要では」と推測で判断すると、削った瞬間に /api が 502 になります。

読み取るべき事実は次の 3 点です。rules1 つだけbackendRefsname: fanclub-frontend / port: 80matchespathtype: PathPrefix / value: /。つまり/api を backend へ振るルールは存在しません。Gateway に届いたリクエストは、パスに関係なくすべて frontend へ送られます。

では /api/members はどうやって backend まで届いているのでしょうか。frontend の Nginx が中継しています。

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

$ kubectl exec -n fanclub deploy/fanclub-frontend -- cat /etc/nginx/conf.d/default.conf

実行結果(該当箇所):

location /api/ {
    proxy_pass http://fanclub-backend:8080;

ここで確定している事実は、frontend の Nginx が location /api/ で backend へリバースプロキシしていることです。

この事実が NetworkPolicy の設計に直結します。ルーティングの実体が「Gateway → frontend → backend」である以上、allow-backend-from-frontendallow-egress-frontend-to-backend は必須です。もし「Gateway が /api を backend へ直接振っている」と思い込んでこの 2 本を削れば、/api/members は即座にエラーになります。

NetworkPolicy の設計は、ルーティングの実体を確認してから行ってください。マニフェストの見た目や思い込みで判断しないことです。「Gateway API を使っているのだから、Gateway が全部のルーティングをしているはずだ」という推測は、この構成では成り立ちませんでした。本回で最も実務的な教訓であり、やってみよう① の棚卸しが効く理由でもあります。

cert-manager が作っている Secret を見る

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

$ kubectl get secret -n fanclub --field-selector type=kubernetes.io/tls

実行結果:

NAME          TYPE                DATA   AGE
fanclub-tls   kubernetes.io/tls   3      11h

fanclub Namespace には fanclub-tls という Secret があり、タイプは kubernetes.io/tls、キーは 3 個です。これは cert-manager の Certificate リソース(fanclub/fanclub-tls)が管理しており、期限が近づけば自動で更新されます。同じ仕組みで cert-manager Namespace には CA 用の fanclub-ca(Secret fanclub-ca-key-pair)があります。

ここで押さえてほしいのは、cert-manager が発行した Secret も、手で作る Secret も同じ kubernetes.io/tls タイプだということです。Ingress や Gateway から見れば区別がありません。違うのは「誰が作って誰が更新するか」だけです。試験では手で作り、実務では cert-manager に任せる——この対応関係が分かれば、両方が同じものに見えてきます。

やってみよう④:試験形式で TLS Ingress 一式を組み立てる

この演習を始める前に

  • 本演習で作る Ingress と Secret は試験形式の練習です。本書ラボの HTTPS 経路は Gateway API のままで、そちらを置き換えるものではありません
  • Secret 名は exam-tls を使ってください。fanclub-tls は使いませんfanclub-tls は cert-manager が Certificate リソース(fanclub/fanclub-tls)で管理している稼働中の証明書で、同名で作ろうとすると失敗し、削除すると第4回以降のブラウザ確認が壊れます
  • cert-manager が管理している Secret には触れないでください
  • 作ったリソースはステップ5 で必ず削除します(削除するのは exam-tlsexam-ingress だけです)
  • 所要時間の目安は 8〜12 分です

この演習の到達点

先にゴールを明示します。「標準 Ingress を組み立てて describe まで確認し、ADDRESS が付かない理由を説明できる」ことが到達点です。ルーティングが通ることは求めません。ラボの構成では原理的に通らないためです。

理由を先に説明しておきます。本書ラボの Traefik は、起動引数に --providers.kubernetescrd--providers.kubernetesgateway を持っていますが、--providers.kubernetesingress を持っていません。つまり標準の Ingress リソースは Traefik の監視対象外です。IngressClasstraefik・controller は traefik.io/ingress-controller)は Helm chart が作っているので存在しますが、それを処理するプロバイダが動いていません。

結果として、Ingress を適用するとリソースは作成されるが ADDRESS は空のまま、describeEvents も空という状態になります。Gateway API 経路とは干渉しないことも実測済みで、適用中も HTTPS は 200 を維持します。だから実適用して構いません

試験では Ingress を扱うタスクが出る以上、それを処理するコントローラが用意されているはずです(許可ドキュメントに ingress-nginx が含まれているのも傍証です)。ただしどの実装が動いているかは公式に明記されていないので、決めつけずに kubectl get ingressclass で確認してから書き始めてください。ラボで ADDRESS が空になるのはマニフェストが間違っているからではなく、処理するコントローラがいないからです。この切り分けができることが、本演習の実質的な価値になります。第2回の「/healthzok を返しているのに kubectl logs は壊れていた」という構図と同じで、コマンドが成功することと機能していることは別です。

ステップ1:証明書ファイルを用意する

試験では、証明書と秘密鍵のファイルが問題文で与えられるのが普通です。生成から問われることは多くありません。そこで本演習でも「手元に tls.crttls.key がある」状態を作るところから始めます。

本書ラボの k8s-ops には openssl が入っていません(AlmaLinux 10 の最小構成のためです)。パッケージを足さずに済ませるため、cert-manager が既に発行している証明書を Secret から取り出してファイルにします。Secret の中身は base64 でエンコードされているので、デコードして書き出します。

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

$ cd /tmp
$ kubectl get secret fanclub-tls -n fanclub -o jsonpath='{.data.tls\.crt}' | base64 -d > tls.crt
$ kubectl get secret fanclub-tls -n fanclub -o jsonpath='{.data.tls\.key}' | base64 -d > tls.key
$ ls -l tls.crt tls.key
$ head -1 tls.crt
$ head -1 tls.key

実行結果:

-rw-r--r--. 1 developer developer 1103  7月 26 14:18 tls.crt
-rw-r--r--. 1 developer developer 1675  7月 26 14:18 tls.key
-----BEGIN CERTIFICATE-----
-----BEGIN RSA PRIVATE KEY-----

この操作は fanclub-tls読むだけで、変更も削除もしません。jsonpathtls\.crt でバックスラッシュを入れているのは、キー名に含まれるドットを「階層の区切り」ではなく文字としてのドットとして扱わせるためです。ここは試験でも手が止まりやすい記法なので覚えておいてください。

Secret から取り出すこの手順自体、実務では「今動いている証明書の期限を確認したい」「別のクラスタへ持っていきたい」という場面でそのまま使えます。

自分で証明書を作りたい場合は sudo dnf install -y opensslbaseos リポジトリにあります)を入れたうえで、次のコマンドで生成できます。

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

$ openssl req -x509 -newkey rsa:2048 -nodes -days 365 -keyout tls.key -out tls.crt -subj "/CN=fanclub.local"

-nodes は秘密鍵を暗号化しない(パスフレーズを付けない)指定です。Kubernetes の Secret に入れる鍵はパスフレーズ無しである必要があります。OpenSSL 3.x では -nodes非推奨のエイリアスで、正式には -noenc です。-nodes でも動作します。

なお、ノードに評価用のパッケージを増やさないという判断は第7回(ホスト OS の攻撃面最小化)の主題そのものです。「入れずに済む方法があるなら入れない」を選べる場面では選んでおきます。

試験では、与えられるファイル名が tls.crt / tls.key でない場合があります。次のステップの --cert / --key に渡すパスを、問題文に合わせて読み替えてください。

ステップ2:kubernetes.io/tls タイプの Secret を作る

本回の暗記対象の 1 つです。

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

$ kubectl create secret tls exam-tls --cert=tls.crt --key=tls.key -n fanclub

タイプを確認します。

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

$ kubectl get secret exam-tls -n fanclub -o jsonpath='{.type}{"\n"}'

実行結果:

kubernetes.io/tls

kubectl create secret generic では作れませんgeneric で作るとタイプは Opaque になり、Ingress の secretName に指定しても TLS 終端に使えません。tls サブコマンドを使うことで、タイプが kubernetes.io/tls になり、キー名も tls.crt / tls.key に固定されます。

試験で最も手が止まりやすい 1 行なので、暗記対象に入れます。「Secret を作る」と考えて反射的に generic を打つと、後の Ingress が動かず、原因も分かりにくくなります。

ステップ3:Ingress を組み立てる

手書きせず、generator からひな形を出します。

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

$ kubectl create ingress exam-ingress -n fanclub --rule="fanclub.local/*=fanclub-frontend:80,tls=exam-tls" --dry-run=client -o yaml

実行結果:

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: exam-ingress
  namespace: fanclub
spec:
  rules:
  - host: fanclub.local
    http:
      paths:
      - backend:
          service:
            name: fanclub-frontend
            port:
              number: 80
        path: /
        pathType: Prefix
  tls:
  - hosts:
    - fanclub.local
    secretName: exam-tls
status:
  loadBalancer: {}

generator が出したものを鵜呑みにせず、3 点を目で確認してください。pathTypePrefix になっていること(/* と書いたので Prefix に変換されました)、spec.tls[].hostsspec.rules[].host両方fanclub.local が入っていること、secretNameexam-tls であること。

ingressClassName が出力に含まれていない点にも注意してください。本書ラボでは traefik が既定の IngressClass に指定されているため、省略しても既定が使われます。試験環境で既定が設定されていない場合は、--class=nginx を付けるか YAML に spec.ingressClassName を書き足す必要があります。

出力が得られたら、そのまま信じずに 2 箇所を目で確認してください。1 つは spec.tls が期待どおりに入っているか、もう 1 つは pathType が何になっているかです。generator の /* という書き方は pathType: Prefix になる場合と ImplementationSpecific になる場合があり、前のセクションで示した全量 YAML と細部が食い違うことがあります。generator は入力の手間を減らす道具であって、正解を出す道具ではありません

試験でのコツを 1 つ。--dry-run=client -o yaml でひな形を出し、ファイルに落として直すほうが、公式ドキュメントから YAML をコピーするより速く済みます。第1回で示した export do="--dry-run=client -o yaml" がここで効きます。kubectl create ingress ... $do > exam-ingress.yaml と打てば、編集対象のファイルが手元にできます。

ステップ4:適用して describe で読む

本演習の核です。ひな形を exam-ingress.yaml として保存し、適用します。

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

$ kubectl apply -f exam-ingress.yaml
$ kubectl get ingress -n fanclub

実行結果:

NAME           CLASS     HOSTS           ADDRESS   PORTS     AGE
exam-ingress   traefik   fanclub.local             80, 443   25s

ADDRESS 列が空であることを確認してください。20 秒待っても 1 分待っても変わりません。次に describe で中身を読みます。

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

$ kubectl describe ingress exam-ingress -n fanclub

実行結果:

Name:             exam-ingress
Labels:           <none>
Namespace:        fanclub
Address:
Ingress Class:    traefik
Default backend:  <default>
TLS:
  exam-tls terminates fanclub.local
Rules:
  Host           Path  Backends
  ----           ----  --------
  fanclub.local
                 /   fanclub-frontend:80 (10.244.119.162:80,10.244.119.157:80)
Annotations:     <none>
Events:          <none>

ここが本演習の核心です。3 つの事実を並べて読んでください。

  • TLS: exam-tls terminates fanclub.local —— TLS の記述は正しく解釈されている
  • / → fanclub-frontend:80 (10.244.119.162:80,10.244.119.157:80) —— バックエンドの Pod IP まで解決できている(Service の Endpoints が引けている)
  • Address: が空、Events:<none> —— それでも誰も処理していない

マニフェストは正しいのです。API Server は受け付け、内容も正しく解釈しています。にもかかわらず何も起きないのは、traefik.io/ingress-controller という IngressClass を名乗るコントローラが、Ingress リソースを監視していないからです。Events が空なのがその証拠で、エラーすら出ません。失敗すら記録されない——これが「処理する主体がいない」ということの見え方です。

本番でこの状態に出会ったら、疑うのは YAML ではなくコントローラの側です。IngressClass に対応するコントローラが動いているか、そのコントローラが Ingress リソースを見る設定になっているかを確認します。

読むべき点は 2 つあります。1 つは、TLS の記述とルールが正しく解釈されて表示されていることです。TLS の行には Secret 名とホスト名の対応が出て、ルールの行にはパスと backend Service、そして解決された Pod の IP が出ます。マニフェストは正しく解釈されています

もう 1 つは Events が空であることです。コントローラが処理していれば、何らかのイベントが記録されます。空だということは、誰もこのリソースを見ていないということです。

この 2 点から切り分けが立ちます。「マニフェストは正しいのに ADDRESS が付かない」→「処理するコントローラがいない」。試験でも実務でも、Ingress が効かないときはまずこの順で見ます。マニフェストを疑って何時間も直し続ける前に、IngressClass とコントローラの稼働状況を確認してください。

最後に、ラボの実経路が無傷であることを確認します。

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

$ kubectl get httproute -n fanclub
$ curl -sk --noproxy '*' -o /dev/null -w '%{http_code}\n' -H 'Host: fanclub.local' https://192.168.1.124/

実行結果:

200

HTTPRoute はそのまま残り、HTTPS も 200 を返します。標準 Ingress と Gateway API は干渉しません。処理する主体が別なので、同じクラスタに両方が存在しても互いに影響しないためです。

ステップ5:後片付け

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

$ kubectl delete ingress exam-ingress -n fanclub
$ kubectl delete secret exam-tls -n fanclub
$ rm -f tls.crt tls.key

削除対象は本演習で作った exam-ingressexam-tls だけです。fanclub-tls には触れないでください。cert-manager が管理するラボの稼働証明書で、削除すると HTTPS のブラウザ確認が止まります。本演習のリソースを第4回以降に持ち込まないようにしてください。

試験相当の粒度

ステップ試験相当の所要時間
ステップ2(kubectl create secret tls約 1〜2 分
ステップ3(generator から Ingress を組み立て、spec.tlspathType を確認)約 4〜6 分
ステップ4(適用して describe で検証)約 2〜3 分

証明書は問題文で与えられることが多いので、試験では kubectl create secret tls から Ingress 作成までで 5〜8 分が目安になります。本演習は通しで 8〜12 分。本回の 4 本の中で、試験の出題粒度に最も近い演習です。

暗記必須コマンド(第3回)

本回で扱った、試験中に手早く打てる必要があるコマンドを集約します。

用途コマンド補足
TLS Secret 作成kubectl create secret tls <name> --cert=tls.crt --key=tls.key -n <ns>generic では kubernetes.io/tls にならない。最頻出
Ingress のひな形生成kubectl create ingress <name> --rule="<host>/*=<svc>:<port>,tls=<secret>" --dry-run=client -o yaml出力の pathTypespec.tls は目で確認する
NetworkPolicy 一覧kubectl get networkpolicy -n <ns>短縮形は netpol
ポリシーの中身を読むkubectl get networkpolicy <name> -n <ns> -o yamlAND / OR の判定は必ず YAML でdescribe では区別できない
対象 Pod のラベル確認kubectl get pod -n <ns> --show-labelsラベルは推測しない
Namespace のラベル確認kubectl get namespace <ns> --show-labelskubernetes.io/metadata.name は自動付与
疎通テスト(一時 Pod)kubectl run np-test --rm -i --restart=Never -n <ns> --image=busybox:1.37 -- sh -c 'wget -q -T 5 -O- http://<svc>.<ns>.svc.cluster.local/'既存 Pod に curl / wget が無い前提で組み立てる
常駐の検証 Podkubectl run probe -n <ns> --image=busybox:1.37 --labels=app=probe --command -- sleep 3600複数回テストするとき

NetworkPolicy に generator はありませんkubectl create networkpolicy というサブコマンドは存在しません)。ただし kubernetes.io/docs は試験の許可ドキュメントに含まれるので、サンプル YAML は公式から取れます。したがって暗記が要るのは YAML の全文ではなく、次の 4 点です。

  • from / toAND / OR の書き分け- を付けるか、同一要素内に並べるか)
  • podSelector 単独が同一 Namespace のみに一致すること
  • ipBlockexceptcidr含まれる範囲でしか書けないこと
  • クライアントが無いイメージでの疎通テストの立て方

この 4 点は、公式ドキュメントから探すより手で覚えたほうが速く済みます。逆に言えば、ここさえ押さえていればサンプルは公式から取ればよいということです。暗記の範囲を広げすぎないでください。

試験環境の作法も再掲します。CKS 試験環境には alias k=kubectl がプリインストールされています。本文は読みやすさのため kubectl の全綴りで統一していますが、試験では k で打ってください

まとめ

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

  • fanclub Namespace には第2巻の時点で NetworkPolicy が 9 本あり、default-deny-all も入っていた。本回は白紙から書く回ではなく、既にあるものを疑って読む回だった
  • default-deny は出発点であって到達点ではない。開けた穴の粒度が粗ければ、リスクはその粗さのまま残る
  • from / to- で並べれば OR、同一リスト要素の中に並べれば AND。インデントの深さだけが意味を変える
  • podSelector 単独は同一 Namespace のみに一致する。別 Namespace の相手は namespaceSelector との AND で指定する
  • allow-frontend-from-gatewayallow-backend-from-monitoringnamespaceSelector 単独で Namespace 全体を許可していた。podSelector との AND に締めても、HTTPS 200・/api/members の JSON・Prometheus の scrape はすべて維持できた
  • ルーティングの実体は Gateway → frontend(Nginx) →(/api/ のみ)→ backend → dballow-backend-from-frontend は必須であり、削ってはならない
  • egress を絞るときは CoreDNS への 53(UDP + TCP)を許可する。allow-dnsk8s-app: kube-dns との AND で書かれた良い実例だった
  • ノードメタデータを塞ぐ最も強い方法は、広い egress をそもそも許可しないことipBlockexcept が要るのは、広い egress を許可せざるを得ない Pod に限られる
  • except が効いていることは、到達できる宛先を except に入れて遮断されることで証明できる。届かないものが届かないままであることは証拠にならない
  • TLS Ingress は試験=標準 Ingress + spec.tls + kubernetes.io/tls Secretラボ=Gateway API HTTPRoute + cert-manager。Gateway API のドキュメントは CKS では参照できない

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

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

  1. default-deny-all が適用されていれば、許可ポリシーの粒度が粗くても最小権限の設計として十分である
  2. from のリスト要素を - で分けて 2 つ書くと、どちらかに一致すれば通る(OR)
  3. namespaceSelector だけを書いて許可すると、その Namespace の全 Pod からの通信を許可することになる
  4. podSelector 単独の指定は、そのポリシーがある Namespace の Pod にのみ一致する
  5. egress を default-deny にしても、CoreDNS への 53 番ポートは自動的に許可される
  6. ipBlockexcept に書く CIDR は、同じ ipBlockcidr に含まれている必要がある
  7. 外向きの egress を一切許可していない Pod にも、メタデータ IP 遮断のための except ルールを追加する必要がある
  8. CKS 試験の TLS Ingress は、標準 Ingressspec.tlskubernetes.io/tls タイプの Secret で解答する
  9. 本回で扱った NetworkPolicy では、HTTP メソッドやパス単位の制御ができる
解答と解説

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

問1 が本回の主題そのものです。default-deny は「明示的に許可したものだけを通す」状態を作るだけで、許可の粒度までは保証しません。本回で締めた 2 件は、default-deny がある状態でも Namespace 全体を開けていました。「default-deny が入っているか」ではなく「開けた穴が最小か」を問う——これが CKS の視点です。

問2 と問4 はセットで押さえてください。問2 は正しく、- で分けたリスト要素は OR で評価されます。逆に同一リスト要素の中に namespaceSelectorpodSelector を並べれば AND です。- を 1 つ付けるかどうかで、絞ったつもりの設定が広い許可のまま残ります。問4 も正しく、podSelector 単独はポリシーが置かれている Namespace の中でしか一致しません。別 Namespace の Pod を指定したいなら namespaceSelector との AND が必要です。この 2 問が本回の最頻出の混乱点で、試験でも時間を失いやすい箇所です。

問3 は正しく、namespaceSelector は Namespace の集合を指すセレクタであり、Pod を絞りません。monitoring の 17 Pod のうち、backend への通信が必要なのは prometheus 1 つだけでした。問5 は誤りで、自動的な例外はありません。CoreDNS への UDP/TCP 53 を明示的に許可します。TCP を忘れると、応答が大きい場合や再送時に名前解決が断続的に失敗します。

問6 は正しく、exceptcidr の内側を差し引く指定なので、範囲外の値を書くと API Server が拒否します。問7 は誤りで、必要ありません。むしろ except を書くために 0.0.0.0/0 を許可すると、塞ぎたかった穴以外の外向き通信をすべて開けてしまいます。fanclub の db / logcollector は egress 許可を持たないため、既にメタデータへも到達できません。

問9 は誤りで、標準の networking.k8s.io/v1 NetworkPolicy は L3/L4(Pod・Namespace・IP・ポート)までです。HTTP メソッドやパス単位の制御は CiliumNetworkPolicy の領域で、第11回(D4)で扱います。問8 は TLS Ingress のセクションを読み返してください。

次回予告

第4回「RBAC 監査 + ServiceAccount トークン管理」では、D2(Cluster Hardening)に入ります。本回は「どこから、どこへ、何が通るか」を絞りました。次回は「誰が、何をできるか」を絞ります。kubectl auth can-i と rbac-tool で過剰な権限を洗い出し、最小権限へ寄せます。あわせて automountServiceAccountToken: false と期限付きトークン(TokenRequest)で、ServiceAccount トークンの露出を減らします。

本回で使った「できないはずのことを試して確かめる」という検証の型は、次回の RBAC でもそのまま使えます。ポリシーを書いた後に「効いているか」を測る癖を、ここで身につけておいてください。

→ 詳しくは 第4回 RBAC 監査 + ServiceAccount トークン管理

前の記事
次の記事