一个内核参数导致的"幽灵进程"排查过程

  1. 1. 是否和tagent有关
  2. 2. “谁在访问文件”
    1. 2.1 inotify 的天然局限
    2. 2.2 auditd 审计
      1. 容器内auditd
      2. 基于overlay的文件结构进行宿主机文件audit
      3. 宿主机系统调用的audit
  3. 3. 真相大白:Daemon流程的锅
  4. 4. 不同机型的性能测试
  5. 5. 写在最后
  6. 6. 测试数据

最近在将老服务上云,在k8s测试集群发现一个奇怪的问题,一个业务进程会自动启动二个,这里先说一下业务背景:

服务器本身的技术栈是c++&lua,业务使用了hive-framwork框架,服务管理使用了tcm,这里将某个服务上云是将相关的进程打包到一个Image,以富容器的方式运行

服务部署后,我发现Pod中同时运行了两个一样的进程:

1
2
root  141682  0.6  ./hive modelsvr.lua --tbus=2020 --daemon --id=1.1.59.1 ...
root 141747 94.6 ./hive modelsvr.lua --tbus=2020 --daemon --id=1.1.59.1 ...

两个进程命令行参数完全相同,其中一个是我自己启动的,另一个来路不明,问题开始了!

1. 是否和tagent有关

一开始怀疑和tagent(服务管理,提供异常退出自动拉起功能)有关,排除过程如下:

  1. 查看tagent的日志并没有拉起的记录;
  2. 把tcm相关的所有管理进程全部kill后,手动执行进程的启动,结果还是一样会启动两个进程;
  3. 手动启动其他进程,例如./hive dbagent.lua ...,并不会和./hive modelsvr.lua ...一样,启动两个进程;

这个时候就有点懵逼了,这个modelsvr有什么特殊的,不应该呀,云下的老架构线网都是正常运行的。其实这里就应该想到是modelsvr本身的业务特殊性导致,但是一开始没往这方面想。因为我们的框架是有防多开的保护,所以拉起重复的进程自动失败的,所以我后面优先的定位问题思路就是:是谁在拉起这个进程?

2. “谁在访问文件”

接下来我的方向就转移到了,是谁在拉起这个”幽灵进程”,也就是谁在读取./hive。基于AI给出了几个解决方案

2.1 inotify 的天然局限

第一个是 inotifywait ,这个也是用的比较多的,之前业务会通过inotify监听文件的变化进行配置相关的reload,这里监听hive被执行了,可以通过如下

1
inotifywait -m -e access hive

但是inotify 只能告诉你”文件被访问了”,无法告诉你”是谁(哪个 PID/UID)访问的”。这是 inotify 机制的天然限制,要补上”是谁”,AI给出了一个方案:只能在事件触发瞬间用 fuser/lsof 抓当前打开该文件的进程,但读取往往是毫秒级的 open→read→close,等通知到达再去查,进程可能已经关闭文件了,会漏抓。结果也当然是没有收获,因为也不是这个问题。

2.2 auditd 审计

接下来就是auditd审计了,平常定位问题的时候也会使用。关于它的介绍:

内核自带的安全审计框架,用于记录系统中与安全相关的事件——哪个用户、在什么时候、对哪个文件或系统调用做了什么。它不阻止行为(那是 SELinux/AppArmor 的活),只负责忠实记录,是合规审计、入侵溯源、故障排查的底层基础设施。

使用方式如下:

1
2
3
4
5
# 添加监控规则:监听对 hive 文件的读(r)、写(w)、属性(a)、执行(x)
sudo auditctl -w /path/to/hive -p r -k hive_read

# 查看审计日志
sudo ausearch -k hive_read

容器内auditd

容器内没有auditd,安装后发现启动报错了:

1
2
# auditctl -s 
You must be root to run this program.

报错的原因其实很简单:auditd 依赖内核 audit 子系统,而 容器默认共享宿主机内核且没有 audit 权限(容器只是通过Namespace + Cgroup实现和宿主机的资源隔离+限制)。所以在容器内通过auditd监控文件的读取这条路也行不通。

基于overlay的文件结构进行宿主机文件audit

不过AI给了一个思路:在Node宿主机上用auditd进行文件的访问抓取。

那么如何在Node上抓取容器中的文件访问呢?如果你知道Linux如何通过Namespace进行资源隔离,你就会知道,容器的文件系统是通过overlayFS+Mount Namespace与主机文件系统进行隔离的。k8s中容器镜像的Layers数据在主机上的位置可以通过如下方式定位到:

1
2
3
4
5
6
7
8
9
10
11
12
# containerd 的 CRI 工具是 `crictl`
# 1. 在 node 上找到容器 ID
$ crictl ps | grep recomodelsvr-b74bd599d-979gv
CONTAINER IMAGE CREATED STATE NAME ATTEMPT POD ID POD
243effde47809 29a0f61d1c877 12 hours ago Running recomodelsvr 0 7926ddb3318ae recomodelsvr-b74bd599d-979gv

# 2. 在 node 上找到容器主进程 PID
$ crictl inspect 243effde47809 | grep -i pid

# 3. 查这个文件在 node 上的真实 overlay 路径
$ cat /proc/211355/mountinfo |grep overlay
3537 2110 0:446 / / rw,relatime master:732 - overlay overlay rw,lowerdir=/data/bcs/service/docker/io.containerd.snapshotter.v1.overlayfs/snapshots/10464/fs:/data/bcs/service/docker/io.containerd.snapshotter.v1.overlayfs/snapshots/10463/fs:/data/bcs/service/docker/io.containerd.snapshotter.v1.overlayfs/snapshots/10462/fs:/data/bcs/service/docker/io.containerd.snapshotter.v1.overlayfs/snapshots/10461/fs:/data/bcs/service/docker/io.containerd.snapshotter.v1.overlayfs/snapshots/10460/fs:/data/bcs/service/docker/io.containerd.snapshotter.v1.overlayfs/snapshots/10459/fs:/data/bcs/service/docker/io.containerd.snapshotter.v1.overlayfs/snapshots/10458/fs:/data/bcs/service/docker/io.containerd.snapshotter.v1.overlayfs/snapshots/10457/fs:/data/bcs/service/docker/io.containerd.snapshotter.v1.overlayfs/snapshots/10456/fs:/data/bcs/service/docker/io.containerd.snapshotter.v1.overlayfs/snapshots/10455/fs:/data/bcs/service/docker/io.containerd.snapshotter.v1.overlayfs/snapshots/10454/fs:/data/bcs/service/docker/io.containerd.snapshotter.v1.overlayfs/snapshots/10453/fs:/data/bcs/service/docker/io.containerd.snapshotter.v1.overlayfs/snapshots/4378/fs:/data/bcs/service/docker/io.containerd.snapshotter.v1.overlayfs/snapshots/4377/fs,upperdir=/data/bcs/service/docker/io.containerd.snapshotter.v1.overlayfs/snapshots/10473/fs,workdir=/data/bcs/service/docker/io.containerd.snapshotter.v1.overlayfs/snapshots/10473/work,index=off

找到Node真是的文件路径后:

1
auditctl -w /data/bcs/service/docker/io.containerd.snapshotter.v1.overlayfs/snapshots/10463/fs/data/home/user00/pangusvr/bin/hive -p rwxa -k hive

结果在容器内手动启动,还是抓取不到,AI提到一个原因:overlay 会”错位”,具体原因如下:

容器进程通过 overlay 合并视图访问文件时,内核为其构建的是 overlay 自身的 inode 对象,这个对象和 lowerdir 底层文件系统的原始 inode 不是同一个;更重要的是:这个 overlay inode 本身生命周期不稳定,可能被内核动态回收重建,导致基于固定 inode 的 -w 监控规则悄悄失效,而不会有任何报错。

宿主机系统调用的audit

没办法了,AI给出了另外一个思路:对二进制来说,监控 execve 系统调用比 watch 文件更靠谱,不受 overlay inode 错位影响。毕竟容器只是利用虚拟化在母机上运行,还是母机的内核,万变不离其中。

下面是通过audit进行系统调用的监控

1
2
# 监控所有 execve,然后按路径名过滤
auditctl -a always,exit -F arch=b64 -S execve -k allexec

结果还是没有,此时的我一脸懵逼,我只能把audit的现有规则贴给AI看:

1
2
3
4
$ auditctl -l
-a never,task
-w /data/bcs/service/docker/io.containerd.snapshotter.v1.overlayfs/snapshots/10463/fs/data/home/user00/pangusvr/bin/hive -p rwxa -k hive
-a always,exit -F arch=b64 -S execve -F key=allexec

这里AI分析了半天:从auditd状态没有enable,到audit 事件被送去别处了(本地日志被关闭、或被安全 agent 接管消费),再到内核编译时关掉了 CONFIG_AUDITSYSCALL,逐个排除后,最终怀疑到了最开始的那条规则:

1
-a never,task

这条规则的含义:专门对应 TASK 事件(进程 fork/clone 创建新任务时触发的内核审计钩子),不生成审计记录,原因是进程创建非常频繁(fork 炸弹式的高频),会产生大量低价值日志。

Audit 规则匹配是分 list 独立执行的,内核维护的是多条不同用途的过滤链(taskexitentryuserexclude 等),task list 只在**进程创建(fork/clone)时被触发检查,exit list 只在系统调用返回(syscall exit)**时被触发检查。这是两套完全独立的钩子点。

为什么task list的规则会影响execve的系统调用事件呢?因为task 规则更早决定了这个任务(**内核里的执行单元,基本对应 task_struct**,对应一个线程)是否参与审计。命中该规则后,该任务就已经被标记为不审计,后续的规则即使命中也不会生效。

删除task的规则后,容器内执行命令启动,就可以看到如下的事件:

1
2
3
4
5
6
7
8
9
type=PROCTITLE msg=audit(07/10/2026 01:40:38.369:437201) : proctitle=./hive modelsvr.lua --tbus=2020 --daemon --id=1.1.59.1 --hash=1 --tlogconf=./config/recomodelsvr.log.xml --rundata=./config/reco

type=PATH msg=audit(07/10/2026 01:40:38.369:437201) : item=0 name=./hive inode=176305801 dev=00:1be mode=file,755 ouid=user00 ogid=users rdev=00:00 obj=unlabeled nametype=NORMAL cap_fp=none cap_fi=none cap_fe=0 cap_fver=0 cap_frootid=0

type=CWD msg=audit(07/10/2026 01:40:38.369:437201) : cwd=/data/home/user00/pangusvr/bin

type=EXECVE msg=audit(07/10/2026 01:40:38.369:437201) : argc=8 a0=./hive a1=modelsvr.lua a2=--tbus=2020 a3=--daemon a4=--id=1.1.59.1 a5=--hash=1 a6=--tlogconf=./config/recomodelsvr.log.xml a7=--rundata=./config/recomodelsvr_rundata.log.xml

type=SYSCALL msg=audit(07/10/2026 01:40:38.369:437201) : arch=x86_64 syscall=execve success=yes exit=0 a0=0x17dd4f80 a1=0x17dc3730 a2=0x17daf6c0 a3=0x7fff6fc1ff20 items=2 ppid=58115 pid=61869 auid=unset uid=root gid=root euid=root suid=root fsuid=root egid=root sgid=root fsgid=root tty=pts0 ses=unset comm=hive exe=/data/home/user00/pangusvr/bin/hive subj=kernel key=allexec

这里只看到一次hive进程的调用,没有第二次,所以问题到这里进入了死胡同,突然想到是不是他自己拉起自己的?

3. 真相大白:Daemon流程的锅

上面最终通过audit发现并没有其他进程多次拉起hive,我只能怀疑是该进程自己的问题了,这个时候我才想起来去看进程的日志,发现了:

1
[start_model_process] success, pid:142236, cost_time:2, dir:/data/home/user00/recomlsvr/models/bin, bin:/data/home/user00/recomlsvr/models/bin/recomlsvr, instance_id:144232076925992978 start_nohup_path:/data/home/user00/recomlsvr/models/bin/../log/144232076925992978_start.nohup, is_reload:false

到这里我才想起来recomodelsvr会启动后拉起一个后端的recomlsvr,但是按道理通过fork+exec的方式拉起子进程的后会变成新的进程名呀,为啥会一直显示./hive recomodelsvr呢?

下面贴一下拉起子进程的逻辑,删除了一些不重要的逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
int my_shell::exec(lua_State* L)
{
...
// fork 出子进程执行外部命令,父进程继续运行 Lua 逻辑
pid_t pid = fork();
if (pid == -1) {
slog_error("my_shell::exec fork fail! errno: %d, strerror: %s", errno, strerror(errno));
return 0;
}

if (pid != 0){
// 父进程分支:把子进程 pid 压栈返回给 Lua 调用方,
lua_pushinteger(L, pid);
return 1;
}

// ==== 以下是子进程分支 ====

// 脱离父进程的会话和控制终端,创建新会话,
// 避免子进程随父进程终止而被终止, 也防止收到父进程会话的信号
setsid();
...

// 获取当前进程允许打开的最大文件描述符数量, 用于后面清理继承自父进程的多余 fd
struct rlimit limits;
if (getrlimit(RLIMIT_NOFILE, &limits) != 0) {
exit(EXIT_FAILURE);
}

for (int f = 3; f < (int)limits.rlim_cur; f++)
close(f);
...

// 用新程序镜像替换当前子进程映像并执行目标命令, 若 execv 成功,后面的代码不会再执行
execv(args[0], (char* const*)args.data());
exit(EXIT_FAILURE);
return 0;
}

按道理execv() 成功会用新程序完全替换当前进程映像,comm/proc/pid/execmdline 都会变成新程序。那是哪里出问题了呢?这段代码在多少个业务跑了多少年了,也没出过问题呀!,接下来就是AI最擅长的地方了,把这个代码丢给AI,他直接给出了问题的根源:

关键嫌疑:for (int f = 3; f < limits.rlim_cur; f++) close(f);

这段是”关闭所有继承的文件描述符”。问题在于 limits.rlim_cur 可能非常大

我分别去了线上CVM集群上和K8s的Node上查看这个内核参数配置:

1
2
3
4
5
6
7
# 线上CVM集群:tlinux2.6版本,linux内核5.4.241
$ cat /proc/sys/fs/nr_open
1048576

# 新集群K8s的Node:tlinux4.4版本,linux内核6.6.92
$ cat /proc/sys/fs/nr_open
1073741816

然后进程的默认limit限制,如下:我们线上的CVM集群初始化的设置了额外限制进程的limit openfiles最大值为204800。

1
2
3
4
5
6
# 线上CVM集群:tlinux2.6版本,linux内核5.4.241
$ ulimit -a
open files (-n) 204800

# K8s的容器中查看的值
open files (-n) 1073741816

所以果然真相大白了!,业务不严谨的代码遇到不确定的环境参数触发了意想不到的BUG。修复方式就很简单,最通用的方式就是遍历打开的fd目录,然后逐一关闭:

1
2
3
4
5
6
7
8
9
...
DIR* d = opendir("/proc/self/fd");
struct dirent* e;
while ((e = readdir(d)) != nullptr) {
int fd = atoi(e->d_name);
if (fd >= 3) close(fd);
}
closedir(d);
...

针对Linux 5.9+ 高版本的内核,可以通过:

1
2
// 一次系统调用关闭整个区间
close_range(3, ~0U, 0)

4. 不同机型的性能测试

让AI写了一个benchmark的性能测试对比,分别在外网不同的机型进行了测试,在不同max limit openfiles的参数前提下,遍历rlimi_cur进行关闭的代码所消耗的时间的性能对比测试:

这里测试完后,发现一个奇怪的问题,为什么S5的机型为啥close的性能那么差?,一脸懵逼,这个时候我只能把所有的数据丢给AI,包括各个机器的CPU型号,果然发现了一个大瓜S5系列的CPU有缺陷😂。

大家应该都知道(「计算机组成原理课程」)CPU 为了加速指令的执行,会预测执行 (大意就是还没确定该不该执行的指令,先猜着跑),猜错了就回滚。

问题在于:回滚只撤销了”架构状态”(寄存器、内存值),但微架构状态(Cache、TLB、分支预测器里留下的痕迹)没清干净。攻击者通过测量”哪块 Cache 变快了”就能反推出被推测访问过的秘密数据——这就是幽灵攻击(Spectre)

芯片改不了,只能靠操作系统内核在软件层面打补丁来堵——代价就是每次 syscall / 上下文切换要多做一堆额外动作。Cascade Lake是受这批漏洞影响最全的一代,几乎全中招。下面是一次系统调用的过程示意:

下面是S5和S6机型关于CPU 硬件漏洞和当前系统对该漏洞的状态和缓解情况,内核参数配置如下:grep -r . /sys/devices/system/cpu/vulnerabilities/

vulnerabilities 项 S5 (Cascade Lake) S6 (Ice Lake) syscall 路径影响
meltdown 🔴 Mitigation: PTI 🟢 Not affected 这就是决定性差异
mds ⚠️ Vulnerable(尝试 VERW) 🟢 Not affected S5 多一条 VERW
l1tf Mitigation: PTE Inversion 🟢 Not affected 都可忽略
tsx_async_abort ⚠️ Vulnerable(尝试 VERW) 🟢 Not affected S5 多一条 VERW
spectre_v2 Vulnerable(未缓解) Vulnerable: eIBRS 都不压 syscall
spec_store_bypass Vulnerable prctl/seccomp 按需 默认不压 syscall

所以结论是:S5 上每次 syscall 确实都多付一笔 PTI 固定开销,虽然只有”极轻量 + 高频”的 syscall 才会表现成明显的性能问题,但是这也是一个坑呀。

5. 写在最后

这个问题排查花了一下午的时间,其实一开始ps如果看到另外一个进程的CPU跑满的话,可能会更快的定位到问题,另外这个问题一开始表现的就是和此进程强相关的特性,应该第一时间考虑从业务的日志上出发,但是自己当时先入为主,脑海中形成了一定固定的假设

  1. 首先,是有其他地方拉起了这个幽灵进程;
  2. 然后,由于进程的防多开,导致启动失败;
  3. 最后,返回循环;

基于上面的假设才进行了“谁在访问hive文件”的流程定位,最终没有发现有其他进程在访问,才回归到进程自己的业务日志上,最终发现了daemon进程创建的流程的问题,还是分析问题的顺序问题。

另外感叹的是,业务代码还是有很多潜在的坑,只是没有发现而已,即使我们已经用AI扫描了很多轮,可能是知识库的建设还是不完善,但是话说模型应该很容易理解这种基础代码才对。

最后,在AI的加持下,如果我们的IDC的机器能够提供MCP进行问题定位分析(安全性问题需要考虑,但是基本的命令组合应该还是可以的),虽然现在可以通过各个组件平台侧提供的MCP/SKILL进行分析问题,例如蓝鲸监控,BKLog,但是都没那么直接,如果能够直接拿到数据一手数据,应该能够更快的定位分析问题。

6. 测试数据

性能测试的原始数据下面

SA3 AMD EPYC™ Milan(2.55GHz/3.5GHz):30.49.40.64

SA5 AMD EPYC Bergamo(-/3.1GHz):21.245.122.28

S5 Intel Xeon Cascade Lake 8255C(2.5GHz/3.1GHz):11.177.159.161

S6 Intel Ice Lake(2.7GHz/3.3GHz):30.49.239.225

SA9 AMD EPYC Turin-D(-/3.4GHz):11.152.253.72

SA5 AMD EPYC Bergamo(-/3.1GHz):30.49.42.109 tlinux4.4的内核