[JVM] WarmUp 을 위해 확인해야하는 로그
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 ...
와 같이 실행 옵션을 넣어야한다.