Amazon Web Services ブログ

Spring Boot アプリケーションを AWS Fargate に最適化する

この記事は Optimize your Spring Boot application for AWS Fargate (記事公開日: 2022 年 7 月 25 日) を翻訳したものです。

中断や需要のピークに迅速に対応するには、起動時間を短縮することが重要です。それによってリソース効率を高めることができます。AWS Fargate では、基盤となるコンテナホストの面倒を見る必要はありません。しかしながら、コンテナとアプリケーションの起動時間を短縮するために、いくつかの変更が必要になることがよくあります。

この記事では、Fargate 上で実行される Java アプリケーションに適用する最適化手法について説明します。この記事では特に Java Spring Boot アプリケーションを取り上げますが、これらの最適化は他のタイプのコンテナ化された Java アプリケーションにも適用できます。

さまざまな実装を紹介するサンプルアプリケーションのコードを GitHub に置いていますので参照してください。

ソリューションの概要

サンプルアプリケーションは、基本的な顧客管理機能を実装した、シンプルな REST ベースの CRUD (Create, Read, Update, Delete) サービスです。すべてのデータは、Amazon DynamoDB テーブルに永続化され、AWS SDK for Java V2 を使用してアクセスされます。

REST 機能は、Spring Boot の RestController アノテーションを使用する CustomerController クラスにあります。このクラスは、Spring Data リポジトリの実装である CustomerRepository を使用する CustomerService を呼び出します。このリポジトリは、AWS SDK for Java V2 を使用して Amazon DynamoDB テーブルにアクセスする機能を実装しています。すべてのユーザー関連情報は、Customer と呼ばれる POJO (Plain Old Java Object) に格納されます。

以下のアーキテクチャ図は、本ソリューションの概要を示しています。

VPC、ALB、AWS Fargate を使用する ECS クラスター、ECR、Amazon DynamoDB を示すインフラストラクチャ

図 1. ソリューションのアーキテクチャ

このテストのために、アプリケーションの 7 つの異なるバージョンを作成しました。

  • バージョン 1、最適化なし、x86_64 で動作
  • バージョン 2、最適化なし、ARM64 で動作
  • バージョン 3、カスタム Java ランタイム環境 (JRE) および追加の最適化、x86_64 で動作
  • バージョン 4、カスタム Java ランタイム環境 (JRE) および追加の最適化、ARM64 で動作
  • バージョン 5、Spring Native (GraalVM AoT コンパイル) 、Ubuntu 22 ベースイメージ、X86_64 で動作
  • バージョン 6、Spring Native (GraalVM AoT コンパイル) 、Ubuntu 22 ベースイメージ、ARM64 で動作
  • バージョン 7、Spring Native (GraalVM AoT コンパイル) 、Distroless ベースイメージ、X86_64 で動作

前提条件

この記事の手順を完了するには、以下が必要です。

ウォークスルー

マルチアーキテクチャコンテナイメージ

マルチアーキテクチャイメージとは、同じコードから構築された異なるプロセッサアーキテクチャ用のコンテナイメージのことを指します。マルチアーキテクチャイメージを作成する方法は複数あります。この記事では、QEMU エミュレーションを使用して、マルチアーキテクチャイメージを迅速に作成します。マルチアーキテクチャイメージをテスト以外の目的でも使用する予定がある場合は、適切な CI/CD パイプラインを使用してイメージをビルドすることを検討してください。最初のステップは、Docker Buildx CLI プラグインをインストールすることです。Docker Desktop をインストールしている場合は、buildx とエミュレータが最初から含まれているため、このステップは必要ありません。

export DOCKER_BUILDKIT=1
docker build --platform=local -o . https://github.com/docker/buildx.git

mkdir -p ~/.docker/cli-plugins
mv buildx ~/.docker/cli-plugins/docker-buildx
chmod a+x ~/.docker/cli-plugins/docker-buildx

Amazon EC2AWS Cloud9 のインスタンス上に、ARM64 用のコンテナをビルドして実行するためのエミュレータをインストールします。

docker run --privileged --rm tonistiigi/binfmt --install all

次のステップでは、新しいビルダーから始めます。

docker buildx create --name SpringBootBuild --use
docker buildx inspect --bootstrap

それでは、buildx のパラメータを使用してマルチアーキテクチャイメージのビルドを開始します。以下のコマンドでは、amd64arm64 という 2 つの異なるアーキテクチャを指定していることがわかります。マルチアーキテクチャマニフェストが生成され、さらに Amazon ECR リポジトリにプッシュされます。測定では、各バージョンは厳密に分離され、同時に実行されることに注意してください。そのため、各ディレクトリは対応するアーキテクチャのイメージをビルドしてプッシュするために使用されれます。

(訳注: 次のコマンドを実行する前に、サンプルリポジトリのクローン、対象バージョンのディレクトリへの移動、Cloud9 を使用している場合はボリュームのサイズ変更ECR リポジトリの作成ECR へのログインなど、追加の手順が必要です。サンプルリポジトリの README や AWS ドキュメントを参照してください。)

docker buildx build --platform linux/amd64,linux/arm64 --tag <account-id>.dkr.ecr.<region>.amazonaws.com/<your-repo>:latest --push .

Amazon Elastic Container Service (ECS)Fargate を使用するタスク定義では、cpuArchitecture というパラメータ (有効な値は X86_64 および ARM64) を指定して、目的の CPU アーキテクチャを使用してタスクを実行します。これについては、後のセクションで詳しく説明します。

インフラストラクチャのセットアップ

これまでのステップで、アプリケーションをネイティブイメージにコンパイルし、コンテナイメージをビルドして Amazon ECR リポジトリに格納しました。ここで、Amazon Virtual Private Cloud (Amazon VPC) 、Fargate 起動タイプを使用する Amazon ECS クラスター、DynamoDB テーブル、および Application Load Balancer (ALB) で構成される基本的なインフラストラクチャをセットアップします。

インフラストラクチャをコード化することで、インフラストラクチャをコードと同じように扱えるようになります。この記事では、オープンソースのソフトウェア開発フレームワークである AWS CDK を使用して、使い慣れたプログラミング言語を使用してクラウドアプリケーションリソースをモデル化し、プロビジョニングします。AWS CDK アプリケーションのコードは、サンプルアプリケーションのコードリポジトリの cdkapp/lib/cdkapp-stack.ts にあります。

次のセクションでは、アプリケーションの最初のバージョンのために AWS リージョン eu-west-1 でインフラストラクチャをセットアップします。

$ npm install -g aws-cdk # Install the CDK if this hasn’t been installed already
$ cd cdkapp
$ npm install # retrieves dependencies for the CDK stack
$ npm run build # compiles the TypeScript files to JavaScript
$ cdk bootstrap
$ cdk deploy CdkappStack --parameters containerImage=<your_repo/you_image:tag> --context cpuType=X86_64

最後の AWS CDK コマンドで示したように、X86_64 または ARM64 という値を使用して、Amazon ECS のタスク定義の CPU アーキテクチャを定義することが可能です。

AWS CloudFormation スタックのアウトプットは、ALB の DNS (Domain Name System) レコードです。インフラストラクチャの中心は、AWS Fargate 起動タイプを使用する Amazon ECS クラスターです。この AWS CDK アプリケーションでは、起動タイプとして Fargate を使用する Amazon ECS クラスターをセットアップしています。コンテキスト (x86_64 または ARM64) に応じて、適切な CPU アーキテクチャ、1 vCPU、2 GB RAM を持つ Amazon ECS タスクのタスク定義が作成されます。さらに、AWS Fargate サービスを作成し、ALB で公開します。このサービスでは、Spring Boot Actuator を使用して実装されたヘルスチェックも提供されます。

パフォーマンスに関する考察

サンプル Java アプリケーションの通常のビルドと比較して、さまざまな最適化を使用した場合の影響を調べてみましょう。

パフォーマンス測定の基準は、アプリケーションのバージョン 1 とバージョン 2 です。どちらのアプリケーションも同じ依存関係で実装された同じロジックを持っています。これらのアプリケーション間では CPU アーキテクチャのみが異なっています。依存関係には、完全な AWS SDK for JavaDynamoDB 拡張クライアントLombok が含まれます。Lombok プロジェクトは、定型的なコードを最小化するためのコード生成ライブラリツールです。DynamoDB 拡張クライアントは、AWS SDK for Java 2.x に含まれる高レベルのライブラリで、クライアント側のクラスを DynamoDB テーブルにマッピングする簡単な方法を提供します。このソリューションにより、DynamoDB のテーブルや項目に対するさまざまな CRUD 操作を直感的に定義できます。Amazon DynamoDB 拡張クライアントの詳細やサンプルについては、こちらを参照してください。

また、Web コンテナとして Tomcat を使用し、Java 11 を使用しています。Dockerfile では、ベースイメージとして Ubuntu 22.04 を使用し、完全な Amazon Corretto 11 Java Development Kit (JDK) をインストールしています。これらの条件により、コンテナイメージはかなりのサイズ (今回の場合は 900 MB) となり、レジストリからのイメージプルの時間や、アプリケーションの起動時間に悪影響を及ぼします。

アプリケーションの 2 回目のイテレーション (バージョン 3 および 4) では、アプリケーションにいくつかの最適化を適用しています。AWS SDK の必要な依存関係だけを使用することで、依存関係の数を減らしています。また、Tomcat を、より軽量で効率のよい Web コンテナである Undertow に置き換えました。Amazon DynamoDB へのアクセスは、DynamoDB 拡張クライアントを削除し、標準のクライアントのみを使用しています。

このバージョンでは、Amazon Corretto 17 を使用し、コンテナイメージのマルチステージビルドプロセスの一部として、jdepsjlink を使用して独自のランタイムを構築しています。

RUN jdeps --ignore-missing-deps \
--multi-release 17 --print-module-deps \
--class-path target/BOOT-INF/lib/* \
target/CustomerService-0.0.1.jar > jre-deps.info

RUN export JAVA_TOOL_OPTIONS="-Djdk.lang.Process.launchMechanism=vfork" && \
jlink --verbose --compress 2 --strip-java-debug-attributes \
--no-header-files --no-man-pages --output custom-jre \
--add-modules $(cat jre-deps.info)

jdeps を使用して、アプリケーションを実行するために必要な JDK モジュールのリストを生成し、このリストを jre-deps.info に書き込みます。このファイルは、モジュールのリストに基づいてカスタム JRE を作成するツールである jlink の入力として使用できます。Dockerfile では、Ubuntu 22.04 をベースイメージとして使用し、カスタム JRE をターゲットコンテナイメージにコピーしています。依存関係の数を制限し、カスタム JRE を構築することで、ターゲットイメージのサイズを大幅 (約 200 MB) に削減できます。パラメータ -XX:TieredStopAtLevel=1-noverify を指定して、アプリケーションを起動します。階層化されたコンパイルがレベル 1 で停止することで、JVM がコードの最適化とプロファイリングに費やす時間が短縮され、起動時間が改善されます。ただし、コードが最適化されていないため、アプリケーションが何度も呼び出される場合は、悪影響を及ぼします。-noverify フラグは、セキュリティに関係するバイトコード検証を無効にします。クラスローダーは、バイトコードの動作をチェックしません。

アプリケーションの 3 回目のイテレーション (バージョン 5、6、7) では、GraalVM を使用する Spring Native を導入しました (150 MB ~ 200 MB) 。この変更により、GraalVM ネイティブイメージコンパイラを使用して、Spring アプリケーションをネイティブ実行ファイルにコンパイルできます。GraalVM は JDK の高性能ディストリビューションで、バイトコードをマシンコードに変換します。これはコードの静的解析を用いて行わます。つまり、コンパイル時にすべての情報が得られることになります。もちろん、これは実行時にコードを生成することができないことを意味します。x86ARM について、比較可能な結果を得たいので、ベースイメージとして Ubuntu 22.04 を選択しました。加えて、生成されるコンテナイメージを最小化するために、x86 のベースイメージとして quay.io/quarkus/quarkus-distroless-image を使用した構成を 1 つ作成します (現時点では、quarkus-distroless-image は ARM64 では使用できません) 。

測定と結果

AWS サービスとアーキテクチャを最適化したいので、AWS Fargate タスクについて、下の図 2 で示すタスク準備期間を測定します。これは、AWS CloudTrail の runTask API コールのタイムスタンプと、Spring Boot アプリケーションの ApplicationReadyEvent のタイムスタンプを使って計算することができます。

起動時間を測定するために、タスクメタデータエンドポイントからのデータと、Amazon ECS のコントロールプレーンへの API コールを組み合わせて使用します。特に、このエンドポイントは、タスクの ARN とクラスター名を返します。

この情報が、Amazon ECS のコントロールプレーンに describeTasks コールを送信して、以下のメトリクスを受け取るために必要です。

  • PullStartedAt: 最初のコンテナイメージのプルを開始した時のタイムスタンプ。
  • PullStoppedAt: 最後のコンテナイメージのプルを終了した時のタイムスタンプ。
  • CreatedAt: コンテナが作成されときのタイムスタンプ。コンテナがまだ作成されていない場合、このパラメーターは省略されます。
  • StartedAt: StartedAt: コンテナが開始されたときのタイムスタンプ。コンテナがまだ開始されていない場合、このパラメーターは省略されます。

必要なメトリクスを取得するためのロジックは EcsMetaDataService クラスに実装されています。

Fargate タスクのさまざまな状態を次の図に示します。

Fargate タスクのさまざまな状態

図 2: Fargate タスクのさまざまな状態

そして、この変更は私たちのアプリケーションにどのような影響を与えたのでしょうか。以下のリストは、効果と手軽さの順に並べてあります。

  • イメージサイズを小さくする: コンテナイメージのサイズは、タスクの準備時間に最も大きな影響を及ぼします。イメージが小さければ小さいほど、Amazon ECR リポジトリからのプルが速くなり、アプリケーションの起動が速くなります。Spring Boot アプリケーションの最適化されていないバージョン (イテレーション 1) のイメージは 900 MB 以上と大きく 、カスタム JRE を使用して依存関係を最小化した最適化されたバージョン (イテレーション 2) では 200 MB、Spring Native を使用したバージョン (イテレーション 3) では Ubuntu ベースのイメージで 200 MB、Distroless ベースのイメージで 150 MB でした 。プル時間に対する効果は驚くほど高く、イテレーション 1 からイテレーション 2 で、プル時間は約 75 % 削減されました。イテレーション 2 からイテレーション 3 の影響はやや小さく、それぞれ 38 % (Distroless ベースのイメージ) 、および 12 % (Ubuntuベースのイメージ) の削減でした。イテレーション 1 からイテレーション 3 まででは、それぞれ 85 % (Distroless ベースのイメージ) および 80 % (Ubuntuベースのイメージ) の削減でした。
  • カスタム JRE を使用する: Java アプリケーションの起動時間 (JVM の起動時間と ApplicationReadyEvent) については、それぞれのバージョンのパフォーマンスに大きな影響を与えることがわかります。イテレーション 1 からイテレーション 2 で、約 78 % の時間が削減されました。
  • Spring Native を使用する: GraalVM とネイティブイメージを使用することは、Java アプリケーションの起動時間に多大な影響を与えます。イテレーション 2 からイテレーション 3 で、起動時間は 96 % 改善され、イテレーション 1 からイテレーション 3 まででは 99 % の改善を達成したことになります。

runTask API コールから ApplicationReadyEvent までのトータルの起動時間を詳しく見てみると、イテレーション 1 からイテレーション 2 では 58 % のパフォーマンス向上が見られ、イテレーション 2 からイテレーション 3 では 28 % のインパクトがあります。イテレーション 1 からイテレーション 3 まででは、ほぼ 70 % の全体的な改善が見られました。

Spring Boot アプリケーションの起動時間の測定結果を以下の図に示します。

Spring Boot アプリケーションの構成を変えた場合のパフォーマンス結果を示すボックスチャート

図 3. Spring Boot アプリケーションの起動時間の測定結果

トレードオフ

一部のレガシーライブラリや依存関係は、Java 9 のモジュールシステムをサポートしておらず、jdepsjlink を使用してカスタム JRE を構築することはできません。このような状況では、モジュールシステムをサポートするライブラリに移行する必要があり、追加の開発作業が必要になります。

GraalVM は、イメージのビルド時にすべてのコードが既知であることを想定します。すなわち、実行時に新しいコードがロードされることはありません。したがって、すべてのアプリケーションが GraalVM を使用して最適化できるわけではありません。詳細については、GraalVM ドキュメントの制限事項を参照してください。アプリケーションのネイティブイメージのビルドが失敗した場合、実行には完全な JVM を必要とするフォールバックイメージが作成されます。さらに、GraalVM によるネイティブイメージのコンパイルは、より多くの時間を必要とし、開発者の生産性に影響を与えます。

クリーンアップ

終了後は、1 つのコマンドでこれらのリソースを簡単に破棄して、コストを節約できます。

$ cdk destroy

まとめ

この記事では、Fargate を使用して Amazon ECS 上で動作する Spring Boot アプリケーションの起動時間に対するさまざまな最適化ステップの影響を示しました。いくつかの不要な依存関係、完全な JDK、および巨大なベースイメージを持つ典型的な実装から始めました。依存関係を減らし、jdepsjlink を使用して独自の JRE を構築しました。Spring Native と GraalVM を採用して起動時間を短縮し、ベースイメージを Distroless に切り替えました。多くの開発者にとって、カスタム JRE と依存関係を最小化したバリアントは、変更の複雑さとパフォーマンスの向上の点でベストなソリューションです。これは、AWS Graviton2 による ARM 命令セットが使用される場合に特に当てはまります。Graviton2 プロセッサは、AWS が 64ビット Arm Neoverse コアを使用してカスタム設計したプロセッサで、Fargate で利用できます。Graviton2 プロセッサを搭載した Fargate は、コンテナ化されたアプリケーションに対して、同等の Intel x86 ベースの Fargate に比べて最大 40 % の価格性能向上と 20 % のコスト低減を実現します。

既存の Java アプリケーションを最適化し、起動時間やメモリ消費量を削減する方法について、いくつかのアイデアを提供できたことを願っています。ソースリポジトリにあるサンプルアプリケーションの機能拡張をお気軽にご提案ください。

翻訳はプロフェッショナルサービスの杉田が担当しました。原文はこちらです。