Diagnosing CPU and Memory Leaks
What RSS, VSZ and PSS Each Measure
한국어 원문으로 표시합니다.
한 줄 요약
VSZ 는 약속, RSS 는 실제로 물린 물리 메모리, PSS 는 공유분을 나눠 가진 몫이다. 파드 여러 개의 RSS 를 더하면 실제보다 많이 나온다 — 그때 봐야 할 것이 PSS 다.
왜 이게 필요했나
"메모리 누수 같다" 는 신고의 절반은 누수가 아니다. 프로세스가 커진 이유는 여러 가지다.
- 진짜 누수: 해제하지 않은 할당이 계속 쌓인다
- 캐시 증가: 애플리케이션이 의도적으로 캐시를 채운다(도달하면 멈춘다)
- 힙 단편화: 해제는 했는데 OS 에 돌려주지 못했다
- 할당자 특성: glibc malloc 은 작은 블록을 OS 에 잘 반환하지 않는다
넷은 대처가 다르다. 구분하지 않고 "메모리를 늘리자" 로 가면 며칠 뒤 같은 일이 반복된다.
어떻게 동작하나
한 프로세스의 실제 사용량은 smaps_rollup 한 줄이 가장 정확하다.
grep -E '^(Rss|Pss|Private_Dirty|Swap):' /proc/<PID>/smaps_rollup
| 항목 | 뜻 |
|---|---|
Rss |
물리 메모리에 올라온 전체(공유 라이브러리 포함) |
Pss |
공유분을 사용자 수로 나눈 몫 — 합계를 낼 때 쓸 유일한 값 |
Private_Dirty |
이 프로세스만 쓰는, 파일로 되돌릴 수 없는 부분 — 누수를 볼 때 이것 |
누수 판별은 한 시점의 값이 아니라 기울기로 한다. 부하를 일정하게 준 상태에서 Private_Dirty 를 몇 분 간격으로 찍어 단조 증가하면 누수 쪽이고, 어느 선에서 평평해지면 캐시 쪽이다.
흔한 착각
VSZ 를 보고 놀라는 것. 64비트 JVM 이나 Go 런타임은 VSZ 로 수십 GB 를 잡는다. 주소 공간을 예약했을 뿐 물리 메모리가 아니다. VSZ 는 거의 항상 무시해도 된다.
RSS 를 더하는 것. 컨테이너 10개가 같은 libc 를 쓰면 그 페이지는 물리적으로 하나인데 RSS 에는 10번 잡힌다. 합계는 PSS 로 낸다.
free -h 의 free 가 작다고 걱정하는 것. 리눅스는 남는 메모리를 전부 페이지 캐시로 쓴다. 봐야 할 값은 available 이다.
컨테이너의 메모리 한도는 어디서 보나
호스트의 free 나 top 은 컨테이너에 할당된 몫을 모릅니다. cgroup 을 직접 봅니다.
cat /sys/fs/cgroup/memory.max # 한도 (max 면 제한 없음)
cat /sys/fs/cgroup/memory.current # 지금 쓰는 양
cat /sys/fs/cgroup/memory.stat # 항목별 내역
memory.current 에는 페이지 캐시가 포함됩니다. 파일을 많이 읽는 프로세스는
캐시 때문에 한도에 가까워 보이지만, 압박이 오면 커널이 캐시를 먼저 버리므로
OOM 이 나지 않습니다. 그래서 "메모리를 90% 쓰고 있다" 가 곧 위험은 아닙니다.
진짜로 위험한지는 memory.stat 의 두 줄로 봅니다.
anon 1234567890 ← 익명 메모리. 이건 버릴 수 없다
file 987654321 ← 페이지 캐시. 압박이 오면 버려진다
anon 이 한도에 가까우면 곧 OOM 입니다.
OOM 이 났는지 확인하는 법
파드가 갑자기 재시작했는데 로그에 아무것도 없으면 대개 OOM 입니다. 애플리케이션이 자기가 죽는다는 것을 알 방법이 없어 마지막 말을 남기지 못합니다.
kubectl describe pod <파드> | grep -A3 "Last State"
Last State: Terminated
Reason: OOMKilled
Exit Code: 137
# cgroup 쪽 기록
cat /sys/fs/cgroup/memory.events
oom 3
oom_kill 1
oom 은 한도에 닿아 회수를 시도한 횟수이고 oom_kill 이 실제로 죽인 횟수입니다.
oom 만 늘고 oom_kill 이 0 이면 아직 죽지는 않았지만 계속 압박받고 있다 는
뜻이라, 그때부터 지연이 나빠집니다. 이 신호를 놓치면 "가끔 느리다" 로만 보입니다.
언어별로 무엇을 조절하나
컨테이너 한도를 줄여도 런타임이 그것을 모르면 소용이 없습니다.
| 런타임 | 조절할 것 | 안 하면 |
|---|---|---|
| JVM | -XX:MaxRAMPercentage=75 |
옛 JVM 은 호스트 메모리 기준으로 힙을 잡는다 |
| Node.js | --max-old-space-size=<MB> |
기본 힙이 컨테이너 한도보다 클 수 있다 |
| Go | GOMEMLIMIT |
GC 가 늦게 돌아 한도를 넘는다 |
| Python | 별도 설정 없음 | 워커 수(gunicorn -w)로 조절한다 |
GOMEMLIMIT 은 Go 1.19 부터 있고, 한도의 90% 정도로 두면 GC 가 그 선에서 더
자주 돕니다. 이것 없이 Go 서비스를 좁은 한도에 넣으면 GC 가 여유롭게 굴다가
OOM 을 맞습니다.
실무에서 진짜 중요한 것
컨테이너는 호스트가 아니라 cgroup 한도로 죽는다.
cat /sys/fs/cgroup/memory.max # 한도
cat /sys/fs/cgroup/memory.current # 지금
cat /sys/fs/cgroup/memory.events # oom_kill 횟수
memory.current 에는 페이지 캐시도 들어간다. 그래서 파일을 많이 읽는 프로세스는 실제 힙이 작아도 한도에 닿는다. memory.stat 의 anon(익명 메모리)과 file(캐시)을 나눠 봐야 진짜 원인이 보인다. OOM 킬은 커널이 하는 일이라 애플리케이션 로그에 아무것도 안 남는다 — memory.events 의 oom_kill 이 늘었는지가 유일한 증거인 경우가 많다.