LabHub

리눅스 장애 대응 · 파일디스크립터와 한도 · 이론

한도는 하나가 아니라 넷이다

LabHub 에서 이어서 보기

한 줄 요약

Too many open files 를 보면 거의 모두가 ulimit -n 65536 을 실행하고 재시작한다. 그리고 같은 에러를 다시 만난다. 한도는 하나가 아니라 넷이고, 셸에서 바꾼 값은 이미 떠 있는 서비스에 적용되지 않기 때문이다.

왜 이게 필요했나

nginx: accept4() failed (24: Too many open files) 또는 java.net.SocketException: Too many open files. 이 메시지에는 세 가지 오해가 겹쳐 있다.

1. 한계가 하나라고 생각한다. 실제로는 넷이다.
2. 셸에서 바꾼 값이 서비스에 적용된다고 생각한다. 적용되지 않는다.
3. 한도를 올리면 해결된다고 생각한다. 대개는 누수라서 장애 시점만 몇 시간 뒤로 미뤄진다.

어떻게 동작하나

한도의 포함 관계는 이렇다.

fs.file-max            시스템 전체 합 (현대 리눅스에선 대개 병목이 아님)  fs.nr_open           한 프로세스의 RLIMIT_NOFILE 이 넘을 수 없는 커널 천장    RLIMIT_NOFILE hard 관리자가 정한 상한      RLIMIT_NOFILE soft 실제 적용값 (프로세스가 hard 까지 스스로 올릴 수 있다)

errno 가 층을 갈라 준다. EMFILE(24)Too many open files프로세스 한계, ENFILE(23)Too many open files in system 으로 시스템 전역이다. in system 이라는 두 단어가 진단의 절반이다.

왜 셸의 ulimit 이 소용없나. /etc/security/limits.conf 는 PAM 모듈이 읽고, PAM 은 로그인 세션에서만 동작한다(SSH, su, login). systemd 가 부팅 시 띄운 서비스는 로그인 세션이 아니므로 이 파일을 아예 읽지 않는다. 서비스의 한도는 유닛 파일의 LimitNOFILE 로 정한다. 그리고 rlimit 은 프로세스 생성 시점에 적용되므로 reload 가 아니라 restart 여야 반영된다.

systemd 가 소프트를 낮게 두는 이유. 240 이후 systemd 는 하드를 크게(524288), 소프트를 낮게(1024) 유지한다. 호환성 때문이다 - select() 가 fd 번호 1024 이상을 다루지 못하고, 일부 오래된 프로그램이 기동 시 0부터 소프트 한계까지 모든 fd 를 닫는 루프를 돈다. 그 한계가 백만이면 기동에 몇 초가 걸린다. "필요한 프로그램이 스스로 올려 쓰라"는 설계다. Go 런타임은 실제로 시작할 때 소프트를 하드까지 올리기 때문에, 같은 머신에서 Go 서비스만 멀쩡한 상황이 생긴다.

확실한 확인 방법은 하나뿐이다.

grep 'Max open files' /proc/<PID>/limits     # soft / hardls /proc/<PID>/fd | wc -l                    # 실제 사용량

lsof -p PID | wc -l 은 cwd, root, 실행 파일, mmap 된 라이브러리까지 세는데 이것들은 RLIMIT_NOFILE 에 포함되지 않아 숫자가 더 크게 나온다. 한계와 비교할 값이 아니다.

현장에서 만나는 모습

소켓 누수의 전형적인 모양. fd 종류를 세어 보면 socket 이 6만 개인데 ss 로 상태를 보면 CLOSE-WAIT 가 2만 개이고, 상대 주소별로 보니 전부 같은 Redis 서버다. CLOSE-WAIT 는 상대가 FIN 을 보냈는데 우리 쪽이 close() 를 호출하지 않은 상태이고, 커널이 무한정 기다리며 타임아웃이 없다. 즉 애플리케이션이 커넥션을 반환하지 않고 있다는 뜻이고, 코드 리뷰 범위가 그 자리에서 확정된다.

반대로 TIME-WAIT 는 커널이 알아서 정리하고 fd 도 소비하지 않는다. 둘을 혼동하면 엉뚱한 커널 파라미터를 만지게 된다.

누수인지 부하인지 구분하는 법. 1분 간격으로 fd 수와 ESTABLISHED 수를 함께 기록한다. 연결 수는 평평한데 fd 만 단조 증가하면 누수다. 그때 한도를 올리는 것은 장애를 몇 시간 뒤로 미루는 일일 뿐이다.

소켓도 fd 다. 파일만 세다 보면 놓치는데, 소켓·파이프·epoll·inotify 인스턴스가 전부 fd 를 소비한다. /proc/PID/fd 의 심링크를 readlink 해 보면 socket:[12345], pipe:[67890], anon_inode:[eventpoll] 처럼 종류가 드러난다.

다음 실습에서 할 것

/proc/1/limits 에서 진짜 한도를 읽는 것으로 시작해, 같은 파일을 50번 열어 fd 는 여러 개인데 inode 는 하나로 수렴하는 구조를 확인한다. 한도를 낮춘 셸에서 EMFILE 을 직접 재현하고, 소켓도 fd 라는 것을 눈으로 본 뒤, fd 상위 프로세스를 뽑는 누수 추적 도구를 만든다.