1. 为什么排查日志写入进程首选lsof1.1 日志文件异常时的三个典型场景做服务端开发或运维的人几乎都遇到过大大小小的日志问题。有一次某业务系统的日志文件突然停止增长排查半天以为是磁盘满了但实际看了眼磁盘空间还很宽裕。还有一次是磁盘空间被占满du一看发现有个日志文件已经膨胀到几十GB却不知道是哪个程序在持续写入。更常见的情况是你明明删除了一个巨大的日志文件磁盘空间却一点没释放而系统里没有任何报错。这三个场景其实都有一个共同需求需要快速定位“到底是哪个进程在写这个文件”。在Linux环境下这需要借助lsof这个工具。lsof的全称是List Open Files专门用来列出当前系统中被进程打开的文件。在Linux的哲学里几乎一切资源都是文件包括日志文件、套接字、设备节点等。所以lsof不仅能查看普通文件还能查网络连接、动态库加载情况执行权限也非常丰富。1.2 lsof查看写入进程的核心原理lsof之所以能精准定位写入日志文件的进程核心在于Linux内核本身维护着一张完整的打开文件表。每个进程在运行过程中凡是被打开的文件都会分配一个文件描述符File Descriptor内核会记录这个描述符指向哪个文件、以什么模式打开、偏移量在哪里。lsof本质上就是通过读取内核中的这些信息并把它格式化展示出来。这个过程和Windows下无法直接删除正在被占用文件的情况是类似的。Windows会提示“文件被某个程序占用”而Linux默认不拦截删除操作它只是解除文件名和inode的关联。于是就会出现文章开头提到的场景你删了日志文件但进程仍然持有该文件的句柄数据继续写入那个已经被删除的inode磁盘空间自然不会释放。只有定位到进程让进程重启或主动关闭文件才能真正释放空间。1.3 与fuser、pidof、ps等命令的对比排查写入进程时开发者往往会想到ps、pidof或者fuser。实际对比下来lsof在大多数场景下效率最高。ps命令只能看到进程本身和其命令行参数当你有多个相同进程名的实例时单靠ps无法明确区分谁在写哪个文件。pidof则是通过进程名反查PID适合已知进程名的情况但如果日志被多个子进程或线程写入pidof的输出会让人无从下手。fuser能够输出访问某个文件或端口的进程PID用法是fuser -v /path/to/logfile但默认不显示文件的打开模式和文件偏移量排查深度不够。而lsof一条命令就能同时给出PID、进程名、用户、文件描述符、打开模式、文件大小以及当前写入偏移量。尤其是在查看“已删除文件仍被占用”这一场景下lsof的DELETED标识几乎是唯一快速可靠的排查入口。在我实际经验里绝大多数日志写入排查问题用lsof就能闭环解决根本不需要动用strace这类更底层的工具。2. 用lsof查看日志写入进程的完整方法2.1 基础命令格式与高频选项速查最基础也是最直接的命令就是指定文件路径lsof /var/log/app/application.log这条命令会直接列出所有正在打开该文件的进程。如果文件被大量进程打开或者你自己也知道大概是谁在写日志可以在后面加上过滤条件lsof /var/log/app/application.log | grep app这里补充一点在日志文件正在被持续写入时执行lsof你会看到结果里的SIZE/OFF字段不断变化。SIZE/OFF代表该进程在文件中的当前偏移量它持续增长就说明这个文件正在被写入这对判断“哪个进程在真正写日志”非常有用。如果日志文件很多分布在同一个目录下逐个指定路径就显得很低效。这时可以使用目录递归方式lsof D /var/log/app/D选项会递归列出该目录下所有被打开的文件以及对应的进程。注意在日志文件数量特别多的目录下D执行会稍慢因为lsof需要遍历所有文件并匹配。生产环境使用时要有所预期建议尽量指定精确的日志文件路径而不是大范围目录。还有一个非常高频的场景查找所有被删除但仍被进程持有的文件。这类文件不会出现在普通ls的结果里却会持续占用磁盘空间命令如下lsof | grep deleted这条命令在日志轮转或误删日志后磁盘空间未释放的场景下往往能一击命中问题进程。2.2 lsof输出字段逐列拆解看到lsof的输出新手最困惑的是那些英文简写的列名。其实每一列都有明确含义逐一拆解如下。第一列COMMAND是进程的命令名第二列PID就是进程号通常排查日志写入问题时这两个字段是最终要的结果。第三列USER是运行该进程的用户在排查权限问题时有用比如你的当前用户明明有权限写日志却发现日志没更新通过USER列能快速确认写入方到底是谁。第四列FD是文件描述符重要程度最高。常见取值有cwd当前工作目录、txt程序文件本身、mem内存映射文件以及数字描述符。数字后面还会带模式符号r代表只读w代表只写u代表读写a代表以追加模式打开。定位日志写入进程时重点看模式为w或a的FD条目因为普通读取日志文件的tail命令显示的是r模式。第五列TYPE代表文件类型。REG是普通文件DIR是目录CHR是字符设备FIFO是命名管道UNIX是UNIX域套接字。日志文件几乎所有场景下都是REG如果你看到日志路径对应的TYPE不是REG就要怀疑是不是配置错了。第六列DEVICE是设备号用major,minor表示这个主要用于判断文件所在磁盘。第七列SIZE/OFF则很关键它显示文件大小或者当前偏移量。对日志文件而言这个值会随着进程写入而增长。第八列NODE代表inode号第九列NAME是文件路径。有些情况下NODE列会显示具体的inode编号注意如果我们看到NAME后面跟着deleted后缀说明这个文件已经被删除但进程仍然持有句柄这是磁盘空间不释放的典型信号。2.3 快速判断当前正在写入的进程有一种情况比较迷惑日志文件同时被tail命令观察、被编辑器打开过又被应用进程写入。到底哪个是真正的写入进程这时候光看FD不够还需要结合打开模式、文件偏移量一起判断。先举一个实际的输出示例COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME app 2048 root 1w REG 253,1 12845056 88321 /var/log/app/application.log tail 3100 root 3r REG 253,1 12845056 88321 /var/log/app/application.log vim 2333 root 4r REG 253,1 12845056 88321 /var/log/app/application.log可以看到app进程的FD第1行是“1w”表示文件描述符1以写模式打开这通常是标准输出重定向到日志文件的情况。tail和vim都是r模式只是读取或编辑并不会写入数据。所以答案一目了然真正写日志的是PID 2048的app进程。这里有一个经验值得强调看到SIZE/OFF值对于读模式打开的进程通常同样会显示文件大小但它不会变化。只有w或a模式的进程它的偏移量会随写入不断变化。所以在排查“日志停止增长”问题时我会先用lsof看是否存在写模式的进程如果没有写模式进程那日志不增长的问题根本不在写入端而在应用本身或日志轮转配置上。3. 日志排查实战从现象到定位的完整记录3.1 磁盘告警下的Nginx访问日志排查某天某台服务器磁盘使用率突然超过90%告警推送到了值班群。登录服务器后首先df -h查看挂载点发现根分区空间异常。接着du -sh *挨个目录排查很快锁定了nginx日志目录。该目录下访问日志文件已经膨胀到近30GB。这种情况下第一步应该先确认哪些进程打开了这些日志文件lsof /var/log/nginx/access.log输出如下COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME nginx 8123 root 9w REG 253,1 32123456789 142097 /var/log/nginx/access.log nginx 8124 root 9w REG 253,1 32123456789 142097 /var/log/nginx/access.log nginx 8125 root 9w REG 253,1 32123456789 142097 /var/log/nginx/access.log可以看到nginx有多个worker进程都在以写模式打开这个日志文件。这是Nginx多进程模型的正常表现每个worker都会继承master进程打开的文件描述符。此时要做的不是kill掉所有worker而是明确是否需要切割日志。通常处理方案有两种如果日志膨胀是当天流量异常导致先定位流量来源如果只是持续增长就做日志轮转把access.log切割成按日期归档的文件。在切割日志时还要注意一个细节执行mv access.log access.log.20240101后不能只是简单移动文件因为nginx worker仍然持有原inode的句柄后续日志会继续写入那个被移动走的文件磁盘空间依然不会释放。正确做法是mv之后马上执行nginx -s reopen让nginx重新打开新的access.log文件。3.2 应用重启后日志依然增长的怪象还有一次更隐蔽的问题某Java应用因为内存问题重启了进程但重启之后发现旧日志文件依然在持续增长。按常理进程重启后文件句柄会重新建立旧文件不可能再被写入。lsof一查发现旧日志文件竟然被另一个残余进程持有lsof /opt/app/logs/error.log结果里除了重启后的新进程还出现了一个PID是几百的老进程其FD列是2w也就是标准错误重定向到了error.log。原来这个Java应用启动脚本里使用nohup方式启动重启时旧进程并没有被完全杀掉只是主进程退出了某个子进程或者守护进程仍在后台运行。这类情况在排查时容易走弯路因为ps -ef | grep java看起来会有多个进程难以分辨哪个是残留的旧进程。而lsof直接从文件反查进程一下子就能把持有该日志句柄的所有进程都暴露出来。这种场景下处理方式很简单确认残留进程后kill掉即可但前提是要确认它确实没有在处理核心业务。如果无法确认最好是先查看进程启动时间和父进程ID理清楚进程树再决定处理动作。3.3 误删大文件后磁盘空间未释放的经典案例在用logrotate或者手动清理日志时经常会出现一个令人困惑的情况删除了一个20GB的日志文件df -h一看磁盘可用空间几乎没有变化。这会让不了解Linux文件系统机制的人误以为删除失败。实际上这正是指向lsof | grep deleted的最佳场景。执行后能看到类似下面的输出java 55680 app 2w REG 253,1 21474836480 348966 /opt/app/logs/app.log (deleted)可以看到这个java进程的文件描述符2仍然指向已经删除的inode文件大小还在持续增长。此时你无论删多少次文件名磁盘空间都不会被释放。处理方法取决于你对业务的容忍度如果日志不再需要可以kill掉进程强制释放句柄如果进程不能重启那就只能接受空间暂时被占用等到进程自然退出或计划维护时再处理。在生产环境处理这类问题时我的建议是优先确认进程是否还在正常服务不要为了释放磁盘空间贸然kill进程否则可能导致更严重的业务中断。能优雅重启就优雅重启不能就通过调整日志级别或临时工具控制写入量为运维争取窗口期。4. lsof使用时的高频坑点与解决办法4.1 lsof未安装或权限不足时怎么处理很多精简安装的系统默认没有lsof。yum install lsof或者apt-get install lsof安装一下即可。但是要注意在容器环境里基础镜像可能连包管理器都不太全更建议在宿主机上执行lsof然后用容器ID过滤。权限不足的情况会更隐蔽。普通用户执行lsof可能看不到其他用户进程打开的文件输出会变得很少甚至没有。会遇到的情况就是明明日志在增长lsof指定路径却查不到任何结果。这时候先别怀疑命令错了大概率是权限不够。可以加sudo执行sudo lsof /var/log/app/application.log另外某些系统开启了SELinux或者设置了特殊的文件系统挂载选项也可能导致lsof读取不到信息。如果sudo后依然查不到可以用ls -l /proc/*/fd命令逐一排查这个方法是根本方案。4.2 用/proc文件系统手工排查写入进程lsof底层依赖/proc文件系统理解了这一点在lsof不可用或者输出异常的时候就能用手工方式排查。方法很简单遍历所有进程的文件描述符符号链接然后匹配目标日志文件。例如你要查哪个进程打开了application.logls -l /proc/*/fd 2/dev/null | grep application.log输出的结果中路径中带有/proc/PID/fd的中间数字就是进程号。比如下面这行输出可看出PID 8123进程的FD 9指向了目标日志文件/proc/8123/fd/9 - /var/log/app/application.log这种手工方式还有一个独门优势可以看到符号链接后面是否带(deleted)标记。如果带deleted标记意味着文件已删除但句柄仍被进程持有这正好印证了之前提到的磁盘空间不释放问题。当系统连lsof都无法安装时这个方法能救命。4.3 容器环境下宿主lsof查不到写入进程刚接触容器化部署的朋友容易踩这个坑容器内某服务在写日志但在宿主机上运行lsof /var/lib/docker/containers/xxx/log.log却看不到任何进程。原因在于容器进程的视角和宿主进程的视角不同容器内的文件路径和宿主机上的路径不一定对应而且容器的PID命名空间是隔离的。处理这类场景最稳妥的方式是直接进入容器内部执行lsof。比如用docker命令进入容器命名空间后运行lsof或者ls -l /proc/*/fd。如果是Kubernetes环境可以使用kubectl exec进入容器再排查。如果出于安全策略无法登录容器还有一个偏门方法在宿主机上找到容器主进程的PID然后用nsenter进入该容器的PID命名空间执行lsof。这个操作相对底层建议只在充分了解容器机制的基础上使用。还有一个很关键的点容器内很多基础镜像没有lsof甚至连shell都未必完整。此时可以使用一条更通用的命令直接在宿主机上读取容器主进程的所有fd信息ls -l /proc/$(docker inspect -f {{.State.Pid}} 容器名)/fd然后用readlink去解析这些符号链接指向的真实路径。这个方法不依赖容器内任何工具非常适合瘦身镜像的容器排障。4.4 lsof误判与延迟刷新问题lsof的输出并不是实时动态刷新的它只是某一瞬间的快照。如果你运行一次lsof发现没有结果但日志明明在涨有可能是执行瞬间和写入瞬间错开了。对于持续写入的日志文件这种概率很低但并非没有。更稳妥的做法是稍微等待一下再执行一次或者直接使用watch动态观察watch -n 1 lsof /var/log/app/application.log这条命令会每秒刷新一次能清晰看到文件偏移量的变化趋势即便没有直接写明“正在写入”偏移量持续增长也是铁证。另外lsof在某些内核版本或者某些网络文件系统上对文件偏移量的报告可能不准确。如果出现这种情况可以直接改用/proc文件系统检查文件大小变化。判断写入活动最朴素的方法就是连续两次stat文件观察大小是否变化stat -c %s /var/log/app/application.log两次执行结果如果不同铁定有进程在写只是lsof因为某些原因没显示出来。5. 进阶技巧与不同场景的最佳实践5.1 多进程写同一份日志时的归属判断实际生产环境中多个进程写同一份日志文件并不少见尤其是多个worker进程都会打开同一个文件。lsof会把所有持有该文件句柄的进程全部列出来但如何在众多进程中判断谁是“当前活跃写入者”简单有效的方法是连续执行两次lsof对比SIZE/OFF字段值。哪个进程两次之间偏移量增长明显哪个就是在持续写入。例如lsof -o /var/log/app/app.log-o选项会强制显示文件偏移量便于观察对比。如果两轮执行间隔时间太短偏移量差异不明显可以适当加大间隔把两次命令隔开3至5秒执行对比就更明显。当然如果一批进程来自同一个服务定位到某个具体PID意义并不大因为服务内部随时可能创建新的worker进程。这时候更应该关注的是服务本身以及它的配置和日志轮转策略。5.2 组合使用lsof与lsof选项解析日志轮转是否生效日志轮转是高效运维中必备的一环。很多团队用logrotate管理日志但配置不当会导致切割后的日志不存在增长、或者原来日志文件被持续占用。用lsof能快速验证轮转是否真正生效。轮转配置中通常会加上copytruncate或者create选项。如果使用create方式日志文件名会重新创建lsof查看到的打开文件路径是新的如果是copytruncate方式进程实际持有的还是同一个inode只是文件被截断了。用lsof观察时需要确认FD写出位置与当前日志文件路径是否完全一致。例如执行完logrotate之后再次运行lsof /var/log/app/application.log发现输出中的NAME依旧是application.log而且SIZE/OFF从0开始增长说明轮转生效了如果NAME显示的是application.log.20240101说明要么没触发reopen要么配置有误。这个验证方法在我处理过的一次日志不写问题里直接排除了application配置错误。5.3 大日志量下的性能注意事项日志量特别大、文件描述符特别多的时候lsof不加限制直接执行有可能会产生较大的系统开销。尤其在日志目录下使用D递归扫描大量文件时I/O消耗会被明显放大。生产环境建议严格限定文件路径避免整个/var/log目录扫描。还可以使用lsof的-p参数指定PID直接查询某个进程打开了哪些文件这在反查进程写入行为时效率更高lsof -p 8123这条命令能看到进程8153的所有文件句柄包括日志文件、配置文件、socket连接等。它适合在已知PID、想要快速理解进程行为时使用。5.4 把lsof封装成日常排查脚本排查经验多了之后我发现经常用到的命令其实就那么几条。推荐把它们整理成简单的shell脚本放到常用工具目录下省得每次手动敲一长串参数。一个比较通用的脚本逻辑是输入日志文件路径第一轮显示当前打开该文件的进程第二轮给出文件实时大小第三轮列出当前目录下所有被占用的文件。参考示意如下#!/bin/bash LOG_FILE$1 echo 打开该文件的进程 lsof $LOG_FILE echo 文件当前大小 stat -c %s bytes $LOG_FILE echo 已删除但仍被占用的文件 lsof | grep deleted | grep $(dirname $LOG_FILE)这个脚本的价值不只是节省重复输入更重要的是它迫使你按照固定顺序排查先看谁打开了文件再看文件当前状态最后扫描删除占用。几次排障下来这套思维模型会变成直觉遇到日志相关的问题自然知道下一步做什么。最后说一个经验我在实际排查过程中最大的体会是遇到日志不增长、磁盘不释放之类的问题先不要急着看应用代码也别忙着重启服务。先冷静下来对目标日志文件执行一次lsof看清到底有没有进程持有它、以什么模式持有、现在是哪个进程在写。这个动作成本极低却能把排查范围瞬间缩小到进程层面。绝大多数日志问题走到这一步方向就已经清晰了。以后遇到这类问题可以先从这条命令开始。
阅读完成 · 觉得有帮助?