1. はじめに

NTTデータの本山です。弊社は、これまでに多数のシステム開発プロジェクトを完遂することで、アセット(※)や開発ノウハウ、要員スキルを磨き上げてきました。
例:TERASOLUNA Server/Batch Framework[参考資料1]

※:アプリケーションフレームワーク、アプリケーションライブラリ、開発ツール、およびシステム開発手順といったシステム開発時に使用できる資材のこと。本記事では、アプリケーションフレームワーク、アプリケーションライブラリ、開発ツールの意味で使用する。

アセットはクラウドの普及前に開発されたものも多くありますが、それらはクラウドを前提としたものではなく、そのまま適合できないケースがあります。
そこで弊社では、既存アセットとクラウドを適合させ、より一層クラウドを活用するための取り組みを行っています。
今回は、クラウドならではの技術である ServerLess に対する既存アセット活用の取り組みについてご紹介します。

1.1. 記事要約

本記事では、弊社のアセットの1つであるTERASOLUNA Server Frameworkを具体例にあげ、APサーバ(サーブレットコンテナ)にWarファイルをデプロイするWebアプリケーションを、AWS Lambdaで動作させる方法について述べます。

2. 取り組みの背景と課題感

今回ご紹介する取り組みの背景と、筆者が持つ課題感をご説明します。

2.1. 背景

2.1.1. ビジネス面

新規サービス開発ではアイデアを市場に投入するスピードが重要です。そのため、アジリティ高くアプリケーションの開発を行う必要があります。しかし、初期開発においては、予算等の都合から開発要員が潤沢でない場合が多いのが実情です。

その結果、大規模プロジェクトのように、アプリケーション、インフラ、セキュリティといった各開発領域の専門要員をアサインできず、最低限の要員数で全領域を担当します。主に、アプリケーション開発を主領域とする要員が他領域も担当する体制になることが多いため、任せられる部分は可能な限りクラウド側に任せたいと考えています。

開発が終わるとシステムを運用・保守していく必要があります。「サービスの価値≒アプリケーションが提供する機能、アプリケーションが提供する体験」であるため、インフラの運用保守作業を低減し、アプリケーションの機能追加に要員や時間を集中することが重要です。

2.1.2. テクニカル面

ビジネス面の背景を満たす技術として Serverless があります。

Note
Serverless は、使用するコンテキストにより定義が異なります。
本記事では、CNCF Serverless Whitepaper v1.0[参考資料2]にて定義されている Functions-as-a-Service (FaaS) の意味とします。

ServerLessの大きな特徴は、以下のとおりです。

Table 1. ServerLessの大きな特徴

特徴 概要
インフラ構築、運用保守の省力化 サーバ構築が不要。パッチ適用やHW,SWアップデートといった保守作業も不要。
高スケーラビリティ 処理状況に応じて自動的にスケール。
高可用性 クラウドベンダによる自動的な高可用性設定。
コスト低減 処理の実行時間単位の課金。

メガクラウド3社(Amazon Web Services、Microsoft Azure、Google Cloud Platform)では、各社からFaaSサービスが提供されています。
本記事においては、グローバルでのシェアが高く弊社でも利用実績の多いAWSが提供する AWS Lambda を検証対象とします。

Table 2. メガクラウド3社のFaaSサービス

No. クラウドベンダー名 サービス名
1 Amazon Web Services AWS Lambda
2 Microsoft Azure Azure Functions
3 Google Cloud Platform Google Cloud Functions
Note
AWS Lambda公式の概要説明は以下のとおりです。
AWS Lambda は、サーバーをプロビジョニングまたは管理せずにコードを実行できるようにするコンピューティングサービスです。 Lambda は可用性の高いコンピューティングインフラストラクチャでコードを実行し、コンピューティングリソースに関するすべての管理を行います。これには、サーバーおよびオペレーティングシステムのメンテナンス、容量のプロビジョニングおよび自動スケーリング、さらにログ記録などが含まれます。Lambda で必要なことは、サポートするいずれかの言語ランタイムにコードを与えることだけです。
https://docs.aws.amazon.com/ja_jp/lambda/latest/dg/welcome.html より引用。2023/12/12 10:10閲覧)

2.2. 課題感

メリットの多いAWS Lambdaですが、弊社での活用するにあたり、筆者が課題と感じている点は以下3点です。

  • 弊社の既存アセットがAWS Lambdaを採用するアプリケーション開発に活用できない
    • AWS Lambdaを利用することで、インフラ構築、運用・保守の工数低減を図りアプリケーション開発に集中することが可能になります。そこに弊社の既存アセットを組合せることができれば、よりアジリティ高く、より品質よく開発することが可能になると考えられます。
      しかしながら、弊社のアプリケーション開発のアセットは、OSSとして公開しているTERASOLUNA Server/Batch Framework[参考資料1]を代表として、プログラム言語にJavaを採用したものが多いです。
      TERASOLUNA Server/Batch FrameworkはAWS Lambdaに対応していないため、そのまま使用することはできません。弊社が抱えるJavaの開発ノウハウや、Java開発要員の活用は可能ですが、TERASOLUNA Server/Batch Frameworkを使えないことで効果が減少します。

  • Javaは起動(コールドスタート)が遅い
    • 弊社のプロジェクトでもAWS Lambdaを使用しているケースはありますが、Javaは起動(コールドスタート)の遅さにより敬遠されており、JavascriptやPythonが選択される傾向にあります。
      Datadog社の2023年5月の調査によると、世界的にみてもJavaは1割程度とかなり低いシェアとなっています。

.AWS Lambdaの言語ごとの呼び出し割合

  • AWS Lambdaを前提にAP開発するとコンテナや仮想マシンへの乗せ換え時に改修が必要
    • サービスが順調に成長していくと、AWS Lambdaではコスト効率が悪くなる等の理由でコンテナや仮想マシンへの載せ替えを検討するタイミングが来ます。
      AWS Lambdaを使用したアプリケーションは、AWS Lambda特有の実装の考え方やインターフェースに適合する必要があります。
      Webアプリケーションを構築する場合でも、FaaSの名前のとおり基本的には1つの関数を実装することになるため、通常Javaの開発で使用するアプリケーションフレームワーク(Spring Framework、TERASOLUNA Server Framework、Jakarta EE 等)をそのまま用いることはできません。AWS Lambdaのインターフェースを使用して実装しているため、コンテナや仮想マシンへ載せ替え時には改修が必要です。

2.3. 課題感への対応

「課題1:弊社のアセットがAWS Lambdaを採用するアプリケーションにおいて活用できない」、「課題3:AWS Lambdaを前提にAP開発するとコンテナや仮想マシンへの乗せ換え時に改修が必要」を解決する方法として AWS Lambda WebAdapter(以降WebAdapterと呼ぶ)があります。

Table 3. WebAdapter概要

No. 機能名 概要
1 WebAdapter AWS Lambda上でWebアプリケーションを実行するAWS公式OSSツール。VM やコンテナ用に実装されたWebアプリケーションを、ほぼそのままAWS Lambdaでも動作可能にする。

WebAdapter概要図

「課題2:起動(コールドスタート)が遅い」を解決する方法として、以下3種類があります。

Table 4. 起動(コールドスタート)が遅い課題の解決方法

No. カテゴリ 機能名 概要
1 AWS Lambdaの機能 SnapStart Lambda関数の関数バージョンを発行するときに、初期化された実行環境のメモリとディスク状態のスナップショットを作成し、関数呼び出し時にそのスナップショットを使用する機能。コールドスタート時にゼロから初期化するのではなく、スナップショットから新しい実行環境を再開するので、Lambda関数呼び出し時のレイテンシーが短縮される。スナップショット作成時間は課金されない。
2 Provisioned Concurrency 同時並行数をリクエストがくる前に指定した数を事前に立ち上げておく機能。ウォームスタンバイ状態になるためLambda関数呼び出し時のレイテンシーが短縮される。Lambdaを待機させるため追加の課金がされる。
3 AWS Lambda以外の機能 GraalVM Native Image JavaコードをNative Imageと呼ばれるスタンドアロンの実行可能ファイルに事前にコンパイルするGraalVMの機能。この実行可能ファイルには、アプリケーション クラス、その依存関係からのクラス、ランタイムライブラリクラス、および JDK から静的にリンクされたネイティブコードが含まれており、高速に起動、動作する。
Note
各機能の詳細はAWSとGraalVMの公式ドキュメントをご参照ください。

3. 検証

3.1. 検証内容

今回の検証では、以下2点を実施し前述の各課題の解決可否を確認します。

  • TERASOLUNA Server Frameworkを、AWS Lambda上でWebAdapterを用い動作可能か確認する
  • SnapStartを使用し、実用に耐えるレスポンスタイムとなるか確認する

3.2. 検証環境構成

AWSサービスは、Amazon API Gateway + AWS Lambda + CloudWatch Logsの構成です。

Lambdaで動作させるアプリケーションは、TERASOLUNA Server Frameworkのチュートリアルアプリケーション(以降Todoアプリと呼ぶ)[参考資料5]を使用します。

検証環境構成図

  • 検証環境構成図

TodoアプリはWebアプリケーションでTodo管理を行うアプリケーションです。主な特徴は以下です。

  • Java言語でTERASOLUNA Server Frameworkをアプリケーションフレームワークとして採用
  • ビルドツールはApache Mavenを使用
  • War形式にビルドされ、Apache Tomcat等のAPサーバ(サーブレットコンテナ)にデプロイして動作
  • DB等の外部アクセスやディスクへのデータ永続化は無し
  • Cookieによるセッション管理を実施
CAUTION
ブラウザからのHTTPリクエストは同じLambdaインスタンスに送信されない可能性があります。その場合、今回の検証環境構成では外部のセッションストアを用意していないため、登録したTodoが表示されない等の挙動になります。

3.3. 検証環境構築

検証を行うためのアプリケーションとAWSサービスを構築します。
アプリケーションは、TERASOLUNA Server FrameworkのチュートリアルアプリのTodoアプリをベースにして、改修を行います。 実際にやってみる場合は、以下からファイル一式をダウンロードしてください。

TERASOLUNA Server Frameworkのチュートリアルアプリ(Todo)
https://github.com/terasolunaorg/tutorial-apps/tree/release/5.8.1.RELEASE/todo/todo

Note
検証2は検証1の完成が前提になりますので、実際に構築する際は検証1から実施してください。
完成版のソースコードは、以下GitHubリポジトリをご参照ください。
https://github.com/honzan0821/lambda-webadapter-snapstart

3.3.1. 検証1:TERASOLUNA Server FrameworkをAWS Lambda Web Adapterを用い動作可能か確認する

Todoアプリで使用しているTERASOLUNA Server Frameworkは、War形式でビルドし、Apache Tomcat等のAPサーバにデプロイすることで動作します。
AWS LambdaでWebAdapterを用い動作させるためには、Javaアプリケーション実装とAWSサービス設定の2種類の対応が必要です。

各種類毎に対応が必要な項目を説明します。

Javaアプリケーション実装

  • Embedded Tomcatの組み込み
  • 実行可能Jar(War)ビルド用の設定
  • WebAdapter用のハンドラ(run.sh)の実装
  • ログ出力先を標準出力に変更
  • 実行可能Jar(War)としてビルド
Note
WebAdapterは、コンテナランタイムにも対応していますが、SnapStartは検証時点(2023/12/01)ではJavaランタイムにしか対応していないため、Zip形式(jar、war含む)でAWS Lambdaへデプロイする必要があります。

AWSサービス設定

  • WebAdapterの動作設定
  • トリガーとなるAmazon API Gatewayの設定
3.3.1.1. Javaアプリケーション実装
3.3.1.1.1. Embedded Tomcatの組み込み

Embedded Tomcatの組み込みは、以下2つの実装/設定が必要です。

  • pom.xmlに依存関係の追加
  • Embedded Tomcatの初期化とWebアプリケーション(todoアプリ)の読み込み、Tomcat起動のコードの実装

各々の実装内容を説明します。

pom.xmlに依存関係の追加

pom.xmlに、Embedded Tomcatが必要とする依存関係の追加を行います。

pom.xml(改修)

     <!-- ommit. -->

      <!-- (1) -->
      <dependency>
          <groupId>org.apache.tomcat.embed</groupId>
          <artifactId>tomcat-embed-core</artifactId>
          <version>${tomcat.version}</version>
      </dependency>
      <dependency>
          <groupId>org.apache.tomcat.embed</groupId>
          <artifactId>tomcat-embed-jasper</artifactId>
          <version>${tomcat.version}</version>
      </dependency>
      <dependency>
          <groupId>org.apache.tomcat</groupId>
          <artifactId>tomcat-jasper-el</artifactId>
          <version>${tomcat.version}</version>
      </dependency>
      <dependency>
          <groupId>org.apache.tomcat</groupId>
          <artifactId>tomcat-jsp-api</artifactId>
          <version>${tomcat.version}</version>
      </dependency>
      <dependency>
          <groupId>org.eclipse.jdt</groupId>
          <artifactId>ecj</artifactId>
          <version>${ecj.version}</version>
      </dependency>
      <dependency>
          <groupId>org.glassfish</groupId>
          <artifactId>jakarta.el</artifactId>
          <version>${jakarta.el.version}</version>
      </dependency>

  <!-- ommit. --->

  <properties>
      <tomcat.version>10.1.15</tomcat.version>
      <ecj.version>3.24.0</ecj.version>
      <jakarta.el.version>4.0.2</jakarta.el.version>
  </properties>

  <!-- ommit. --->

(1) 組み込みTomcatの動作に必要な依存関係を追加します。

Embedded Tomcatの初期化とWebアプリケーション(todoアプリ)の読み込み、Tomcat起動のコードの実装

Embedded Tomcatの初期化とWebアプリケーション(todoアプリ)の読み込み、Tomcat起動のコードの実装を行います。

com/example/todo/Main.java(新規作成)

  // ommit.

  private static String CONTEXT_PATH = System.getenv("X_CONTEXT_PATH") != null ?
      System.getenv("X_CONTEXT_PATH") : "/todo";

  private static String BASE_DIR = System.getProperty("java.io.tmpdir");

  private static boolean isIDE = System.getenv("X_IDE") != null;

  public static void main(String[] args) {

      // (1)
      Tomcat tomcat = new Tomcat();
      tomcat.setBaseDir(BASE_DIR);
      tomcat.setPort(PORT);
      tomcat.getConnector();
      tomcat.getHost().setAppBase(BASE_DIR);

      // (2)
      Context ctx = null;
      if (isIDE) {
        // for IDE.
        ctx = tomcat.addWebapp(CONTEXT_PATH, new File("src/main/webapp/").getAbsolutePath());
      } else {
        ctx = tomcat.addWebapp(CONTEXT_PATH,
            Main.class.getProtectionDomain().getCodeSource().getLocation().getPath()); // (3)
      }

      // (4)
      ctx.start();
      tomcat.start();

      // (5)
      tomcat.getServer().await();
  }

  // ommit.

(1) Embedded Tomcatインスタンスを作成、必要な設定を行います。システムプロパティからtmpディレクトリパスを取得しています。

(2) Webアプリケーション(War)をAWS Lambdaにデプロイすると、/var/task にWarが解凍され配置されます。Tomcatに読み込むWar構成のディレクトリルートパスを指定し、Webアプリケーションを読み込ませます。

(3) アプリケーションのルートディレクトリにあたる /var/task/ が取得できます。

(4) Tomcatを起動します。

(5) mainメソッドが終了しないように待機させます。

3.3.1.1.2. 実行可能Jar(War)ビルド用の設定

pom.xmlに実行可能Jar化(war)を生成するためにPluginの設定修正と、新規追加を行います。

pom.xml(改修)(maven-war-plugin設定の修正)

        <!-- ommit. -->

        <pluginManagement>
            <plugins>
                <!-- ommit. -->

                <plugin>
                    <groupId>org.apache.maven.plugins</groupId>
                    <artifactId>maven-war-plugin</artifactId>
                    <version>${org.apache.maven.plugins.maven-war-plugin.version}</version>
                    <configuration>
                        <warName>${project.artifactId}</warName>
                        <archive>
                            <addMavenDescriptor>false</addMavenDescriptor>
                            <manifest>
                                <addDefaultImplementationEntries>true</addDefaultImplementationEntries>
                                <mainClass>com.example.todo.Main</mainClass> <!-- for executable jar. --><!-- (1) -->
                            </manifest>
                        </archive>
                        <packagingExcludes>
                            WEB-INF/classes/com/example/todo/Main.class
                            ,WEB-INF/lib/tomcat-embed-core-*.jar
                            ,WEB-INF/lib/tomcat-embed-jasper-*.jar
                            ,WEB-INF/lib/tomcat-jasper-el-*.jar
                            ,WEB-INF/lib/tomcat-jsp-api-*.jar
                            ,WEB-INF/lib/tomcat-servlet-api-*.jar
                            ,WEB-INF/lib/tomcat-el-api-*.jar
                            ,WEB-INF/lib/tomcat-annotations-api-*.jar
                            ,WEB-INF/lib/ecj-*.jar
                            ,%regex[WEB-INF/lib/jakarta.el\-\d.+.jar]
                        </packagingExcludes> <!-- (2) -->
                        <!-- for executable jar and Embedded Tomcat. -->

                    </configuration>
                </plugin>

                <!-- ommit. -->

            </plugins>
        </pluginManagement>

        <!-- ommit. -->

(1) <mainClass>に起動時に使用するmainメソッドがあるClassをFQCNで指定します。

(2) <packagingExcludes>に組み込みTomcatの実行に必要となるclassとjarがWarのWEB-INF内に含まれないように指定します。ここで指定したclassとjarは、WEBアプリのクラスローダーではなく、親のクラスローダーで読み込まれる形にする必要があるため、後述するmaven-dependency-pluginとmaven-antrun-pluginでWar直下に配置されるようにします。

pom.xml(改修)(実行可能Jar用のPluginの追加)

        <!-- ommit. -->

        <plugins>
            <!-- for embedded tomcat. -->
            <!-- (1) -->
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-dependency-plugin</artifactId>
                <version>2.8</version>
                <executions>
                    <execution>
                        <id>embedded-tomcat-classpath</id>
                        <phase>prepare-package</phase>
                        <goals>
                            <goal>unpack-dependencies</goal>
                        </goals>
                        <configuration>
                            <includeGroupIds>
                                org.apache.tomcat
                                ,org.eclipse.jdt
                                ,org.glassfish
                            </includeGroupIds>
                            <excludeTypes>pom</excludeTypes>
                            <outputDirectory>${project.build.directory}/${project.build.finalName}</outputDirectory>
                        </configuration>
                    </execution>
                </executions>
            </plugin>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-antrun-plugin</artifactId>
                <version>3.1.0</version>
                <executions>
                    <execution>
                        <id>main-class-placement</id>
                        <phase>prepare-package</phase>
                        <goals>
                            <goal>run</goal>
                        </goals>
                        <configuration>
                            <target>
                                <copy
                                  todir="${project.build.directory}/${project.build.finalName}/">
                                    <fileset dir="${project.build.directory}/classes/">
                                        <include name="com/example/todo/Main.class"/>
                                    </fileset>
                                </copy>
                            </target>
                        </configuration>
                    </execution>
                </executions>
            </plugin>

        <!-- ommit. -->

        </plugins>

        <!-- ommit. -->

(1) 組み込みTomcatの実行に必要となるjar(includeGroupIdsで指定)とclass(copyで指定)を親のクラスローダーで読み込まれる形にするためWar直下に配置されるようにします。

3.3.1.1.3. WebAdapter用のハンドラ(run.sh)の実装

javaを実行するシェルスクリプトを定義します。

src/main/webapp/run.sh(新規作成)

#!/bin/sh

exec java -cp "./:lib/*" "com.example.todo.Main"
3.3.1.1.4. ログ出力先を標準出力に変更

AWS Lambdaでは/tmpしか書き込みができないため、ファイル出力する設定をコメントアウトし標準出力に出力する設定にすることで、CloudWatch Logsに連携されるようにします。

src/main/resources/logback.xml(改修)

    <!-- ommit. -->

    <!--    <appender name="APPLICATION_LOG_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">-->

    <!-- ommit. -->

    <!--    <appender name="MONITORING_LOG_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">-->

    <!-- ommit. -->

    <logger name="org.terasoluna.gfw.common.exception.ExceptionLogger.Monitoring" additivity="false">
        <level value="error" />
        

<!--        <appender-ref ref="MONITORING_LOG_FILE" />-->
    </logger>

    <!-- ommit. -->

    <root level="warn">
        <appender-ref ref="STDOUT" />
        <!--        <appender-ref ref="APPLICATION_LOG_FILE" />-->
    </root>
3.3.1.1.5. 実行可能Jar(War)としてビルド

前述の改修が完了次第 mvn clean package -Dmaven.test.skip を実行しビルドすると、todo.war が生成されます。こちらはWarですが実体はZip形式の圧縮ファイルなため、AWS Lambdaにそのままデプロイできます。

Note
AWS Lambdaへのデプロイでは関係ありませんが、ここでビルドしたWarファイルは実行可能Warのため、AWS Lambda以外でもコマンド java -jar todo.war で実行可能です。
3.3.1.2. AWSサービスの構築と設定

AWS Lambdaと、トリガーとなるAmazon API Gatewayの構築を実施します。通常のデフォルト設定の構築に加え、WebAdapterの動作に必要な設定は以下です。

Note
本記事では東京リージョンを使用しています。
3.3.1.2.1. AWS Lambdaの設定
  • ランタイムを Java17 に設定(TodoアプリがJava17前提です。)
  • メモリを 512MB に設定(動作確認したところ350MB弱は使用しますので余裕をみて512MBにしています。)
  • LambdaレイヤーにWebAdapterを追加
    arn:aws:lambda:${AWS::Region}:753240598075:layer:LambdaAdapterLayerX86:17
  • WebAdapterに必要な環境変数を設定
    AWS_LAMBDA_EXEC_WRAPPER: /opt/bootstrap
  • ランタイム設定のハンドラに run.sh を指定
  • 実行タイムアウトの延長(本検証では余裕をみて 1分 にしています。)
3.3.1.2.2. Amazon API Gatewayの設定
  • ステージ名を com/example/todo/Main.java で設定しているCONTEXT_PATHの todo に設定
Note
今回の検証では、簡略化のためAmazon API Gatewayは、APIタイプを HTTP API で構築し、ルートとメソッドを ANY /{proxy+} としています。
アクセスするURLは https://{API ID}.execute-api.ap-northeast-1.amazonaws.com/todo/todo/list です。
Note
検証1の構築完了時点でも動作はしますが、INITフェーズが長時間になるため、INITフェーズが規定時間で終わらずエラーになる可能性があります。動作したい場合、AWS Lambdaの環境変数に AWS_LWA_ASYNC_INIT=true を設定してください。詳細な説明はWebAdapterのGitHubPage(https://github.com/awslabs/aws-lambda-web-adapter)を参照してください。なお、この設定はSnapStartを設定している場合は false に設定するか、環境変数を削除してください。

ここまでの対応で、WebAdapterの用の構築は完了です。

3.3.2. 検証2:SnapStartを使用し、実用に耐えるレスポンスタイムとなるか確認する

次に、SnapStartを使用するための対応を行います。
SnapStartを動作させるため、検証1で作成したJavaコードとAWS Lambdaに以下の対応が必要です。

3.3.2.1. Javaアプリケーション実装

検証1で作成したJavaコードに初期化処理内で暖気運転するためのコードを追加します。

com/example/todo/app/base/HealthCheckRestController.java(新規作成)

import static com.example.todo.Main.isPrepared;

@RestController
@RequestMapping("health")
public class HealthCheckRestController {

  @GetMapping("")
  public ResponseEntity get() {

    // (1)
    if (isPrepared) {
      return new ResponseEntity(HttpStatus.OK);
    }
    return new ResponseEntity(HttpStatus.SERVICE_UNAVAILABLE);
  }

}

(1) 環境変数 AWS_LWA_READINESS_CHECK_PATH で設定した、WebAdapterがヘルスチェックするためのAPIを作成します。WebAdapterがヘルスチェックに成功すると、その時点でINITフェーズが終了してしまうため、暖気運転を実行後にWebAdapterにHTTPステータス200を返すようにします。

com/example/todo/Main.java(修正)

  // ommit.

  public static volatile boolean isPrepared;

  private static String X_WARM_UP_TARGET_PATHS = System.getenv("X_WARM_UP_TARGET_PATHS") != null ?
      System.getenv("X_WARM_UP_TARGET_PATHS") : "/todo/list";

  public static void main(String[] args) {

      // ommit.

      ctx.start();
      tomcat.start();

      // (1)
      warmUp();

      // (2)
      isPrepared = true;

      tomcat.getServer().await();
  }

  private static void warmUp() throws IOException, InterruptedException {
    String[] targetUrls = X_WARM_UP_TARGET_PATHS.split(",");
    for (String ele : targetUrls) {
      HttpClient httpClient = HttpClient.newHttpClient();
      HttpRequest request = HttpRequest.newBuilder(
          URI.create("http://localhost:8080" + CONTEXT_PATH + ele)).build();
      httpClient.send(request, HttpResponse.BodyHandlers.ofString());
    }
  }

(1) tomcat.start()を実行するとPort8080でTomcatがリクエストを受付します。受付後に暖気運転として、暖気運転しておきたいAPIへHTTPリクエストを実行します。

(2) 暖気運転完了後にWebAdapterにHTTPステータス200を返すようにします。

Note
暖気運転をしない場合、初回のリクエスト時にJSPのコンパイルやJavaの最適化処理等が実行され、処理完了時間が長くなります。筆者が実行したところ、処理完了まで7411ミリ秒程度かかるため、暖気運転の検討は必須です。
Note
com/example/todo/Main.javaについて、GitHubのコードでは、mainメソッドに実装しているコードをWebAdapterからのリクエスト開始のタイミングをずらすためStatic Initializerに移しています。
3.3.2.2. AWSサービスの構築と設定

AWS LambdaとAmazon API Gatewayに以下設定を行います。

3.3.2.2.1. AWS Lambdaの設定

バージョン

  • 新しいバージョンを発行

一般設定(基本設定)

  • SnapStart設定を PublishedVersionsSnapstart に設定
  • エフェメラルストレージを 512MB に設定(SnapStartの制約)

ランタイム設定

  • ランタイムを Java に設定(今回の検証ではJava17にしています。)
  • アーキテクチャを x86_64 に設定(SnapStartの制約)

環境変数

  • AWS_LWA_READINESS_CHECK_PATH=/todo/health を設定
Note
検証1の動作確認でAWS Lambdaの環境変数に AWS_LWA_ASYNC_INIT=true を設定している場合は値を false に設定するか、環境変数を削除してください。
3.3.2.2.2. Amazon API Gatewayの設定
  • 新しいバージョンとAmazon API GatewayのAPIを統合

以上で構築完了です。

3.4. 検証結果

今回の検証では、2点検証しました。

3.4.1. TERASOLUNA Server FrameworkをAWS Lambda上でWeb Adapterを用い動作するか

ブラウザからTodoアプリにアクセスしたところ、Tomcat上で起動した場合と同様にアプリケーションを実行できました。

しかしながら、コールドスタートが発生した場合、メモリを512MBに設定した環境でTomcatの起動とSpring Framweorkの初期化処理で40秒程度かかりました。
当初から予測できていましたが、実運用上現実的な時間で無いため、検証2のSnapStartや、Provisioned-concurrencyの併用を行わないと実用に耐えないことがわかりました。

3.4.2. SnapStartを使用し、実用に耐えるレスポンスタイムとなるか

SnapStartをONにすることで、OFFの場合には1つだったINITフェーズが、INITフェーズとRESTOREフェーズにわかれます。

新しいバージョンを発行した際に、INITフェーズが実行されSnapShotが作成されます。 以下のログのように、INIT_STARTが実行され、50秒程度で完了しています。

新しいバージョン発行時のCloudWatch Logs出力

timestamp,message
1701356694131,"INIT_START Runtime Version: java:17.v17  Runtime Version ARN: arn:aws:lambda:ap-northeast-1::runtime:10036bdb8988f08ecedb58a4d55b3195219317f0e7111bf497a8c91f761d8a80"
1701356695054,"Nov 30, 2023 3:04:54 PM com.example.todo.Main <clinit>"
1701356695054,"INFO: start static initializer."
1701356699633,"Nov 30, 2023 3:04:59 PM org.apache.catalina.startup.ContextConfig getDefaultWebXmlFragment

// ommit.

1701356743654,"Nov 30, 2023 3:05:43 PM com.example.todo.Main main"
1701356743654,"INFO: Server is awaiting."
1701356743694,"date:2023-11-30 15:05:43 thread:http-nio-8080-exec-1 X-Track:77d2e0f375134f2d88045cbcc187c5d1    level:TRACE logger:o.t.gfw.web.logging.TraceLoggingInterceptor      message:[END CONTROLLER  ] HealthCheckRestController.get()-> view=null, model=null"
1701356743713,"date:2023-11-30 15:05:43 thread:http-nio-8080-exec-1 X-Track:77d2e0f375134f2d88045cbcc187c5d1    level:WARN  logger:o.t.gfw.web.logging.TraceLoggingInterceptor      message:[HANDLING TIME   ] HealthCheckRestController.get()-> 6,057,859,794 ns > 3000000000"
1701356743733,"EXTENSION    Name: lambda-adapter    State: Ready    Events: []"
1701356743734,"INIT_REPORT Init Duration: 49603.01 ms"

初回のTodoアプリへのリクエスト時にAWS Lambdaのコールドスタートが発生した場合、RESTOREフェーズが実行されます。
以下のログのように、INITSTARTではなくRESTORESTARTが実行され、Restoreに654ミリ秒程度、全体で717ミリ秒程度で処理が完了しています。
2回目のリクエスト時は同一Lambdaで処理された場合にウォームスタートとなり、RESTORE_STARTが実行されません。その結果、111ミリ秒程度で処理が完了しています。
アプリケーションの処理内容は外部アクセスが無い軽量なものですが、コールドスタートでも1秒未満でレスポンスが返せることから実用に耐えるレベルであると言えます。

新しいバージョン発行時のCloudWatch Logs出力

########## 1回目 ##########
1701356909861,"RESTORE_START Runtime Version: java:17.v17   Runtime Version ARN: arn:aws:lambda:ap-northeast-1::runtime:10036bdb8988f08ecedb58a4d55b3195219317f0e7111bf497a8c91f761d8a80"
1701356910515,"RESTORE_REPORT Restore Duration: 654.29 ms"
1701356910534,"START RequestId: 8b305e5f-028b-4732-ae68-f0a56d60f41c Version: 49"
1701356911055,"date:2023-11-30 15:08:31 thread:http-nio-8080-exec-4 X-Track:a686308556fe42bcb8e2c857733f463d    level:TRACE logger:o.t.gfw.web.logging.TraceLoggingInterceptor      message:[START CONTROLLER] TodoController.list(Model)"
1701356911077,"date:2023-11-30 15:08:31 thread:http-nio-8080-exec-4 X-Track:a686308556fe42bcb8e2c857733f463d    level:TRACE logger:o.t.gfw.web.logging.TraceLoggingInterceptor      message:[END CONTROLLER  ] TodoController.list(Model)-> view=todo/list, model={todoForm=com.example.todo.app.todo.TodoForm@1d47d9e5, todos=[], org.springframework.validation.BindingResult.todoForm=org.springframework.validation.BeanPropertyBindingResult: 0 errors}"
1701356911078,"date:2023-11-30 15:08:31 thread:http-nio-8080-exec-4 X-Track:a686308556fe42bcb8e2c857733f463d    level:TRACE logger:o.t.gfw.web.logging.TraceLoggingInterceptor      message:[HANDLING TIME   ] TodoController.list(Model)-> 20,955,631 ns"
1701356911237,"END RequestId: 8b305e5f-028b-4732-ae68-f0a56d60f41c"
1701356911237,"REPORT RequestId: 8b305e5f-028b-4732-ae68-f0a56d60f41c   Duration: 716.72 ms Billed Duration: 717 ms Memory Size: 512 MB Max Memory Used: 328 MB Restore Duration: 654.29 ms Billed Restore Duration: 0 ms   "

########## 2回目 ##########
1701356915443,"START RequestId: b8d8f1ce-a89c-4f82-b624-c9cdd847e9b4 Version: 49"
1701356915477,"date:2023-11-30 15:08:35 thread:http-nio-8080-exec-8 X-Track:e798e56aa87549bda516a42220bb51cc    level:TRACE logger:o.t.gfw.web.logging.TraceLoggingInterceptor      message:[START CONTROLLER] TodoController.list(Model)"
1701356915495,"date:2023-11-30 15:08:35 thread:http-nio-8080-exec-8 X-Track:e798e56aa87549bda516a42220bb51cc    level:TRACE logger:o.t.gfw.web.logging.TraceLoggingInterceptor      message:[END CONTROLLER  ] TodoController.list(Model)-> view=todo/list, model={todoForm=com.example.todo.app.todo.TodoForm@24d4a423, todos=[], org.springframework.validation.BindingResult.todoForm=org.springframework.validation.BeanPropertyBindingResult: 0 errors}"
1701356915495,"date:2023-11-30 15:08:35 thread:http-nio-8080-exec-8 X-Track:e798e56aa87549bda516a42220bb51cc    level:TRACE logger:o.t.gfw.web.logging.TraceLoggingInterceptor      message:[HANDLING TIME   ] TodoController.list(Model)-> 856,744 ns"
1701356915554,"END RequestId: b8d8f1ce-a89c-4f82-b624-c9cdd847e9b4"
1701356915554,"REPORT RequestId: b8d8f1ce-a89c-4f82-b624-c9cdd847e9b4   Duration: 111.23 ms Billed Duration: 112 ms Memory Size: 512 MB Max Memory Used: 328 MB "

4. まとめ

4.1. 課題の解決可否

今回の検証では以下2点を課題として、その解決可否を確認しました。

結論としては、2点とも解決可能との結果でした。
この結果により、AWS Lambdaを使用したアプリケーション開発においても弊社がこれまで磨き上げたJava開発アセットを活用することができ、アジリティと品質の向上やインフラコスト低減を両立可能となる可能性が見えました。

Table 5. 課題解決可否のまとめ

# 課題 解決案 結果
1 ・ 弊社のアセット(開発ノウハウ、開発要員)がAWS Lambdaを採用するアプリケーションにおいて活用できない。
・ AWS Lambdaを前提にAP開発するとコンテナや仮想マシンへの乗せ換え時に改修が必要
TERASOLUNA Server Frameworkを、AWS Lambda Web Adapterを用い動作させる 組み込みTomcatを使ったmainメソッドの実装追加と実行可能Jar(war)の形式を用いることで、元々のコードベースを変更せずに動作可能。ただし、レスポンスは40秒程度となり現実的には使用不可。コールドスタート対策が必須。
2 Javaの起動(コールドスタート)が遅い SnapStartを使用する コールドスタートでも1秒未満でレスポンスが返却可能。

4.2. 今後の課題

今回AWS Lambdaで動作させたアプリケーションは非常に単純なアプリケーションです。本番稼働させるには以下のような課題を解決する必要があります。

  • アプリケーションの処理バリエーションの確認
    • 今回の検証では、TERASOLUNA Server Frameworkのチュートリアルアプリ(Todo)といった軽量なアプリケーションを対象に実施しています。実際の業務アプリケーション開発では各処理の処理量や規模が多くなるため、同様の規模や処理の種類を持ったアプリケーションを使用した検証が必要です。
      例:ファイルアップロード、データストアアクセス、外部セッションストア、静的コンテンツ配信先の変更 等

  • 組み込みTomcatのチューニング
    • 今回の検証では、組み込みTomcatの設定はデフォルト設定のため、実稼働にあたってはチューニングが必要です。
      例:acceptキュー数、コネクション数、Workerスレッド数、タイムアウト設定 等

  • SnapStartの制限事項に抵触する可能性がある
    • SnapStartは、2023/12/4現在、いくつか制限事項や留意事項がドキュメントに記載されています。実際に開発アプリケーションを想定し、制限事項に抵触しないか検証が必要です。
      例えば、512MBを超えるエフェメラルストレージの非サポートは、JSP等のテンプレートの数が膨大になった場合や、ファイルアップロード等で大規模な一時ファイルを生成する場合にはこの制限に抵触する可能性があります。
  • WebAdapterは推奨とは言われていない
    • AWS DevDay 2022のセッション資料で「このセッションはLambda上にWEBフレームワークを載せることを推奨するものではありません。」と述べており、AWS Lambdaの利用方法として推奨されるものではないことが伺えます。非推奨とも言われていませんが、重要なシステムにおける本番での利用にはさらなる検証が必要です。

5. おわりに

今回は、AWS Lambdaを活用したJava既存アセットの活用の取り組みについてご紹介をさせていただきました。
JavaはServerlessでも活用できるプログラム言語であり、弊社はプロジェクト開発を通じて磨き上げたアセットや開発ノウハウ、開発要員を活用すべく技術調査を行っています。今後も技術ノウハウを蓄積し、お客様のご支援ができるように準備を進めてまいります。

6. 関連リンク

  1. TERASOLUNA Server/Batch Framework
    https://www.terasoluna.jp/product/framework/
  2. AWS Lambda デベロッパーガイド AWS Lambda の概要
    https://docs.aws.amazon.com/ja_jp/lambda/latest/dg/welcome.html
  3. AWS Lambdaの言語ごとの呼び出し割合(datadog調査)2023
    https://www.datadoghq.com/ja/state-of-serverless/
  4. TERASOLUNA Server Frameworkのチュートリアルアプリ(Todo)
    https://github.com/terasolunaorg/tutorial-apps/tree/release/5.8.1.RELEASE/todo/todo
  5. AWS Lambda デベロッパーガイド Lambda SnapStart による起動パフォーマンスの向上 サポートされている機能と制限事項
    https://docs.aws.amazon.com/ja_jp/lambda/latest/dg/snapstart.html#snapstart-runtimes
  6. AWS Lambda の上でいろんなWEB フレームワークを動かそう! / Web Frameworks on Lambda
    https://speakerdeck.com/_kensh/web-frameworks-on-lambda

[PR]提供:NTTデータ