LabHub
배우기 러닝패스 코스

Linux Incident Response

There Is Not One Limit but Four

LabHub 에서 이어서 보기

한국어 원문으로 표시합니다.

한 줄 요약

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

Concept map: 한계가 하나라고 생각한다. · 셸에서 바꾼 값이 서비스에 적용된다고 생각한다. · 한도를 올리면 해결된다고 생각한다. · 한도의 포함 관계

왜 이게 필요했나

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 / hard
ls /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] 처럼 종류가 드러난다.

한도가 실제로 어디서 오는가

Too many open files 를 만나면 ulimit -n 을 올리는 것이 첫 반응인데, 그것이 안 먹는 경우가 절반이다. 한도는 네 곳에서 오고, 어느 것이 이겼는지는 실행 중인 프로세스에서만 확인된다.

cat /proc/<pid>/limits | grep -i 'open files'

이 값이 진실이다. 셸에서 ulimit -n 을 아무리 바꿔도 이미 떠 있는 프로세스 에는 적용되지 않는다.

자리 적용 대상
ulimit -n 그 셸과 그 자손
/etc/security/limits.conf PAM 을 거쳐 로그인한 세션
systemd 유닛의 LimitNOFILE 그 서비스 — limits.conf 를 보지 않는다
컨테이너 런타임 설정 컨테이너 안

systemd 로 뜨는 서비스가 가장 많이 걸린다. limits.conf 를 고치고 재시작해도 안 바뀌는 이유가 이것이다. 유닛에 직접 적어야 한다.

[Service]
LimitNOFILE=65535

시스템 전체 한도도 따로 있다. 프로세스별 한도를 올려도 fs.file-max 에 걸리면 거기서 막힌다.

sysctl fs.file-max fs.nr_open
cat /proc/sys/fs/file-nr        # 현재 사용, 미사용, 최대

무엇이 fd 를 먹고 있는지 센다. 소켓인지 파일인지에 따라 처방이 다르다.

ls -l /proc/<pid>/fd | awk '{print $NF}' | sed 's/:.*//' | sort | uniq -c | sort -rn | head

socket: 이 대부분이면 연결을 안 닫고 있는 것이고, 같은 파일이 수천 개면 파일을 열고 안 닫는 코드다. 한도를 올리는 것은 시간을 버는 것이지 고치는 것이 아니다.

엄청나게 큰 값을 넣지 않는다. LimitNOFILE=infinity 로 두면 어떤 프로그램은 0부터 그 수까지 전부 닫으려 시도하며 몇 분씩 멈춘다. 실제로 필요한 값보다 조금 크게 잡는다.

다음 실습에서 할 것

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