Certified Kubernetes Security Specialist (CKS) #18: コンテナの不変性、forensics
#17 Falco 行動分析、audit logs では、ランタイムで異常な行動を検知し、監査ログを残す方法を扱いました。検知は「何かがうまくいっていない」と知らせてくれるだけです。今回の記事は同じ Runtime Security ドメインの中で、その前と後ろ、つまり そもそもコンテナを変えられないように固める不変性 (immutability) と すでに突破された後のインシデント対応 (forensics) を整理します。
不変性とインシデント対応はひと組です。コンテナを不変に固めておくと、攻撃者が中でバイナリを仕込んだり設定を変えたりするのが難しくなり、もし侵害が起きても「もともと何があったのか」という基準線がはっきりしているので調査がしやすくなります。試験でもこの 2 つは束になって出ます。不変の設定を適用する作業と、侵害された Pod を隔離して証拠を保全する手順です。
不変コンテナとは何か #
不変コンテナ (immutable container) は 実行が始まった後はその中身が変わらない コンテナです。コードを直したり設定を変えたりするには、コンテナの中に入ってファイルを修正するのではなく、新しいイメージをビルドして 再デプロイ します。生きているコンテナに手を入れることはありません。
この考え方はセキュリティに直結します。コンテナが侵害されたとき、攻撃者が真っ先にやろうとするのが 中に何かを仕込むこと です。コインマイニングのバイナリをダウンロードして実行し、バックドアスクリプトを /tmp やシステムパスに書き、既存のバイナリを悪性のもので上書きします。ファイルシステムが読み取り専用なら、これらの書き込みの試みはすべて失敗します。
Kubernetes で不変性を強制する核心の設定はコンテナの securityContext.readOnlyRootFilesystem: true です。#8 kernel hardening で権限を削る securityContext のフィールドを一緒に扱いましたが、そのうちこのフィールドが不変性の出発点です。
readOnlyRootFilesystem が防ぐもの #
このフィールドをオンにすると、コンテナのルートファイルシステムが読み取り専用でマウントされます。次のような攻撃パターンがそのまま防がれます。
curl ... -o /usr/local/bin/minerのような悪性バイナリのダウンロード/etc/cron.dや起動スクリプトへのバックドアの仕込み- 既存のシステムバイナリを悪性のもので上書き
- シェル履歴・一時ファイルを通じた痕跡残し
読み取り専用のファイルシステムは侵害後の調査でも価値が大きいです。ルートファイルシステムが起動時点のイメージと同一であることが保証されるので、改ざんの有無を問う必要のないクリーンな基準線 になります。
readOnlyRootFilesystem の適用 #
核心は 1 行です。コンテナの securityContext に readOnlyRootFilesystem: true を入れます。
apiVersion: v1
kind: Pod
metadata:
name: immutable-web
spec:
containers:
- name: web
image: nginx:1.27
securityContext:
readOnlyRootFilesystem: true
runAsNonRoot: true
allowPrivilegeEscalation: false
capabilities:
drop: ["ALL"]このマニフェストはルートファイルシステムを読み取り専用に固め、非 root で回し、権限昇格を防ぎ、すべての capabilities を落とします。不変性と最小権限をひと束で適用した形です。
書き込みが必要なパスは emptyDir で #
問題は、多くのアプリケーションが動作に書き込みを必要とする点です。nginx は /var/cache/nginx と /var/run に書かなければならず、あるアプリケーションは /tmp に一時ファイルを作ります。ルートファイルシステムを丸ごと読み取り専用にすると、こうした正常な書き込みまで防がれてコンテナが起動できません。
解決は 書き込みが必要なディレクトリだけを選んで書き込み可能なボリュームをかぶせること です。ふつうは emptyDir ボリュームをそのパスにマウントします。emptyDir は Pod の寿命の間だけ存在し、Pod が消えると一緒に空になるので、攻撃者がそこに何かを仕込んでも再デプロイ 1 回で痕跡なく消えます。
apiVersion: v1
kind: Pod
metadata:
name: immutable-nginx
spec:
containers:
- name: web
image: nginx:1.27
securityContext:
readOnlyRootFilesystem: true
allowPrivilegeEscalation: false
capabilities:
drop: ["ALL"]
volumeMounts:
- name: cache
mountPath: /var/cache/nginx
- name: run
mountPath: /var/run
- name: tmp
mountPath: /tmp
volumes:
- name: cache
emptyDir: {}
- name: run
emptyDir: {}
- name: tmp
emptyDir: {}ルートファイルシステム全体は読み取り専用ですが、キャッシュ・ラン・一時ディレクトリの 3 か所だけを書き込み可能な emptyDir で開けてあります。読み取り専用をデフォルトに置き、必要な場所だけを例外で開ける のが不変運用の標準的な形です。
どのパスを開けるべきか見つける方法 #
どのディレクトリに書き込みが必要か事前にわからないことが多いです。最も単純な方法は、ひとまず readOnlyRootFilesystem: true だけを適用して起動してみてから、ログでどこに書き込みを試みて失敗したかを確認することです。
kubectl logs immutable-nginx
# 例: "Read-only file system" または "Permission denied" が指すパスを確認失敗が指すパスだけを emptyDir で 1 つずつ開けていき、コンテナが正常に起動するまで絞り込んでいきます。試験では nginx のようによく知られたイメージが出る場合が多いので、/var/cache/nginx・/var/run・/tmp くらいを覚えておくと素早く通せます。
実行中の変更禁止とドリフトの防止 #
不変性は設定だけでなく 運用原則 でもあります。生きているコンテナに kubectl exec で入ってファイルを直すことはしません。こうした実行中の変更は ドリフト (drift)、つまりマニフェストが宣言した状態と実際のコンテナの状態がずれる問題を生みます。ドリフトが積み重なると「今回っているのが正確に何なのか」を誰も断言できなくなり、それ自体がセキュリティの空白です。
原則は単純です。
- コード・設定の変更は 新しいイメージのビルド → 再デプロイ だけで行う
- 生きているコンテナに入ってファイルを直さない
kubectl execは調査・デバッグだけに使い、変更には使わない
readOnlyRootFilesystem はこの原則を技術的に強制する仕掛けです。人が誤って入ってファイルを直そうとしてもファイルシステムが拒否するので、ドリフトが起きる余地を構造的になくします。
startupProbe で起動を安定化 #
不変コンテナは起動するときにすべての書き込みパスがきちんと開いていてはじめて正常に動作します。起動が遅かったり初期化に時間がかかったりするアプリケーションなら、startupProbe で 起動が終わったか を先に判定し、その後に livenessProbe・readinessProbe が動くようにしておくのが安全です。startupProbe が成功するまでは他のプローブがコンテナを殺さないので、読み取り専用環境で初期化が終わる時間を稼げます。
startupProbe:
httpGet:
path: /healthz
port: 8080
failureThreshold: 30
periodSeconds: 5この設定は最大 150 秒 (30 × 5 秒) まで起動を待ち、その間は liveness の失敗による再起動が起きません。
Forensics: 侵害された Pod を扱う #
検知と予防を経てもなお侵害が起きたなら、ここからは インシデント対応 (incident response) の領域です。CKS が求める forensics の核心は派手な分析ツールではなく 手順 です。順序を間違えると証拠が消えたり攻撃が広がったりします。原則は 2 つです。広がらないように囲い込み、証拠を保全してから調査します。
1) 隔離: 広がらないように囲い込む #
まず最初にやることは、侵害された Pod が他の Pod や外部と通信できないように切ることです。ところが Pod をすぐに消してはいけません。 消した瞬間にメモリ・プロセス・一時ファイルのような揮発性の証拠が一緒に消えてしまうからです。
ネットワークから切ります。侵害された Pod に付いたラベルを選び、すべての ingress・egress を防ぐ NetworkPolicy を適用します。#2 NetworkPolicy で扱った default deny パターンを単一の Pod に狙って使う形です。
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: quarantine-compromised
namespace: prod
spec:
podSelector:
matchLabels:
quarantine: "true"
policyTypes:
- Ingress
- EgresspodSelector で隔離ラベルが付いた Pod を選び、policyTypes に Ingress・Egress を入れつつ許可ルールを 1 つも置かないことですべての通信を遮断します。侵害された Pod に kubectl label pod <name> quarantine=true でラベルを付ければ即座に隔離されます。こうすると攻撃者の指令・制御 (C2) の通信と横方向の移動が断たれます。
ラベルを変えるもう 1 つの効果があります。Deployment が管理する Pod なら、Pod のラベルを変えて Service・ReplicaSet のセレクターから外すとトラフィックがもうその Pod に行かなくなり、コントローラーは正常な Pod を新しく起動します。侵害された Pod は調査用に切り離しておき、サービスは回り続けさせる方法です。
その次にノードを隔離します。侵害がノードレベルに広がった可能性があるなら、kubectl cordon で該当ノードに新しい Pod がスケジュールされないように防ぎます。
kubectl cordon node-3 # 新しい Pod のスケジュール遮断 (既存の Pod は維持)cordon は既存の Pod を追い出さず新しいスケジュールだけを防ぐので、ノード上の証拠を保全しながら被害の拡散を制限します。drain は Pod を追い出して証拠を散らしかねないので、調査前の段階では cordon だけをかけるのが安全です。
2) 証拠の保全: 消す前に残す #
隔離が終わったら、Pod に手をつける前に証拠を確保します。揮発性の高いものから押さえます。
kubectl logs <pod> -n prod --all-containers --previous > evidence-logs.txt
kubectl describe pod <pod> -n prod > evidence-describe.txt
kubectl get pod <pod> -n prod -o yaml > evidence-spec.yaml- ログ: コンテナの標準出力。
--previousで再起動前のログまで確保 - メモリ・プロセス: ノードからコンテナランタイムでプロセス一覧とメモリの状態を確認
- ファイル: コンテナの中で改ざん・追加されたファイル。readOnlyRootFilesystem をオンにしてあれば emptyDir 領域だけ見れば足ります
コンテナランタイムが対応していれば 停止前のスナップショット を取っておきます。コンテナを止めたり消したりする瞬間にメモリと実行中の状態が消えるので、できる限り生きている状態そのままのイメージを残すのが forensics の基本です。
# ノードからコンテナランタイムで現在の状態をイメージに取っておく (例: containerd/Docker)
crictl ps # 侵害コンテナの ID を確認
docker commit <container-id> evidence:incident-0001 # 停止前のスナップショット3) 調査: kubectl debug で覗き込む #
証拠を保全したら、いよいよ調査します。readOnlyRootFilesystem や distroless (#13) イメージでシェルすらない場合が多いのですが、このとき使うツールが kubectl debug です。侵害された Pod に影響を与えずに 一時的なデバッグコンテナ を同じプロセスネームスペースに付けて調査します。
kubectl debug -it <pod> -n prod \
--image=busybox \
--target=web \
--share-processes--target で調査対象コンテナのプロセスネームスペースを共有し、--share-processes でそのコンテナのプロセスをデバッグコンテナから覗き込みます。侵害されたコンテナ自体には新しいバイナリを入れないので、証拠を汚染せずに調査できます。ノード自体を調査しなければならないなら、kubectl debug node/<node> でノードのファイルシステムを /host にマウントしたデバッグ Pod を起動します。
調査が終わってすべての証拠を確保した後にはじめて、侵害された Pod を削除し、クリーンなイメージで再デプロイします。
試験ポイント #
- 不変性の核心の 1 行: コンテナの
securityContext.readOnlyRootFilesystem: true。コンテナ単位のフィールドであって Pod 単位ではない - 書き込みパス: 読み取り専用でコンテナが起動しないなら、失敗したパスを emptyDir でマウントして開ける。nginx は
/var/cache/nginx・/var/run・/tmp - 不変運用: 変更は再デプロイだけで。生きているコンテナは直しません。exec は調査用
- 隔離の順序: 消さずにまず隔離します。隔離ラベル + default deny NetworkPolicy で通信を遮断し、ノードは
cordon(drain ではない) - 証拠の保全: ログ (
--previous)・メモリ・ファイルを消す前に確保。停止前のスナップショット - 調査ツール:
kubectl debugでデバッグコンテナを付着 (--target・--share-processes)。ノードはkubectl debug node/<node> - 順序が点数: 隔離 → 証拠の保全 → 調査 → 削除・再デプロイ。この順序がずれると証拠が消える
まとめ #
この記事で押さえたこと:
- 不変コンテナ は実行後に中身が変わらないコンテナ。攻撃者がバイナリを仕込んだりファイルを改ざんしたりするのが難しくなり、クリーンな基準線が保証される
- readOnlyRootFilesystem: true が不変性の出発点。書き込みが必要なパスだけを emptyDir で例外として開けておく
- ドリフト防止: 変更は再デプロイだけで。startupProbe で読み取り専用環境の遅い起動を安定化
- forensics の手順: 侵害された Pod を消さずに隔離 (NetworkPolicy・ノード cordon) → 証拠の保全 (ログ・メモリ・ファイル、停止前のスナップショット) →
kubectl debugで調査 → 削除・再デプロイ - 試験では不変の設定の適用と侵害 Pod の隔離手順が束になって出て、順序を守ることが点数
これで 6 つのドメインの最後である Monitoring・Logging・Runtime Security まで、すべての技術ドメインを一周しました。
次へ — 試験のコツ #
内容はすべて押さえました。残るのは、その内容を 2 時間以内に 67% へ引き出す運用 です。
#19 試験のコツと時間管理、よく間違えるパターン では、試験開始直後のショートカット・別名 (alias) のセットアップ、難しい作業を飛ばして戻ってくる時間管理、ツールごとのドキュメントを素早く探す方法、そして readOnlyRootFilesystem を適用したのにコンテナが起動しない罠のようなよく間違えるパターンをまとめて整理します。