1 of 54

Javaサーバレス本番稼働一年を振り返って�JJUG CCC 2023 Spring 登壇資料

Conoris Technologies

CTO/CPO 近藤徳行

2 of 54

自己紹介

近藤 徳行(こんどう とくゆき)��企業向けERP開発ベンダーにて内部統制系製品の開発導入保守を担当

2021年9月からConoris Technologies CTO/CPO

セキュリティチェック業務支援SaaS(ニッチ領域)開発・運用中

3 of 54

本セッションでは

StartUpの一人目エンジニアとしてJava×サーバレスでSaaS開発、�本番環境での1年の運用を振り返り、� どういう問題があり、� どのようなソリューションがあったか、� 自分なりにどう苦労工夫したかを� 開発時の知見、運用時のデータを踏まえてお伝えします。�

2021年9月 開発開始→Provisioned Concccurency採用�2022年4月 ベータ版リリース�2022年7月 GraalVM native imageの全面採用�2023年2月 主な関数へのSnapStartへの切り替えを実施

4 of 54

Agenda

1, 自己紹介�2, 前提 なぜサーバレス / Java なのか?� アプリケーション対象領域・アーキテクチャ・利用技術etc�3, AWS Lambdaの基本構造とJava利用時の問題�4, Solution: Provisioned Concurrency + Cloud Watch�5, Solution: GraalVM Native-image�6, Solution: Lambda SnapStart(CRaC実装)�7, まとめ

5 of 54

なぜサーバレスなのか?

1、Computing処理を実行した時間だけ課金される料金形態。��  StartUp→市場が存在するかわからない領域のSaaS開発�  コストを削減し会社が存在する期間を延ばす(チャレンジする期間   を延ばす)上で最善の選択。� (市場が未成熟で使う人がいない時に課金されない)

2、AutoScaling�  Mangementサービスとして提供されるため、自動でスケーリング�  (アプリ開発者としてはインフラ管理を委譲したい。)

ビジネス的、運用的に絶対に取り入れたい技術。� (特にコストのインパクトは劇的)

6 of 54

なぜJavaなのか?

B2B領域のSaaSを開発者一人で0から全部実装なんてできない。�→OSSの利用を検討

 企業向けの業務領域(歴史的経緯)的に圧倒的にJavaが強く� 絶対に使いたいOSSというのが全てJava言語��やっぱりJavaしかない

Java×サーバレスで業務アプリケーションを作るという挑戦に。

7 of 54

[参考]どんなアプリケーションなのか?[前提]

業務アプリケーション概要 (数値は現在のもの)� 顧客(管理者ユーザ)がいろいろ設定できて(管理系機能が多い)� 設定に基づいて画面が表示されるようなB2B向けの業務アプリ� 業務フローをカバーすべく多機能を実装 = 処理の種類が多い� ニッチ業務のためアクセス数は少ない

テーブル種別

テーブル数

外部ライブラリ

48

自社管理

39

合計

87

コード概要

DB概要

Query(Read)

63

Mutation

73

合計

136

GraphQL概要

8 of 54

[参考]アーキテクチャ概要[前提]

今日の発表範囲��WebアプリケーションとしてLambdaを利用するユースケース

アーキテクチャに関する過去の公開資料��Amplifyで企業向けSaaS作ってみた

http://bit.ly/35MWQvh��マルチテナント実装事例

http://bit.ly/3QUMxaU

9 of 54

[参考]Java関連技術スタック(時系列)

ProvisionedConcurrency

GraalVM native-image

Lambda SnapStart

Infrastracture

AWS Lambda

Runtime

Amazon correto11

GraalVM native-image

Amazon correto11

Framework

-

Micronaut

CI/CD

gradle

Amplify CI/CD

Amplify CLI + Cloud9 (manual deploy)

DB

Aurora (Postgres)

Aurora Serverless V2(Postgres)

10 of 54

AWS Lambdaの概要

AWS Lambdaで起動する処理を関数という単位で管理します。�関数は実行するソースコードを持ち、Javaであれば1つのJarファイルが基本の形になります。��関数が起動されるとコンテナが割り当てられ、�JarファイルがDeployされたのち、�処理が起動される形となります。

11 of 54

AWS LambdaのColdStart

関数が起動されると、コンテナが起動しINIT処理を実施します。�INIT処理中にJar形式(zip)のソースコードを、コンテナにダウンロードします。�INIT処理(クラスロードなどを含む)が完了すると処理を実行します。�

12 of 54

AWS LambdaのWarmStart

すでに起動されているコンテナ(インスタンス)がある場合は、再利用します。�ソースコードのダウンロードやクラスロードはスキップされて処理が実行されるため、ColdStartに比べて高速に動作します。

前回起動時から約5分以内に同じ関数が起動された場合

13 of 54

AWS LambdaのShutDown

関数実行終了から約5分程度経過すると、自動的にコンテナは終了します。�次回起動時はColdStartからスタートします。

前回起動時から約5分以上たった場合

14 of 54

関数実行中に関数を起動するとどうなるのか?

AutoScaleされます。�具体的にはインスタンス2がColdStartで起動します。

インスタンス1

インスタンス2

処理中

15 of 54

とはいえ関数をどう書けばいいのか?

フレームワーク� 2021時点で既存のフレームワークの大部分がサーバでの稼働を想定� あるいはそこから関数化へ切り出す目的のものなど� �0から書こうと思った時に適切なフレームワークを見つけられず。�思想の合うものが出てきた時に載せ替えられるように�だけしておこうと決定�→フレームワークなしで実装� (StandAloneなJavaに近い実装イメージ)��※現在はMicronautというフレームワークを利用

Infrastracture

AWS Lambda

Runtime

Amazon correto11

Framework

-

CI/CD

gradle

Amplify CI/CD

DB

Aurora (Postgres)

16 of 54

関数分割戦略

関数ってどういう単位で分割するのが良いのでしょうか?

集中するのか?�1関数でなんでもできる構造

分散するのか?� 1関数でできることが限られる構造

機能A

機能B

機能C

機能A

機能B

機能C

17 of 54

関数分割戦略①

1,関数の数が多くなるとCloudへのDeploy/管理コストが上がる�2,関数が分散すると、ユーザが別操作をするとColdStartする可能性 が高くなる。(利用ユーザが少ない状況を想定)�

関数A� 機能1

関数B� 機能2

関数A� 機能1� 機能2

1回目 Cold Start

2回目 Warm Start

1回目 Cold Start

2回目 Cold Start

集中型

分散型

18 of 54

関数分割戦略②

仮に機能を集中させて大きな関数にするとしても�関数のサイズが大きくなると、起動処理は遅くなる。�重いライブラリを利用する処理とそれを利用しない処理を1つの関数にしてしまうと、ライブラリを使用しない処理は無駄な起動時間を必要としてしまう。

関数A� 機能1

関数B� 機能2

ライブラリ利用処理

ライブラリ非利用処理

関数A� 機能1� 機能2

ライブラリ利用処理

ライブラリ非利用処理

重めライブラリ

重めライブラリ

重いライブラリ同居型

重いライブラリ棲み分け型

19 of 54

関数分割戦略まとめ

少人数開発のためDeployのコストを抑えたい。�ニッチ業務のためアクセス数が少なく、色々な処理にアクセスする�→可能な限り機能が集中する関数とし、WarmStartを最大限利用する� しかし、利用ライブラリの単位で分割し2つの関数に分割。

重い関数

普通関数

外部OSSライブラリ利用の重めの関数�外部ライブラリの初期化処理に時間がかかる��DBにもアクセスしてCRUDを行う。

よくある業務アプリ。��DBにアクセスしてCRUDを行う。

20 of 54

ColdStartとWarmStart処理時間例(普通関数)

DBアクセスする普通の関数��関数処理時間の

1分間あたりの最大値��(ソースコードは2023/05)

ColdStartで�3秒程度

WarmStartで�500msec以下

21 of 54

ColdStartとWarmStart処理時間例(重い関数)

外部ライブラリによる初期化処理が重たい関数��関数処理時間の1分間あたりの最大値��(ソースコードは2023/05)

ColdStartで�18秒程度

WarmStartで�2秒前後

22 of 54

JavaだとColdStartに時間がかかりすぎる

Javaの起動時間はnode.jsやPythonに比べて遅い。��①JVMの起動、クラスの読み込みに時間がかかること�②処理が短時間で終わるため、HotSpotVM(実行時のJITコンパイル)が 効きづらい(インスタンスのライフサイクルにより破棄される)

初期起動は遅いしJITコンパイルもうまく効かない�→サーバレスに対して圧倒的に不利な状況

23 of 54

この問題に対してどう対応してきたか?

ソリューション

方法

効果

課題

Provisioned Concurrency �+ CloudWatch�(2021 / 11)

GraalVM native-image�(2022 / 07)

Lambda Snapstart�(2023 / 02)

24 of 54

2021年当時の対応方法

1, 5分に一回何かしらの(空)処理を実施して、warmStart状態をkeep� →AutoScalingした時にNインスタンスwarm状態にする機構が必要。�  しかし正確にNインスタンスwarm状態にできるとは考えられない。��2, Provisioned Concurrency という機能を利用して、インスタンスを待機さ せておきwarmStartと同等の効果を狙う。� →AutoScaling対策としてCloudWatchで負荷状況を監視し、�  使用率が高い場合に待機させるインスタンスを増加するように設定。��Provisioned Conccurency + Cloud Watchを採用

25 of 54

Provisioned Conccurency + Cloud Watch実装

Provisioned Conccurencyにて待機インスタンスを3に指定��CloudWatchにて15分に一回(当時最頻)メトリクスを確認し、利用率が60%を超えていた場合待機インスタンスの数を追加。���参考: Provisioned Conccurency

26 of 54

Provisioned Conccurency + Cloud Watch

インスタンス1

ProvisionedConcurrencyUtilization

インスタンス2

インスタンス3

インスタンス4

ProvisionedConcurrencyUtilization

ProvisionedConcurrencyUtilization

0.5

0.5

0

0.7

0.7

1.0

0.6 >

Alert

Action�Provisioned Concurrency 追加

0.5

0.5

0.5

0.5

15分間隔(当時)

27 of 54

Provisioned Conccurency + Cloud Watch 課題①

1, Provisioned Concurrencyは有料であるため待機している� インスタンス毎に課金が発生する。�� 金額それ自体よりも、� 今後利用ユーザが増えるにつれ� コストが増大するという構造が難点。�� 

28 of 54

Provisioned Conccurency + Cloud Watch 課題①

1, Provisioned Concurrencyは有料であるため待機している� インスタンス毎に課金が発生する。�� 金額それ自体よりも、� 今後利用ユーザが増えるにつれ� コストが増大するという構造が難点。�� サーバレスにする意味があるのか

29 of 54

Provisioned Conccurency + Cloud Watch 課題②

多くのケースでColdStartを防げていたが、15分に一回のアラートでしか検知できないためか、急なスケーリングに対応できず、ColdStartが発生

実際に週に1度程度ColdStartと思しき処理が起きていたことがわかる

重い関数��関数処理時間の�1時間あたり最大値�(Production env)

30 of 54

効果と課題

ソリューション

方法

効果

課題

Provisioned Concurrency �+ CloudWatch�(2021 / 11)

インスタンス待機によるWarmStart

ColdStartの低減

コストがかかる�ColdStartがゼロではない

GraalVM native-image�(2022 / 07)

Lambda Snapstart�(2023 / 02)

31 of 54

GraalVM native-imageの採用

GraalVM native-imageではNativeコンパイラを用いてAOT(Ahead-Of-Time)コンパイルを実施することでOSで実行可能なNativeコードを生成できる��→LambdaのRuntimeにJVMがいらない(!)� すでにNativeコードになっているのでColdStartであろうが圧倒的な速度 が期待できる

コンパイラの種類

From

To

静的/動的

Javaコンパイラ

javaファイル

classファイル(バイトコード)

静的

動的コンパイラ(JIT)

javaバイトコード

Nativeコード

動的(メモリ上)

Nativeコンパイラ

javaファイル

Nativeコード実行ファイル

静的

一部改変。元アイデアこちらの記事より

詳細に関してはこちらの拙記事参照

32 of 54

Reflectionが使えない問題と解決法

AOTコンパイルの構造上、動的なクラスのロードができない。��→javaのコードまたはjsonファイルで、� Reflection等に利用するClassやresourceを事前定義することで� Reflectionなど動的なクラスを正しくNativeコンパイルできる。

�[参考]

Reflection対象をGraalVM側に指定するためのヘルパークラス

ヘルパーを利用して実際にReflectionするクラスを指定

※この定義ファイルで定義していないClassがReflection対象となった場合、Runtimeでエラーとなる

33 of 54

Reflectionが使えない問題

しかし、外部ライブラリのReflection箇所を知ることは容易ではない

34 of 54

Reflectionが使えない問題 nativeTestの利用

しかし、外部ライブラリのReflection箇所を知ることは容易ではない��→全ての処理に対してJUnitテストをnative-buildした状態で実行  (nativeTest)。落ちた箇所はReflection定義を見直すといった形で 地道に実装

native-build

Reflection�定義修正

nativeTest

OK

build-error

runtime-error

success

build-success

nativeTest�実行

35 of 54

Reflectionが使えない問題 nativeTestのTips

また、各テストに関しては永続層部分に関してモックを利用するのではなく、実DBアクセスまでを含めてテストするようにした。

これはO/R mapperがReflectionを利用していたりするため、I/Oを含めたテストにしておかないと native-imageで本当に動くかの確証が持てない。������※O/R Mapperに限らず全ての外部ライブラリで同じことが言える

36 of 54

GraalVM native-imageの実装

1, フレームワークとしてMicronautがnative-imageをサポートしてい たので採用。�2, JUnitテストの実装(DBレイヤまで実装) �3, Reflection定義の指定�4, gradle のビルドにて native-buildを実行するようにする�5, LambdaのRuntimeをJavaRuntimeではなくAmazon Linux2に変 更してDeploy���※その他、監視機構などの一定の諦め� JVMに依存した監視機構などは� そもそも利用できない。

ProvisionedConcurrency

GraalVM native-image

Runtime

Amazon correto11

GraalVM native-image

FrameWork

-

Micronaut

CI/CD

Amplify CI/CD

Amplify CLI + Cloud9 (manual deploy)

37 of 54

GraalVM native-imageの結果①

1, Lambdaのコストがほぼ0に(無料枠内)。�

7月半ばにnative-imageに置き換え

38 of 54

GraalVM native-imageの結果②ColdStart時速度

ColdStartでも数秒程度で処理完了(致命的な状況を回避)。�→初期化処理も処理速度が早ければ運用上なんとかなるという例。��最低限サーバレスで運用できるというレベルには達してきた。

重い関数�普通関数��関数処理時間の�1時間あたり最大値�(Production env)

39 of 54

GraalVM native-imageの結果②平均速度

普通の関数であれば1秒以下程度で返せることが多くなった。

重い関数に関しては時間がかかっていることから、�ライブラリの重さ/初期化処理自体が重いのではないかと分析。

重い関数�普通関数��関数処理時間の�6時間あたり平均値�(Production env)

40 of 54

GraalVM native-imageの課題①実装コストと難易度

実装コスト� 普段の開発 + native-imageの対応でタスクが純粋に増える

実装

JUnit

native-build

Reflection�定義修正

nativeTest

build-error

runtime-error

通常開発タスク

native-image対応タスク

41 of 54

GraalVM native-imageの課題①実装コストと難易度

実装難易度が高い� native-build、nativeTestを実施した際のエラーがわかりにくい。� 普通のJava開発で見たことがない類のエラーになりがち。

 � 実際ほぼビルドのオプション、Reflection定義で解消できたが、� これを将来的にチームメンバーに強いるのは厳しいと感じた。� 

42 of 54

GraalVM native-imageの課題②ビルドコスト

native-buildに時間がかかる� ローカルPCで3-5分程度(2021 MacBookPro M1 Pro)� Git Hub Actions → 30-40min overでタイムアウト

native-build

Reflection�定義修正

nativeTest

build-error

runtime-error

build-success

nativeTest�実行

3-5分

数秒

43 of 54

GraalVM native-imageの課題③Lambdaサイズ

Reflectionする対象クラスをどんどん増やしていくと、�Lambdaへのアップロードでサイズオーバーを引き起こす。�(Lambdaサイズ上限:250MB)

例:某チャット系ツールのSDKを利用する�   nativeTestでReflectionでクラスが見つからずエラー�  →POJOの変換でReflectonを利用している模様�  →Reflection定義を追加を修正してテスト再実行�  →エラー。依存するPOJOのクラスが見つからない×N回�  →特定パッケージ以下のReflectionを全部指定。パスする�  →Lambda upload時サイズオーバーが発生。� →最終的にhttpRequestでの実装に切り替える

※Lambdaではなく、ECRでデプロイするという案も考えられるが、機能が使えなくなる、および管理コスト増大のため非採択

native-�build

Reflection�定義修正

nativeTest

runtime-error

Upload error

success

44 of 54

効果と課題

ソリューション

方法

効果

課題

Provisioned Concurrency �+ CloudWatch�(2021 / 11)

インスタンス待機によるWarmStart

ColdStartの低減

コストがかかる�ColdStartがゼロではない

GraalVM native-image�(2022 / 07)

JVM不使用�nativeコード�高速化

理想的なコスト�ColdStartでも現実的な処理速度

開発コスト/難易度�Lambdaサイズ限界

Lambda Snapstart�(2023 / 02)

45 of 54

SnapStartの登場 (2022年11月 re:Invent)

Coordinated Restore at Checkpoint (CRaC)を実装した仕組みで��起動中のJVMプロセスの状態をsnapshotとして保存し、�ColdStart時にそのsnapshotから再実行する仕組み。��→特に初期化処理などをsnapshot取得前に実行することで、

 実処理で初期化処理をSkipできるため、速度改善が見込める

� (処理速度自体の改善であるnative-imageとは別のアプローチ)

46 of 54

SnapStart利用の実装

0, 引き続きMicronautの利用

1, Hookを実装し、beforeCheckpointでOSSの初期化処理を実施(参考)� 注意点:DBにアクセスしたりといった外部と接続した状態のまま� SnapShotをとってしまうと復元時にエラーになってしまいます。

2, LambdaのRuntimeをjava11corretoに戻す。� Deployではversionを作成することでsnapShotを取得し、� エイリアスから該当versionを参照するようにする。

GraalVM native-image

Lambda SnapStart

Runtime

GraalVM native-image

Amazon correto11

Framework

Micronaut

47 of 54

SnapStart利用の結果①コスト(まとめ)

監視のため起動回数は増加しているが�SnapStartにしてもコストはほぼかからず。�(月内無料枠を使い切り月額2$程度)

Provisioned Concurrency

GraalVM native-image

SnapStart

関数起動回数�(1日合計)�重い関数(左)普通関数(右)

AWSコスト�(Lambdaのみ)

監視で増加

48 of 54

SnapStart利用の結果②速度(重い関数)ColdStart

重い関数のColdStart時(※)の速度が改善。�OSS初期化処理部分での改善とみられる。

※複雑な処理が実装されてきたため、ColdStartではなく純粋に処理が� 遅いものもこのメトリクスに含まれる

重い関数(native-image)�重い関数(snapstart)��関数処理時間の�1時間あたり最大値(Production env)

4-5秒程度�(OSS初期化実施)

2秒以下も�(OSSの初期化Skip)

49 of 54

SnapStart利用の結果②速度(重い関数)

重い関数の平均速度がnative-imageにくらべ�SnapStartでは改善されています。�

重い関数(native-image)�重い関数(snapstart)��関数処理時間の�1時間あたり平均値�(Production env)

50 of 54

SnapStart利用の結果②速度(重い関数)(補足)

青枠期間はLambdaのインスタンス性能を3Gにして運用していたため先の比較としては正しくない期間のものです。

(Lambdaでは高メモリの割当=高性能CPUが割当)

重い関数(native-image)�重い関数(snapstart)��関数処理時間の�1時間あたり平均値�(Production env)

51 of 54

SnapStart利用の結果②速度(普通関数)

native-imageに比べSnapStartの方が処理時間平均としては�やや遅くなっている

初期化などが発生しない処理の場合はやはりnative-imageが高速。

普通関数(native-image)普通関数(snapstart)��関数処理時間の�1時間あたり平均値�(Production env)

52 of 54

効果と課題

ソリューション

方法

効果

課題

Provisioned Concurrency �+ CloudWatch�(2021 / 11)

インスタンス待機によるWarmStart

ColdStartの低減

コストがかかる�ColdStartがゼロではない

GraalVM native-image�(2022 / 07)

JVM不使用�nativeコード�高速化

理想的なコスト�ColdStartでも現実的な処理速度

開発コスト/難易度�Lambdaサイズ限界

Lambda Snapstart�(2023 / 02)

snapshot利用初期化処理のSkip

理想的なコスト

重い初期化でも高速�一般的なJava開発

(処理自体はnative-imageよりは遅い)

53 of 54

まとめ

2021年から振り返ると様々なソリューションが出揃ってきました。��問題領域や用途ごとに検証の必要はありますが、Java×サーバレスがWebアプリケーション用途で利用可能になってきたのではないかと思います。�(APサーバのコストが非常に安いというのは無視できない要素)��発表の構成上、SnapStartが最終解のような見え方になっているかもしれませんが、実際にはそれぞれを使い分をしており、今後も注目しながら取り入れていきたいと思います。�� 頻繁に更新される複雑なビジネスロジック→ Lambda SnapStart� 単機能で変更されない処理→ GraalVM native-image

54 of 54

ご清聴ありがとうございました