트래픽이 어느 정도 오르기 시작한 서버라면 누구나 한 번쯤 이 로그를 만납니다. Too many open files. 대개는 새벽에, 부하가 몰릴 때, 예고 없이 터집니다. 원인은 리눅스의 파일 디스크립터(File Descriptor, FD) 한계입니다. 소켓·파일·파이프가 모두 FD를 소비하는데, 프로세스와 시스템에는 열 수 있는 FD의 상한이 걸려 있기 때문입니다.

이 글에서는 FD가 무엇인지, ulimit과 커널 파라미터가 어떻게 얽히는지, 그리고 systemd·컨테이너 환경에서 한계를 올바르게 올리는 방법을 실전 명령과 함께 정리합니다. 무작정 숫자를 키우기 전에 구조를 이해하는 것이 목표입니다.

파일 디스크립터가 소진되는 경로

리눅스에서 프로세스가 여는 거의 모든 커널 자원은 FD로 표현됩니다. 파일뿐 아니라 TCP 소켓, 유닉스 소켓, epoll 인스턴스, eventfd, 파이프가 전부 FD를 씁니다. 그래서 실제로 “파일”을 많이 안 여는 웹 서버도 동시 연결 수만큼 FD를 소비합니다.

전형적인 고갈 시나리오는 다음과 같습니다.

  • 동접이 늘어 소켓 FD가 프로세스 한계를 초과
  • 커넥션을 close()하지 않는 코드 버그로 FD가 누적(FD leak)
  • TIME_WAIT이나 CLOSE_WAIT 소켓이 쌓여 FD를 붙잡고 있음

먼저 현재 프로세스가 몇 개의 FD를 쓰는지 확인합니다.

# 특정 PID의 열린 FD 개수
ls /proc/<PID>/fd | wc -l

# 프로세스별 FD 사용량 상위 확인 (lsof)
lsof -n 2>/dev/null | awk '{print $2}' | sort | uniq -c | sort -rn | head

# 시스템 전체 열린 FD 수와 상한
cat /proc/sys/fs/file-nr

file-nr의 세 값은 각각 “할당된 FD, 사용 중이지만 미할당(항상 0에 가까움), 시스템 최대치”입니다.

soft limit과 hard limit

ulimit이 다루는 한계는 두 종류입니다. 이 구분을 모르면 “분명 올렸는데 안 올라간다”는 함정에 빠집니다.

  • soft limit: 실제로 적용되는 현재 한계. 프로세스가 스스로 hard limit 이하로 올리고 내릴 수 있습니다.
  • hard limit: soft limit이 올라갈 수 있는 천장. 올리려면 root(또는 CAP_SYS_RESOURCE) 권한이 필요합니다.
# 현재 셸의 soft/hard limit 확인
ulimit -Sn   # soft (open files)
ulimit -Hn   # hard

# soft를 hard 한도까지 올림 (해당 셸 세션 한정)
ulimit -n 65536

주의할 점은 ulimit -n으로 올린 값은 그 셸과 자식 프로세스에만 적용된다는 것입니다. 이미 떠 있는 서비스나 systemd로 관리되는 데몬에는 영향이 없습니다. 그래서 서버 세팅은 셸이 아니라 서비스 관리 계층에서 해야 합니다.

영구 설정 1: limits.conf

로그인 세션 기반 프로세스(SSH 접속 후 실행하는 것들)의 한계는 PAM의 limits.conf로 정합니다.

# /etc/security/limits.conf 또는 /etc/security/limits.d/90-nofile.conf
# <domain>  <type>  <item>   <value>
*        soft   nofile   65536
*        hard   nofile   65536
appuser  soft   nofile   131072
appuser  hard   nofile   131072

이것이 적용되려면 /etc/pam.d/의 관련 파일에 pam_limits.so가 로드되어 있어야 하고, 재로그인해야 반영됩니다. 다만 앞서 말했듯 systemd가 띄우는 서비스에는 이 파일이 적용되지 않습니다. 이 지점이 실무에서 가장 흔한 착오입니다.

영구 설정 2: systemd 서비스 단위

오늘날 대부분의 서버 애플리케이션은 systemd로 관리됩니다. 이 경우 FD 한계는 서비스 유닛의 LimitNOFILE로 지정합니다.

# /etc/systemd/system/myapp.service.d/override.conf
# (systemctl edit myapp 로 생성 권장)
[Service]
LimitNOFILE=131072
systemctl daemon-reload
systemctl restart myapp

# 실제 적용된 값 확인
cat /proc/$(pgrep -f myapp)/limits | grep "open files"

systemd 전역 기본값을 바꾸려면 /etc/systemd/system.confDefaultLimitNOFILE을 설정합니다. 다만 개별 서비스 override가 더 명시적이고 안전합니다.

시스템 전역 상한: fs.file-max와 nr_open

프로세스별 한계를 아무리 올려도 시스템 전역 상한을 넘을 수는 없습니다. 두 커널 파라미터가 천장 역할을 합니다.

  • fs.file-max: 시스템 전체가 동시에 열 수 있는 FD 총합.
  • fs.nr_open: 단일 프로세스가 가질 수 있는 FD의 절대 상한. LimitNOFILE을 이보다 크게 잡을 수 없습니다.
# 현재 값 확인
sysctl fs.file-max fs.nr_open

# 영구 반영: /etc/sysctl.d/99-fd.conf
fs.file-max = 2097152
fs.nr_open  = 1048576
sysctl --system   # sysctl.d 재적용

대부분의 최신 배포판은 file-max가 이미 넉넉하게 잡혀 있어 손댈 일이 적습니다. 진짜 병목은 거의 항상 프로세스별 soft limit입니다.

컨테이너 환경에서의 FD 한계

도커·쿠버네티스에서는 한계가 또 한 겹 얽힙니다. 컨테이너 프로세스의 한계는 호스트 커널 상한 아래에서, 컨테이너 런타임 설정으로 결정됩니다.

# Docker: 실행 시 --ulimit 지정
docker run --ulimit nofile=65536:65536 myapp

# docker-compose
services:
  app:
    ulimits:
      nofile:
        soft: 65536
        hard: 65536

쿠버네티스는 파드 스펙에 직접 nofile 필드가 없어, 컨테이너 이미지의 진입 시점에서 처리하거나(권한 필요) 노드 런타임 기본값(예: containerd의 기본 rlimit)에 의존합니다. 최신 쿠버네티스/컨테이너 런타임은 기본 nofile을 매우 크게(사실상 무제한에 가깝게) 잡는 추세라, 오히려 너무 큰 기본값 때문에 특정 애플리케이션이 부팅 시 FD 테이블을 과도하게 할당해 느려지는 반대 문제도 생깁니다. 이 경우엔 명시적으로 합리적인 값으로 낮춰 주는 것이 낫습니다.

숫자만 올리기 전에: leak을 먼저 의심하라

한계를 올리는 것은 대증요법일 뿐입니다. FD가 정상적으로 늘어난 것인지, 아니면 닫히지 않고 새고 있는지를 먼저 판별해야 합니다. 시간이 지날수록 FD가 단조 증가하고 트래픽과 무관하게 줄지 않는다면 leak입니다.

# 1분 간격으로 FD 추이 관찰 (leak 판별)
watch -n 60 'ls /proc/$(pgrep -f myapp)/fd | wc -l'

# CLOSE_WAIT 소켓이 쌓이면 애플리케이션이 close를 안 하는 신호
ss -tan state close-wait | wc -l

CLOSE_WAIT이 계속 쌓인다면 이는 커널이 아니라 애플리케이션이 소켓을 닫지 않는다는 확실한 증거입니다. 이럴 때 ulimit을 올리면 장애 발생 시점만 뒤로 미룰 뿐, 결국 다시 터집니다.

마무리

Too many open files는 표면적으로는 숫자 하나 올리면 사라지는 문제처럼 보이지만, 그 이면에는 soft/hard limit, PAM, systemd, 커널 sysctl, 컨테이너 런타임이라는 여러 계층이 있습니다. 올바른 해결은 (1) 어느 계층이 실제 한계를 걸고 있는지 /proc/<PID>/limits로 확인하고, (2) 서비스 관리 계층(대개 systemd)에서 한계를 조정하며, (3) 그 전에 FD leak 여부를 반드시 배제하는 순서입니다. 한계를 올리는 것과 누수를 고치는 것은 전혀 다른 일임을 잊지 마세요.