Tetragon の containerSelector はなぜ runc を検知するのか? cgroup から見るコンテナの境界線

Hayama Kohei

2026.6.12

はじめに

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 execcmd.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 のコンテナは複数の仕組みを組み合わせて実現されています。

レイヤー仕組み役割
リソース制限cgroupCPU・メモリ・I/O の制限と計測
名前空間namespacePID・ネットワーク・マウントの分離
ファイルシステム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

ブラックリスト方式はランタイムが crunyouki に変わると漏れが生じるため、監視目的が明確な場合はホワイトリスト方式を推奨します。

また、matchBinaries はプロセスの execve 時のバイナリパスで照合するため、シンボリックリンク経由で起動されるコマンドは実体のパスを指定する必要があります。たとえば /bin/sh はディストロによって /usr/bin/dash(Debian系)や /usr/bin/bash(RHEL系)の実体を指すシンボリックリンクであるため、/bin/sh ではなく実体のパスを指定しないとマッチしません。

参考リンク

Tetragon / eBPF ロジック

runc 内部構造とコンテナライフサイクル

  • runc ソースコード (GitHub)
    • opencontainers/runc
    • libcontainer/standard_init_linux.go 内の finalizeNamespace() および getUserHome() の実装部分などを参照。

ブログ一覧へ戻る

お気軽にお問い合わせください

SREの設計・技術支援から、
SRE運用で使用する
ツールの導入など、
SRE全般についてご支援しています。

資料請求・お問い合わせ