TT Lab
시작하기
배우기 러닝패스 코스

CPU·메모리 누수 판별

RSS·VSZ·PSS 가 각각 재는 것

TT Lab 에서 이어서 보기

한 줄 요약

VSZ 는 약속, RSS 는 실제로 물린 물리 메모리, PSS 는 공유분을 나눠 가진 몫이다. 파드 여러 개의 RSS 를 더하면 실제보다 많이 나온다 — 그때 봐야 할 것이 PSS 다.

왜 이게 필요했나

"메모리 누수 같다" 는 신고의 절반은 누수가 아니다. 프로세스가 커진 이유는 여러 가지다.

넷은 대처가 다르다. 구분하지 않고 "메모리를 늘리자" 로 가면 며칠 뒤 같은 일이 반복된다.

어떻게 동작하나

한 프로세스의 실제 사용량은 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 이 늘었는지가 유일한 증거인 경우가 많다.