왜 free()를 해도 RSS가 안 줄어드나
운영 중인 서비스에서 트래픽 스파이크 뒤 메모리 사용량(RSS)이 다시 내려오지 않는 현상을 자주 겪는다. 애플리케이션은 분명 객체를 해제했는데도 top의 RES는 그대로다. 원인은 대부분 애플리케이션 버그가 아니라 메모리 할당자와 커널의 반환 지연 정책이다. 할당자는 free된 메모리를 즉시 커널에 돌려주지 않고 재사용을 위해 붙잡아 둔다. 이 지연은 성능에는 유리하지만, 컨테이너 메모리 리밋이나 OOM 관점에서는 폭탄이 된다.
madvise: 커널에 힌트를 주는 시스템 콜
사용자 공간이 커널에게 "이 페이지 이제 안 쓴다"고 알리는 통로가 madvise(2)다. 핵심 두 플래그의 동작이 완전히 다르다.
- MADV_DONTNEED: 페이지를 즉시 회수한다. RSS가 바로 감소하지만, 다음 접근 시 페이지 폴트가 나고 익명 페이지는 0으로 초기화된다. 반환은 확실하나 재사용 비용이 크다.
- MADV_FREE: "회수해도 좋다"고 표시만 한다. 커널은 메모리 압박이 있을 때만 실제로 회수하고, 그 전에 앱이 다시 쓰면 폴트 없이 그대로 사용한다. 회수 전까지 RSS는 줄지 않는다.
MADV_FREE vs MADV_DONTNEED
| 항목 | MADV_FREE | MADV_DONTNEED |
|---|---|---|
| RSS 즉시 감소 | 아니오(지연 회수) | 예 |
| 재사용 비용 | 낮음 | 페이지 폴트 발생 |
| 모니터링 착시 | 큼(회수 전까지 높게 보임) | 없음 |
| 적합한 상황 | 재할당 잦은 워크로드 | 확실한 반환 필요, 컨테이너 리밋 |
glibc malloc 튜닝
glibc 기본 할당자는 작은 청크를 arena에 쌓아두고, top 청크가 M_TRIM_THRESHOLD(기본 128KB)를 넘어야만 trim으로 커널에 반환한다. 단편화가 있으면 이 조건이 거의 충족되지 않아 RSS가 고착된다. 환경변수로 임계값을 낮추거나 주기적으로 강제 반환할 수 있다.
# trim 임계값을 낮추고, mmap 사용을 유도해 반환을 쉽게 만든다
export MALLOC_TRIM_THRESHOLD_=131072
export MALLOC_MMAP_THRESHOLD_=131072
# arena 수를 제한해 단편화와 과다 예약을 줄인다
export MALLOC_ARENA_MAX=2
애플리케이션에서 유휴 시점에 명시적으로 반환하는 것도 효과적이다.
import ctypes
# 유휴 상태나 요청 처리 종료 후 호출
libc = ctypes.CDLL("libc.so.6")
def release_memory():
# top 청크의 여유 메모리를 커널에 반환
libc.malloc_trim(0)
jemalloc / tcmalloc의 decay 튜닝
jemalloc은 dirty_decay_ms, muzzy_decay_ms로 반환 속도를 조절한다. 값이 크면(기본 10초) 반환이 느려 RSS가 오래 유지된다. 즉시성이 중요하면 0으로 낮춘다. 또한 백그라운드 스레드가 있어야 지연 회수가 실제로 실행된다.
# jemalloc: 반환을 공격적으로, 백그라운드 회수 활성화
export MALLOC_CONF="background_thread:true,dirty_decay_ms:0,muzzy_decay_ms:0"
# tcmalloc: 반환 속도 계수(기본 1.0), 값이 크면 더 빨리 반환
export TCMALLOC_RELEASE_RATE=10
Transparent Huge Pages 함정
THP가 always면 2MB 단위로 페이지가 묶여, 일부만 free해도 huge page 전체가 회수되지 않아 RSS가 부풀고 반환이 지연된다. 지연 문제가 의심되면 madvise 모드로 두고, RSS 변화를 관찰한다.
cat /sys/kernel/mm/transparent_hugepage/enabled
# [always] madvise never → madvise로 전환 권장
echo madvise | sudo tee /sys/kernel/mm/transparent_hugepage/enabled
주의점
반환을 공격적으로 튜닝하면 RSS는 줄지만 재할당·페이지 폴트 비용으로 지연시간(latency)이 늘 수 있다. 처리량 위주 배치 작업과 지연 민감한 온라인 서비스는 목표가 다르다. 컨테이너 환경에서는 RSS 착시로 인한 불필요한 OOM Kill을 막는 것이 우선이므로 반환을 앞당기는 편이 안전하고, 그 외에는 실제 벤치마크로 latency와 메모리의 트레이드오프를 확인한 뒤 값을 정해야 한다. 무엇보다 "RSS가 안 준다 = 누수"라는 단정은 피하고, 먼저 할당자 정책부터 점검하는 것이 순서다.