Certified Kubernetes Security Specialist (CKS) #6: AppArmor プロファイル (System Hardening)
CKA シリーズ でクラスター運用を身につけ、この CKS シリーズの前半 5 編でネットワーク隔離とクラスターハードニングを扱ったなら、ここでドメインが変わります。System Hardening は Kubernetes の上ではなく ノードの Linux カーネルレベル でコンテナを閉じ込める領域です。その最初の道具が AppArmor です。
AppArmor は、コンテナが呼び出すファイルアクセスと機能 (capability) を プロファイルに書かれた分だけ 許可する Linux セキュリティモジュールです。コンテナが侵害されても、プロファイルが塞いだ経路の外へは手を伸ばせないようにする、被害を閉じ込める最後の防壁です。今回の記事ではプロファイルを自分で作成してノードにロードし、Pod に付けて、実際に止めるかまで確認します。
AppArmor とは何か #
Linux の基本のアクセス制御は、ファイルの所有者・グループ・権限ビットに基づく DAC (Discretionary Access Control) です。ファイルの所有者が権限を自由に変えられるため、プロセスが権限を得るとその権限の範囲全体を使えます。コンテナが root で動くと、この DAC だけでは止められない動作が多くなります。
AppArmor はその上に MAC (Mandatory Access Control) をかぶせます。MAC はプロセスが何をできるかを 管理者が定めたポリシーで強制 し、プロセス自身はそのポリシーを解けません。AppArmor のポリシーの単位がまさに プロファイル (profile) です。プロファイルは、特定の実行ファイルが次のことをどこまでできるかを書きます。
- どのファイル経路を読み (
r)、書き (w)、実行 (x) できるか - どの Linux capability を持てるか
- ネットワーク・マウント・シグナルなどを使えるか
コンテナに AppArmor プロファイルを付けると、そのコンテナ内のプロセスがプロファイルに書かれた範囲外のファイルや機能に手を出した瞬間、カーネルが拒否します。侵害されたコンテナがホストのファイルを読んだり書いたりしようとする試みをノードのカーネルが遮断するので、被害をコンテナの中に閉じ込める 効果があります。
AppArmor は経路ベース (path-based) の MAC なので、ファイル経路のルールが書きやすいです。同じ MAC 系列の SELinux はラベルベースなのでルールがより複雑です。CKS は AppArmor を扱い、試験ノードは通常 AppArmor がデフォルトで有効化されたディストリビューションです。
プロファイルの 2 つのモード #
AppArmor プロファイルは、同じルールをどう適用するかによって 2 つのモードのいずれかで動作します。
| モード | 動作 | 用途 |
|---|---|---|
enforce | プロファイルが許可しない動作を 実際に遮断 してログを残す | 運用。実際の保護 |
complain | 遮断せず、違反動作を ログにだけ記録 (audit) | プロファイル開発・チューニング |
complain モードは、アプリケーションを正常に動かしながらどのアクセスが起きるかのログを集める段階です。そのログを見て必要なルールを埋めてから enforce に切り替える流れが一般的です。CKS 試験で実際の遮断まで確認しなければならない作業は、ほとんどが enforce モードを要求します。
プロファイルの作成 #
プロファイルはノードの /etc/apparmor.d/ 配下にテキストファイルとして置きます。コンテナが / を除くほぼすべての場所に書けないように塞ぐ簡単なプロファイルを例にします。
# /etc/apparmor.d/k8s-deny-write
#include <tunables/global>
profile k8s-deny-write flags=(attach_disconnected) {
#include <abstractions/base>
# すべてのファイル読み取りは許可
file,
# ディスクへの書き込みのすべての試みは拒否
deny /** w,
}中核となる文法を押さえます。
- 最初の行の
profile k8s-deny-writeが プロファイル名 です。Pod に付けるときこの名前をそのまま使います。ファイル名とプロファイル名は違っても構いませんが、混乱しないように揃える方が安全です。 flags=(attach_disconnected)は、コンテナ環境で経路がマウントネームスペースのために切れて見えるときにプロファイルが正常に適用されるよう助けるフラグです。Kubernetes コンテナ用のプロファイルによく付けます。#include <abstractions/base>は、プロセスが正常動作に必要な共通アクセス (ライブラリのロードなど) をあらかじめまとめておいた束です。file,は、すべてのファイルアクセスをいったん許可するルールです。deny /** w,がこのプロファイルの中核です。/**はすべての下位経路を意味し、wは書き込みを、denyは拒否を意味します。つまり どこでも書き込みを禁止 します。
さらに狭めたければ、経路ごとに権限を明示します。次は特定のディレクトリだけ読み取りを許可し、残りの書き込みを塞ぐ形です。
profile k8s-restrict flags=(attach_disconnected) {
#include <abstractions/base>
# アプリケーションディレクトリだけ読み取り・実行を許可
/app/** r,
/usr/bin/** rix,
# 一時ディレクトリだけ書き込みを許可
/tmp/** rw,
# 機密経路へのアクセスを明示的に拒否
deny /etc/shadow r,
deny /proc/sysrq-trigger rwx,
}ルールは上から下へ見るのではなく 権限と拒否を合わせて 判断し、deny は他の許可より優先されます。そのため広く許可してから危険な経路だけ deny でピンポイントに塞ぐ方式がよく使われます。
ノードにプロファイルをロード #
作成したプロファイルはファイルとして置くだけでは動作しません。カーネルに ロード しなければなりません。AppArmor がインストールされたノードで apparmor_parser でロードします。
# プロファイルをカーネルにロード (または更新)。-r は replace
sudo apparmor_parser -r /etc/apparmor.d/k8s-deny-write-r (replace) は、すでに同じ名前のプロファイルがロードされていても新しい内容で上書きします。そのためプロファイルを直した後に再ロードするときも同じコマンドを使います。デフォルトモードは enforce で、complain でロードするには aa-complain を使います。
# complain モードでロード (遮断せずログだけ)
sudo aa-complain /etc/apparmor.d/k8s-deny-write
# 再び enforce モードへ
sudo aa-enforce /etc/apparmor.d/k8s-deny-writeロード結果は aa-status (または apparmor_status) で確認します。
sudo aa-status出力で次を確認します。
apparmor module is loaded.
42 profiles are loaded.
38 profiles are in enforce mode.
...
k8s-deny-write
4 profiles are in complain mode.
...k8s-deny-write が enforce のリストに見えれば、ノードに正常にロードされています。試験ではプロファイル名がこのリストに正確に現れるかでロードの成功を判断する ので、名前の綴りを必ず合わせなければなりません。
重要な落とし穴を 1 つ。プロファイルは Pod がスケジュールされるノードにあらかじめロードされていなければなりません。マルチノードクラスターでは、どのノードにロードしたかと Pod がどのノードへ行くかを合わせなければなりません。試験では通常
nodeNameを指定するか単一のワーカーノードを使うよう案内されますが、複数ノードならすべての候補ノードにロードするか、スケジューリングを固定する方が安全です。
Pod にプロファイルを適用 #
ノードにプロファイルがロードされたら、今度は Pod のコンテナにそのプロファイルを付けます。Kubernetes バージョンによって方式が 2 つに分かれます。
1.30 以上: securityContext.appArmorProfile #
Kubernetes 1.30 から AppArmor の設定が正式なフィールドとして入りました。securityContext の下の appArmorProfile で指定します。Pod 単位とコンテナ単位の両方で使え、コンテナ単位が Pod 単位を上書きします。
apiVersion: v1
kind: Pod
metadata:
name: hardened-pod
spec:
securityContext:
appArmorProfile:
type: Localhost
localhostProfile: k8s-deny-write
containers:
- name: app
image: busybox:1.36
command: ["sh", "-c", "sleep 3600"]type によって意味が変わります。
| type | 意味 |
|---|---|
Localhost | ノードにあらかじめロードされたプロファイルを使う。localhostProfile にプロファイル名を書く |
RuntimeDefault | コンテナランタイムのデフォルト AppArmor プロファイルを適用 |
Unconfined | AppArmor を適用せず、制限のない状態にする |
自分で作成したプロファイルを付けるときは、type: Localhost に localhostProfile で ノードにロードされたプロファイル名 を書くのが中核です。名前が aa-status のリストと正確に同じでなければならず、違うと Pod が作成に失敗します。
1.29 以下: annotation #
1.30 以前は AppArmor を annotation で指定していました。試験クラスターのバージョンが低い場合や既存のマニフェストを扱う場合のために、形式を知っておく必要があります。キーはコンテナ名まで含みます。
apiVersion: v1
kind: Pod
metadata:
name: hardened-pod
annotations:
container.apparmor.security.beta.kubernetes.io/app: localhost/k8s-deny-write
spec:
containers:
- name: app
image: busybox:1.36
command: ["sh", "-c", "sleep 3600"]annotation のキーは container.apparmor.security.beta.kubernetes.io/<コンテナ名> の形で、値は localhost/<プロファイル名> の形です。上の例ではコンテナ名が app なのでキーの末尾が /app で、値としてノードにロードされた k8s-deny-write を localhost/ 接頭で指します。値に書くタイプもフィールド方式と同じで、localhost/<名前>、runtime/default、unconfined を使います。
2 つの方式の対応を覚えておけば、バージョンを問わず解けます。フィールドの
type: Localhost+localhostProfile: NAMEは annotation の値localhost/NAMEと同じで、type: RuntimeDefaultはruntime/default、type: Unconfinedはunconfinedと同じです。
プロファイルが止めるかの検証 #
付けたら終わりではありません。プロファイルが実際に遮断するか を exec で確認する習慣が試験で点数を守ります。上の k8s-deny-write (どこでも書き込み禁止) を付けた Pod で確認します。
# Pod を作成
kubectl apply -f hardened-pod.yaml
# コンテナの中から書き込みを試行
kubectl exec hardened-pod -- sh -c 'echo test > /tmp/x'プロファイルが正常に適用されていれば、書き込みが拒否されて次のようなエラーが出ます。
sh: can't create /tmp/x: Permission denied
command terminated with exit code 1読み取りは塞いでいないので、次は正常に動作するはずです。
# 読み取りは許可されるので成功
kubectl exec hardened-pod -- cat /etc/hostname書き込みが塞がれて読み取りができれば、プロファイルが意図どおりに動作しています。遮断が起きるとノードのカーネルログにも記録が残るので、ノードで次のように確認できます。
# ノードで AppArmor 拒否ログを確認
sudo dmesg | grep -i apparmor
# または
sudo grep -i 'apparmor.*DENIED' /var/log/syslogログに DENIED とともにプロファイル名・遮断された経路が見えれば、どの動作が塞がれたかを正確に押さえられます。逆にプロファイルがきつすぎてアプリケーションの正常動作まで塞いだ場合は、このログがどのルールを緩めるべきかを教えてくれます。
プロファイルを付けたのに遮断が起きないなら、ほぼ常に次の 2 つのいずれかです。1 つ目、プロファイルがそのノードにロードされていません。
aa-statusで確認します。2 つ目、名前がずれています。localhostProfileの値とaa-statusのプロファイル名が 1 文字でも違うと適用されません。
試験ポイント #
CKS の System Hardening ドメインで AppArmor はほぼ毎回出る常連です。次を手に馴染ませておけばミスなく解けます。
- 流れを覚える。 プロファイル作成 → ノードに
apparmor_parser -rでロード →aa-statusでロード確認 → Pod にtype: Localhost+localhostProfile(または annotation) で付ける → exec で遮断検証。この 5 ステップが 1 つの作業の典型です。 - プロファイルがノードにあって初めて Pod が立つ。 最もよくある失敗は、ノードへのロードを抜かして Pod マニフェストだけを直すことです。ロードが先、Pod はその後です。
- 名前を正確に。
localhostProfileの値とaa-statusに見えるプロファイル名が正確に同じでなければなりません。綴りがずれると Pod が作成段階で塞がれます。 - バージョンごとの方式を両方知る。 1.30+ は
securityContext.appArmorProfileフィールド、それ以前はcontainer.apparmor.security.beta.kubernetes.io/<コンテナ>annotation です。試験クラスターのバージョンを先に確認します。 - 3 つのタイプを区別。
Localhost(自分で作ったプロファイル)、RuntimeDefault(ランタイムのデフォルト)、Unconfined(制限なし)。問題が何を要求するかを見て選びます。 - 検証までやる。 exec で塞がれる動作とできる動作を一度ずつ確認すれば、付けるだけで適用されないまま進む事故を防げます。
まとめ #
この記事で押さえたこと:
- AppArmor は Linux の MAC。DAC の上にかぶせ、プロセスがアクセスできるファイル経路と capability をプロファイルで強制し、プロセス自身はそのポリシーを解けません。
- モードは 2 つ。
enforceは実際の遮断、complainはログだけ。開発は complain、運用・試験検証は enforce - プロファイルは
/etc/apparmor.d/に作成 し、file,・deny /** w,・経路ごとのrwxルールで権限を定める。denyが許可より優先 - ロードと確認。
apparmor_parser -rでノードにロードし、aa-statusでプロファイル名が enforce のリストに見えるか確認 - Pod 適用。1.30+ は
securityContext.appArmorProfile(type: Localhost+localhostProfile)、それ以前はcontainer.apparmor.security.beta.kubernetes.io/<コンテナ>annotation。タイプはLocalhost・RuntimeDefault・Unconfined - 検証。exec で遮断・許可を直接確認し、ノードの
dmesg・syslog のDENIEDログで何が塞がれたかを押さえる
次: seccomp プロファイル #
AppArmor がファイルと機能を経路基準で塞ぐなら、同じ System Hardening ドメインの相棒は システムコールそのものを塞ぐ seccomp です。
#7 seccomp プロファイル では、コンテナが呼び出せるシステムコールをホワイトリストで狭める方法、RuntimeDefault プロファイルの意味、カスタム seccomp プロファイル (JSON) をノードに置いて securityContext.seccompProfile で付ける手順、SCMP_ACT_ERRNO・SCMP_ACT_ALLOW のようなアクション、そして遮断されたシステムコールを見つけてプロファイルを狭める流れまで、自分で作りながら整理します。