OSTEP 14장 Interlude: Memory API와 15장 Mechanism: Address Translation을 읽고 나서 이 내용을 실제 서비스 장애와 연결해 보고 싶어졌습니다. 출발점은 다음 질문이었습니다.

Java 힙에는 여유가 있고 GC도 동작하는데, 왜 컨테이너는 메모리 한도에 걸려 종료될까요?

15장에서는 프로그램이 가상 주소를 사용하고 운영체제와 하드웨어가 그 주소를 실제 메모리 위치에 매핑한다는 내용을 다룹니다. 프로그램이 보는 주소 공간과 물리 메모리의 배치는 같지 않습니다. 평소에는 이 차이를 신경 쓰지 않고 코드를 작성해도 되지만 컨테이너에서 서비스를 운영하다 보면 이 차이 때문에 장애가 나기도 합니다.

14장에는 이 질문에 바로 쓸 수 있는 설명이 있습니다. malloc과 free는 시스템 콜이 아닌 C 라이브러리 함수입니다. 운영체제에 메모리를 요청하는 brk나 mmap 같은 시스템 콜은 할당자가 필요할 때 따로 호출합니다. free를 호출하면 공간은 할당자에게 돌아갑니다. 그 공간이 곧바로 운영체제에 반환된다는 보장은 없습니다.

이 글에서는 Picnic이 공개한 장애 사례를 따라가면서 메모리 지표의 측정 범위, 해제와 반환의 차이, GC와 jemalloc이 각각 정리할 수 있는 범위, cgroup 메모리 한도를 차례로 살펴봅니다. 15장의 base and bounds 방식은 지금 서버에서 그대로 쓰이지 않습니다. 이 글에서 가져오는 것은 15장에서 익힌 “프로그램이 보는 메모리와 시스템이 보는 메모리를 나눠 보는 방법”입니다. 기업 사례와 공식 문서를 바탕으로 정리했으며 직접 실행한 실험이나 측정 결과는 포함하지 않았습니다.

1. Picnic에서는 어떤 일이 있었을까요?

네덜란드의 온라인 식료품 배송 회사 Picnic은 2021년에 트럭 배송 일정 같은 계획 알고리즘을 실행하는 Fulfillment Service의 장애를 공개했습니다. Kubernetes에서 메모리가 계속 늘다가 컨테이너 한도인 6GB에 도달하면 종료되고 재시작되는 문제였습니다. 참고: Quest to the OS: Java Native Memory.

이때 Java 힙 사용량은 약 2.5GB였고, 힙 상한(-Xmx)은 4GB였습니다. 팀은 NMT(Native Memory Tracking)를 켜서 off-heap 사용량이 500MB 미만인 것을 확인했고, DirectByteBuffer 크기를 더해 direct buffer가 약 600MB라는 것도 확인했습니다. 셋을 더해도 4GB가 안 됐습니다. 힙에 객체가 쌓였다는 가설로는 6GB를 설명할 수 없었습니다.

팀은 네이티브 할당이 어디서 일어나는지 보려고 LD_PRELOAD로 jemalloc을 붙이고 heap profiling을 켰습니다. malloc을 직접 호출하는 곳은 OpenJDK의 Unsafe_allocateMemory0(direct buffer가 쓰는 메모리)과 AllocateHeap 두 곳뿐이었고, 누수로 보이는 곳은 없었습니다. 그런데 jemalloc을 붙이자 메모리 증가가 멈추고 약 4GB에서 안정됐습니다.

팀은 이 결과만 보고 원인을 정하지 않았습니다. 로컬 Docker 환경에서 planning 작업을 반복 실행하고, gdb로 glibc의 mallinfo를 호출해 할당자 안에 남은 빈 공간의 비율을 계산했습니다. 이 비율은 planning 작업 한 번 뒤 약 80%, 다음 작업 뒤 약 84%로 늘었습니다. 작업이 끝날 때마다 쓰지 않는 공간이 할당자 안에 쌓였고, 팀은 이 단편화를 원인으로 결론 내렸습니다.

jemalloc을 계속 쓰기로 한 뒤 이 서비스는 메모리 부족으로 종료되지 않았습니다. 다만 이 서비스는 planning 작업 때 부하가 몰리고 그 밖에는 거의 쉬는 패턴이었습니다. Picnic도 부하가 고르게 분포한 서비스에서는 결과가 다를 수 있다며, 바꾸기 전에 벤치마크부터 하라고 권했습니다.

힙은 4GB 아래인데 컨테이너는 6GB에 닿았습니다

Picnic 글에 나온 수치를 0~6GB 축 하나에 놓았습니다. 단계를 따라가며 어느 막대가 컨테이너 한도에 닿는지 보세요.

수치는 Picnic 글에 나온 값만 옮겼습니다. JVM 지표 합계 막대는 힙 약 2.5GB, off-heap 500MB 미만, direct buffer 약 600MB를 더한 최댓값(약 3.6GB)으로 그렸습니다. 메모리가 시간에 따라 늘어난 곡선은 그리지 않았습니다.

이 사례에서는 조사 범위를 넓혀 간 순서가 눈에 띕니다.

  1. Java 힙에 쌓인 객체
  2. NMT로 본 off-heap 영역과 direct buffer
  3. jemalloc 프로파일로 본 네이티브 할당
  4. glibc 할당자가 들고 있는 빈 공간

단계마다 보는 숫자가 왜 다른지부터 정리해 보겠습니다.

2. “메모리 사용량”은 하나의 숫자일까요?

조사 방향을 정하려면 같은 이름으로 부르는 여러 숫자를 먼저 구분해야 했습니다.

지표보여 주는 것이 숫자만으로 알 수 없는 것
Java 힙 사용량JVM이 객체를 저장하는 영역의 사용량네이티브 라이브러리와 프로세스 전체가 쓰는 메모리
NMTHotSpot 내부에서 일어난 메모리 할당서드파티 네이티브 코드와 JDK 클래스 라이브러리의 할당
VSS (VmSize)프로세스의 가상 메모리 크기그만큼 RAM을 실제로 쓰고 있는지
RSS (VmRSS)프로세스에 매핑되어 지금 RAM에 올라와 있는 메모리그중 애플리케이션이 실제로 쓰는 양
cgroup 메모리 사용량해당 cgroup에 포함되는 메모리어느 런타임의 힙 때문에 늘었는지

RSS에는 여러 프로세스가 공유하는 페이지가 들어갈 수 있습니다. 그래서 여러 프로세스의 RSS를 단순히 더하면 같은 페이지를 여러 번 세게 됩니다. 공유 페이지를 나눠 센 값이 필요하면 /proc/<pid>/smaps의 PSS를 봅니다. VmRSS는 커널의 확장성 최적화 때문에 근삿값이라, 정확한 값이 필요하면 /proc/<pid>/smaps_rollup을 씁니다. 참고: proc_pid_status(5).

NMT(Native Memory Tracking)는 이름만 보면 모든 네이티브 메모리를 추적할 것 같습니다. 실제로 추적하는 범위는 HotSpot 내부의 사용량이고, 서드파티 네이티브 코드와 JDK 클래스 라이브러리의 할당은 포함되지 않습니다. 참고: Oracle NMT. NMT에서 증가가 보이지 않아도 네이티브 메모리 문제일 수 있습니다.

대시보드끼리 숫자가 맞지 않을 때는 각 대시보드가 어디까지 측정하는지부터 확인해야 합니다. 같은 이유로 RSS에서 Java 힙을 뺀 값을 곧바로 누수량이나 단편화량으로 볼 수도 없습니다.

3. 해제했는데 왜 메모리가 남아 있을까요?

처음에는 GC가 객체를 회수하면 그만큼 프로세스의 메모리도 줄어들 것이라고 생각했습니다. 하지만 Java 프로세스에서 메모리가 해제되는 경로는 두 가지로 나눠 봐야 합니다.

  1. 힙 안의 일반 객체. JVM은 힙 영역을 mmap으로 직접 확보하고 그 안에 객체를 배치합니다. GC가 객체를 회수해도 free는 호출되지 않고, 그 공간은 JVM 안에서 다시 쓰입니다. 확보해 둔 힙을 운영체제에 돌려줄지는 GC 정책과 -Xms, -Xmx 같은 설정이 정합니다.
  2. 네이티브 메모리를 쥔 객체. direct buffer는 off-heap 메모리를 malloc으로 받아 씁니다. Picnic의 프로파일에 나온 Unsafe_allocateMemory0이 이 경로입니다. GC가 DirectByteBuffer 객체를 회수하면 Cleaner에 등록해 둔 해제 작업이 실행되고, 이때 free가 호출됩니다.

Picnic 사례는 두 번째 경로입니다. free를 호출하면 블록은 할당자에게 돌아갑니다. 할당자는 다음 요청에 쓰려고 그 블록을 계속 들고 있을 수 있습니다. 참고: jemalloc(3).

할당자가 들고 있는 공간을 운영체제에 돌려주려면 페이지 단위의 조건을 맞춰야 합니다. 운영체제는 메모리를 페이지 단위로 관리하고, 페이지 안에 아직 쓰는 데이터가 조금이라도 있으면 그 페이지는 반환할 수 없습니다. 참고: Linux page tables. 게다가 할당자는 할당 속도를 높이려고 객체보다 큰 공간을 미리 받아 두고 크기별로 나눠 관리하거나 캐시에 넣어 둡니다. 할당이 빨라지는 대신 쓰지 않는 공간이 늘어날 수 있습니다.

예를 하나 들어 보겠습니다. 실제 서비스에서 측정한 값은 아니고 설명을 위해 단순하게 만든 모형입니다. 4KB 페이지 네 개에 1KB 블록 16개가 들어 있습니다. 같은 8KB를 흩어서 해제할 때와 몰아서 해제할 때 RSS가 어떻게 달라지는지 직접 해 보세요.

해제한 만큼 RSS가 줄어들까요?

4KB 페이지 네 개에 1KB 블록 16개가 모두 사용 중입니다. 블록을 눌러 해제하거나 8KB 해제 버튼을 고른 뒤, 2KB 요청과 malloc_trim 호출을 눌러 보세요.

사용 중 16KB 해제했지만 할당자가 들고 있음 0KB RSS 16KB

페이지 4KB, 블록 1KB로 단순화한 모형입니다. 페이지 번호가 클수록 높은 주소에 있다고 보고, 가장 높은 페이지를 heap 맨 위로 둡니다. glibc처럼 heap 맨 위의 빈 페이지만 자동으로 반환하고, 요청은 같은 페이지 안의 연속된 빈 블록에 들어간다고 가정했습니다. 실제 할당자는 arena와 캐시를 두는 등 훨씬 복잡합니다. 실제 측정값이 아닙니다.

흩어서 해제하면 블록 여덟 개를 해제해도 페이지마다 사용 중인 블록이 남습니다. 운영체제에 돌려주려면 페이지 하나가 통째로 비어 있어야 하므로 반환할 페이지가 없습니다. 이 상태에서 2KB를 요청하면 빈 블록이 모두 1KB 조각이라 들어갈 자리가 없고 할당자는 운영체제에서 새 페이지를 받아 옵니다. 빈 바이트를 모두 더하면 크더라도, 할당자는 그 공간을 다시 쓰지도 운영체제에 돌려주지도 못할 수 있습니다.

몰아서 해제하면 page 1과 page 2가 통째로 빕니다. 그래도 RSS는 16KB 그대로입니다. 이 실험의 할당자는 glibc처럼 heap 맨 위(top chunk)의 빈 공간만 자동으로 반환하는데, 맨 위인 page 4가 아직 사용 중이기 때문입니다. malloc_trim 호출을 눌러야 중간의 빈 페이지까지 반환됩니다. 반대로 page 4의 블록을 모두 해제하면 맨 위 페이지라서 바로 반환됩니다.

Picnic도 glibc에서 각 heap의 맨 위 chunk만 운영체제에 돌아갈 수 있어서, 중간의 free chunk가 사용 중인 메모리로 남는 것을 원인으로 들었습니다. 다만 중간 공간을 절대 반환하지 못하는 것은 아닙니다. malloc_trim을 호출하면 조건이 맞을 때 쓰지 않는 페이지를 반환하고, glibc 2.8부터는 모든 arena와 통째로 빈 페이지가 있는 chunk까지 처리합니다. 이 함수는 누군가 호출해야 동작합니다. 참고: malloc_trim(3). mmap threshold(기본 128KB)보다 큰 블록은 처음부터 따로 mmap으로 받아서 free할 때 바로 반환됩니다. 참고: mallopt(3).

4. 15장의 단편화와 같은 문제일까요?

15장에서는 프로세스마다 메모리 영역을 통째로 배정하는 방식을 다룹니다. 이 방식에서는 힙과 스택 사이의 공간이 쓰이지 않아도 이미 그 프로세스에 배정되어 있어서 다른 프로세스가 쓸 수 없습니다. 이렇게 배정된 영역 안에서 버려지는 공간을 내부 단편화(internal fragmentation)라고 부릅니다.

3장의 실험에서 본 것은 다른 종류입니다. 빈 블록의 합계는 충분한데 흩어져 있어서 연속된 공간을 만들 수 없는 상태를 외부 단편화(external fragmentation)라고 부르고, OSTEP에서는 16장과 17장에서 다룹니다. Picnic 사례는 사용자 공간의 할당자 안에서 생긴 단편화 때문에 할당자가 메모리를 돌려주지 못한 문제입니다. 관리 단위도, 문제가 생긴 위치도 15장의 상황과 다릅니다.

그래도 두 상황 모두 같은 질문으로 볼 수 있습니다.

공간이 비어 있다는 것은 누가 볼 때 그런 걸까요? 그 공간을 누가, 어떤 단위로 다시 쓸 수 있을까요?

프로세스 안에서는 다시 쓸 수 있는 공간이어도, 다른 프로세스가 쓸 수 있는 물리 메모리로 돌아가지 않았을 수 있습니다. 15장의 내부 단편화와 Picnic의 장애는 둘 다 이 질문으로 설명할 수 있습니다.

하나 더 주의할 점이 있습니다. 연속된 가상 주소라고 해서 물리 주소도 연속인 것은 아닙니다. malloc이 연속된 가상 주소 범위를 돌려줘도, 그 범위에 매핑된 물리 페이지는 흩어져 있을 수 있습니다. 가상 주소와 물리 주소는 페이지 단위로 페이지 테이블에 매핑됩니다. 그래서 RSS가 세는 단위도, 할당자가 운영체제에 메모리를 돌려주는 단위도 페이지입니다.

5. GC가 객체를 옮겨 정리해 주지 않을까요?

GC에는 살아 있는 객체를 한쪽으로 모아 흩어진 빈 공간을 하나로 합치는 compaction이 있습니다. G1이나 Parallel GC는 객체를 다른 위치로 복사하고, 그 객체를 가리키던 reference를 모두 새 주소로 고칩니다. 그래서 Java 힙 안에서는 3장의 실험처럼 흩어진 빈 공간이 계속 쌓이지 않습니다.

객체를 옮길 수 있는 이유는 JVM이 reference의 위치를 모두 알기 때문입니다. 힙 안 객체의 필드, 스레드 스택의 지역 변수, JIT 코드가 쓰는 레지스터 가운데 어디에 reference가 있는지 GC가 찾을 수 있도록 JVM은 OopMap 같은 metadata를 유지합니다. 이 구조는 이전 글에서 Safepoint와 함께 살펴봤습니다.

GC가 힙을 정리할 때 off-heap 버퍼도 함께 정리되는지 단계별로 보겠습니다.

GC가 힙을 정리하면 off-heap 버퍼도 정리될까요?

위는 GC가 관리하는 Java 힙, 아래는 malloc으로 받은 off-heap 메모리입니다. D는 DirectByteBuffer 객체이고, 선은 D가 들고 있는 버퍼 주소입니다.

객체 수와 버퍼 크기는 설명을 위해 정한 모형입니다. 실제 GC는 힙을 여러 영역으로 나눠 일부만 옮기기도 하고, Cleaner는 GC가 끝난 뒤 별도 스레드에서 실행됩니다. off-heap 버퍼의 위치는 할당자가 정합니다.

malloc으로 받은 네이티브 메모리는 사정이 다릅니다. malloc이 돌려준 주소는 C 포인터로 여러 곳에 복사됩니다. 할당자는 누가 그 주소를 들고 있는지 모르므로 블록을 다른 위치로 옮길 수 없습니다. 옮기면 예전 주소를 들고 있던 코드가 엉뚱한 메모리를 읽게 됩니다. 그래서 malloc으로 받은 블록은 free할 때까지 같은 주소에 머뭅니다.

direct buffer가 이 차이를 잘 보여 줍니다. DirectByteBuffer 객체 자체는 Java 힙에 있어서 GC가 옮길 수 있습니다. 하지만 이 객체가 가리키는 실제 버퍼는 malloc으로 받은 off-heap 메모리이고, 객체는 그 주소를 long 값으로만 들고 있습니다. GC가 객체를 옮겨도 버퍼는 제자리에 있고, 버퍼 사이에 생긴 빈 공간도 그대로 남습니다.

Picnic에서 GC가 정리할 수 있는 Java 힙은 약 2.5GB로 문제가 없었습니다. 늘어난 것은 GC가 손댈 수 없는 할당자 쪽 공간이었습니다.

6. jemalloc은 무엇을 다르게 했을까요?

jemalloc도 블록을 옮기지는 못합니다. 대신 블록을 처음 배치하는 방법과 빈 페이지를 운영체제에 돌려주는 방법이 glibc와 다릅니다.

glibc mallocjemalloc
블록 배치크기가 다른 블록이 같은 영역에 이어서 놓입니다. free chunk는 이웃한 free chunk와 합쳐 다시 씁니다.작은 블록은 size class별로 slab에 모읍니다. slab은 같은 크기의 region으로 나눈 페이지 묶음이고, region 하나에 블록 하나가 들어갑니다.
자동 반환heap 맨 위의 빈 공간만 자동으로 줄입니다.쓰지 않는 페이지는 위치와 상관없이 일정 시간이 지나면 반환합니다(dirty_decay_ms).

size class는 빈 공간의 재사용을 어떻게 바꿀까요?

Picnic에서 문제가 된 단편화는 4장에서 본 외부 단편화 쪽입니다. 빈 공간의 합계는 충분한데, 다음 요청에 맞는 빈 공간이 없어서 새 메모리를 받아 오는 상황입니다. 같은 빈 공간에 같은 요청을 보내 두 할당자를 비교해 보겠습니다.

빈 공간 6KB에 요청 6KB가 들어갈까요?

두 할당자에 같은 블록 8개를 할당한 뒤 같은 블록 4개를 해제한 상태입니다. 양쪽 모두 빈 공간이 6KB 있습니다. 1KB, 1KB, 2KB, 2KB 요청을 보내 보세요.

1KB 블록 2KB 블록 glibc 방식 RSS 12KB jemalloc 방식 RSS 12KB

페이지 4KB, 블록 1KB와 2KB로 단순화한 모형입니다. 두 쪽 모두 같은 순서로 2KB, 1KB, 2KB, 1KB, 2KB, 1KB, 2KB, 1KB 블록을 할당한 뒤 첫 번째와 세 번째 2KB, 두 번째와 세 번째 1KB를 해제했습니다. glibc 방식은 낮은 주소부터 처음 맞는 free chunk에 넣고, jemalloc 방식은 같은 size class의 slab에서 낮은 주소부터 넣습니다. 실제 jemalloc의 size class는 1KB와 2KB 사이에도 여러 개 있을 만큼 촘촘하고, 실제 할당자는 bin과 캐시 등 훨씬 복잡합니다.

glibc 방식은 1KB 요청을 낮은 주소에서 처음 맞는 free chunk에 넣습니다. 그 chunk가 2KB였으므로 1KB 요청 두 개가 2KB chunk를 나눠 차지하고, 원래 있던 1KB free chunk 두 개는 그대로 남습니다. 마지막 2KB 요청은 1KB 조각 두 개에 들어가지 못해 새 페이지를 받아 옵니다.

jemalloc 방식에서는 1KB 블록과 2KB 블록이 처음부터 다른 slab에 놓입니다. 2KB 블록이 해제된 자리는 2KB region이고, 이 region은 다음 2KB 요청만 씁니다. 작은 요청이 큰 빈 공간을 쪼개지 않으니, 해제된 region이 다음 요청에 맞지 않아 버려지는 일이 줄어듭니다.

대가도 있습니다. 요청은 그보다 크거나 같은 가장 작은 size class의 region에 담깁니다. 실제 size class는 이 모형보다 촘촘해서 1,024바이트와 2,048바이트 사이에도 1,280, 1,536, 1,792바이트 class가 있습니다. 그래도 1,100바이트 요청은 1,280바이트 region에 들어가고 그 안의 180바이트는 쓰이지 않습니다. 이것은 4장에서 본 내부 단편화입니다. jemalloc은 size class의 간격을 조절해 이 낭비를 작은 크기를 빼면 대략 20% 이내로 제한합니다. 참고: jemalloc(3). 정리하면 jemalloc은 단편화를 없애지 않습니다. 크기가 예측되지 않는 외부 단편화를, 상한이 있는 내부 단편화로 바꿉니다.

같은 작업을 반복하면 어떻게 될까요?

Picnic의 planning 작업처럼 같은 작업을 반복하는 상황에 이 차이를 대어 보겠습니다. 작업마다 임시 블록을 여러 개 쓰고, 결과 블록 몇 개만 다음 작업까지 남깁니다. 두 할당자에 같은 요청을 같은 순서로 보내고, 작업이 끝난 뒤 RSS를 비교해 보세요.

같은 작업을 반복하면 RSS는 어디서 멈출까요?

두 할당자에 같은 요청을 같은 순서로 보냅니다. 작업마다 1KB와 2KB 블록 10개를 쓰고, 마지막 1KB 두 개는 결과로 남아 다음 작업이 끝날 때 해제됩니다. 작업을 실행해 보세요.

작업 0회 glibc 방식 RSS 0KB jemalloc 방식 RSS 0KB

페이지 4KB, 블록 1KB와 2KB로 단순화한 모형입니다. glibc 방식은 낮은 주소의 빈 공간부터 채우고 heap 맨 위의 빈 페이지만 자동으로 반환합니다. jemalloc 방식은 size class별 slab에 블록을 모으고, 통째로 빈 페이지를 잠시 뒤 반환합니다(실제 대기 시간은 dirty_decay_ms 설정). Picnic처럼 시간이 지날수록 계속 늘어나는 현상까지 재현한 모형은 아니며, 작업이 끝난 뒤 남는 메모리의 차이만 보여 줍니다. 실제 측정값이 아닙니다.

두 할당자 모두 작업이 끝난 뒤 실제로 쓰는 메모리는 결과 블록 2KB뿐입니다. 그런데 RSS는 다르게 남습니다.

  1. glibc 방식은 맨 위 한 페이지 때문에 나머지를 돌려주지 못합니다. 결과 블록은 작업 마지막에 할당되어 heap 맨 위 페이지에 놓입니다. 자동 반환은 heap 맨 위에서만 일어나는데 그 페이지가 사용 중이라, 아래의 빈 페이지 세 개가 RSS에 그대로 남습니다. 다음 작업은 이 빈 페이지를 다시 쓰지만, 작업이 끝날 때마다 같은 일이 되풀이됩니다.
  2. jemalloc 방식은 빈 페이지를 위치와 상관없이 돌려줍니다. 결과 블록은 1KB slab 하나에 모이고, 임시 블록이 쓰던 slab은 통째로 빕니다. 빈 slab은 decay 시간이 지나면 heap의 맨 위가 아니어도 운영체제로 돌아갑니다. 다음 작업에서는 페이지를 다시 받아 오고, 작업이 끝나면 다시 돌려줍니다.

모형에서는 작업마다 같은 양이 남지만, 실제 서비스에서는 크기와 수명이 제각각인 블록이 섞이면서 돌려주지 못한 공간이 작업마다 조금씩 늘었습니다. Picnic이 계산한 빈 공간 비율도 작업 한 번 뒤 약 80%, 다음 작업 뒤 약 84%로 늘었습니다.

jemalloc은 이런 구조로 단편화에 상한을 두도록 설계된 할당자이고, Picnic도 jemalloc이 효과를 낸 이유로 이 점을 들었습니다. Java 프로세스에는 jemalloc 공유 라이브러리를 LD_PRELOAD로 지정해 JVM을 시작하는 방식으로 적용할 수 있고, Picnic도 이 방식을 썼습니다.

다만 jemalloc이 원인을 없앤 것은 아닙니다. direct buffer를 만들고 버리는 할당 패턴은 그대로이고, jemalloc은 그 과정에서 생기는 빈 공간이 계속 쌓이지 않도록 관리합니다. 그래서 메모리는 JVM 지표 합계보다 여전히 조금 높은 약 4GB에서 멈췄고, 더 늘지 않으니 컨테이너가 한도에 닿지 않았습니다. 할당 패턴이나 부하가 바뀌면 이 균형도 달라질 수 있습니다.

다른 서비스에도 적용하려면

할당자의 효과는 객체 크기, 객체 수명, thread 수, 부하가 반복되는 패턴에 따라 달라집니다. Picnic에서 jemalloc이 효과가 있었다고 다른 서비스에서도 같은 결과가 나온다고 볼 수는 없습니다.

해제한 공간을 들고 있으면 다음 할당이 빨라집니다. 운영체제에 자주 반환하면 RSS는 줄어들지만 나중에 메모리를 다시 받아 오는 비용이 듭니다. jemalloc에도 캐시, arena, 페이지 반환 정책을 조정하는 설정이 여럿 있습니다. 참고: jemalloc(3).

이 사례를 다른 서비스에 적용한다면 다음 순서로 진행하려고 합니다. 사례를 읽고 정리한 순서입니다.

  1. 무엇이 늘어나는지 구분합니다. Java 객체가 늘어나는지, 네이티브 할당이 늘어나는지, 할당자가 쓰지 않는 공간을 많이 들고 있는지 나눠서 봅니다. JVM 쪽은 NMT의 baseline과 summary.diff로 두 시점의 차이를 보고, 프로세스 전체는 /proc/<pid>/smaps_rollup으로 봅니다. 할당자 안쪽은 Picnic처럼 glibc의 mallinfo나 jemalloc 프로파일(jeprof)로 확인할 수 있습니다.
  2. 같은 부하로 비교합니다. 메모리 수치와 함께 지연 시간과 처리량도 확인합니다.
  3. 오래 실행해 봅니다. 짧은 테스트에서 안정적이어도 작업을 반복하거나 오래 실행하면 결과가 달라질 수 있습니다.
  4. 결과를 해당 서비스에만 적용합니다. 한 서비스에서 효과가 있었다고 모든 서비스의 기본 설정을 바꾸지 않습니다.

재현 조건을 만들고 측정하기 전에 RSS 증가를 단편화로 결론 내리지 않아야 합니다. 누수가 있다면 할당자를 바꾸기 전에 누수부터 고쳐야 합니다.

7. off-heap 메모리도 컨테이너 한도에 포함될까요?

Java 힙 상한은 JVM이 객체를 저장하는 영역의 한도입니다. 컨테이너 메모리 한도에는 이 영역 밖의 메모리도 포함됩니다. 힙이 상한에 도달하지 않았어도 컨테이너 전체는 한도에 가까울 수 있습니다.

Linux cgroup v2에서는 anonymous memory, 페이지 캐시, 여러 커널 메모리가 모두 cgroup의 메모리 사용량에 포함됩니다. 사용량이 memory.max에 도달했는데 줄일 수 없으면 cgroup 안에서 OOM killer가 실행됩니다. 참고: cgroup v2. 여기서는 cgroup v2를 기준으로 설명합니다.

RSS와 cgroup 사용량도 서로 다른 숫자입니다. cgroup 사용량에는 페이지 캐시가 들어가는데, 페이지 캐시는 메모리가 부족하면 커널이 회수할 수 있습니다. 그래서 사용량이 한도에 가깝다고 곧바로 OOM이 나지는 않습니다. 회수할 수 있는 메모리를 다 줄인 뒤에도 한도를 넘을 때 OOM killer가 실행됩니다. 할당자가 들고 있는 빈 블록은 프로세스가 쓰는 anonymous memory라서 커널이 페이지 캐시처럼 회수할 수 없습니다.

그래서 호스트에 RAM이 남아 있어도 컨테이너는 자기 한도에 걸려 종료될 수 있습니다.

15장의 bounds register는 프로세스가 접근할 수 있는 주소 범위를 제한하고, 범위를 넘는 접근은 그 자리에서 예외로 처리됩니다. cgroup은 프로세스 그룹이 쓸 수 있는 메모리 양을 제한하고, 한도를 넘으면 커널이 먼저 메모리 회수를 시도한 뒤 OOM killer로 그룹 안의 프로세스를 종료합니다.

8. 다른 서비스에서도 같은 문제가 있었을까요?

LinkedIn의 Venice에서도 오래 실행하면 RSS와 VSS가 늘었는데, Java 힙과 NMT로는 원인을 설명할 수 없었습니다. 팀은 할당자의 단편화를 조사했고 jemalloc을 적용한 뒤 RSS가 계속 늘어나는 현상이 사라졌다고 밝혔습니다. 참고: Taming memory fragmentation in Venice with Jemalloc.

이 사례는 컨테이너 한도를 넘어 종료된 장애는 아니었습니다. 하지만 Picnic처럼 JVM 밖의 할당자까지 조사해야 원인을 찾을 수 있었습니다.

9. 처음 질문으로 돌아가면

Java 힙에는 여유가 있고 GC도 동작하는데, 왜 컨테이너는 메모리 한도에 걸려 종료될까요?

이제 계층별로 답할 수 있습니다.

계층해제하면 일어나는 일컨테이너 한도와의 관계
Java 객체GC가 회수한 공간을 JVM 안에서 다시 씁니다. free는 호출되지 않습니다.힙 사용량이 줄어도 JVM이 확보한 힙 영역은 그대로일 수 있습니다.
JVM이 확보한 힙 영역GC 정책과 설정에 따라 운영체제에 돌려줍니다.돌려주기 전까지 쓴 적 있는 페이지는 RSS와 cgroup 사용량에 남습니다.
네이티브 할당자direct buffer 같은 객체가 회수되면 free가 호출되고, 할당자는 그 블록을 들고 있을 수 있습니다.할당자가 들고 있는 공간도 RSS와 cgroup 사용량에 남습니다.
운영체제페이지가 통째로 비고 할당자가 반환해야 물리 메모리를 돌려받습니다.cgroup은 off-heap 메모리까지 셉니다.

3장에서 적은 첫 생각과 달리, GC가 객체를 회수해도 메모리가 바로 운영체제로 돌아가지는 않습니다. 힙 객체의 공간은 JVM 안에 남고, 네이티브 메모리는 할당자와 페이지 단위의 조건을 거쳐야 돌아갑니다.

GC가 동작해도 RSS는 계속 늘어날 수 있습니다. RSS가 높을 때도 누수부터 의심하기 전에 런타임, 네이티브 라이브러리, 할당자, 운영체제 가운데 어디서 늘었는지 나눠 봐야 조사할 곳을 정할 수 있습니다.

14장의 메모리 할당과 해제, 15장의 가상화를 실제 장애에 대어 보니 “지금 보고 있는 메모리는 누구의 메모리일까?”라는 질문을 먼저 하게 됐습니다. 아직 답을 정리하지 못한 질문도 있습니다.

  • 해제한 메모리를 바로 반환하지 않고 들고 있는 정책은 어떤 부하에서 유리할까요?
  • 컨테이너 메모리 한도를 올리면 언제 문제가 해결되고, 언제 재시작을 미루기만 할까요?

학습 자료: Remzi H. Arpaci-Dusseau, Andrea C. Arpaci-Dusseau, Operating Systems: Three Easy Pieces, Chapter 14 Interlude: Memory API, Chapter 15 Mechanism: Address Translation.

사례: Picnic (2021), Quest to the OS: Java Native Memory. LinkedIn (2021), Taming memory fragmentation in Venice with Jemalloc.

기업 사례는 각 서비스 환경에서 관찰한 결과로 읽었고 API와 구현에 대한 일반 설명은 공식 문서를 기준으로 정리했습니다. 기업 사례와 최근 구현에 관한 내용은 책 본문에는 없고, 직접 실행해 측정한 결과도 포함하지 않았습니다.

썸네일 사진: Possessed Photography / Unsplash