20131020

FSniper - Monitor Newly Created Files in Directory

업로드 디렉터리를 주기적으로 cron이 훑는 구조에서 벗어나고 싶어서 FSniper를 찾았다. 리눅스 커널의 inotify(2.6.13+)를 이용한 데몬 형태 파일 감시 툴이다. 목적은 단순: "디렉터리에 새 파일이 떨어지면 즉시 어떤 명령을 실행"시키는 것. cron으로 5분마다 폴링하지 않아도 되고, 파일이 놓이자마자 ms 단위로 반응한다.

왜 inotify인가

파일 감시는 크게 세 가지 방식이 있다.

  • 폴링(polling): cron이 주기적으로 디렉터리 스캔. 간단하지만 지연이 크고, 디렉터리 큰 경우 부하가 있다.
  • dnotify: 구형 리눅스에서 쓰던 방식. fd 관리가 까다롭고 제한 많음.
  • inotify: 커널이 파일시스템 이벤트(IN_CREATE, IN_MODIFY, IN_CLOSE_WRITE 등)를 올려줌. O(1) 반응. CentOS 6 쓰면 기본 커널에 이미 들어있다.

inotify는 파일 디스크립터 하나에 watch descriptor 여러 개를 붙이고, read()로 이벤트를 읽는 구조. 직접 프로그래밍하기도 어렵지 않은데, 데몬 형태로 돌릴 때 재시작/로그/설정 관리 등을 직접 짜기가 귀찮아서 FSniper 같은 wrapper를 쓰는 게 편하다.

설치

# CentOS 6 / EPEL 없는 경우 소스빌드
$ wget http://fsniper.googlecode.com/files/fsniper-1.3.1.tar.gz
$ tar xzf fsniper-1.3.1.tar.gz
$ cd fsniper-1.3.1
$ ./configure && make && make install

# 의존성: libinotifytools, pcre
$ yum install inotify-tools pcre pcre-devel

설정 파일

FSniper는 ~/.config/fsniper/config에서 설정을 읽는다. 디렉터리별로 "어떤 파일이 떨어지면 어떤 handler를 돌릴지"를 트리 구조로 선언한다.

watch {
    /home/upload/incoming {
        *.jpg {
            handler = /usr/local/bin/process_image.sh %%
        }
        *.csv {
            handler = /usr/local/bin/import_csv.sh %%
        }
        # 정규식도 가능 (PCRE)
        match_re ^backup_[0-9]+\.tar\.gz$ {
            handler = /usr/local/bin/archive.sh %%
        }
    }
}

%%가 실제 파일 경로로 치환된다. handler가 exit code 0 이면 정상 처리된 걸로 간주하고, 넌제로면 실패로 로그 남김.

실행

$ fsniper         # 포그라운드
$ fsniper --daemon

startup 스크립트로 올리려면 /etc/init.d/fsniper를 간단하게 짜거나, nohup에 태워서 rc.local에 넣는다. CentOS 6 기준. upstart/systemd가 들어오면 unit file로 깔끔하게 가능.

주의: IN_CLOSE_WRITE vs IN_CREATE

함정 하나. inotify에는 이벤트가 여러 종류고, FSniper도 기본으로 "파일이 close 된 시점"(= IN_CLOSE_WRITE)에 handler를 호출한다. 이게 중요한 이유는, 업로드 중인 파일에 트리거되면 안 되기 때문이다.

예를 들어 scp로 1GB 파일이 들어오는 중이면 파일이 만들어진 순간(IN_CREATE)엔 0바이트다. 여기서 handler가 돌면 "잘린 파일"을 처리해버린다. IN_CLOSE_WRITE는 write 후 close() 되는 시점에만 발생하니까 안전하다.

근데 예외가 있다. rsync는 임시 파일(.filename.abcd)에 쓴 뒤 rename()으로 교체한다. 이 경우 IN_CLOSE_WRITE가 임시 파일에 찍히고, 최종 이름에는 IN_MOVED_TO가 찍힌다. 그래서 rsync 소스를 감시할 땐 IN_MOVED_TO도 같이 봐야 한다. 설정에 events = close_write,moved_to 형태로 추가.

서브디렉터리 감시는 기본 안 됨

inotify는 재귀적으로 watch하지 않는다. 디렉터리별로 watch descriptor를 따로 달아야 한다. FSniper는 설정에서 디렉터리 트리를 선언하면 시작 시점에 서브디렉터리에도 재귀적으로 watch를 건다. 단, 감시 시작 후에 새로 생긴 서브디렉터리에는 자동으로 watch가 안 붙는다. 이거 모르고 쓰면 "갑자기 감지가 안 된다"는 증상이 나온다. 회피법: 상위 디렉터리에 IN_CREATE도 감시해서 새 서브디렉터리 만들어지면 watch를 추가하는 로직. FSniper는 기본 구현에 이 보강이 들어있긴 한데, 버전에 따라 빠진 경우도 있어 확인 필요.

kernel limit

한 프로세스가 걸 수 있는 watch 수는 /proc/sys/fs/inotify/max_user_watches로 제한된다. CentOS 6 기본값이 8192인가 그랬는데, 수십만 파일 디렉터리 구조에서는 부족해질 수 있다. 올리려면

# /etc/sysctl.conf
fs.inotify.max_user_watches = 524288
fs.inotify.max_queued_events = 32768

$ sysctl -p

각 watch는 커널 메모리를 조금 쓰는데(약 1KB 선), 50만개 잡아도 500MB 정도라 서버에서 감당 가능하다.

대안: inotifywait / incron

shell 한 줄로 처리할 거면 inotify-tools의 inotifywait가 더 가볍다.

inotifywait -m -e close_write,moved_to /home/upload/incoming | \
while read dir event file; do
    /usr/local/bin/process.sh "$dir$file"
done

단일 디렉터리 단일 핸들러면 이게 제일 간결. 여러 디렉터리, 패턴 매칭, 데몬 라이프사이클이 엮이면 FSniper나 incron이 낫다. incron은 cron 스타일 설정이라 관리자 친화적이고, 시스템 서비스로 패키지에 포함되어 있어서 운영 쪽이 더 좋아한다.

장애 대비

데몬 자체가 죽는 경우를 대비해야 한다.

  • FSniper 프로세스 감시(monit으로 PID 체크) - 죽으면 restart
  • handler 실패 파일은 quarantine 디렉터리로 옮기는 step을 handler 끝에 넣기
  • 시작 시점에 놓여있던 "과거 파일"은 감시 대상이 아니니까, 부팅/재시작 직후 기존 디렉터리 스캔해서 한번 처리하는 init 스크립트를 같이 두기

결론

cron 폴링에서 inotify 기반으로 바꾸고 가장 크게 달라진 건 반응성. 파일이 놓인 지 100ms 이내에 handler가 실행된다. 5분 주기 cron이 만들던 "사용자가 업로드하고 5분째 기다리는" UX가 사라진다. 운영 관점에서도 부하가 낮고, 파일 수 많은 디렉터리에서 주기 스캔의 IO 비용이 사라진다.

다만 inotify는 NFS에서 제대로 안 먹힌다는 점 기억해둘 것. NFS 4.1부터 일부 지원이라지만 운영에서는 믿고 쓰기 어렵고, 로컬 파일시스템에서만 신뢰할 수 있다고 보는 게 안전하다. 업로드 서버는 로컬 디스크로, 그 뒤에 rsync/lsyncd로 뿌리는 구조를 추천.