コンテナは隔離ではなく制限だ
一言でいうと
コンテナはホストカーネルを共有します。そのため、システムハードニングは「隔離を強化する」というより、「共有したカーネルに対して何を要求できるかを減らす」に近いものです。
なぜ必要なのか
仮想マシンにはカーネルが別にあります。ゲストでカーネルの脆弱性を突いても、ハイパーバイザーの境界を越えるのは困難です。コンテナは違います。ノード上のすべてのPodが同じカーネルを使い、カーネルのシステムコールインターフェースは数百個あります。コンテナ脱出のCVEは、たいていこの攻撃面のどこかを突きます。
そのため、防御の論理が変わります。侵入そのものを防ぐ代わりに、侵入したプロセスがカーネルに要求できる動作をあらかじめ減らしておきます。Linuxにはこの目的の仕組みが3層あり、Kubernetesはその3つともをPodスペックのフィールドとして公開しています。
| 仕組み | 制限の対象 | Podスペックのフィールド |
|---|---|---|
| seccomp | 呼び出せるシステムコールの一覧 | securityContext.seccompProfile |
| AppArmor / SELinux | アクセスできるファイル・ネットワーク・機能(MAC) | securityContext.appArmorProfile |
| capabilities | root権限の細かな断片 | securityContext.capabilities |
どう動くのか
seccompは、RuntimeDefaultとLocalhostの2つのタイプが実務のすべてです。RuntimeDefaultはコンテナランタイムが持っているデフォルトの拒否リストを使い、Localhostはノードの<seccomp-root>/profilesの下にあるJSONプロファイルをlocalhostProfileのパスで指定します。プロファイルファイルがノードになければ、Podは起動できません。スペックに書いたことと、ノードにあることは別です。
AppArmorはKubernetes 1.30で、アノテーションから正式なフィールドに昇格しました。以前の形式はcontainer.apparmor.security.beta.kubernetes.io/<컨테이너이름>(プレースホルダーはコンテナ名です)アノテーションで、今はsecurityContext.appArmorProfile.typeとlocalhostProfileです。試験ではクラスターのバージョンによって両方が出題されうるため、2つの形式をどちらも知っておく必要があります。ここでもプロファイルはノードに事前にロードされている必要があり、aa-statusで確認します。
capabilitiesは、理解がいちばん簡単で、効果がいちばん大きいものです。コンテナのrootは本物のrootではなく、capabilityの束を持ったUID 0です。drop: ["ALL"]ですべて捨ててから、本当に必要なものだけをaddすれば、たとえばポート80を開く必要があるWebサーバーはNET_BIND_SERVICE1つだけで足ります。privileged: trueは、これらすべてを無効にします。すべてのcapabilityを与え、すべてのデバイスへのアクセスを開き、AppArmor・SELinux・seccompのプロファイルまで無効にします。そのため、privilegedコンテナを探す問題はCKSの定番です。
ホストネームスペースの共有も同じ層です。hostPID: trueだと、コンテナからノードのすべてのプロセスが見え、/proc/<PID>/environを通じて他のプロセスの環境変数を読めます。Secretを環境変数として渡したワークロードが隣にあれば、それで終わりです。hostNetworkはネットワークポリシーを事実上無意味にし、hostIPCは共有メモリの境界を消します。
最後がreadOnlyRootFilesystem: trueです。侵入者がバイナリを置いたり、既存のバイナリをすり替えたりする経路を消します。書き込みが必要なパスは、emptyDirで別にマウントします。
現場での姿
コンテナイメージのスキャン結果を読んでいると、この層の必要性がはっきりしてきます。node:22イメージ1つにインストールされているパッケージは432個で、脆弱性レポートは1,247件(CRITICAL 9、HIGH 137)出ます。ところがlddで、アプリケーションが実際にリンクする共有ライブラリを数えてみると8個です。残りはベースイメージに付いてきたもので、そのほとんどはシェルとパッケージマネージャーとユーティリティです。侵入者にとっては、それが道具箱です。イメージから消すのが最善で、消せないなら、capabilityとseccompでそれらの道具がカーネルに要求できることを減らすのが次善です。
CRIの層でも同じ話が繰り返されます。containerdは、PodスペックのSecurityContextをOCIスペックに変換します。readOnlyRootFilesystem: trueはroot.readonly = trueになり、capabilitiesはprocess.capabilitiesに、seccompProfileはlinux.seccompに落とし込まれます。そしてprivileged: trueは、この変換の過程で、すべてのcapabilityの付与、すべてのデバイスの許可、AppArmor・SELinux・seccompの無効化に展開されます。フィールド1つが3つの層を同時に崩す構造です。実際に適用されたかどうかは、ノードでcrictl inspectを実行して最終的なスペックを読んで確認します。
次のラボですること
この環境には実際のコンテナランタイムがないため、プロファイルが本当に強制されるかは確認できません。代わりに、CKSが実際に採点するもの、つまりPodスペックのフィールドを正確に書く練習をします。seccompの2つのタイプ、AppArmorプロファイル、capabilityのdrop/add、ホストネームスペースの遮断、そしてprivilegedコンテナを見つけ出す検知スクリプトまでを手で作ります。