운영 DB가 갑자기 죽었다 흑흑 — Linux OOM Killer와 스왑 이야기

Faithful Dev·2026년 6월 15일

셀파트너랩스

목록 보기
10/11

1. 사건 발단

어느 날 운영 DB(PostgreSQL)가 응답을 멈췄었다. 프로세스를 확인해보니 postgres가 그냥 사라져 있었음.. 범인은 리눅스 커널이었다.

서버 RAM이 거의 다 찼을 때, 커널이 메모리를 가장 많이 쓰는 프로세스를 골라 강제 종료했는데 — 하필 그게 postgres였던;

이것을 해결했던 게 26년 3월 16일. 이 때 dev랑 stg에도 같이 반영을 해뒀으면 좋았을텐데 (이때 stg가 있었나?), 기록도 제대로 안해두고 반영도 안해둬서 26년 6월 15일에 다시 작업하게 된 내용 기록 시작.

2. 범인: OOM Killer

리눅스는 메모리를 오버커밋(overcommit)한다. 프로세스가 “이만큼 쓸게"라고 요청하면 실제 물리 메모리보다 많이 약속해준다. 대부분은 실제로 다 쓰지 않으니 문제가 없지만, 정말로 메모리가 바닥나는 순간이 오면 커널은 선택을 강요받는다.

이때 발동하는 게 OOM(Out Of Memory) Killer다. 커널은 각 프로세스에 “badness score”(oom_score)를 매기고, 점수가 가장 높은 프로세스를 죽여서 메모리를 회수한다. 점수는 대체로 메모리를 많이 쓰는 프로세스일수록 높다.

DB가 탑재된 서버에서 메모리를 가장 많이 쓰는 프로세스는?? 당연히 DB. 그래서 메모리 압박 상황에서 postgres가 1순위 희생양이 된 것.

# dmesg에 이런 흔적이 남는다
Out of memory: Killed process 1234 (postgres) ...

3. 왜 작은 서버에서 더 위험할까

우리 인스턴스는 RAM이 약 1.9GB인 작은 서버였다. 여기에:

  • 평상시에도 메모리 사용률이 높고 (backend・frontend・redis 등이 다 한 서버에 떠 있음)
  • 무거운 쿼리・배치 작업이 순간적으로 메모리를 끌어올리면

여유 버퍼가 없어 곧장 한계에 부딪힌다. 그리고 스왑(swap)이 없으면 커널은 디스크로 잠시 내려놓을 곳도 없어져서, 곧바로 OOM Killer를 호출한다.

4. 그래서 스왑이 왜 필요해?

스왑은 “메모리가 부족할 때 디스크를 임시 메모리로 빌려 쓰는 안전망”이다.

  • 스왑이 없으면: RAM이 차는 순간 → 바로 OOM Killer → postgres 사망 ☠️
  • 스왑이 있으면: RAM이 차면 → 당장 급하지 않은 페이지를 디스크(스왑)로 내려서 버팀 → OOM까지 가는 걸 지연/회피

즉 스왑은 근본 해결책까지는 아닌데,, “갑자기 터져서 프로세스가 죽는 사고”를 막아주는 완충 장치다. (스왑을 쓰기 시작하면 느려지지만, 죽는 것보다는 느린 게 낫다,,)

참고: PostgreSQL은 패키지 기본 설정으로 마스터 프로세스에 oom_score_adj = -900을 줘서 되도록 자기를 죽이지 않도록 커널에 신호를 보낸다. 하지만 이는 우선순위를 낮출 뿐, 정말 메모리가 없으면 결국 죽음 ㅠ. 스왑이라는 물리적 여유가 함께 있어야 한다.

5. 진단 — 환경별로 비교해보니

3월 16일의 사건 이후 운영(prod)에는 조치를 해뒀는데, 개발(dev)・릴리즈(release) 환경에는 빠져 있었다. 세 서버를 같은 기준으로 점검했다.

항목proddevrelease
RAM1.9GB1.9GB1.9GB
스왑 활성✅ 2GB⚠️ 0B (파일만 존재)❌ 없음
fstab 영구등록
postgres oom_score_adj-900-900-900

확인에 쓴 명령들:

free -h                 # Swap 줄이 0B인지 확인
swapon --show           # 활성 스왑 목록
grep swap /etc/fstab    # 재부팅 후에도 유지되는지
  • prod: 2GB 스왑이 활성 + fstab 등록까지 완료. (이게 3월 16일에 적용한 조치)
  • dev: /swapfile이 만들어져 있긴 한데, swapon을 안 해서 실제로는 비활성,, fstab에도 없어 재부팅하면 사라지는 상태. → 반만 적용
  • release: 스왑 파일 아예 없음 → 미적용

oom_score_adj = -900은 세 서버 모두 동일했는데, 이건 앞서 말한 패키지 기본값이라 누가 따로 한 조치는 아니었다. 실제 prod의 방어책은 “스왑” 하나로 좁혀짐.

6. 해결 — 스왑 추가

dev (파일은 있으니 활성화만):

sudo swapon /swapfile
echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab   # 재부팅 후에도 유지

release (처음부터):

sudo fallocate -l 2G /swapfile      # 안 되면: sudo dd if=/dev/zero of=/swapfile bs=1M count=2048
sudo chmod 600 /swapfile            # 보안상 600 필수
sudo mkswap /swapfile
sudo swapon /swapfile
echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab

포인트: swapon만 하면 지금 세션에만 적용된다. fstab에 등록해야 재부팅 후에도 자동으로 올라온다. 이 두 단계를 모두 해야 “영구 적용”됨.

7. 검증

swapon --show && free -h

세 서버 모두 Swap: 2.0Gi가 잡히고 fstab에 한 줄씩(중복 없이) 등록된 걸 확인했다. 이제 dev・release도 prod와 동일한 안전망을 갖추게 된.

8. 스왑은 “응급처치”… 진짜 문제는 “다 한 박스 안에 있다”는 것

스왑은 또 안 죽게 막아주는 응급처치고, 근본 원인은 따로 있다.

우리 서버엔 실제로 뭐가 떠 있나

세 서버(dev・prod・stg) 전부 구성이 똑같다:

  • postgres - 호스트에 네이티브로 떠 있음
  • frontend・backend・nginx・redis - 전부 Docker 컨테이너
  • 이게 전부 RAM 1.9GB짜리 한 박스에 같이 올라가 있다.
항목proddevrelease
RAM / Swap1.9GB / 2GB1.9GB / 2GB1.9GB / 2GB
DBpostgres(호스트)동일동일
앱 스택frontend・backend・nginx・redis (Docker)동일동일
컨테이너 mem_limit0 (무제한)00
redis maxmemory0 / noeviction0 / noeviction0 / noeviction

그러니까 이번 OOM 사고는 “postgres가 메모리를 많이 써서”가 아니라, 6개 가까운 프로세스가 1.9GB를 두고 칸막이 없이 경쟁하다가, 터지는 순간 제일 큰 postgres가 총 맞은 거,,,, 흑흑.

Docker로 쓰는데 괜찮은 거 아냐? → 우리가 정확히 반례임

우리는 앱을 다 Docker로 띄고 있었다. 근데 컨테이너 메모리 limit이 전부 0(무제한)이다.

컨테이너는 호스트 커널・호스트 RAM을 그대로 공유한다. limit이 없으면 컨테이너 안 프로세스도 그냥 호스트 위 프로세스랑 똑같이 1.9GB를 두고 경쟁한다. 즉 limit 없는 Docker는 격리 효과가 사실상 0. redis 컨테이너가 부풀면 → 호스트 OOM Killer 발동 → 컨테이너 밖 postgres가 죽는다. Docker를 쓰는 의미가 사실상 조금 떨어지는,,

Docker의 가치는 “컨테이너로 띄웠다”가 아니라 mem_limit으로 터뜨린 놈만 죽게 격리하는 데 있는데, limit이 0이면 베어메탈이랑 위험도가 다르지 않다.

redis가 특히 위험한 이유

redis는 인메모리라 RAM을 직접 먹는데, 우리 설정은 maxmemory 0(무제한) + noeviction이다.

  • maxmemory 0 → 캐시가 무한정 자랄 수 있음. 한도가 없으니 RAM을 끝까지 먹을 수 있다.
  • noeviction → 꽉 차도 오래된 키를 안 비우고 그냥 쓰기를 거부(에러)한다. 즉 메모리도 안 줄고 장애도 나는 좋지 않은 조합…

DB랑 한 박스에서 이러고 있으면, redis가 슬금슬금 부푸는 것만으로 DB를 밀어낼 수 있다.

postgres는 보호받고 있었는데 왜 죽어?

postgres는 호스트에서 oom_score_adj = -900으로 되도록 자기를 죽이지 말라는 신호를 켜두고 있었다(패키지 기본값). 근데도 죽은 이유는,,

  • -900은 마스터 프로세스에만 적용되고 자식 워커들은 0이다.
  • 무엇보다 박스 전체가 메모리로 터지는 상황에선, 우선순위를 낮춰둬도 결국 가장 덩치 큰 후보가 희생된다. 보호는 “우선순위 조정”일 뿐 “면역”이 아니므로… ㅠ

결국 소프트한 우선순위(-900)만 믿고, 물리적 여유(스왑)도 칸막이(limit)도 없던 게 이번 사고의 본질.

그래서 어떻게 할 건데??

  1. redis maxmemory + eviction 정책 필요함 — 예: maxmemory 200mb, maxmemory-policy allkeys-lru. 캐시가 RAM을 끝까지 먹는 걸 원천 차단.

  2. 컨테이너 mem_limit 설정 — 이미 Docker를 쓰고 있으니 compose에 한 줄씩만 추가해도 된다. 이걸로 “터뜨린 컨테이너만 죽고 postgres는 살게” 할 수 있음.

    services:
      redis:   { mem_limit: 256m, command: redis-server --maxmemory 200mb --maxmemory-policy allkeys-lru }
      backend: { mem_limit: 500m }
      frontend:{ mem_limit: 400m }
      nginx:   { mem_limit: 128m }
    # 단, limit 총합 + postgres 몫이 (RAM + swap) 안에 들어오게 설계할 것
  3. vm.swappiness 낮추기 (60→10) — 평소엔 RAM 우선, 스왑은 진짜 위급할 때만.

  4. PostgreSQL 파라미터 점검 — shared_bufferswork_mem가 1.9GB 대비 과하지 않은지.

그 중에서도 제일 필요한 근본 해결책..

  1. stateful 분리 — 최소한 postgres(가능하면 redis도)를 별도 인스턴스로, 더 나아가 관리형(RDS・ElasticCache)으로. app과 운명공동체에서 빼내는 게 진짜 해결,,,
  2. 사이즈업 — 결국 1.9GB에 6개를 욱여넣는 건 무리다; 스왑은 시간만 벌어줄 뿐이라..

9. 배운 점

  1. 작은 서버 + 스왑 없음 = OOM Killer의 먹잇감. 그리고 죽는 건 보통 제일 덩치 큰 DB.
  2. 죽은 프로세스 ≠ 원인 프로세스. 한 박스에 다 몰면, 누가 터뜨려도 DB가 대신 죽는다.
  3. Docker ≠ 격리. mem_limit 없는 컨테이너는 베어메탈과 위험도가 다르지 않음,, 우리는 Docker를 쓰면서 이점을 잘 못살리고 있긴 하다 ㅠ
  4. 환경 간 설정은 어긋난다. prod만 고치고 dev/stg를 빼먹으면,, 같은 사고가 재발한다 → 기록하고, IaC/체크리스트로 관리(이 글이 그 기록이다 ㅎ).
  5. swapon과 fstab은 별개, 둘 다 해야 영구 적용.
  6. 스왑은 안전망이지 해결책이 아니다. 칸막이(limit)・분리까지 가야 진짜 끝.
profile
Turning Vision into Reality.

0개의 댓글