본문 바로가기

Conference

[Conference] JVM warm up

컨퍼런스 영상 : [Backend] JVM warm up / if(kakao)dev2022

 

 

* 해당 블로그의 모든 내용과 사진은 위 영상을 참고하여 작성하였습니다.

 

이슈

배포할 때 발생한 응답 Latency 문제를 어떻게 해결했는지, 그 과정에서 알게된 JVM jit compilerwarm up에 대해 소개합니다.

 

담당 서버 소개

담당하고 계신 서버는 카카오 T의 계정 서비스입니다. 해당 서비스는 카카오 T 앱 사용자의 가입, 휴면, 정지 및 탈퇴 절차에 관여하며 개인정보를 저장합니다. 이를 암호화 및 분리보관하여 보안적으로 문제없이 사용자가 서비스를 사용할 수 있게 합니다. 외부 게이트 웨이 뿐만아니라 내부 서비스에서도 요청이 많아 응용되는 Micro Server 중 TPS 가 높은 서버 중 하나이고, 더욱 응답 속도가 중요합니다.

 

 

담당서버에서 자바 언어를 많이 사용하다 보니, 자바 언어에 대해 살펴볼 필요가 있습니다. 

 

 

자바 코드에 대해 Byte 코드로 컴파일해야합니다. 빌드된 파일을 실행하게되면 JVM에서는 Byte 코드를 번역하여 기계어로 만들고, 이 기계어를 cpu에서 처리하는 절차를 가집니다.

이렇게 빌드된 Byte 코드는 별도의 추가 빌드가 필요없이 자바가 실행가능한 cpu 아키텍처, os에서 실행 할 수 있는 장점이 있습니다. 이렇듯 자바는 컴파일과 인터프리터에 의해 실행됩니다.

 

 

그러다보니 컴파일 과정에서 바로 기계어를 만드는 C, C++, GoLang 과같은 언어들에 비해 성능이 뒤쳐지게 됩니다. 이유는 이런 컴파일 언어들은 런타임 환경에서 준비된 기계어를 즉시 실행 가능하기 때문입니다. 

 

컴파일을 통해 기계어를 만들때 코드 최적화 또한 하게되어 인터프리터언어보다 더 빠른 성능을 보장할 수 있습니다.

다만 컴파일을 통해 생성된 기계어는 빌드 환경인 cpu 아키텍쳐에 종속적이기 때문에, 다른 아키텍쳐에서 실행하고 싶다면 빌드를 다시해야하는 단점이 있습니다.

 

 

위의 내용을 들었을땐 컴파일 언어가 성능적으로 더 좋습니다. 그러면 자바는 느리기만 한 것일까요?

 

JIT Compiler

위와 같은 성능차이를 해결하기 위해 자바에서는 즉시 기계어를 만드는 JIT Compiler를 제공하고 있습니다.

 

 

JIT Compiler 는 Byte 코드를 기계어로 변환하는 과정에서 기계어를 Cache에 저장하고 활용하게됩니다.

반복되는 기계어 변환과정을 줄이게되어 성능을 높여주고, 런타임 환경에 맞춰 코드를 최적화도 합니다.

JIT Compiler 가 자바 성능향상에는 도움이 되지만 어플리케이션이 시작하는 단계에서는 Cache된 내역들이 없기에 성능이슈가 있습니다. 

 

 

그래서 어플리케이션 시작 후 의도적으로 미리 로직을 실행하여 기계어가 Cache 에 저장되고 최적화될 수 있게하는 Warm up 절차가 필요합니다.

 

계정 서비스 배포 시 발생한 Latency 이슈

계정 서비스로 요청되는 트래픽은 매달 10 ~ 20% 씩 증가하고있었습니다.

그에 따라 동작 지연이슈가 발생했습니다.

 

 

계정 서비스는 Kubernetes에서 운영되며 다수의 pot에 대해 롤링 업데이트 방식으로 배포가 진행됩니다.

그래서 pot이 순차적으로 배포가 되기 때문에 그래프와 같이 지연 또한 순차적으로 나타나게 됐습니다.

 

병목을 찾자

CPU는 10% 이하로 운영되고 있었으며, 메모리는 60% 이하, Network bandwidth는 worker node당 10~20MB 수준으로 모두 여유가 있는 상태였습니다. TPS는 배포 전과 비슷한 수치였습니다.

 

 

어플리케이션 모니터링을 위한 APM에서 응답 지연당시 요청들을 봤는데 외부 데이터베이스에서의 지연은 확인 되지 않았습니다. 

대부분은 어플리케이션 영역이었는데, 도메인 로직에대한 지연도 발견되지 않았습니다.

 

 

이번에는 트래픽을 처리하는 Tomcat WAS의 Thread 개수를 확인해 보았습니다.

 

 

10개로 시작하여 최대 8192개 까지 늘어날 수 있도록 설정이 되있었습니다.

 

그리고 Database에 대한 연결은 RDB의 connection pool이 20개로 시작하도록 되있었고, 서버 시작 이후에도 큰 변화가 없었기에 문제가 없는것으로 확인 되었습니다.

 

 

남은 것은 자바로 구현된 계정 API에 JVM Warm up이 준비가 잘 되있는지 확인할 차례입니다.

 

 

서버 시작과정을 살펴보면 처음에는 Kubernetes의 Readiness probes 요청에 대해 400 응답코드를 돌려주게 됩니다.

Application Ready 이벤트가 발생하고 3초의 지연을 갖게되는데, 그 후로는 Readiness 요청에 200 응답을 주도록 하여 Traffic이 인입되도록 되어있습니다.

 

 

 

Warm up 동작을 살펴보면 liveness / readiness probe 요청이 있을 시 데이터베이스에 정해진 정보를 질의하도록 되어있는데요. 

 

 

의사 코드를 살펴보면 liveness / readiness probe 요청을 처리하는 컨트롤러에서 warmer 메서드를 호출하게 되고, warmer 메서드에서는 데이터베이스의 정해진 데이터를 질의하게 됩니다.

 

현재 구현된 warm up은  실 서비스에서 사용되는 api가 아닐뿐더러 해당 api의 내용도 특정 데이터 조회 호직이 한정되어 충분한 warm up 과정이 이루어지고 있지 않다고 판단됩니다.

 

 

지금까지 분석된 내용을 요약하면 첫번째로 각 pot의 tomcat Thread를 200여개로 시작할 수 있도록 개선하는 것이 필요하고, JVM Warm up 과정은 실제 사용하는 api를 localhost로 호출하되 리얼 트래픽과 같은 유사한 요청이 되도록 개설하는게 필요해 보입니다.

 

 

Warm up 개선 방식은 기존과 같이 liveness / readiness probe 요청과정에서 처리하게 되고, 또한 Kubernetes readiness probe 요청에 대해 400 응답을 줌으로써 외부 Traffic이 유입되지 않게 지연을 주게되고, warm up 절차가 완료되면 그 이후 readiness probe 요청에대해 200을 주어 Traffic이 유입될 수 있게 합니다.

 

 

예전에는 정해진 시간 만큼만 지연시키고 Traffic을 인입시켰다면 이제는 warm up이 완료되어야 Traffic이 인입되게 개선하였습니다.

 

 

warm up은 각 pot마다 localhost 로 api 요청을 하도록 변경하였습니다. 대부분 GET api를 호출하였습니다.

 

 

Thread 시작개수, localhost 기반 warm up 의 조치를 시행하여 문제 되었던 초기 응답 지연이 사라짐을 확인하였습니다.

 

하지만 2달이라는 시간이 흐른뒤, 비슷한 문제가 발생하였습니다.

TPS가 5000정도로 이전 트래픽 대비 20%가 높아진 상태에서 배포하니 초기 응답 지연현상이 나타나기 시작하였습니다.

 

다양한 아이디어를 내본 끝에 실제트래픽과 같이 많은 수의 warm up을 시도해 보려 Warm up count를 상당 수 늘리고 나서야 내부 재현환경에서 응답 지연문제가 해소되는것을 발견할 수 있었습니다.

 

어떻게 해소가 되었을까요? 이를 이해하기 위해선 JIT 내부동작을 알아봐야합니다.

 

JIT Internals

JIT은 메서드 전체 단위로 컴파일을 합니다. 예를들어 메서드 내의 모든 Byte 코드들은 한꺼번에 Native 코드로 컴파일됩니다.

Native 코드로 전환 후 후속 최적화 작업을 위해 Profiling 정보를 수집합니다.

Tiered Compilation이라는 단계적 컴파일을 통해 코드 최적화를 진행합니다.

Tiered Compilation은 C1과 C2로 이루어지는데, C1은 간략한 최적화를 진행하게되고, C2 단계에서는 최대 최적화를 하여 코드 캐시에 저장하고, 활용하므로써 코드의 실행속도를 높입니다.

 

 

그림으로 살펴보면 

1. 인터프리터에 의해 Byte가 기계어로 번역

2. 해당 메서드가 정해진 임계치 설정만큼 호출되면 C1 컴파일러를 통해 최적화됩니다.

3. 그 후 C2 컴파일러의 임계치 설정만큼 해당 메서드가 더 많이 호출되면 최대 최적화를 진행하게됩니다.

 

Compilation Level

Level 0 interpreted code
Level 1 simple C1 compiled code
Level 2 limited C1 compiled code
Level 3 full C1 compiled code
Level 4 C2 compiled code

 

Compilation 은 다수의 Level로 나누어 표현 할 수 있습니다.

Level 0는 Byte를 최적화 없이 기계어로 변경하는 단계입니다.

 

Level 1 더 이상 최적화가 불필요하다고 느끼는 간략한 코드들을 컴파일 하는 단계를 말하며 추가적인 최적화를 하지 않을 것이기 때문에 Profiling 정보 또한 수집하지 않습니다.

Level 2 제한된 최적화를 진행하는 단계입니다.

Level 3 Profiling 모드로써 정보를 수집하고 최적화를 진행하는 단계입니다.

 

Level 4 최대 최적화를 진행하여 성능을 보장하고 계정 서비스에서 발생한 지연 문제를 해결하는데 있어 중요한 역할을 합니다.

 

 

C1에서는 Level 3단계로 최적화를 진행하여 이를 Code Cache에 저장합니다.

C2에서는 Level 4를 통해 최대 최적화를 진행한 후 Code Cache에 다시 저장합니다.

 

이 과정에서 C1과 C2는 각각 Thread를 가지고 있고, 별도로 동작하게 되어있습니다.

여기서 C2 컴파일러 Queue가 가득 차게되면 담겨져있는 메서드를 꺼내 C1 Level 2로 컴파일하게 됩니다.

이 후 Queue에 여유가 생기게되면 다시 Level 3, Level4 까지 컴파일이 되는 절차를 밟습니다.

 

따라서 Level 2가 실행되고 있다면 C2 컴파일 Queue가 가득 차있다고 판단할 수 있어 C2 컴파일러 Thread 수를 조정할 필요가 있습니다. 

 

 

어플리케이션 실행 단계에서 JVM 옵션으로 초기 코드 Cache 와 최대 사이즈를 조절할 수 있습니다.

 

 

만일 코드 Cache가 가득 차게되면 더 이상 성능상의 이득을 기대할 수 없기에 위와같이 Cache full message를 본다면 크기를 늘려주어야합니다.

 

 

다음과 같이 설정된 임계치값을 확인할 수 있습니다. 

여기서 Tier를 level로 바꿔서 생각해주시면 좋을것 같습니다.

 

InvocationThreshold : 메서드의 호출 수

BackEdgeThreshold : 하나의 메서드 내 반복문 횟수

CompileThreshold : InvocationThreshold + BackEdgeThreshold

 

여기서 메서드의 호출 수와 하나의 메서드 내 반복문 횟수가 설정된 기준을 넘게되면 각 Level에 맞게 최적화를 진행합니다.

 

C1 / C2 Compile Count

 

서비스 실행 단계에서 warm up count를 변경하여 테스트를 진행해본 결과 각각의 C1, C2 컴파일러가 얼마나 실행되는지 확인할 수 있었습니다. 

증가하는 횟수에 따라 C1, C2 최적화가 더 많이 실행되는 줄은 알았지만 그만큼 warm up 시간도 오래걸리기에 적절한 횟수 선택이 필요합니다.

 

 

 

최종조치로 warm up count를 늘려 배포를 진행하였고, 응답지연현상이 해소 되었습니다.

 

간단 요약

먼저 JIT Compiler란 무엇인지 왜 사용하였는지를 설명하기 위한 개념설명을 시작으로

liveness / readiness probe 요청에 따른 응답 전략을 바꾸어 초기응답지연현상을 해결한 사례

또 다시 초기응답지연현상이 발생했을 때, 해결하기 위한 warm up count 증가

warm up count를 정하기 위한 JIT Compiler의 내부동작의 이해입니다.

 

여기서 핵심이 되는 warm up count를 이해하지 못하신 분들을 위해 간단하게 소개하면 사람은 운동을 할 때 준비운동으로 예열을 하게됩니다. 마찬가지로 예열 작업을 통해 Cache를 이용한 최적화를 할 수 있게 하는 것이라고 이해하시면 좋을것 같습니다.