포스트

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

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

원인 파악하기

운영 환경에서 메모리 누수 객체를 특정하기 위해 비교적 부담이 적은 jmap -histo -all 명령어를 활용했습니다.

jcmd GC.heap_dumpGC.class_histogram(without -all)과 달리 Full GC를 유발하지 않아 운영 환경에서도 안전하게 실행할 수 있습니다.

현재 live가 아닌 garbage 영역이 포함되어서 부정확할 수 있음

1
jmap -histo -all <pid>

실제 운영 Pod에서 확인한 결과는 아래와 같았습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
num     #instances         #bytes  class name
1:        198432      156821648  [B

2:        312847       75083280  [C

3:        311203       74688720  java.lang.String

4:         87654       21033600  java.util.HashMap$Node

5:         34521        8284440  [Ljava.lang.Object;

...

142:            66        5395200  org.apache.cxf.endpoint.ClientImpl

143:            66        4316160  org.apache.cxf.transport.http.HttpClientHTTPConduit

144:           837         321648  sun.security.ssl.SSLContextImpl$TLSContext

상위권은 [B(byte array), String, HashMap$Node 등 일반적인 JVM 객체들이었습니다.

이 자료구조들은 다양한 원천에서 생성되기 때문에 이것만으로는 원인을 특정할 수 없었습니다.

목록을 내려가다 org.apache.cxf 패키지의 클래스명이 직접 보이는 지점에서 단서를 찾을 수 있었습니다.

ClientImplHttpClientHTTPConduit이 1:1로 66개씩, SSLContextImpl$TLSContext가 837개 잔류하고 있었습니다.

정상적으로 close()가 호출됐다면 GC에 의해 회수되어야 할 객체들이 계속 살아있다는 것은 어딘가에서 참조가 유지되고 있다는 신호였습니다.

이를 바탕으로 org.apache.cxf 관련 객체가 생성되는 코드를 추적했고, 공통적으로 반복되는 누수 패턴을 발견할 수 있었습니다.

아래는 해당 패턴을 일반화한 예제입니다

프록시가 닫히지 않은 케이스

호출부

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class ExchangeRateProxy {

    public BigDecimal getRate(String from, String to) {
        // 매 호출마다 프록시를 새로 생성
        ExchangeRateService port = WsClientFactory.createProxy(
            ExchangeRateService.class,
            "/exchange/ExchangeRateService?WSDL"
        );

        try {
            return port.getRate(from, to);
        } catch (ExchangeServiceException e) {
            throw new RuntimeException("환율 조회 실패", e);
        }
        // finally 없음 → 정상 흐름이든 예외든 port는 닫히지 않는다
    }
}

팩토리

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public final class WsClientFactory {

    private static final String BASE_URL = "<https://api.example-fx.com>";

    public static <T> T createProxy(Class<T> serviceClass, String wsdlPath) {
        JaxWsProxyFactoryBean factory = new JaxWsProxyFactoryBean(); // ← 매번 new
        factory.setServiceClass(serviceClass);
        factory.setAddress(BASE_URL + wsdlPath);

        Object proxy = factory.create();                           // ← CXF Client 생성
        Client client = ClientProxy.getClient(proxy);
        HTTPConduit conduit = (HTTPConduit) client.getConduit();   // ← HTTP 소켓

        SSLContext sslContext = SSLContext.getInstance("TLS");     // ← TLS 컨텍스트
        return serviceClass.cast(proxy);
    }
}

Native 영역의 누수 - close() 누락

요청마다 HTTPConduit(소켓) + SSLContext(TLS)가 새로 열리고, 이 객체들을 close() 하지 않음으로써 TLS 세션 버퍼 및 소켓이 쌓이게 됩니다. 이 객체들은 Java 객체가 아닌 OS 자원이기에 GC 대상이 아니며, 해당 자원들을 반납하기 위해서는 close()로만 반환이 가능합니다. 이 문제는 Heap 그래프에서는 측정되지 않아 특히 주의가 필요한 패턴입니다.

Old Gen 누수

ClientImpl / HTTPConduit 잔류 - close() 누락

Apache CXF는 Spring 환경에서 기본적으로 SpringBus를 사용합니다. factory.create()로 프록시를 만들 때마다 Client와 연결을 맺기 위한 참조 ClientImpl / HTTPConduit이 생성되는데, close()를 하지 않으면 이 객체들은 싱글턴과 참조가 연결되어 지속적으로 살아있게 됩니다.

CXF-4795 — SpringBus alreadyCreated Set 누수

싱글턴인 SpringBus의 내부 Set에 목적지를 저장하는데, close()를 실행하더라도 이 내부 Set의 목적지 URL을 지우지 않습니다. 그렇기에 연동이 많은 다수 목적지 서비스에서는 이 객체가 지속적으로 누적되는 현상이 발생합니다. 이 문제는 프록시 자체를 싱글턴으로 설정하여 캐싱 전략을 활용해 과도하게 생성되는 것을 막아야 합니다.

프록시 캐싱으로 개선

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public final class WsClientFactory {

    private static final String BASE_URL = "<https://api.example-fx.com>";

    // 서비스 타입별로 프록시 1개를 만들어 재사용
    private static final Map<Class<?>, Object> PROXY_CACHE = new ConcurrentHashMap<>();

    @SuppressWarnings("unchecked")
    public static <T> T getProxy(Class<T> serviceClass, String wsdlPath) {
        return (T) PROXY_CACHE.computeIfAbsent(serviceClass, clazz -> {
            JaxWsProxyFactoryBean factory = new JaxWsProxyFactoryBean();
            factory.setServiceClass(clazz);
            factory.setAddress(BASE_URL + wsdlPath);
            return factory.create();
        });
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class ExchangeRateProxy {

    private final ExchangeRateService port = WsClientFactory.getProxy(
        ExchangeRateService.class,
        "/exchange/ExchangeRateService?WSDL"
    );

    public BigDecimal getRate(String from, String to) {
        try {
            return port.getRate(from, to);   // 캐싱된 프록시 재사용, close 불필요
        } catch (ExchangeServiceException e) {
            throw new RuntimeException("환율 조회 실패", e);
        }
    }
}

프로젝트 내에서 누수 지점의 코드를 찾는 것은 난이도가 높은 일입니다. 의심되는 부분을 수정하더라도 결국 검증은 트래픽이 많은 환경에서 진행되어야 합니다.

또한 코드를 수정했다고 해서 문제가 완전히 해결됐다고 단정하기도 어렵습니다. close()를 추가하더라도 SpringBus의 내부 Set에는 여전히 데이터가 쌓이고, 누수처럼 보이는 현상이 사실은 프레임워크 내부 동작이나 JVM 설정에서 비롯된 경우도 있기 때문입니다.

실제로 저희 환경에서도 코드 수정만으로 설명되지 않는 메모리 증가가 있었습니다.

Old Gen이 3주간 꾸준히 우상향하고 있었는데, 코드상 명확한 누수 지점을 찾기 어려웠습니다.

모니터링 툴을 통해 jvm_gc_pause 메트릭(GC 이벤트 로그)을 확인했을 때 다른 서비스들은 Mixed GC가 주기적으로 발생하는 반면, 해당 서비스만 3주간 Mixed GC가 발생하지 않았다는 것을 발견했습니다. (초기 재기동 제외)

메모리 증가의 또 다른 원인은 코드가 아닌 GC 튜닝 설정에 있었습니다.

왜 Mixed GC가 돌지 않았을까?

GC는 크게 Minor GC와 Full GC로 나뉘어집니다.

다만 Full GC는 STW의 위험이 크기에 이를 G1GC는 Heap 영역을 여러 Region으로 나누어 관리하며 Full GC를 여러 Region으로 나누어 진행하여 STW의 위험을 최소화합니다. → Mixed GC(Major GC)

Mixed GC는 JVM 자체의 Major GC에서 G1GC의 사용하는 알고리즘이 더해진 GC

다만 Mixed GC 또한 STW에 영향을 미세하게 끼칠 수 있기에 Heap의 객체가 IHOP 수치에 넘어서면 작동하는 시스템입니다.

여기서 IHOP 수치를 정리해보면 왜 Old Gen이 지속적으로 상승 했는지 보이기 시작합니다.

기존 GC 설정

1
2
3
4
5
ENV JAVA_OPTS="\
  -XX:+UseContainerSupport \
  -XX:InitialRAMPercentage=25.0 \
  -XX:MaxRAMPercentage=75.0 \
  -XX:+UseG1GC"

MaxRAMPercentage가 75.0이기에 1Gi Pod 기준 MaxHeap은 750Mi입니다.

IHOP의 기본 값은 45%이기에 750Mi × IHOP 45% = 337Mi로 Old Gen의 수치가 337Mi가 넘어서면 Mixed GC가 Old Gen의 청소를 시작합니다.

Pod 압박 상태 당시 Pod의 메모리 정보는 아래와 같았습니다.

1
2
3
4
5
6
7
8
Heap used        396Mi   (Young 75 + Survivors 6 + Old Gen 315)
Old Gen          315Mi
Young Gen         75Mi
Survivors          6Mi
Heap committed   509Mi
Non-Heap         368Mi
Pod RSS          877Mi
Pod limit       1000Mi

Old Gen이 315Mi 였고 Mixed GC가 돌기 위해서는 337Mi를 넘어야하기에 Pod 전체 메모리 기준 900Mi가 넘어야 본격적으로 GC가 도는 형태입니다.

즉 GC가 메모리를 청소하는 시점에 이미 900Mi에 가깝게 도달했고 여유가 100Mi인 상태이기에 트래픽 스파이크등이 겹치면 순식간에 OOM이 발생합니다.

GC 튜닝

Pod의 Limit이 적절한 수준에서 유지될 수 있도록 IHOP 값 새로 설정하고 역산하는 식으로 진행합니다.

IHOP 40% → Old Gen 발동점 = 750Mi × 40% = 300Mi

  • 발동 시점 Heap used = Old Gen(300Mi) + Young Gen(75Mi) + Survivors(6Mi) = 381Mi
  • 발동 시점 RSS = Non-Heap(368Mi) + Heap used(381Mi) = 749Mi

IHOP가 40%로 설정한다면 Old Gen의 used가 약 307Mi를 초과하면 Mixed GC가 Old Gen을 청소하기 시작하게 됩니다.

하지만 여기서 잡은 목표는 Heap used 기준이고, 실제 RSS를 구성하는 건 committed 값입니다. 실측해보면 이 둘 사이엔 차이가 있었습니다.

  • Heap used = 396Mi
  • Heap committed = 509Mi
  • Heap Buffer = 113Mi

Heap 내부적으로 버퍼 및 한번 commit하여 확보된 메모리에 대해서는 Pod로 다시 반환하지 않기에 이 Buffer(113Mi)의 차이가 생기게 됩니다.

이러한 Buffer까지 감안한다면 RSS 기준 860Mi로 86%로 여전히 OOM의 가능성이 존재합니다.

이러한 버퍼를 감안해 좀 더 빡빡한 IHOP = 35%로 다시 계산해보겠습니다.

IHOP 35% → Old Gen 발동점 = 750Mi × 35% = 262Mi

  • 발동 시점 Heap used = Old Gen(262Mi) + Young Gen(75Mi) + Survivors(6Mi) = 343Mi
  • 발동 시점 RSS = Non-Heap(368Mi) + Heap used(343Mi) + Heap Buffer(113Mi) = 824Mi

여전히 RSS 비율이 80%가 넘는 위험한 값이지만 Mixed GC가 CPU 등 서비스에 부담을 준다는 점을 감안 했을 때 더이상 낮추는건 힘들다고 판단되었습니다.

남은 과제 - 여전히 높은 RSS 비율

Pod의 Limit 대비 적정 RSS 값은 InfoQ에 따르면 70% 정도가 안전한 값이라고 합니다.

하지만 저희 서비스의 특성상 IHOP를 35%로 하여도 Non-Heap 영역에서 기본으로 368Mi로 절반 넘는 값을 차지하는 상태입니다.

이를 해결하기 위해서는 Non-Heap 영역의 기본값 자체가 너무 과도하게 설정될 수 있으며, 그 원인을 코드 레벨에서 다시 짚어볼 필요가 있습니다.

GC 튜닝으로 Old Gen이 적절한 시기에 청소되지 않던 문제는 해결했지만, RSS 비율이 여전히 80%인 것은 걱정인 부분입니다.

메모리 문제는 한 가지 원인으로 깔끔하게 설명되는 경우가 드뭅니다. 저희도 CXF 프록시 미close, SpringBus의 알려진 이슈, Old Gen 누수 등 여러 가설을 세우고 하나씩 검증하며 코드 레벨 개선을 진행했고, 그럼에도 남아있던 문제는 GC 튜닝 작업으로 해결했습니다.

이제 추후 Non-Heap 영역을 개선하거나 숨겨진 메모리 누수 포인트를 추가로 찾아내는 것, 혹은 Pod의 limit 자체를 상향하는 등 다양한 관점으로 지속적으로 개선이 필요합니다.

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