土法炼钢 · 系统与基础设施

【Tetragon / eBPF】进程模型:exec_id、血缘、in_init_tree 与 procfs 回填

文章导航

分类入口
kubernetessecurity
标签入口
#tetragon#ebpf#process-exec#exec-id#in-init-tree#execve-map#procfs#v1.7.0

目录

第 1 篇 把排障收束到五轴;本篇钉 进程血缘轴。读者常见误区有两个:一是把 Tetragon 的进程树理解成「对 /proc 做了更快的采样」;二是以为只要装上 agent,血缘就是完整的。真正要钉死的是:exec_id 解决 PID 复用,不解决 map 未命中;内核 execve_map 是执行期权威状态,JSON 里的父子来自用户态 cache 的富化;fork 时若父不在 map,子进程不会被写入 map。

本篇在系列中的位置

篇目 核心内容
第 1 篇 · 运行时安全全景 五轴坐标系
第 2 篇 · 进程模型 exec_id、血缘、in_init_tree、procfs 与 fork 失败路径
第 3 篇 · Sensor 与 Hook 内置进程传感器与动态 hook
系列目录 全部篇目

版本锚定:Tetragon v1.7.0(源码 tag v1.7.0,重点 pkg/process/bpf/lib/process.hbpf/process/bpf_fork.c)。机制叙述对齐官方 Events 文档与 tetragon.protoProcess 注释。无真实节点则不粘贴伪造 process_exec 输出;下文 JSON 片段来自 v1.7.0 文档示例,不是本机采集。


一、process_exec / process_exit 字段

默认安装会产生 process_execprocess_exit 两类基础事件。v1.7.0 Events 文档把执行事件的主键钉在 process_exec.process.exec_id,并把 Kubernetes 元数据放在 process_exec.process.pod。二进制与参数是 process.binaryprocess.arguments。所有事件类型都带 node_nametime

api/v1/tetragon/tetragon.protoProcess 与两个信封消息的职责如下(字段以 proto 为准,不是「文档示例里碰巧出现的键」):

字段 / 消息 作用 注意
Process.exec_id 跨节点、跨时间标识一次执行 不是 PID
Process.pid 主机 PID 命名空间里的 TGID 容器内 PID 在 pod.container.pid
Process.tid 线程 ID;线程组首领上 tid 等于 pid 缓存按 TGID,其他事件再填精确 tid
Process.parent_exec_id 父进程的 exec_id 父不在 cache 时可能走恢复路径
Process.pod namespace / name / container / labels / workload 主机进程没有该字段
Process.flags 调试用位的空格分隔字符串 proto 明确写不可当作可靠语义源
Process.in_init_tree 是否为容器 PID 1 子树成员 主机进程为 false
ProcessExec.process / parent / ancestors 执行事件的自身、直接父、更上层祖先 祖先需配置开启,见 filters 对 ancestors 的门闩
ProcessExit.process / parent / signal / status 退出原因 无信号处理时看 status

下面这段是 v1.7.0 官方文档示例(经删减,只保留本篇用到的键),用来对照字段位置,不是本机 tetra 输出:

{
  "process_exec": {
    "process": {
      "exec_id": "Z2tlLWpvaG4tNjMyLWRlZmF1bHQtcG9vbC03MDQxY2FjMC05czk1OjEzNTQ4Njc0MzIxMzczOjUyNjk5",
      "pid": 52699,
      "binary": "/usr/bin/curl",
      "arguments": "https://ebpf.io/applications/#tetragon",
      "flags": "execve rootcwd",
      "parent_exec_id": "Z2tlLWpvaG4tNjMyLWRlZmF1bHQtcG9vbC03MDQxY2FjMC05czk1OjEzNTQ4NjcwODgzMjk5OjUyNjk5",
      "pod": {
        "namespace": "default",
        "name": "xwing",
        "container": { "name": "spaceship", "pid": 49 }
      }
    },
    "parent": {
      "exec_id": "Z2tlLWpvaG4tNjMyLWRlZmF1bHQtcG9vbC03MDQxY2FjMC05czk1OjEzNTQ4NjcwODgzMjk5OjUyNjk5",
      "binary": "/bin/bash",
      "flags": "execve rootcwd clone"
    }
  },
  "node_name": "gke-john-632-default-pool-7041cac0-9s95"
}

flags 里的 execve 表示这次身份来自 execve 路径;clone 表示该进程在 exec 之前经历过 clone。rootcwd 对应内核 EVENT_ROOT_CWD:CWD 是 /,不占事件缓冲区。完整位到字符串的映射在 pkg/reader/exec/exec.goDecodeCommonFlags。proto 注释还列出 procFSmisstruncArgs 等;把它们当产品功能开关会误判。

能力集、命名空间、完整 credentials 默认关闭,分别由 --enable-process-cred--enable-process-ns 打开。用户名解析还要求 --username-metadata=unix 且进程在主机用户命名空间——并且文档写明这是用户态查 /etc/passwd,hook 执行到解析之间映射可能已变。


二、exec_id 如何生成

PID 会回绕。Tetragon 的稳定键不是「节点上的 PID」,而是一次执行的 exec_id。Linux 上的生成函数只有一行有效逻辑:

// project: cilium/tetragon, tag: v1.7.0
// path: pkg/process/process_id_linux.go
// function: GetProcessID
func GetProcessID(pid uint32, ktime uint64) string {
    return base64.StdEncoding.EncodeToString(fmt.Appendf(nil, "%s:%d:%d", node.GetNodeNameForExport(), ktime, pid))
}

三元组含义:

分量 来源 消除什么
导出用节点名 GetNodeNameForExport():先 HUBBLE_NODE_NAME,再 NODE_NAME,再内核 hostname 跨节点 PID 碰撞
ktime 该次执行在内核侧记录的时间戳 同一节点上 PID 复用
pid TGID 与内核 execve_map 的键对齐

GetExecID / GetExecIDFromKey 分别从 MsgProcessMsgExecveKey 调到同一函数。父进程的 parent_exec_id 用父的 (pid, ktime) 再算一遍。若父 PID 为 0,initProcessInternalExec 会用 GetProcessID(0, 1) 填一个占位父——这是「没有可用父键」时的工程填缝,不是 PID 0 真的 exec 过。

proto 注释写 exec_id「uniquely identifies the process over time across all the nodes in the cluster」。该唯一性依赖导出节点名在集群内不碰撞,以及 ktime 在 PID 复用周期内足够区分两次执行。节点改名或 HUBBLE_NODE_NAME 与 Kubernetes 节点名不一致时,导出身份与 API server 里的 node 对不齐——pkg/reader/node/node.go 为此把「导出用名」和「watch Pod 用名」分成两个函数。

exec_id 消除的是 PID 作为跨时间主键;它不消除「内核根本没为这次 clone/exec 建过 map 条目」。下一节把这两层状态拆开。


三、execve_map 与用户态 cache

flowchart LR
  tp["sched_process_exec / wake_up_new_task"] --> map["execve_map TGID key"]
  procfs["procfs snapshot at start"] --> map
  map --> ring["Ring or perf buffer"]
  ring --> cache["userspace process cache LRU"]
  cache --> json["JSON process.exec_id"]

图:内核 map 先记下 (pid, ktime) 与父键;用户态 cache 用 exec_id 做 LRU 键,再序列化成 JSON。

内核:execve_map

bpf/lib/process.h 把 map 值定义为按 TGID 跟踪的 execve_map_value:自身 key(pid + ktime)、父 pkey、flags、nspid、命名空间、capability、binary。线程 ID 不作为 map 主键;注释写明 TID 随具体事件附带。默认最大条目在 pkg/sensors/base/base.goexecveMapMaxEntries = 32768,可被配置项覆盖。本篇不把条目数换算成内存字节——换算依赖结构体填充与内核分配器,未在本仓库实测。

execve 路径在 bpf/process/bpf_execve_event.c:程序挂在 tracepoint/sys_execve(用户态加载时 attach sched/sched_process_exec)。event_execveEVENT_EXECVE,读路径、参数、CWD;execve_sendexecve_map_get_noinit 更新当前 TGID 的 key / pkey / binary,并 event_output_metric 送出。clone 路径见第五节。

用户态:process cache

pkg/process/cache.gohashicorp/golang-lruProcess.ExecId 字符串缓存 ProcessInternaladd 只应从 clone 或 execve 事件调用。引用计数为 0 后进入分色 GC(inUsedeletePendingdeleteReadydeleted);过早删除记 tetragon_process_cache_early_deletions_total(v1.7.0 引入)。LRU 满时驱逐仍 inUse 的条目会给父做 parent--,避免父引用泄漏。

AddExecEvent 构造 cache 条目:若 CleanupProcess.Ktime == 0 或带 clone 标志,父键用 Linux 提供的 Parent;否则用 CleanupProcess。这是「execve_map 里找不到旧条目」时的退路,不是第二条完整进程树。

JSON 导出的 process / parent 是 cache 里 protobuf 的克隆,再叠加 Pod 信息(GetPodInfo)。map 有、cache 已驱逐时,后续 hook 事件仍可能从 BPF 带出 pid/ktime,但用户态会表现为 miss 或残缺父信息。proto 对 miss 的口径是:buffer 里找不到父,Tetragon 尽量从内核数据结构恢复,参数不可用,并建议当作应报给开发者的错误标志。

两层状态不要合并成一句「Tetragon 维护完整进程树」:内核 map 服务 hook 与选择器;用户态 cache 服务导出与祖先展开。任何一层未命中都是血缘轴故障,不是「eBPF 没装上」。


四、in_init_tree 与注入

容器里「PID 1 的子孙」与「后来被 nsenter / kubectl exec / docker exec 塞进来的进程」在主机 PID 命名空间里看起来都像同一个 Pod。Tetragon 用 in_init_tree 区分它们。

内核 set_in_init_treebpf/process/bpf_process_event.h)的规则是:

  1. 父条目已带 EVENT_IN_INIT_TREE,则子继承。
  2. 否则若当前 nspid == 1(容器 PID 命名空间内的 1 号进程),则自己置位。
// project: cilium/tetragon, tag: v1.7.0
// path: bpf/process/bpf_process_event.h
// function: set_in_init_tree
FUNC_INLINE void
set_in_init_tree(struct execve_map_value *curr, struct execve_map_value *parent)
{
    if (parent && parent->flags & EVENT_IN_INIT_TREE) {
        curr->flags |= EVENT_IN_INIT_TREE;
        return;
    }
    if (curr->nspid == 1) {
        curr->flags |= EVENT_IN_INIT_TREE;
    }
}

execve 的 execve_send 在更新 map 后调用 set_in_init_tree(curr, NULL),只靠 当前 nspid=1 置位,再把标志抄进事件。clone 路径传入 parent,因此容器 init 的子进程靠继承,而不是每个子进程都变成 nspid=1。用户态 initProcessInternalExec / initProcessInternalClone 把该位变成 protobuf BoolValue

导出过滤里的 in_init_treeevents.protoFilter.in_init_tree)文档口径与此一致:用来监视经 docker exec、kubectl exec 等机制注入的进程。主机进程的 nspid 逻辑不会把它标成容器 init 子树,值为 false。

失败表象:

现象 优先检查
业务入口进程 in_init_tree=false 容器 PID 1 是否在 agent 启动前已存在且 procfs 回填未标上(见第五节);或该进程其实是注入会话
kubectl exec 的 shell in_init_tree=true 该 shell 是否由容器内已有 init 子孙 spawn,而不是从主机 nsenter
过滤 in_init_tree=true 仍看到 exec 会话 导出过滤与 gRPC 是否同一 sink(第 4 篇

in_init_tree 消除的是「凡是在这个容器里跑的都算业务树」;它不消除「PID 1 自己没进 map」时整棵树无法继承。


五、procfs 回填与失败路径

启动快照:procFS

agent 起来之前,节点上已经有进程。pkg/sensors/exec/procevents/proc_reader_linux.golistRunningProcs 扫配置的 procfs 目录,给每个 TGID 填 flags: api.EventProcFS | api.EventNeedsCWD,再 writeExecveMap 写入内核 map。DecodeCommonFlags 把该位渲染成字符串 procFS。proto 要求一次正确格式化的事件应带 execveprocFS 之一。

procToKeyValue 用与内核相同的规则回填 EVENT_IN_INIT_TREEnspid == 1 或父已在本次扫描构造的 inInitTree 集合中。这修复了「容器先于 Tetragon 启动则 in_init_tree 全错」的窗口;它仍然是扫描时刻的快照。扫描期间退出的短命进程不会出现在 map 里——Garfinkel(NDSS 2003)讨论过依赖 /proc 重建进程状态的竞态,这里是工程对应物。

StartTime 对 procfs 条目的处理与 live exec 不同:initProcessInternalExec(process.Flags&api.EventProcFS) == 0 时才把 ktime 转成可导出时间戳选项。不要用 procfs 回填事件的绝对时间去对齐审计时钟而不看 flags

fork 失败路径:父不在 execve_map

clone 不是「用户态再读一遍 /proc」。bpf/process/bpf_fork.c 把程序挂在 kprobe/wake_up_new_task。关键控制流:

// project: cilium/tetragon, tag: v1.7.0
// path: bpf/process/bpf_fork.c
// function: event_wake_up_new_task
parent = __event_find_parent(task);
if (!parent)
    return 0;

curr = execve_map_get(tgid);
if (!curr)
    return 0;

if (curr->key.ktime != 0)
    return 0;

注释写明:找不到父就不创建消息,也不调用会插入新条目的 execve_map_get 父缺失时子进程不会进入 execve_map。每个线程组只生成一次 MSG_OP_CLONE(靠 curr->key.ktime != 0 去重)。用户态 AddCloneEvent 若 cache 里没有父 exec_id,同样直接返回错误,不造孤立子条目。

这就是本系列禁止复述「零缺口血缘」的源码原因。缺口至少包括:

  1. 父从未进 map(agent 启动窗口、map 满、更早一次 fork 已失败)。
  2. 父已从 map 删除(进程组已 exit),子仍在 fork。
  3. procfs 扫描与 live hook 之间的 TOCTTOU:扫描时父在,hook 时父已退出;或相反。

exit 侧 bpf/process/bpf_exit.c hook acct_process(若符号不存在则退到 disassociate_ctty),只在线程组最后一个任务上发送,避免每个线程一次 exit 事件。map 删除与 cache process-- 在这条路径上汇合;细节排障见第 13 篇。

开放问题(本篇只立靶):启动快照丢失祖先时,后续 live 事件的血缘是否永久残缺——取决于父是否永远无法被补进 map。Tetragon 不会在用户态「猜一个历史父进程」来填 exec_id。可观测入口是 cache dump、execve_map dump(v1.7.0 bugtool)与 miss / early deletion 指标。


六、学术谱系、争论与开放问题

谱系:经典审计与主机 IDS 用 PID 加 /proc 快照描述进程。Garfinkel(NDSS 2003)指出系统调用拦截工具若在用户态补进程树,会撞上短命进程与 TOCTTOU。McCanne & Jacobson(USENIX Winter 1993)提供的是「把过滤放进内核虚拟机」的范式;Tetragon 把该范式用在执行跟踪——execve_map 是 BPF map 状态,不是 XDP 数据包环。Höiland-Jørgensen et al.(CoNEXT 2018)的 XDP 假设面向网卡早路径,与 wake_up_new_task 上的 clone 跟踪不是同一代价模型。

争论:内核进程树 vs procfs 快照

主张 代价
/proc 实现简单,不依赖专用 map 短命进程在两次采样之间消失;与拦截不同步
内核 execve_map exec/clone/exit 与选择器共用同一份状态 父未命中则子不入图;启动仍必须扫 procfs

没有文献能宣布某一侧在所有节点生命周期下零窗口。可检验的工程命题是:在你的 churn 下,空洞是否可被 procFS / miss / fork 早退归因。若团队要求「每个历史父进程都可还原」,需要在 agent 启动顺序与 map 容量上单独设计,而不是指望 exec_id 本身。

开放问题

  1. fork 失败是否永久残缺:父稍后出现(例如用户态补偿)能否补边——v1.7.0 的 bpf_fork.c 早退不写子条目。入口:本文件第五节;第 13 篇。
  2. 导出完备性:采样、节流、cache 驱逐之后,JSON 所见树是否仍够取证。入口:第 4、11 篇。
  3. 与 Cilium identity 联合归因exec_id 与 numeric Identity 如何写进同一 runbook——第 16 篇。

七、小结

  1. exec_idbase64(node:ktime:pid),消除 PID 复用,不消除 map 未命中。
  2. execve_map 按 TGID 保存内核权威状态;process cacheexec_id 服务导出。
  3. in_init_tree 标记容器 PID 1 子树,用来分开 kubectl exec 一类注入。
  4. procFS 是启动回填标志,不是 live execve。
  5. 父不在 map 则 fork 不入图——这是失败路径。下一轴是 hook:身份只有被进程传感器和 TracingPolicy 程序读到才成为事件。

八、参考资料

规范 / 官方文档(A)

源码(A)

核心论文

实验 / 工具

站内对照


上一篇:运行时安全全景 · 系列目录 · 下一篇:Sensor 与 Hook

读完这篇,下一步读什么

优先读同系列或同问题的下一篇,把单篇消费变成主题集群。

2026-08-17 · kubernetes / security

Tetragon / eBPF 运行时安全:从进程血缘到 TracingPolicy

补齐站内 Cilium 数据面与可观测对照之上的运行时安全内核层:进程模型、Sensor/Hook、TracingPolicy 与内核过滤、强制执行、事件导出与节流,并以排障与相对 Falco/CNP 的选型收束。


By .