포스트

(C++) 파일 생성 이벤트 모니터링 (Linux)

특정 디렉토리에 파일이 올라오면 자동으로 처리하도록 inotify를 붙였다. IN_CREATE로는 다 써지지 않은 파일을 잡는다는 것, scp로 올리면 이벤트 종류가 달라진다는 것, 큐가 넘치면 조용히 놓친다는 것을 겪고 나서 정리했다.

(C++) 파일 생성 이벤트 모니터링 (Linux)

폴링을 대체하려고 시작했다

원격에서 파일을 특정 디렉토리에 올려두면 보드가 그걸 집어서 처리하는 구조가 필요했다. 처음엔 1초마다 디렉토리를 훑어서 새 파일이 있는지 보는 폴링이었다.

폴링은 두 가지가 걸렸다. 반응이 최대 1초 늦고, 아무 일이 없어도 계속 디렉토리를 읽는다. SD 카드로 도는 보드에서 계속 읽는 게 마음에 안 들었다.

리눅스에는 커널이 알려주는 방법이 있다.

처음 짠 코드

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
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
#include <iostream>
#include <sys/inotify.h>
#include <sys/stat.h>
#include <cstring>
#include <unistd.h>

// Size of read buffer
#define BUF_LEN 1024
#define EVENT_SIZE (sizeof(struct inotify_event))

int main(int argc, char* argv[]) 
{
    // Create an INOTIFY instance
    int fd = inotify_init();

    // Check for error
    if (fd < 0) {
        perror("inotify_init");
        return 1;
    }

    // Add /path directory into watch list.
    int wd = inotify_add_watch(fd, "/path", IN_CREATE);

    if (wd < 0) {
        perror("inotify_add_watch");
        return 1;
    }

    char buffer[BUF_LEN];

    while (1) 
    {
        int length = read(fd, buffer, BUF_LEN);
        if (length < 0) {
            perror("read");
            continue;  // If read failed, try to continue to the next read
        }

        int i = 0;
        while (i < length) {
            struct inotify_event* event = (struct inotify_event*)&buffer[i];
            if (event->len) {
                if (event->mask & IN_CREATE) {
                    if (!(event->mask & IN_ISDIR)) {
                        std::string file_name = event->name;
                        if (file_name.substr(file_name.find_last_of(".") + 1) == "rbf")
                        {
                            // 원하는 기능 삽입
                        }
                    }
                }
            }
            i += EVENT_SIZE + event->len;
        }
    }

    // Removing the "/path" directory from the watch list.
    inotify_rm_watch(fd, wd);

    // Close the INOTIFY instance
    close(fd);

    return 0;
}

inotify_init으로 인스턴스를 만들고, inotify_add_watch로 감시할 디렉토리와 이벤트 종류를 등록한다. 그다음 read로 이벤트를 읽으면 된다.

read 한 번에 이벤트가 여러 개 딸려 올 수 있어서, 버퍼 안을 돌면서 하나씩 처리한다. event->len은 이름의 길이인데 패딩이 포함된 값이라, 다음 이벤트의 위치를 구할 때 EVENT_SIZE + event->len을 더하면 된다. 이름이 없는 이벤트도 있어서 event->len이 0인지 먼저 본다.

동작은 했다. 그런데 실제로 파일을 올려보니 문제가 줄줄이 나왔다.

IN_CREATE는 “다 써졌다”가 아니다

이게 제일 큰 문제였다.

IN_CREATE는 파일이 만들어진 순간에 온다. 아직 내용은 0바이트다. 그 시점에 파일을 읽으면 빈 파일이거나 앞부분만 있다. 몇 MB짜리 파일을 네트워크로 올리는 중이면 몇 초 동안 계속 써지는 중인데, 프로그램은 이미 처리를 시작한다.

처음엔 이벤트를 받고 1초 기다렸다가 읽는 식으로 때웠다. 파일이 커지거나 망이 느리면 또 깨진다. 애초에 얼마를 기다려야 하는지 알 방법이 없다.

정답은 다른 이벤트를 쓰는 것이다.

1
int wd = inotify_add_watch(fd, "/path", IN_CLOSE_WRITE | IN_MOVED_TO);

IN_CLOSE_WRITE쓰기 모드로 열었던 파일이 닫힐 때 온다. 쓰는 쪽이 다 쓰고 닫았다는 뜻이라 이 시점에는 내용이 완성되어 있다.

scp로 올리면 이벤트가 아예 안 온다

IN_CLOSE_WRITE로 바꾸고 나서도 안 잡히는 경우가 있었다. scprsync로 올릴 때다.

이 도구들은 임시 이름으로 파일을 만들어 다 쓰고 나서 최종 이름으로 rename한다. 최종 이름으로는 쓰기가 일어난 적이 없으니 IN_CLOSE_WRITE가 안 온다. 대신 IN_MOVED_TO가 온다.

에디터도 비슷하다. vim 같은 것들은 원본을 직접 고치지 않고 새로 쓴 뒤 바꿔치기한다.

그래서 두 이벤트를 같이 등록한다. 반대로 IN_CREATE는 뺐다. IN_CREATE까지 켜두면 하나의 파일에 대해 이벤트가 두 번 오게 되어 처리가 두 번 돈다.

마스크언제 오는가파일 내용
IN_CREATE파일이 만들어진 순간비어 있다
IN_CLOSE_WRITE쓰기용으로 연 파일이 닫힐 때완성
IN_MOVED_TO다른 이름에서 옮겨 들어올 때완성
IN_DELETE삭제됨 
IN_MODIFY내용이 바뀜 (쓸 때마다 여러 번)진행 중

IN_CLOSE_WRITE가 왔다고 파일이 반드시 온전한 것도 아니다. 쓰던 프로그램이 중간에 죽으면 파일 디스크립터가 닫히면서 이벤트가 온다. 결국 처리하는 쪽에서 크기나 체크섬을 한 번 확인하는 단계를 넣었다.

확장자 검사가 틀렸다

1
file_name.substr(file_name.find_last_of(".") + 1) == "rbf"

점이 없는 파일명이면 find_last_ofnpos를 돌려주고, npos + 1은 0이 된다. 그러면 substr(0)이 되어 파일명 전체가 나온다. rbf라는 이름의 확장자 없는 파일이 걸린다.

흔한 상황은 아니지만, 임시 파일 이름이 점 없이 만들어지는 경우가 있어서 실제로 한 번 걸렸다.

1
2
3
4
5
6
static bool has_extension(const std::string& name, const std::string& ext)
{
    const size_t dot = name.find_last_of('.');
    if (dot == std::string::npos) return false;
    return name.compare(dot + 1, std::string::npos, ext) == 0;
}

숨김 파일도 걸러야 한다. .으로 시작하는 이름은 대개 도구가 만든 임시 파일이다.

버퍼 크기와 정렬

BUF_LEN을 1024로 잡았는데 근거가 없었다. 이벤트 하나도 못 담는 크기면 readEINVAL로 실패한다. 이름이 최대 NAME_MAX(255)까지 오니 최소한 그건 담아야 한다.

1
2
3
constexpr size_t kEventSize = sizeof(struct inotify_event) + NAME_MAX + 1;
constexpr size_t kBufLen    = 32 * kEventSize;      // 이벤트 여러 개를 한 번에
alignas(struct inotify_event) char buffer[kBufLen];

alignas를 붙인 이유가 있다. 버퍼를 char[]로 잡으면 정렬이 보장되지 않는데, 그 위에 inotify_event*를 얹어서 멤버를 읽는 건 정렬을 요구하는 접근이다. x86에서는 정렬이 안 맞아도 동작하지만 ARM에서는 안 될 수 있다. 보드가 ARM이라 그냥 넘길 문제가 아니었다.

버퍼를 크게 잡을수록 한 번의 read로 이벤트를 많이 가져와서 다음 항목이 밀릴 확률이 줄어든다.

큐가 넘치면 조용히 놓친다

이벤트는 커널의 큐에 쌓이고, 큐가 가득 차면 오래된 것부터 버려진다. 파일을 한꺼번에 여러 개 올리거나, 처리 로직이 오래 걸려서 다음 read가 늦으면 이 상황이 된다.

넘쳤다는 사실은 IN_Q_OVERFLOW 이벤트로 알려준다. 이 이벤트는 wd-1이고 이름이 없다.

1
2
3
4
5
if (event->mask & IN_Q_OVERFLOW) {
    // 놓친 이벤트가 있다 — 디렉토리를 통째로 다시 훑는다
    rescan_directory(path);
    continue;
}

이걸 처리 안 하면 파일이 조용히 무시된다. 로그에도 안 남으니 나중에 “왜 이 파일만 처리가 안 됐지”가 된다. 실제로 여러 개를 한 번에 올렸을 때 몇 개가 빠지는 걸 보고 알았다.

큐 크기는 /proc/sys/fs/inotify/max_queued_events에서 볼 수 있고 기본값이 16384다. 늘릴 수는 있지만, 근본적으로는 이벤트 처리를 빨리 끝내고 실제 작업은 다른 곳에 넘기는 구조가 맞다. 이벤트 루프에서는 파일명만 큐에 넣고, 처리는 작업 스레드가 한다.

종료할 방법이 없다

원 코드의 inotify_rm_watchclosewhile(1) 아래에 있어서 절대 실행되지 않는다. read가 이벤트를 기다리며 무한정 막혀 있으니 루프를 빠져나갈 방법도 없다.

inotify_init1(IN_NONBLOCK)으로 논블로킹으로 열고 epoll이나 poll에 물리면 해결된다. 종료용 eventfd를 같이 감시하면 밖에서 깨울 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
int fd  = inotify_init1(IN_NONBLOCK | IN_CLOEXEC);
int evt = eventfd(0, EFD_NONBLOCK | EFD_CLOEXEC);   // 종료 신호용

struct pollfd fds[2] = {
    { fd,  POLLIN, 0 },
    { evt, POLLIN, 0 },
};

while (running) {
    int n = poll(fds, 2, -1);
    if (n < 0) {
        if (errno == EINTR) continue;
        break;
    }
    if (fds[1].revents & POLLIN) break;        // 종료 요청
    if (fds[0].revents & POLLIN) { /* read + 처리 */ }
}

IN_CLOEXEC을 준 것도 이유가 있다. 이 프로그램이 나중에 다른 프로세스를 띄우게 됐는데, 플래그가 없으면 자식이 inotify 디스크립터를 물려받는다. 자식이 오래 사는 프로세스면 그만큼 디스크립터가 잡혀 있게 된다. 앞에서 fd 문제로 데인 뒤로는 fd를 만드는 자리마다 이 플래그를 챙기게 됐다.

read 실패 처리

1
2
3
4
if (length < 0) {
    perror("read");
    continue;
}

모든 실패에서 continue하면, 회복 불가능한 에러가 났을 때 perror를 무한히 찍으면서 CPU를 다 쓴다. errno를 봐야 한다.

1
2
3
4
5
6
if (length < 0) {
    if (errno == EINTR)  continue;              // 시그널 — 재시도
    if (errno == EAGAIN) continue;              // 논블로킹 — 지금 이벤트 없음
    perror("read");
    break;                                       // 그 외에는 빠져나간다
}

그 밖에 알아둘 것

하위 디렉토리는 따로 등록해야 한다. inotify에 재귀 옵션이 없다. 트리 전체를 감시하려면 디렉토리마다 watch를 걸고, 새 디렉토리가 생기면 그때 추가해야 한다. 그 사이에 만들어진 파일은 놓친다.

감시 대상이 사라지면 IN_IGNORED가 온다. 디렉토리가 삭제되거나 이동하면 watch가 자동으로 제거된다. 이 이벤트를 받으면 경로가 다시 생겼는지 확인하고 재등록해야 한다.

네트워크 파일시스템에서는 안 된다. NFS나 CIFS로 마운트한 디렉토리는 다른 호스트에서 일어난 변경을 커널이 모르니 이벤트가 안 온다. 이 경우엔 폴링밖에 답이 없다.

watch 개수 제한이 있다. /proc/sys/fs/inotify/max_user_watches가 기본 8192다. 큰 트리를 재귀로 감시하면 부족할 수 있다.

정리한 형태

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
// 이벤트 루프에서는 파일명만 모으고, 처리는 밖에서 한다
std::vector<std::string> poll_events(int fd, int wd, bool& overflowed)
{
    alignas(struct inotify_event) char buf[32 * (sizeof(struct inotify_event) + NAME_MAX + 1)];
    std::vector<std::string> ready;
    overflowed = false;

    const ssize_t len = ::read(fd, buf, sizeof(buf));
    if (len <= 0) return ready;

    for (char* p = buf; p < buf + len; ) {
        auto* e = reinterpret_cast<struct inotify_event*>(p);

        if (e->mask & IN_Q_OVERFLOW) overflowed = true;

        if (e->len > 0 && !(e->mask & IN_ISDIR) &&
            (e->mask & (IN_CLOSE_WRITE | IN_MOVED_TO)))
        {
            const std::string name = e->name;          // 널 종료되어 있다
            if (!name.empty() && name[0] != '.' && has_extension(name, "rbf"))
                ready.push_back(name);
        }

        p += sizeof(struct inotify_event) + e->len;
    }
    return ready;
}

e->name은 널로 끝나는 게 보장되어 있어서 std::string에 그대로 넣어도 된다. e->len은 패딩까지 포함한 값이라 문자열 길이와 다르다. 이걸 길이로 써서 std::string(e->name, e->len)으로 만들면 뒤에 널이 여러 개 붙는다.

정리하면

  • IN_CREATE는 파일이 만들어진 순간이라 내용이 없다. 완성을 알려면 IN_CLOSE_WRITE
  • scp/rsync/에디터는 임시 파일을 rename 하므로 IN_MOVED_TO도 같이 등록해야 한다
  • 버퍼는 sizeof(inotify_event) + NAME_MAX + 1 이상이어야 하고, ARM에서는 정렬도 챙겨야 한다
  • IN_Q_OVERFLOW를 처리 안 하면 파일을 조용히 놓친다. 오버플로우가 오면 디렉토리를 다시 훑는다
  • 블로킹 read 대신 inotify_init1(IN_NONBLOCK) + poll로 가야 종료 처리가 된다
  • 하위 디렉토리는 재귀로 감시되지 않고, 네트워크 파일시스템에서는 이벤트 자체가 안 온다

참고

  • man 7 inotify
  • man 2 inotify_add_watch
이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.