はじめに
Sreake 事業部の@hymaaa_kです。私は Sreake 事業部にて、 SRE や k8sやクラウドを用いた基盤開発をしています。
Tetragon の機能を検証している際に、 containerSelector で nginx コンテナだけに絞ったポリシーを設定したところ、コンテナ内の cat だけでなく、ホスト側のバイナリである runc もイベントとして記録されることがありました。
📚 read tetragon-demo-control-plane /usr/local/sbin/runc /etc/passwd
📚 read default/nginx /usr/bin/cat /etc/passwd
「nginx に絞っているのになぜ runc が?」と調べたところ、containerSelector は イベント発火時点の cgroup を基準に ‘そのコンテナに属するプロセス’ を判定しています。
一見バグのように見えますが、これは Linux のコンテナ実装の仕組みに根ざした意図的な設計です。本記事では kubectl exec の内部シーケンスと Tetragon の BPF フィルタリングロジックを追いながら、この挙動がなぜ起きるのかを詳しく見ていきます。
再現手順
環境
- kind クラスター(
tetragon-demoという名前) - Tetragon をインストール済み
TracingPolicy の適用
containerSelector で nginx に絞り、/etc/passwd へのアクセスをフックするポリシーを適用します。
apiVersion: cilium.io/v1alpha1
kind: TracingPolicy
metadata:
name: "file-access-detection"
spec:
containerSelector:
matchExpressions:
- key: repo
operator: In
values:
- nginx
kprobes:
- call: "security_file_permission"
syscall: false
args:
- index: 0
type: "file"
- index: 1
type: "int"
selectors:
- matchArgs:
- index: 0
operator: "Equal"
values:
- "/etc/passwd"
containerSelector のフィールドフィルターには現在 name(コンテナ名)と repo(コンテナリポジトリ名)の2つが使えます。ここでは key: repo でイメージのリポジトリ名を指定しています。docker.io/library/nginx:latest の場合、repo の値は nginx になります。Pod ラベルではなくコンテナのフィールドで絞り込む点に注意してください。
イベントの確認
nginx Pod を起動して kubectl exec を実行します。
kubectl run nginx --image=nginx --restart=Never
kubectl exec nginx -- cat /etc/passwd
Tetragon のイベントストリームを確認します。
kubectl exec -n kube-system ds/tetragon -c tetragon -- \\
tetra getevents -o compact --pods nginx
nginx の cat と、ホスト側の runc の両方でイベントが記録されます。
📚 read tetragon-demo-control-plane /usr/local/sbin/runc /etc/passwd
📚 read default/nginx /usr/bin/cat /etc/passwd
kubectl exec のプロセスツリーを追う
なぜ runc がイベントに記録されるのかを理解するには、kubectl exec の内部でどういうプロセスが生まれるかを追う必要があります。
プロセスの起動シーケンス
kubectl
└─ (API Server 経由)
└─ containerd
└─ runc exec
└─ runc init ← /proc/self/exe init として起動
└─ cat ← exec() でプロセスを置き換え
containerd から呼ばれたrunc execは cmd.Start()で/proc/self/exe initを子プロセスとして起動します。このrunc initプロセスでは nsexec()が実行され、setns() 等を利用して既存コンテナの名前空間に参加します。本記事ではこのプロセスを runc-init と呼びます。
親 runc と runc-init のパイプ同期
親runcとrunc-initはパイプ経由で同期しながらセットアップを進めます。runc-initは名前空間のセットアップ完了後、json.Decode(initPipe) により親プロセスの設定完了を待機します。親がaddIntoCgroup()を完了させてからWriteJSON()でパイプに設定を書き込むことで、runc-initが動き出します。
runc (親) runc-init (子)
| |
+- cmd.Start() ───────────────────────>|
| | [C] setns() (PID/NET/MNT ...)
| | [Go] Init() -> startInitialization()
| | \\- json.Decode(initPipe) でブロック
| | (親の書き込みを待機)
+- addIntoCgroup() |
| \\- runc-initプロセスを |
| コンテナcgroupへ移動 |
| ※ CLONE_INTO_CGROUP 経由の場合は |
| cmd.Start()の時点でCgroup移行済み |
+- WriteJSON() ───────────────────────>| 待機を解除
| \\- containerInit()
| \\- linuxSetnsInit.Init()
| +- syncParentReady()
| +- finalizeNamespace()
| \\- linux.Exec()
重要なのは順序です。finalizeNamespace() が実行される時点では、addIntoCgroup() はすでに完了しており、runc-init はコンテナの cgroup に入った状態になっています。
finalizeNamespace() で何が起きているか
finalizeNamespace() は exec() 前にコンテナ内での実行環境を整える関数です。
- UID/GID の切り替え
- capability の調整
- HOME 環境変数の解決(
getUserHome()が/etc/passwdを読む) execコマンドへの権限チェック
この中の getUserHome() が /etc/passwd を読むため、Tetragon の kprobe がここで発火します。mount namespace はすでにコンテナのものに切り替わっているので、runc が読んでいるのはコンテナ内の /etc/passwd です。ログの tetragon-demo-control-plane /usr/local/sbin/runc は「バイナリがホスト側にある」ことを示しているだけで、アクセス先はコンテナのファイルシステムです。
Tetragon のフィルタリングロジック
policy_filter_check() の仕組み
containerSelector のフィルタリングは BPF プログラム内の policy_filter_check() で実装されています。
FUNC_INLINE bool policy_filter_check(u32 policy_id)
{
void *policy_map;
__u64 cgroupid, trackerid;
// policy_id が 0 の場合はすべてのプロセスに適用(フィルタなし)
if (!policy_id)
return true;
// policy_id に対応するマップを取得(存在しなければ対象外)
policy_map = map_lookup_elem(&policy_filter_maps, &policy_id);
if (!policy_map)
return false;
// kprobe 発火時のプロセスの cgroup ID を取得
cgroupid = tg_get_current_cgroup_id();
if (!cgroupid)
return false;
// コンテナ内に後から作られたサブ cgroup を、コンテナの cgroup に正規化するため
// tracker ID がある場合はそちらを使用(cgroup の追跡に利用)
trackerid = cgrp_get_tracker_id(cgroupid);
if (trackerid)
cgroupid = trackerid;
// cgroup ID がポリシーのマップに含まれているかチェック
return map_lookup_elem(policy_map, &cgroupid);
}
ポイントは「kprobe が発火したその瞬間のプロセスの cgroup ID」を見ていることです。execve 時点でもなく、プロセスが生まれた時点でもなく、イベント発生時のリアルタイムな cgroup 所属を使います。
cgroup ID マップの構造
Tetragon の agent は、Pod watcher や NRI フックからの通知を契機に(イベント駆動で)、コンテナの cgroup ID と Kubernetes の Pod 情報を紐づける対応表をユーザー空間で構築・維持しています。BPF マップに書き込まれるのは、このうちポリシー対象となる cgroup ID の集合だけです。
// ユーザー空間側の対応表
cgroup_id → { container_id, namespace, pod_name, ... }
podSelector や containerSelector を指定すると、一致した Pod の各コンテナの cgroup ID がポリシー対象として BPF マップに登録されます。
これが意味することは:プロセスが「コンテナの cgroup サブツリーに入った」時点でコンテナの一部とみなされる、ということです。
コンテナの境界とは
3つの隔離レイヤー
Linux のコンテナは複数の仕組みを組み合わせて実現されています。
| レイヤー | 仕組み | 役割 |
|---|---|---|
| リソース制限 | cgroup | CPU・メモリ・I/O の制限と計測 |
| 名前空間 | namespace | PID・ネットワーク・マウントの分離 |
| ファイルシステム | rootfs + bind mount | コンテナのルートファイルシステム |
finalizeNamespace() 実行時点の runc-init はこういう状態です。
バイナリ : ホスト (/usr/local/sbin/runc)
所属 cgroup : コンテナ (nginx) ← addIntoCgroup() 済み
mount namespace : コンテナ ← setns() 済み
PID namespace : コンテナ ← setns() 済み
バイナリ自体はホスト側ですが、cgroup もファイルシステムもすでにコンテナ側です。
Tetragon が cgroup をコンテナの境界とする理由
Tetragon のメンテナーは upstream の議論で次のように説明しています。
cgroup サブツリーへの参加をもってコンテナの一部とみなすのは正しい設計である。runc のようなランタイムは、セットアップの過程でコンテナの cgroup に入ることがあり、その時点でコンテナのリソース制限や監査対象に含まれるのは自然なことだ。
(upstream の議論 より意訳・要約)
コンテナのセキュリティ監視の観点では、「どのパスのバイナリが動いているか」よりも「どのコンテナのリソース上で何が起きているか」を捉える方が重要な場面が多くあります。containerSelector はこの考え方を忠実に実装しています。
まとめと対策
整理
containerSelectorは cgroup ID ベースのフィルタリングのため、exec()前のrunc-initもマッチします- これは Tetragon のバグではなく、cgroup をコンテナ境界とする意図的な設計
- 監査ログにはコンテナランタイム(
runcなど)のイベントが混在することがあります - runc-init がアクセスしているのはコンテナのファイルシステムなので、 コンテナランタイムとしての正しい処理といえど、コンテナセキュリティ的には捉えるべきイベントとも言えます
対策
ランタイムのイベントをノイズと判断して除外したい場合は、matchBinaries セレクターで絞り込む方法が有効です。
監視対象のバイナリを明示する(ホワイトリスト方式):
selectors:
- matchBinaries:
- operator: In
values:
- /usr/bin/cat
- /bin/sh
- /bin/bash
ランタイムを除外する(ブラックリスト方式):
selectors:
- matchBinaries:
- operator: NotIn
values:
- /usr/local/sbin/runc
- /usr/local/bin/containerd-shim-runc-v2
ブラックリスト方式はランタイムが crun や youki に変わると漏れが生じるため、監視目的が明確な場合はホワイトリスト方式を推奨します。
また、matchBinaries はプロセスの execve 時のバイナリパスで照合するため、シンボリックリンク経由で起動されるコマンドは実体のパスを指定する必要があります。たとえば /bin/sh はディストロによって /usr/bin/dash(Debian系)や /usr/bin/bash(RHEL系)の実体を指すシンボリックリンクであるため、/bin/sh ではなく実体のパスを指定しないとマッチしません。
参考リンク
Tetragon / eBPF ロジック
- Tetragon 構造とフィルタリング解説
- Cilium / Tetragon GitHub Repository
- Detecting a Container Escape with Tetragon and eBPF – Isovalent:Tetragon がどのようにコンテナの境界やホスト側の挙動を eBPF で監視しているかを詳しく解説している公式ブログです。
runc 内部構造とコンテナライフサイクル
- runc ソースコード (GitHub)
- opencontainers/runc
libcontainer/standard_init_linux.go内のfinalizeNamespace()およびgetUserHome()の実装部分などを参照。