Javaサーバレス本番稼働一年を振り返って��JJUG CCC 2023 Spring 登壇資料
Conoris Technologies
CTO/CPO 近藤徳行
自己紹介
近藤 徳行(こんどう とくゆき)��企業向けERP開発ベンダーにて内部統制系製品の開発導入保守を担当
2021年9月からConoris Technologies CTO/CPO
セキュリティチェック業務支援SaaS(ニッチ領域)開発・運用中
本セッションでは
StartUpの一人目エンジニアとしてJava×サーバレスでSaaS開発、�本番環境での1年の運用を振り返り、� どういう問題があり、� どのようなソリューションがあったか、� 自分なりにどう苦労工夫したかを� 開発時の知見、運用時のデータを踏まえてお伝えします。�
2021年9月 開発開始→Provisioned Concccurency採用�2022年4月 ベータ版リリース�2022年7月 GraalVM native imageの全面採用�2023年2月 主な関数へのSnapStartへの切り替えを実施
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, まとめ
なぜサーバレスなのか?
1、Computing処理を実行した時間だけ課金される料金形態。�� StartUp→市場が存在するかわからない領域のSaaS開発� コストを削減し会社が存在する期間を延ばす(チャレンジする期間 を延ばす)上で最善の選択。� (市場が未成熟で使う人がいない時に課金されない)
2、AutoScaling� Mangementサービスとして提供されるため、自動でスケーリング� (アプリ開発者としてはインフラ管理を委譲したい。)
→ビジネス的、運用的に絶対に取り入れたい技術。� (特にコストのインパクトは劇的)
なぜJavaなのか?
B2B領域のSaaSを開発者一人で0から全部実装なんてできない。�→OSSの利用を検討
企業向けの業務領域(歴史的経緯)的に圧倒的にJavaが強く� 絶対に使いたいOSSというのが全てJava言語��やっぱりJavaしかない!
Java×サーバレスで業務アプリケーションを作るという挑戦に。
[参考]どんなアプリケーションなのか?[前提]
業務アプリケーション概要 (数値は現在のもの)� 顧客(管理者ユーザ)がいろいろ設定できて(管理系機能が多い)� 設定に基づいて画面が表示されるようなB2B向けの業務アプリ� 業務フローをカバーすべく多機能を実装 = 処理の種類が多い� ニッチ業務のためアクセス数は少ない
テーブル種別 | テーブル数 |
外部ライブラリ | 48 |
自社管理 | 39 |
合計 | 87 |
コード概要
DB概要
| 数 |
Query(Read) | 63 |
Mutation | 73 |
合計 | 136 |
GraphQL概要
[参考]アーキテクチャ概要[前提]
今日の発表範囲��WebアプリケーションとしてLambdaを利用するユースケース
[参考]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) | |
AWS Lambdaの概要
AWS Lambdaで起動する処理を関数という単位で管理します。�関数は実行するソースコードを持ち、Javaであれば1つのJarファイルが基本の形になります。��関数が起動されるとコンテナが割り当てられ、�JarファイルがDeployされたのち、�処理が起動される形となります。
AWS LambdaのColdStart
関数が起動されると、コンテナが起動しINIT処理を実施します。�INIT処理中にJar形式(zip)のソースコードを、コンテナにダウンロードします。�INIT処理(クラスロードなどを含む)が完了すると処理を実行します。�
AWS LambdaのWarmStart
すでに起動されているコンテナ(インスタンス)がある場合は、再利用します。�ソースコードのダウンロードやクラスロードはスキップされて処理が実行されるため、ColdStartに比べて高速に動作します。
前回起動時から約5分以内に同じ関数が起動された場合
AWS LambdaのShutDown
関数実行終了から約5分程度経過すると、自動的にコンテナは終了します。�次回起動時はColdStartからスタートします。
前回起動時から約5分以上たった場合
関数実行中に関数を起動するとどうなるのか?
AutoScaleされます。�具体的にはインスタンス2がColdStartで起動します。
インスタンス1
インスタンス2
処理中
とはいえ関数をどう書けばいいのか?
フレームワーク� 2021時点で既存のフレームワークの大部分がサーバでの稼働を想定� あるいはそこから関数化へ切り出す目的のものなど� �0から書こうと思った時に適切なフレームワークを見つけられず。�思想の合うものが出てきた時に載せ替えられるように�だけしておこうと決定�→フレームワークなしで実装� (StandAloneなJavaに近い実装イメージ)��※現在はMicronautというフレームワークを利用
Infrastracture | AWS Lambda |
Runtime | Amazon correto11 |
Framework | - |
CI/CD | gradle |
Amplify CI/CD | |
DB | Aurora (Postgres) |
関数分割戦略
関数ってどういう単位で分割するのが良いのでしょうか?
集中するのか?�1関数でなんでもできる構造
分散するのか?� 1関数でできることが限られる構造
機能A
機能B
機能C
機能A
機能B
機能C
関数分割戦略①
1,関数の数が多くなるとCloudへのDeploy/管理コストが上がる�2,関数が分散すると、ユーザが別操作をするとColdStartする可能性 が高くなる。(利用ユーザが少ない状況を想定)�
関数A� 機能1
関数B� 機能2
関数A� 機能1� 機能2
1回目 Cold Start
2回目 Warm Start
1回目 Cold Start
2回目 Cold Start
集中型
分散型
関数分割戦略②
仮に機能を集中させて大きな関数にするとしても�関数のサイズが大きくなると、起動処理は遅くなる。�重いライブラリを利用する処理とそれを利用しない処理を1つの関数にしてしまうと、ライブラリを使用しない処理は無駄な起動時間を必要としてしまう。
関数A� 機能1
関数B� 機能2
ライブラリ利用処理
ライブラリ非利用処理
関数A� 機能1� 機能2
ライブラリ利用処理
ライブラリ非利用処理
重めライブラリ
重めライブラリ
重いライブラリ同居型
重いライブラリ棲み分け型
関数分割戦略まとめ
少人数開発のためDeployのコストを抑えたい。�ニッチ業務のためアクセス数が少なく、色々な処理にアクセスする�→可能な限り機能が集中する関数とし、WarmStartを最大限利用する� しかし、利用ライブラリの単位で分割し2つの関数に分割。
重い関数 | 普通関数 |
外部OSSライブラリ利用の重めの関数�外部ライブラリの初期化処理に時間がかかる��DBにもアクセスしてCRUDを行う。 | よくある業務アプリ。��DBにアクセスしてCRUDを行う。 |
ColdStartとWarmStart処理時間例(普通関数)
DBアクセスする普通の関数��関数処理時間の
1分間あたりの最大値��(ソースコードは2023/05)
ColdStartで�3秒程度
WarmStartで�500msec以下
ColdStartとWarmStart処理時間例(重い関数)
外部ライブラリによる初期化処理が重たい関数��関数処理時間の1分間あたりの最大値��(ソースコードは2023/05)
ColdStartで�18秒程度
WarmStartで�2秒前後
JavaだとColdStartに時間がかかりすぎる
Javaの起動時間はnode.jsやPythonに比べて遅い。��①JVMの起動、クラスの読み込みに時間がかかること�②処理が短時間で終わるため、HotSpotVM(実行時のJITコンパイル)が 効きづらい(インスタンスのライフサイクルにより破棄される)
初期起動は遅いし、JITコンパイルもうまく効かない�→サーバレスに対して圧倒的に不利な状況
この問題に対してどう対応してきたか?
ソリューション | 方法 | 効果 | 課題 |
Provisioned Concurrency �+ CloudWatch�(2021 / 11) | | | |
GraalVM native-image�(2022 / 07) | | | |
Lambda Snapstart�(2023 / 02) | | | |
2021年当時の対応方法
1, 5分に一回何かしらの(空)処理を実施して、warmStart状態をkeep� →AutoScalingした時にNインスタンスwarm状態にする機構が必要。� しかし正確にNインスタンスwarm状態にできるとは考えられない。��2, Provisioned Concurrency という機能を利用して、インスタンスを待機さ せておきwarmStartと同等の効果を狙う。� →AutoScaling対策としてCloudWatchで負荷状況を監視し、� 使用率が高い場合に待機させるインスタンスを増加するように設定。��Provisioned Conccurency + Cloud Watchを採用
Provisioned Conccurency + Cloud Watch実装
Provisioned Conccurencyにて待機インスタンスを3に指定��CloudWatchにて15分に一回(当時最頻)メトリクスを確認し、利用率が60%を超えていた場合に待機インスタンスの数を追加。���参考: Provisioned Conccurency
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分間隔(当時)
Provisioned Conccurency + Cloud Watch 課題①
1, Provisioned Concurrencyは有料であるため待機している� インスタンス毎に課金が発生する。�� 金額それ自体よりも、� 今後利用ユーザが増えるにつれ� コストが増大するという構造が難点。��
Provisioned Conccurency + Cloud Watch 課題①
1, Provisioned Concurrencyは有料であるため待機している� インスタンス毎に課金が発生する。�� 金額それ自体よりも、� 今後利用ユーザが増えるにつれ� コストが増大するという構造が難点。�� サーバレスにする意味があるのか�
Provisioned Conccurency + Cloud Watch 課題②
多くのケースでColdStartを防げていたが、15分に一回のアラートでしか検知できないためか、急なスケーリングに対応できず、ColdStartが発生。
実際に週に1度程度ColdStartと思しき処理が起きていたことがわかる
重い関数��関数処理時間の�1時間あたり最大値�(Production env)
効果と課題
ソリューション | 方法 | 効果 | 課題 |
Provisioned Concurrency �+ CloudWatch�(2021 / 11) | インスタンス待機によるWarmStart | ColdStartの低減 | コストがかかる�ColdStartがゼロではない |
GraalVM native-image�(2022 / 07) | | | |
Lambda Snapstart�(2023 / 02) | | | |
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コード実行ファイル | 静的 |
一部改変。元アイデアこちらの記事より
詳細に関してはこちらの拙記事参照
Reflectionが使えない問題と解決法
AOTコンパイルの構造上、動的なクラスのロードができない。��→javaのコードまたはjsonファイルで、� Reflection等に利用するClassやresourceを事前定義することで� Reflectionなど動的なクラスを正しくNativeコンパイルできる。
�[参考]
Reflection対象をGraalVM側に指定するためのヘルパークラス
ヘルパーを利用して実際にReflectionするクラスを指定
※この定義ファイルで定義していないClassがReflection対象となった場合、Runtimeでエラーとなる
Reflectionが使えない問題
しかし、外部ライブラリのReflection箇所を知ることは容易ではない
�
Reflectionが使えない問題 nativeTestの利用
しかし、外部ライブラリのReflection箇所を知ることは容易ではない��→全ての処理に対してJUnitテストをnative-buildした状態で実行 (nativeTest)。落ちた箇所はReflection定義を見直すといった形で 地道に実装。
native-build
Reflection�定義修正
nativeTest
OK
build-error
runtime-error
success
build-success
nativeTest�実行
Reflectionが使えない問題 nativeTestのTips
また、各テストに関しては永続層部分に関してモックを利用するのではなく、実DBアクセスまでを含めてテストするようにした。
これはO/R mapperがReflectionを利用していたりするため、I/Oを含めたテストにしておかないと native-imageで本当に動くかの確証が持てない。������※O/R Mapperに限らず全ての外部ライブラリで同じことが言える
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) |
GraalVM native-imageの結果①
1, Lambdaのコストがほぼ0に(無料枠内)。�
7月半ばにnative-imageに置き換え
GraalVM native-imageの結果②ColdStart時速度
ColdStartでも数秒程度で処理完了(致命的な状況を回避)。�→初期化処理も処理速度が早ければ運用上なんとかなるという例。��最低限サーバレスで運用できるというレベルには達してきた。
重い関数�普通関数��関数処理時間の�1時間あたり最大値�(Production env)
GraalVM native-imageの結果②平均速度
普通の関数であれば1秒以下程度で返せることが多くなった。
重い関数に関しては時間がかかっていることから、�ライブラリの重さ/初期化処理自体が重いのではないかと分析。
重い関数�普通関数��関数処理時間の�6時間あたり平均値�(Production env)
GraalVM native-imageの課題①実装コストと難易度
実装コスト� 普段の開発 + native-imageの対応でタスクが純粋に増える
実装
JUnit
native-build
Reflection�定義修正
nativeTest
build-error
runtime-error
通常開発タスク
native-image対応タスク
GraalVM native-imageの課題①実装コストと難易度
実装難易度が高い� native-build、nativeTestを実施した際のエラーがわかりにくい。� 普通のJava開発で見たことがない類のエラーになりがち。
� 実際ほぼビルドのオプション、Reflection定義で解消できたが、� これを将来的にチームメンバーに強いるのは厳しいと感じた。�
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分
数秒
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
効果と課題
ソリューション | 方法 | 効果 | 課題 |
Provisioned Concurrency �+ CloudWatch�(2021 / 11) | インスタンス待機によるWarmStart | ColdStartの低減 | コストがかかる�ColdStartがゼロではない |
GraalVM native-image�(2022 / 07) | JVM不使用�nativeコード�高速化 | 理想的なコスト�ColdStartでも現実的な処理速度 | 開発コスト/難易度�Lambdaサイズ限界 |
Lambda Snapstart�(2023 / 02) | | | |
SnapStartの登場 (2022年11月 re:Invent)
Coordinated Restore at Checkpoint (CRaC)を実装した仕組みで��起動中のJVMプロセスの状態をsnapshotとして保存し、�ColdStart時にそのsnapshotから再実行する仕組み。��→特に初期化処理などをsnapshot取得前に実行することで、
実処理で初期化処理をSkipできるため、速度改善が見込める
� (処理速度自体の改善であるnative-imageとは別のアプローチ)
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 | |
SnapStart利用の結果①コスト(まとめ)
監視のため起動回数は増加しているが�SnapStartにしてもコストはほぼかからず。�(月内無料枠を使い切り月額2$程度)
Provisioned Concurrency
GraalVM native-image
SnapStart
関数起動回数�(1日合計)�重い関数(左)�普通関数(右)
AWSコスト�(Lambdaのみ)
監視で増加
SnapStart利用の結果②速度(重い関数)ColdStart
重い関数のColdStart時(※)の速度が改善。�OSS初期化処理部分での改善とみられる。
�※複雑な処理が実装されてきたため、ColdStartではなく純粋に処理が� 遅いものもこのメトリクスに含まれる
重い関数(native-image)�重い関数(snapstart)��関数処理時間の�1時間あたり最大値�(Production env)
4-5秒程度�(OSS初期化実施)
2秒以下も�(OSSの初期化Skip)
SnapStart利用の結果②速度(重い関数)
重い関数の平均速度がnative-imageにくらべ�SnapStartでは改善されています。�
重い関数(native-image)�重い関数(snapstart)��関数処理時間の�1時間あたり平均値�(Production env)
SnapStart利用の結果②速度(重い関数)(補足)
青枠期間はLambdaのインスタンス性能を3Gにして運用していたため先の比較としては正しくない期間のものです。
(Lambdaでは高メモリの割当=高性能CPUが割当)
重い関数(native-image)�重い関数(snapstart)��関数処理時間の�1時間あたり平均値�(Production env)
SnapStart利用の結果②速度(普通関数)
native-imageに比べSnapStartの方が処理時間平均としては�やや遅くなっている
初期化などが発生しない処理の場合はやはりnative-imageが高速。
普通関数(native-image)�普通関数(snapstart)��関数処理時間の�1時間あたり平均値�(Production env)
効果と課題
ソリューション | 方法 | 効果 | 課題 |
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よりは遅い) |
まとめ
2021年から振り返ると様々なソリューションが出揃ってきました。��問題領域や用途ごとに検証の必要はありますが、Java×サーバレスがWebアプリケーション用途で利用可能になってきたのではないかと思います。�(APサーバのコストが非常に安いというのは無視できない要素)��発表の構成上、SnapStartが最終解のような見え方になっているかもしれませんが、実際にはそれぞれを使い分けをしており、今後も注目しながら取り入れていきたいと思います。�� 頻繁に更新される複雑なビジネスロジック→ Lambda SnapStart� 単機能で変更されない処理→ GraalVM native-image
ご清聴ありがとうございました