Web
수정·

[JVM] WarmUp 을 위해 확인해야하는 로그

by Dohoon Kim · 24년 12월 23일 03:48:23

WarmUp을 위해 확인해야하는 로그

2023년 숏폼 플랫폼 개발 당시 최초로 스프링부트를 이용하였고, 이를 이용해 거의 모놀리식으로 서비스를 작성하던 중 이상한 현상이 발견되었다. bootRun으로 로컬 환경에서 실행하여 Postman과 같은 툴로 실제 요청을 보낼 때는 곧바로 응답이 오는데 AWS EKS에 배포된 어플리케이션은 실행 직후 최초 요청에서 3초 이상의 지연이 발생했다.

이 현상을 발견한 직후에 인스턴스에 문제가 있나? 라는 생각으로 Grafana를 켜서 해당 서비스가 배포되는 인스턴스들의 하드웨어 상태를 확인하였다. CPU 점유율은 10% 미만, 8GB의 메모리 용량중 1.5GB만 사용, 네트워크 대역폭은 남아돌았으며, DiskIO는 당연히 적었다. bootRun으로 실행할 때는 즉각 응답이 왔으니, 네트워크를 이용하는 계층(데이터베이스 or Redis)에 문제가 있을거라 생각했지만 이 부분도 아니었다. 이 때가 말로만 알고 있었던 JIT가 실제 서비스에 어떤 영향을 끼치는지 처음 경험한 사례였다.

당시의 대응

해결 방법은 ApplicationRunner 인터페이스를 상속받아, 최초 실행 시 WarmUp을 이용하는 방식을 선택했다. 그리고 WarmUp관련 컨픽을 생성하여 어플리케이션이 준비가 되었는지 확인할 수 있도록 하고, readiness 엔드포인트를 만들어 쿠버네티스에서 이를 확인할 수 있도록 하였다.

@Configuration
public class WarmUpConfig() {
    private final AtomicBoolean isReady = new AtomicBoolean(false);
    
    public void setIsReady(boolean value) {
       isReady.set(value);
    }
    
    public boolean getIsReady() {
        return isReady.get();
    }
}
//----
@Configuration
public class ApplicationConfig {

    @Bean ApplicationRunner warmUpRunner(WarmUpConfig warmUpConfig) {
        return args -> {
            ... // warm up logic 메서드 실행
            warmUpConfig.setIsReady(true);
        }
    }
}
//----

@RestController
@RequestMapping("actuator/")
public class ReadinessController {
    private WarmUpConfig warmUpConfig;
    
    @GetMapping("/readiness")
    public ResponseEntity<String> readiness() {
        if(warmUpConfig.getIsReady()) {
            return ResponseEntity.ok("Ready")
        }
        return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).body("Not ready")
    }
}

이런 로직들을 작성해두고, 최종적으로 Kubernetes의 Deployment를 정의한 YAML 파일 중 컨테이너 정의에 다음과 같은 부분을 추가했다.

readinessProbe:
  httpGet:
    path: /actuator/readiness
    port: 8080
  initialDelaySeconds: 10 # 파드가 시작된 이후 얼마 뒤부터 요청을 보낼것인가?
  periodSeconds: 5       # 확인 주기
  timeoutSeconds: 2      # 타임 아웃
  failureThreshold: 5    # 실패 허용 횟수

실제 WarmUp을 수행하는 로직에는 컨트롤러 -> 서비스 -> 데이터베이스 호출까지 되도록이면 모두 호출되도록, 내부에 시딩한 데이터를 대상으로 호출을 반복적으로 진행하는 로직을 작성해두었고 결과적으로 응답시간이 크게 단축되었다.

좀 더 살펴보기

퇴사 이후 정돈되지 않은 지식들을 정리하면서, JVM에 관한 공부를 같이 병행하고 있다. 결국은 JIT은 단계적 최적화(Tiered compilation) 방식을 따르고 있다. 각 단계에 필요한 호출 횟수(임계치)를 만족하면 다음 단계의 최적화를 수행하는 방식이다.

레벨은 5가지 단계로 나누어진다

  • 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 - 최대 최적화

Level 1~3 는 C1 Compiler에 의해 수행되고, Level4 는 C4 컴파일 단계이다. 이렇게 코드가 컴파일 되면 코드 캐시에 저장이 되게 된다. 이 코드 캐시는 JVM 메모리 영역 중 메서드 영역에 저장되게 된다. 메서드 영역의 크기는 당연히 제한되어 있다.

만약 어플리케이션이 굉장히 크다면? 당연히 이 영역의 메모리가 부족해질 가능성이 있다.

실제로 최적화 과정 중 메모리가 부족하면 다음과 같은 로그가 발생하게 된다.

CodeHeap[Name] is full. Compiler has been disabled.
Try increasing the code heap size using -XX:ReservedCodeCacheSize=......

컴파일러가 중단되며, JVM 실행 옵션을 추가하여 코드 캐시의 크기를 증가시키라는 가이드를 제공해준다. 코드 캐시 관련 JVM 옵션은 다음과 같다.

-XX:ReservedCodeCacheSize // 코드캐시의 최대 사이즈를 지정한다.
-XX:InitialCodeCacheSize // 초기 코드캐시 사이즈

위에 언급했듯 C1/C2 컴파일러들은 각 단계의 임계치를 넘는 호출이 발생하면 최적화를 수행한다. 해당 옵션은 다음과 같은 CLI 명령어로 확인이 가능하다.

$ java -XX:+PrintFlagsFinal -version | grep Threshold | grep Tier
openjdk version "19.0.2" 2023-01-17
OpenJDK Runtime Environment (build 19.0.2+7-44)
OpenJDK 64-Bit Server VM (build 19.0.2+7-44, mixed mode, sharing)
    uintx IncreaseFirstTierCompileThresholdAt      = 50                                        {product} {default}
     intx Tier2BackEdgeThreshold                   = 0                                         {product} {default}
     intx Tier2CompileThreshold                    = 0                                         {product} {default}
     intx Tier3BackEdgeThreshold                   = 60000                                     {product} {default}
     intx Tier3CompileThreshold                    = 2000                                      {product} {default}
     intx Tier3InvocationThreshold                 = 200                                       {product} {default}
     intx Tier3MinInvocationThreshold              = 100                                       {product} {default}
     intx Tier4BackEdgeThreshold                   = 40000                                     {product} {default}
     intx Tier4CompileThreshold                    = 15000                                     {product} {default}
     intx Tier4InvocationThreshold                 = 5000                                      {product} {default}
     intx Tier4MinInvocationThreshold              = 600                                       {product} {default

Tier3는 C1의 Lv3, Tier4는 C2의 Lv4 를 나타낸다. Prefix를 제외하고 각 임계치의 의미는 다음과 같다.

  • InvocationThreshhold: 한 메서드의 호출 횟수에 대한 임계치
  • BackEdgeThreshold: 한 메서드 내 반복문 횟수의 임계치
  • CompileThreshold: 한 메서드 호출 횟수와 메서드 내 반복문 횟수의 합을 기준으로 한 임계치

이 임계치를 넘게되면 각 단계의 최적화가 수행된다. C2 컴파일러에 할당될 스레드 수도 JVM 실행 옵션으로 조정이 가능하다.

$ java -XX:CICompilerCount ...

세부적인 조절

코드 최적화 과정은 서비스 머신의 리소스를 사용한다. 머신의 성능이 떨어진다면 이런 컴파일 과정의 단계의 상한을 지정할 수도 있다. 서비스 자체의 성능과의 타협을 하는 것이다.

java -XX:-TieredCompilation ... # 특정 단계의 최적화를 제외한다.
java -XX:TieredStopAtLevel=N ... # 최적화 단계를 N까지로 제한한다.

예를들어 C2 컴파일러가 수행되는 시점에서 CPU 사용률에 스파이크가 발생한다면, Level 3까지만 수행하도록 제한할 수 있으며 이 때는

java -XX:TieredStopAtLevel=3 ...

와 같이 실행 옵션을 넣어야한다.