왜 프로세스가 갑자기 죽는가
운영 중인 서버에서 로그도 남기지 못하고 프로세스가 사라진 경험이 있다면 대부분 OOM Killer의 소행이다. 리눅스는 메모리를 요청 시점에 실제로 할당하지 않고, 페이지에 처음 쓰기가 발생할 때 물리 메모리를 배정한다(demand paging). 이 때문에 malloc()은 성공했지만 실제 사용 시점에 물리 메모리가 부족해지는 상황이 발생한다. 커널은 시스템 전체가 멈추는 것을 막기 위해 특정 프로세스를 강제 종료(SIGKILL)하는데, 이것이 OOM Killer다.
OOM Killer의 희생자 선정 기준
커널은 각 프로세스에 oom_score를 매기고 가장 높은 프로세스를 죽인다. 점수는 프로세스가 사용하는 물리 메모리 비율을 기반으로 하며, 관리자가 oom_score_adj(-1000 ~ +1000)로 조정할 수 있다. -1000은 사실상 OOM 대상에서 제외, +1000은 최우선 종료 대상이다. 메모리를 많이 쓰는 프로세스가 우선 대상이 되므로, DB나 JVM처럼 정상적으로 메모리를 크게 쓰는 프로세스가 억울하게 희생되는 경우가 많다.
# 현재 프로세스의 OOM 점수와 조정값 확인
cat /proc/$(pgrep -f postgres | head -1)/oom_score
cat /proc/$(pgrep -f postgres | head -1)/oom_score_adj
# 특정 프로세스를 OOM 대상에서 보호 (root 필요)
echo -1000 > /proc/$(pgrep -f postgres | head -1)/oom_score_adj
# dmesg에서 OOM 발생 이력 확인
dmesg -T | grep -iE 'killed process|out of memory'
overcommit 정책 이해하기
OOM의 근본 원인은 커널이 물리 메모리보다 많은 양의 할당을 허용하는 overcommit에 있다. vm.overcommit_memory 값으로 정책을 제어한다.
| 값 | 정책 | 동작 |
|---|---|---|
| 0 | heuristic(기본) | 커널이 휴리스틱으로 판단, 명백히 과도한 요청만 거부 |
| 1 | always | 항상 허용, 실사용 시 OOM 위험 최대 |
| 2 | never | swap + RAM*overcommit_ratio 초과 할당 거부 |
Redis처럼 fork 기반 스냅샷을 쓰는 서비스는 vm.overcommit_memory=1을 권장하지만, 이는 OOM 위험과의 트레이드오프다. 예측 가능한 거부를 원한다면 값 2로 두고 overcommit_ratio를 조정한다.
cgroup으로 프로세스별 격리하기
시스템 전체 OOM보다 특정 서비스의 메모리를 제한해 그 안에서만 OOM을 발생시키는 것이 안전하다. cgroup v2와 systemd를 쓰면 서비스 단위로 상한을 건다.
# systemd 서비스에 메모리 제한 적용
# /etc/systemd/system/myapp.service.d/limit.conf
[Service]
# 소프트 리밋: 초과 시 회수 압박, 하드 리밋: 초과 시 cgroup 내 OOM
MemoryHigh=1500M
MemoryMax=2G
MemorySwapMax=512M
# 적용
systemctl daemon-reload
systemctl restart myapp
# 현재 사용량과 이벤트 확인
systemctl status myapp | grep Memory
cat /sys/fs/cgroup/system.slice/myapp.service/memory.events
MemoryHigh는 회수 압박을 주는 소프트 상한, MemoryMax는 넘으면 해당 cgroup 내에서 OOM을 트리거하는 하드 상한이다. 이렇게 하면 문제 서비스만 정리되고 다른 서비스는 살아남는다.
애플리케이션 레벨 방어
커널 튜닝만으로는 부족하다. 애플리케이션이 메모리 압박을 미리 감지하고 우아하게 대응하도록 만드는 것이 근본적이다.
import resource, gc
# 프로세스 가상 메모리 상한 설정 (bytes)
soft, hard = resource.getrlimit(resource.RLIMIT_AS)
resource.setrlimit(resource.RLIMIT_AS, (1_500_000_000, hard))
def memory_guard(threshold_mb=1200):
# RSS 확인 후 임계치 초과 시 캐시 정리
with open('/proc/self/status') as f:
for line in f:
if line.startswith('VmRSS'):
rss_mb = int(line.split()[1]) // 1024
if rss_mb > threshold_mb:
gc.collect()
return False # 신규 작업 수신 중단 신호
return True
큐 컨슈머라면 memory_guard()가 False를 반환할 때 새 메시지 수신을 잠시 멈추고 처리 중인 작업만 마무리하는 백프레셔를 걸 수 있다.
실무에서의 주의점
몇 가지 흔한 함정을 정리한다.
oom_score_adj=-1000으로 DB를 보호하면 정작 다른 필수 프로세스가 죽어 시스템이 불안정해질 수 있다. 보호는 신중하게, 소수에만 적용한다.- swap을 완전히 끄면(
swapoff) 메모리 스파이크 시 OOM이 더 빨리 발생한다. 약간의 swap은 완충 역할을 한다. vm.overcommit_memory=2는 안전해 보이지만, 정상 요청까지 거부해 애플리케이션이 예상치 못한ENOMEM으로 죽을 수 있으니 스테이징에서 충분히 검증한다.- OOM 이력은 재부팅 시 dmesg에서 사라진다. journald나 별도 로그로
memory.events의oom_kill카운터를 수집해 두어야 사후 분석이 가능하다.
결국 OOM 대응은 커널 정책, cgroup 격리, 애플리케이션 방어의 3단계를 함께 설계할 때 안정적으로 동작한다. 어느 한 계층에만 의존하면 반드시 예외 상황에서 무너진다.