ps 整整 50 年没回答的问题,witr 用一条祖先链补上了
- witr 通过 PID 统一抽象,将端口、文件、容器等入口收敛到进程溯源
- 祖先链沿 PPID 一路向上,直到 PID 1 或 PPID 0,生成因果叙述
- 源头检测按容器、SSH、shell、systemd 等优先级排序,避免误判
- 容器内溯源受 PID namespace 限制,需从宿主机查询才能看到完整链
凌晨三点,告警把你吵醒。一台服务器内存吃满,你 top 一看,一个叫 node 的进程占了 4 个 G。
然后呢?
你知道它叫 node,你知道它吃了多少内存,你甚至知道它跑了两天。但你不知道是谁把它拉起来的。是 pm2?是 systemd?是某个 cron?还是某个人 SSH 上去手动敲了一行命令?于是你开始翻 ps -ef 的输出找 PPID,又开一个窗口跑 lsof 看它占着哪个端口,再 systemctl status 一通查,半个钟头过去了,你还在拼这条因果链。
说真的,这件事从 Unix 诞生起就一直没被好好解决。ps 是 1970 年代的东西,top、lsof、ss、systemctl status,每一个都只告诉你一面。它们回答的是「什么在跑」,而不是「为什么在跑」。
witr 干的就是把这个问题正面补上。
它到底在回答什么
witr 的全名是「Why Is This Running」,作者 Pranshu Parmar 把它写成了一个 Go 的单文件二进制,18220 个 Star,2025 年 12 月才建仓,七个月长成这个体量,速度很猛。
一句话定位,witr 给任何「正在跑的东西」生成一条因果链,告诉你它从哪来、谁拉起的、被什么系统托管着。输入可以是一个进程名、一个 PID、一个端口号、一个被占用的文件,甚至一个容器名。不管你从哪个口子进去,witr 都会把它还原成一个进程,然后往上追溯祖先。
核心就一句话,它把「为什么跑」这件事,从你脑子里手工拼图,变成了工具的默认输出。
一切皆 PID,这才是它的统一抽象
很多人会用 witr,是冲着「查端口」或者「查容器」去的。但你要理解它的设计,得先看一个关键决定。
witr 把所有入口都收敛到 PID。
端口 5000 占用了,witr 不是简单告诉你「5000 被 pid 1482060 占了」就完事,它先做一次 port 到 PID 的解析,拿到那个 PID 之后,后面所有逻辑都一样了。文件被锁住了,也是先找到持有它的进程 PID。容器在跑,也是先拿到容器里那个一号进程的 PID。容器名、镜像名、compose 项目标签,都只是定位 PID 的线索。
这个抽象的好处太明显了。不管你从哪个维度来问,最后都落到同一个问题上,这个 PID 是怎么来的。witr 不用为端口写一套逻辑、为容器写一套逻辑、为文件写一套逻辑,它只需要把「任意入口」翻译成 PID,剩下的交给溯源引擎。
追源码就能看到这个收敛点。internal/proc/net_linux.go 里读 /proc/net/tcp 解析端口,stateMap 把十六进制状态码翻译成人话,0A 是 LISTEN,01 是 ESTABLISHED,一一对应。拿到 inode 再去 /proc/[pid]/fd 里反查,端口就这么落到了一个具体的 PID 上。
这个设计很 Unix,也很干净。
溯源引擎,沿 PPID 链一路向上
真正让 witr 有别于 ps 和 lsof 的,是它的祖先链。
internal/proc/ancestry.go 里有个 ResolveAncestry 函数,逻辑朴素得让人意外。给定一个 PID,它读出这个进程的 PPID,跳到父进程,再读父进程的 PPID,一路向上,直到撞见 PID 1 或者 PPID 0,然后停。
就这么一个 while 循环。
但朴素不等于粗糙。注意几个细节。第一,它带了一个 seen map 做环检测,万一 PPID 链里出现回路(理论上不该,但内核 bug 或者 PID namespace 乱套时真会出现),它不会死循环。第二,拿到链之后它做了一次反转,因为遍历是从叶子往根走的,输出要反过来,从 systemd(pid 1)一路画到你的目标进程。第三,终止条件是 p.PPID == 0 || p.PID == 1,这俩任何一个命中就停。
这条链生成之后,你看到的不是一坨进程列表,而是一个有方向的因果叙述。
systemd (pid 1) → pm2 (pid 5034) → node (pid 14233)
一行读完,你就知道是这个 pm2 在两年前把 node 拉起来的,pm2 自己又被 systemd 托管。这种叙事密度,是 ps -ef 那种扁平表格给不了的。
源头检测,一条精心排过序的优先级链
光有祖先链还不够。链上每一级都是进程,但读者真正想知道的是「谁负责」,是一个能一句话说清的源头。
这是 internal/source/detect.go 的 Detect 函数干的事,也是整个项目里我最想拆的一块。
它对祖先链做一轮优先级检测,顺序是这样的。容器最先查,然后是 SSH 会话,再然后是交互式 shell,接着 systemd、launchd、BSD 的 rc.d、supervisor、cron、Windows Service,最后兜底是 init,全部 miss 就返回 Unknown。
你想想这个顺序为什么这么排。
容器排第一,是因为现代部署里容器太常见了,而且容器进程的祖先链往往长得跟普通服务很像,如果不先判容器,很容易误判成 systemd 拉起的。SSH 排在 shell 前面,是因为一个 SSH 进来开的 shell,真正的源头是那次远程登录,不是 shell 本身。systemd 和 launchd 这种 init 系统靠后一些,因为它们太通用了,几乎所有 Linux/macOS 进程的链顶都是它们,放前面会把所有东西都归因到「systemd 拉起的」,等于没说。
这个排序是踩过坑才会有的。代码注释里写得很直白,检测顺序优先平台特定的 init 系统,避免误报。一个 Detect 函数,十几个 if src := detectXxx(ancestry); src != nil,谁先谁后,全是设计。
把这几层拼在一起看,witr 的整体架构就是一条从上到下的数据流。
从入口的四种查询方式(进程名、PID、端口、容器),全部收敛到 PID 这个统一抽象,然后交给溯源引擎沿 PPID 链上溯,生成祖先链,再由源头检测层按优先级判定「谁负责」,最后编排成 narrative 输出。深蓝高亮的那几个节点,就是整条链路的关键枢纽。
当祖先链看不见宿主机,一个绕不开的天花板
讲到这里必须说一个诚实的限制,而且这个限制不是我拍脑袋想出来的,是源码摆在那的。
前面提到 ResolveAncestry 在 p.PID == 1 时就停了。在普通物理机或者 VM 上,PID 1 是 systemd,停在这里完全合理。但容器不一样,容器有自己的 PID namespace,容器里看到的 PID 1 是它自己的 init 进程,宿主机的 systemd 和 dockerd 根本不在容器的进程树里。
这意味着什么?你在容器外面用 witr 查一个容器进程,能看到完整的宿主机到容器的链。但如果你在容器里面跑 witr,溯源会停在容器的 init,再往上的 dockerd、containerd、systemd 全都看不见。
这是 Linux namespace 的天然属性,不是 witr 的 bug,但 witr 也没法绕过去。理解这一层,你才知道 witr 在容器场景的正确用法是「从宿主机查」,而不是「进容器里查」。
几条藏在源码里的小局限
再挖几条。
internal/proc/boot_linux.go 里有个 ticksPerSecond 函数,直接 return 100,注释写的是「Linux default, portable enough for now」。Linux 内核的 USER_HZ 在 x86 上确实是 100,但在很多 ARM 平台是 250,某些配置下能到 1000。witr 用这个值把 /proc/[pid]/stat 里第 22 个字段(进程启动时的时钟滴答数)换算成绝对时间,如果 ticksPerSecond 算错,进程启动时间就会有偏差。作者自己加了「for now」,说明他也知道这是临时方案。
internal/proc/docker_proxy.go 里 resolveDockerProxyContainer 这个函数,专门处理 docker-proxy 进程的识别。docker-proxy 是 Docker 默认发布端口的代理进程,它的命令行里带一个 -container-ip 参数,witr 会解析这个参数拿到容器 IP,然后调 docker network inspect bridge 反查容器名。问题在于,它用的是 exec.Command("docker", ...),也就是说你的 PATH 里必须有 docker CLI。纯 containerd 或者 k8s 直接用 crictl 的场景,这个反查就失效了。
这两条都不是致命问题,但它们说明一个事实。witr 的很多「智能」依赖对具体运行时环境的探测,环境越标准它越准,环境越边缘它就越容易漏。这是个 best-effort 工具,作者在 README 里也反复强调 best-effort,连输出里都标着不确定性。这种诚实我挺欣赏。
工程取舍里的两个细节
读源码还能看到一些有意思的工程取舍。
net_linux.go 里有个 socket 缓存,socketCacheTTL 设成 2 秒。为什么?因为溯源一次要沿着祖先链调 5 到 10 次 ReadProcess,每次都要查端口,如果每次都重读 /proc/net/tcp 和 /proc/net/tcp6,性能扛不住。缓存 2 秒,既保证一次完整的溯源里端口表是稳定的,又不至于缓存太久看到陈旧数据。这种 TTL 的选择,是踩过性能坑的痕迹。
另一个是 internal/pipeline/analyze.go 里对 LXC 容器标签的重写逻辑。注释解释得很清楚,ReadProcess 在 cgroup 层面只能把 LXC payload 识别成笼统的 lxc-based:,因为它看不到祖先链。而 source.Detect 能通过祖先进程的二进制名(incusd、lxd、lxc-start)判断真正的运行时。于是 analyze 阶段会把 ancestry 里那些 lxc-based: 标签重写成具体的 incus:、lxd:、lxc:,保证输出里 Container 行和 Source 行对得上。
这种「分两层检测再统一修正」的处理,看着啰嗦,其实是分层架构的必然代价。读进程的归读进程,判源头的归判源头,两个模块各干各的,中间用一个修正步骤对齐。模块边界清晰,代价是要写一点胶水。
它给了运维工具一个新范式
我一直在想,witr 值不值得被认真对待,毕竟它做的事「看起来」不难,沿 PPID 链走谁不会写。
但它的价值不在那个 while 循环,在于它把「因果」这个维度正式拉进了运维工具的默认输出。
传统的系统监控,你拿到的是三类信息。state,进程现在的状态,ps 给你。metadata,进程的元数据,端口、文件、用户,lsof 和 ss 给你。metrics,CPU 内存这些指标,top 给你。这三类合起来回答的是「现在是什么样」。
witr 加的是第四类,causality,因果链。它回答「为什么会变成现在这样」。这件事以前不是做不到,是你得手动跨好几个工具去拼,拼完还不一定拼对。witr 把这个拼图过程内置成了默认行为,你只要给它一个入口,它给你一条完整的因果叙述。
你想想看,日志系统早就有了从 trace 到 span 的因果追踪,APM 工具早就把请求链路画得清清楚楚。但操作系统进程这一层,五十年来一直停在「给你状态,因果你自己猜」。witr 把分布式追踪那套「链路可视化」的思路,下沉到了单机的进程层面。
这个范式能不能迁移,我觉得能。任何「给你状态但不给因果」的工具,都有被重做的空间。网络连接为什么建立、文件为什么被打开、定时任务为什么触发,这些「为什么」都可以用同样的祖先链思路去回答。witr 做的是进程这一层,但它示范的方法论不限于进程。
如果你日常要排查「这个进程哪来的」这种问题,装一个试试,brew install witr 就行。它不会替代你的 ps 和 lsof,但它能让你少开三个终端窗口。对一个半夜被叫醒的运维来说,少开三个窗口,就是多睡二十分钟。
评论互动