登录
推荐 文章 Go 技术 课程 下载 专题 AI
首页 >  文章 >  linux

Linux fanotify怎样监控目录变化并过滤事件

来源:17golang原创

时间:2026-11-05 02:32:43 484浏览 收藏

我第一次把目录监控从轮询改成事件通知时,最容易写错的不是宏名称,而是把“目录被标记”误解成“整棵目录树都被递归监听”。Linux fanotify 的基本链路其实很清楚:fanotify_init() 创建事件队列,fanotify_mark() 把目录、挂载点或文件系统加入监控范围,用户态再从返回的文件描述符读取事件,并用 mask 过滤自己真正关心的变化。

本文使用通知类事件做一个最小示例,目标是捕获目录下的创建、移动到目录和写入关闭事件。官方资料入口可以从 https://www.kernel.org/doc/html/latest/ 开始,具体 fanotify 接口还可以查看 https://man7.org/linux/man-pages/man7/fanotify.7.html。示例不会对文件访问做放行或拒绝,也不会把它包装成安全审计系统。

想监控一个目录并过滤事件,核心顺序是“初始化队列—添加目录标记—读取 metadata—按 mask 分流—关闭事件 fd”。如果需要覆盖子目录,单独标记父目录并不够,应重新评估挂载点或文件系统级标记。
  • 初始化:用 fanotify_init 获得事件队列文件描述符。
  • 标记:用 fanotify_mark 指定目录和关心的事件掩码。
  • 过滤:只处理 FAN_CREATE、FAN_MOVED_TO、FAN_CLOSE_WRITE 等目标事件。
  • 清理:通知里的文件描述符由接收程序负责关闭,队列溢出和非递归范围也要单独处理。

Linux fanotify 的事件链路

fanotify 不是一个“每次变化都回调一个函数”的高层库,而是一个由内核维护事件队列的文件描述符接口。调用 fanotify_init 后得到的 fd 同时承担两件事:传给 fanotify_mark 作为通知组的句柄,以及在循环里作为 read 的来源。

标记时可以选择目录、挂载点或文件系统,并用事件掩码声明关注哪些动作。下面的示意图把这条链路拆成了四段;它是原创结构图,不是终端或官方页面截图。

Linux fanotify 从目录事件进入内核队列并由用户态过滤的结构示意图
图1:Linux fanotify 从目录事件进入内核队列,再由用户态按事件掩码筛选的结构示意图。

先用通知类事件标记一个目录

下面的 C 示例把 /tmp/watch-demo 作为命令行参数,使用 FAN_MARK_ONLYDIR 限定目标是目录,并监听三类常见变化。因为 FAN_CREATE 和 FAN_MOVED_TO 属于需要文件句柄信息的目录项事件,初始化时加上 FAN_REPORT_DFID_NAME,标记时再加上 FAN_EVENT_ON_CHILD 与 FAN_ONDIR。FAN_NONBLOCK 让示例在没有事件时不会永久卡住,便于把它放入已有的轮询循环;正式守护进程也可以结合 poll 或 epoll 等待 fd 可读。

#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 

int main(int argc, char **argv) {
    if (argc != 2) {
        fprintf(stderr, "用法:%s 目录\n", argv[0]);
        return EXIT_FAILURE;
    }

    /* 创建带目录项信息的通知队列;非阻塞便于接入 poll/epoll。 */
    int fan_fd = fanotify_init(FAN_CLASS_NOTIF | FAN_CLOEXEC | FAN_NONBLOCK |
                               FAN_REPORT_DFID_NAME,
                               O_RDONLY | O_LARGEFILE);
    if (fan_fd == -1) {
        perror("fanotify_init");
        return EXIT_FAILURE;
    }

    /* 只标记目录本身,监听其直接子项的创建、移动和写入关闭。 */
    uint64_t mask = FAN_CREATE | FAN_MOVED_TO | FAN_CLOSE_WRITE |
                    FAN_EVENT_ON_CHILD | FAN_ONDIR;
    if (fanotify_mark(fan_fd, FAN_MARK_ADD | FAN_MARK_ONLYDIR,
                      mask, AT_FDCWD, argv[1]) == -1) {
        perror("fanotify_mark");
        close(fan_fd);
        return EXIT_FAILURE;
    }

    printf("开始监听:%s\n", argv[1]);
    char buffer[8192];
    for (;;) {
        ssize_t length = read(fan_fd, buffer, sizeof(buffer));
        if (length == -1) {
            /* 非阻塞队列暂时为空时,交给上层循环等待下一次可读。 */
            if (errno == EAGAIN || errno == EWOULDBLOCK) {
                usleep(100000);
                continue;
            }
            if (errno == EINTR) {
                continue;
            }
            perror("read");
            break;
        }

        struct fanotify_event_metadata *meta;
        for (meta = (struct fanotify_event_metadata *)buffer;
             FAN_EVENT_OK(meta, length);
             meta = FAN_EVENT_NEXT(meta, length)) {
            /* 队列溢出没有可用对象 fd,先单独记录这个异常信号。 */
            if (meta->mask & FAN_Q_OVERFLOW) {
                fprintf(stderr, "警告:fanotify 事件队列溢出\n");
                continue;
            }

            uint64_t wanted = FAN_CREATE | FAN_MOVED_TO | FAN_CLOSE_WRITE;
            if ((meta->mask & wanted) == 0) {
                /* 其他事件不进入业务处理,但仍要释放它携带的 fd。 */
                if (meta->fd >= 0) close(meta->fd);
                continue;
            }

            printf("命中事件:mask=0x%llx pid=%d fd=%d\n",
                   (unsigned long long)meta->mask, meta->pid, meta->fd);

            /* FID 模式通常返回 FAN_NOFD;若改用 fd 模式,必须关闭有效对象 fd。 */
            if (meta->fd >= 0) close(meta->fd);
        }
    }

    close(fan_fd);
    return EXIT_FAILURE;
}

编译时可以使用 gcc -O2 -Wall -Wextra fanwatch.c -o fanwatch,再执行 ./fanwatch /tmp/watch-demo。命令只说明构建方式,是否能成功取决于本机内核配置、权限和头文件环境;这里的关键不在轮询间隔,而在每次 read 后用 FAN_EVENT_OK 和 FAN_EVENT_NEXT 正确遍历一个缓冲区中的多条事件。

按 mask 过滤,并及时关闭事件 fd

一条 fanotify 事件至少包含长度、版本、掩码、对象 fd 和触发进程 pid 等元数据。掩码可能同时包含多个 bit,所以不要把它当成单一枚举值直接比较;更稳妥的做法是使用位运算判断关心的 bit,再根据业务决定是否继续处理。

示例中的过滤顺序有两个容易忽略的点。第一,FAN_Q_OVERFLOW 表示队列曾经溢出,之前的部分事件可能已经丢失,不能把它当成普通文件变化。第二,即使某个事件最终被过滤掉,只要 metadata 中带有有效 fd,接收方仍然应该关闭它。下面的图把这两个分支和清理动作画在了一起。

fanotify event metadata 按 mask 分流并关闭文件描述符的流程示意图
图2:读取 fanotify_event_metadata 后按 mask 分流,并在处理结束时关闭事件文件描述符的流程示意图。
事件或字段处理建议容易误判的地方
FAN_CREATE记录目录下新建对象的通知父目录标记不等于对子目录递归监听
FAN_MOVED_TO关注对象被移动到目标目录需要结合业务判断重命名和跨目录移动
FAN_CLOSE_WRITE在写入 fd 关闭时触发后续处理它不是“每一次写系统调用”通知
FAN_Q_OVERFLOW记录丢失风险并安排补偿扫描不能把队列溢出后的通知当作完整变化集
metadata.fd处理完毕后关闭有效 fd关闭的是事件携带的 fd,不只是 fanotify 主 fd

目录监控不递归,什么时候改成挂载点

这是我实际使用时最先需要写进设计说明的一条边界:fanotify 对一个目录加标记,不会自动把所有子目录都变成监控对象。只监听父目录时,直接在该目录创建或移动对象可以进入事件流,但子目录内部的变化不一定会按你预期出现。

如果业务确实要覆盖目录树,可以在新建子目录时继续添加标记,但这个补标记过程存在竞态窗口;另一条路线是选择合适的挂载点或文件系统级标记,让内核从更大的范围观察事件。范围变大后,事件数量、权限要求和过滤压力也会一起上升,不能只把 FAN_MARK_ONLYDIR 换成更大的范围就结束。

此外,fanotify 只报告通过文件系统 API 触发的部分活动,网络文件系统远端变化、通过 mmap、msync、munmap 产生的修改也有覆盖边界。部署前应明确“要发现的是目录项变化、文件写入结束,还是完整的数据变化”,再决定 fanotify 是否合适。

和 inotify 怎么选

我会把两者的选择理解成监控范围和事件对象模型的取舍,而不是简单的“谁更新”。inotify 更适合从目录项和路径事件出发的小型工具;fanotify 更适合需要挂载点或文件系统范围、并且希望通过事件 fd 访问被通知对象的场景。fanotify 还提供访问权限事件,但权限类事件需要接收方写回允许或拒绝结果,错误处理和延迟风险都会更高。

如果只是做一个配置目录变化提醒,先用较小监控范围和简单过滤即可;如果要做内容扫描、文件访问控制或大树范围监控,应额外设计队列积压、权限、补偿扫描、资源限制和失败降级。监控接口能告诉你“发生了什么”,不自动保证你已经拿到了完整、无重复、无丢失的业务事实。

常见问题

fanotify 能直接递归监听目录吗?

给父目录添加普通标记并不会自动递归。可以为新建子目录继续加标记,但补标记有竞态;需要覆盖整棵树时,应评估挂载点或文件系统级监控以及随之增加的事件量。

为什么 read 读到的不是一条固定大小的事件?

一次 read 可能返回多个连续的事件记录,每条记录由 event_len 描述长度。因此应使用 FAN_EVENT_OK 和 FAN_EVENT_NEXT 按记录遍历,而不是按固定结构大小递增。

过滤掉事件后还要 close metadata.fd 吗?

要。只要它不是 FAN_NOFD 或其他无效值,就应在处理或忽略后关闭事件携带的 fd;否则长时间运行的监听器可能出现文件描述符泄漏。

队列溢出后能不能继续当作正常监听?

可以继续运行,但不能假设溢出期间的变化都已收到。应记录 FAN_Q_OVERFLOW,安排一次补偿扫描或重建索引,并根据业务容忍度决定是否暂停后续处理。

fanotify 需要特殊权限吗?

所需权限取决于标记范围、事件类型、内核配置和系统安全策略。示例只展示接口关系,不承诺普通用户在所有发行版上都能标记任意目录;遇到 EPERM、EINVAL 等错误时,应先查看当前内核和目标对象的限制。

总结一下:用 fanotify 监控目录时,先把监控范围、事件掩码和通知类型定清楚,再写用户态过滤器。最小可靠实现至少要能处理多事件缓冲区、无关事件、队列溢出和 fd 清理;如果需求已经扩大到递归目录树或完整变更审计,就不要把一个父目录标记当成全部答案。

声明:本文转载于:17golang原创 如有侵犯,请联系study_golang@163.com删除
相关阅读
更多>
最新阅读
更多>
课程推荐
更多>