서버가 느린 이유 #2 메모리를 늘려도 빨라지지 않을 때: 페이지 캐시, 스왑, 워킹셋

6 분 소요

느려진 서버에 가장 먼저 나오는 처방이 “메모리를 늘리자"입니다. 그런데 32GB를 64GB로 올렸는데 응답 시간이 그대로인 경우가 드물지 않습니다. 돈은 나갔는데 왜 빨라지지 않았을까요? 1편이 CPU 사용률의 착시를 다뤘다면, 이번에는 메모리 용량의 착시입니다.

결론부터 말하면, 메모리 증설은 메모리가 병목일 때만 효과가 있고, 메모리가 병목인 상황은 생각보다 좁습니다. 증설 전에 그 좁은 조건에 해당하는지 판정하는 방법을 익히는 것이 이 글의 목표입니다.

남는 메모리는 놀고 있지 않습니다: 페이지 캐시 #

먼저 리눅스의 메모리 회계부터 짚어야 합니다. free -h를 봅니다.

free -h
$ free -h
               total        used        free      shared  buff/cache   available
Mem:            31Gi        12Gi       1.2Gi       0.5Gi        18Gi        18Gi
Swap:          8.0Gi       2.1Gi       5.9Gi

free가 1.2GB뿐이라 부족해 보이지만, buff/cache 18GB의 대부분은 페이지 캐시입니다. 커널이 한 번 읽은 파일을 메모리에 올려 두고 다음 읽기를 디스크 없이 처리하는 영역이고, 프로세스가 메모리를 요구하면 즉시 회수해 내주는 영역이기도 합니다. 그래서 실제 여유는 available(18GB)로 읽습니다. 이 회계는 하드웨어 중급 #3에서 자세히 다뤘습니다.

이 구조가 증설 효과의 첫 갈림길입니다. 메모리를 늘리면 페이지 캐시가 커지고, 디스크 읽기가 캐시 히트로 바뀌면서 빨라집니다. 뒤집으면, 자주 읽는 데이터가 이미 캐시에 다 들어가 있다면 캐시를 더 키워도 히트율은 오르지 않습니다. 히트율 98%인 시스템에 메모리를 두 배로 줘도 얻는 것은 나머지 2%의 일부뿐입니다.

워킹셋: 증설이 효과 있는 유일한 조건 #

판정 기준이 되는 개념이 워킹셋(working set)입니다. 시스템이 실제로 반복해서 만지는 데이터의 크기입니다. 전체 데이터가 500GB라도 매일 만지는 부분이 10GB라면 워킹셋은 10GB에 가깝습니다.

  • 워킹셋 > 메모리: 캐시에서 밀려난 데이터를 디스크에서 다시 읽는 일이 반복됩니다. 이때가 증설이 극적으로 듣는 유일한 구간입니다.
  • 워킹셋 < 메모리: 이미 캐시가 워킹셋을 다 품고 있습니다. 증설해도 체감이 없습니다.

워킹셋이 메모리를 넘는지는 직접 잴 수 있습니다. 신호는 지속적인 디스크 읽기입니다. 쓰기는 어차피 디스크로 내려가야 하지만, 정상 상태에서 읽기는 캐시가 흡수해야 하기 때문입니다. vmstatbi(블록 읽기)가 부하 내내 높게 유지되거나, iostat -x에서 읽기 IOPS가 꾸준하다면 캐시가 워킹셋을 못 담고 있다는 뜻입니다. 반대로 디스크 읽기가 거의 없는 시스템이라면, 느림의 원인은 메모리가 아닙니다. 1편의 진단 순서로 돌아가 다른 자원을 봐야 합니다.

스왑: 있다는 사실보다 움직임이 중요합니다 #

free에서 스왑 사용량 2.1GB를 보고 “스왑을 쓰고 있으니 메모리 부족"이라고 판정하기 쉽지만, 이것도 착시입니다. 커널은 한가할 때 오래 안 쓴 페이지를 미리 스왑으로 내려 두기도 합니다. 문제는 스왑에 있는 것이 아니라 스왑을 드나드는 것입니다.

vmstat 1: si/so 컬럼
$ vmstat 1
procs -----------memory---------- ---swap-- -----io----
 r  b   swpd   free   buff  cache   si   so    bi    bo
 2  1 2201M  1.1G   218M   17.9G  840  1120  2400  3100

si(swap in)와 so(swap out)가 부하 중에 지속적으로 0이 아니라면, 활성 페이지가 스왑과 메모리를 오가는 스래싱입니다. 이때는 메모리 증설이 정확한 처방입니다. 반대로 swpd가 커도 si/so가 0에 붙어 있다면 오래된 페이지가 잠들어 있을 뿐이라, 증설해도 달라지는 것이 없습니다.

vm.swappiness를 0으로 내려 스왑을 봉인하는 튜닝이 유행하지만, 순서가 중요합니다. 스래싱의 원인이 워킹셋 초과라면 swappiness를 낮춰도 회수 압박이 페이지 캐시 쪽으로 옮겨 갈 뿐이고, 더 나쁘면 OOM 킬러를 부릅니다. 스왑 설정은 워킹셋 판정이 끝난 뒤의 미세 조정입니다.

증설했는데 그대로인 흔한 이유 둘 #

워킹셋 판정과 별개로, 증설분이 아예 전달되지 않는 경우가 둘 있습니다.

컨테이너 메모리 한도: 호스트 메모리를 늘려도 컨테이너에 memory limit이 걸려 있으면 컨테이너 안 프로세스의 상한은 그대로입니다. 컨테이너 안의 free는 호스트 값을 보여 주기 때문에 착각하기 쉽습니다. cgroup 기준의 실제 한도와 사용량은 컨테이너 런타임 쪽(docker stats, Kubernetes라면 kubectl top pod)이나 cgroup 파일에서 읽어야 합니다. 이 구분은 하드웨어 중급 #3에서 다뤘습니다.

애플리케이션이 스스로 정한 상한: JVM의 -Xmx, 데이터베이스의 버퍼 풀 크기(shared_buffers, innodb_buffer_pool_size), 언어 런타임의 힙 설정이 대표적입니다. 이 값들이 고정돼 있으면 서버에 메모리를 얼마를 더 꽂아도 프로세스는 옛 상한 안에 머뭅니다. 증설 후에는 이 설정들을 새 용량에 맞춰 함께 올려야 증설분이 실제로 쓰입니다.

메모리가 남는데 OOM이 나는 경우 #

증설 논의에서 자주 섞이는 별개 증상도 하나 정리해 둡니다. available이 넉넉한데 OOM 킬러가 프로세스를 죽였다면, 대부분 시스템 전체가 아니라 cgroup(컨테이너) 한도 초과입니다. 커널 로그(dmesg)에서 OOM 메시지에 찍힌 cgroup 경로를 확인하면 시스템 OOM인지 컨테이너 OOM인지 바로 구분됩니다. 컨테이너 OOM이라면 호스트 증설이 아니라 해당 컨테이너의 limit 조정이 처방입니다.

진단 순서 정리 #

증상이 “메모리를 늘렸는데(늘리려는데) 효과가 없다(없을 것 같다)“일 때, 순서는 이렇습니다.

  1. free -havailable: 여유를 먼저 정확히 읽습니다. free가 아니라 available입니다.
  2. vmstatsi/so: 부하 중 스왑 드나듦이 지속되면 워킹셋 초과입니다. 증설이 듣는 구간입니다.
  3. vmstatbi / iostat 읽기 IOPS: 부하 내내 디스크 읽기가 꾸준하면 캐시가 워킹셋을 못 담고 있다는 뜻입니다. 역시 증설 후보입니다.
  4. 둘 다 아니면 메모리는 무죄: 1편의 진단 순서로 돌아가 CPU 대기, 스토리지, 락을 봅니다.
  5. 증설했다면 전달 경로 확인: 컨테이너 limit과 애플리케이션 힙·버퍼 풀 설정을 새 용량에 맞춰 올렸는지 확인합니다.

정리 #

  • 남는 메모리는 페이지 캐시로 이미 일하고 있습니다. 여유는 free가 아니라 available로 읽습니다.
  • 증설이 듣는 조건은 사실상 하나, 워킹셋이 메모리보다 클 때입니다. 신호는 지속적인 스왑 드나듦(si/so)과 꾸준한 디스크 읽기입니다.
  • 스왑은 사용량이 아니라 움직임으로 판정합니다. swappiness 조정은 워킹셋 판정 뒤의 미세 조정입니다.
  • 컨테이너 limit과 JVM 힙, DB 버퍼 풀처럼 상한이 별도로 정해진 곳에는 증설분이 자동으로 전달되지 않습니다.
  • 메모리가 남는데 나는 OOM은 대부분 컨테이너 한도 초과입니다. dmesg의 cgroup 경로로 구분합니다.

다음 편은 스토리지입니다. SSD를 썼는데도 느린 이유를 쓰기 증폭, fsync, 큐 깊이로 추적합니다.

X