로직과의 사투/Operational Issues

네이티브 메모리 누수 추적기

Native Memory Leak Detection.

1. 개요


java.lang.OutOfMemoryError: Direct buffer memory
협력 업체에서 통신이 안된단 연락을 받고 해당 프로세스의 로그를 열었을 때 위 에러가 제일 먼저 반겨주었다. Java는 일반적으로 Heap 메모리 영역을 이용하는 경우 JVM에서 관리를 하지만, Direct Buffer를 이용해 네이티브 메모리 영역을 사용하는 경우도 있다. 일례로 java.nio.DirectByteBuffer를 이용하는 경우인데, HeapByteBuffer와 다르게 DirectByteBuffer는 성능적으로 유리하단 점이 있어 DirectMemoryAccess(DMA)를 활용한다. 그런고로 Heap Memory 처럼 Garbage Collecting 이 이뤄지지 않기 때문에 사용자가 직접 이용한 메모리를 해제해줘야한다.

2. Netty


해당 프로세스는 Netty 란 프레임워크를 사용 중이다. NIO 서버, 클라이언트 프레임워크로서 TCP 요청 및 응답을 주고 받기 위해 사용 중이다. Netty 프레임워크에는 ByteBuffer를 직접적으로 사용하는 ByteBuf 란 클래스가 있다. Netty의 데이터 컨테이너로서 nio ByteBuffer에서 확장해 버퍼풀, 쓰기/읽기의 인덱스 분리 등을 지원한다. ByteBuf에서도 heap 또는 native 메모리 영역을 선택해서 이용할 수 있는데, io.netty.ByteBufAllocator를 통해 메모리 allocate가 가능하다.

ByteBuf heapBuf = ByteBufAllocator.DEFAULT.heapBuffer();
ByteBuf directBuf = ByteBufAllocator.DEFAULT.directBuffer();

유의할 점은 앞서 밝혔지만, directBuffer 로 natvie memory 영역을 이용하는 경우 JVM, GC 에서 감지할 수 없어 명시적인 해제가 필요하다.

// directBuf.release();
// ByteBufAllocator로 memroy allocated 했을 때 ReferenceCountUtil을 이용할 수 있다.
ReferenceCountUtil.safeRelease(directBuf);

3. 메모리 Leak Detecting

위와 같은 사전 지식에 기반하여 Memory Leak Point 추적을 시작해보자. 우선, JVM 옵션을 추가해야한다.

-XX:NativeMemoryTracking=summary

or

-XX:NativeMemoryTracking=detail

위 기능을 통해 Java 어플리케이션의 native memory를 트랙킹할 수 있다. 위 설정을 추가한 다음 baseline을 지정해줘야 하는데, 위 기능에서 기준점 대비 메모리 증감을 볼 수 있도록 한다.

jcmd {PID} VM.native_memory baseline

명령어를 실행했을 때 {PID} Baseline succeed. 란 메시지가 출력되면 기준점 설정이 완료됐단 뜻이다. 이후 jcmd 명령어를 통해 메모리 영역의 상황을 확인해보자.

jcmd {pid} VM.native_memory summary.diff

Native Memory Tracking:

Total: reserved=1607231KB +4027KB, committed=353295KB +4027KB

-                 Java Heap (reserved=131072KB, committed=131072KB)
                            (mmap: reserved=131072KB, committed=131072KB)

-                     Class (reserved=1115164KB +2KB, committed=74100KB +2KB)
                            (classes #11777 +2)
                            (malloc=5148KB +2KB #22604 +51)
                            (mmap: reserved=1110016KB, committed=68952KB)

-                    Thread (reserved=41554KB, committed=41554KB)
                            (thread #41)
                            (stack: reserved=41120KB, committed=41120KB)
                            (malloc=131KB #204)
                            (arena=303KB #80)

-                      Code (reserved=257280KB +14KB, committed=44408KB +14KB)
                            (malloc=7680KB +14KB #10801 +12)
                            (mmap: reserved=249600KB, committed=36728KB)

-                        GC (reserved=8268KB, committed=8268KB)
                            (malloc=3472KB #298 +1)
                            (mmap: reserved=4796KB, committed=4796KB)

-                  Compiler (reserved=255KB +5KB, committed=255KB +5KB)
                            (malloc=125KB +5KB #314 +8)
                            (arena=131KB #3)

-                  Internal (reserved=35454KB +4006KB, committed=35454KB +4006KB)
                            (malloc=35422KB +4006KB #15774 -9)
                            (mmap: reserved=32KB, committed=32KB)

-                    Symbol (reserved=15113KB, committed=15113KB)
                            (malloc=12899KB #134203 +2)
                            (arena=2214KB #1)

-    Native Memory Tracking (reserved=2892KB +1KB, committed=2892KB +1KB)
                            (malloc=9KB #114)
                            (tracking overhead=2883KB +1KB)

-               Arena Chunk (reserved=178KB, committed=178KB)
                            (malloc=178KB)

여기서 주목할 부분은

  • Internal (reserved=35454KB +4006KB, committed=35454KB +4006KB)
         (malloc=35422KB +4006KB #15774 -9)
         (mmap: reserved=32KB, committed=32KB)

이다 . 기준점 설정 이후 internal 메모리를 나타내는 부분인데, 해당 영역이 늘어난 것은 괜찮다. 정상적으로 메모리 해제가 된다면 다시 기준점으로 돌아가거나 몇 KB 차이 안나는 수준으로 떨어질 것이므로 정상적인 동작으로 판단할 수 있다. 단, 위 영역이 줄어들지 않고 늘어나기만 하는 경우 시간이 흐른 뒤에 결국,
java.lang.OutOfMemoryError: Direct buffer memory 가 log에 찍히는 것을 볼 수 있다. 실제로 네이티브 메모리 누수가 추정되는 위 프로세스는 시간이 지날 수록 internal 영역이 계속 늘어나기만 하고 있어 native memory leaking을 확신할 수 있었다.

  • Internal (reserved=35582KB +4134KB, committed=35582KB +4134KB)
         (malloc=35550KB +4134KB #15790 +7)
         (mmap: reserved=32KB, committed=32KB)

Native Memory 누수를 확신한 이후의 스텝은 코드 추적을 통해 판단 가능한 영역이지만, 비즈니스 로직이 복잡하게 얽혀있는 경우 그마저도 쉽지 않아 약간의 힌트가 필요하다.

pmap과 smaps

OutOfMemoryError가 Heap Memory 에서 발생한 경우, Heap dump를 발생하게 해 Heap Dump 분석이 용이했겠지만, non-heap 영역에서 발생했으므로, 메모리 덤프를 확인해야 한다. pmap 명령어의 경우 프로세스 아이디를 기준으로 프로세스의 메모리 맵을 출력하는 리눅스의 명령어이다. -x 옵션은 확장된 포맷으로 출력을 해주어 Address, Kbytes, RSS 등을 확인할 수 있는 옵션이다.

pmap -x {pid}

Address           Kbytes     RSS   Dirty Mode   Mapping
0000000000400000       4       4       0 r-x--  java
0000000000600000       4       4       4 rw---  java
0000000000d67000     404     308     308 rw---    [ anon ]
00000000f8000000  139648   80208   80208 rw---    [ anon ]
0000000100860000 1040000       0       0 -----    [ anon ]
00000030afe00000     128     116       0 r-x--  ld-2.12.so
00000030b0020000       4       4       4 r----  ld-2.12.so
00000030b0021000       4       4       4 rw---  ld-2.12.so
00000030b0022000       4       4       4 rw---    [ anon ]
00000030b0200000    1576     696       0 r-x--  libc-2.12.so
00000030b038a000    2048       0       0 -----  libc-2.12.so
00000030b058a000      16      16       8 r----  libc-2.12.so
00000030b058e000       8       8       8 rw---  libc-2.12.so
00000030b0590000      16      16      16 rw---    [ anon ]
00000030b0600000     524      56       0 r-x--  libm-2.12.so
00000030b0683000    2044       0       0 -----  libm-2.12.so
00000030b0882000       4       4       4 r----  libm-2.12.so
00000030b0883000       4       4       4 rw---  libm-2.12.so
00000030b0a00000       8       8       0 r-x--  libdl-2.12.so
00000030b0a02000    2048       0       0 -----  libdl-2.12.so
00000030b0c02000       4       4       4 r----  libdl-2.12.so
00000030b0c03000       4       4       4 rw---  libdl-2.12.so
00000030b0e00000      92      72       0 r-x--  libpthread-2.12.so
00000030b0e17000    2048       0       0 -----  libpthread-2.12.so
00000030b1017000       4       4       4 r----  libpthread-2.12.so
00000030b1018000       4       4       4 rw---  libpthread-2.12.so
00000030b1019000      16       4       4 rw---    [ anon ]
00000030b1600000      28      20       0 r-x--  librt-2.12.so
00000030b1607000    2044       0       0 -----  librt-2.12.so
00000030b1806000       4       4       4 r----  librt-2.12.so
00000030b1807000       4       4       4 rw---  librt-2.12.so
00000030bc200000      88      16       0 r-x--  libgcc_s-4.4.7-20120601.so.1
00000030bc216000    2044       0       0 -----  libgcc_s-4.4.7-20120601.so.1
00000030bc415000       4       4       4 rw---  libgcc_s-4.4.7-20120601.so.1
.
.
. 이하 생략

정렬 기준이 메모리 주소로 정렬되어 아래와 같이 RSS(Resident Set Size) 기준으로 다시 정렬한다.

pmap -x {PID} | sort -k 3 -n -r | more

00000000f8000000  139648   80208   80208 rw---    [ anon ]
00007f448409f000   64900   63488   63488 -----    [ anon ]
00007f447c02b000   65364   59392   59392 -----    [ anon ]
00007f44c5000000   36160   36144   36144 rwx--    [ anon ]
00007f4480bfe000   28676   28676   28676 rw---    [ anon ]
00007f449c000000   41820   24576   24576 rw---    [ anon ]
00007f44a0000000   19180   19156   19156 rw---    [ anon ]
00007f44d4000000   18428   16808   16808 rw---    [ anon ]
00007f44d8034000   14396   13256   13256 rw---    [ anon ]
00007f44c43ce000   10592   10392   10392 rw---    [ anon ]
.
.
. 이하 생략

맨 위의 00000000f8000000 주소는 128mb로 설정한 java heap 영역이라 제외 하고 smaps 명령어로 자세하게 확인해본다. smaps의 경우 프로세스 단위로 mapping된 메모리 정보를 훨씬 더 디테일하고 상세하게 볼 수 있는 명령어이다.

smaps 를 검색할 때 pmap 상 주소값에서 앞의 0000은 떼고 검색한다.

vi /proc/{PID}/smaps 

7f447c000000-7f447c02b000 rw-p 00000000 00:00 0
Size:                172 kB
Rss:                 172 kB
Pss:                 172 kB
Shared_Clean:          0 kB
Shared_Dirty:          0 kB
Private_Clean:         0 kB
Private_Dirty:       172 kB
Referenced:          172 kB
Anonymous:           172 kB
AnonHugePages:         0 kB
Swap:                  0 kB
KernelPageSize:        4 kB
MMUPageSize:           4 kB
VmFlags: rd wr mr mw me nr
7f447c02b000-7f4480000000 ---p 00000000 00:00 0
Size:              65364 kB
Rss:                   0 kB
Pss:                   0 kB
Shared_Clean:          0 kB
Shared_Dirty:          0 kB
Private_Clean:         0 kB
Private_Dirty:         0 kB
Referenced:            0 kB
Anonymous:             0 kB
AnonHugePages:         0 kB
Swap:                  0 kB
KernelPageSize:        4 kB
MMUPageSize:           4 kB
VmFlags: mr mw me nr
7f4480bfe000-7f44827ff000 rw-p 00000000 00:00 0
Size:              28676 kB
Rss:               28676 kB
Pss:               28676 kB
Shared_Clean:          0 kB
Shared_Dirty:          0 kB
Private_Clean:         0 kB
Private_Dirty:     28676 kB
Referenced:        28672 kB
Anonymous:         28676 kB
AnonHugePages:     26624 kB
Swap:                  0 kB
KernelPageSize:        4 kB
MMUPageSize:           4 kB
VmFlags: rd wr mr mw me ac
7f4484000000-7f448409f000 rw-p 00000000 00:00 0
Size:                636 kB
Rss:                 624 kB
Pss:                 624 kB
Shared_Clean:          0 kB
Shared_Dirty:          0 kB
Private_Clean:         0 kB
Private_Dirty:       624 kB
Referenced:          532 kB
Anonymous:           624 kB
AnonHugePages:         0 kB
Swap:                  0 kB
KernelPageSize:        4 kB
MMUPageSize:           4 kB
VmFlags: rd wr mr mw me nr
7f448409f000-7f4488000000 ---p 00000000 00:00 0
Size:              64900 kB
Rss:                   0 kB
Pss:                   0 kB
Shared_Clean:          0 kB
Shared_Dirty:          0 kB
Private_Clean:         0 kB
Private_Dirty:         0 kB
Referenced:            0 kB
Anonymous:             0 kB
AnonHugePages:         0 kB
Swap:                  0 kB
KernelPageSize:        4 kB
MMUPageSize:           4 kB
VmFlags: mr mw me nr

여기서 RSS 값이 수상하게 높은 메모리 주소 값들을 여러 개 확보해 기록해둔다.

다음 스텝은 gdb 명령어로 memory dump.

gdb -pid 11679
...

(gdb) dump memory {dump 위치}/dump1 0x7f447c02b000 0x7f4480000000
(gdb) dump memory {dump 위치}/dump2 0x7f4480bfe000 0x7f44827ff000
(gdb) dump memory {dump 위치}/dump3 0x7f448409f000 0x7f4488000000
(gdb) dump memory {dump 위치}/dump4 0x7f4484000000 0x7f448409f000

위 명령어를 통해 attach 하고 위에서 확보한 주소의 dump 실행한다. 참고할 점은 확보한 주소에 0x를 붙여 Hex로 표기해줘야한다. 이후 생성된 파일들을 strings 명령어를 통해 인간이 알아볼 수 있는 문자들을 확인한다.

strings dump1 | more
strings dump2 | more

와 같이 dump 파일들을 확인하는데, dump2 파일에서 기동 이래로 특정 TCP 통신 전문들이 모두 네이티브 메모리 영역에 쌓여있는 것을 확인할 수 있었다. TCP 전문을 처리하는 곳에서 native memory를 이용하고선 해제 하지 않은 것이고, 처음엔 문제가 되지 않았지만 이렇게 전문들이 계속 쌓이다보면 결국 OutOfMemoryError가 발생하는 것으로 추정했다.

4. 소스 수정


누수 포인트를 찾았으니 소스를 수정하고 한동안 모니터링한다. Netty 의 ReferenceCountUtil을 통해 명시적으로 ByteBuf 참조를 해제시킨다.

InboundHandler.java
try {
    // business logic    
    byteBuf = ByteBufAllocator.DEFAULT.directBuffer();
} catch (Exception e) {
    // exception handling
} finally {
    ReferenceCountUtil.safeRelease(byteBuf);
}

특히, Netty 는 인바운드 핸들러를 통해 생선된 byteBuf 는 자동으로 해제되지 않는다.
cf. https://javacoding.tistory.com/149
safeRelease메소드는 이미 해제된 byteBuf 를 다시 해제 시도하는 경우 Exception을 발생시키지 않고 log 로만 남긴다. (release를 이용하는 경우엔 Exception이 발생함.)

5. 결과 모니터링


테스트 서버에 반영 이후 경과를 지켜본다. 신속한 테스트를 위해 아래와 같은 1초에 한 번씩 TCP 전문을 쏘는 클라이언트 프로그램을 만들어 실행시켰다.

        try {
            socket = new Socket(IP, PORT);
            for (int i = 0; i < 99999; i++) {
                System.out.println("Time : " + LocalDateTime.now());
                try {
                    TimeUnit.SECONDS.sleep(1);
                    OutputStream out = socket.getOutputStream();
                    String message = "전문내용";
                    byte[] data = message.getBytes("UTF-8");
                    out.write(data);
                    out.flush();
                } catch (Exception e) {
                    e.printStackTrace();
                }
            }
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            socket.close();
        }

아래와 같은 소스를 통해 실제 native memory 사용량을 확인하여도 되고, jcmd 명령어나, pmap, smaps, gdb 등을 그대로 이용하여 재확인해도된다.

OperatingSystemMXBean operatingSystemMXBean = (OperatingSystemMXBean) ManagementFactory.getOperatingSystemMXBean();
long usedNativeMemory = operatingSystemMXBean.getCommittedVirtualMemorySize() - operatingSystemMXBean.getFreePhysicalMemorySize();
logger.info("used direct memory after release : " + usedNativeMemory);
Native Memory Tracking:

Total: reserved=1568938KB -1776KB, committed=300134KB -1648KB

-                 Java Heap (reserved=131072KB, committed=131072KB)
                            (mmap: reserved=131072KB, committed=131072KB)

-                     Class (reserved=1106642KB +1KB, committed=64298KB +1KB)
                            (classes #10151 +11)
                            (malloc=4818KB +1KB #15520 -36)
                            (mmap: reserved=1101824KB, committed=59480KB)

-                    Thread (reserved=43491KB, committed=43491KB)
                            (thread #43)
                            (stack: reserved=43176KB, committed=43176KB)
                            (malloc=138KB #214)
                            (arena=177KB #84)

-                      Code (reserved=254419KB -13KB, committed=27959KB +115KB)
                            (malloc=4819KB -13KB #7503 -10)
                            (mmap: reserved=249600KB, committed=23140KB +128KB)

-                        GC (reserved=8265KB, committed=8265KB)
                            (malloc=3469KB #215 +10)
                            (mmap: reserved=4796KB, committed=4796KB)

-                  Compiler (reserved=219KB, committed=219KB)
                            (malloc=89KB #251 +13)
                            (arena=131KB #3)

-                  Internal (reserved=8333KB -1805KB, committed=8333KB -1805KB)
                            (malloc=8301KB -1805KB #13563 -167)
                            (mmap: reserved=32KB, committed=32KB)

결과를 확인해봤을 때 인터널 메모리 값도 사용 이후 적절히 해제되는 것으로 사료된다. 메모리 dump를 떠서 봤을 때도 위와 같이 전문이 지속적으로 찍히는 현상은 일어나지 않는 것으로 보인다. 당분간의 모니터링은 더 필요하겠지만, memory 분석을 이쯤에서 마쳐도 될 것 같다.