튜닝 한 줄로 max_connections를 1만까지 올린 인스턴스가 어느 날 통째로 죽는다. 원인은 쿼리도 디스크도 아니고, DBA가 평소 신경 쓰지 않는 OS limit, file descriptor다. 연결 풀러 없이 연결 수만 키운 구성은 file descriptor 고갈이라는 단 하나의 벽에 부딪혀 무너진다. 이 글은 그 메커니즘과 대응을 한 번에 정리한다.

PostgreSQL의 프로세스 모델과 file descriptor

PostgreSQL은 연결 하나마다 OS 프로세스 하나를 띄우는 process-per-connection 모델이다. 클라이언트가 접속하면 postmaster가 backend 프로세스를 fork하고, 그 backend가 해당 세션의 모든 작업을 담당한다. 스레드 풀로 연결을 다중화하는 일부 다른 데이터베이스와 결정적으로 다른 지점이다.

여기서 핵심은 backend 프로세스 하나가 여러 개의 file descriptor를 동시에 들고 있다는 점이다. file descriptor(이하 fd)는 프로세스가 열어둔 파일·소켓·파이프를 가리키는 OS 차원의 정수 핸들이다. 하나의 backend는 다음을 모두 fd로 잡는다.

  • 클라이언트와 연결된 소켓
  • 읽고 쓰는 테이블·인덱스 파일 (PostgreSQL 내부 VFD 계층이 관리)
  • WAL 세그먼트
  • 정렬·조인 과정에서 만들어지는 임시 파일

그래서 fd 소비량은 연결 수에 딱 비례하지 않고, 그 연결이 무슨 일을 하느냐에 따라 출렁인다. 놀고 있는 idle backend는 fd를 10개에서 15개 정도만 쓰지만, 여러 테이블과 인덱스를 동시에 건드리는 write backend는 50개에서 200개 이상까지 올라간다. 연결 1만 개가 전부 활성 상태로 무거운 쓰기를 돌리는 순간, fd 소비량은 산술적 예상치를 한참 넘어선다.

고갈 메커니즘과 증상

fd에는 두 겹의 한계가 걸려 있고, 둘 중 무엇에 먼저 닿느냐에 따라 증상이 달라진다.

첫 번째는 프로세스 단위 한계인 RLIMIT_NOFILE이다. ulimit -n으로 조회·설정하며, 프로세스 하나가 열 수 있는 fd 개수를 제한한다. PostgreSQL의 max_files_per_process는 이 OS limit 안에서 backend 하나가 잡을 fd 상한을 한 번 더 좁히는 PostgreSQL 자체 파라미터다.

두 번째는 시스템 전체 한계인 fs.file-max다. 커널이 머신 전체에서 열 수 있는 fd 총량을 제어하며, 이 값에 도달하면 어느 프로세스가 요청하든 모든 open() 호출이 실패한다. PostgreSQL뿐 아니라 그 위에 떠 있는 모든 프로세스가 동시에 영향을 받는다.

연결 풀러 없이 max_connections를 과하게 올린 구성에서는 두 번째 한계에 먼저 닿기 쉽다. 이론상 최악의 소비량은 다음 곱으로 표현된다.

worst-case fd ≈ max_connections × max_files_per_process

max_files_per_process 기본값이 1,000이므로, max_connections를 10,000으로 잡으면 산술적 상한이 천만 단위로 뛴다. 실제로는 모든 backend가 동시에 상한까지 fd를 사용하지는 않으니 평소엔 멀쩡하다. 문제는 배치 잡이 한꺼번에 쓰기를 일으킬 때다. LWLock:BufferContent, LWLock:WALInsert, LWLock:WALWrite 같은 lock 경합으로 락 보유 시간이 길어지면 동시에 오픈된 fd가 쌓이고, 어느 순간 시스템 한계를 갑자기 넘긴다.

이때 PostgreSQL 로그는 한꺼번에 죽지 않고 단계적으로 무너진다.

out of file descriptors: Too many open files in system; release and retry
...
server process (PID XXXXX) was terminated by signal 6
...
failed to send SSL negotiation response: Broken pipe

처음에는 평범한 로그 사이에 “Too many open files”(OS 레벨로는 EMFILE/ENFILE) 메시지가 하나둘 섞이다가, fd를 못 잡은 backend가 signal 6(abort)으로 죽고, postmaster가 crash recovery에 들어가면서 살아 있던 연결까지 SSL 끊김으로 떨어진다. 애플리케이션 쪽에서는 “Unable to create new connection” 같은 커넥션 풀 예외로 나타난다. 즉 fd 고갈은 느린 성능 저하가 아니라 인스턴스 전체가 한 번에 내려앉는 장애로 드러난다.

공식 문서도 같은 맥락을 짚는다. max_files_per_process에 대해 “If you find yourself seeing ‘Too many open files’ failures, try reducing this setting"이라고 안내하고, kernel resource 항목에서는 시스템 전역 한계를 fs.file-max로 조정하라고 명시한다.

대응 — ulimit, max_files_per_process, 연결 풀러

대응은 세 층위로 나뉜다. 급한 불을 끄는 임시 조치와 구조를 바꾸는 근본 조치를 구분하는 것이 중요하다.

대응무엇을 푸나성격부작용·한계
fs.file-max 상향시스템 전역 fd 총량 확대임시 (시간 벌기)근본 원인인 backend 수는 그대로
ulimit -n 상향프로세스당 fd 상한 확대임시·보완메모리 소비 증가, 한계만 미룸
max_files_per_process 조정backend 1개의 fd 상한 제어보완너무 낮추면 VFD 캐시 회전 잦아져 성능 저하, 재시작 필요
PgBouncer 등 연결 풀러backend 절대 수 감소근본풀러 운영·모드 이해 필요

1단계 — 임시로 OS limit 넓히기

PgBouncer 도입 전까지 시간을 벌어야 한다면 시스템 전역 한계부터 올린다.

# 현재 상태 확인
cat /proc/sys/fs/file-max          # 시스템 최대치
cat /proc/sys/fs/file-nr           # 현재 사용량: <할당> <유휴> <최대>

# 시스템 전역 한계 상향 (재부팅 후에도 유지)
sysctl -w fs.file-max=20000000
echo "fs.file-max = 20000000" >> /etc/sysctl.conf
sysctl -p

프로세스당 한계는 postgres 사용자에 대해 /etc/security/limits.conf(또는 systemd 유닛의 LimitNOFILE)에서 soft·hard를 함께 올린다. soft 한계가 실제로 적용되는 값이고 hard 한계까지 사용자가 올릴 수 있으며, hard 한계 자체는 root만 바꿀 수 있다. 다만 이건 어디까지나 시간 벌기다. backend 수가 그대로면 한계를 올린 만큼 더 큰 폭으로 다시 터진다.

2단계 — max_files_per_process로 backend 소비 제한

OS 전역 한계를 못 건드리는 환경이라면, backend 하나가 잡는 fd를 PostgreSQL 쪽에서 좁힐 수 있다. 공식 문서가 안내하는 방식이다.

# postgresql.conf — 재시작 필요
max_files_per_process = 1000   # 기본값. "Too many open files" 보이면 낮춰 본다

단, 이 값은 OS의 프로세스당 fd 한계를 넘을 수 없다. 그리고 너무 낮추면 backend가 파일을 자주 닫았다 여는 VFD 캐시 회전이 잦아져 성능이 떨어진다. 어디까지나 OS 한계 조정과 짝을 이루는 보완책으로 본다.

3단계 — 근본 대응, 연결 풀러

진짜 해법은 애플리케이션과 PostgreSQL 사이에 PgBouncer 같은 연결 풀러를 두는 것이다. 풀러는 수천 개의 애플리케이션 연결을 실제 200~500개 backend로 다중화한다. transaction 모드에서는 트랜잭션이 끝날 때마다 backend가 풀로 반환되므로, 1만 개 클라이언트가 붙어도 PostgreSQL이 실제로 띄우는 backend 수는 풀 크기로 묶인다.

풀러를 앞에 세운 뒤에는 max_connections를 500~1,000 수준으로 되돌린다. 그래야 backend가 무한정 쌓이는 구조 자체가 사라진다. 한계를 넓히는 게 아니라, 한계에 도달할 일을 없애는 방향이다.

flowchart TD
    A["앱 연결 수천 개"] --> B{연결 풀러
있나?} B -- 없음 --> C["backend 수천 개
fork"] C --> D["fd 누적"] D --> E["fs.file-max 초과"] E --> F["EMFILE → 인스턴스
전체 다운"] B -- PgBouncer --> G["backend 200~500개로
다중화"] G --> H["fd 안정 → 정상 운영"]

DBA 체크리스트

장애가 터지기 전에 점검할 항목과, 터졌을 때 들여다볼 지점을 나눠 정리한다.

평상시 점검:

  • SHOW max_connections; 값이 실제 동시 연결 수요 대비 과하게 크지 않은가
  • 애플리케이션과 PostgreSQL 사이에 PgBouncer 등 연결 풀러가 있는가
  • 풀러를 쓴다면 transaction 모드로 backend가 트랜잭션마다 반환되는가
  • fs.file-max와 postgres 사용자 ulimit -n이 워크로드 피크를 감당할 값인가
  • cat /proc/sys/fs/file-nr 첫 번째 값(할당된 fd)이 fs.file-max의 50~60%를 넘지 않는가

모니터링·알람:

  • file-nr 사용량이 fs.file-max의 일정 비율을 넘으면 알람이 울리는가
  • pg_stat_activity의 활성 연결 수가 배치 기준선(예: 1,000)을 넘으면 감지되는가
  • 배치 윈도우에는 watch -n 5 'cat /proc/sys/fs/file-nr'로 실시간 추적하는가

장애 발생 시 진단:

  • 로그에 “Too many open files in system"이 보이는가 — fd 고갈 확정
  • cat /proc/$(pgrep -o postgres)/limits | grep "open files"로 프로세스 한계 확인
  • pg_stat_activity를 state·wait_event별로 집계해 lock 경합 backend가 몰려 있는지 확인
SELECT count(*), state, wait_event_type, wait_event
FROM pg_stat_activity
GROUP BY state, wait_event_type, wait_event
ORDER BY count DESC;

정리하면, fs.file-maxulimit을 올리는 건 응급 처치이지 치료가 아니다. fd 고갈의 뿌리는 backend 프로세스가 너무 많다는 데 있고, 그 수를 구조적으로 묶는 유일한 방법이 연결 풀러다. 연결 풀러가 이번 주 계획에 없다면, 지금 넣어야 한다.

참고