新卒インフラエンジニア向け「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
目次
第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 |
| Pod | 117 個・異常 0・16 namespace |
| Falco | DaemonSet 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回で撤去) |
| fanclub | backend 0.4.1 × 3 / frontend 1.0.0 × 2 / postgres:18 / busybox:1.37 |
| HTTPS | https://fanclub.local/api/members が 200・会員 3 名 |
| ArgoCD | fanclub-api-prod が Synced / Healthy |
| Longhorn | 4 ボリューム attached healthy |
| 監査ログ | 撤去済み(kube-apiserver.yaml に audit フラグ 0 件) |
| alma-proxy の whitelist | 44 行(本回も 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 の中にしかない証拠がある」という事実から導かれます。 その根拠は後半の「証拠には寿命がある」で数字として示します。

本回の全体像です。上段が「効く道具」、下段が「行き止まる場所」で、フェーズごとに対になっています。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:26 の cmd=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 container は proc.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 | ルール名。調査の起点になる |
priority | Warning / Notice など |
hostname | どのノードで起きたか。 やってみよう① でノードを特定する手間が、ここでは要らない |
k8s_ns_name / k8s_pod_name | namespace と Pod 名 |
source / tags | syscall / ルールに付けたタグ |
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_range に step=3600 で投げると、ルールが落ちることがあります。
query_range は 評価点ごとに [1h] の窓を評価するので、評価点が粗いと、その瞬間の窓にサンプルが無いストリームが結果から落ちます。 本書の実測では step=3600 で 3 ルールに減り、Shell spawned も Blocked 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 | コントローラが起こした状態変化 | 対象外 |
| Hubble | Pod 間の通信(次節) | 分からない |
| 監査ログ | 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 の中のhubbleをkubectl exec経由で使います - ノードを間違えると空を返します。
hubble observe --namespace fanclubが 0 行なのは、「通信が無い」ではなく「このノードには無い」の可能性があります
証拠の寿命の比較
| 証拠源 | どれだけ残るか | 本ラボでの実測 | 「誰が」 |
|---|---|---|---|
| Loki(Falco アラート) | 残る | 最古は約 10 時間前(falcosidekick を入れた第17回の演習中)。Falco Pod が何度も再起動したあとでも残っている | 分からない |
Falco Pod のログ(kubectl logs) | Pod が再起動するまで | 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 は 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 / ReplicaSet | app=fanclub-backend | 管理下から外れる → Deployment が新しい Pod を補充する |
| NetworkPolicy(個別許可 6 本) | app=fanclub-backend など | 許可から外れる |
CiliumNetworkPolicy fanclub-backend-l7 | endpointSelector: app=fanclub-backend | L7 の許可からも外れる |
NetworkPolicy default-deny-all | podSelector: {}(全 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-shipper は initContainers に restartPolicy: Always で置かれた sidecar なので、READY の数に入ります)。完了済みの init コンテナ wait-for-db は数に入りません。落ちているのは backend のほうで、log-shipper は生きています——log-shipper は tail -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.txt が 8 バイトしかありません。サイドカーは起動時に 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 の素の設定では allowPrivilegeEscalation も capabilities.drop も runAsNonRoot も満たしません。
段階 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=restricted は runAsNonRoot: true を付けますが、runAsUser を指定しません。 busybox イメージの既定 USER は root なので、kubelet が起動時に弾きます。 admission(PSA)は通っているのに動かない、というもっとも分かりにくい失敗の仕方です。
エフェメラルコンテナは、Pod を残したまま取り除けません。
kubectl edit でも消えません。失敗したものも Pod の spec に残り続けます。 再挑戦は --container に別の名前を付けて行います。消したければ Pod ごと削除するしかありませんが、今は証拠保全中なので Pod は消せません。 名前の付け方に気をつける理由がここにあります。
段階 3: --custom で runAsUser を明示する
プロファイルの一部を自分で書いて渡します。ファイル名は 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 と格闘するのは、事故の最中にやることではありません。

関門が 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により、補充中もサービスは落ちません(実測で HTTPS200が継続) - 隔離した 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:26 | log-shipper サイドカーの起動(攻撃ではない) | Falco |
| 08:26:28 | シェル起動(偵察・id; whoami) | Falco |
| 08:26:31 | 環境変数の窃取(DB_PASSWORD 流出) | Falco |
| 08:26:34 | ServiceAccount トークンの探索(未マウントのため失敗) | Falco |
| 08:26:37 | 書き込み試行(readOnlyRootFilesystem で阻止) | Falco 2 本 |
| 08:28:51 | 隔離(ラベル書き換え) | 対応者の操作 |
| 08:29:17 頃 | 遮断を確認(ラベル書き換えから +26 秒) | Hubble |
| 08:34 | 証拠保全 | 対応者の操作 |
| — | 実行者の特定 | この時点では不可 |
Falco の画面表示は UTC です。 上の表は日本時間に直してあります。異なるツールの記録を 1 本のタイムラインに並べるときは、タイムゾーンを最初に揃えます。
ステップ2:根本原因を 3 つの問いで整理する
Postmortem は仕組みの改善に向けます。
「誰の操作ミスだったか」を突き止めることを目的にすると、次から報告が上がらなくなり、検知が遅れます。 上の 3 つの問いは、いずれも設計上の判断を指しており、個人を指していません。
ステップ3:改善アクションを 6 件挙げる
| # | アクション | 本回で見えた根拠 |
|---|---|---|
| 1 | exec に限定した最小の Audit Policy を入れる | 実行者を記録できるのは監査ログだけ |
| 2 | Secret をファイルマウントにして env から外す | env 一発で平文になる |
| 3 | Hubble のフローをエクスポートする | 約 1 分で消えるものは証拠にならない |
| 4 | falcosidekick の送信失敗を監視する | 落ちた分は戻らない |
| 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.subresource が exec になります。絞り込むときはこのフィールドを使います。
ステップ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
あわせて volumeMounts と volumes に 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-XX が 1/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: verb は create ではなく get
kubectl exec は pods/exec サブリソースへの操作なので create だと考えがちですが、実測は get です。kubectl exec は接続を SPDY / WebSocket へ昇格させる GET として飛びます。verb で絞り込むときに間違えると、記録があるのに見つけられません。 絞り込みは objectRef.subresource で行うのが確実です。
想定と違った点 2: sourceIPs は実行元ではない
記録された sourceIPs は 192.168.1.124 です。しかし kubectl を打ったのは k8s-ops(192.168.1.122)です。
| 項目 | 値 |
|---|---|
監査ログの sourceIPs | 192.168.1.124 |
| その IP の正体 | k8s-lb(HAProxy) |
実際に kubectl を打ったホスト | k8s-ops(192.168.1.122) |
kubeconfig の server | https://k8s-lb:6443 |
| HAProxy の設定 | mode tcp・option forwardfor は 0 件 |
ロードバランサ越しでは、実行元の 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 につき ResponseStarted と ResponseComplete の 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-maxbackup は 10 以上」を要求する項目です。第16回は CIS 準拠値の 10 を使ったので 4 項目とも PASS でしたが、本回は exec 系しか記録しない=1 ファイルの成長が遅いので、世代を 3 に減らしました。
これは「直し忘れ」ではなく判断です。 ベンチマークの推奨値は「厚いポリシーで大量に書く」前提の数字であって、記録する範囲を絞ったなら保持世代も見直すのが筋です。スコアを 1 件落としてでも、その理由を説明できる状態を選びます——第2回から繰り返してきた「ベンチマークは出発点であって到達点ではない」の、最後の適用例です。
組織の基準が 1.2.18 の PASS を要求するなら、maxbackup を 10 に戻してください。 exec 限定なら容量は問題になりません。どちらを選ぶかを決めて記録に残すところまでが Postmortem です。
試験ではどう出るか
本回が担当した D6 のコンピテンシーは 2 つです。Investigate and identify phases of attack and bad actors within the environment と Use audit logs to monitor access——前者を「攻撃のフェーズは追えたが実行者には届かなかった」という形で、後者を「その解決として最小のポリシーを実装する」という形で扱いました。試験でもこの 2 つは同じ順序で問われます——何が起きたかを追う設問と、追えるようにする設問です。
そのうえで、暗記対象を絞って整理します。
| # | 覚えること | 区分 |
|---|---|---|
| 1 | kubectl debug <pod> --image=<img> --target=<container> の骨格 | CKS 実技 |
| 2 | --profile=restricted は runAsUser を付けない(イメージが root 起動なら kubelet が弾く) | CKS 実技 |
| 3 | --custom=<file> で securityContext を上書きできる | CKS 実技 |
| 4 | エフェメラルコンテナは Pod を残したまま取り除けない(失敗したものも spec に残る) | CKS 実技 |
| 5 | Audit Policy の level: Metadata で user.username / user.groups / objectRef が記録される | CKS 実技 |
| 6 | - level: None を最後に置いて、それ以外を捨てる書き方 | CKS 実技 |
| 7 | pods/exec は subresource(記録側は objectRef.subresource) | CKS 実技 |
| 8 | kubectl exec の verb は create ではなく get。 絞り込みは objectRef.subresource で行う | CKS 実技 |
| 9 | ラベル書き換えによる隔離(Deployment とセレクタから同時に外す) | 実務・KCSA |
| 10 | Hubble のリングバッファは有限(永続化にはエクスポートが要る) | 実務 |
| 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.com も helm.sh も github.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 問)
次の各文が正しいか(○)誤りか(×)を判断してください。下の「解答と解説」を開くと答え合わせができます。
- Falco のアラートに出る
user=appuserは、kubectl execを実行した人物を表す kubectl label pod <pod> quarantine=trueを実行すると、その Pod は Deployment の管理下から外れるdefault-deny-allが適用済みの namespace では、Pod のラベルを書き換えるだけで通信を遮断できる- ラベルを書き換えた瞬間に、その Pod の通信は完全に遮断される
- Hubble のフローログは、インシデントに気づいてから数時間後でも遡って調査できる
- PSA が
enforce: restrictedの namespace では、kubectl debugを既定のまま実行すると拒否される kubectl debug --profile=restrictedを使えば、どのイメージでもエフェメラルコンテナが起動する- TCP パススルーのロードバランサ経由でも、監査ログの
sourceIPsにはkubectlを実行したホストの IP が記録される - 隔離した Pod は、証拠保全が済むまで削除しない
解答と解説
1=×/2=×/3=○/4=×/5=×/6=○/7=×/8=×/9=○
- 問1(×):
user=appuser uid=10001はコンテナの中で起動したプロセスの実行ユーザーです。正規の運用者が打っても攻撃者が打っても同じ値になります。実行者は監査ログにしか残りません - 問2(×): ReplicaSet のセレクタは
appとpod-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=restrictedはrunAsNonRoot: trueを付けますがrunAsUserを指定しません。イメージの既定 USER が root なら、kubelet がCreateContainerConfigErrorで弾きます。 admission は通っているので、エラーが出ないぶん気づきにくくなります - 問8(×):
user.username/user.groups/objectRefはMetadataレベルで記録されますが、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)の進め方を示します。
