跳至正文
  • 12 views
  • 4 min read

iowatch —— 专为 Linux 休眠分析设计的轻量级监控工具

新浪微博 豆瓣 QQ 百度贴吧 QQ空间

分类: 操作系统 / 运维 / NAS & 嵌入式

标签: fanotify, 硬盘休眠, I/O 监控, C语言, ARM, OpenWrt, 静态编译


内容摘要

你的 NAS 或 OpenWrt 硬盘是否总是莫名其妙地自动启动?iowatch 是一个不到 1MB、零依赖的 C 语言小工具。它利用 Linux 内核的 fanotify 机制,能精准告诉你 “谁”“什么时候” 读写了 “哪个文件”。它是分析磁盘休眠问题的终极利器,支持 ARM、x86 及 OpenWrt 环境。


简介

如果你拥有一台基于 ARM 的 NAS(如群晖、绿联或自建 NAS)或运行 OpenWrt 的路由器,你一定经历过这种痛苦:明明设置了 15 分钟硬盘休眠,但每隔一会儿硬盘就会发出“咔哒”一声重新转起来,节能计划瞬间化为泡影。

问题的难点在于:到底是谁在读写磁盘? 传统的 iotop 只能看到实时吞吐量,却难以捕捉瞬时且零星的文件访问。

iowatch 诞生了。它是一个专门为诊断磁盘休眠而设计的超轻量级工具,直接监控文件系统挂载点底层的每一丝动静。


核心功能

  • 零依赖运行: 采用静态编译(Static Linking),生成后的单个二进制文件不依赖任何系统库(如 GLIBC),在任何 Linux 版本上都能直接跑。
  • 全架构支持: 源码可在 ARM64/AArch64(现代 NAS)、x86_64(普通服务器)甚至 MIPS(老旧路由器)上编译运行。
  • 深度内核集成: 基于 Linux fanotify 子系统(内核 2.6.37+ 支持),直接从虚拟文件系统(VFS)层获取最真实的 I/O 通知。
  • 精准追踪: 不仅显示进程 ID(PID)和进程名,还能追溯触发该事件的原始执行程序文件名,即使在 Docker 环境下也能快速锁定元凶。
  • 清晰简洁: 移除冗余信息,只保留时间戳、动作(OPEN/READ/WRITE)、PID 和文件路径。

作用与角色:如何解决休眠问题?

iowatch 在你的“磁盘休眠保卫战”中扮演着侦察兵的角色:

  1. 识别“心跳”进程: 许多后台服务会定期轮询配置文件(如每 30 秒读一次)。iowatch 的时间戳能帮你发现这些规律性的访问。
  2. 锁定“隐形”写入: 系统日志(rsyslog)或容器缓存的每一次 WRITE_CLOSE 动作都会强制内核刷盘,唤醒磁盘。通过路径,你可以迅速知道是哪个日志文件在搞鬼。
  3. 容器透明化: 在 Docker 繁多的环境下,单纯看进程名很难判断归属。iowatch 显示的执行程序路径能帮你直接关联到具体的容器。

完整 C 语言源码:iowatch_clean.c

该代码已针对现代 Linux 内核(5.1+)优化,使用 FAN_MARK_FILESYSTEM 标志以获得最佳兼容性。

/*
 * IOWATCH - 磁盘休眠分析工具
 * 功能:监控指定挂载点的所有文件访问事件
 * 编译:gcc -O3 -static iowatch_clean.c -o iowatch
 */

#include <stdio.h>
#include <stdlib.h>
#include <sys/fanotify.h>
#include <fcntl.h>
#include <unistd.h>
#include <sys/stat.h>
#include <errno.h>
#include <string.h>
#include <time.h>
#include <limits.h>
#include <libgen.h>

void print_usage(char *prog_name) {
    printf("\n================================================================\n");
    printf(" IOWATCH - 磁盘休眠分析工具 (ARM/x86_64)                       \n");
    printf("================================================================\n");
    printf("核心规则:\n");
    printf("  监控目标必须是一个【挂载点】根目录(如 / 或 /volume1)。\n");
    printf("  请通过 'df -h' 命令查看 \"Mounted on\" 一栏确认路径。\n\n");
    printf("用法:\n");
    printf("  sudo %s <挂载点路径>\n", prog_name);
    printf("示例:\n");
    printf("  sudo %s /volume1\n", prog_name);
    printf("================================================================\n\n");
}

const char* get_action(uint64_t mask) {
    if (mask & FAN_MODIFY) return "MODIFY";
    if (mask & FAN_CLOSE_WRITE) return "WRITE_CLOSE";
    if (mask & FAN_ACCESS) return "READ";
    if (mask & FAN_OPEN) return "OPEN";
    return "EVENT";
}

int main(int argc, char *argv[]) {
    if (argc != 2) {
        print_usage(argv[0]);
        return 1;
    }

    // 初始化 fanotify 句柄
    int fd = fanotify_init(FAN_CLASS_NOTIF, O_RDONLY);
    if (fd < 0) {
        if (errno == EPERM) fprintf(stderr, "[错误] 需要 root 权限。\n");
        else perror("fanotify_init 失败");
        return 1;
    }

    // 设置监控掩码:修改、写入关闭、读取、打开以及子目录事件
    uint64_t mask = FAN_MODIFY | FAN_CLOSE_WRITE | FAN_ACCESS | FAN_OPEN | FAN_EVENT_ON_CHILD;
    
    // 标记监控目标:FAN_MARK_FILESYSTEM 支持整个文件系统监控 (内核 5.1+)
    if (fanotify_mark(fd, FAN_MARK_ADD | FAN_MARK_FILESYSTEM, mask, AT_FDCWD, argv[1]) < 0) {
        fprintf(stderr, "[错误] 无法监控目标路径: %s\n", argv[1]);
        if (errno == ENOENT) {
            fprintf(stderr, "提示:请运行 'df -h' 确保该路径是基础挂载点。\n");
        } else {
            perror("原因");
        }
        return 1;
    }

    print_usage(argv[0]);
    printf("%-20s | %-12s | %-7s | %-15s | %s\n", 
           "时间戳", "动作", "PID", "进程名", "文件路径 [触发程序]");
    printf("----------------------------------------------------------------------------------------------------\n");

    char buf[8192];
    while (1) {
        ssize_t len = read(fd, buf, sizeof(buf));
        if (len < 0) break;

        struct fanotify_event_metadata *metadata = (struct fanotify_event_metadata *)buf;
        while (FAN_EVENT_OK(metadata, len)) {
            if (metadata->fd >= 0) {
                char f_path[PATH_MAX], e_path[PATH_MAX], ts[20], proc_comm[32];

                // 1. 获取受影响的文件路径
                sprintf(f_path, "/proc/self/fd/%d", metadata->fd);
                ssize_t p_len = readlink(f_path, f_path, sizeof(f_path)-1);
                if (p_len != -1) f_path[p_len] = '\0';
                else strcpy(f_path, "未知路径");

                // 2. 获取触发进程的简称 (Comm)
                sprintf(proc_comm, "/proc/%d/comm", metadata->pid);
                int c_fd = open(proc_comm, O_RDONLY);
                if (c_fd >= 0) {
                    ssize_t c_len = read(c_fd, proc_comm, 31);
                    if (c_len > 0) proc_comm[c_len-1] = '\0';
                    close(c_fd);
                } else strcpy(proc_comm, "已退出");
                
                // 3. 获取触发程序的执行文件名 (basename)
                char raw_exe[PATH_MAX];
                sprintf(raw_exe, "/proc/%d/exe", metadata->pid);
                ssize_t e_len = readlink(raw_exe, raw_exe, sizeof(raw_exe)-1);
                if (e_len != -1) {
                    raw_exe[e_len] = '\0';
                    strcpy(e_path, basename(raw_exe)); 
                } else {
                    strcpy(e_path, "未知");
                }

                // 4. 生成时间戳
                time_t now = time(NULL);
                strftime(ts, 20, "%Y-%m-%d %H:%M:%S", localtime(&now));

                // 排除工具自身的事件
                if (metadata->pid != getpid()) {
                    printf("%-20s | %-12s | %-7d | %-15s | %s [%s]\n", 
                           ts, get_action(metadata->mask), metadata->pid, proc_comm, f_path, e_path);
                }
                close(metadata->fd);
            }
            metadata = FAN_EVENT_NEXT(metadata, len);
        }
    }
    return 0;
}

编译与部署指南

第一步:安装编译器(GCC)

在你的目标机器(NAS 或路由器)上安装 GCC:

  • Debian/Ubuntu/宿主机: sudo apt install -y gcc libc6-dev
  • OpenWrt: opkg update && opkg install gcc libc-dev

第二步:静态编译

静态编译是跨版本运行的关键。它会将所需的库直接包含在二进制文件中。

Bash

gcc -O3 -static iowatch_clean.c -o iowatch

第三步:运行与分析

  1. 运行 df -h 找到你想要监控的硬盘挂载点(例如 /volume1)。
  2. 启动工具:sudo ./iowatch /volume1
  3. 观察输出,特别注意那些每隔几分钟就规律出现的 WRITE_CLOSEOPEN 动作。

兼容性注意事项

此脚本默认使用 FAN_MARK_FILESYSTEM,这需要 Linux 内核 5.1 或更高版本。

如果你的设备(如老旧 OpenWrt 路由器)内核版本较低(可通过 uname -r 查看),请将源码中的: FAN_MARK_FILESYSTEM 替换为: FAN_MARK_MOUNT

注意: 使用 FAN_MARK_MOUNT 时,必须确保输入的路径是 df 命令中显示的完全一致的挂载根路径,否则会报错。


希望这个小工具能帮你找回丢失的“硬盘休眠时间”!

发表回复

联系站长