서버 메모리 사용률이 갑자기 폭주했던 날 — 범인은 의외의 곳에 있었다

문제 상황

어느 날 서버 메모리 사용률을 확인했는데 심상치 않았다.

Mem:  23Gi total / 19Gi used / 345Mi free / 3.1Gi buff/cache / 2.9Gi available
Swap: 4.0Gi total / 3.2Gi used / 806Mi free

전체 23Gi 중 19Gi가 실사용 중이었고, 이미 캐시(buff/cache)까지 다 밀어내고 스왑을 3.2Gi나 쓰는 상태였다. 스왑까지 밀린다는 건 물리 메모리가 완전히 고갈됐다는 뜻이라, 방치하면 서비스 응답 지연이나 최악의 경우 OOM(Out Of Memory)으로 컨테이너가 강제 종료될 수 있는 위험한 신호였다.

첫 번째 용의자: Nextcloud

마침 Nextcloud 앱을 쓰다가 버벅이는 걸 느껴서, “얘가 범인이겠지” 하고 자연스럽게 의심하게 됐다. 근데 막상 컨테이너명을 찾으려니 예상했던 이름이 아니었다.

docker ps --format "table {{.Names}}\t{{.Image}}\t{{.Status}}"

컨테이너명을 정확히 확인한 뒤 개별 조회를 해봤다.

docker stats --no-stream nextcloud-app nextcloud-db
nextcloud-app   2.313GiB / 3GiB   77.10%
nextcloud-db    45.71MiB / 23.42GiB   0.19%

결과는 의외였다. nextcloud-app은 애초에 컨테이너 자체에 3GiB 메모리 제한이 걸려 있었고, 그 안에서 77% 정도를 쓰는 건 오히려 정상 범위였다. 이 컨테이너 하나로는 호스트 전체가 19Gi까지 치솟는 걸 설명할 수 없었다. 범인이 아니었다.

진짜 범인 찾기

개별 컨테이너만 봐서는 전체 그림이 안 보인다는 걸 깨닫고, 인자 없이 전체 컨테이너를 한 번에 조회했다.

docker stats --no-stream

떠 있던 컨테이너 20개 가운데, 압도적으로 튀는 값이 하나 있었다.

netdata   CPU 122.53%   MEM 14.64GiB / 23.42GiB (62.51%)   BLOCK I/O 117GB / 60.9GB   PIDS 221

서버 모니터링용으로 띄워둔 netdata가 전체 메모리의 62.5%를 혼자 먹고 있었다. 정작 의심했던 Nextcloud는 억울한 누명을 쓴 셈이었다.

왜 netdata가 폭주했을까

netdata는 시스템/컨테이너 메트릭을 실시간으로 수집해서 메모리 상에 유지하는 dbengine 방식을 쓰는데, 아래 요인들이 겹치면서 메모리가 서서히 누적된 것으로 보인다.

  • 컨테이너 20개의 cgroup 메트릭(CPU, 메모리, 네트워크, 블록 I/O 등)을 전부 개별 수집 — 모니터링 대상이 많을수록 메모리 사용량이 늘어남
  • **ML 기반 이상탐지(anomaly detection)**가 기본 활성화되어 있어, 메트릭마다 개별 모델을 메모리에 올려두고 계산
  • dbengine 캐시 크기에 상한을 설정하지 않은 채 기본값으로 운영 — 시간이 갈수록 캐시가 계속 불어남 (BLOCK I/O가 117GB로 비정상적으로 높았던 것도 이 캐시 flush 흔적으로 추정)
  • 컨테이너가 6주간 재시작 없이 연속 가동 — 누수성 증가가 서서히 쌓이다 임계점을 넘으며 한꺼번에 표면화된 전형적인 패턴

응급 조치: 일단 재시작

docker restart netdata

즉시 14.64GiB → 698MiB로 회수됐다. 전체 시스템도 아래처럼 정상화됐다.

Mem:  23Gi total / 7.8Gi used / 11Gi free / 4.3Gi buff/cache / 14Gi available
Swap: 4.0Gi total / 0B used / 4.0Gi free

스왑은 swapoff -a && swapon -a로 정리했는데, 중간에 “Device or resource busy” 에러가 떴다. 확인해보니 swapoff가 먼저 성공해서 이미 스왑이 0B로 비워진 뒤였고, 그 상태에서 swapon을 또 실행하려니 “이미 켜져있다”는 충돌 메시지가 뜬 것뿐이었다. 실질적으로는 이미 목표를 달성한 상태였다.

재발 방지: 상한선을 걸어두다

임시방편으로 재시작만 해두면 언젠가 똑같은 일이 반복될 게 뻔했다. 두 가지를 손봤다.

1) docker-compose.yml에 리소스 상한 설정

services:
  netdata:
    mem_limit: 1g          # 하드 리밋 — 도달 시 컨테이너가 재시작되며, 호스트 전체 잠식을 차단
    mem_reservation: 512m  # 소프트 리밋 — 시스템 전체가 빠듯할 때 회수 우선순위 기준

mem_limit은 “이 컨테이너가 쓸 수 있는 최대치”를 못박는 하드 캡이다. 이걸 넘기면 리눅스 커널이 컨테이너 프로세스를 강제 종료(OOM Kill)시키고 컨테이너는 재시작 정책에 따라 다시 뜬다. 즉 다음에 또 메모리 누수가 나도 1GB 선에서 끝나지, 호스트 전체 메모리를 잠식해서 다른 서비스까지 끌고 내려가는 일은 없어진다.

mem_reservation은 강제성이 없는 소프트 리밋이다. 평소엔 자유롭게 쓰다가, 호스트 전체 메모리가 빠듯해지는 순간에만 이 값 이상 쓰는 컨테이너부터 먼저 압박받는 기준선 역할을 한다.

2) netdata.conf에 리소스 사용량 자체를 줄이는 설정 추가

설정 파일을 열어보니 [web] 섹션만 있고 [db], [ml] 섹션 자체가 없어서, 완전 기본값으로 운영되고 있었다는 걸 알게 됐다.

[db]
    mode = dbengine
    dbengine multihost disk space MB = 512   # 디스크 기반 메트릭 캐시 상한을 명시적으로 제한

[ml]

enabled = no # 컨테이너가 많은 환경에서는 메트릭당 이상탐지 모델 유지 부담이 크므로 비활성화

수정 후 재적용:

docker compose up -d

적용 결과

netdata   CPU 105.68%   MEM 455.7MiB / 1GiB (44.50%)   PIDS 25

MEM USAGE/LIMIT가 1GiB로 정상 반영됐고, PIDS도 221개 → 25개로 크게 줄었다(ML 비활성화 등 설정 변경이 반영된 것으로 보인다). CPU는 아직 105%대로 높은 편인데, 재시작 직후 dbengine이 과거 히스토리를 재인덱싱하는 과도기적 현상일 가능성이 있어서, 시간을 두고 다시 확인해볼 계획이다.

정리하며

이번 일로 얻은 교훈은 단순하다.

  • 체감 증상과 실제 원인은 다를 수 있다. Nextcloud가 버벅였다고 해서 Nextcloud가 범인이라는 보장은 없었다.
  • 개별 컨테이너 조회보다 전체 조회가 먼저다. docker stats --no-stream 하나로 20개 컨테이너를 한눈에 비교해야 진짜 이상치가 보인다.
  • 모니터링 도구 자체도 리소스를 관리해야 할 대상이다. 아이러니하게도 서버를 지켜보라고 띄워둔 netdata가 서버를 위협하는 원인이 됐다.
  • 컨테이너에는 항상 메모리 상한을 걸어두자. 상한이 없으면 누수 하나가 시스템 전체를 잠식할 수 있다.