Cloud Run용 자바 애플리케이션 최적화

이 가이드에서는 자바 프로그래밍 언어로 작성된 Cloud Run 서비스 최적화와 일부 최적화와 관련된 절충안을 파악하는 데 유용한 배경 정보를 설명합니다. 이 페이지의 정보는 자바에도 적용되는 일반 최적화 팁을 보완합니다.

기존의 자바 웹 기반 애플리케이션은 동시 실행이 많고 지연 시간이 짧은 요청을 처리하도록 설계되었으며 대부분 장기간 실행되는 애플리케이션입니다. 또한 시간이 경과함에 따라 JVM 자체에서 JIT로 실행 코드를 최적화하므로 핫 경로가 최적화되고 애플리케이션 실행 효율이 점점 향상됩니다.

이러한 기존 자바 웹 기반 애플리케이션의 권장사항과 최적화는 대부분 다음을 기반으로 합니다.

  • 동시 요청 처리(스레드 기반 및 비차단 I/O 모두)
  • 연결 풀링 및 중요하지 않은 함수 일괄 처리(예: 백그라운드 태스크로 trace 및 측정항목 전송)를 사용하여 응답 지연 시간 단축

이러한 기존 최적화 상당수는 장기 실행 애플리케이션에서 잘 작동합니다. 하지만 요청을 능동적으로 처리할 때만 실행되는 Cloud Run 서비스에서는 제대로 작동하지 않을 수도 있습니다. 이 페이지에서는 시작 시간과 메모리 사용량을 줄이는 데 사용할 수 있는 Cloud Run의 몇 가지 최적화와 절충안을 설명합니다.

컨테이너 이미지 최적화

컨테이너 이미지를 최적화하면 로드 시간과 시작 시간을 줄일 수 있습니다. 다음과 같은 방법으로 이미지를 최적화할 수 있습니다.

  • 컨테이너 이미지 최소화
  • 중첩 라이브러리 보관 파일 JAR 사용 금지
  • Jib 사용

컨테이너 이미지 최소화

이 문제에 대한 자세한 내용은 컨테이너 최소화의 일반 팁 페이지를 참조하세요. 일반 팁 페이지에서는 컨테이너 이미지 콘텐츠를 필요한 항목만으로 줄일 것을 권장합니다. 예를 들어 컨테이너 이미지에 다음이 포함되지 않아야 합니다.

  • 소스 코드
  • Maven 빌드 아티팩트
  • 빌드 도구
  • Git 디렉터리
  • 사용되지 않는 바이너리/유틸리티

기본 이미지에는 Jib를 사용하여 컨테이너 이미지를 빌드할 때 선택하는 기본 이미지이기도 한 최소 자바 기본 이미지에 Distroless 자바 기본 이미지를 사용하는 것이 좋습니다.

Dockerfile 내에서 코드를 빌드할 경우 Docker 멀티 스테이지 빌드를 사용하여 최종 컨테이너 이미지에 JRE 및 애플리케이션 JAR 파일만 포함되도록 합니다.

중첩 라이브러리 보관 파일 JAR 사용 금지

Spring Boot와 같이 자주 사용되는 일부 프레임워크는 추가 라이브러리 JAR 파일(중첩 JAR)이 포함된 애플리케이션 보관 파일(JAR)을 만듭니다. 시작 시에 이러한 파일 압축이 풀려야 Cloud Run에서 시작 속도가 향상될 수 있습니다. 가능한 한 외부화된 라이브러리로 씬(thin) JAR을 만듭니다. Jib를 사용하여 애플리케이션을 컨테이너화해 이 작업을 자동화할 수 있습니다.

Jib 사용

Jib 플러그인을 사용하면 최소한의 컨테이너를 만들고 애플리케이션 보관 파일을 자동으로 평면화할 수 있습니다. Jib는 Maven와 Gradle 모두에서 작동하며 Spring Boot 애플리케이션과 함께 바로 작동합니다. 일부 애플리케이션 프레임워크에는 Jib 구성이 추가로 필요할 수 있습니다.

JVM 최적화

Cloud Run 서비스용 JVM을 최적화하면 성능과 메모리 사용량이 향상될 수 있습니다.

컨테이너 인식 JVM 버전 사용

VM 및 머신에서 CPU와 메모리 할당의 경우 JVM은 잘 알려진 위치(예: Linux의 경우 /proc/cpuinfo/proc/meminfo)에서 사용할 수 있는 CPU와 메모리를 파악합니다. 그러나 컨테이너에서 실행하면 CPU 및 메모리 제약조건이 /proc/cgroups/...에 저장됩니다. 이전 버전의 JDK는 /proc/cgroups 대신 /proc을 계속 확인하므로 CPU와 메모리가 할당된 양보다 더 많이 사용될 수 있습니다. 이로 인해 다음 상황이 발생할 수 있습니다.

  • 스레드 풀 크기는 Runtime.availableProcessors()에 의해 구성되므로 스레드 수가 과도해집니다.
  • 기본 최대 힙이 컨테이너 메모리 한도를 초과합니다. JVM은 가비지 컬렉션 전에 메모리를 적극적으로 사용합니다. 이로 인해 컨테이너가 컨테이너 메모리 한도를 쉽게 초과할 수 있으며 OOMKill될 수 있습니다.

따라서 컨테이너를 인식하는 JVM 버전을 사용하세요. OpenJDK 8u192 이상 버전은 기본적으로 컨테이너를 인식합니다.

JVM 메모리 사용량 이해

JVM 메모리 사용량은 기본 메모리 사용량과 힙 사용량으로 구성됩니다. 애플리케이션 작업 메모리는 일반적으로 힙에 있습니다. 힙 크기는 최대 힙 구성으로 제한됩니다. Cloud Run 256MB RAM 인스턴스에서 256MB 전부를 최대 힙에 할당할 수 없습니다. JVM과 OS에도 스레드 스택, 코드 캐시, 파일 핸들, 버퍼 등 기본 메모리가 필요하기 때문입니다. 애플리케이션이 OOMKill되고 JVM 메모리 사용량(기본 메모리 + 힙)을 파악해야 하는 경우에는 기본 메모리 추적을 사용 설정하여 애플리케이션이 정상 종료될 때의 사용량을 확인합니다. 애플리케이션이 OOMKill되면 정보를 출력할 수 없게 됩니다. 이 경우 먼저 더 많은 메모리로 애플리케이션을 실행하여 출력이 생성되도록 할 수 있습니다.

JAVA_TOOL_OPTIONS 환경 변수를 통해 기본 메모리 추적을 사용 설정할 수 없습니다. 컨테이너 이미지 진입점에 자바 명령줄 시작 인수를 추가하여 애플리케이션이 이러한 인수로 시작되도록 해야 합니다.

java -XX:NativeMemoryTracking=summary \
  -XX:+UnlockDiagnosticVMOptions \
  -XX:+PrintNMTStatistics \
  ...

로드할 클래스 수를 기준으로 기본 메모리 사용량을 예측할 수 있습니다. 필요한 메모리 양을 예측하려면 오픈소스 자바 메모리 계산기를 사용하는 것이 좋습니다.

최적화 컴파일러 사용 중지

기본적으로 JVM에는 여러 JIT 컴파일 단계가 있습니다. 이러한 단계는 시간이 지남에 따라 애플리케이션 효율성을 높이지만 메모리 사용량에 오버헤드를 추가하고 시작 시간을 늘릴 수도 있습니다.

단기 실행 서버리스 애플리케이션(예: 함수)의 경우 장기적인 효율성보다는 시작 시간 단축을 위해 최적화 단계를 사용 중지해야 할 수 있습니다.

Cloud Run 서비스의 경우 환경 변수를 구성합니다.

JAVA_TOOL_OPTIONS="-XX:+TieredCompilation -XX:TieredStopAtLevel=1"

애플리케이션 클래스 데이터 공유 사용

JIT 시간 및 메모리 사용량을 더 줄이려면 애플리케이션 클래스 데이터 공유(AppCDS)를 사용하여 미리 컴파일된 자바 클래스를 보관 파일로 공유하는 것이 좋습니다. 동일한 자바 애플리케이션의 다른 인스턴스를 시작할 때 AppCDS 보관 파일을 다시 사용할 수 있습니다. JVM은 보관 파일에서 사전 계산된 데이터를 다시 사용하여 시작 시간을 줄일 수 있습니다.

AppCDS를 사용할 때는 다음 사항을 고려해야 합니다.

  • 재사용할 AppCDS 보관 파일은 원래 생성하기 위해 사용한 것과 동일한 OpenJDK 배포, 버전, 아키텍처로 재현해야 합니다.
  • 애플리케이션을 한 번 이상 실행하여 공유할 클래스 목록을 생성한 다음 이 목록을 사용하여 AppCDS 보관 파일을 생성해야 합니다.
  • 클래스 범위는 애플리케이션 실행 중에 실행되는 코드 경로에 따라 달라집니다. 범위를 늘리려면 프로그래매틱 방식으로 더 많은 코드 경로를 트리거합니다.
  • 이 클래스 목록을 생성하려면 애플리케이션이 성공적으로 종료되어야 합니다. AppCDS 보관 파일 생성을 표시하는 데 사용되는 애플리케이션 플래그를 구현하는 것이 좋습니다. 그러면 애플리케이션이 즉시 종료될 수 있습니다.
  • 보관 파일이 생성된 것과 동일한 방식으로 새 인스턴스를 시작할 경우에만 AppCDS 보관 파일을 다시 사용할 수 있습니다.
  • AppCDS 보관 파일은 일반 JAR 파일 패키지에서만 작동합니다. 중첩된 JAR을 사용할 수 없습니다.

음영 처리된 JAR 파일을 사용하는 Spring Boot의 예시

Spring Boot 애플리케이션은 기본적으로 중첩된 uber JAR을 사용하므로 AppCDS에서 작동하지 않습니다. 따라서 AppCDS를 사용 중인 경우 음영 처리된 JAR을 만들어야 합니다. 예를 들어 Maven 및 Maven Shade 플러그인을 사용하는 경우 다음을 실행합니다.

<build>
  <finalName>helloworld</finalName>
  <plugins>
    <plugin>
      <groupId>org.apache.maven.plugins</groupId>
      <artifactId>maven-shade-plugin</artifactId>
      <configuration>
        <keepDependenciesWithProvidedScope>true</keepDependenciesWithProvidedScope>
        <createDependencyReducedPom>true</createDependencyReducedPom>
        <filters>
          <filter>
            <artifact>*:*</artifact>
            <excludes>
              <exclude>META-INF/*.SF</exclude>
              <exclude>META-INF/*.DSA</exclude>
              <exclude>META-INF/*.RSA</exclude>
            </excludes>
          </filter>
        </filters>
      </configuration>
      <executions>
        <execution>
          <phase>package</phase>
          <goals><goal>shade</goal></goals>
          <configuration>
            <transformers>
              <transformer implementation="org.apache.maven.plugins.shade.resource.AppendingTransformer">
                <resource>META-INF/spring.handlers</resource>
              </transformer>
              <transformer implementation="org.springframework.boot.maven.PropertiesMergingResourceTransformer">
                <resource>META-INF/spring.factories</resource>
              </transformer>
              <transformer implementation="org.apache.maven.plugins.shade.resource.AppendingTransformer">
                <resource>META-INF/spring.schemas</resource>
              </transformer>
              <transformer implementation="org.apache.maven.plugins.shade.resource.ServicesResourceTransformer" />
              <transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
                <mainClass>${mainClass}</mainClass>
              </transformer>
            </transformers>
          </configuration>
        </execution>
      </executions>
    </plugin>
  </plugins>
</build>

음영 처리된 JAR에 모든 종속 항목이 포함된 경우 Dockerfile을 사용하여 컨테이너 빌드 중에 간단한 보관 파일을 생성할 수 있습니다.

# Use Docker's multi-stage build
FROM adoptopenjdk:11-jre-hotspot as APPCDS

COPY target/helloworld.jar /helloworld.jar

# Run the application, but with a custom trigger that exits immediately.
# In this particular example, the application looks for the '--appcds' flag.
# You can implement a similar flag in your own application.
RUN java -XX:DumpLoadedClassList=classes.lst -jar helloworld.jar --appcds=true

# From the captured list of classes (based on execution coverage),
# generate the AppCDS archive file.
RUN java -Xshare:dump -XX:SharedClassListFile=classes.lst -XX:SharedArchiveFile=appcds.jsa --class-path helloworld.jar

FROM adoptopenjdk:11-jre-hotspot

# Copy both the JAR file and the AppCDS archive file to the runtime container.
COPY --from=APPCDS /helloworld.jar /helloworld.jar
COPY --from=APPCDS /appcds.jsa /appcds.jsa

# Enable Application Class-Data sharing
ENTRYPOINT java -Xshare:on -XX:SharedArchiveFile=appcds.jsa -jar helloworld.jar

클래스 확인 사용 중지

JVM은 실행할 클래스를 메모리에서 로드할 때 클래스가 변조되지 않았고 악의적인 수정이나 손상이 없는지 확인합니다. 소프트웨어 제공 파이프라인을 신뢰할 수 있고(예: 모든 출력을 확인 및 검증할 수 있음), 컨테이너 이미지의 바이트코드를 완전히 신뢰할 수 있으며, 애플리케이션이 임의의 원격 소스에서 클래스를 로드하지 않으면 확인을 사용 중지할 수도 있습니다. 시작 시 클래스 다수가 로드되는 경우 확인을 사용 중지하면 시작 속도가 빨라질 수 있습니다.

Cloud Run 서비스의 경우 환경 변수를 구성합니다.

JAVA_TOOL_OPTIONS="-noverify"

스레드 스택 크기 줄이기

대부분의 자바 웹 애플리케이션은 연결당 스레드를 기준으로 합니다. 각 자바 스레드는 힙에 없는 기본 메모리를 사용합니다. 이를 스레드 스택이라고 하며 기본적으로 스레드당 1MB로 설정됩니다. 애플리케이션이 동시 요청을 80개 처리한다면 스레드가 최소 80개 이상 있을 수 있으며 따라서 사용되는 스레드 스택 공간은 80MB입니다. 메모리는 힙 크기에 더하는 값입니다. 기본값이 필요한 것보다 더 클 수 있습니다. 스레드 스택 크기를 줄일 수도 있습니다.

단, 너무 많이 줄이면 java.lang.StackOverflowError가 표시됩니다. 애플리케이션을 프로파일링하여 구성할 최적의 스레드 스택 크기를 찾을 수 있습니다.

Cloud Run 서비스의 경우 환경 변수를 구성합니다.

JAVA_TOOL_OPTIONS="-Xss256k"

스레드 줄이기

차단하지 않는 반응형 전략을 사용하고 백그라운드 활동을 방지하여 스레드 수를 줄임으로써 메모리를 최적화할 수 있습니다.

스레드 수 줄이기

자바 스레드마다 스레드 스택으로 인해 메모리 사용량이 증가할 수 있습니다. Cloud Run은 동시 요청을 최대 80개까지 허용합니다. 연결당 스레드 모델을 사용하면 모든 동시 요청을 처리하는 데 스레드가 최대 80개 필요합니다. 대부분의 웹 서버와 프레임워크에서는 최대 스레드 수와 연결 수를 구성할 수 있습니다. 예를 들어 Spring Boot는 applications.properties 파일에서 최대 연결 수를 제한할 수 있습니다.

server.tomcat.max-threads=80

메모리와 시작을 최적화하도록 차단하지 않는 반응형 코드 작성

스레드 수를 실제로 줄이려면 차단하지 않는 반응형 프로그래밍 모델을 사용하는 것이 좋습니다. 그러면 스레드 수를 크게 줄이면서 더 많은 동시 요청을 처리할 수 있습니다. Webflux, Micronaut, Quarkus가 포함된 Spring Boot와 같은 애플리케이션 프레임워크는 반응형 웹 애플리케이션을 지원합니다.

일반적으로 Webflux, Micronaut, Quarkus가 포함된 Spring Boot와 같은 반응형 프레임워크의 시작 시간이 더 짧습니다.

차단하지 않는 프레임워크에서 차단 코드를 계속 작성하면 Cloud Run 서비스에서 처리량과 오류 발생률이 크게 나빠집니다. 이는 차단하지 않는 프레임워크에는 스레드 수가 얼마 되지 않기 때문입니다(예: 2개 또는 4개). 차단하는 코드는 동시 요청을 거의 처리하지 못합니다.

이러한 차단하지 않는 프레임워크는 제한되지 않은 스레드 풀로 차단 코드를 오프로드할 수도 있습니다. 즉, 동시 요청을 여러 개 수락할 수 있지만 차단 코드는 새 스레드에서 실행됩니다. 스레드가 제한되지 않은 방식으로 누적되면 CPU 리소스가 소진되고 스래싱이 발생합니다. 지연 시간에 심각한 영향을 미칩니다. 차단하지 않는 프레임워크를 사용하는 경우 스레드 풀 모델을 이해하고 이에 맞게 풀을 결합해야 합니다.

백그라운드 활동 방지

이 인스턴스가 더 이상 요청을 수신하지 않으면 Cloud Run이 인스턴스 CPU를 제한합니다. Cloud Run에서 실행할 경우 백그라운드 태스크가 있는 기존 워크로드를 특별히 고려해야 합니다.

예를 들어 애플리케이션 측정항목을 수집하고 이러한 측정항목을 정기적으로 전송하도록 백그라운드에서 일괄 처리하는 경우 CPU가 제한되면 측정항목이 전송되지 않습니다. 애플리케이션에서 지속적으로 요청을 수신하는 경우 문제가 더 적을 수 있습니다. 애플리케이션의 QPS가 낮으면 백그라운드 태스크가 실행되지 않을 수 있습니다.

주의해야 하는 잘 알려진 백그라운드 처리 패턴은 다음과 같습니다.

  • JDBC 연결 풀 - 정리 및 연결 확인이 일반적으로 백그라운드에서 수행됩니다.
  • 분산 trace 발신자 - 분산 trace가 일반적으로 일괄 처리되며 백그라운드에서 정기적으로 전송되거나 버퍼가 가득 찼을 때 전송됩니다.
  • 측정항목 발신자 - 일반적으로 측정항목이 백그라운드에서 일괄 처리되며 정기적으로 전송됩니다.
  • Spring Boot의 경우 @Async 주석이 있는 모든 메서드
  • 타이머 - 타이머 기반 트리거(예: ScheduledThreadPoolExecutor, Quartz 또는 @Scheduled Spring 주석)는 CPU가 제한되면 실행되지 않을 수 있습니다.
  • 메시지 수신자 - 예를 들어 Pub/Sub 스트리밍 풀 클라이언트, JMS 클라이언트 또는 Kafka 클라이언트는 일반적으로 요청 필요 없이 백그라운드 스레드에서 실행됩니다. 이들 클라이언트는 애플리케이션에 요청이 없으면 작동하지 않습니다. Cloud Run에서는 이 방식으로 메시지를 수신하지 않는 것이 좋습니다.

애플리케이션 최적화

Cloud Run 서비스 코드에서 시작 시간을 단축시키고 메모리 사용량을 향상시키도록 최적화할 수도 있습니다.

시작 태스크 줄이기

기존 자바 웹 기반 애플리케이션은 시작 시 여러 태스크(예: 데이터 사전 로드, 캐시 준비, 연결 풀 설정)을 완료해야 할 수 있습니다. 이러한 태스크를 순차적으로 실행하면 애플리케이션 속도가 느려질 수 있습니다. 반면, 이들 태스크를 동시에 실행하려면 CPU 코어 수를 늘려야 합니다.

Cloud Run은 현재 실제 사용자 요청을 보내서 콜드 스타트 인스턴스를 트리거합니다. 새로 시작된 인스턴스에 요청이 할당된 사용자는 긴 지연 시간을 경험할 수 있습니다. Cloud Run에는 현재 '준비' 확인 기능이 없으므로 준비되지 않은 애플리케이션에 요청을 보내지 않을 방법이 없습니다.

연결 풀링 사용

연결 풀을 사용하면 연결 풀이 백그라운드에서 불필요한 연결을 삭제할 수 있습니다(백그라운드 태스크 방지 참조). 애플리케이션의 QPS가 낮고 긴 지연 시간이 허용된다면 요청당 연결을 열고 닫는 것이 좋습니다. 애플리케이션의 QPS가 높으면 활성 요청이 있는 한 백그라운드 삭제가 계속 실행될 수 있습니다.

두 경우 모두 데이터베이스에서 허용된 최대 연결 수에 따라 애플리케이션의 데이터베이스 액세스에 병목 현상이 발생합니다. Cloud Run 인스턴스별로 설정할 수 있는 최대 연결 수를 계산하고, 최대 인스턴스 수와 인스턴스별 연결 수를 곱한 값이 최대 허용 연결 수보다 적도록 Cloud Run 최대 인스턴스 수를 구성합니다.

Spring Boot 사용

Spring Boot을 사용하는 경우 다음 최적화를 고려해야 합니다.

Spring Boot 버전 2.2 이상 사용

Spring Boot는 버전 2.2부터 시작 속도 향상을 위해 대폭 최적화되었습니다. 2.2 이전 버전의 Spring Boot를 사용하는 경우에는 업그레이드하거나 개별 최적화를 수동으로 적용하는 것이 좋습니다.

지연 초기화 사용

Spring Boot 2.2 이상 버전에는 전역 지연 초기화 플래그를 사용 설정할 수 있습니다. 이 플래그를 사용하면 시작 속도가 향상되지만 첫 번째 요청의 경우 구성요소가 처음 초기화될 때까지 기다려야 하므로 지연 시간이 길어질 수 있습니다.

application.properties에서 지연 초기화를 사용 설정할 수 있습니다.

spring.main.lazy-initialization=true

또는 다음 환경 변수를 사용합니다.

SPRING_MAIN_LAZY_INITIALIZATIION=true

하지만 최소 인스턴스를 사용하는 경우에는 지연 초기화가 유용하지 않습니다. 최소 인스턴스가 시작되기 전에 초기화가 수행되어야 했기 때문입니다.

클래스 스캔 방지

Cloud Run에서는 일반적으로 디스크 액세스가 일반 머신보다 느리므로 클래스 스캔을 수행하면 Cloud Run에서 추가 디스크 읽기가 발생합니다. 구성요소 스캔을 제한하거나 전혀 수행하지 않도록 해야 합니다. Spring 컨텍스트 색인 생성기를 사용하여 색인을 사전에 생성하는 것이 좋습니다. 이렇게 해서 시작 속도가 향상될지 여부는 애플리케이션마다 다릅니다.

예를 들어 Maven pom.xml에 색인 생성기 종속 항목(실제로는 주석 프로세서)을 추가합니다.

<dependency>
  <groupId>org.springframework</groupId>
  <artifactId>spring-context-indexer</artifactId>
  <optional>true</optional>
</dependency>

프로덕션 단계가 아닌 Spring Boot 개발자 도구 사용

개발 중에 Spring Boot 개발자 도구를 사용할 경우 도구가 프로덕션 컨테이너 이미지에 패키징되어 있지 않아야 합니다. Spring Boot 빌드 플러그인 없이 Spring Boot 애플리케이션을 빌드한 경우(예: Shade 플러그인 사용 또는 Jib을 사용하여 컨테이너화)에 이러한 상황이 발생할 수 있습니다.

이 경우 빌드 도구에서 Spring Boot 개발자 도구를 명시적으로 제외해야 합니다. 또는 Spring Boot 개발자 도구를 명시적으로 사용 중지합니다.

다음 단계

자세한 내용은 다음을 참조하세요.