포스트

[Java] 쿠버네티스 환경 JVM 메모리 분석기 - 문제편

[Java] 쿠버네티스 환경 JVM 메모리 분석기 - 문제편

Pod의 메모리 관리와 JVM의 구조에 대해 먼저 알아본 글
unggu.dev | [K8s/JVM] Node부터 JVM의 Memory 관리

컨테이너 환경에서 운영 중인 Spring Boot 애플리케이션에서 메모리 사용량이 지속적으로 증가하는 경우가 있습니다.

아래와 같은 스펙을 가진 Pod가 있다고 가정합니다.

1
2
3
4
5
6
7
resources:
  requests:
    cpu: 200m
    memory: 500Mi
  limits:
    cpu: 500m
    memory: 1000Mi  # Limit 1Gi

해당 Pod가 일정 시간 기동된 후 아래와 같이 메모리 압박 상태가 확인되었습니다.

1
pod-6d7d6f47cc-dhmvn                  3m           877Mi

메모리가 이렇게 증가하는 이유는 너무나 다양합니다.

  • heap의 Old Gen에 GC가 회수하지 못하는 객체가 쌓여있음
  • GC가 제대로 동작하지 못함
  • Non-Heap(Native) 영역에 객체가 쌓여 있음
  • 누수되는 라이브러리 존재

정확한 원인을 알기 위해서는 heap의 Dump를 추출하는것 GC.heap_dump 가장 정확하다고 볼 수 있습니다.

하지만 Dump를 추출하는 동작은 그 자체로도 큰 메모리에 압박을 줍니다. OOM을 회피하기 위한 분석이 오히려 OOM을 유발한다면 전말전도일 것입니다.

Dump를 추출하는 것이 부담스럽다면 현재 Heap에 있는 객체들을 보는 GC.class_histogram 또한 하나의 방법이 될 수도 있습니다.

하지만 GC.heap_dump 또한 Full GC를 수행한다는 Side Effect가 있습니다.

객체를 정리해 메모리를 확보하는 Full GC가 왜 문제일까요?

Full GC가 위험한 이유

Garbage Collector(GC)는 Minor GC와 Major GC가 정기적으로 동작하지만, 이 과정에서 일정 수준의 오버헤드가 발생합니다.

GC는 각 Generation에 존재하는 객체를 마킹하고 이동하는 과정에서 임시 자료구조를 생성할 수밖에 없습니다. 따라서 단순히 메모리를 확보하기 위해 수행하는 GC는 오히려 즉시 OOM을 유발할 수도 있습니다.

우선 현재 Pod의 RSS를 확인하여 JVM 메모리가 어떻게 분배되어 있는지 파악하는 것이 더 중요합니다.

RSS(Resident Set Size)란? 프로세스가 현재 물리 메모리에 실제로 올라와 있는 양

메모리 압박 상태의 JVM 정보

상대적으로 오버헤드가 적은 Attach 방식의 jcmd 1 GC.heap_info를 통해 현재 JVM 메모리 상태를 확인합니다.

1
jcmd 1 GC.heap_info
1
2
3
garbage-first heap   total 521216K, used 405072K
region size 1024K, 75 young (76800K), 6 survivors (6144K)
Metaspace  used 140135K, committed 140928K, reserved 1179648K

이 정보를 풀어 쓰면 아래와 같습니다.

1
2
3
4
5
6
7
8
Heap total (committed)   521216K  =  509Mi
Heap used                405072K  =  396Mi
  Young Gen               76800K  =   75Mi
  Survivors                6144K  =    6Mi
  Old Gen (역산)                   =  315Mi

Metaspace committed      140928K  =  138Mi
Metaspace used           140135K  =  137Mi

Heap은 509Mi만큼 메모리를 예약하고 있으며, 실제 사용량은 396Mi입니다.

여기서 Young 영역과 Survivor 영역을 제외하면 Old Gen은 약 315Mi로 계산할 수 있습니다.

Metaspace는 약 138Mi를 사용하고 있으며, 현재 Pod의 사용량이 877Mi이므로 아래와 같이 Non-Heap 영역의 메모리 점유율을 추정할 수 있습니다.

1
2
3
4
5
6
Pod RSS          877Mi   ← 실측
- Heap committed 509Mi   ← 실측 (GC.heap_info total)
─────────────────────────
Non-Heap 추정    368Mi
  ├── Metaspace  138Mi   ← 실측 (GC.heap_info Metaspace committed)
  └── 나머지     230Mi   ← 추정

압박 상태의 수치를 확인했다면, 반대로 Pod를 재기동한 후 초기 상태와 비교하여 어떤 영역의 메모리가 증가했는지 확인해보겠습니다.

초기 상태의 JVM 정보

방금 기동된 Pod에 동일하게 jcmd 1 GC.heap_info를 실행합니다.

1
2
3
4
garbage-first heap   total 234496K, used 138880K
  region size 1024K, 57 young (58368K), 4 survivors (4096K)
Metaspace  used 136816K, committed 137728K, reserved 1179648K
  class space  used 16601K, committed 17088K, reserved 1048576K

이 정보를 동일하게 정리해보겠습니다.

1
2
3
4
5
6
7
8
Heap total (committed)   234496K  =  229Mi
Heap used                138880K  =  136Mi
  Young Gen               58368K  =   57Mi
  Survivors                4096K  =    4Mi
  Old Gen (역산)                   =   75Mi

Metaspace committed      137728K  =  135Mi
Metaspace used           136816K  =  134Mi

동일하게 Non-Heap 영역을 계산합니다.

1
2
3
4
5
6
Pod RSS          562Mi   ← 실측
- Heap committed 229Mi   ← 실측
─────────────────────────
Non-Heap 추정    333Mi
  ├── Metaspace  135Mi   ← 실측
  └── 나머지     198Mi   ← 추정

결국 Non-Heap 영역의 증가분은 약 32Mi에 불과하지만, Heap 영역의 Old Gen은 약 240Mi 증가한 것을 확인할 수 있습니다.

과정에서 수십 Mi 정도의 추가 메모리를 사용할 수 있습니다.

jcmd 또한 Attach 과정에서 수십 Mi 정도의 추가 메모리를 사용할 수 있습니다. OOM에 근접한 상황에서는 해당 명령어 사용에도 충분한 주의가 필요합니다. OOM에 근접한 상황에서는 해당 명령어 사용에도 충분한 주의가 필요합니다.

마치며

이번 분석을 통해 메모리 증가의 주요 원인이 Non-Heap이 아닌 Heap 영역, 그중에서도 Old Gen에 있음을 확인할 수 있었습니다.

다만 Old Gen이 증가했다고 해서 곧바로 메모리 누수로 단정할 수는 없습니다. 현재 단계에서는 문제 영역을 특정한 상태이며, 실제 원인 객체를 찾기 위한 추가 분석이 필요합니다.

다음 글에서는 Full GC를 일으키지 않는 GC.class_histogram -all을 활용하여 어떤 객체가 Old Gen을 점유하고 있는지 추적해보겠습니다.

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.