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

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

6面の脅威検知とコンテナ不変性【CKS第17回】

公開

新卒インフラエンジニア向け「Kubernetes 実践教科書 ③ CKS セキュリティ・ハードニング編」(全19回)の第17回です。第15回で Falco(syscall)を、第16回で監査ログ(Kubernetes API への操作)を扱いました。本回は D6 Monitoring, Logging and Runtime Security(配点 20%)の締めくくりとして、検知の全体像を 1 枚の地図に組み上げ、ラボに実際に開いている穴を点検します。

本回の中心にある事実を、最初に置きます——防御は、検知を生みません。 readOnlyRootFilesystem で書き込みが弾かれても、既定の Falco はそれを 1 件も記録しません。 「書けなかった」という事実は、コンテナの中で完結して消えます。検知させるには、条件を自分で書く必要があります。

そして本回は、もう 1 つの「できているつもり」も扱います。検知は動き、送信は HTTP 200 で成功し、それでも人には届かない——本ラボの Alertmanager が、そのまま実例になります。「送れた」は「届いた」ではありません。

  • ラボ Kubernetes v1.36.3
  • CKS 試験環境 v1.35
  • Falco 0.44.1(chart 9.1.0)
  • falcosidekick chart 0.12.1(image 2.32.0)
  • Alertmanager v0.32.1
  • Loki 3.6.8
  • kube-prometheus-stack chart 84.5.0
  • Helm v4.1.4
  • Cilium v1.19.6
  • containerd v2.2.6
  • AlmaLinux 10.2
  • 確認日 2026-08-02
目次
  1. 第17回のスコープ・今ここマップ
    1. 本回の軸: 静かなのは、起きていないからか、見ていないからか
    2. 既習範囲 / 本回で上書きする観点
  2. この回のゴール
    1. 本回の起点
    2. 本回で行き来するホスト
  3. 点の検知から面の網羅へ —— なぜ CKS は 6 つの面を挙げるのか
  4. 6 面 × 観測手段の実機棚卸し
  5. 検知の穴を点検する —— 「入れた=効いている」ではない
    1. 穴①: Users 面が空
    2. 穴②: Gatekeeper は動いているが、何も強制していない
    3. 穴③: Data 面は「保存時」だけ守られている
    4. 点検の作法(読者が持ち帰るもの)
  6. 実行時不変性を、検知の観点で読み直す
    1. ルートを全部塞いで、必要な穴だけ 2 つ開ける
    2. PSS Restricted は readOnlyRootFilesystem を要求しない
  7. 防御は検知を生まない —— 既定 25 ルールに write 系が 1 本もない
    1. open_write マクロが阻止された書き込みを拾えない理由
    2. 阻止された書き込みを拾う 3 通りの条件
  8. やってみよう①:既定では無音であることを確かめ、カスタムルールで可視化する
    1. ステップ1:既存の防御を読む
    2. ステップ2:Falco のログを流しながら、書き込みを試す
    3. ステップ3:open_write 版のルールを書いて、それでも拾えないことを確かめる
    4. ステップ4:evt.res = EROFS 版に書き換えて、可視化する
    5. ステップ5:対照実験(書ける場所で取る)
    6. ステップ5.5:対照 Pod を「不変」にする —— 不変性を自分の手で実装する
    7. ステップ6:後始末
  9. やってみよう②:rule_matching: first —— 広いルールが具体的なルールを潰す
    1. 第15回で扱った分と、本回で新しく踏み込む分
    2. ステップ1:広いルールを、具体的なルールの前に置く
    3. ステップ2:広いほうだけが発火する
    4. ステップ3:2 本の順序だけを入れ替える
    5. ステップ4:rule_matching: all という選択肢
    6. ステップ5:first に戻す
    7. この論点が効く場面
  10. アラート設計 —— 届かない検知に意味はない
    1. ノイズは priority では落とせない
    2. 検知は二系統ある
    3. 通知先の分け方
    4. 本ラボでの経路の選択
  11. やってみよう③:falcosidekick を入れる —— Loki には届き、Alertmanager には届かない
    1. ステップ1:導入 —— helm repo add は要らない
    2. ステップ2:Loki へは既定のまま通る
    3. ステップ3:Alertmanager は chart 既定だと全件 410 で落ちる
    4. ステップ4:200 が返っても、誰にも届いていない
    5. ステップ5:何を直せば届くのか
    6. ステップ6:本回の後始末(回全体)
  12. 暗記必須コマンド(コンテナ不変性 / Falco 検知)
    1. 試験ではどう出るか
  13. まとめ
  14. 理解度チェック(○×形式・全 9 問)
  15. 次回予告

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

D6 は 5 つのコンピテンシーで構成されます。本回はそのうち残る 2 つを担当します——6 面にわたる脅威検知と、コンテナの実行時不変性です。

公式コンピテンシー(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回

本回の軸: 静かなのは、起きていないからか、見ていないからか

本回は 3 つの「入れたつもり・届いたつもり」に出会います。 どれも、コマンドを打った瞬間には成功して見えるものです。

#どこでやったつもりのこと実機で起きること
やってみよう①readOnlyRootFilesystem: true を入れたので、書き込みの試みは検知できるはずFalco のアラートは 0 件です。 既定 25 ルールに書き込み系が 1 本もなく、open_write マクロは fd.num>=0(成功した open)しか拾いません
やってみよう②ルールを 2 本書いたので、両方とも発火するはず先に書いたほうしか発火しません。 rule_matching: first の先勝ちで、後ろの具体的なルールがエラーにならないまま出なくなります
やってみよう③falcosidekick が Alertmanager へ POST OK (200) を返したので、通知は届いたはず誰にも届いていません。 kube-prometheus-stack 既定のルーティングは受け皿が "null" の 1 つだけです

本回の中心メッセージ: 防御は検知を生みません。

防御(弾く)と検知(記録する)は別の価値です。両方が要るなら、両方を別々に入れます。 そして検知を入れたら、「静かなのは、起きていないからか、見ていないからか」を必ず切り分けます。 第15回で「静かになったことを成功と読まない」と述べたのと同じ構図が、本回では 3 か所に現れます。

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

コンテナの実行時不変性は、すでにラボに入っています。 readOnlyRootFilesystem / capabilities.drop: [ALL] / allowPrivilegeEscalation: false / runAsNonRoot / runAsUser: 10001 は fanclub-backend に実装済みで、Pod レベルの seccompProfile: RuntimeDefault第6回で追加しました。本回はこれを入れ直すのではなく、「検知の観点で読み直す」ところから始めます。 読者が不変性を自分の手で書くのは、やってみよう① ステップ 5.5 の対照 Pod 1 つだけです(本番相当のワークロードを演習で書き換えないためです)。

既習どこで本回で上書きする観点
Falco のカスタムルール(list / macro / rule)を書く第15回本回は「既定のマクロを使うと拾えない」ケースを扱います。 open_write をそのまま使うと、阻止された書き込みは永久に拾えません
rule_matching: first(最初にマッチした 1 本で打ち切る)第15回存在と意味は第15回で扱い済みです。 本回で新しく踏み込むのは①評価順を決めるのは定義順である ②priority は発火順序と無関係 ③rule_matching: all という選択肢と代償の 3 点です
PSA restrictedfanclub namespace に適用した第8回PSS Restricted は readOnlyRootFilesystem を要求しません。 「Restricted にしたから不変」は誤りで、実機でも不変性なしの Pod が admit されます
ConstraintTemplate / ImagePolicyWebhook を演習末尾で撤去した第8回第14回撤去したことを忘れると、helm listgatekeeper を見て「ポリシーは効いている」と読みます。 本回で点検コマンドを持ち帰ります
Audit Policy を入れて、回の終わりに撤去した第16回その結果、Users 面が今このラボでは空です。 「誰が何をしたか」を後から追う手段がありません
ConfigMap の直接編集は Server-Side Apply と衝突する第15回本回の変更はすべて ~/falco-lab/custom-values.yaml 経由で入れます。 kubectl edit configmap falco-rules は使いません

第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 への道

この回のゴール

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

  • 6 つの面それぞれについて「何を・どの手段で見るか」を説明できる
  • 自分のクラスタで検知できていない面(穴)を、コマンドで点検できる
  • 「入れた=効いている」ではないことを、Gatekeeper の実例で説明できる
  • 既定の Falco が阻止された書き込みを拾えない理由open_writefd.num>=0)を説明できる
  • 阻止された書き込みを検知するカスタムルールを書けるevt.res = EROFS 版と evt.rawres < 0 版の 2 通り)
  • rule_matching: first(先勝ち)によって、広いルールが具体的なルールを潰すことを説明・再現できる
  • PSS Restricted が readOnlyRootFilesystem を要求しないことを説明できる
  • コンテナの実行時不変性を自分で実装できる(ルートを塞ぎ、必要な穴だけ emptyDir で開ける)
  • 検知を人に届ける経路を組み、送信が成功しても届かない設計があることを実例で示せる

本回の起点

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

項目
ノード5/5 Ready・Kubernetes v1.36.3・containerd v2.2.6・AlmaLinux 10.2
Pod116 個・異常 016 namespace
Falco5/5 Running・Helm falco REVISION 4 deployed・chart 9.1.0 / app 0.44.1
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 件)。ログファイルは第16回の方針どおり /var/log/kubernetes/audit/ に残っていますが、新しいイベントはもう 1 件も書かれません
alma-proxy の whitelist44 行本回も 1 行も足しません・5 回連続です)

本回で行き来するホスト

ホスト本回での役割
k8s-opsほぼすべての作業をここで行います。 ~/falco-lab/custom-values.yaml の編集・helm upgradekubectlkubectl port-forward
k8s-cp-01Users 面の点検(kube-apiserver.yamlaudit フラグ確認)と kube-bench の所在確認のみ
k8s-wl-01 / 02Falco の DaemonSet が動くノードです。ログは kubectl logs で読むので、ssh はしません
k8s-lb / alma-proxy触りません。 whitelist は 44 行のままです

点の検知から面の網羅へ —— なぜ CKS は 6 つの面を挙げるのか

第15回の Falco は syscall(コンテナ内の挙動)を、第16回の監査ログは Kubernetes API への操作を見ます。どちらも強力ですが、守備範囲が違います。そして攻撃は 1 つの面だけを通りません。 侵入・横展開・持ち出しは、それぞれ別の場所に痕跡を残します。

CKS の公式コンピテンシー D6 は、検知の対象を次のように列挙しています。

Detect threats within physical infrastructure, apps, networks, data, users and workloads

物理インフラ / アプリ / ネットワーク / データ / ユーザ / ワークロードの 6 つです。本回ではこの 6 つを「面」と呼びます。面とは、攻撃者が触れる場所の分類です。ある道具が 1 つの面をどれだけ深く見ていても、別の面は見えません。Falco と監査ログの 2 つだけでは、6 面のうち埋まらない面が残ります。

先頭の physical を落とさないことが重要です。 Infrastructure 面は「クラスタのコンポーネント」だけでなく、物理・ノード層までを指します。だからこそ 第2回の kube-bench と 第6回第7回の OS 堅牢化が、この面に割り当たります。

この 6 面は KCSA の出題範囲とも重なります。

Kubestronaut 5 冠の 5 つ目にあたる KCSA(Kubernetes and Cloud Native Security Associate)は多肢選択式で、「どの脅威をどのレイヤで検知するか」を概念として問います。第16回の攻撃フェーズ(偵察 → 権限取得 → 横展開 → 永続化)と本回の 6 面は、CKS の実技だけでなく KCSA の学習にもそのまま使えます。

6 面 × 観測手段の実機棚卸し

第2回から第16回までで導入したものを、6 面の上に並べ直します。下の表は概念図ではなく、このラボの現在の実機状態です。

ラボに「今」あるもの該当回状態
Infrastructure
physical infrastructure
/usr/bin/kube-bench + /etc/kube-bench(cp-01)第2回第6回第7回都度実行(常時監視ではありません)
AppsFalco 既定 25 ルール + 第15回のカスタム 1 本(rules-fanclub.yaml第15回常駐
NetworksNetworkPolicy 13 本(fanclub 7 / argocd 6)+ CiliumNetworkPolicy fanclub-backend-l7 + Hubble(hubble-relay / hubble-ui / hubble-peer第3回第11回常駐
Dataetcd 暗号化(--encryption-provider-config + /etc/kubernetes/enc/enc.yaml)+ SealedSecret 1 件第9回常駐。ただし「誰がどの Secret を読んだか」は追えません
Usersなし第4回第16回★空(監査ログは第16回で撤去済み)
WorkloadsPSA restricted(enforce/warn/audit・fanclub)——実際に効いているのはこれだけ / Gatekeeper 3.23.0(稼働中だが Constraint 0 本)/ Trivy・cosign・bom(CI 時点の道具第8回第13回第14回一部のみ常駐
CKS の 6 面(Infrastructure / Apps / Networks / Data / Users / Workloads)にラボの観測手段を並べ、常駐・都度実行・空・一部のみの 4 状態で塗り分けた棚卸し図
図1:CKS の 6 面に、ラボの観測手段を並べた棚卸し

この表の読み方は 3 つです。

  1. 常駐か、都度実行か。 kube-bench と Trivy は「打った瞬間のスナップショット」です。逸脱が起きた時刻には気づけません。「Infrastructure 面は kube-bench で見ている」と書いた地図は、実際には「見ようと思えば見られる」でしかありません
  2. 記録するか、拒否するか。 NetworkPolicy と PSA は拒否しますが、拒否したこと自体を後から数える手段は別に要ります。本回の主題(防御は検知を生まない)は、ここでも同じ形で現れます
  3. 面が埋まっている=検知できている、ではありません。 次節でこの表の裏返しを点検します

監視基盤(Loki / Grafana / Alertmanager / Prometheus)はこの表に載せていません。

これらは第2巻で構築した監視基盤であり、それ自体は「脅威を検知する道具」ではなく「検知した結果を受け取る器」だからです。本回のやってみよう③で、この器へ Falco のイベントを流し込みます。器を持っていることと、検知していることは別です。

検知の穴を点検する —— 「入れた=効いている」ではない

6 面の表は、埋まっている行より埋まっていない行のほうが役に立ちます。 このラボには現在 3 つの穴があります。どれも事故ではなく、設計判断の結果として今こうなっているものです。

穴①: Users 面が空

第16回で Audit Policy を有効化し、回の終わりに撤去しました。その結果、「誰が何をしたか」を後から追う手段が現在ゼロです。第16回で読んだ pods/exec の記録も、今このラボには残りません。

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

# grep -c audit /etc/kubernetes/manifests/kube-apiserver.yaml

実行結果(0 件——監査ログは有効化されていません):

0

/var/log/kubernetes/audit/ にはファイルが残っています。それが最も誤解しやすい状態です。

第16回はログファイルを分析のために残す方針だったので、audit.log とローテート済みファイルは 3 台とも在ります(cp-01 では 100 MB を超えています)。ファイルが在るのに、新しいイベントは 1 件も書かれません。 ls だけで確認すると「監査ログはある」と読めてしまいます。Users 面が埋まっているかを判断する材料は、ファイルの有無ではなく kube-apiserver.yaml のフラグです。 grep -c audit0 なら、その瞬間から先の操作は 1 つも残りません。

穴②: Gatekeeper は動いているが、何も強制していない

helm release(gatekeeper 3.23.0)も CRD も webhook も存在します。それでも、強制の実体は空です。

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

$ kubectl get constrainttemplates
$ kubectl get constraints -A
$ kubectl get validatingadmissionpolicy

実行結果(3 つとも空。ただし返り方は 2 種類あります):

No resources found
error: the server doesn't have a resource type "constraints"
No resources found

2 つ目のエラーは、失敗ではなく答えそのものです。

constraintsConstraintTemplate が生やす CRD です(テンプレートごとに 1 つ CRD が作られ、そこに constraints というカテゴリが付きます)。テンプレートが 0 本なら、CRD も 0 個——つまり constraints という型が API に存在しません。 だから「0 件」ではなく「そんな型は無い」と返ります。ここで手が止まるのが、試験でいちばんありがちな時間の使い方です。 エラーを見て打ち直すのではなく、「型が無い=1 本も定義されていない」と読んで次へ進みます。

ConstraintTemplate 0 本・Constraint 0 本です。これは第8回の演習末尾で意図的に撤去したためで、事故ではありません。撤去には理由がありました——ポリシーを残したままにすると、以降の回で Pod を作るたびに本題と無関係な拒否が起きて、環境が学習の邪魔をするからです。

問題は、判断そのものではなく、判断を覚えていられないことです。 撤去したことを忘れていれば、helm list -Agatekeeper が並んでいるのを見て「ポリシーは効いている」と読みます。

同じことが署名検証にも当てはまります。ImagePolicyWebhook は第14回の末尾で撤去済みです。cosignbom のバイナリは /usr/bin/ に残っていますが、それは CI 時点の道具であって、実行中のクラスタを見張るものではありません。

Workloads 面で実際に効いているのは PSA だけです。

穴③: Data 面は「保存時」だけ守られている

第9回の etcd 暗号化は保存時のデータを守ります。ディスクを持ち出されても Secret は読めません。しかし「Secret を誰が読んだか」は、監査ログが無い今は追えません。 第16回で設計した「secrets は Metadata で記録する」というルールは、監査ログがあって初めて意味を持ちます。

点検の作法(読者が持ち帰るもの)

撤去を判断した本人でも、3 回後には忘れます。 だから記憶ではなくコマンドで点検します。 下の 5 行は、自分のクラスタでそのまま使えます。

「効いているか」を確かめるコマンド
Workloads(Admission)kubectl get constrainttemplates / kubectl get constraints -A / kubectl get validatingadmissionpolicy
Workloads(PSA)kubectl get ns <ns> -o jsonpath='{.metadata.labels}'
Users(監査)grep audit /etc/kubernetes/manifests/kube-apiserver.yaml
Apps(Falco)kubectl logs -n falco <falco Pod 名> -c falco | grep 'Loading rules'
Networkskubectl get netpol -A / kubectl get cnp -A

helm list は「入っているか」しか答えません。

gatekeeperdeployed であることと、ポリシーが強制されていることは別の事実です。Admission 系の道具は「制御プレーンの存在」と「ルールの存在」が分離しているので、必ず後者を数えます。同じ構図は Falco にもあります——Pod が Running であることと、必要なルールが読み込まれていることは別です(第15回schema validation: ok を毎回確認したのはこのためです)。

実行時不変性を、検知の観点で読み直す

コンテナの実行時不変性(immutability at runtime)とは、起動後のコンテナのファイルシステムを書き換えられない状態にすることです。 攻撃者がコンテナに入っても、ツールをダウンロードして置くことも、バイナリを差し替えることもできなくなります。

fanclub-backend の securityContext は次のとおりです(実機値)。

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

$ kubectl get deploy -n fanclub fanclub-backend -o jsonpath='{.spec.template.spec.containers[?(@.name=="backend")].securityContext}'

実行結果:

{"allowPrivilegeEscalation":false,"capabilities":{"drop":["ALL"]},"readOnlyRootFilesystem":true,"runAsNonRoot":true,"runAsUser":10001}

Pod レベルには seccompProfile が付いています(第6回で追加したものです)。

{"seccompProfile":{"type":"RuntimeDefault"}}

ルートを全部塞いで、必要な穴だけ 2 つ開ける

readOnlyRootFilesystem: true にすると、アプリが書きたい場所も塞がります。そこで、書く必要のあるパスにだけ emptyDir をマウントします。 backend には 2 つあります。

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

$ kubectl get deploy -n fanclub fanclub-backend -o jsonpath='{range .spec.template.spec.containers[?(@.name=="backend")].volumeMounts[*]}{.name}{" "}{.mountPath}{"\n"}{end}'

実行結果:

applog /var/log/app
payara-tmp /opt/payara/tmp
mountPathvolume 名用途
/var/log/appapplogアプリログ(第8回の log-shipper サイドカーと共有)
/opt/payara/tmppayara-tmpPayara のテンポラリ

「ルートを全部塞いで、必要な穴だけ 2 つ開ける」——これが不変性の実装の形です。emptyDir は Pod と寿命を共にするので、Pod が作り直されれば書かれたものは消えます。攻撃者が置いたツールも、Pod の再作成で消えます。

PSS Restricted は readOnlyRootFilesystem を要求しない

fanclub namespace は enforce / warn / audit すべて restricted です。

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

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

実行結果(抜粋):

{"pod-security.kubernetes.io/enforce":"restricted","pod-security.kubernetes.io/warn":"restricted","pod-security.kubernetes.io/audit":"restricted"}

それでも、readOnlyRootFilesystem を持たない Pod は何の警告も出ずに admit されます(やってみよう① ステップ 5 で実際に作ります)。公式の Restricted プロファイル要件一覧(https://kubernetes.io/docs/concepts/security/pod-security-standards/)に readOnlyRootFilesystem は列挙されていません。 Restricted が要求するのは allowPrivilegeEscalation: false / capabilities.drop: [ALL] / runAsNonRoot / seccompProfile などであって、ルートファイルシステムの読み取り専用化は含まれません。

「PSS Restricted を入れてあるから不変」は誤りです。

不変性は PSS の要件の外にあり、自分で入れる必要があります。 CKS で「コンテナの不変性を担保せよ」と問われて enforce: restricted のラベルだけを貼ると、要件を満たしません。要求されているのは readOnlyRootFilesystem: true(+書き込み先の emptyDir)です。 なお kubernetes.io/docs は試験中に参照できるので、要件一覧はその場で開いて確認できます。

防御は検知を生まない —— 既定 25 ルールに write 系が 1 本もない

ここからが本回の背骨です。「書けなかった」という事実は、既定の Falco には残りません。

第15回で確認したとおり、Falco の既定ルールは 25 本です。その一覧に Write below etc / Write below root / Write below binary dir は含まれません。

Directory traversal monitored file read
Read sensitive file trusted after startup
Read sensitive file untrusted
Run shell untrusted
System user interactive
Terminal shell in container
Contact K8S API Server From Container
Netcat Remote Code Execution in Container
Search Private Keys or Passwords
Clear Log Activities
Remove Bulk Data from Disk
Create Symlink Over Sensitive Files
Create Hardlink Over Sensitive Files
Packet socket created in container
Redirect STDOUT/STDIN to Network Connection in Container
Linux Kernel Module Injection Detected
Debugfs Launched in Privileged Container
Detect release_agent File Container Escapes
PTRACE attached to process
PTRACE anti-debug attempt
Find AWS Credentials
Execution from /dev/shm
Drop and execute new binary in container
Disallowed SSH Connection Non Standard Port
Fileless execution via memfd_create

この一覧の読み方です。 既定ルールは「読まれると困るもの」(機密ファイルの read・鍵の探索)と「実行されると困るもの」(シェル・netcat・カーネルモジュール)に寄っています。書き込みは、成功しても失敗しても、既定では見えません。

open_write マクロが阻止された書き込みを拾えない理由

既定に無いなら自分で書けばよい——そう考えて、既定のマクロ open_write を素直に使うと、やはり拾えません。実機のマクロ定義を見ます。

- macro: open_write
  condition: (evt.type in (open,openat,openat2) and evt.is_open_write=true and fd.typechar='f' and fd.num>=0)

fd.typecharファイルディスクリプタの種別を表す 1 文字で、'f' は通常ファイルを意味します。問題は最後の fd.num>=0 です。open が成功したときだけ、カーネルはファイルディスクリプタ番号(0 以上の整数)を返します。 readOnlyRootFilesystem に阻まれた open は失敗するので、fd.num-1 になります。

open_write を使ったカスタムルールでは、阻止された書き込みは永久に拾えません。

ルールの側で and k8s.ns.name = "fanclub" のように条件をいくら足しても、マクロの内側の fd.num>=0 は外れません。 マクロは「条件の部品」であり、展開されると and で結合されるからです。

touch が readOnlyRootFilesystem に弾かれ、fd.num=-1 が open_write マクロの網(fd.num≧0)を素通りしてアラート 0 件になり、evt.res=EROFS のカスタムルールで初めて可視化される 4 段の流れ図
図2:readOnlyRootFilesystem に弾かれた書き込みが検知に出ない 4 段

阻止された書き込みを拾う 3 通りの条件

では何を書けばよいか。実機で確認したところ、次の 3 通りはいずれも同じイベントにマッチします。

条件実測フィールド値性格
evt.res = EROFSres=EROFS最も具体的(読み取り専用ファイルシステムに限定)
evt.rawres < 0rawres=-30-EROFS広い(失敗した open すべて)
fd.num < 0fdnum=-1広い(fd が取れなかったもの)
  • evt.res は syscall の結果を文字列で表したものです。成功なら SUCCESS、失敗ならエラー名(EROFS / EACCES など)が入ります
  • evt.rawres は syscall が返した生の戻り値です。成功なら 0 以上、失敗なら負の errno になります
  • -30-EROFS である理由: Linux の errno 30 が EROFS(Read-only file system)です。syscall は失敗時に errno へ負号を付けた値を返すので -30 になります
  • %fd.name は open が失敗していても対象パスが取れます/tmp/attacker-tool)。%evt.arg.name も同じ値を返します。「どこへ書こうとしたか」は失敗しても分かります

本回で採用するのは evt.res = EROFS です。 3 つのうち最も具体的で、「不変性の防御が実際に働いた瞬間」だけを拾えるからです。evt.rawres < 0fd.num < 0 は、権限不足(EACCES)・ファイル不在(ENOENT)まで拾ってしまいます。

本回の主張は「不変性は無意味」ではありません。

readOnlyRootFilesystem は攻撃者の手を確実に止めます。その価値は変わりません。言いたいのは、防御と検知は別の価値であり、防御を入れただけでは検知は付いてこないということです。 「気づける防御」にするには、防御の効いた瞬間を拾う条件を自分で書きます。

やってみよう①:既定では無音であることを確かめ、カスタムルールで可視化する

ここまでの話を、自分の手で再現します。「拾えない → もっと拾えない → 拾える」の 3 段を順に踏みます。

演習に入る前に: 本回は helm upgrade を何度も打ちます。

第15回で見たとおり、1 回ごとに Falco の DaemonSet がローリング再起動し(5 台で約 2 分)、その間そのノードは監視されません。 検知基盤自体が、更新中は無音になります——本回の主題(静かなのは、起きていないからか、見ていないからか)と同じ構図が、演習の手順そのものに現れます。本番でルールを頻繁に足す運用にするなら、この空白をどう扱うかを先に決めておきます。
あわせて、本回の変更はすべて ~/falco-lab/custom-values.yaml 経由で入れます。 kubectl edit configmap falco-rules は使いません(第15回で踏んだ Server-Side Apply の衝突を再発させないためです)。

実測では、helm upgrade 1 回あたり「コマンド発行から DaemonSet のローリング完了まで 126 秒」でした(5 ノード)。本回で打つ helm upgrade合計 10 回です(やってみよう① で 2 回・やってみよう② で 4 回・やってみよう③ で 4 回)。合計 20 分前後は、この待ち時間に使います。

ステップ1:既存の防御を読む

前節で実行した 2 つのコマンド(securityContextvolumeMounts)を、まだ打っていなければここで打ちます。コンテナ名は backend です。これから何を「検知したい」のかを、先に自分の目で確認します。

ステップ2:Falco のログを流しながら、書き込みを試す

別ターミナルで Falco のログを --tail=0 で開いておきます(第15回の作法)。backend Pod が動いているノードの Falco Podを選びます。

実行コマンド(k8s-ops・developer・監視用ターミナル):

$ kubectl logs -n falco <backend Pod と同じノードの falco Pod 名> -c falco --tail=0 -f

もう一方のターミナルで、ルートへの書き込みを試します。

実行コマンド(k8s-ops・developer・作業用ターミナル):

$ kubectl exec -n fanclub <backend Pod 名> -c backend -- sh -c 'touch /evil.txt'

実行結果(防御は働きました):

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

このとき Falco 側に出るのは、第15回で作ったカスタムシェルルール 1 本だけです(sh -c を実行しているのでシェル起動として検知されます)。書き込み試行そのものは 0 件です。

ここで一度立ち止まります。防御は働きました。しかし、働いたことは記録されていません。

もし攻撃者が kubectl exec ではなく、アプリの脆弱性を突いてコンテナ内でファイルを置こうとしたなら、シェル起動のアラートすら出ません。 「Falco は静かだった」は「何も起きなかった」ではありません。

ステップ3:open_write 版のルールを書いて、それでも拾えないことを確かめる

~/falco-lab/custom-values.yamlcustomRules.rules-fanclub.yaml に、マクロ 1 本とルール 1 本を追記します(第15回の最終形は壊しません)。

追記する内容(customRules.rules-fanclub.yaml の末尾へ):

    - macro: fanclub_ns_container
      condition: (container and k8s.ns.name = "fanclub")

    - rule: Write in fanclub via open_write
      desc: open_write マクロで書き込みを検知しようとしたもの
      condition: open_write and fanclub_ns_container
      output: >
        open_write 版で検知 (proc=%proc.name file=%fd.name fdnum=%fd.num
        pod=%k8s.pod.name ns=%k8s.ns.name)
      priority: WARNING
      tags: [fanclub, immutability, cks]

第15回の fanclub_backend_pod を再利用しません。

あのマクロは k8s.pod.name startswith "fanclub-backend"backend Pod だけに絞られています。それを使うと、ステップ 5 で作る対照 Pod がマッチせず、対照実験が成立しません。 本回のルール 2 本は同じ fanclub_ns_container でスコープを揃え、条件の違い(fd.num>=0 の有無)だけを比較できるようにします。 fanclub namespace に絞ることで、後半で扱う PostgreSQL のノイズも同じ namespace の中で観察できます(クラスタ全域へ広げる必要はありません)。

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

$ helm upgrade falco oci://ghcr.io/falcosecurity/charts/falco --version 9.1.0 --namespace falco -f ~/falco-lab/custom-values.yaml
$ kubectl rollout status ds/falco -n falco --timeout=300s

rollout status の完了を待たずに次へ進まないでください。

helm upgrade が返ってきた時点では、まだ古いルールで動いている Falco Pod が残っています(ローリングに 126 秒かかります)。待たずに touch を打つと、新しいルールの結果なのか古いルールの結果なのかが区別できません。 本回はこの後「ルールを変えると出力が変わる」ことを何度も確かめるので、ここを飛ばすと結論が逆に読めます。

ローリング再起動が終わったら、ルールが読み込まれたことを必ず確認します。

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

$ kubectl logs -n falco <falco Pod 名> -c falco --tail=20 | grep -E 'Loading rules|schema validation'

実行結果:

Loading rules from:
   /etc/falco/falco_rules.yaml | schema validation: ok
   /etc/falco/rules.d/rules-fanclub.yaml | schema validation: ok

ok を確認してから、ステップ 2 の touch /evil.txt を再実行します。結果は 0 件のままです。 ルールは読み込まれ、条件は文法的に正しく、それでも 1 件も出ません——open_write の内側の fd.num>=0 が外れないからです。

このルールを入れた瞬間から、PostgreSQL のログが流れ始めます。

探している touch は 1 件も出ないのに、関係のない書き込みだけが延々と出ます。 実測では定常状態で 60 秒あたり 4 件postmaster.pid / /dev/shm/PostgreSQL.* / base/16384/2601 / base/16384/1259 の 4 ファイル)です。ただしスナップショットから復元した直後など、DB が動き出したばかりのタイミングでは跳ね上がります——復元直後の実測では約 2 分で 123 件でした。「探しているものは出ないのに、探していないものが出る」——この状態そのものが、後半のアラート設計の題材になります。ログを追うときは grep 'evil.txt' のように対象を絞ってください。

ステップ4:evt.res = EROFS 版に書き換えて、可視化する

open_write マクロを使わず、条件を自分で全部書きます。 ステップ 3 のルールはこの後の比較で使うので残したまま、もう 1 本を追記します。

追記する内容(customRules.rules-fanclub.yaml の末尾へ):

    - rule: Blocked write in immutable container
      desc: readOnlyRootFilesystem に阻まれた書き込み試行
      condition: >
        evt.type in (open,openat,openat2)
        and evt.is_open_write=true
        and fanclub_ns_container
        and evt.res = EROFS
      output: >
        不変コンテナへの書き込みが阻止された (proc=%proc.name file=%fd.name
        res=%evt.res rawres=%evt.rawres fdnum=%fd.num
        container=%container.name pod=%k8s.pod.name ns=%k8s.ns.name)
      priority: WARNING
      tags: [fanclub, immutability, cks]

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

$ helm upgrade falco oci://ghcr.io/falcosecurity/charts/falco --version 9.1.0 --namespace falco -f ~/falco-lab/custom-values.yaml
$ kubectl rollout status ds/falco -n falco --timeout=300s
$ kubectl exec -n fanclub <backend Pod 名> -c backend -- sh -c 'touch /evil.txt'

実行結果(Falco のログ・今度は発火します):

12:49:35.156388015: Warning 不変コンテナへの書き込みが阻止された (proc=touch file=/evil.txt res=EROFS rawres=-30 fdnum=-1 container=backend pod=fanclub-backend-56875d65c9-4fdf8 ns=fanclub) container_id=232e20b98e4e container_name=backend container_image_repository=k8s-registry:5000/fanclub-backend container_image_tag=0.4.1 k8s_pod_name=fanclub-backend-56875d65c9-4fdf8 k8s_ns_name=fanclub

res=EROFS / rawres=-30 / fdnum=-1 が出力に載りました。 防御が働いた瞬間が、初めて記録に残りました。

evt.is_open_write は open が失敗しても true になります。

ステップ 4 のルールは evt.is_open_write=true を含んだまま、fd.num=-1 のイベントに発火しています。このフラグは「書き込み目的で open しようとしたか」をオープンフラグから判定するもので、成否は見ていません。つまり open_write マクロが失敗した open を落としているのは evt.is_open_write ではなく fd.num>=0 のほうです。 ここを取り違えると、原因追及の方向を誤ります。

ステップ5:対照実験(書ける場所で取る)

不変性のある / なしで結果が変わることを確かめます。比較対象の Pod を fanclub namespace に作ります。

マニフェスト(k8s-ops 上・~/falco-lab/mutable-probe.yamlPSA restricted を通る最小形):

apiVersion: v1
kind: Pod
metadata:
  name: mutable-probe
  namespace: fanclub
spec:
  restartPolicy: Never
  securityContext:
    runAsNonRoot: true
    runAsUser: 10001
    seccompProfile:
      type: RuntimeDefault
  containers:
    - name: probe
      image: busybox:1.37
      command: ["sleep", "3600"]
      securityContext:
        allowPrivilegeEscalation: false
        capabilities:
          drop: ["ALL"]

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

$ kubectl apply -f ~/falco-lab/mutable-probe.yaml

実行結果(警告 1 行も出ずに作成されます):

pod/mutable-probe created

このマニフェストには readOnlyRootFilesystem がありません。 それでも enforce: restricted の namespace に通ります——PSS Restricted が不変性を要求していない証拠です。

3 つの対象で、同じパスへの書き込みを比べます。

対象コマンド結果Falco
backend(不変)touch /tmp/attacker-toolRead-only file systemres=EROFS rawres=-30 fdnum=-1(EROFS 版が拾う)
mutable-probe(不変性なし)touch /tmp/attacker-tool成功fdnum=3open_write 版が拾う)
backend の emptyDirtouch /opt/payara/tmp/ok.txt書けるopen_write 版が拾う

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

$ kubectl exec -n fanclub <backend Pod 名> -c backend -- sh -c 'touch /tmp/attacker-tool'
$ kubectl exec -n fanclub mutable-probe -c probe -- sh -c 'touch /tmp/attacker-tool; ls -l /tmp/attacker-tool'
$ kubectl exec -n fanclub <backend Pod 名> -c backend -- sh -c 'touch /opt/payara/tmp/ok.txt; ls -l /opt/payara/tmp/ok.txt'

実行結果(2 行目・不変性が無ければ書けます):

-rw-r--r--    1 10001    root             0 Aug  2 12:50 /tmp/attacker-tool

対照は「書ける場所」で取ります。

対照 Pod で / に書こうとすると Permission deniedEACCES になり、EROFS にはなりません。runAsUser: 10001 で動いているのに /root:root 755 なので、通常のファイル権限で先に落ちるからです。/tmp1777)で取らないと、「不変性のせいで書けなかったのか、権限のせいで書けなかったのか」が区別できません。

本番向けのガードレール。

検証用の Pod を業務 namespace に作るのはラボだからできることです。本番では専用の検証 namespace を用意します。 業務 namespace に検証物を置くと、NetworkPolicy や監視の対象範囲に紛れ込み、後から「これは何だったか」が分からなくなります。

ステップ5.5:対照 Pod を「不変」にする —— 不変性を自分の手で実装する

本回で唯一、読者が不変性を書く場面です。 公式コンピテンシー Ensure immutability of containers at runtime は、ここで満たします。backend 本体には一切触りません(本番相当のワークロードを演習で書き換えないためです)。所要は 3〜5 分で、helm upgrade は不要です。

1. readOnlyRootFilesystem: true を足して作り直す

マニフェスト(~/falco-lab/mutable-probe.yaml を書き換え・コンテナの securityContext に 1 行追加):

apiVersion: v1
kind: Pod
metadata:
  name: mutable-probe
  namespace: fanclub
spec:
  restartPolicy: Never
  securityContext:
    runAsNonRoot: true
    runAsUser: 10001
    seccompProfile:
      type: RuntimeDefault
  containers:
    - name: probe
      image: busybox:1.37
      command: ["sleep", "3600"]
      securityContext:
        allowPrivilegeEscalation: false
        readOnlyRootFilesystem: true
        capabilities:
          drop: ["ALL"]

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

$ kubectl delete pod -n fanclub mutable-probe
$ kubectl apply -f ~/falco-lab/mutable-probe.yaml
$ kubectl exec -n fanclub mutable-probe -c probe -- sh -c 'touch /tmp/attacker-tool'

実行結果:

touch: /tmp/attacker-tool: Read-only file system
command terminated with exit code 1

Falco の出力も入れ替わります。 直前まで fdnum=3open_write 版が拾っていたものが、res=EROFS rawres=-30 fdnum=-1 になり、EROFS 版が拾います。 ルールは 1 文字も変えていないのに、出力するルールが変わりました——変えたのは Pod の securityContext だけです。

実行結果(Falco のログ):

12:52:03.942564162: Warning 不変コンテナへの書き込みが阻止された (proc=touch file=/tmp/attacker-tool res=EROFS rawres=-30 fdnum=-1 container=probe pod=mutable-probe ns=fanclub) container_id=bcde1d08085c container_name=probe container_image_repository=docker.io/library/busybox container_image_tag=1.37 k8s_pod_name=mutable-probe k8s_ns_name=fanclub

2. /tmp に emptyDir をマウントして、必要な穴を開ける

マニフェスト(~/falco-lab/mutable-probe.yaml を書き換え・volumeMounts と volumes を追加):

apiVersion: v1
kind: Pod
metadata:
  name: mutable-probe
  namespace: fanclub
spec:
  restartPolicy: Never
  securityContext:
    runAsNonRoot: true
    runAsUser: 10001
    seccompProfile:
      type: RuntimeDefault
  containers:
    - name: probe
      image: busybox:1.37
      command: ["sleep", "3600"]
      securityContext:
        allowPrivilegeEscalation: false
        readOnlyRootFilesystem: true
        capabilities:
          drop: ["ALL"]
      volumeMounts:
        - name: probe-tmp
          mountPath: /tmp
  volumes:
    - name: probe-tmp
      emptyDir: {}

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

$ kubectl delete pod -n fanclub mutable-probe
$ kubectl apply -f ~/falco-lab/mutable-probe.yaml
$ kubectl exec -n fanclub mutable-probe -c probe -- sh -c 'touch /tmp/attacker-tool; ls -l /tmp/attacker-tool'

実行結果(再び書けるようになります):

-rw-r--r--    1 10001    root             0 Aug  2 12:52 /tmp/attacker-tool

3. backend に回収する

今やった 2 段が、そのまま fanclub-backend の姿です。 ルートを readOnlyRootFilesystem: true で塞ぎ、書く必要のある /var/log/appapplog)と /opt/payara/tmppayara-tmp)にだけ emptyDir を開ける——この作業を第1巻の時点で済ませた結果が、いま backend に入っている設定です。

試験で「コンテナを不変にせよ」と言われたら、この 2 段を書きます。 1 段目だけだとアプリが起動に失敗することがあり、どこに書いているかを kubectl logs のエラーから読み取って、2 段目で穴を開ける——という順序になります。

ステップ6:後始末

対照 Pod を削除します。カスタムルールは次の演習で使うので残します(回全体の後始末は やってみよう③ ステップ 6 で行います)。

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

$ kubectl delete pod -n fanclub mutable-probe --ignore-not-found

やってみよう②:rule_matching: first —— 広いルールが具体的なルールを潰す

第15回で、/etc/falco/falco.yaml の設定を読んだときに rule_matching: first(最初にマッチしたルールで打ち切る)を確認しました。ここでは、それを自分で書いた 2 本のルールで実際に起こします。

実行コマンド(k8s-ops・developer・設定値の再確認):

$ kubectl exec -n falco <falco Pod 名> -c falco -- grep -n 'rule_matching' /etc/falco/falco.yaml

実行結果(106 行目):

106:rule_matching: first

第15回で扱った分と、本回で新しく踏み込む分

論点第15回本回
rule_matching: first の存在と意味扱い済み第15回falco.yaml 実測値表)再掲
同一イベントの評価順を決めるのはルールを定義した順である未(rules_files の読み込み順=override の話とは別物です)本回で扱います
priority は発火順序と無関係本回で扱います
rule_matching: all という選択肢と代償本回で扱います

観測できる事実はこうです——1 つのイベントに対して、最初にマッチした 1 本だけが発火します。 そして評価の順番を決めるのは、ルールを定義した順です。したがって広いルールを前に置くと、後ろの具体的なルールが警告もエラーも出さずに発火しなくなります。

ステップ1:広いルールを、具体的なルールの前に置く

Blocked write in immutable containerに、evt.rawres < 0 の広いルールを追加します。

追記する内容(Blocked write in immutable container直前へ):

    - rule: Any failed open in container
      desc: 失敗した open すべて(広いルール)
      condition: >
        evt.type in (open,openat,openat2)
        and evt.is_open_write=true
        and container
        and evt.rawres < 0
      output: >
        失敗した open (proc=%proc.name file=%fd.name res=%evt.res rawres=%evt.rawres
        pod=%k8s.pod.name ns=%k8s.ns.name)
      priority: WARNING
      tags: [fanclub, immutability, cks]

ステップ2:広いほうだけが発火する

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

$ helm upgrade falco oci://ghcr.io/falcosecurity/charts/falco --version 9.1.0 --namespace falco -f ~/falco-lab/custom-values.yaml
$ kubectl rollout status ds/falco -n falco --timeout=300s
$ kubectl exec -n fanclub <backend Pod 名> -c backend -- sh -c 'touch /evil.txt'

実行結果(Falco のログ・広いほうだけが出ます):

12:56:51.136683618: Warning 失敗した open (proc=touch file=/evil.txt res=EROFS rawres=-30 pod=fanclub-backend-56875d65c9-4fdf8 ns=fanclub) container_id=232e20b98e4e container_name=backend container_image_repository=k8s-registry:5000/fanclub-backend container_image_tag=0.4.1 k8s_pod_name=fanclub-backend-56875d65c9-4fdf8 k8s_ns_name=fanclub

このルールは container スコープなので、クラスタ全体の失敗した open を拾います。

実測では fanclub 以外からも出ました——Longhorn のコンテナが /dev/tty を開こうとして ENXIOrawres=-6)になるものが、5 分で 295 件です(k8s-wl-01 が 147 件・k8s-wl-02 が 148 件。約 1 秒に 1 件)。EROFS ですらありません。 「失敗した open すべて」という条件は、それだけ広いということです。この広さが、この後の実験でそのまま効いてきます。

Blocked write in immutable container は 0 件です。エラーも警告も出ません。schema validationok のままで、ルールは正しく読み込まれています。 それでも出ません。

ステップ3:2 本の順序だけを入れ替える

Any failed open in containerBlocked write in immutable container後ろへ移します。条件は 1 文字も変えません。 変えるのは並び順だけです。

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

$ helm upgrade falco oci://ghcr.io/falcosecurity/charts/falco --version 9.1.0 --namespace falco -f ~/falco-lab/custom-values.yaml
$ kubectl rollout status ds/falco -n falco --timeout=300s
$ kubectl exec -n fanclub <backend Pod 名> -c backend -- sh -c 'touch /evil.txt'

実行結果(今度は具体的なほうだけが出ます):

12:58:56.609767046: Warning 不変コンテナへの書き込みが阻止された (proc=touch file=/evil.txt res=EROFS rawres=-30 fdnum=-1 container=backend pod=fanclub-backend-56875d65c9-4fdf8 ns=fanclub) container_id=232e20b98e4e container_name=backend container_image_repository=k8s-registry:5000/fanclub-backend container_image_tag=0.4.1 k8s_pod_name=fanclub-backend-56875d65c9-4fdf8 k8s_ns_name=fanclub

priority を上げても順番は変わりません。

公式ドキュメント(https://falco.org/docs/concepts/rules/basic-elements/)はpriority は他のルールを上書きしたり発火順序を決めたりするものではない。順序を制御するのはルールを定義する順番である」と明記しています。Blocked write in immutable containerpriorityCRITICAL に上げても、広いルールが前にある限り発火しません。 falco.org/docs は CKS 試験中に参照できるので、この記述はその場で確認できます。

ステップ4:rule_matching: all という選択肢

Falco 0.36.0 以降rule_matching: all を選べます。同一イベントにマッチしたルールが互いを打ち切らなくなり、該当するもの全部が発火します。 公式は代償として CPU 使用の増加を挙げています(ドキュメント由来の記述で、本ラボでの実測値ではありません)。

values に書く形(~/falco-lab/custom-values.yaml へ追記):

falco:
  rule_matching: all

falco:customRules: と同じトップレベルに置きます(falco.yaml に流し込まれるキーがこの下にまとまっています)。

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

$ helm upgrade falco oci://ghcr.io/falcosecurity/charts/falco --version 9.1.0 --namespace falco -f ~/falco-lab/custom-values.yaml
$ kubectl rollout status ds/falco -n falco --timeout=300s
$ kubectl exec -n falco <falco Pod 名> -c falco -- grep -n 'rule_matching' /etc/falco/falco.yaml
$ kubectl exec -n fanclub <backend Pod 名> -c backend -- sh -c 'touch /evil.txt'

実行結果(falco.yaml の同じ 106 行目が書き換わります):

106:rule_matching: all

実行結果(Falco のログ・2 本とも出ます):

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

2 行のタイムスタンプが 13:01:51.804467722 でナノ秒まで一致しています。 別々のイベントが 2 回起きたのではなく、1 つのイベントから 2 本のアラートが出たことの証拠です。

CPU については、本ラボの規模では差を読み取れませんでした。 kubectl top pod -n falco の実測は切替前が 5m16m、切替 60 秒後が 5m13m で、ゆらぎの範囲に収まっています。 公式が挙げる CPU 増は、ルール本数とイベント量がラボよりはるかに多い環境での話だと考えてください。「本番で試す前に負荷をかけて測る」——それが公式の推奨する手順です。

なお chart の values では、rule_matching の説明に [Incubating](開発途上)と書かれています。本番で採用する前に、この表記が外れているかを確認します。

rule_matching の意味は、試験中どこにも書いていません。

chart の values(helm show values)には first / all の説明と [Incubating] の注記がありますが、helm.sh も chart のリポジトリも CKS では参照できません。 本ラボの /etc/falco/falco.yaml にも手がかりはありません——chart が values から生成したファイルなので、コメントが 1 行も残っていません(実測で 0 行)。falco.org/docs 側で確実に引けるのは、上で引用したpriority は発火順序を決めない」のほうです。したがって rule_matching: first が既定であることと、その意味は、暗記対象になります。

ステップ5:first に戻す

本ラボの既定は first のままにします。 ステップ 4 で追加した falco: の 2 行を ~/falco-lab/custom-values.yaml から削除し、もう一度反映します。ステップ 3 で入れ替えた順序はそのまま(具体的なものが先・広いものが後)にしておきます。

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

$ helm upgrade falco oci://ghcr.io/falcosecurity/charts/falco --version 9.1.0 --namespace falco -f ~/falco-lab/custom-values.yaml
$ kubectl rollout status ds/falco -n falco --timeout=300s
$ kubectl exec -n falco <falco Pod 名> -c falco -- grep -n 'rule_matching' /etc/falco/falco.yaml

実行結果:

106:rule_matching: first

運用の原則はこうです——具体的なルールを先に、広いルールを後に並べます。 Audit Policy の評価順(第16回)とまったく同じ形です。catch-all を先頭に置くと、後ろが全部無意味になります。

この論点が効く場面

場面何が起きるか
既定 25 ルールと自作ルールが同じ evt.type を見る既定ルールが前にあれば、自作ルールは発火しません
自作ルールを追記していく運用後から足したルールほど後ろに来ます=新しいルールほど潰されやすい
「静かになった」ことを成功と読む第15回と同じ罠です。潰れているのか、起きていないのかが区別できません

第15回の override: condition: replace と、本回の「順序で潰れる」は別の話です。 混同しないよう、並べて対比します。

項目override: condition: replace(第15回)rule_matching: first による先勝ち(本回)
何をするもの既定ルールの条件を、自分の条件で置き換える1 イベントに対し最初にマッチした 1 本で打ち切る
いつ効くかルールの読み込み時rules_files の順で後勝ち)イベントの評価時(定義順で先勝ち)
気づけるか気づけます。 自分で明示的に書いた変更です。記法を誤れば schema validation が落ちます気づけません。 schema validation: ok のまま、エラーにならないまま発火しなくなります
確認方法読み込みログ(schema validation: ok実際にイベントを起こして、期待するルールが出るか数える

アラート設計 —— 届かない検知に意味はない

ルールが書けるようになると、次は「これを誰に、どうやって届けるか」という設計の問題になります。Falco Pod のログに出ているだけでは、誰も見ていません。

ノイズは priority では落とせない

やってみよう① ステップ 3 で入れた open_write 版のルールは、実運用ではノイズ源になります。 fanclub namespace には PostgreSQL が居るからです。PostgreSQL だけで 60 秒ごとに 4 件発火します。

/var/lib/postgresql/data/pgdata/postmaster.pid
/dev/shm/PostgreSQL.1535092568
/var/lib/postgresql/data/pgdata/base/16384/2601
/var/lib/postgresql/data/pgdata/base/16384/1259

すべて priority が Warning です。 したがって、出力先で minimumpriority: warning を設定しても1 件も落ちません。 「重要度でしきい値を切ればノイズは減る」という発想は、ここでは効きません。

ノイズはルールの condition 側で落とします。

and not container.image.repository endswith "postgres" のような除外条件を書くか、そもそも open_write をクラスタ全域に適用しない設計にします。送ってからフィルタするのではなく、送る前に消します。 出力先のしきい値は「送る量を減らす最後の手段」であって、設計の一段目ではありません。

falcosidekick の minimumpriority は、出力先ごとにしかありません。 chart 0.12.1 の values を数えると minimumpriority64 箇所あり、そのすべてが config.<出力先>.minimumpriority の形です。config 直下に「全体のしきい値」を置くキーはありません。 全体で絞りたい場合は、falcosidekick ではなく Falco 本体の priority/etc/falco/falco.yaml の 101 行目・実機は debug =すべて出す)で絞ります。

検知は二系統ある

由来経路性格
Falco(syscall)falcosidekick → 出力先件数が多い。 ノイズ除外が前提です
監査ログ(API 操作)ファイル → ログ基盤本ラボでは現在経路なし第16回で撤去)。集約には第16回で述べた制約(Secret の複製)が付きます

通知先の分け方

分類届け方
即応が要る特権 Pod の起動・release_agent の書き換え・想定外のシェル起動呼び出し(本番なら PagerDuty 等の当番呼び出し)
日次で足りる阻止された書き込み・想定内の execログ基盤へ蓄積し、ダッシュボードで見る
落とすPostgreSQL の定期書き込みルール条件で除外(送る前に消す)

本回で作った Blocked write in immutable container は「日次で足りる」側です。 防御は既に働いているので、緊急に人を起こす必要はありません。ただし件数の傾向が変わったら調べます——普段 0 件のものが 1 日 100 件になったなら、誰かが何かを試しています。

本ラボでの経路の選択

経路本回での扱い
falcosidekick → Loki → Grafanaラボ内で端から端まで通ります。 「届く」経路として実演します
falcosidekick → Alertmanager「送信は成功するのに誰にも届かない」実例として実演します
falcosidekick → Slack記法の提示のみ(外部 SaaS への到達性は検証しません)

Slack へ送る場合の values は次の形です。本ラボでは alma-proxy の whitelist に hooks.slack.com が無いため到達しません(whitelist は 44 行のまま変えません)。

falcosidekick:
  config:
    slack:
      webhookurl: "https://hooks.slack.com/services/XXXXXXXXX/YYYYYYYYY/ZZZZZZZZ"
      minimumpriority: "warning"
      outputformat: "all"

参照可否を意識します。

falco.org/docskubernetes.io/docsCKS 試験中に参照できます。一方、falcosidekick(github.com)・Alertmanager(prometheus.io)・Loki と Grafana(grafana.com)・Helm(helm.sh/docs)は参照できません(Helm は CKA では参照可ですが CKS では不可です)。ただしこの 4 つは、そもそも CKS の出題範囲外です。設定キー(falcosidekick.enabled / config.<出力先>.hostport / endpoint: /api/v2/alerts / {source="syscall"})は試験のための暗記ではなく、現場で使うために覚えます。

やってみよう③:falcosidekick を入れる —— Loki には届き、Alertmanager には届かない

falcosidekick は、Falco のアラートを受け取って各種の出力先へ転送するコンポーネントです。Falco 本体は「検知」だけを行い、「どこへ送るか」は falcosidekick が担います。

falcosidekick から Loki(204 で届き Grafana で見える)と Alertmanager(v1 は 410 Gone・v2 は 200 だが receiver が null で誰にも届かない)へ分岐し、Slack は記法のみで未到達であることを示す図
図3:falcosidekick から Loki へは届き、Alertmanager へは届かない

ステップ1:導入 —— helm repo add は要らない

chart 9.1.0 には falcosidekick が同梱されていますfalco/charts/falcosidekick/)。第15回で判明した「helm repo addgithub.io)が Forbidden になる」問題を踏まずに、values だけで入ります。

項目
falcosidekick chart0.12.1(appVersion 2.31.1)
image(chart 既定)docker.io/falcosecurity/falcosidekick:2.32.0
replicaCount 既定2(ラボでは 1 に落とします)
Servicefalco-falcosidekick(ClusterIP・2801/TCP・2810/TCP
whitelist の追加不要(イメージは docker.io、chart は OCI ghcr.io で、いずれも登録済み)

values(~/falco-lab/custom-values.yaml末尾へ追記・customRules: と同じ階層):

falcosidekick:
  enabled: true
  replicaCount: 1
  config:
    loki:
      hostport: "http://loki.monitoring:3100"
    alertmanager:
      hostport: "http://kube-prometheus-stack-alertmanager.monitoring:9093"

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

$ helm upgrade falco oci://ghcr.io/falcosecurity/charts/falco --version 9.1.0 --namespace falco -f ~/falco-lab/custom-values.yaml
$ kubectl rollout status deploy/falco-falcosidekick -n falco --timeout=300s
$ kubectl get svc -n falco

実行結果:

NAME                  TYPE        CLUSTER-IP      EXTERNAL-IP   PORT(S)             AGE
falco-falcosidekick   ClusterIP   10.106.39.222   <none>        2801/TCP,2810/TCP   2m9s

Falco 側の設定は chart が自動で配線します。 実機の /etc/falco/falco.yaml を見ると、http_output(35 行目)が falcosidekick の Service を向き、json_output: true(54 行目)が有効になっています。values に自分で書く必要はありません。

ステップ2:Loki へは既定のまま通る

先ほどの touch /evil.txt をもう一度打ってから、falcosidekick のログを読みます。

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

$ kubectl logs -n falco deploy/falco-falcosidekick --tail=20

実行結果(Loki は 204 で受け取っています):

2026/08/02 13:05:56 [INFO]  : Loki - POST OK (204)

Loki の endpoint は既定(/loki/api/v1/push)のまま通ります。 Grafana で中身を確認します。Grafana は ClusterIP のみで外部公開していないので、kubectl port-forward でつなぎます。

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

$ kubectl port-forward -n monitoring svc/kube-prometheus-stack-grafana 3000:80

Service 名は kubectl get svc -n monitoring で確認できます(実機は Grafana が kube-prometheus-stack-grafana80/TCP、Alertmanager が kube-prometheus-stack-alertmanager9093/TCP、Loki が loki3100/TCP です)。これらは第2巻で構築した監視基盤で、本回はそこへ相乗りします。

Grafana の Explore で Loki データソースを選び、LogQL(Loki のクエリ言語。{ラベル="値"} でログの流れを選びます)で引きます。

{source="syscall"}
項目実測
stream ラベルhostname / k8s_ns_name / k8s_pod_name / priority / rule / source / tags
rule ラベルの値Contact K8S API Server From Container / Shell spawned in fanclub-backend pod / Blocked write in immutable container
同梱ダッシュボードchart は Grafana 用 ConfigMap falcosidekick-loki-dashboard-grafana も作ります(loki.grafanaDashboard.enabled: true が既定)

ここまでで「届く」経路が 1 本できました。 Falco のログを人が読みに行くのではなく、ログ基盤の側で横断して検索できます。

ステップ3:Alertmanager は chart 既定だと全件 410 で落ちる

同じログの中に、Alertmanager 側のエラーが並んでいます。

実行結果(kubectl logs -n falco deploy/falco-falcosidekick より):

2026/08/02 13:05:56 [ERROR] : AlertManager - unexpected Response (410)
2026/08/02 13:05:56 [ERROR] : AlertManager - 410 Gone

原因は chart 既定の endpoint: "/api/v1/alerts" です。本ラボの Alertmanager は v0.32.1 で、v1 API は撤去済みです。endpoint を明示して /api/v2/alerts にすると通ります。

values(alertmanager の下へ endpoint を追加):

falcosidekick:
  enabled: true
  replicaCount: 1
  config:
    loki:
      hostport: "http://loki.monitoring:3100"
    alertmanager:
      hostport: "http://kube-prometheus-stack-alertmanager.monitoring:9093"
      endpoint: "/api/v2/alerts"

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

$ helm upgrade falco oci://ghcr.io/falcosecurity/charts/falco --version 9.1.0 --namespace falco -f ~/falco-lab/custom-values.yaml
$ kubectl rollout status deploy/falco-falcosidekick -n falco --timeout=300s
$ kubectl exec -n fanclub <backend Pod 名> -c backend -- sh -c 'touch /evil.txt'
$ kubectl logs -n falco deploy/falco-falcosidekick --tail=20

実行結果(200 が返るようになりました):

2026/08/02 13:06:52 [INFO]  : AlertManager - POST OK (200)

この時点で「直った」と読みたくなります。

エラーが消え、POST OK (200) が並んでいます。ここが本演習の分岐点です。 次のステップで、送信先の中身を見に行きます。

ステップ4:200 が返っても、誰にも届いていない

Alertmanager 側にアラートは実在します。

実行コマンド(k8s-ops・developer・別ターミナルで port-forward してから):

$ kubectl port-forward -n monitoring svc/kube-prometheus-stack-alertmanager 9093:9093
$ curl -s --noproxy '*' 'http://localhost:9093/api/v2/alerts?filter=source%3D%22falco%22' | jq -r '.[].labels.rule' | sort | uniq -c | sort -rn

実行結果(アラートは Alertmanager の中にあります):

   6850 Contact K8S API Server From Container
     45 Any failed open in container
      4 Write in fanclub via open_write
      1 Shell spawned in fanclub-backend pod
      1 Blocked write in immutable container

合計 6,901 件が入っていました。探しているものは 1 件で、残りはノイズです。 検知を送る前に落とすべきものが、そのまま通知経路に載っている状態です。

ノイズの主役は、自分で書いたルールではありません。

99.3% は既定ルールの Contact K8S API Server From Container(fluent-bit が API Server へ接続するたびに出ます。実測で 3 分間に 3,346 件)で、やってみよう② で入れた広いルールは 45 件——全体の 0.65% にすぎません。

Falco 側の発火頻度とは順位が逆転しています。 Any failed open in container は Longhorn の /dev/tty をほぼ毎秒拾っているのに、Alertmanager では 45 件しかありません。Alertmanager が同じラベルの組み合わせを 1 件へ畳むからです——/dev/tty はいつも同じ Pod・同じファイルなので 1 件に集約され、Contact K8S API Server From Container は Pod ごとにラベルが変わるので畳まれません。

読み取るべきはこうです——「送った量」と「受け側に溜まる件数」は一致しません。 どちらか一方だけを見て「このルールがうるさい」と判断すると、実際に絞るべきルールを取り違えます。ノイズを減らす作業は、送信側(Falco の condition)と受信側(集約後の件数)の両方を見てから決めます。

それでも誰にも通知は行きません。 kube-prometheus-stack 既定のルーティングは、受け皿が "null" の 1 つだけだからです。

route:
  receiver: "null"
  group_by: [namespace]
  routes:
  - receiver: "null"
    matchers:
    - alertname="Watchdog"
receivers:
- name: "null"

さらに、falcosidekick が付けるラベルには alertname がありません。

container_id / container_image_repository / container_image_tag / container_name /
eventsource / evt_time / hostname / k8s_ns_name / k8s_pod_name / priority /
proc_cmdline / proc_exepath / proc_name / rule / severity / source / tags /
user_name / user_uid
何が起きるか理由
全部が 1 グループに落ちるgroup_by(Alertmanager がどのラベルでアラートをまとめるかの指定)が [namespace] なのに対し、falco 側のラベルは k8s_ns_name です。噛みません
inhibit_rules が一切効かないinhibit_rules重い障害が出ているときに、それに付随する軽いアラートを抑制する仕組みです。抑制の判定はラベルの一致alertnamenamespace)で行うため、そのラベルが無いと機能しません
ルートが既定の receiver: "null" に落ちる誰にも届きません

なお severity は Falco の priority から自動でマップされます(Warningwarning)。annotations には description / info / summary(= ルール名)が入ります。形式としては整っています。それでも届きません。

ラベルの顔ぶれはルールごとに変わります。 上の一覧はシェル起動ルールのものです。open 系のルールでは proc_cmdline / proc_exepath / user_name / user_uid が無く、代わりに evt_res / evt_rawres / fd_name が付きます——Falco 側の output に書いたフィールドが、そのままラベルになるからです。Alertmanager 側で「このラベルで振り分ける」と決めるときは、対象のルールがそのフィールドを出力しているかを先に確認します。

ステップ5:何を直せば届くのか

直す順番を間違えないことが重要です。alertname が無いから届かない」ではありません。

① 必須の修正 —— これをやらない限り、何をしても届かない

Alertmanager 側に "null" 以外の receiver と、そこへ落とす route を追加します。 受け皿が "null" しかない限り、alertname を付けても、group_by を直しても、絶対に届きません。 届かない直接原因はここ 1 点です。

kube-prometheus-stack の values に書く形は次のとおりです。本ラボには通知を受け取れる先(Slack / PagerDuty / メールサーバ)が無いため、本回では記法の提示までとします。 到達の実演は、次のステップで扱う Loki 側で行います。

alertmanager:
  config:
    route:
      receiver: "null"
      group_by: [namespace]
      routes:
        - receiver: "falco-alerts"
          matchers:
            - source="falco"
          continue: false
    receivers:
      - name: "null"
      - name: "falco-alerts"
        slack_configs:
          - api_url: "https://hooks.slack.com/services/XXXX/YYYY/ZZZZ"
            channel: "#security-alerts"

要点は 2 つです。source="falco" でマッチする route を、既定の receiver: "null" より先に置くこと。 そしてその route が指す receiver を、"null" 以外として receivers に定義すること。 片方だけでは届きません。route の評価も上から順で先勝ち——Falco のルールや Audit Policy と同じ形です。

② そのうえで質を上げる修正 —— 到達性の問題ではない

falcosidekick 側で alertname に相当するラベルを付けると、group_by が機能し、inhibit_rules も噛むようになります。 ①を済ませた後で効いてくる改善であって、①の代わりにはなりません。

falcosidekick 側の書き方はこうです。

falcosidekick:
  config:
    customfields: "alertname:FalcoAlert"

これは実際に効きます。 ~/falco-lab/custom-values.yamlfalcosidekick.config の直下にこの 1 行を足し、helm upgrade で反映してから touch /evil.txt を打つと、届いたアラートに alertname="FalcoAlert" が付きます。group_byinhibit_rules も噛むようになります。

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

$ helm upgrade falco oci://ghcr.io/falcosecurity/charts/falco --version 9.1.0 --namespace falco -f ~/falco-lab/custom-values.yaml
$ kubectl rollout status deploy/falco-falcosidekick -n falco --timeout=300s
$ kubectl exec -n fanclub <backend Pod 名> -c backend -- sh -c 'touch /evil.txt'
$ curl -s --noproxy '*' 'http://localhost:9093/api/v2/alerts?filter=source%3D%22falco%22' | jq -r '[.[].labels.alertname] | unique | @json'

実行結果(ラベルは付きましたnull は適用前に届いた分):

[null,"FalcoAlert"]

それでも、届く先は変わりませんでした。

実機で customfields を入れた後に確認すると、アラートの alertname ラベルは FalcoAlert になっていました。しかし受け取る receiver は、溜まっているアラート全件(実測では 18,403 件)が "null" のままです。 ラベルを整えても、受け皿が増えなければ届く先は 1 ミリも動きません。 ①をやらずに②だけをやると、「設定を足したのに何も変わらない」という結果になります。

③ 選ばないという選択 —— 本ラボの結論

Loki 経由に寄せます。 Alertmanager は「Prometheus が出すアラート」のために置かれています。Falco のイベントは形が違います——ラベル体系(alertnamenamespace が前提)・発生頻度(1 秒に何件も出る)・継続時間の概念(Prometheus のアラートは「条件を満たしている間ずっと firing」ですが、Falco のイベントは点です)。無理に載せるより、ログ基盤で受けてダッシュボードで見るほうが素直です。

本回の結論: 検知は動き、送信は 200 で成功し、それでも人には届きませんでした。

「送れた」は「届いた」ではありません。 アラート経路は、送信側のログではなく、受け取る側の設定まで見て初めて確認できます。 「連携しました」と報告する前に、受け取る側の receiver を数えます。

ステップ6:本回の後始末(回全体)

falcosidekick と不変性ルールは残します。 第18回(インシデント対応)の検知フェーズが、これを土台にします。第15回で「本回で入れた Falco は残す」と述べたのと同じ方針です。

残すもの理由
falcosidekick(enabled: true / replicaCount: 1第18回の検知経路になります
macro: fanclub_ns_container下のルールが参照します
rule: Blocked write in immutable containerevt.res = EROFS第18回の土台になります
Loki 出力loki.hostportラボ内で届く唯一の経路です
第15回の rules-fanclub.yaml 既存 4 要素第15回の成果です
撤去するもの撤去理由
rule: Write in fanclub via open_write「拾えないこと」を示す対比専用です。残す意味がなく、PostgreSQL のノイズ源にもなります
rule: Any failed open in containerevt.rawres < 0必ず撤去します。 失敗した open を広く拾うため、①恒常的なノイズ源になる ②rule_matching: first の先勝ちで Blocked write in immutable container を永続的に潰し続けます。本回で学んだ罠を自分で仕掛けたまま第18回へ進むことになります
対照 Pod mutable-probe検証用です(やってみよう① ステップ 6 で削除済み)
Alertmanager 出力alertmanager.hostport / endpoint撤去します。 残すと第18回まで "null" receiver へ 200 で送り続けます。エラーログすら出ないので、存在に気づけません。 本回の主張(送れた ≠ 届いた)と後始末を一致させます
config.customfieldsalertname:FalcoAlertAlertmanager 出力とセットで入れたものです。送り先を撤去するので、この行も一緒に消します

戻すもの: rule_matchingfirst へ(やってみよう② ステップ 5 で実施済みであることを再確認します)。

~/falco-lab/custom-values.yaml の最終形です(全量・これが本回の成果物です)。

customRules:
  rules-fanclub.yaml: |-
    - list: fanclub_shell_binaries
      items: [sh, ash, bash, busybox]

    - macro: fanclub_backend_pod
      condition: (k8s.ns.name = "fanclub" and k8s.pod.name startswith "fanclub-backend")

    - rule: Shell spawned in fanclub-backend pod
      desc: fanclub-backend Pod のいずれかのコンテナ内でシェルが起動した
      condition: >
        spawned_process
        and container
        and fanclub_backend_pod
        and proc.name in (fanclub_shell_binaries)
      output: >
        fanclub-backend Pod でシェル起動 (user=%user.name uid=%user.uid proc=%proc.name
        exe=%proc.exepath cmd=%proc.cmdline tty=%proc.tty
        container=%container.name
        image=%container.image.repository:%container.image.tag
        pod=%k8s.pod.name ns=%k8s.ns.name)
      priority: WARNING
      tags: [fanclub, shell, cks]

    - macro: user_expected_terminal_shell_in_container_conditions
      condition: (k8s.ns.name = "fanclub" and k8s.pod.name startswith "fanclub-backend")
      override:
        condition: replace

    - macro: fanclub_ns_container
      condition: (container and k8s.ns.name = "fanclub")

    - rule: Blocked write in immutable container
      desc: readOnlyRootFilesystem に阻まれた書き込み試行
      condition: >
        evt.type in (open,openat,openat2)
        and evt.is_open_write=true
        and fanclub_ns_container
        and evt.res = EROFS
      output: >
        不変コンテナへの書き込みが阻止された (proc=%proc.name file=%fd.name
        res=%evt.res rawres=%evt.rawres fdnum=%fd.num
        container=%container.name pod=%k8s.pod.name ns=%k8s.ns.name)
      priority: WARNING
      tags: [fanclub, immutability, cks]

falcosidekick:
  enabled: true
  replicaCount: 1
  config:
    loki:
      hostport: "http://loki.monitoring:3100"

実行コマンド(k8s-ops・developer・最後の helm upgrade で確定させます):

$ helm upgrade falco oci://ghcr.io/falcosecurity/charts/falco --version 9.1.0 --namespace falco -f ~/falco-lab/custom-values.yaml
$ kubectl rollout status ds/falco -n falco --timeout=300s
$ kubectl exec -n falco <falco Pod 名> -c falco -- cat /etc/falco/rules.d/rules-fanclub.yaml

/etc/falco/rules.d/rules-fanclub.yaml の中身が、上の customRules.rules-fanclub.yaml と一致していれば完了です。schema validation: ok も確認します。

本回を終えたときのラボの状態です。

項目
Helm falcochart 9.1.0 / app 0.44.1deployed
Falco Pod5/5 Running + falco-falcosidekick 1/1 Running
Pod 総数117 個・異常 0(falcosidekick が 1 つ増えました)
ノード5/5 Ready・v1.36.3
rule_matchingfirst/etc/falco/falco.yaml 106 行目)
カスタムルール2 本Shell spawned in fanclub-backend pod / Blocked write in immutable container)+ list 1・macro 3
falcosidekick の出力先Loki のみPOST OK (204))。Alertmanager 出力は撤去済み
対照 Pod削除済み(pods "mutable-probe" not found
alma-proxy の whitelist44 行のまま(本回も 1 行も足していません)

後始末を終えた状態で touch /evil.txt を打つと、広いルールが消えているので Blocked write in immutable container が単独で出ます。 ここまで来れば、次回の土台が整っています。

暗記必須コマンド(コンテナ不変性 / Falco 検知)

試験ではどう出るか

本回の演習 3 本は、いずれも 20 分前後かかります。 CKS の 1 問は 5〜12 分で解く想定なので、この一連がそのまま出題されることはありません。 出るとすれば、次の 2 か所です。

  • ルールを 1 本書く部分——「指定された条件でカスタムルールを追加し、発火することを確認せよ」。list / macro / rule の記法と、schema validation: ok の確認までが範囲です
  • PSS Restricted と readOnlyRootFilesystem の関係を問う部分——「このコンテナを不変にせよ」に対して、PSA ラベルではなく securityContext を書くのが正解です

6 面の棚卸し・穴の点検・Alertmanager の到達性は、試験の実技よりも実務と KCSA 側で効く知識です。 試験対策として抜き出すなら上の 2 点、実務として持ち帰るなら点検コマンド 5 行、という分け方をします。

ルールの反映手段は、本ラボと試験で違います。

本回では helm upgrade を 10 回打ちましたが、これは Falco を Helm で入れている本ラボ固有の事情です。CKS では Helm の運用そのものが出題範囲外で(helm.sh/docs も参照できません)、ルールを足す課題が出た場合は /etc/falco/ 配下を直接編集する形が普通です。反映は試験環境が systemd 構成なら systemctl restart falco、DaemonSet 構成なら ConfigMap の更新か kubectl rollout restart ds/<name> -n <ns> になります——第15回で述べたのと同じ切り分けです。「ルールを追加する=helm upgrade」という手続きで覚えないでください。 覚えるのは condition の書き方と、schema validation: ok を確認するところまでです。

区分用途設定 / コマンド補足
CKS 実技不変性readOnlyRootFilesystem: true + 書き込み先の emptyDirPSS Restricted は要求しません。 自分で入れます
CKS 実技権限昇格の阻止allowPrivilegeEscalation: falsePSS Restricted の要件でもあります
CKS 実技阻止された書き込みの検知evt.res = EROFS(広くするなら evt.rawres < 0 / fd.num < 0open_writefd.num>=0 なので拾えません
CKS 実技失敗した open のパス取得%fd.name%evt.arg.name も同値)open が失敗しても取れます
CKS 実技ルールの評価順rule_matching: first(既定)/ all定義順が勝ちます。priority は無関係です
CKS 実技ルール読み込みの確認kubectl logs -n falco <falco Pod 名> -c falco | grep 'schema validation'ok を必ず確認します
CKS 実技Falco アラートの確認kubectl logs -n falco <falco Pod 名> -c falco --tail=0 -f第15回の作法です
CKS 実技Admission の点検kubectl get constrainttemplates / kubectl get constraints -A / kubectl get validatingadmissionpolicy「入れた=効いている」ではありません
実務・KCSASidekick の有効化falcosidekick.enabled: truechart に同梱・helm repo add は不要。Helm は CKS の出題範囲外
実務・KCSAAlertmanager の送信先endpoint: /api/v2/alertschart 既定の v1 は 410 Goneprometheus.io は試験中参照不可
実務・KCSALoki のクエリ{source="syscall"}LogQL。grafana.com は試験中参照不可

「区分」列の見方——CKS 実技 は試験でそのまま手を動かす対象です。実務・KCSA の 3 行は CKS の出題範囲外で(falcosidekick / Alertmanager / Loki はいずれも試験中に参照できるドキュメントを持ちません)、覚える理由は「現場で使うから」と「KCSA の観測性の設問で問われるから」です。 試験直前の暗記時間を配分するときは、上 8 行を優先します。

まとめ

  • CKS が挙げる 6 面physical infrastructure / apps / networks / data / users / workloads)で検知を棚卸しすると、常駐しているもの・都度実行のもの・空いている面が分かれます
  • 本ラボの穴は 3 つです。 Users 面が空第16回で監査ログ撤去)/ Gatekeeper は Constraint 0 本第8回で撤去)/ 署名検証は第14回で撤去。Workloads 面で効いているのは PSA だけです
  • 防御は検知を生みません。 readOnlyRootFilesystem に阻まれた書き込みは、既定 25 ルールでは 1 件も残りません
  • open_writefd.num>=0 を含むため、阻止された書き込みには使えません。evt.res = EROFS / evt.rawres < 0 / fd.num < 0 のいずれかで書きます
  • rule_matching: first は先勝ちです。 広いルールを前に置くと、後ろの具体的なルールがエラーにならないまま発火しなくなります。priority は順序に無関係です
  • PSS Restricted は readOnlyRootFilesystem を要求しません。 不変性は securityContext で自分で入れます
  • ノイズは priority では落とせません(PostgreSQL の 60 秒 4 件はすべて Warning)。ルールの condition 側で除外します
  • 送信の 200 は「届いた」ではありません。 Alertmanager は 200 を返しつつ receiver: "null" で捨てます。ラボで端から端まで通るのは Loki + Grafana です
  • 本回も whitelist を 1 行も足していません(44 行のまま・5 回連続です)

第19回で復習する項目。

falco.org/docskubernetes.io/docs は試験中に参照できます。第19回の試験直前の暗記に送るのは readOnlyRootFilesystem + emptyDir の 2 段(PSS Restricted は要求しない)②カスタムルールの condition 記法と evt.res / evt.rawres / fd.numrule_matching と「定義順が勝つ・priority は無関係」の 3 点だけです。falcosidekick / Alertmanager / Loki / Helm の設定キーは CKS の出題範囲外なので、暗記の対象ではなく「現場と KCSA のための知識」として分けて持ちます。 6 面の棚卸しと Alertmanager の receiver: "null" も同じ扱いです。

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

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

  1. CKS 公式コンピテンシーは physical infrastructure / apps / networks / data / users / workloads の 6 つの面にわたる脅威検知を挙げている
  2. Falco の既定ルールには、コンテナ内でのファイル書き込みを検知するルールが含まれている
  3. open_write マクロは fd.num>=0 を条件に含むため、readOnlyRootFilesystem に阻まれた書き込みは拾えない
  4. readOnlyRootFilesystem: true を入れておけば、書き込み試行があったことは自動的に記録される
  5. Pod Security Standards の restricted プロファイルは readOnlyRootFilesystem を要求する
  6. Falco の既定 rule_matching: first では、同じイベントに複数のルールが該当しても最初にマッチした 1 本しか発火しない
  7. ルールの priority を上げると、そのルールが他のルールより先に評価される
  8. falcosidekick から Alertmanager へ 200 が返れば、担当者に通知が届いたことになる
  9. 出力先の minimumprioritywarning にすれば、PostgreSQL の定期的な書き込みノイズは落とせる
解答と解説

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

  • 問1(6 面): 公式表記は Detect threats within physical infrastructure, apps, networks, data, users and workloads です。この 6 面の脅威検知は KCSA の出題範囲とも重なります——CKS の実技だけでなく、Kubestronaut 5 冠の 5 つ目にあたる KCSA でも問われる論点です
  • 問2(既定 25 ルール): Write below etc / Write below root / Write below binary dir は既定 25 本に含まれません。書き込みは既定では見えません
  • 問3(open_write: マクロの定義は (evt.type in (open,openat,openat2) and evt.is_open_write=true and fd.typechar='f' and fd.num>=0) です。落としているのは fd.num>=0 のほうで、evt.is_open_write は open が失敗しても true になります。ここを取り違えると原因追及の方向を誤ります
  • 問4(防御と検知): 防御と検知は別の価値です。阻止された open は fd.num=-1 で、既定ルールにも open_write にも引っかかりません。検知したいなら evt.res = EROFS 等の条件を自分で書きます
  • 問5(PSS Restricted): Restricted の要件一覧に readOnlyRootFilesystem はありません。実機でも、不変性なしの Pod が enforce: restricted の namespace に警告なく admit されます
  • 問6(先勝ち): 既定は rule_matching: first で、1 つのイベントに対して発火するのは最初にマッチした 1 本だけです。rule_matching: all に切り替えると全部が発火します——実機では 2 本のアラートのタイムスタンプがナノ秒まで一致し、1 イベントから 2 本出ていることが確かめられます
  • 問7(priority: 公式が「priority は他のルールを上書きしたり発火順序を決めたりするものではない。順序を制御するのは定義順である」と明記しています。CRITICAL にしても順番は変わりません
  • 問8(200 と到達): 200 は「Alertmanager が受け取った」までを意味します。そこから先のルーティングで receiver: "null" に落ちれば、誰にも届きません
  • 問9(ノイズ): PostgreSQL のノイズはすべて Warning です。priority のしきい値では 1 件も落ちません。ルールの condition 側で除外します

次回予告

第18回「セキュリティインシデント対応 DAIR 実戦」では、第15〜17回で組み上げた検知体制を使って、実際のインシデントに対応します。Detect(Falco アラートの受信と一次評価)→ Analyze(影響範囲の特定)→ Isolate(NetworkPolicy による遮断・証拠保全)→ Recover → Postmortem のフローを通します。本回で確認した「穴」——Users 面が空であること——が、Analyze の段でどう効いてくるかも扱います。

D6 の 5 コンピテンシーが揃いました。Falco(第15回)・監査ログ(第16回)・6 面の地図と不変性・アラート経路(本回)——検知の道具と、その限界の両方が手元にあります。次回は、これらを持って実際のインシデントに向き合います。

→ 詳しくは第18回 セキュリティインシデント対応 DAIR 実戦

前の記事
次の記事