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

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

K8sインシデント対応DAIR実戦【CKS第18回】

公開

新卒インフラエンジニア向け「Kubernetes 実践教科書 ③ CKS セキュリティ・ハードニング編」(全19回)の第18回です。第15回から第17回までで、検知の体制を組み上げました。本回はそれを実際のインシデントで使い切る回です。新しいツールは 1 つも導入しません。すでにあるものだけで、どこまで追えて、どこで行き止まるかを確かめます。

本回の中心にある事実を、最初に置きます——インシデントに気づいた時、何が残っているかは、事前の設計で決まっています。 気づいてから証拠を集めに行っても、そこに無いものは出てきません。本回では、Falco が捕らえた記録は残り、Hubble のフローは約 1 分で消え、「誰が実行したか」はどこにも無い——という状態を、コマンドの出力として体験します。

第17回の中心は「防御は検知を生まない」でした。本回はその先です——防御は、調査も妨げます。 fanclub namespace にかけた PSA restricted は、攻撃者だけでなく、調査しようとする自分の kubectl debug も拒否します。

  • ラボ Kubernetes v1.36.3
  • CKS 試験環境 v1.35
  • Falco 0.44.1(chart 9.1.0)
  • falcosidekick chart 0.12.1(image 2.32.0)
  • Cilium v1.19.6
  • Loki 3.6.8
  • Helm v4.1.4
  • containerd v2.2.6
  • AlmaLinux 10.2
  • 確認日 2026-08-03
目次
  1. 第18回のスコープ・今ここマップ
    1. 本回の軸: 4 つの反転
    2. 既習範囲 / 本回で使う道具
  2. この回のゴール
    1. 本回の起点
  3. インシデント対応の型 —— DAIR
    1. この型で押さえる 3 つの原則
  4. 今回のシナリオ
    1. アラートに気づく経路は 1 本しかない
    2. 最初に持つべき 6 つの疑問
    3. ラボでの再現方法
  5. やってみよう①:攻撃を再現して Falco で捕まえる(Detect)
    1. ステップ1:対象 Pod を選び、開始時刻を控える
    2. ステップ2:偵察 —— 誰として動いているかを調べる
    3. ステップ3:認証情報を探す —— ここが実害になる
    4. ステップ4:横展開を試みる —— ここは防げている
    5. ステップ5:痕跡を残そうとする —— ここも防げている
    6. ステップ6:Falco が何を捕らえたかを見る
    7. ここで確認する 3 つのこと
  6. Analyze その 1 —— Loki で全体像を組む
    1. ステップ1:Loki へ到達する
    2. ステップ2:どんなルールが記録されているかを見る
    3. ステップ3:対象のアラートだけを取り出す
    4. falcosidekick が付けるラベル
    5. 件数を数えるなら instant query を使う
    6. ステップ4:アラートが落ちていないかを確認する
  7. Analyze その 2 —— 「誰が」に到達できない
    1. 経路 1: kube-apiserver のログ
    2. 経路 2: kubelet のログ
    3. 経路 3: Kubernetes Events
    4. 経路 4: 監査ログ —— 存在しない
    5. 結論 —— 実行者を記録できるのは監査ログだけ
  8. 証拠には寿命がある
    1. Hubble のフローは 1 分弱で消える
    2. 証拠の寿命の比較
  9. やってみよう②:ラベル 1 つで隔離する(Isolate)
    1. ステップ1:効かないやり方を先に見る —— ラベルを「足す」
    2. ステップ2:何がラベルを見ているかを確認する
    3. ステップ3:効くやり方 —— ラベルを「書き換える」
    4. ステップ4:Deployment 側で何が起きたかを見る
    5. ステップ5:遮断は即時ではない
    6. ステップ6:隔離を Hubble で裏づける
    7. ステップ7:隔離の限界を 2 つ確認する
    8. ステップ8:サービスが継続していることを確認する
  10. 証拠保全 —— kubectl debug は 2 段階で止まる
    1. ステップ1:すぐに取れるものから取る
    2. ステップ2:ファイルを取り出す
    3. 段階 1: kubectl debug をそのまま打つ —— PSA が拒否する
    4. 段階 2: –profile=restricted —— 通ったのに動かない
    5. 段階 3: –custom で runAsUser を明示する
    6. 段階 4: 対象コンテナの中身を読む
    7. 調査そのものも検知される
  11. Recover —— 復旧はもう始まっている
    1. 調査が終わってから削除する
  12. やってみよう③:Postmortem —— 改善策を実装する
    1. ステップ1:タイムラインを引く
    2. ステップ2:根本原因を 3 つの問いで整理する
    3. ステップ3:改善アクションを 6 件挙げる
    4. ステップ4:exec だけを記録する最小の Audit Policy を書く
    5. ステップ5:3 台すべての Control Plane に適用する
    6. ステップ6:今度は「誰が」が分かるかを確かめる
    7. 想定と違った点 1: verb は create ではなく get
    8. 想定と違った点 2: sourceIPs は実行元ではない
    9. ログ量を確かめる
    10. ステップ7:GitOps へ記録する
    11. このポリシーは残す
  13. 試験ではどう出るか
    1. ラボと試験環境の違い
    2. 試験中に参照できるもの・できないもの
  14. まとめ
  15. 理解度チェック(○×形式・全 9 問)
  16. 次回予告

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

本回は D6 の 5 コンピテンシーのうち、「攻撃のフェーズと実行者を特定する」の応用にあたります。第16回が監査ログの設計を扱ったのに対し、本回は実際の事案の上で、どこまで特定できるかを確かめます。

公式コンピテンシー(D6 Monitoring, Logging and Runtime Security・20%)担当
Perform behavioral analytics to detect malicious activities第15回
Detect threats within physical infrastructure, apps, networks, data, users and workloads第17回
Investigate and identify phases of attack and bad actors within the environment第16回本回(第18回)
Ensure immutability of containers at runtime第17回
Use audit logs to monitor access第16回本回(第18回)

この文言を 2 つに割ると、本回の構造が見えます。phases of attack(攻撃のフェーズ)は追えます。 Falco の記録が残っているからです。しかし bad actors(実行者)には届きません。 それを記録する仕組みを、第16回の最後に撤去したからです。

本回の軸: 4 つの反転

本回では、順調に進んでいた対応が 4 回止まります。 どれも、事前の設計がそのまま結果になっているものです。

#どこでやろうとすること実機で起きること
やってみよう①Falco が捕らえたのだから、誰が kubectl exec したかも分かるはず分かりません。 user=appuser uid=10001コンテナの中で動いたプロセスの実行ユーザーです。正規の運用者が打っても攻撃者が打っても、同じ値になります
やってみよう②隔離のために、対象 Pod に quarantine=true のラベルを付ける何も起きません。 Pod は Deployment の管理下のまま、通信も 200 のままです。ラベルは足すのではなく書き換える必要があります
やってみよう②kubectl debug でエフェメラルコンテナを立てて中を調べる2 段階で止まります。 まず PSA restricted が admission で拒否し、--profile=restricted にすると今度はエラーを出さずに起動だけしません
やってみよう③監査ログを入れたので、次からは「誰が・どこから」の両方が分かるはず「どこから」は分かりません。 sourceIPs に記録されるのはロードバランサの IP で、kubectl を打ったホストではありません

本回の中心メッセージ: 気づいた時に何が残っているかは、事前の設計で決まっています。

インシデント対応の質は、対応中の手際ではなく、その前に何を記録する設計にしていたかで決まります。本回で行き止まる 3 か所は、どれも過去の回で下した判断の帰結です。

既習範囲 / 本回で使う道具

本回で新しく導入するものはありません。 第2回から第17回で入れたものを、ひとつの事案の上でつなぎます。

既習どこで本回での役割
NetworkPolicy default-deny-all と個別許可第3回隔離の土台になります。 全 Pod に効く拒否がすでに敷いてあるので、隔離のために新しいポリシーを書く必要がありません
automountServiceAccountToken: false第4回攻撃者の横展開を止めます。 本回で実際に「トークンが無い」ところまで確認します
PSA enforce: restricted第8回調査の障害にもなります。 kubectl debug を拒否するのがこれです
Secret 管理(etcd 暗号化 / SealedSecrets / ExternalSecrets)第9回本回で限界が見えます。 envFrom で環境変数にした時点で、コンテナに入られたら平文で読めます
Cilium と Hubble第11回隔離の裏づけに使います。 ただしフローの保持は約 1 分です
Falco のカスタムルール 2 本第15回第17回検知の主役です。 シェル起動と書き込み阻止が、1 つの攻撃行為でつながって発火します
falcosidekick から Loki への配線第17回5 台の Falco を串刺しにします。 ただし送信に失敗したアラートは戻りません
Audit Policy を入れて撤去した第16回本回でその代償が実害になります。 そして最後に、最小構成で入れ直します

第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)
    第16回: 監査ログ設計・分析 + 攻撃フェーズ特定(D6)
    第17回: 多面的脅威検知の体系 + コンテナ不変性 + アラート連携(D6)

第7部 インシデント対応・総括
  ★ 第18回: セキュリティインシデント対応 DAIR 実戦  ← 今ここ
    第19回: CKS 試験直前対策 + 第3巻完走宣言 + Kubestronaut への道

この回のゴール

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

  • DAIR(検知 → 分析 → 隔離 → 復旧 → 振り返り)を、自分のクラスタで最後まで実施できる
  • どの証拠がどれだけの時間残るかを把握したうえで、調査の順番を決められる
  • kubectl exec の実行者を記録できるのは監査ログだけであることを、3 つの経路を当たって説明できる
  • ラベル 1 つで Pod を隔離できる(足すのではなく書き換える)
  • 隔離が効くまでに時間差があること・ホストからの通信は切れないことを説明できる
  • PSA restricted の下で kubectl debug を通すプロファイルを書ける
  • 復旧に rollout restart が不要である理由を説明できる
  • Postmortem で出た改善策を、実際にクラスタへ適用できる

本回の起点

起点は第17回の完了状態です。Falco と falcosidekick は稼働しており、監査ログは撤去済みです。

項目
ノード5/5 Ready・Kubernetes v1.36.3・containerd v2.2.6・AlmaLinux 10.2
Pod117 個・異常 016 namespace
FalcoDaemonSet 5/5・falcosidekick 1/1・Helm falco REVISION 14 deployed
Falco のカスタムルール2 本Shell spawned in fanclub-backend pod / Blocked write in immutable container
falcosidekick の出力先Loki のみ(Alertmanager 出力は第17回で撤去)
fanclubbackend 0.4.1 × 3 / frontend 1.0.0 × 2 / postgres:18 / busybox:1.37
HTTPShttps://fanclub.local/api/members200・会員 3 名
ArgoCDfanclub-api-prodSynced / Healthy
Longhorn4 ボリューム attached healthy
監査ログ撤去済みkube-apiserver.yamlaudit フラグ 0 件)
alma-proxy の whitelist44 行本回も 1 行も足しません・6 回連続です)

インシデント対応の型 —— DAIR

セキュリティインシデントは、思いついた順に手を動かすと、証拠を壊すか、被害を広げるか、その両方になります。順番を決めておくのが DAIR です。

フェーズ目的本シリーズで使う道具本回で当たる壁
Detect(検知)異常に気づくFalco の自作ルール(第15回第17回)+ falcosidekick から Loki送信に失敗したアラートは戻らない
Analyze(分析)何が・誰が・どこまでLoki の LogQL / kubectl debug / Hubble「誰が」に到達できない。 Hubble は約 1 分で消える
Isolate(隔離)被害拡大の停止と証拠保全ラベル書き換え + 既存の default-deny-all第3回ラベルを足しても効かない。遮断まで時間差がある
Recover(復旧)正常状態へ戻すDeployment の自動補充隔離した時点ですでに始まっている
Postmortem(振り返り)再発防止Audit Policy(第16回)/ Secret のマウント方式(第9回)/ GitOps

この型で押さえる 3 つの原則

  • 証拠保全は復旧より先です。 Pod を消すと、その中にしかない痕跡は戻りません
  • 隔離と削除は別です。 隔離は「動かしたまま切り離す」ことで、削除ではありません
  • Postmortem は仕組みの改善に向けます。 個人の責任追及に向けると、次から報告が上がらなくなり、検知が遅れます

1 つ目の原則は、精神論ではありません。「Pod の中にしかない証拠がある」という事実から導かれます。 その根拠は後半の「証拠には寿命がある」で数字として示します。

DAIR の 5 フェーズごとに、第15〜17回で積み上げた効く道具と、実機で行き止まった場所を上下 2 段で対比した図。Analyze と Postmortem に断線の矢印が引かれている
図1:DAIR の 5 フェーズと、効く道具・行き止まった場所

本回の全体像です。上段が「効く道具」、下段が「行き止まる場所」で、フェーズごとに対になっています。Analyze と Postmortem の 2 か所に、上段から下段へ落ちる線が引かれています——道具はあるのに届かない、という箇所です。

本回は攻撃を自分で再現します。

実運用では、対応を始める前に「これは訓練ではない」ことを関係者へ共有する手順が入ります。演習では省きますが、本番の対応で最初にやるのは、対応中であることの共有です。

今回のシナリオ

Grafana のダッシュボードに、fanclub-backend の Pod でシェルが起動したというアラートが出ています。第15回で自分が書いたルール Shell spawned in fanclub-backend pod が発火したものです。この Pod は Payara Micro が 1 プロセス動くだけの構成で、正規の運用でシェルが起動することはありません。

アラートに気づく経路は 1 本しかない

本ラボの falcosidekick は Loki だけに送っています(第17回)。Alertmanager への出力は、「送信は 200 で成功しても受け皿が null しかなく誰にも届かない」ことを確認したうえで撤去しました。したがって気づく経路は次の 2 つだけです。

  • Grafana の Explore で {rule="Shell spawned in fanclub-backend pod"} を引く
  • 第17回で入った falcosidekick の Loki ダッシュボード(ConfigMap falcosidekick-loki-dashboard-grafana)を開く

誰かが見に行かなければ気づけない状態である、という点も本回の論点です。Postmortem で扱います。

最初に持つべき 6 つの疑問

#疑問答える場所結果
1何が起きたのかやってみよう①追える
2誰がやったのかやってみよう①行き止まる
3どこまで持って行かれたのかやってみよう①追える
4止めるにはどうするかやってみよう②ラベル 1 つ
5サービスをどう戻すかやってみよう②の後半すでに戻っている
6次に同じことが起きたら、今度は追えるのかやってみよう③追えるようにする

ラボでの再現方法

本書では kubectl exec で意図的にシェルを起動し、偵察・認証情報の窃取・書き込みの 3 段階を再現します。実際の侵入経路(アプリケーションの脆弱性など)は扱いません。

この再現手順は、自分が管理しているラボ環境でのみ実行してください。

他者が運用するクラスタで同じ操作を行うと、意図せず本物のインシデント対応を発動させることになります。

もう 1 点、演習を始める前に決めておくことがあります。再現行為そのものも Falco に記録されます。 あとでタイムラインを引くときに「これは自分の操作」と区別できるよう、開始時刻を控えておきます。

やってみよう①:攻撃を再現して Falco で捕まえる(Detect)

攻撃を再現し、Falco が何を捕らえ、何を捕らえていないかを確認します。

ステップ1:対象 Pod を選び、開始時刻を控える

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

$ POD=$(kubectl get pods -n fanclub -l app=fanclub-backend -o jsonpath='{.items[0].metadata.name}')
$ echo $POD
$ date '+%H:%M:%S'

実行結果:

fanclub-backend-56875d65c9-4fdf8
08:26:28

この時刻を控えます。 ここから先に出るアラートのうち、この時刻以降のものは自分の操作です。

ステップ2:偵察 —— 誰として動いているかを調べる

攻撃者がコンテナ内に入って最初にすることは、自分の権限の確認です。

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

$ kubectl exec -n fanclub $POD -c backend -- sh -c 'id; whoami'

実行結果:

uid=10001(appuser) gid=10001(appuser) groups=10001(appuser)
appuser

root ではありません。 第1巻第15回で入れ、第6回で確認した runAsUser: 10001 / runAsNonRoot: true が効いています。攻撃者はこの時点で、ホストへの脱出に使える権限を持っていないことを知ります。

ステップ3:認証情報を探す —— ここが実害になる

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

$ kubectl exec -n fanclub $POD -c backend -- sh -c 'env | grep -i -E "pass|secret|token"'

実行結果:

DB_PASSWORD=apppassword

データベースの認証情報が平文で読めます。 第9回で etcd 暗号化・SealedSecrets・ExternalSecrets まで積み上げたにもかかわらず、です。理由は Deployment の定義にあります。

envFrom:
- configMapRef:
    name: fanclub-config
- secretRef:
    name: fanclub-db-secret

Secret から来ていても、envFrom で環境変数になった時点で、コンテナの中では平文です。 etcd の中で暗号化されていることも、Git に平文で入っていないことも、コンテナに入られたあとには効きません。 Postmortem の改善アクション候補 1 号です。

上の値は本書ラボの既定値なので、そのまま載せています。

実運用では、調査で得た認証情報をチケットやチャットに貼らないでください。 貼った時点で、その記録の閲覧権を持つ全員に漏れます。

ステップ4:横展開を試みる —— ここは防げている

攻撃者の次の狙いは、Kubernetes API へのアクセスです。Pod には既定で ServiceAccount のトークンがマウントされます。

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

$ kubectl exec -n fanclub $POD -c backend -- sh -c 'ls -la /var/run/secrets/kubernetes.io/serviceaccount/'

実行結果:

ls: cannot access '/var/run/secrets/kubernetes.io/serviceaccount/': No such file or directory
command terminated with exit code 2

ディレクトリごと存在しません。 第4回で入れた automountServiceAccountToken: false が効いており、攻撃者はこの Pod を踏み台にしてクラスタ全体へ広げられません。 被害はこの Pod と、そこから届く範囲(データベース)に限定されます。

ステップ5:痕跡を残そうとする —— ここも防げている

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

$ kubectl exec -n fanclub $POD -c backend -- sh -c 'touch /evil.txt'

実行結果:

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

第17回で読み直した readOnlyRootFilesystem: true です。

ステップ6:Falco が何を捕らえたかを見る

Falco は DaemonSet なので 5 台に分かれています。対象 Pod が載っているノードを先に特定します。

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

$ NODE=$(kubectl get pod -n fanclub $POD -o jsonpath='{.spec.nodeName}')
$ FALCO=$(kubectl get pods -n falco -l app.kubernetes.io/name=falco -o jsonpath="{range .items[?(@.spec.nodeName=='$NODE')]}{.metadata.name}{end}")
$ echo "$NODE / $FALCO"
$ kubectl logs -n falco $FALCO -c falco --since=10m | grep 'Shell spawned'

実行結果(ノード名と Falco Pod 名):

k8s-wl-02 / falco-q84r4

アラートは JSON で出ます。読みやすいように output フィールドだけを並べると、次の 5 件が出ていました(時刻は UTC)。

23:21:26.698640981: Warning fanclub-backend Pod でシェル起動 (user=<NA> uid=10001 proc=sh exe=/bin/sh cmd=sh -c echo sidecar; tail -F /var/log/app/app.log 2>/dev/null || sleep infinity tty=0 container=log-shipper image=docker.io/library/busybox:1.37 pod=fanclub-backend-56875d65c9-4fdf8 ns=fanclub)
23:26:28.183578183: Warning fanclub-backend Pod でシェル起動 (user=appuser uid=10001 proc=sh exe=/bin/busybox cmd=sh -c id; whoami tty=0 container=backend image=k8s-registry:5000/fanclub-backend:0.4.1 pod=fanclub-backend-56875d65c9-4fdf8 ns=fanclub)
23:26:31.321475930: Warning fanclub-backend Pod でシェル起動 (user=appuser uid=10001 proc=sh exe=/bin/busybox cmd=sh -c env | grep -i -E "pass|secret|token" tty=0 container=backend ...)
23:26:34.444435679: Warning fanclub-backend Pod でシェル起動 (user=appuser uid=10001 proc=sh exe=/bin/busybox cmd=sh -c ls -la /var/run/secrets/kubernetes.io/serviceaccount/ tty=0 container=backend ...)
23:26:37.607681052: Warning fanclub-backend Pod でシェル起動 (user=appuser uid=10001 proc=sh exe=/bin/busybox cmd=sh -c touch /evil.txt tty=0 container=backend ...)

書き込み阻止のルールも見ます。

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

$ kubectl logs -n falco $FALCO -c falco --since=10m | grep '不変コンテナ'

実行結果(抜粋):

23:26:37.611950648: Warning 不変コンテナへの書き込みが阻止された (proc=touch file=/evil.txt res=EROFS rawres=-30 fdnum=-1 container=backend pod=fanclub-backend-56875d65c9-4fdf8 ns=fanclub)

1 件目は攻撃ではありません。

23:21:26cmd=sh -c echo sidecar; tail -F /var/log/app/app.log ... は、log-shipper サイドカーが起動したときの正規のシェルです。カスタムルールが fanclub-backend Pod のすべてのコンテナを対象にしているので、Pod が起動するたびに 1 件出ます。アラートが出ていること自体は、攻撃を意味しません。 控えておいた開始時刻(08:26:28 JST = 23:26:28 UTC)より前のものは、これで切り分けられます。

対象 Pod が載っていないノードの Falco を見ても、何も出ません。

「アラートが無い」と読む前に、見ているノードが合っているかを確認します。-l app.kubernetes.io/name=falco --prefix を付ければ 5 台分をまとめて取れますが、ほかの Pod のノイズも混ざって流れてきます。まとめて見るなら、次の節の Loki のほうが扱いやすくなります。

ここで確認する 3 つのこと

1 つ目——2 本のルールが連続して発火します。 書き込みを試みた瞬間に、第15回で書いたシェル検知(23:26:37.607681052)と、第17回で書いた書き込み阻止の検知(23:26:37.611950648)が、4.27 ミリ秒の差で並びます。 別々の回で別々の目的で書いたルールが、1 つの攻撃行為でつながって見えます。(この差はミリ秒台であることは変わりませんが、値そのものは実行のたびに変わります。)

2 つ目——tty=0 でも検知できています。 Falco の既定ルール Terminal shell in containerproc.tty != 0 を要求するため、kubectl exec -- sh -c のような非対話実行を取りこぼします(第15回で実測)。本ラボのカスタムルールには tty の条件を入れていないので、両方を捕らえます。

3 つ目——Falco のタイムスタンプは UTC です。 出力の 23:26:28 は、日本時間の 08:26:28 です。タイムラインを引くときに 9 時間ずれるので、最初に気づいておきます。

Analyze その 1 —— Loki で全体像を組む

5 台に分かれた Falco を 1 か所で見るために、第17回で falcosidekick から Loki への配線を入れました。本回がその配線を「使う」最初の回です。

ステップ1:Loki へ到達する

Loki の Service は ClusterIP なので、k8s-ops からは直接届きません。ポートフォワードします。

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

$ kubectl port-forward -n monitoring svc/loki 3100:3100

ステップ2:どんなルールが記録されているかを見る

実行コマンド(k8s-ops 上・別の端末):

$ curl -s --noproxy '*' http://127.0.0.1:3100/loki/api/v1/label/rule/values

実行結果:

{"status":"success","data":["Blocked write in immutable container","Contact K8S API Server From Container","Read sensitive file untrusted","Redirect STDOUT/STDIN to Network Connection in Container","Shell spawned in fanclub-backend pod"]}

自分で書いた 2 本のほかに、既定ルールが 3 本並んでいます。 Contact K8S API Server From Container は fluent-bit が API Server へ接続するたびに出るもので、常時鳴り続けているノイズです。調査では rule ラベルで絞り込むことになります。

ステップ3:対象のアラートだけを取り出す

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

$ curl -s --noproxy '*' -G http://127.0.0.1:3100/loki/api/v1/query_range --data-urlencode 'query={rule="Shell spawned in fanclub-backend pod"}' --data-urlencode 'limit=10' | jq '[.data.result[].values[]] | length'

実行結果:

4

攻撃分の 4 件が取れました。 jq を外せば、Falco のログと同じ本文が JSON で返ります。ラベルには hostname も入っているので、5 台のうちどこで起きたかも同時に分かります。

falcosidekick が付けるラベル

falcosidekick は、Falco の output_fields をそのまま Loki のラベルにします。 使えるラベルは次のとおりです。

ラベル用途
ruleルール名。調査の起点になる
priorityWarning / Notice など
hostnameどのノードで起きたか。 やってみよう① でノードを特定する手間が、ここでは要らない
k8s_ns_name / k8s_pod_namenamespace と Pod 名
source / tagssyscall / ルールに付けたタグ

5 台を串刺しにできることが、Loki を入れた価値です。ノードを間違えて「何も出ない」と読む事故が起きません。

件数を数えるなら instant query を使う

ルールごとの件数をまとめて知りたくなります。ここで query_range を使うと、取りこぼします。

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

$ curl -s --noproxy '*' -G http://127.0.0.1:3100/loki/api/v1/query --data-urlencode 'query=sum by (rule) (count_over_time({rule=~".+"}[1h]))' | jq -c '.data.result[] | {rule:.metric.rule, v:.value}'

実行結果:

{"rule":"Blocked write in immutable container","v":[1785713256.897,"1"]}
{"rule":"Contact K8S API Server From Container","v":[1785713256.897,"12"]}
{"rule":"Shell spawned in fanclub-backend pod","v":[1785713256.897,"4"]}

同じクエリを query_rangestep=3600 で投げると、ルールが落ちることがあります。

query_range評価点ごとに [1h] の窓を評価するので、評価点が粗いと、その瞬間の窓にサンプルが無いストリームが結果から落ちます。 本書の実測では step=3600 で 3 ルールに減り、Shell spawnedBlocked write も含まれませんでした。step=60 にすれば 5 ルールすべて返ります。

ただし、これが起きるのは対象がまばらなときだけです。 別の検証では、Contact K8S API Server From Container が 1 時間で 113,534 件・Any failed open in container が 312 件という密度があり、step=3600 でも 5 ルールすべて返りました。 「落ちるときと落ちないときがある」——それがいちばん厄介です。 攻撃の痕跡のように数件しか無いものほど落ちやすいので、件数を知りたいだけなら instant query(/loki/api/v1/query)を使うのが確実です。

ステップ4:アラートが落ちていないかを確認する

Loki に無いことは、起きなかったことを意味しません。 falcosidekick 自身のログで、送信に失敗した記録がないかを見ます。

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

$ kubectl logs -n falco deploy/falco-falcosidekick --tail=200 | grep -E 'ERROR|OK' | sort | uniq -c

実行結果(抜粋):

      2 2026/08/02 23:19:45 [ERROR] : Loki - Post "http://loki.monitoring:3100/loki/api/v1/push": dial tcp 10.109.113.46:3100: connect: connection refused
      4 2026/08/02 23:19:46 [ERROR] : Loki - Post "http://loki.monitoring:3100/loki/api/v1/push": dial tcp 10.109.113.46:3100: connect: connection refused
     38 2026/08/02 23:19:52 [ERROR] : Loki - Post "http://loki.monitoring:3100/loki/api/v1/push": dial tcp 10.109.113.46:3100: connect: connection refused
     22 2026/08/02 23:19:55 [ERROR] : Loki - Post "http://loki.monitoring:3100/loki/api/v1/push": dial tcp 10.109.113.46:3100: connect: connection refused

この実測では、クラスタを起動した直後の 11 秒間に ERROR が 116 件出ていました。件数は起動のたびに変わります(別の回では 23 件でした)。

falcosidekick は、送信に失敗したアラートを捨てます。リトライしません。

本ラボでは、クラスタを起動した直後の数分間(falcosidekick が Loki より先に Ready になる時間帯)に、アラートが失われることが実測で確認できます。 落ちたかどうかは、falcosidekick 自身のログでしか分かりません。

Analyze その 2 —— 「誰が」に到達できない

この節では答えが出ません。

出ないことを確かめるのが、この節の目的です。手順を間違えているわけではありません。行き止まったところが、そのまま Postmortem の改善アクションになります。

Falco のアラートには user=appuser uid=10001 と書かれています。しかしこれはコンテナの中で起動したプロセスの実行ユーザーであって、kubectl exec を叩いた人物ではありません。正規の運用者が打っても、攻撃者が打っても、同じ値になります。

では、実行者はどこに記録されているのでしょうか。考えられる経路を順に当たります。

経路 1: kube-apiserver のログ

kubectl exec は API Server への create pods/exec リクエストです。API Server 自身のログに残っていないでしょうか。

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

$ kubectl logs -n kube-system kube-apiserver-k8s-cp-01 --since=20m | grep -ci exec

実行結果:

0

0 件です。 kube-apiserver は既定の verbosity では、個々のリクエストをログに書きません。書かせるには --v を上げる必要があり、それは監査ログの代わりにはなりません(構造化されておらず、量も制御できません)。

経路 2: kubelet のログ

kubectl exec のストリームは、最終的に対象 Pod が載っているノードの kubelet が処理します。そちらには残っていないでしょうか。

実行コマンド(対象 Pod が載るノード上・root):

# journalctl -u kubelet --since '-25min' --no-pager | grep -i exec | grep -viE 'operationExecutor|VerifyController' | wc -l

実行結果:

0

除外を忘れると誤読します。

grep -i exec だけで実行すると、kubelet の operationExecutor.VerifyControllerAttachedVolume が大量に一致します(本ラボでは 25 分間で 129 件)。「見つかった」と読んでしまう典型的な罠なので、除外してから数えます。

経路 3: Kubernetes Events

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

$ kubectl get events -n fanclub --sort-by=.lastTimestamp -o json | grep -ci exec

実行結果:

0

Events はそもそも対象外です。 Events に記録されるのは、コントローラやスケジューラが起こした状態変化(スケジュール・イメージ pull・probe の失敗など)であって、ユーザーが API を叩いたことは記録されません。

経路 4: 監査ログ —— 存在しない

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

$ kubectl get pod -n kube-system -l component=kube-apiserver -o jsonpath='{.items[0].spec.containers[0].command}' | tr ',' '\n' | grep -i audit

実行結果:

(何も出力されない)

audit を含むフラグが 1 つもありません。 第16回で Audit Policy を設計・検証したあと、演習の後始末として撤去したままになっています。

結論 —— 実行者を記録できるのは監査ログだけ

情報源何が分かるか「誰が」
Falcoコンテナの中で何が起きたか(プロセス・ファイル・syscall の結果)分からない(コンテナ内の uid のみ)
kube-apiserver ログ既定では個々のリクエストを書かない分からない
kubelet ログPod のライフサイクル分からない
Eventsコントローラが起こした状態変化対象外
HubblePod 間の通信(次節)分からない
監査ログAPI への全リクエスト(user.username / sourceIPs / objectRefこれだけが分かる

API を誰が叩いたかは、API Server の監査ログにしか残りません。

Falco はコンテナの中を見る道具で、API の外側にいる人物までは見えません。層が違うものを、代わりには使えません。

第16回で監査ログを撤去した判断は、運用としては筋が通っていました。RequestResponse レベルは Secret の中身を平文でディスクへ書き、get secrets -A -o yaml 1 回で 690 万バイトを生むことを、実測で確認したうえでの撤去です。

本回で見えたのは、その判断の代償が、インシデントが起きて初めて実害になるということです。 これは「入れておけばよかった」という後知恵ではありません。何を捨てたかを覚えておき、捨てたことで追えなくなる範囲を把握しておく——それが運用の側の仕事です。Postmortem では、この range を狭める形で入れ直します。

試験での出方

CKS では「kubectl exec の実行者を特定せよ」という筋の設問に対して、Audit Policy の設定と audit.log の読み方が問われます。Falco では答えになりません。 D6 の公式文言 Investigate and identify phases of attack and bad actors within the environment のうち、bad actors に対応するのは監査ログだと覚えます。

証拠には寿命がある

もう 1 つ、調査の順番を決めるうえで欠かせない事実があります。証拠源ごとに、残る時間がまったく違います。

Hubble のフローは 1 分弱で消える

第11回で入れた Hubble は、Pod 間の通信を可視化します。インシデント調査でも使えそうに見えます。まず状態を見ます。

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

$ NODE=$(kubectl get pod -n fanclub $POD -o jsonpath='{.spec.nodeName}')
$ CILN=$(kubectl get pods -n kube-system -l k8s-app=cilium -o jsonpath="{range .items[?(@.spec.nodeName=='$NODE')]}{.metadata.name}{end}")
$ kubectl exec -n kube-system $CILN -c cilium-agent -- hubble status

実行結果:

Healthcheck (via unix:///var/run/cilium/hubble.sock): Ok
Current/Max Flows: 4,095/4,095 (100.00%)
Flows/s: 77.72

リングバッファは 4,095 フロー固定で、すでに満杯です。 毎秒 77.72 フローで割ると 約 53 秒——1 分足らずで一周します。(同じ日の別の時点では 73.48 フロー / 秒で約 56 秒でした。いずれにせよ 1 分前後です。)

Hubble は「今そこで起きていること」を見る道具です。

あとから掘り返す証拠源ではありません。インシデントに気づいてから見に行っても、その時の通信はもう消えています。 残すには、フローのエクスポート設定が要ります(本ラボでは未設定です)。

使ううえでの注意も 2 つあります。

  • k8s-ops に hubble コマンドはありません。 Cilium Pod の中の hubblekubectl exec 経由で使います
  • ノードを間違えると空を返します。 hubble observe --namespace fanclub が 0 行なのは、「通信が無い」ではなく「このノードには無い」の可能性があります

証拠の寿命の比較

証拠源どれだけ残るか本ラボでの実測「誰が」
Loki(Falco アラート)残る最古は約 10 時間前(falcosidekick を入れた第17回の演習中)。Falco Pod が何度も再起動したあとでも残っている分からない
Falco Pod のログkubectl logsPod が再起動するまでVM を停止・起動するたびに消える(本ラボでは毎回起きる)分からない
Hubble約 1 分4,095 フロー固定 / 毎秒 70 フロー前後分からない
Kubernetes Events既定 1 時間(--event-ttlそもそも exec は記録されない対象外
kube-apiserver / kubelet ログノードのログ保持に依存exec の痕跡 0 件分からない
監査ログ設定次第存在しないこれだけが分かる
Pod の中身/opt/payara/tmp など)Pod を消すまで第17回の演習で作った ok.txt が残っていた

2 行目が、Loki を入れた意味です。 Falco Pod のログだけに頼っていると、クラスタを再起動した時点で証拠が消えます。 第17回で falcosidekick から Loki への配線を入れたのは、この差を埋めるためでした。

最後の行が、隔離の順序を決めます。

Pod の中にしかない証拠は、Pod を消した瞬間に消えます。 改変されたファイルも、動いているプロセスも、メモリ上の状態も戻りません。だから「隔離が先、削除は後」になります。 これは原則を暗記する話ではなく、この表から導かれる結論です。

監査ログ・Hubble・Events・Falco Pod のログ・Pod の中身・Loki の 6 つの証拠源を、残る時間の長さでバーの幅として並べた比較図。Pod の中身から結論へ矢印が伸びている
図2:6 つの証拠源を「残る時間」の長さで並べる

バーの長さが、その証拠が残る時間です。 監査ログは存在せず、Hubble は 1 分足らずで消え、Loki だけが右端まで伸びています。下段の 2 つの箱が、その差を作ったもの——第17回で Loki への配線を入れ、第16回で監査ログを撤去した、という過去の判断そのものです。

やってみよう②:ラベル 1 つで隔離する(Isolate)

攻撃者が入っている Pod を、動かしたまま切り離します。 削除はしません。

ステップ1:効かないやり方を先に見る —— ラベルを「足す」

隔離と聞いて最初に思いつくのは、目印のラベルを付けることです。

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

$ kubectl label pod -n fanclub $POD quarantine=true

実行結果:

pod/fanclub-backend-56875d65c9-4fdf8 labeled

何が変わったかを確認します。

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

$ kubectl get pods -n fanclub -l app=fanclub-backend --no-headers | wc -l
$ FE=$(kubectl get pods -n fanclub -l app=fanclub-frontend -o jsonpath='{.items[0].metadata.name}')
$ PIP=$(kubectl get pod -n fanclub $POD -o jsonpath='{.status.podIP}')
$ kubectl exec -n fanclub $FE -- curl -s -o /dev/null -w '%{http_code}\n' --max-time 5 http://$PIP:8080/api/members

実行結果:

3
200

何も起きていません。 Pod の数は 3 のまま、通信も 200 のままです。quarantine=true は、ReplicaSet のセレクタにも NetworkPolicy の podSelector にも噛みません。 ラベルを足しただけでは、目印になるだけで隔離になりません。

ステップ2:何がラベルを見ているかを確認する

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

$ kubectl get deploy fanclub-backend -n fanclub -o jsonpath='{.spec.selector}'
$ kubectl get netpol -n fanclub --no-headers
$ kubectl get cnp -n fanclub --no-headers

実行結果:

{"matchLabels":{"app":"fanclub-backend"}}
allow-db-from-backend              app=fanclub-db         8d
allow-dns                          <none>                 8d
allow-egress-backend-to-db         app=fanclub-backend    8d
allow-egress-batch-to-db           app=fanclub-batch      8d
allow-egress-frontend-to-backend   app=fanclub-frontend   8d
allow-frontend-from-gateway        app=fanclub-frontend   8d
default-deny-all                   <none>                 8d
fanclub-backend-l7   5d    True

この Pod の app=fanclub-backend というラベルを、4 種類のリソースが見ています。

見ているものセレクタラベルを書き換えるとどうなるか
Deployment / ReplicaSetapp=fanclub-backend管理下から外れる → Deployment が新しい Pod を補充する
NetworkPolicy(個別許可 6 本)app=fanclub-backend など許可から外れる
CiliumNetworkPolicy fanclub-backend-l7endpointSelector: app=fanclub-backendL7 の許可からも外れる
NetworkPolicy default-deny-allpodSelector: {}全 Pod残る → 許可が消えた Pod は全遮断に落ちる

第3回default-deny-all を敷いてあるので、隔離のために新しい NetworkPolicy を書く必要がありません。

ラベルを 1 つ書き換えるだけで、Deployment の管理からも、通信の許可からも、同時に外れます。 「拒否を既定にしておく」設計が、インシデント対応の場面でそのまま効きます。

ステップ3:効くやり方 —— ラベルを「書き換える」

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

$ date '+%H:%M:%S'
$ kubectl label pod -n fanclub $POD app=fanclub-backend-quarantined --overwrite

実行結果:

08:28:51
pod/fanclub-backend-56875d65c9-4fdf8 labeled

2 つのやり方の違いは --overwrite の有無に出ます。

kubectl label は、既存のラベルを黙って上書きしません。 上書きするには --overwrite が要ります。このフラグを打つかどうかが、「ラベルを足す」と「ラベルを書き換える」の分かれ目であり、隔離が効くかどうかの分かれ目でもあります。

ステップ4:Deployment 側で何が起きたかを見る

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

$ kubectl get pods -n fanclub -l 'app in (fanclub-backend,fanclub-backend-quarantined)' --show-labels --no-headers
$ kubectl get deploy fanclub-backend -n fanclub --no-headers

実行結果(約 3 分後):

fanclub-backend-56875d65c9-4fdf8   1/2   Running   8    25h     app=fanclub-backend-quarantined,pod-template-hash=56875d65c9,quarantine=true
fanclub-backend-56875d65c9-lzbl4   2/2   Running   8    31h     app=fanclub-backend,pod-template-hash=56875d65c9
fanclub-backend-56875d65c9-q78xg   2/2   Running   12   44h     app=fanclub-backend,pod-template-hash=56875d65c9
fanclub-backend-56875d65c9-wpj2g   2/2   Running   0    3m21s   app=fanclub-backend,pod-template-hash=56875d65c9
fanclub-backend   3/3   3     3     8d

Deployment はラベルを書き換えた直後に「1 台足りない」と判断し、新しい Pod(wpj2g)を作り始めます。 この実測では 3 分 21 秒で 2/2 Running になりました(Payara Micro の起動時間です)。隔離した Pod(4fdf8)は、Deployment から見えない場所で動き続けます。ラベルの列を見ると、app だけが違い、pod-template-hash は同じままであることも分かります。

ステップ5:遮断は即時ではない

ラベルを書き換えたら、通信が実際に止まったかを確認します。

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

$ kubectl exec -n fanclub $FE -- curl -s -o /dev/null -w '%{http_code}\n' --max-time 6 http://$PIP:8080/api/members
$ kubectl exec -n fanclub $POD -c backend -- sh -c 'timeout 5 nc -z fanclub-db 5432 && echo REACHABLE || echo BLOCKED'

実行結果(ラベル書き換えからの経過時間つきで繰り返した実測):

  +0s  : 200
  +5s  : 200
  +11s : 200
  +16s : 200
  +21s : 200
  +26s : 000
  ==> 遮断確認: +26 秒

000 は curl がタイムアウトして応答を得られなかったことを示します。同じ手順を 3 回繰り返すと、遮断されるまでの時間はばらつきました。

最後に応答があった時点遮断を確認した時点
1 回目+21 秒(200+26 秒
2 回目+5 秒(200+9 秒
3 回目+22 秒(404+26 秒

十数秒から 30 秒程度と考えておくのが実際的です。3 回目の応答が 404 なのは、対象にした Pod が起動 1 分ほどの新しい Pod で、Payara がまだアプリケーションを展開していなかったためです。遮断前の応答が常に 200 とは限りません。

「ラベルを変えた」は「その瞬間に切れた」ではありません。

Cilium が security identity を計算し直し、全ノードへ配るまでのラグがあります。切れたことを確認してから次へ進みます。 確認せずに証拠保全へ移ると、保全している間も攻撃者が通信できている可能性が残ります。

ステップ6:隔離を Hubble で裏づける

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

$ kubectl exec -n kube-system $CILN -c cilium-agent -- hubble observe --verdict DROPPED --namespace fanclub --last 10

実行結果(抜粋):

Aug  2 23:34:25.857: fanclub/fanclub-backend-56875d65c9-4fdf8:51350 (ID:11022) <> fanclub/fanclub-db-0:5432 (ID:27790) policy-verdict:none TRAFFIC_DIRECTION_UNKNOWN DENIED (TCP Flags: SYN)
Aug  2 23:34:25.857: fanclub/fanclub-backend-56875d65c9-4fdf8:51350 (ID:11022) <> fanclub/fanclub-db-0:5432 (ID:27790) Policy denied DROPPED (TCP Flags: SYN)
Aug  2 23:34:25.922: fanclub/fanclub-backend-56875d65c9-4fdf8:51372 (ID:11022) <> fanclub/fanclub-db-0:5432 (ID:27790) policy-verdict:none TRAFFIC_DIRECTION_UNKNOWN DENIED (TCP Flags: SYN)
Aug  2 23:34:25.922: fanclub/fanclub-backend-56875d65c9-4fdf8:51372 (ID:11022) <> fanclub/fanclub-db-0:5432 (ID:27790) Policy denied DROPPED (TCP Flags: SYN)

Policy denied DROPPED が、隔離が効いている証拠です。 隔離した Pod がデータベースへ接続しようとして、SYN の段階で落とされています。この Pod の identity(ID:11022)は、隔離前とは別の番号です。ラベルが変われば Cilium の identity が変わり、それに紐づく許可がすべて外れます。

identity の番号そのものは環境や再作成のたびに変わるので、値ではなく「変わったこと」を見ます。

ステップ7:隔離の限界を 2 つ確認する

限界その 1 —— ホストからの通信は切れません。

同じ Hubble の出力の中に、次のような行が混ざります。

Aug  2 23:34:18.698: 10.244.4.30:47022 (host) -> fanclub/fanclub-backend-56875d65c9-4fdf8:8080 (ID:11022) policy-verdict:none TRAFFIC_DIRECTION_UNKNOWN ALLOWED (TCP Flags: SYN)
Aug  2 23:34:18.698: 10.244.4.30:47022 (host) -> fanclub/fanclub-backend-56875d65c9-4fdf8:8080 (ID:11022) to-endpoint FORWARDED (TCP Flags: SYN)

送信元が (host) と表示され、判定は ALLOWED です。

kubelet の probe はノード自身から来るので、NetworkPolicy の対象外です。 「全遮断した」と言っても、ノードからの経路は残っています。 隔離は「Pod 間の通信を止める」ことであって、「そのコンテナを外界から完全に切り離す」ことではありません。

限界その 2 —— 隔離した Pod は READY が欠けます。

データベースへ到達できなくなった結果、backend コンテナの readinessProbe が失敗して 1/2 になります。プロセスは動いたまま、READY だけが落ちた状態です(Ready=False ContainersNotReady)。壊れたのではなく、隔離の副作用です。 ここで「Pod が壊れた」と判断して削除すると、証拠が消えます。

1/2 の分母が何かも押さえておきます。 内訳はこうです。

$ kubectl get pod -n fanclub $POD -o jsonpath='{range .status.containerStatuses[*]}{.name}={.ready}{"\n"}{end}'
$ kubectl get pod -n fanclub $POD -o jsonpath='{range .status.initContainerStatuses[*]}{.name}={.ready}{"\n"}{end}'

実行結果:

backend=false
wait-for-db=true
log-shipper=true

分母の 2 は「通常コンテナ backend 1 つ + native sidecar の log-shipper 1 つ」です(log-shipperinitContainersrestartPolicy: Always で置かれた sidecar なので、READY の数に入ります)。完了済みの init コンテナ wait-for-db は数に入りません。落ちているのは backend のほうで、log-shipper は生きています——log-shippertail -F /var/log/app/app.log を回すだけでデータベースに接続しないので、遮断の影響を受けません。

ステップ8:サービスが継続していることを確認する

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

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

実行結果:

200

利用者から見えるサービスは止まっていません。 3 台のうち 1 台を切り離しても、残る 2 台と補充される 1 台で処理が続きます。冗長化してあるからこそ、落ち着いて隔離できます。

証拠保全 —— kubectl debug は 2 段階で止まる

通信を止めたので、次は証拠を取ります。ここで、自分がかけたハードニングが調査の障害になります。

ステップ1:すぐに取れるものから取る

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

$ mkdir -p ~/forensics-18 && cd ~/forensics-18
$ kubectl get pod -n fanclub $POD -o yaml > pod-$POD.yaml
$ kubectl logs -n fanclub $POD -c backend --tail=2000 > log-backend.txt
$ kubectl logs -n fanclub $POD -c log-shipper --tail=2000 > log-shipper.txt
$ kubectl exec -n fanclub $POD -c backend -- ps -o pid,user,args > ps-snapshot.txt
$ ls -l

実行結果:

合計 232
-rw-r--r--. 1 developer developer 213347  8月  3 08:34 log-backend.txt
-rw-r--r--. 1 developer developer      8  8月  3 08:34 log-shipper.txt
-rw-r--r--. 1 developer developer   8453  8月  3 08:34 pod-fanclub-backend-56875d65c9-4fdf8.yaml
-rw-r--r--. 1 developer developer    260  8月  3 08:34 ps-snapshot.txt

log-shipper.txt8 バイトしかありません。サイドカーは起動時に echo sidecar を出したきりなので、これで正常です。ファイルが空に近いことと、取得に失敗したことは別なので、サイズだけで判断しません。

ステップ2:ファイルを取り出す

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

$ kubectl cp -n fanclub -c backend $POD:/opt/payara/tmp/postboot.txt ./evidence-postboot.txt
$ ls -l evidence-postboot.txt

実行結果:

tar: removing leading '/' from member names
-rw-r--r--. 1 developer developer 346  8月  3 08:34 evidence-postboot.txt

kubectl cp は、対象コンテナの中の tar を使って動きます。 警告文が tar のものであることが、その証拠です。本書ラボの backend(alpine ベース)にも frontend(nginx-alpine)にも /bin/tar があるので、どちらからも取り出せます。ただし tar を持たない最小イメージでは失敗します。 その場合はエフェメラルコンテナ経由になります。

/opt/payara/tmp は emptyDir なので、readOnlyRootFilesystem の外側にあり書き込めます。

「不変コンテナ」でも、書ける場所は残っています。 攻撃者が痕跡を残せる場所であり、調査で最初に見る場所でもあります。ここに置かれたファイルは Pod を消すまでしか存在しません第17回の演習で書いたファイルが、この時点でも残っていました)。

段階 1: kubectl debug をそのまま打つ —— PSA が拒否する

コンテナの中をもっと詳しく見るには、エフェメラルコンテナを立てます。

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

$ kubectl debug -n fanclub $POD --image=busybox:1.37 --target=backend --container=forensic-probe -- sleep 3600

実行結果:

Targeting container "backend". If you don't see processes from this container it may be because the container runtime doesn't support this feature.
--profile=legacy is deprecated and will be removed in the future. It is recommended to explicitly specify a profile, for example "--profile=general".
Error from server (Forbidden): pods "fanclub-backend-56875d65c9-4fdf8" is forbidden: violates PodSecurity "restricted:latest": allowPrivilegeEscalation != false (container "forensic-probe" must set securityContext.allowPrivilegeEscalation=false), unrestricted capabilities (container "forensic-probe" must set securityContext.capabilities.drop=["ALL"]), runAsNonRoot != true (pod or container "forensic-probe" must set securityContext.runAsNonRoot=true)

第8回fanclub namespace に入れた PSA enforce: restricted が、エフェメラルコンテナにも同じ基準を要求します。 busybox の素の設定では allowPrivilegeEscalationcapabilities.droprunAsNonRoot も満たしません。

段階 2: --profile=restricted —— 通ったのに動かない

kubectl debug には、securityContext を自動で埋めるプロファイルがあります。

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

$ kubectl debug -n fanclub $POD --image=busybox:1.37 --target=backend --container=fp-restricted --profile=restricted -- sleep 600

ここが本回でいちばん分かりにくい箇所です。エラーは出ません。

コマンドは正常に終わります。だから「成功した」と思い込んで kubectl exec を打ち、container not found で初めて何かおかしいと気づきます。kubectl debug を打ったら、必ず status を見ます。

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

$ kubectl get pod -n fanclub $POD -o json | jq -r '.status.ephemeralContainerStatuses[] | "\(.name)\n  reason  = \(.state.waiting.reason // "-")\n  message = \(.state.waiting.message // "-")\n  ready   = \(.ready)"'

実行結果:

fp-restricted
  reason  = CreateContainerConfigError
  message = container has runAsNonRoot and image will run as root (pod: "fanclub-backend-56875d65c9-4fdf8_fanclub(5310e79d-98d3-4a39-aa66-c222e1680aa9)", container: fp-restricted)
  ready   = false

何が入れられたのかを見ると、原因が分かります。

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

$ kubectl get pod -n fanclub $POD -o json | grep -A12 '"name": "fp-restricted"' | head -14

実行結果(抜粋):

                "name": "fp-restricted",
                "resources": {},
                "securityContext": {
                    "allowPrivilegeEscalation": false,
                    "capabilities": {
                        "drop": [
                            "ALL"
                        ]
                    },
                    "runAsNonRoot": true,
                    "seccompProfile": {
                        "type": "RuntimeDefault"
                    }

--profile=restrictedrunAsNonRoot: true を付けますが、runAsUser を指定しません。 busybox イメージの既定 USER は root なので、kubelet が起動時に弾きます。 admission(PSA)は通っているのに動かない、というもっとも分かりにくい失敗の仕方です。

エフェメラルコンテナは、Pod を残したまま取り除けません。

kubectl edit でも消えません。失敗したものも Pod の spec に残り続けます。 再挑戦は --container に別の名前を付けて行います。消したければ Pod ごと削除するしかありませんが、今は証拠保全中なので Pod は消せません。 名前の付け方に気をつける理由がここにあります。

段階 3: --customrunAsUser を明示する

プロファイルの一部を自分で書いて渡します。ファイル名は forensic-profile.json とします(全量)。

{
  "securityContext": {
    "runAsNonRoot": true,
    "runAsUser": 10001,
    "runAsGroup": 10001,
    "allowPrivilegeEscalation": false,
    "capabilities": { "drop": ["ALL"] },
    "seccompProfile": { "type": "RuntimeDefault" }
  }
}

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

$ kubectl debug -n fanclub $POD --image=busybox:1.37 --target=backend --container=fp-custom --custom=./forensic-profile.json -- sleep 900

実行結果:

Targeting container "backend". If you don't see processes from this container it may be because the container runtime doesn't support this feature.
--profile=legacy is deprecated and will be removed in the future. It is recommended to explicitly specify a profile, for example "--profile=general".

--profile を明示していないので legacy が既定になり、非推奨の警告が出ます。動作には影響しませんが、警告を消したい場合は --profile=general などを併記します。

エフェメラルコンテナの ready は、起動しても false のままです。

readinessProbe を持たないためで、異常ではありません。ready で成否を判断せず、kubectl exec が通るかどうかで確かめます。

段階 4: 対象コンテナの中身を読む

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

$ kubectl exec -n fanclub $POD -c fp-custom -- ps -o pid,user,args
$ kubectl exec -n fanclub $POD -c fp-custom -- ls /proc/1/root/

実行結果(抜粋):

PID   USER     COMMAND
    1 10001    java -XX:MaxRAMPercentage=75.0 -Djava.io.tmpdir=/opt/payara/tmp -jar /opt/payara/payara-micro.jar --postbootcommandfile /opt/payara/tmp/postboot.txt --port 8080 --rootDir /opt/payara/tmp
  184 10001    sleep 900
  192 10001    ps -o pid,user,args
__cacert_entrypoint.sh
bin
dev
etc
home
lib
media
mnt

PID 1 に対象コンテナの Payara Micro が見えています。 sleep 900 がエフェメラルコンテナ自身のプロセスです。

--target でプロセス名前空間を共有しているので、/proc/1/root/ から対象コンテナのファイルシステムをそのまま読めます。 つまり、対象コンテナに調査ツールが入っていなくても、エフェメラルコンテナ側で持ち込めます。 これがエフェメラルコンテナの本質的な価値です。

ハードニングは、攻撃者だけを止めるものではありません。

インシデントの最中に、自分の調査手段も止めます。 だから「フォレンジック用のプロファイルを事前に用意しておく」ことが、Postmortem の改善アクションになります。PSA と格闘するのは、事故の最中にやることではありません。

kubectl debug の 3 段階を、PSA admission と kubelet 起動時の 2 つの関門で整理した図。段階 2 は admission を通過するのに kubelet で CreateContainerConfigError になる
図3:kubectl debug が 3 段階で詰まる(PSA と kubelet の 2 つの関門)

関門が 2 つあることが、この図の要点です。左が admission(PSA)、右が kubelet の起動時チェックで、段階 2 だけが「左は通って右で止まる」という形になっています。エラーが出るのは左だけ——右で止まったことは、status を見ないと分かりません。

調査そのものも検知される

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

$ kubectl logs -n falco $FALCO -c falco --since=5m | grep -o '"rule":"[^"]*"' | sort | uniq -c

実行結果:

      3 "rule":"Redirect STDOUT/STDIN to Network Connection in Container"
      2 "rule":"Shell spawned in fanclub-backend pod"

保全作業中に打った kubectl exec が、追加でアラートを発火させています。 やってみよう① のステップ 1 で開始時刻を控えた理由がここにあります。タイムラインを引くときに「攻撃者の操作」と「対応者の操作」を区別できなければ、記録は使えません。

Recover —— 復旧はもう始まっている

証拠を取り終えたので、サービスの状態を確認します。

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

$ kubectl rollout status deployment/fanclub-backend -n fanclub --timeout=300s
$ kubectl get deploy fanclub-backend -n fanclub --no-headers
$ kubectl get pods -n fanclub -l app=fanclub-backend --no-headers

実行結果:

deployment "fanclub-backend" successfully rolled out
fanclub-backend   3/3   3     3     8d
fanclub-backend-56875d65c9-lzbl4   2/2   Running   8    31h
fanclub-backend-56875d65c9-q78xg   2/2   Running   12   44h
fanclub-backend-56875d65c9-wpj2g   2/2   Running   0    7m29s

すでに 3/3 に戻っています。 隔離のためにラベルを書き換えた時点で、Deployment は補充を始めていました。

kubectl rollout restart は打ちません。

打つと健全な 2 台まで巻き込んで作り直すことになります。Payara Micro の起動は 1 台あたり 約 150 秒第10回で実測)なので、3 台を順に作り直せば 10 分近くかかります。1 台の補充で済むものを、3 台分の時間に引き延ばす操作です。

あわせて押さえておくことが 2 つあります。

  • Deployment の maxUnavailable: 0 / maxSurge: 1 により、補充中もサービスは落ちません(実測で HTTPS 200 が継続)
  • 隔離した Pod は Deployment の READY に数えられません。 3/3 に戻ったあとも、クラスタ上には backend の Pod が 4 つある状態です(3 つが稼働・1 つが隔離中)

調査が終わってから削除する

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

$ kubectl delete pod -n fanclub $POD

実行結果:

pod "fanclub-backend-56875d65c9-4fdf8" deleted from fanclub namespace

エフェメラルコンテナも同時に消えます。保全物は ~/forensics-18/ に取ってあるので、ここで消して構いません。 順序が「隔離 → 保全 → 削除」であることを、最後にもう一度確認します。

やってみよう③:Postmortem —— 改善策を実装する

振り返りを文書で終わらせず、クラスタに適用するところまで行います。

ステップ1:タイムラインを引く

まず、この事案で分かったことを時系列に並べます。

時刻(JST)出来事記録源
08:21:26log-shipper サイドカーの起動(攻撃ではないFalco
08:26:28シェル起動(偵察・id; whoamiFalco
08:26:31環境変数の窃取(DB_PASSWORD 流出Falco
08:26:34ServiceAccount トークンの探索(未マウントのため失敗Falco
08:26:37書き込み試行(readOnlyRootFilesystem で阻止Falco 2 本
08:28:51隔離(ラベル書き換え)対応者の操作
08:29:17 頃遮断を確認(ラベル書き換えから +26 秒Hubble
08:34証拠保全対応者の操作
実行者の特定この時点では不可

Falco の画面表示は UTC です。 上の表は日本時間に直してあります。異なるツールの記録を 1 本のタイムラインに並べるときは、タイムゾーンを最初に揃えます。

ステップ2:根本原因を 3 つの問いで整理する

問い原因関連する回
なぜ exec ができたのかfanclub namespace に対する pods/exec の権限が絞られていない第4回
なぜデータベースの認証情報を取られたのかSecret を envFrom で環境変数にしているから第9回
なぜ実行者を特定できないのか監査ログが無いから第16回

Postmortem は仕組みの改善に向けます。

「誰の操作ミスだったか」を突き止めることを目的にすると、次から報告が上がらなくなり、検知が遅れます。 上の 3 つの問いは、いずれも設計上の判断を指しており、個人を指していません。

ステップ3:改善アクションを 6 件挙げる

#アクション本回で見えた根拠
1exec に限定した最小の Audit Policy を入れる実行者を記録できるのは監査ログだけ
2Secret をファイルマウントにして env から外すenv 一発で平文になる
3Hubble のフローをエクスポートする約 1 分で消えるものは証拠にならない
4falcosidekick の送信失敗を監視する落ちた分は戻らない
5フォレンジック用のプロファイルを事前に用意する事故の最中に PSA と格闘しない
6ロードバランサのログを保全対象に加える監査ログの sourceIPs は LB の IP になる(後述)

本演習で実装するのは 1 番です。 残る 5 件は記法の提示に留め、第19回の復習項目へ回します。1 回のインシデントで全部やろうとしないのも、Postmortem の作法です。

ステップ4:exec だけを記録する最小の Audit Policy を書く

第16回では「Secret は薄く(Metadata)、RBAC は厚く(RequestResponse)」という設計を検証しました。今回入れるのは、さらに絞った 1 ルールだけです。

  • 実行者(user.username)・実行元 IP(sourceIPs)・対象 Pod(objectRef)は Metadata レベルで記録されます。 リクエスト本文は要りません
  • exec の頻度は低いので、ログ量が増えません
  • RequestResponse にすると、exec のストリーム内容まで書き込もうとします。 選びません

audit-policy-exec.yaml(全量):

apiVersion: audit.k8s.io/v1
kind: Policy
omitStages:
  - RequestReceived
rules:
  - level: Metadata
    resources:
      - group: ""
        resources: ["pods/exec", "pods/attach", "pods/portforward"]
  - level: None

最後の - level: None が要点です。 これを置くことで、exec 系以外のリクエストを一切記録しません。第16回のポリシーとの違いはここにあります。

pods/exec は subresource です。

resources"pods/exec" と書くほか、記録された JSON 側では objectRef.subresourceexec になります。絞り込むときはこのフィールドを使います。

ステップ5:3 台すべての Control Plane に適用する

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

# mkdir -p /etc/kubernetes/audit /var/log/kubernetes/audit
# cp /etc/kubernetes/manifests/kube-apiserver.yaml /root/kube-apiserver.yaml.bak-18
# cat > /etc/kubernetes/audit/audit-policy-exec.yaml <<'EOF'
apiVersion: audit.k8s.io/v1
kind: Policy
omitStages:
  - RequestReceived
rules:
  - level: Metadata
    resources:
      - group: ""
        resources: ["pods/exec", "pods/attach", "pods/portforward"]
  - level: None
EOF

退避ファイルには回ごとに別の名前を付けます。

.bak のような汎用名を使い回すと、次の演習で上書きして元の manifest を失います。 本書は 第14回.bak-14第16回.bak-16、本回の .bak-18回番号を付けて分けています。 分けておけば、戻したあとに「前の回の設定まで巻き戻していないか」を検算する手間が要りません第16回grep -c 'authentication-config\|encryption-provider-config' を打ったのがその検算です)。

ポリシーファイルを置いたら、kube-apiserver.yaml にフラグと volume を追加します。追加するフラグ(全量):

    - --audit-policy-file=/etc/kubernetes/audit/audit-policy-exec.yaml
    - --audit-log-path=/var/log/kubernetes/audit/audit.log
    - --audit-log-maxage=30
    - --audit-log-maxbackup=3
    - --audit-log-maxsize=100

あわせて volumeMountsvolumes に 2 組(ポリシーの置き場とログの置き場)を追加します(全量):

    volumeMounts:
    - mountPath: /etc/kubernetes/audit
      name: audit-policy
      readOnly: true
    - mountPath: /var/log/kubernetes/audit
      name: audit-log
      readOnly: false
  volumes:
  - hostPath:
      path: /etc/kubernetes/audit
      type: DirectoryOrCreate
    name: audit-policy
  - hostPath:
      path: /var/log/kubernetes/audit
      type: DirectoryOrCreate
    name: audit-log

Control Plane が複数あるなら、すべてに同じ設定を入れます。

1 台だけに入れると、ロードバランサが振り分けた先によって記録される・されないが変わります。 第14回の ImagePolicyWebhook で「12 回中 4 回しか拒否されない」という形で実証した落とし穴と、まったく同じ構造です。

反映は kubelet が manifest の変更を検知して自動で行います。systemctl restart kubelet は不要で、待ちます。

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

$ kubectl get pods -n kube-system -l component=kube-apiserver --no-headers

実行結果(3 台すべてに適用したあと):

kube-apiserver-k8s-cp-01   1/1   Running   0     3m47s
kube-apiserver-k8s-cp-02   1/1   Running   0     2m8s
kube-apiserver-k8s-cp-03   1/1   Running   0     31s

3 台を同時に編集しないでください。

API Server が全台同時に作り直されると、その間クラスタを操作できなくなります。1 台編集したら kube-apiserver-k8s-cp-XX1/1 Running に戻るのを待ち、それから次の 1 台へ進みます。 本ラボでは 1 台あたり 約 40 秒で戻りました(AGE 列の差が、1 台ずつ進めた間隔です)。

ステップ6:今度は「誰が」が分かるかを確かめる

同じ操作をもう一度行います。

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

$ POD=$(kubectl get pods -n fanclub -l app=fanclub-backend -o jsonpath='{.items[0].metadata.name}')
$ kubectl exec -n fanclub $POD -c backend -- sh -c 'id'

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

# grep '"subresource":"exec"' /var/log/kubernetes/audit/audit.log | tail -1 | jq '{user:.user.username, groups:.user.groups, sourceIPs:.sourceIPs, verb:.verb, resource:.objectRef.resource, subresource:.objectRef.subresource, name:.objectRef.name, ns:.objectRef.namespace, stage:.stage, ts:.requestReceivedTimestamp}'

実行結果:

{
  "user": "kubernetes-admin",
  "groups": [
    "kubeadm:cluster-admins",
    "system:authenticated"
  ],
  "sourceIPs": [
    "192.168.1.124"
  ],
  "verb": "get",
  "resource": "pods",
  "subresource": "exec",
  "name": "fanclub-backend-56875d65c9-9kj78",
  "ns": "fanclub",
  "stage": "ResponseComplete",
  "ts": "2026-08-02T23:42:54.626843Z"
}

user.username が出ました。 「Analyze その 2」で行き止まった問いに、これで答えられます。

ここで 2 つ、想定と違う点があります。

想定と違った点 1: verbcreate ではなく get

kubectl execpods/exec サブリソースへの操作なので create だと考えがちですが、実測は get です。kubectl exec は接続を SPDY / WebSocket へ昇格させる GET として飛びます。verb で絞り込むときに間違えると、記録があるのに見つけられません。 絞り込みは objectRef.subresource で行うのが確実です。

想定と違った点 2: sourceIPs は実行元ではない

記録された sourceIPs192.168.1.124 です。しかし kubectl を打ったのは k8s-ops(192.168.1.122です。

項目
監査ログの sourceIPs192.168.1.124
その IP の正体k8s-lb(HAProxy)
実際に kubectl を打ったホストk8s-ops(192.168.1.122
kubeconfig の serverhttps://k8s-lb:6443
HAProxy の設定mode tcpoption forwardfor0 件

ロードバランサ越しでは、実行元の IP が監査ログに届きません。

HAProxy は TCP パススルーで動いており、TLS を終端しません。終端しない以上、X-Forwarded-For のようなヘッダを挿入できず、API Server から見える送信元は常にロードバランサになります。 本当の実行元を知るには、HAProxy 側のログと時刻で突き合わせる必要があります。

改善アクションを 1 つ入れても、まだ届かないものが残ります。 これは失敗ではなく、次の Postmortem の入口です。「誰が」は分かるようになり、「どこから」は次の課題になりました。

時点「誰が」「どこから」
この事案の対応中不可不可
改善アクション適用後user.username が記録されるまだロードバランサに隠れている

ログ量を確かめる

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

# wc -l /var/log/kubernetes/audit/audit.log ; stat -c '%s' /var/log/kubernetes/audit/audit.log
(ここで kubectl exec を 6 回打つ)
# wc -l /var/log/kubernetes/audit/audit.log ; stat -c '%s' /var/log/kubernetes/audit/audit.log

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

+4 行 / +4678 バイト

6 回の exec が、3 台に 4 行ずつ——きれいに 3 分の 1 ずつ分かれています。 1 回の exec につき ResponseStartedResponseComplete の 2 行が出るので、6 × 2 ÷ 3 = 4 行です。1 台あたり 4,678 バイト——exec 1 回で 800 バイト弱です。第16回の厚いポリシーが get secrets 1 回で 690 万バイトを書いたのとは、規模が違います。

絶対値ではなく増分で測ります。

audit.logゼロから始まりません第16回のログを残す方針なので、同じパスにすでに数千万バイトが在ります(実測では cp-01 が 27 MB、cp-02 が 20 MB、cp-03 が 16 MB。cp-01 にはローテート済みの 100 MB もあります)。本回のポリシーは、そこへ追記します。 wc -l をそのまま打つと 17941 のような値が返り、「4 行」にはなりません。

だから前後で測って引きます。 追記されるログの量を知りたいときは、いつでもこの形が正解です——ファイルサイズは過去の履歴を含みますが、増分は今の設定だけを映します。

HA 構成では、1 台に約 3 分の 1 しか残りません。

HAProxy が 3 台へ振り分けるためです(第16回で確定)。調査するときは 3 台の audit.log を集めます。 1 台だけ見て「記録が無い」と読むのは誤りです。

ステップ7:GitOps へ記録する

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

$ cd ~/gitops/gitops-fanclub
$ git add docs/postmortem-2026-08-03.md
$ git commit -m "postmortem: fanclub-backend exec incident + exec audit policy"
$ git push

Audit Policy 自体は kube-apiserver の static Pod manifest なので、ArgoCD の管理対象ではありません。 GitOps に載るのは、振り返り文書と、手順書としての manifest 断片です。クラスタの状態と Git の内容が一致するのは ArgoCD が見ている範囲だけであり、Control Plane の設定はその外側にあります。

このポリシーは残す

本回で入れた Audit Policy は、演習の後始末で撤去しません。 理由は 3 つあります。

  • Postmortem を文書で終わらせないため。 改善アクションを適用したのに元へ戻したら、次に同じことが起きたときにまた「誰が」に届きません
  • 量が違うため。 第16回で撤去したのは全リソースを対象にした厚いポリシーでした。本回のものは pods/exec 系 3 つの Metadata レベルだけで、それ以外は - level: None で捨てています
  • 第17回で「空」と示した Users 面が、これで埋まるため。 第3巻を通して棚卸ししてきた 6 つの面のうち、最後まで空だった 1 面が、最終回の手前で埋まります

ただし --audit-log-maxbackup=3 にしたので、CIS 1.2.18 だけは FAIL のまま残ります。

本回のポリシー適用後に kube-bench を打つと、master は 50 PASS / 1 FAIL / 9 WARN になります(実測)。唯一の FAIL は 1.2.18——「--audit-log-maxbackup10 以上」を要求する項目です。第16回は CIS 準拠値の 10 を使ったので 4 項目とも PASS でしたが、本回は exec 系しか記録しない=1 ファイルの成長が遅いので、世代を 3 に減らしました。

これは「直し忘れ」ではなく判断です。 ベンチマークの推奨値は「厚いポリシーで大量に書く」前提の数字であって、記録する範囲を絞ったなら保持世代も見直すのが筋です。スコアを 1 件落としてでも、その理由を説明できる状態を選びます——第2回から繰り返してきた「ベンチマークは出発点であって到達点ではない」の、最後の適用例です。

組織の基準が 1.2.18 の PASS を要求するなら、maxbackup10 に戻してください。 exec 限定なら容量は問題になりません。どちらを選ぶかを決めて記録に残すところまでが Postmortem です。

試験ではどう出るか

本回が担当した D6 のコンピテンシーは 2 つです。Investigate and identify phases of attack and bad actors within the environmentUse audit logs to monitor access——前者を「攻撃のフェーズは追えたが実行者には届かなかった」という形で、後者を「その解決として最小のポリシーを実装する」という形で扱いました。試験でもこの 2 つは同じ順序で問われます——何が起きたかを追う設問と、追えるようにする設問です。

そのうえで、暗記対象を絞って整理します。

#覚えること区分
1kubectl debug <pod> --image=<img> --target=<container> の骨格CKS 実技
2--profile=restrictedrunAsUser を付けない(イメージが root 起動なら kubelet が弾く)CKS 実技
3--custom=<file>securityContext を上書きできるCKS 実技
4エフェメラルコンテナは Pod を残したまま取り除けない(失敗したものも spec に残る)CKS 実技
5Audit Policy の level: Metadatauser.username / user.groups / objectRef が記録されるCKS 実技
6- level: None を最後に置いて、それ以外を捨てる書き方CKS 実技
7pods/exec は subresource(記録側は objectRef.subresourceCKS 実技
8kubectl execverbcreate ではなく get 絞り込みは objectRef.subresource で行うCKS 実技
9ラベル書き換えによる隔離(Deployment とセレクタから同時に外す)実務・KCSA
10Hubble のリングバッファは有限(永続化にはエクスポートが要る)実務
11(HA 構成の場合)TCP パススルーの LB 越しでは sourceIPs が LB の IP になる実務

ラボと試験環境の違い

  • 試験でも /etc/kubernetes/manifests/kube-apiserver.yaml を直接編集します。本ラボと同じです
  • 反映は kubelet が自動で行います。 systemctl restart kubelet は不要で、Pod が作り直されるのを待ちます。Control Plane が 1 台しかない環境では、その間 kubectl が応答しません——落ちたのではなく作り直し中なので、待ちます
  • Control Plane が複数あるなら、すべてに入れます。 本ラボは 3 台です
  • Falco のルール反映が helm upgrade なのはラボ固有です。 試験では /etc/falco/ を直接編集し、systemd なら systemctl restart falco、DaemonSet なら kubectl rollout restart です(第17回で既出)

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

本回の内容で試験中に引けるのは、kubernetes.io/docs(Audit Policy・エフェメラルコンテナ・NetworkPolicy)と falco.org/docs の 2 つです。

Loki・Grafana・Hubble の記法は、試験中に参照できません。

grafana.comhelm.shgithub.com も許可リストの外です。暗記するか、その場で --help を読みます。 本回で hubble を Cilium Pod の中から呼んだように、ツールが手元にあるなら --help が最短の資料になります。

まとめ

  • インシデント対応は DAIR(検知 → 分析 → 隔離 → 復旧 → 振り返り)の型で進めます
  • 検知は成立しました。 第15回第17回で別々に書いたルールが、1 つの攻撃行為でミリ秒差の 2 本として発火しました
  • 「誰が」には届きませんでした。 kube-apiserver ログ・kubelet ログ・Events のいずれにも kubectl exec の痕跡は無く、実行者を記録できるのは監査ログだけでした
  • 証拠には寿命があります。 Loki は残り、Hubble は約 1 分で消え、Pod の中身は Pod を消すまでしかありません。だから隔離が先、削除は後になります
  • 隔離はラベル 1 つです。 ただし足すのではなく書き換えます--overwrite)。default-deny-all が敷いてあれば新しいポリシーは要らず、遮断までには時間差があります
  • 復旧は自動で始まっています。 rollout restart を打つと、健全な Pod まで巻き込んで遅くなります
  • ハードニングは調査も妨げます。 kubectl debug は PSA と kubelet の 2 段で止まります。プロファイルは事前に用意しておきます
  • Postmortem は実装まで行います。 exec に限定した最小の Audit Policy を入れ、次は「誰が」に答えられる状態にして残しました
  • 改善を 1 つ入れても、まだ届かないものが残ります。 user.username は記録されるようになりましたが、sourceIPsロードバランサの IPのままです。「どこから」は次の課題になりました

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

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

  1. Falco のアラートに出る user=appuser は、kubectl exec を実行した人物を表す
  2. kubectl label pod <pod> quarantine=true を実行すると、その Pod は Deployment の管理下から外れる
  3. default-deny-all が適用済みの namespace では、Pod のラベルを書き換えるだけで通信を遮断できる
  4. ラベルを書き換えた瞬間に、その Pod の通信は完全に遮断される
  5. Hubble のフローログは、インシデントに気づいてから数時間後でも遡って調査できる
  6. PSA が enforce: restricted の namespace では、kubectl debug を既定のまま実行すると拒否される
  7. kubectl debug --profile=restricted を使えば、どのイメージでもエフェメラルコンテナが起動する
  8. TCP パススルーのロードバランサ経由でも、監査ログの sourceIPs には kubectl を実行したホストの IP が記録される
  9. 隔離した Pod は、証拠保全が済むまで削除しない
解答と解説

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

  • 問1(×): user=appuser uid=10001コンテナの中で起動したプロセスの実行ユーザーです。正規の運用者が打っても攻撃者が打っても同じ値になります。実行者は監査ログにしか残りません
  • 問2(×): ReplicaSet のセレクタは apppod-template-hash です。ラベルを足しても外れません。 実測でも Pod 数は 3 のまま、通信も 200 のままでした
  • 問3(○): 個別の許可ポリシーが app= セレクタなので、ラベルを変えると許可から外れ、全 Pod に効いている default-deny-all に落ちます。新しい NetworkPolicy を書く必要はありません
  • 問4(×): Cilium が security identity を計算し直して全ノードへ配るまで、時間差があります。 3 回の実測で、遮断されるまで 9 秒・26 秒・26 秒とばらつきました。切れたことを確認してから次へ進みます
  • 問5(×): リングバッファは 4,095 フロー固定です。本ラボの流量では約 1 分で一周します。永続化にはエクスポート設定が要ります
  • 問6(○): エフェメラルコンテナにも PSA の基準がそのまま適用されます。allowPrivilegeEscalation / capabilities.drop / runAsNonRoot の 3 点で拒否されます
  • 問7(×): --profile=restrictedrunAsNonRoot: true を付けますが runAsUser を指定しません。イメージの既定 USER が root なら、kubelet が CreateContainerConfigError で弾きます。 admission は通っているので、エラーが出ないぶん気づきにくくなります
  • 問8(×): user.username / user.groups / objectRefMetadata レベルで記録されますが、sourceIPs に入るのはロードバランサの IPです。TCP パススルーの LB は TLS を終端しないので X-Forwarded-For を挿入できず、API Server から見える送信元は常に LB になります。実行元を知るには LB 側のログと突き合わせます
  • 問9(○): Pod の中にしかない証拠(改変されたファイル・動いているプロセス)は、Pod を消した瞬間に失われます。 隔離は「動かしたまま切り離す」ことで、削除ではありません

次回予告

第19回「CKS 試験直前対策 + 第3巻完走宣言 + Kubestronaut への道」では、第2〜18回で積み上げた暗記必須コマンドを総ざらいします。試験環境の作法(タスクごとに指定される SSH ホストでの作業・参照可能ドキュメントの使いこなし・時間配分)を確認し、ラボで手を動かせなかった領域(AppArmor / Istio mTLS / Kata)を明示して補完計画を立てます。そのうえで第3巻の完走を宣言し、Kubestronaut 5 冠への残り 2 つ(KCNA / KCSA)の進め方を示します。

→ 詳しくは第19回 CKS 試験直前対策 + 第3巻完走宣言 + Kubestronaut への道

前の記事
次の記事