之前项目中遇到过一个bug,bug产生的原因是某个程序在两个不同的启动脚本中被同时启动了两次,系统中出现了两个实例。这个程序在代码内部没有保证单实例,靠shell
脚本的pidof(1)
命令保证单实例,然而因为两个启动脚本的启动时间太过接近,pidof(1)
没能起作用,导致程序被启动了两次。
出了这个bug后就想着需要梳理一下系统在启动时都运行了哪些脚本,这个系统“传承”了估计有七八年,启动脚本粗略估计有几十个了,这几十个脚本里面必然有可以合并或者精简的。一开始人肉分析,发现难度有点大,有些脚本是rc
脚本,有些脚本在rc
脚本中被启动,有些脚本被网卡配置脚本启动,有些脚本被Xserver
启动脚本启动,甚至有些脚本放在了窗口管理器启动脚本中启动,还有些脚本虽然在磁盘上躺着,但已经被另外的启动脚本取代了,但根本没有被启动。
所以有没有现成的工具能够记录系统启动时都启动了哪些进程呢,我一开始想到了用auditd
,之前我在另外一篇博客:linux下监控shell脚本或可执行程序启动过的子进程中提到过auditd
,它在我的Ubuntu
上工作得很正常(不过会拖慢系统速度),但在项目的系统里面无法正常工作,很容易把系统搞死,在经过几个小时的尝试以后,我放弃了这种方法。
经过一番思考后,我决定使用最笨最直接的方法:printk
大法。既然有内核源码,何不直接修改内核,在内核创建进程的时候打印出来呢?
日志生成流程和结果可视化代码的Github仓库:Tasktree
我使用的“发行版”是之前编的一个lfs
,内核版本是5.2.8
首先需要指出,我在文章标题写的是“进程树”,这个“进程”指的是Linux
内核里面的“轻量级进程”(LWP
),而不是操作系统课本里的那个“进程”的概念。在Linux
内核中并不会区分进程或者线程,只有LWP
,使用task_struct
结构体保存。
总共有3个需要修改的源文件:kernel/fork.c、fs/exec.c和kernel/exit.c,只要能够获取一个每一个进程fork
/exec
/exit
的时间,我们就能够还原出整颗进程树。
插入的printk
主要需要记录进程号和进程名的信息,为了方便以后将内核线程隐藏,我还在fork.c的printk
中记录了p->flags & PF_KTHREAD
的值。
在 copy_process
中,在return
之前插入一个printk
...
trace_task_newtask(p, clone_flags);
uprobe_copy_process(p, clone_flags);
printk(KERN_ERR "FORK|%d|%s|=>|%d|%u", current->pid, current->comm, p->pid, p->flags & PF_KTHREAD); // Inserted here!
return p;
...
在__set_task_comm
中,在函数一开始插入一个printk
void __set_task_comm(struct task_struct *tsk, const char *buf, bool exec)
{
printk(KERN_ERR "EXEC|%d|%s|=|%s", tsk->pid, tsk->comm, buf); // Inserted here!
task_lock(tsk);
trace_task_rename(tsk, buf);
strlcpy(tsk->comm, buf, sizeof(tsk->comm));
task_unlock(tsk);
perf_event_comm(tsk, exec);
}
在do_exit
中,在函数一开始插入一个printk
void __noreturn do_exit(long code)
{
printk(KERN_ERR "EXIT|%d|%s", current->pid, current->comm); // Inserted here!
struct task_struct *tsk = current;
int group_dead;
profile_task_exit(tsk);
...
将修改后的内核编译安装完毕后重新启动系统,我们就能在/var/log/kern.log或者dmesg(1)
命令的输出中看到我们插入的日志了:
[ 4.070211] FORK|170|S05modules|=>|172|0
[ 4.082378] EXEC|172|S05modules|=|egrep
[ 4.092978] EXEC|172|egrep|=|grep
[ 4.098429] EXIT|172|grep
[ 4.099345] EXIT|170|S05modules
[ 4.099777] FORK|130|rc|=>|173|0
[ 4.100872] EXEC|173|rc|=|S08localnet
[ 4.102791] FORK|173|S08localnet|=>|174|0
[ 4.103201] EXEC|174|S08localnet|=|stty
[ 4.104313] EXIT|174|stty
[ 4.108745] FORK|173|S08localnet|=>|175|0
[ 4.123296] EXEC|175|S08localnet|=|cat
[ 4.125846] EXIT|175|cat
[ 4.126549] FORK|173|S08localnet|=>|176|0
使用Tasktree将日志还原成进程树,会得到如下输出:
[0] idle(living)
\_ [1] swapper/0 -> [1] init(living)
| \_ [129] init
| | \_ [130] init -> [130] rc
| | | \_ [133] rc -> [133] stty
| | | \_ [134] rc -> [134] dmesg
| | | \_ [135] rc
| | | | \_ [136] rc -> [136] ls
| | | \_ [137] rc -> [137] S00mountvirtfs
| | | | \_ [138] S00mountvirtfs -> [138] stty
...
" -> "
表示一个exec
调用。
" \_ "
表示一个fork
调用。
这个输出样式是仿照ps(1)
做的,但是ps(1)
输出不了已经退出的进程,也记录不了exec
调用。
由于日志都是有时间戳的,除了能生成进程树外,还能画出进程fork
/exec
/exit
的时间线:
以上就是使用内核日志来还原出进程树的流程了。修改内核听起来虽有些麻烦,但是比使用各种文档残缺可用性难以保障的工具的心智负担要低得多。如果你有更好的方式或工具,请一定要指教一下。