strace 与 lsof:看不见的东西怎么观察

难度 ★★★☆☆ · 前置:信号:kill -9 到底做了什么 | 一句话结论:strace 用 ptrace 把目标进程在每次系统调用前后停下来,所以它慢十倍、还会改变时序;lsof 靠读 /proc/*/fd,几乎零开销但只看得到当下。两个工具回答的是不同的问题。

八秒和两百毫秒,差在哪

线上有个服务,偶尔启动要 8 秒,偶尔 200 毫秒,重启一万次也复现不出规律,代码看完了,日志翻遍了,配置也比对了三遍,一个字不差,剩下的可能性就只有一种,它不是卡在自己的代码里,而是卡在跟内核说话的路上,而这条路在用户态完全不可见(越到这个阶段我越会怀疑自己前面的所有判断,因为你越是确信代码没问题,越说明问题在代码之外)。

这时候能做的只剩一件事,看它到底在跟内核说什么。

1
strace -f -T -e trace=openat,connect,read,write -o /tmp/trace.log ./service

然后 grep 一下,按耗时倒序取前五。

1
awk -F'<' '{print $2}' /tmp/trace.log | sort -rn | head -5
1
2
3
4
5
3.901832>
2.102774>
0.000431>
0.000380>
0.000112>

两个 4 秒级、2 秒级的调用,抓出来看是什么。

1
grep -E '<(3\.9|2\.1)[0-9]+>' /tmp/trace.log | head -4
1
2
[pid 8123] connect(3, {sa_family=AF_INET, sin_port=htons(3306), sin_addr=inet_addr("10.0.3.12")}, 16 <3.901832>
[pid 8123] connect(3, {sa_family=AF_INET, sin_port=htons(3306), sin_addr=inet_addr("10.0.3.13")}, 16 <2.102774>

数据库连接。两个地址,第一个超时 4 秒,第二个 2 秒。配置里写了两个 MySQL 地址做故障转移,但客户端没设 connect_timeout,两个 connect 各自在 TCP 握手阶段从头等到尾才放弃。注意这不是内核的默认值,tcp_syn_retries 默认是 6,指的是 SYN 重传 6 次,退避 1+2+4+8+16+32+64 秒,单地址就要一分多钟。这里量到 4 秒和 2 秒,说明这台机器的 tcp_syn_retries 被调过,或者客户端库有自己的短超时,6 秒是实测结果,不是内核默认(我当时专门去线上机器敲了一遍 sysctl net.ipv4.tcp_syn_retries,值确实是 2,跟推算对得上,那一刻才敢确定自己没看错)。

这个问题光看代码永远看不出来。connect 在内核里等着,用户态的日志一点感觉都没有,你甚至会觉得进程根本没在干活,而它其实正在等一个永远等不到的 SYN-ACK,等的那几秒里 CPU 占用是零,线程栈上什么都看不出来,top 里那个进程甚至都不显示消耗,只有把边界上的耗时打出来,你才知道时间花在了一个你没有写的函数里,这也解释了为什么这类问题在代码评审里永远看不出来,评审看的是你写的东西,而它慢在你没写的东西上。

收费站这个比喻,只对了一半

我以前把 strace 理解成在系统调用边界上架了个收费站,车过就记一笔。这个说法对 strace 是准的,但把 lsof 也放进来就错得离谱,错到什么程度呢,差不多等于把温度计和空调当成同一类东西,都能影响室温,一个只是在看,另一个真的在改(这个比喻是我自己想的,想完还挺得意,用了小半年才发现它只能解释一半,另外一半得靠 ptrace 这个词本身去理解)。

strace 用的是 ptrace,内核提供的调试接口。它能让一个进程(跟踪者)控制另一个进程(被跟踪者)的执行,在系统调用入口和出口处停下来,读参数、读返回值,还能读目标进程的内存把指针参数解成字符串,所以它看到的是执行过程,而不是某个时刻的快照。这个区别听起来抽象,落到实际就是,它能在调用还没返回的时候告诉你进去了,而任何读状态的工具都只能告诉你现在是什么样,这一点决定了它所有的脾气和所有的开销,也决定了它为什么必须慢。

lsof 完全不用 ptrace。它干的事很朴素,遍历 /proc/*/fd,把每个符号链接的目标读出来,再读 /proc/*/maps 拿 mmap 打开的文件,拼成一张表,整个过程不打断任何人,也不被任何人打断,代价是你只能看它读的那一刻,早一秒晚一秒都不是同一张表。也正因为如此,它对被观察的进程是完全透明的,进程自己不会知道自己被看过,这也意味着它没能力告诉你刚刚发生过什么,想知道刚才那个 open 到底打开了哪个文件,只能事先挂上去看,事后补是不可能的。

这个区别比参数表重要得多。

差别就在这里。

strace lsof
机制 ptrace(拦截执行) 读 /proc(事后看状态)
能不能看历史 不能,只能看从现在开始 只能看现在
性能影响 十倍级 几乎没有
需要权限 目标同 uid 或 root 同 uid 或 root(别人的 fd 看不到)

所以用法完全不同,strace 用在怀疑它卡在哪的时候盯着,lsof 用在怀疑它开着什么的时候查一遍,一个看过程,一个看结果,而且两个都没法事后补。

lsof 那个输出,很长一段时间没看全

1
2
3
4
5
6
7
8
COMMAND    PID USER   FD      TYPE DEVICE SIZE/OFF    NODE NAME
nginx 1421 root cwd DIR 253,1 4096 2 /
nginx 1421 root rtd DIR 253,1 4096 2 /
nginx 1421 root mem REG 253,1 186520 2163718 /usr/lib/x86_64-linux-gnu/libpcre2-8.so.0
nginx 1421 root txt REG 253,1 847520 1328477 /usr/sbin/nginx
nginx 1421 root 0u CHR 136,0 0t0 3 /dev/pts/0
nginx 1421 root 2w REG 253,1 1240195 2355566 /var/log/nginx/error.log
nginx 1421 root 4u IPv4 24510 0t0 TCP *:80 (LISTEN)

FD 那一列不只是数字,它描述的是这个进程和这个文件之间的关系类型。

六种。

FD 值 意思
cwd 当前工作目录
rtd 根目录(chroot 之后会不一样)
txt 可执行文件本体
mem 通过 mmap 打开的内存映射文件(共享库)
3u / 4w / 5r 数字加模式:r 读、w 写、u 读写
数字后面不带字母 等同于 u

TYPE 列常见值。

七个值。

TYPE 是什么
REG 普通文件
DIR 目录
CHR / BLK 字符设备 / 块设备
IPv4 / IPv6 网络 socket
unix unix domain socket
FIFO 命名管道
a_inode 内核内部用的匿名 inode(信号量等)

(LISTEN) 出现在 NAME 里,这是 lsof -i 才有的信息。加上 -i 之后,lsof 会交叉比对 /proc/net/tcp 拿到网络状态,所以那一列不是从 fd 里读出来的,是另外拼的,这也解释了为什么 lsof -i 比 lsof -p 慢一截(这一条我翻了两遍 man 才确认,之前一直以为 fd 那一列自己能给出监听状态)。

strace 慢十倍,是因为你让它停三次

ptrace 的工作方式是,被跟踪的进程每次进出系统调用都要停下来,等跟踪者读参数、记日志、再放行。

1
2
正常:        用户态 → 系统调用 → 内核 → 返回 → 用户态
被 strace: 用户态 → [停] → 内核 → [停] → 返回 → [停] → 用户态

一共三次上下文切换,每次都要调度。

这是它绕不过去的账。而系统调用本来就贵,现在是贵十几倍,注意这还只是空转的开销,如果你的服务每秒几千次系统调用,这十几倍会直接体现成 QPS 上的一个数量级,而且丢的不只是吞吐,时序也一起变了,本来能复现的竞态可能就复现不出来,本来能压测出来的边界也压不出来,你以为自己在观测它,其实是换了一个程序在跑,这一点在排查并发问题时尤其致命,因为那些问题本来就依赖时序,你一挂上去它就不出现了。

所以真正该记住的是下面这三条,它们比任何参数都重要。

三条,都不长。

  • 不能在生产上对高流量服务长跑 strace -p,它能把 QPS 从 5000 拖到 200。
  • 测性能绝不能在 strace 下测。
  • 想低开销地观测,用 perf、bpftrace,或者 strace -c(只统计不打印)。

strace -c 为什么快一些,因为它不解析参数、不打印,只累计计数。但停下来那一下还是躲不掉,所以 -c 只能用来估量级,不能用来测性能。

不收窄范围,等于没看

不加 -e 的话,一个正常程序一次启动能产生几万行,绝大多数是噪声,翻起来跟看十六进制转储差不多。我第一次这么干的时候在终端里滚了十分钟,最后什么也没找着,当时还怪这个工具不好用(错的是用法,不是工具,这一点我承认得比较晚)。

-e trace= 是必须用的,下面这七行基本覆盖日常九成的需求。

七行,抄下来就够用。

1
2
3
4
5
6
7
strace -e trace=openat,open,stat,newfstatat ./app    # 只看文件操作
strace -e trace=network ./app # 一类:网络
strace -e trace=desc ./app # 一类:fd 操作
strace -e trace=file ./app # 一类:以文件名为参数的
strace -e trace=process ./app # 一类:fork/exec/wait
strace -e trace=signal ./app # 一类:信号
strace -e trace=%pure ./app # 不阻塞的调用(基本只有 getpid 之类)

-e trace= 后面跟的是只显示这些,跟 -e signal= 完全不是一回事,后者是对这些信号做特殊处理,两个选项差一个词,行为差出去很远。我第一次把这两行抄错成同一条,盯着一份干干净净的输出看了半小时,还以为是那个程序真的一次网络都没碰过,后来才发现是选项写错了,这种错很难自查,因为输出本身看不出任何异常,它只是少了一大块。

另一类常见的浪费是过滤写得太细,只留 openat,结果连接超时这种纯网络问题根本不会出现在输出里,你翻到天亮也翻不到,然后开始怀疑 strace 是不是漏了事件,其实是你自己把那一类事件关掉了,所以过滤的粒度要跟着问题走,先宽后窄,别一上来就锁死在某一个调用上,那样等于带着眼罩找东西。

先宽后窄,这四个字值一篇的篇幅。

常用的组合。

参数 作用
-f 跟踪子进程(fork、exec 出去的),必须加
-p PID 附到已存在进程
-o file 输出到文件,别刷屏幕
-T 每个调用后面标耗时,最有用
-t / -tt 加时间戳
-s 256 字符串最大长度,默认 32,太短会截断
-c 只统计,最后给汇总
-y 把 fd 数字翻译成路径(很慢但很有用)
-yy 连 socket 的四元组(源 IP:端口 + 目标 IP:端口)都翻译出来
-k 每次系统调用后打栈回溯(-kk 连源码一起打,极慢)

-f 最容易忘。一个 shell 脚本、一个 Python 程序起子进程、一个用 system() 的 C 程序,不加 -f 就只看到父进程,子进程干的事全丢,而现在的服务几乎没有不起子进程的。我第一次给一个 Python 服务做 trace,输出干净得可疑,只有十几行。折腾了二十分钟才想起来它所有实际工作都在 multiprocessing 起的子进程里,加上 -f 之后刷了几千行,问题三秒就看见了(那二十分钟我一直坚信是那个服务真的没在干活,还顺手怀疑了一下是不是内核参数不对)。

lsof 报不全的时候,先想权限

它的局限来自只读 /proc 这一件事,下面四条后果都是从这一条推出来的。

四条。

一,看不到已删除的文件。名字栏会写 (deleted),这恰好是找泄漏的利器,一个日志文件被 rm 了但进程还开着,磁盘空间就不会回来,df 说满了,du 说没占多少,差的那一块就在这儿。

1
lsof +L1

二,需要权限。别人的进程,普通用户看不到 mem 和 txt 这些,因为 /proc/PID/maps 有权限检查。所以 lsof 报不全的时候先想是不是权限问题,别先怀疑工具坏了。

三,读不到 /proc 就没有。容器里 /proc 被遮了、或者进程在另一个 namespace 里,就看不到,这时候要先 nsenter 进去。

四,它可能是你的 IO 延迟来源。

最后一条最容易忽略。在进程数上万的机器上,lsof 一次能跑十几秒,因为它要 readlink 每一个 fd。别在故障排查时随手 lsof 全系统,加 -p 或 -i 收窄(我有次在压测时顺手跑了一下,机器直接卡了半分钟,那一下才知道这个工具不是免费的)。

什么时候该用哪个。

还是那张表更省事。

症状 先用什么
启动慢,不知道卡在哪 strace -f -T -e trace=file
端口占用了,不知道是谁 lsof -i :8080 或 ss -ltnp
磁盘满了但删不掉 lsof +L1
某个文件被哪个进程写着 lsof /var/log/foo.log
进程卡着不动 /proc/PID/wchan + /proc/PID/stack
想看历史调用 都不行,只能复现

先建立一个数量级的直觉

strace -c 建立的是数量级的直觉,一个命令到底调了多少次系统调用。

1
strace -c ls > /dev/null
1
2
3
4
5
6
7
8
9
% time     seconds  usecs/call     calls    errors syscall
------ ----------- ----------- --------- --------- ----------------
55.33 0.000817 58 14 mmap
14.63 0.000216 216 1 execve
10.16 0.000150 10 15 newfstatat
8.87 0.000131 13 10 close
...
------ ----------- ----------- --------- --------- ----------------
100.00 0.001476 1426 1 total

ls 一个空目录,一千多次调用。这就是第一篇里说系统调用不便宜的意思,不是单次贵,是次数多,一次 ls 背后是 mmap、newfstatat、close、execve 一长串,每一项都要过一次边界。然后看每次调用花多久。

1
strace -T -e trace=openat,newfstatat,write,close ls /tmp 2>&1 | head -12

每行末尾的 <0.000012> 就是耗时。绝大多数是微秒级,偶尔有一个几百微秒,那就是异常点,-T 的精度到微秒,这也是它能用来定位慢调用的原因。现在抓一个慢的现场,用 nc 连一个不存在的地址。

1
time strace -T -e trace=connect nc -w 5 10.0.3.99 3306

-T 会在 connect 那行标出耗时。如果那台机器把包丢了(不是拒绝),你会看到超时,这两种失败的耗时差着一个数量级,包被丢的时候你得等到 connect 自己放弃。对比一下 DNS 解析的代价。

1
strace -T -e trace=connect ssh -o ConnectTimeout=2 -o BatchMode=yes nowhere.invalid

connect 之前会有一次 sendto、recvfrom,那就是 DNS。很多连接慢其实是 DNS 慢,而 DNS 慢又常常是因为 /etc/resolv.conf 里配了两个不可达的 nameserver。

这一条我自己吃过两次。

DNS 这个坑还有一层,/etc/resolv.conf 里默认的 options timeout 是 5 秒、attempts 是 2,两个不可达的 nameserver 叠起来就是 20 秒起步,而这个等待发生在 connect 之前,strace -e trace=network 只会显示 sendto 和 recvfrom 各一次,看起来干干净净,耗时全藏在中间的空白里,不加 -T 根本看不出来。我第一次定位这种问题用的是 time,只看到整条命令慢了二十秒,以为是网络问题,让人去查防火墙,查了两个小时才发现是 resolver 配置写错了一个 IP。

然后是 lsof。

1
lsof -p $$ | head -12
1
2
3
4
5
6
7
COMMAND  PID USER   FD   TYPE DEVICE SIZE/OFF  NODE NAME
bash 3021 alice cwd DIR 253,1 4096 21527 /home/alice
bash 3021 alice rtd DIR 253,1 4096 2 /
bash 3021 alice txt REG 253,1 1396520 13284 /usr/bin/bash
bash 3021 alice mem REG 253,1 2220408 16971 /usr/lib/x86_64-linux-gnu/libc.so.6
bash 3021 alice 0u CHR 136,0 0t0 3 /dev/pts/0
...

$$ 是 shell 自己。看 txt 那一行是 /usr/bin/bash,它自己。0u、1u、2u 全指向 /dev/pts/0,三个标准流都在同一个终端(这个观察很简单,但它顺手解释了一个经典问题,为什么重定向只改一个数字就能生效)。查端口。

1
2
lsof -i :22
lsof -i -P -n | grep LISTEN # -P 不翻译端口号,-n 不解析域名,快很多

-n 和 -P 一定要加,否则 lsof 会去做 DNS 反查和 services 查询,能把一个 0.1 秒的命令拖成 10 秒。这个坑踩过的人很多。最后是找删除未释放。

1
lsof +L1 2>/dev/null | awk '$7 ~ /^[0-9]+$/ && $7+0 > 1048576 {print}'

只打印链接数为 0 且大小超过 1MB 的,前面那些 (deleted) 的小文件是噪声,真正吃空间的是几个 GB 的日志。

strace 测不出性能

strace 能不能用来做性能分析。它的开销是十倍级,测出来的性能毫无意义,要看性能用 perf,要让它进生产用 bpftrace,三个工具的分工不能混。

strace -p 安不安全。对高流量服务可能是致命的,先用 -c 估一下,或者干脆换 bpftrace,只在预发和故障现场用 -p(这条是被人当场拦住的,我之前一直在线上随手 -p,还觉得自己很熟练)。

不加 -f 也能看到全部,这是错的。fork 出去的子进程全部丢,脚本、Python、任何用 system() 的程序都要 -f。

lsof 会不会拖慢系统。它自己不拖,但它在进程数多的机器上要跑很久,排查时可能变成新问题,lsof -i :80 记得加 -n,快十倍。

strace 能看到历史调用,只能看到从附加那一刻开始的,要历史只能靠日志或 bpftrace 的 ring buffer;strace 能看内核里的执行,只能看边界,内核内部发生了什么它一无所知,那是 perf、ftrace、bpftrace 的地盘。

lsof 报 (deleted) 是不是文件坏了。不是,是名字没了但还开着,这恰恰是找泄漏最关键的证据。

两种看法,两种代价

strace 靠 ptrace 在系统调用边界上拦截,开销十倍级,绝不能用于性能测试。-f 跟子进程、-e trace= 收窄范围、-T 看耗时,这三个是必加的,缺一个就会得到一份自己看不懂的输出。lsof 靠读 /proc,几乎零开销,但只能看当下状态,加 -n -P 快十倍,+L1 找删除未释放。

两个都受权限限制,看到的可能不全,不全的时候先怀疑权限,别先怀疑工具。到这里已经能回答进程在干什么、它在等什么、它开着什么这三个问题了,也知道它们的边界在哪里,一个是过程视角但有代价,一个是状态视角但只有一瞬,两个都不能事后补,也都不能在生产上空跑。这套东西我用了三年,到现在还是每次排查先跑一遍 strace -c 估个量级,再决定要不要挂 -p,这个顺序反过来的话,往往是把故障现场又搅了一遍。下一个问题是,nohup ./server & 起的服务,关掉终端就没了,而 nohup 明明装了、也确实生效了,为什么。