쿠버네티스가 스프링부트 3.0 네이티브 이미지를 만났네

🧐 | 2023-10-26

안녕하세요, 넷마블 QA실 QA시스템팀 이동근입니다.

크래시리포트

QA실에서는 ‘크래시리포트’라는 시스템을 운영하고 있습니다. 크래시리포트는 게임 실행 과정에서 예상치 못한 종료 현상이 발생할 때, 그 상황을 저장한 데이터를 크래시라 합니다. 크래시 데이터를 통계화하여 시각화 형태로 사용자에게 제공하는 시스템입니다. 사내에서만 쓸 수 있으며, 크래시리포트 웹페이지와 SDK를 제공하고 있습니다.

시스템 구조

게임 실행 과정에서 발생한 크래시 데이터는 크래시리포트 SDK를 통해, 크래시리포트 엣지 서버 클러스터로 전달됩니다. 엣지 서버 클러스터는 쿠버네티스(Kubernetes, K8S)로 구성했습니다. 데이터 인입량 증가를 대비해 HPA(Horizontal Pod Autoscaling)에는 CPU 자원 요청 조건을 기준으로 노드 및 파드(POD)를 증설하도록 설정했습니다.

신규 파드 추가 소요 시간을 줄여야 한다

간단히 생각해보면, 크래시리포트로 들어오는 데이터는 게임 사용자 수에 비례합니다. 맨 처음 쿠버네티스 환경으로 옮겼을 땐 파드 몇 개로 시작해야 적당할지가 궁금했었죠. 파드 갯수 자체는 HPA를 사용해 CPU 자원 요청 비율을 기준으로 조절하기로 하니 큰 문제는 아니었습니다. 정작 대면한 문제는 신규 파드가 추가되는 소요 시간이었습니다.

크래시리포트 운영용으로 마련한 엣지 서버 클러스터 환경에서는 신규 파드 추가마다 최소 1분 이상 필요해습니다. 게임 사용자가 언제 급증할지 예측할 수 없기에, 스케줄에 맞춘 확장도 적합하지 않았습니다. 또한, 서버에 접속하는 클라이언트의 통신 연결 대기 시간은 대략 10~20초로 설정돼 있어서, 신규 파드를 준비하기 위해 소모하는 1분 동안 누락되는 데이터도 늘어날 수밖에 없었습니다.

즉, 단순히 15초 이내에 노드와 파드를 증설할 수 있는 환경이 필요했습니다.

스프링부트 3.0

지난 2022년 11월 24일, 스프링부트 3.0(Spring Boot 3.0)이 정식 배포됐습니다. 스프링부트 3.0에서는 최소 지원 자바(Java) 버전은 17로 변경됐으며, GraalVM을 사용한 네이티브 이미지(Native Image) 생성 기능이 추가됐습니다.

여기서 등장한 GraalVM(TheGeneral Recursive Applicative and Algorithmic Language Virtual Machine)은 고성능 JDK이며, 네이티브 이미지 빌더이기도 합니다. 특히, 네이티브 이미지를 생성하는 기능은 2019년 9월부터 진행된 프로젝트에서 기능을 흡수 통합해 정식 지원하는 기능이 됐습니다.

네이티브 이미지

특징

네이티브 이미지는 독립적으로 실행할 수 있도록 자바 코드를 빌드하는 기술입니다. JVM(Java Virtual Machine)도 네이티브 이미지 안에 포함되기 때?문에, 실행을 위해 JDK나 JRE가 필요하지 않습니다. 그저 실행할 플랫폼에 맞춰서 빌드하기만 하면 되죠. 사용할 라이브러리만 잘 정하면 됩니다. (참고로, 오라클 리눅스용으로 빌드한 네이티브 이미지는 알파인 리눅스에서는 실행되지 않습니다.)

장단점

네이티브 이미지는 실행 환경에 맞춰서 빌드하기 때문에, 컨테이너처럼 제한된 실행 환경에서는 큰 힘을 발휘합니다. 또한, 네이티브 이미지로 만든 프로그램은 서비스를 제공하기 위한 초기 준비 시간도 짧고 메모리 사용량도 적습니다.

하지만, 자바 코드를 네이티브 이미지로 컴파일할 때는 자바의 주요 특징이자 장점인 동적으로 할당하고 실행하던 기능을 포기해야 합니다. 모두 사전 정의해서 컴파일할 때 참조할 수 있도록 해야 합니다.

위 이미지에 나온 Metadata는 GraalVM에서 JSON 힌트 파일(Hint files)에 해당합니다.

네이티브 이미지 생성하기

네이티브 이미지는 spring initializr(https://start.spring.io)에서 프로젝트를 추가할 때, 디펜던시에 ‘GraalVm Native Support’를 추가하면 생성할 수 있습니다.

네이티브 이미지 배포 방식 도입

크래시리포트 엣지 서버의 HPA는 최소 15에서 최대 30으로 설정했습니다. 그래서 실제 서비스 가동 시에는 CPU 리소스 소모에 맞춰서 빈번하게 오토스케일링이 발생합니다.

아래 그림은 2023년 8월 18일 금요일 오전 6시부터 8월 19일 밤 12시 사이에 발생한 오토스케일링 동작 결과입니다. 2023년 8월 18일 금요일 오후 6시부터 8월 19일 새벽 3시 사이에 소모한 자원이 늘어났다가 줄어들었음을 확인할 수 있습니다. 오토스케일링이 발생한 시간 이외에는 최소 파드로도 큰 무리 없이 크래시리포트를 운영할 수 있다는 의미가 되므로, 일시적인 최대 요청을 맞추기 위해 파드를 사전에 더 늘려두고 운영한다면 자원 낭비로 인한 비효율이 생긴다고 할 수 있습니다.

K8S 클러스터 동작은 큰 무리가 없음을 확인했으나, 기존 이미지 배포 방식하에서는 오토스케일링이 동작하며 자원을 증설하는 시간 동안에는 클라이언트의 짧은 접속 타임아웃 시간으로 인해 데이터 유실이 발생했습니다. 그래서 약 1분 넘게 걸리는 이미지 배포 시간을 타임아웃 이내로 줄일 수 있도록, 네이티브 이미지 배포 방식을 도입해 보기로 했습니다.

스프링부트 3.0으로 마이그레이션

네이티브 이미지로 만들기 위해서 최우선으로 기존의 스프링부트2 프로그램을 스프링부트3으로 마이그레이션 해야 했습니다.

마이그레이션으로 인해 엣지 서버에서 변경된 주요 내용은 다음과 같습니다.

  • Javax.*Jakarta.* 패키지로 변경
  • RestTemplate 사용해 org.apache.httpcomponents.client5:httpclient5 별도 추가
    • 스프링부트2에서 기본 제공하는 httpclient 라이브러리는 스프링부트3에서 제외됨.
  • Ehache를 사용해 jakarta 추가
    • Ehcache는 기본적으로 Javax 패키지로 작성돼 있음.
    • 스프링부트 3.0에서는 사용한 Jakarta 패키지 버전으로 변경해야 함.

네이티브 이미지 컴파일

네이티브 이미지를 컴파일하기 위해 힌트 파일을 생성해야 했습니다.. 힌트 파일은 spring-boot-maven-plugin에서 제공하는 spring-boot:process-aot 명령어를 활용해서 만들었습니다. 생성 결과는 target/spring-aot 하위에 proxy-config.json, reflect-config.json, resource-config.json, serialization-config.json 파일이 생성된 것으로 확인할 수 있었습니다.

Spring-boot:process-aot 명령어로 생성되지 않는 동적 할당 내용들은 사용자 정의 힌트 클래스를 활용했습니다. 사용자 정의 힌트 클래스를 사전 정의하면 Spring-boot:process-aot 명령어를 사용할 때 JSON 힌트 파일에 클래스에서 정의한 내용이 자동으로 추가됩니다. 사용자 정의 힌트 클래스 예시는 아래를 참고해 주세요.

import java.lang.reflect.Method;
import org.springframework.aot.hint.ExecutableMode;
import org.springframework.aot.hint.RuntimeHints;
import org.springframework.aot.hint.RuntimeHintsRegistrar;
import org.springframework.util.ReflectionUtils;
public class MyRuntimeHints implements RuntimeHintsRegistrar {
    @Override
    public void registerHints(RuntimeHints hints, ClassLoader classLoader) {
        // Register method for reflection
        Method method = ReflectionUtils.findMethod(MyClass.class, "sayHello", String.class);
        hints.reflection().registerMethod(method, ExecutableMode.INVOKE);
        // Register resources
        hints.resources().registerPattern("my-resource.txt");
        // Register serialization
        hints.serialization().registerType(MySerializableClass.class);
        // Register proxy
        hints.proxies().registerJdkProxy(MyInterface.class);
    }
}

도커파일(Dockerfile)을 활용해 네이티브 이미지를 생성하기 위한 기본 이미지는 graalvm-community 버전을 사용했고, mvn -Pnative native:compile 명령어를 실행해 컴파일했습니다. 이때 AOT 컴파일을 위해 필요했던 Pom.xml은 아래를 참고해 주세요.

<plugin>
    <groupId>org.graalvm.buildtools</groupId>
    <artifactId>native-maven-plugin</artifactId>
    <configuration>
        <mainClass>com.netmarble.meerkat.edge.EdgeServerApplication</mainClass>
        <buildArgs>
            <buildArg>-H:+ReportExceptionStackTraces</buildArg>
            <buildArg>-H:+RunReachabilityHandlersConcurrently</buildArg>
            <buildArg>--trace-class-initialization=org.apache.commons.logging.LogFactory</buildArg>
            <buildArg>--initialize-at-build-time=org.apache.commons.logging.LogFactory,org.apache.commons.compress</buildArg>
            <buildArg>-H:DynamicProxyConfigurationFiles=proxy-config.json</buildArg>
            <buildArg>-H:SerializationConfigurationFiles=serialization-config.json</buildArg>
            <buildArg>-H:ReflectionConfigurationFiles=reflect-config.json</buildArg>
        </buildArgs>
    </configuration>
</plugin>

도커 파일은 알파인 리눅스용 네이티브 이미지로 만들기 위해, 빌드와 실행의 멀티 스테이지 환경으로 작성했습니다.

# build stage
FROM ghcr.io/graalvm/graalvm-community:17-ol9 AS builder

# oracle linux 계정 세팅
RUN groupadd appgroup && useradd appuser
RUN usermod -aG appgroup appuser
USER appuser

ARG DOCKER_BUILD_RES=src/main/resources/Dockerfile
ARG USER_HOME=/home/appuser

# musl 설치
WORKDIR $USER_HOME
COPY $DOCKER_BUILD_RES/x86_64-linux-musl-native-10.2.1.tgz $USER_HOME
RUN tar xvfz x86_64-linux-musl-native-10.2.1.tgz
ENV MUSL_HOME=$USER_HOME/x86_64-linux-musl-native

# zlib 설치
WORKDIR $USER_HOME
COPY $DOCKER_BUILD_RES/zlib-1.3.tar.gz $USER_HOME
RUN tar -xvf zlib-1.3.tar.gz
WORKDIR $USER_HOME/zlib-1.3
RUN ./configure --prefix=$MUSL_HOME --static
RUN make
RUN make install

# upx 설치
WORKDIR $USER_HOME
COPY $DOCKER_BUILD_RES/upx-4.1.0-amd64_linux.tar $USER_HOME
RUN tar -xvf upx-4.1.0-amd64_linux.tar
ENV UPX_HOME=$USER_HOME/upx-4.1.0-amd64_linux

# maven 설치
WORKDIR $USER_HOME
ARG MAVEN_VERSION=3.9.4
COPY $DOCKER_BUILD_RES/apache-maven-$MAVEN_VERSION-bin.tar.gz $USER_HOME
RUN tar -xvf apache-maven-$MAVEN_VERSION-bin.tar.gz
ENV MAVEN_HOME $USER_HOME/apache-maven-$MAVEN_VERSION
ENV MAVEN_CONFIG $USER_HOME/.m2

# SET PATH
ENV PATH=$PATH:$MUSL_HOME/bin:$MAVEN_HOME/bin:$UPX_HOME

# dependency caching
WORKDIR /home/appuser
COPY pom.xml .
RUN mvn -B dependency:resolve
# source 복사 & package
COPY src/main/ src/main/
COPY src/main/resources/proxy-config.json proxy-config.json
COPY src/main/resources/serialization-config.json serialization-config.json
COPY src/main/resources/reflect-config.json reflect-config.json
RUN mvn -Pnative native:compile
RUN upx -7 target/edge-server

FROM alpine:3.18

# 1) alpine 계정 세팅
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
USER appuser

# 2) Native 실행 파일 복사
COPY --from=builder /home/appuser/target/edge-server /home/appuser


# 3) 변수 설정
ENV SPRING_PROFILES_ACTIVE=${SPRING_PROFILES_ACTIVE:-stg}
EXPOSE 8080

# 4) 실행
WORKDIR /home/appuser
CMD ["./edge-server", "-Xms2g", "-Xmx2g"]

네이티브 이미지 실행 후 변경된 사항

네이티브 이미지로 실행한 후, 실행 문구에 AOT-processed가 추가 됐고, 프로그램 준비 완료 시간도 단축됐음을 확인할 수 있었습니다.

Starting AOT-processed EdgeServerApplication using Java 17.0.8 with PID 1
…
Started EdgeServerApplication in 1.905 seconds (process running for 1.911)

아래 그림을 보면, 네이티브 이미지 파일을 실행할 때 CPU와 메모리를 적게 소모하며 배포가 완료된 것을 볼 수 있습니다.

성능 테스트

네이티브 이미지 배포 방식 설정을 마쳤습니다. 이전과 얼마나 달라졌는지, 간단히 성능 테스트를 했습니다.

테스트 구간 및 소요 시간은 ‘클라이언트에서 메시지 전송 후 응답을 받을 때까지 소한 시간(ms)’으로 계산했으며, 단일 스레드와 멀티 스레드 방식을 각각 측정해 비교했습니다.

멀티 스레드에서는 10개 스레드를 동시에 사용하는 방식으로 측정했습니다. 첫 번째 시도 시 가장 긴 응답시간이 걸렸지만, 초기 로딩이 끝난 이후에는 네이티브 이미지에서 더 안정적인 응답 성능이 나왔습니다.

롱런 예측을 위해 2000회 반복해서 결과를 측정했습니다. 후반부로 갈수록 네이티브 이미지와 JVM 기반 Jar 실행파일의 응답시간이 250ms로 수렴함을 볼 수 있었습니다. 실제 Jar 중심축을 그어보면, Jar 실행 파일의 응답 성능이 점점 개선되는 것처럼 나왔습니다. JVM의 효율적인 캐싱 기능으로 초기 성능 차이가 시간이 지남에 따라서 줄어든 것으로 보였습니다. 하지만 동일한 메시지를 2000회 반복해 얻은 결과이므로, 실제 상황에서는 더 2000회보다 더 긴 실행 시간이 지나야 성능 역전 현상이 발생할 것으로 예상됩니다.

크래시리포트 엣지 서버는 데이터 유실을 막기 위해 파드 증설 시간과 초기 응답시간이 중요했습니다. 그래서 네이티브 이미지 방식이 직접적으로 얻을 수 있는 이득이 훨씬 크다는 결론을 낼 수 있었습니다.

네이티브 이미지 적용 후기

위 테스트를 마친 후, 크래시리포트 엣지 서버에 정식으로 네이티브 이미지 방식을 적용했습니다. 이후로 다섯 차례 이상 네이티브 이미지가 배포됐고, 그 과정에서 몇 가지 특징을 더 알게 됐습니다.

1차 배포

  • 빌드 환경: FROM vegardit/graalvm-maven:latest-java17 AS builder, Debian-slim 기반 이미지
  • 실행 환경: FROM eclipse-temurin:17-jre-jammy AS runner, Ubuntu 기반 이미지

두근대고 불안했던 1차 배포였습니다. 빌드 환경과 실행 환경의 운영체제가 다른 탓이었는지, 실행 시 CPU 사용률이 기대보다 높았습니다. OOM(Out of Memory)으로 인한 재시작도 자주 발생했습니다. CPU 사용률의 진폭이 넓어서 파드의 증감도 빈번히 발생했습니다.

우선, 할당했던 CPU 자원을 800m에서 1200m으로 변경하고, 최소 파드 수도 15에서 20으로 변경했습니다. 파드 증가 조건인 CPU 자원 요청률은 50%에서 60%로 조정해 1차 배포 후 생긴 현상에 대응했습니다. 기능적으로는 특별한 이슈가 없었지만, 안정적으로 작동하리라는 보장이 어려운 와중에 배포 결과도 기존 JAR 대비 불안정해 보였습니다.

2차 배포

  • 빌드 환경: FROM ghcr.io/graalvm/graalvm-community:17-ol9 AS builder, Oracle Linux 9 기반 이미지
  • 실행 환경: FROM ghcr.io/graalvm/jdk-community:latest AS runner, Oracle Linux 9 기반 이미지

2차 배포를 마쳤습니다. 이번에는 빌드 환경과 실행 환경을 일치시켰고, 이전 대비 OOM으로 인한 재시작도 2~3회로 줄었습니다. 빈번하게 발생했던 파드의 증설 또한 감소했습니다.

3차 배포

  • 빌드 환경: FROM ghcr.io/graalvm/graalvm-community:17-ol9 as builder, Oracle Linux 9 기반 이미지
  • 실행 환경: FROM ghcr.io/graalvm/jdk-community:17-ol9 as runner, Oracle Linux 9 기반 이미지

3차 배포에서는 그동안 크래시리포트 엣지 서버 업데이트 내용을 반영하기 위해 spring-boot:process-aot 명령어를 재실행해서 변경된 힌트 파일(reflect-config.json 변경)과 네이티브 이미지를 위한 톰캣 버전(tomcat-embed-programmatic)을 적용했습니다. 최소 파드 수도 20에서 15로 다시 낮춰 1차 배포 이전값으로 원복했습니다. 최초 네이티브 이미지 배포 전과 동일한 파드 수로 다시 맞춰져서, 비교를 해볼 수 있었습니다.

Graalvm은 Serial GC 옵션을 기본으로 제공합니다. (G1 GC 옵션을 사용하려면 엔터프라이즈 버전을 사용하면 됩니다.) Serial GC는 G1 GC보다 GC에 메모리가 더 많이 필요했으며, 이로 인해 OOM이 간혹 발생할 것으로 예상됐습니다. 그래서 실제 메모리 할당을 조금 더 많이 넣었습니다.

4차 배포

  • 빌드 환경: FROM ghcr.io/graalvm/graalvm-community:17-ol9 AS builder, Oracle Linux 9 기반 이미지
  • 실행 환경: FROM alpine:3.18 AS runner, Alpine Linux 기반 이미지

4차 배포에서는 tomcat-embed-programmatic 버전을 10.1.1에서 10.1.11로 업그레이드했고, CPU와 메모리 할당 설정을 기존 JAR 시절처럼 원복했습니다. 드디어 기존 JAR 시절과 동일한 자원으로 서비스 운영을 시작했습니다.

또한, 빌드할 때 –libc:musl 옵션을 추가해서 알파인 리눅스에서 실행하도록 변경했습니다. 기존 오라클 리눅스 대비 용량이 적은 알파인 리눅스가 컨테이너 환경에서 더 적합해 보였습니다.

5차 배포

  • 빌드 환경: FROM ghcr.io/graalvm/graalvm-community:17-ol9 (oracle linux 9) AS builder
  • 실행 환경: FROM alpine:3.18 AS runner

어느덧 5차 배포까지 왔습니다. 5차 배포에서는 UPX(the Ultimate Packer for eXecutables)를 적용했습니다. UPX는 네이티브 이미지로 생성한 실행 파일의 크기를 줄이는 도구로, 컨테이너의 크기를 줄일 때 큰 도움이 됩니다. 실제로 네이티브 이미지에서 사용하던 알파인 리눅스는 10MB 내외지만, 실행 파일은 100MB 정도였습니다.

UPX를 거친 후, 실행 파일은 60MB 내외까지 줄였습니다. 이외에도 실행 환경에서 불필요한 파일을 제거해 알파인 리눅스까지 합친 최종 도커 이미지 크기는 69.81MB까지 감소했습니다. 배포 회차별로 도커 이미지 크기를 정리해 보니, 체감량이 상당했습니다.

K8S에는 각 파드의 상태를 체크하는 기능이 있습니다. 예를 들면, 서비스할 준비(readinessProbe) 혹은 서비스 중(livenessProbe ) 같은 상태입니다. 네이티브 이미지로 실행하는 서버가 2초 내외로 준비가 되면, 이를 체크해서 최대 5초 내외로 클라이언트의 요청이 파드로 신속하게 전달될 수 있도록, 이 기능을 이번 5차 배포에 추가했습니다.

5차 배포 후, OOM은 특정 파드에서 일 1회 미만으로 발생하거나 거의 발생하지 않았습니다. 리소스 사용량 그래프를 보면, 주말을 걸쳐있음을 감안하더라도 상당히 안정적으로 자원을 사용하고 있음을 볼 수 있습니다.

5차례에 걸친 배포를 통해서 네이티브 이미지를 빌드 할 때는 실행 환경에 맞게 빌드 옵션을 적용하는 것이 무엇 보다 중요했습니다. 네이티브 이미지가 JVM에 기반하여 만들어졌기 때문에 GC가 작동하는데,, 기존 JAR JVM G1 GC가 아닌 Serial GC 알고리즘이 적용되기 때문에 메모리 자원을 기존 JAR 환경 파드 대비 넉넉히 할당해야 했습니다. 실행 환경에서는 JRE 나 JDK 가 필요하지 않아 훨씬 더 경량화된 컨테이너 이미지를 생성할 수 있는 특징이 있었습니다.

확실한 차이

네이티브 이미지 배포 방식 적용 전후를 되돌아보면, 50초였던 기존 프로그램 실행시간은 2초로 줄었습니다. 신규 파드를 추가할 때 사용하는 도커 이미지 크기는 기존 JAR 기반에서는 300MB였지만, 이제는 70MB 정도로 줄었습니다. 방식 자체를 바꾼 결과가 수치로도 큰 차이가 났음을 볼 수 있습니다.

5차 배포 이후, 크래시리포트 엣지 서버는 안정적으로 구동 중입니다. 줄어든 도커 이미지 크기와 실행 시간은 향후 트래픽이 일시적으로 증가하는 타이밍에, 이전보다 더 빠른 확장으로 안정적인 서비스 제공을 위한 큰 축이 될 것으로 생각합니다.

네이티브 이미지 배포 방식이 무조건 좋은 것은 아닐 것입니다. 2000회 이상 반복 테스트에서도 나왔듯, JAR를 사용한 서비스도 장기간 이용 시 충분히 안정적인 성능을 제공합니다. 하지만 K8S 환경에서 파드 증설이 빈번히 일어나고, 파드 준비 시간도 짧아야 하며, 초기 응답을 위해 로딩 시간을 줄여야 한다면, 네이티브 이미지 배포 방식은 충분한 효과를 주는 고려 대상이 될 수 있지 않을까요?

<참고자료>

  • https://spring.io/blog/2022/11/24/spring-boot-3-0-goes-ga
  • https://docs.spring.io/spring-boot/docs/current/reference/html/native-image.html#native-image.introducing-graalvm-native-images
  • https://www.baeldung.com/spring-native-intro
  • https://amrutprabhu.medium.com/building-native-image-for-a-spring-boot-application-b9df25556120
  • https://github.com/spring-attic/spring-native
  • https://mangkyu.tistory.com/302
  • https://docs.spring.io/spring-boot/docs/current/reference/html/native-image.html#native-image.advanced.known-limitations
  • https://upx.github.io/