Cloud Run 向けに Java アプリケーションを最適化する

このガイドでは、Java プログラミング言語で作成された Cloud Run サービスの最適化と、最適化の一部に関連したトレードオフの理解に役立つ背景情報について説明します。このページの情報は、Java にも適用される全般的な最適化のヒントを補完するものです。

従来の Java ウェブベース アプリケーションは、同時実行性と低レイテンシでリクエストを処理するよう意図され、そのためアプリケーションの実行時間が長くなる傾向にあります。また、JVM 自体も JIT を使用して時間とともに実行コードを最適化することで、ホットパスが最適化され、アプリケーションが時間とともに効率的に実行されるようにしています。

これらの従来の Java ウェブベース アプリケーションのベスト プラクティスと最適化の多くは、以下を中心に展開されています。

  • 同時リクエストの処理(スレッドベースと非ブロッキング I/O の両方)。
  • バックグラウンド タスクへのトレースや指標の送信など、重要性が低い機能に接続プールとバッチ処理を適用してレスポンス レイテンシを短縮する。

これらの従来の最適化の多くは長時間実行アプリケーションには適していますが、リクエストへの対応時にのみ実行される Cloud Run サービスではうまく機能しない場合があります。このページでは、Cloud Run のさまざまな最適化とトレードオフについて説明します。これにより起動時間とメモリの使用量を低減できます。

起動時の CPU ブーストを使用して起動レイテンシを短縮する

起動時の CPU ブーストを有効にすることで、インスタンスの起動時に一時的に CPU 割り当てを増やして起動レイテンシを短縮できます。

Google の指標には、Java アプリが起動時の CPU ブーストを使用するとメリットを得られることが示されており、これにより起動時間が最大 50% 短縮されます。

コンテナ イメージを最適化する

コンテナ イメージを最適化すると、負荷を軽減し、起動時間を短縮できます。次の方法でイメージを最適化できます。

  • コンテナ イメージを最小化する
  • ネストされたライブラリ アーカイブ JAR の使用を回避する
  • Jib を使用する

コンテナ イメージを最小化する

この問題の詳細については、コンテナの最小化に関する一般的なヒントのページをご覧ください。一般的なヒントページでは、コンテナ イメージの内容を必要なものだけに制限することをおすすめします。たとえば、コンテナ イメージに以下が含まれないことを確認します。

  • ソースコード
  • Maven ビルド アーティファクト
  • ビルドツール
  • Git ディレクトリ
  • 未使用のバイナリ / ユーティリティ

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 はガベージ コレクションを行う前にメモリを積極的に使用します。これにより、コンテナのメモリ上限を超え、OOMKilled が発生する可能性があります。

したがって、コンテナ対応の JVM バージョンを使用します。バージョン 8u192 以上の OpenJDK バージョンは、デフォルトでコンテナ対応です。

JVM メモリ使用量を確認する方法

JVM のメモリ使用量は、ネイティブ メモリとヒープの使用量から構成されます。通常、アプリケーションの作業メモリはヒープ内にあります。ヒープのサイズは最大ヒープ構成によって制限されます。RAM が 256 MB の Cloud Run インスタンスの場合、256 MB すべてが最大ヒープに割り当てられるわけではありません。JVM と OS にもスレッド スタック、コード キャッシュ、ファイルハンドル、バッファなどのネイティブ メモリが必要です。アプリケーションで OOMKilled が発生し、JVM のメモリ使用量(ネイティブ メモリ + ヒープ)を確認する必要がある場合は、ネイティブ メモリのトラッキングをオンにして、アプリケーションが正常に終了したときの使用量を確認します。アプリケーションで OOMKilled が発生すると、情報が出力されません。その場合は、出力が正常に生成されるように、メモリを増やしてからアプリケーションを実行します。

ネイティブ メモリのトラッキングは、JAVA_TOOL_OPTIONS 環境変数で有効にすることはできません。Java コマンドライン スタートアップ引数をコンテナ イメージ エントリポイントに追加して、アプリケーションが次の引数で起動されるようにする必要があります。

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

ネイティブ メモリの使用量は、読み込むクラスの数に基づいて計算できます。必要なメモリ量の見積りを行う場合は、オープンソースの Java Memory Calculator の使用を検討してください。

最適化コンパイラをオフにする

デフォルトでは、JVM には複数の JIT コンパイル フェーズがあります。これらのフェーズは、アプリケーションの効率性を時間とともに改善する一方で、メモリ使用時のオーバーヘッドが増加し、起動時間が長くなることがあります。

実行時間の短いサーバーレス アプリケーション(関数など)では、最適化フェーズをオフにして、長期的な効率性よりも起動時間の短縮を優先させることを検討します。

Cloud Run サービスの場合、環境変数を構成します。

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

アプリケーションのクラスデータ共有を使用する

JIT 時間とメモリ使用量をさらに削減するには、アプリケーション クラス データ共有(AppCDS)を使用して、コンパイル済みの Java クラスをアーカイブとして共有することを検討してください。AppCDS アーカイブは、同じ Java アプリケーションの別のインスタンスを起動するときに再利用できます。JVM がアーカイブから計算済みのデータを再利用できるため、起動時間を短縮できます。

AppCDS を使用する場合、以下の考慮事項が適用されます。

  • 再利用する AppCDS アーカイブは、元の作成に使用された OpenJDK ディストリビューション、バージョン、アーキテクチャとまったく同じ方法で再現する必要があります。
  • 共有するクラスのリストを生成するには、少なくとも 1 回アプリケーションを実行してから、そのリストを使用して 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 eclipse-temurin:11-jre 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 eclipse-temurin:11-jre

# 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

スレッド スタックのサイズを小さくする

ほとんどの Java ウェブ アプリケーションは接続ごとのスレッドをベースにしています。Java スレッドはヒープではなくネイティブ メモリを使用します。これはスレッド スタックと呼ばれ、デフォルトではスレッドあたり 1 MB に設定されています。アプリケーションが 80 件の同時リクエストを処理する場合、少なくとも 80 個のスレッドがあり、80 MB のスレッド スタック空間が使用されています。メモリはヒープサイズに追加されます。デフォルトが必要以上に大きい場合もあります。その場合、スレッドのスタックサイズを小さくできます。

縮小しすぎると、java.lang.StackOverflowError が表示されます。アプリケーションをプロファイリングすれば、構成する最適なスレッド スタックのサイズを見つけることができます。

Cloud Run サービスの場合、環境変数を構成します。

JAVA_TOOL_OPTIONS="-Xss256k"

スレッドを減らす

メモリを最適化するには、スレッド数を減らし、非ブロッキング型の事後対応戦略を使用して、バックグラウンド アクティビティを回避します。

スレッド数を減らす

スレッド スタックが原因で各 Java スレッドのメモリ使用量が増加することがあります。Cloud Run では、最大 1,000 件の同時リクエストが可能です。スレッド パー コネクション モデルでは、すべての同時リクエストを処理できるように最大で 1,000 個のスレッドが必要になります。ほとんどのウェブサーバーとフレームワークでは、スレッドと接続の最大数を構成できます。たとえば、Spring Boot では、applications.properties ファイルに最大接続数を定義できます。

server.tomcat.max-threads=80

メモリと起動を最適化するための非ブロッキング リアクティブ コードを作成する

スレッド数を減らすため、非ブロッキング リアクティブ プログラミング モデルの採用を検討してください。これにより、より多くの同時リクエストを処理しながらスレッド数を大幅に削減できるようになります。アプリケーション フレームワーク(Spring Boot with Webflowx、マイクロノート、Quakus など)は、リアクティブなウェブ アプリケーションをサポートしています。

通常、Spring Boot with Webflux、Micronaut、Quarrkus などのリアクティブ フレームワークでは起動時間が短くなります。

非ブロッキング フレームワークでブロッキング コードの記述を続けると、Cloud Run サービスのスループットとエラー率が著しく悪化します。これは、非ブロッキング フレームワークには 2 つや 4 つなど、小数のスレッドしかないためです。コードがブロックしている場合、極めて小数の同時リクエストしか処理できません。

非ブロッキング フレームワークでは、ブロッキング コードを制限なしスレッドプールにオフロードすることもできます。この場合、多数の同時リクエストが受け付けられるものの、ブロッキング コードは新しいスレッドで実行されることを意味します。スレッドがこのように制限なしに蓄積されると CPU リソースが使い尽くされ、CPU にスラッシングが発生し始めます。これは、レイテンシに多大な影響を与えます。非ブロッキング フレームワークを使用する場合、スレッドプール モデルを理解し、それに応じてプールをバインドしてください。

バックグラウンド アクティビティを使用する場合に常に割り当てられるように CPU を構成する

バックグラウンド アクティビティは、HTTP レスポンスの送信後に発生します。バックグラウンド タスクのある従来のワークロードを Cloud Run で実行する場合は、特別な考慮事項があります。

CPU が常に割り当てられるように構成する

Cloud Run サービスでバックグラウンド アクティビティをサポートする場合は、リクエストの外部でもバックグラウンド アクティビティを実行できて、CPU にアクセスできるように、Cloud Run サービスの CPU を常に割り当てます。

CPU がリクエスト処理中にのみ割り当てられる場合は、バックグラウンド アクティビティを回避する

リクエスト処理中にのみ CPU を割り当てるようにサービスを設定する必要がある場合は、バックグラウンド アクティビティに関する潜在的な問題を認識する必要があります。たとえば、アプリケーション指標を収集し、バックグラウンドで指標をバッチにまとめて定期的に送信する場合、CPU が割り当てられていないとそれらの指標は送信されません。アプリケーションがリクエストを絶えず受信していると、問題が少なくなります。アプリケーションの QPS が低い場合、バックグラウンド タスクは実行されません。

以下に、リクエスト処理中にのみ CPU を割り当てる場合に、バックグラウンド化されたよく知られているパターンで注意が必要なものを示します。

  • JDBC 接続プール - 通常、クリーンアップと接続チェックはバックグラウンドで行われます。
  • 分散トレース送信機能 - 通常、分散トレースはバッチで処理されます。定期的またはバッファがいっぱいになったときにバックグラウンドで送信されます。
  • 指標送信機能 - 通常、指標はバッチで処理され、バックグラウンドで定期的に送信されます。
  • Spring Boot の場合、@Async アノテーションが付いたすべてのメソッド
  • タイマー - CPU が割り当てられていないと、タイマーに基づくトリガー(例: ScheduledThreadPoolExecutor、Quartz、@Scheduled Spring アノテーション)が動作しない場合があります。
  • メッセージ レシーバ - Pub/Sub ストリーミング pull クライアント、JMS クライアント、Kafka クライアントなどのクライアントは、通常、リクエストを必要とせずにバックグラウンド スレッドで実行されます。アプリケーションにリクエストがない場合、これらは機能しません。Cloud Run では、この方法でのメッセージの受信はおすすめしません。

アプリケーションの最適化

Cloud Run サービスコードにより、起動時間とメモリ使用量を最適化することもできます。

起動タスクを減らす

従来の Java ウェブベース アプリケーションでは、起動中に完了するタスクが多数あります。たとえば、データのプリロード、キャッシュのウォームアップ、接続プールの確立などです。これらのタスクは順次実行すると、遅くなる原因になります。ただし、同時に実行するには、CPU コアの数を増やす必要があります。

Cloud Run は現在、コールド スタート インスタンスをトリガーするために実際のユーザー リクエストを送信しています。新しく開始したインスタンスにリクエストが割り当てられているユーザーは、大幅な遅延が発生する可能性があります。Cloud Run には現在、準備未完了のアプリケーションに対するリクエスト送信を回避するための「準備」チェックがありません。

接続プーリングを使用する

接続プールを使用する場合、接続プールがバックグラウンドで不要な接続を削除することに注意してください(バックグラウンド タスクの回避を参照)。アプリケーションの QPS が低く高レイテンシを許容できる場合、リクエストごとに接続の開始と終了を行うことを検討してください。アプリケーションの QPS が高い場合、アクティブなリクエストがあれば、バックグラウンド エビクションが実行されている可能性があります。

どちらの場合も、アプリケーションによるデータベース アクセスは、データベースで許可される最大接続数によってボトルネックになります。Cloud Run インスタンスごとに確立できる最大接続数を計算し、最大インスタンス数 × インスタンスごとの接続数が、許可されている最大接続数より少なくなるよう Cloud Run の最大インスタンス数を構成します。

Spring Boot を使用する場合

Spring Boot を使用する場合、次の最適化を検討する必要があります。

Spring Boot バージョン 2.2 以降を使用する

バージョン 2.2 以降の Spring Boot では起動速度が大幅に最適化されています。2.2 より前のバージョンの Spring Boot を使用している場合、アップグレードを検討するか、個々の最適化を手動で適用してください。

遅延初期化を使用する

グローバル遅延初期化フラグは、Spring Boot 2.2 以降で有効にできます。これにより起動速度は向上しますが、最初のリクエストでコンポーネントの初期化を待機する必要があるため、レイテンシが長くなる可能性があります。

遅延初期化は application.properties で有効にできます。

spring.main.lazy-initialization=true

環境変数を使用する場合は次のようになります。

SPRING_MAIN_LAZY_INITIALIZATIION=true

ただし、min-instances を使用している場合、min-instance の起動時に初期化が行われるため、遅延初期化の効果はありません。

クラススキャンを回避する

Cloud Run では、一般的にディスク アクセスが通常のマシンよりも遅いため、クラススキャンによって Cloud Run で追加のディスク読み取りが発生します。コンポーネント スキャンを制限するか、完全に回避するようにします。Spring Context Indexer でインデックスを事前に生成することも検討してください。これにより起動速度が改善されるかどうかは、アプリケーションによって異なります。

たとえば、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 デベロッパー ツールを明示的に無効にすることもできます

次のステップ

その他のヒントについては、以下を参照してください。