본문 바로가기
개발이야기

mac M1에서 메모리 힙 덤프 떠서 분석하기

by janiiiiie 2023. 12. 10.
반응형

 

 

이전 회사에 다닐 때 카테고리 구조를 바꾼 큰 프로젝트가 배포가 나갔는데요, 그 때 같이 변경 되었던 부분이 실시간 polling 기능이었습니다.

 

전 회사는 미술품 경매가 주 사업이었는데요, 응찰 할 때마다 가격 갱신이 즉시반영이 되지 않아 고민 끝에 socket에서 1초 polling 방식으로 변경했습니다. 이 기능은 목록 조회, 상세페이지 조회 시 자동으로 1초마다 현재 최고가 데이터를 갱신합니다.

 

그런데 그 이후, 서버 어플리케이션이 이틀에 한번씩 갑자기 OOM이 발생하여 다운되는 현상이 지속됩니다. 개발자 전원이 다 붙어서 확인을 해보지만 별다른 성과가 없자, 메모리 덤프를 떠서 분석을 해보자는 의견이 나오게 됩니다!

 

 

 

서비스 환경 

저희 서비스를 아래 환경이었기 때문에 참고 부탁드립니다 :) 

  • Mac M1
  • Spring Boot
  • Java 11
  • Docker
  • AWS EC2

 

 

JDK 설치하기

당시 API 서버에는 JDK가 없고 JRE만 설치되어 있었습니다. jmap 명령어를 사용하려면 반드시 JDK가 설치되어 있어야 합니다.

서버 어플리케이션 구조상 도커 위에 떠있기 때문에 Dockerfile의 수정이 필요했습니다.

FROM /adoptopenjdk/openjdk11:alpine-jre

# AS-IS
# RUN apk add tzdata

# TO-BE: openjdk11 추가함
RUN apk add openjdk11 tzdata

RUN cp /usr/share/zoneinfo/Asia/Seoul /etc/localtime
RUN echo "Asia/Seoul" > /etc/timezone
RUN apk del tzdata


#  Add Maintainer Info
LABEL maintainer="dev@test.com"

VOLUME /app

EXPOSE 8080

ARG JAR_FILE=build/libs/grape.war

ADD ${JAR_FILE} app.war
ADD fluentbit.conf /fluentbit.conf
ADD elastic-apm-agent-1.28.4.jar /elastic-apm-agent-1.28.4.jar
ADD app.sh /app.sh
RUN chmod +x /app.sh

ENTRYPOINT ["/app.sh"]

 

 

EC2 인스턴스 접속하기

ssh -i "pem파일명" ec2-user@******.ap-northeast-2.compute.amazonaws.com

pem key 를 이용해서 API 서버가 떠있는 ec2 인스턴스에 직접 접속합니다. 

 

 

EC2 인스턴스 내 도커 환경 접속하기

sudo docker ps -a

# 아래와 같이 출력 됨, container ID로 접속
CONTAINER ID   IMAGE          COMMAND 
[containerID]  [image ID]     "/app.sh"

위에 언급한대로, 저희 API 서버는 도커 위에 올려져 있기 때문에 docker container 안으로 진입해야합니다.

container ID 조회를 위해 도커 명령어를 입력합니다.

 

 

도커 컨테이너 접속하기

# alpine 으로 설치된 openjdk 경우 /bin/sh 로 해야 접속 됨
sudo docker exec -it [CONTAINER ID] /bin/sh

중요한 점은, alpine으로 설치된 jdk의 경우에는 맨 마지막 명령어가 살짝 다릅니다. 참고해주세요!

 

 

Java application PID 번호 조회하기

ps -fea|grep -i java

# 아래와 같이 출력 됨, 7번이 자바 어플리케이션 PID
7   root    4:12 java -Xms2g -Xmx2g -javaagent:~~~~~
255 root    0:00 grep -i java

이제 도커 환경 내에 자바 어플리케이션의 PID 번호를 알아야 합니다. 

서버 구조상 저희는 EC2 접속 -> 도커 컨테이너 접속 -> 자바 어플리케이션 접속 

이런 순으로 들어가게 된 것입니다!

 

 

heap 덤프파일 뜨기

jmap -dump:live,format=b,file=test-heap.hprof 7

# 아래와 같이 덤프파일 생성되었다고 뜸
Dumping heap to /test-heap.hprof ...
Heap dump file created [292651726 bytes in 2.561 secs]

현재 실시간 힙 덤프 상태를 위해 live로 명시하고 file= 이하는 힙 덤프 생성 후 지정할 파일명입니다. 확장자는 .hprof 입니다!

 

 

ls 명령어로 쳤을 때, 명시한 파일명으로 힙 덤프파일이 잘 생성되었습니다!

 

 

 

heap 덤프파일을 도커 환경에서 EC2 인스턴스로 전송하기

이제 힙 덤프 파일을 제 컴퓨터 로컬로 내려받아야 합니다. 저희는 도커 위에서 구동되고 있기 때문에 바로 내려받을 수 없습니다. 도커 컨테이너 -> EC2 인스턴스로 전송을 먼저 하도록 합니다.

# docker 환경 나가기
exit

# sudo docker cp [컨테이너 ID]:/[파일명] [전송할 위치]
sudo docker cp a77777777777:/test-heap.hprof .

 

먼저 도커 컨테이너를 빠져 나온 상태에서 도커 컨테이너 내 힙 덤프 파일을 EC2 인스턴스로 전송하는(cp) 명령어를 입력합니다.

 

 

 

EC2 인스턴스 내 파일이 제대로 전송되었는지 확인

ls

# 아래와 같이 출력 됨
[ec2-user@ip ~]$ ls
test-heap.hprof

# 덤프파일 권한 주기
sudo chmod 777 test-heap.hprof

# 확인되면 ec2 인스턴스 나가기
exit

ls 명령어로 쳤을 때 도커 컨테이너 내에 만든 힙 덤프파일이 정상적으로 EC2 인스턴스로 이동되었습니다! 이제 이 파일을 내 컴퓨터 로컬로 다운을 받아야 합니다.

 

 

로컬 환경에서 EC2 인스턴스 내 파일 다운로드 받기

# scp -i [pem 파일] [다운로드할 파일명] [ec2인스턴스 주소]:~/[파일위치 및 파일명] [로컬 다운로드 위치]
scp -i "pem파일명" test-heap.hprof ec2-user@******.********compute.amazonaws.com:~/test-heap.hprof /Users/jane

# 아래와 같이 다운로드 바가 생김
blacklot-test-heap.hprof    100%  260MB   9.0MB/s   00:28

다운로드 받을 때에는 터미널에서 scp 명령어를 사용합니다. 직접 EC2 인스턴스로 접근을 해야하기 때문에 pem key는 필수입니다. 

이렇게 명령어를 치면 다운로드 바가 생기게 되고, 내가 지정한 위치에 다운로드 된 .hprof 파일이 보이게 됩니다.

 

 

 

 

 

인텔리제이에서 간단하게 heap 파일 열어보기

.hprof 파일을 열기 위해서 2가지 방법이 있습니다. 만약 인텔리제이를 사용하고 있다면 편하게 힙 덤프파일을 열어볼 수 있습니다.

File > Open... 클릭 > 힙 덤프 파일 클릭

 

파일만 열기를 누르면 손 쉽게 힘 덤프 내용을 볼 수 있습니다!!!

 

 

 

Memory Analyzer 설치하기

또 다른 방법은 Memory Analyzer를 설치하여 열어보는 방법입니다. 

Eclipse mat 이라는 프로그램 활용하여 다운로드 받아 설치했습니다.

 

MemoryAnalyzer.ini 파일 수정

MemoryAnalyzer는 다운로드 받아도 바로 실행되지 않습니다. 아래 순서대로 파일 속성을 변경해주어야 합니다.

  • 다운로드 후, dmg 파일 더블클릭 하면 창이 띄워짐
  • 이때 프로그램이 바로 정상실행 되지 않음
  • 파일 내 mat을 응용프로그램 디렉토리로 이동

 

오른쪽 우클릭 > 패키지 내용 보기 클릭 > Contents > Eclipse > MemoryAnalyzer.ini 파일 열기 > 코드 수정 후 저장

 

-startup
../Eclipse/plugins/org.eclipse.equinox.launcher_1.6.400.v20210924-0641.jar
--launcher.library
../Eclipse/plugins/org.eclipse.equinox.launcher.cocoa.macosx.aarch64_1.2.700.v20221108-1024
-vmargs
--add-exports=java.base/jdk.internal.org.objectweb.asm=ALL-UNNAMED
-Xmx1024m
-Dorg.eclipse.swt.internal.carbon.smallFonts
-XstartOnFirstThread

# 아래 코드 추가해야함
-vm /usr/bin/java # which java 로 나오는 경로 그대로 넣으면 됨
-Dosgi.requiredJavaVersion=11 # 현재 PC에는 jdk11이 깔려있기 때문에 자바 버전 맞춰주기

 

 

 

mat 실행시키기

  • 저장 후 닫은 다음 더블클릭해서 실행하면 성공
  • open heap dump 해서 덤프받은 파일 열면 됩니다!!

요렇게 오픈 완료~~

 

 

그래서 메모리 누수 원인은?

SessionDTO가 원인이었습니다. (SessionDTO는 유저 정보를 담는 객체입니다.)

기본적으로 JWT 토큰 방식의 로그인이라면 세션을 사용하지 않기 때문에 무상태를 유지해야합니다.

Spring Security에 STATELESS 설정이 되어있지 않아, SessionDTO를 기본으로 받는 API를 호출할 때 불필요하게 세션을 차지해서 발생하였습니다.

 

최근 1초 pooling 방식으로 포팅이 있으면서 더더욱 가중된 것으로 보입니다.

@Configuration
@EnableWebSecurity
@Order(1)
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Override
    public void configure(WebSecurity webSecurity) throws Exception {
        webSecurity.ignoring()
                .antMatchers("/", "/test/test");
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {

        final TokenAuthenticationFilter tokenFilter = new TokenAuthenticationFilter();

        http.csrf().disable().addFilterBefore(tokenFilter, BasicAuthenticationFilter.class)
                .authorizeRequests()
                .antMatchers("/").permitAll()
                .antMatchers("/test/**").permitAll()
                .antMatchers("/swagger-ui/**").permitAll()
                .antMatchers("/favicon.ico").permitAll()
                .antMatchers("/api/common/**").permitAll()
                .antMatchers("/api/batch/**").permitAll()
                .antMatchers("/api/").access("hasAuthority('MEMBER')")
                .antMatchers("/api/v2/**").permitAll()
                .antMatchers("/api/order/**").access("hasAuthority('MEMBER')")
                .antMatchers(HttpMethod.OPTIONS, "/**").permitAll()
                .antMatchers("/api/partner/**").access("hasAuthority('PARTNER')")
                .anyRequest().authenticated()
                .and()
                .logout().permitAll().logoutRequestMatcher(new AntPathRequestMatcher("/api/logout"))
        ;

		// 이 부분에 대한 설정이 없었음
        http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
    }

 

 

 

반응형