200MB超えのJavaバッチを中身はほぼ変えず ECS から Lambda に移してコスト 1/4 & 処理時間 1/6 になった話
はじめまして、ギークプラスの千葉です。Geekplus Tech Blog初投稿になります!
本記事は Serverless Advent Calendar 2025 の12月24日の記事です。
今回は、JavaバッチのインフラをAmazon Elastic Container Service(以下ECS)からAWS Lambda(以下Lambda)に置き換えた事例について、工夫したポイントなどを交えながらご紹介します。
随所に補足も加えていますので、AWS初心者の方々にも読んでいただければ幸いです。
補足(AWS初心者向け)
AWSでは、定期的なデータ取込や集計などの「バッチ処理」を
・サーバーを常時起動して実行する方式
・必要なときだけ実行するサーバーレス方式
のどちらかで構築できます。本記事は、その実行基盤を見直した事例になります。
なぜJavaバッチをLambdaに置き換えることにしたのか
弊社で開発しているskylaaの倉庫間オーケストレーションでは、各種マスタや入出荷情報の取込や出力を行うバッチがあります。このバッチはECS on EC2での運用では以下の課題があり、Lambdaに置き換えたい改善要望が上がっていました。
補足(AWS初心者向け)
ECSはコンテナを実行するためのサービスで、2つの実行方式があります。
- ECS on EC2:EC2インスタンスを常時起動してコンテナを実行
- ECS on Fargate:必要なときだけコンテナの実行環境となるサーバーリソースを確保
課題① 常時起動EC2によるコスト過剰
JavaバッチはSpring Bootを利用しコンテナで開発、運用しています。バッチ起動の都度レジストリからイメージがダウンロードされてオーバーヘッドが増大することを防ぐため、ECS on EC2を利用しイメージをキャッシュする戦略を取っていました。しかし、コンテナが起動していない時間はECS on EC2の使用料が余分に生じていた実態がわかってきました。
課題② Fargate起動が重く、開発環境でも待たされる
課題1ではJavaバッチはECS on EC2で運用していると述べましたが、開発、検証環境ではコスト削減のためECS on Fargateで動作させています。ECS on Fargateではバッチ起動の都度インスタンスが立ち上がり、イメージをダウンロードしてJava起動とオーバーヘッドが非常に高く、数件程度の処理を実行しても3分前後かかってしまうという課題もありました。
置き換え前のJavaバッチ構成※置き換えに関わらない箇所は除く

今回の方針:アプリは触らず、インフラだけ変える
今回の置き換えでは、すでにJavaバッチは本番運用されていること、アーキテクチャや言語からリプレイスするような大規模な対応はしないことから、極力インフラだけを置き換えることにフォーカスしました。
常時起動でコスト過剰になっていたECSから、必要な時だけ実行されるLambdaに切り替えることでコスト削減、またコンテナベースの処理から脱却することで処理時間の短縮を目指します。コスト試算では、1時間あたりの処理で計算すると約4分の1の削減が見込まれました。
置き換え前後のJavaバッチ構成※置き換えに関わらない箇所は除く

ECSからLambdaへ置き換えるために工夫したこと
工夫① 15分制限を超える処理はECSに逃がす
Lambdaの利用にあたり注意しなければならないポイントの一つとして、一度の実行につき利用時間が同期、非同期問わず15分を超えた場合はタイムアウトとなる仕様があります。15分を超えることが予想される場合にはECS on FargateでJavaバッチを実行するという仕組みも備えることとしました。
JavaバッチはAWS Step Functions(以下StepFunctions)の一連の処理の中で動いており、Javaバッチ実行の前段のStateでファイルサイズが基準を超えるかどうか判定しています。基準値を超えない場合はLambda、超えるならECS on Fargate上でバッチ処理を行います。
このアプローチについては、実際に本番運用されファイルサイズと処理時間の傾向が掴めたため実現できた手段であると感じています。
補足(AWS初心者向け)
AWS Lambdaは「サーバーを管理せずにコードを実行できるサービス」ですが、
1回の実行につき最大15分までという制限があり、長時間処理には向いていません。
StepFunctionsは複数の処理を順番に実行したり、条件分岐を定義できるAWSのワークフローサービスです。Stateはワークフローの構成要素の一つです。
工夫② JavaのコールドスタートをSnapStartで短縮
Lambdaを利用する場合、これまでの常時起動型アプリケーションと異なり利用の都度アプリケーションを起動、処理が終われば停止するという起動ライフサイクルになるため、Javaでは起動停止のオーバーヘッドが顕著になる場合があります。今回はそれを軽減するためSnapStartを活用しました。
SnapStartは、Lambdaの初回起動時に初期化完了後のスナップショットを保存し、以降はそのスナップショットから実行環境を高速に復元する仕組みです。これにより、Javaアプリケーションのコールドスタート時間を大幅に削減できます。いわばキャッシュを利用しているようなものかと思います。
また、SnapStartは他の言語では有料ですが、Javaでは無料だったことも採用しやすかった点でした。
補足(AWS初心者向け)
Javaアプリケーションは起動に時間がかかることが多く、
Lambdaのように毎回起動・停止する環境では処理開始までの待ち時間が課題になりがちです。
工夫③ Lambda関数を集約してSnapStartの効果を最大化
SnapStartのスナップショット再利用率を上げるために、Javaバッチを一つのLambda関数に集約する構成としています。例えば複数の機能を実装する場合に、それらの機能を一つのアプリケーションとして実装して一つのLambda関数で実行するのか、複数のアプリケーションとして実装し複数のLambda関数として実装するのかという違いがあります。
前者では管理しやすくなる点や、単一のLambda関数に集約することで同一関数が繰り返し呼び出されやすくなりSnapStartの恩恵を受けられやすくなりますが、一つのアプリケーションに全てを詰め込むためLambdaのデプロイファイルサイズ上限に達する可能性が高くなります。後者は複数Lambdaを運用するため管理が多重になる点や、利用がそれぞれのLambdaに分散するため、全体で見ると前者の場合よりもSnapshotの再利用率が下がります。
今回はJavaでQuarkusではなくSpring Bootを利用していたこともあり、起動オーバーヘッド削減のため前者の方式としています。この方式を実現するため、Javaバッチのビジネスロジックを呼び分けるハンドラー層を追加しました。
ハンドラー層はSpring Cloud Functionを導入し、ストラテジーパターンを取り入れています。StepFunctionsのStateでどのビジネスロジックを起動するか切り替えられるよう文字列のキーをStepFunctionsからLambda、LambdaからJavaバッチに受け渡せるようにし、文字列のキーによって実行するロジックを切り替える仕組みです。
以下はハンドラー層のコードになります。※本記事記載のため一部内容を変更しています。
package hoge.fuga.batch.fn.runner;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.util.function.Function;
/**
* Lambda関数の窓口となり、どのビジネスロジックを実行するか実際に切り替えるクラス。
* ここで受け取ったリクエストを各Lambda関数リクエストに変換し、引数で指定されたLambda関数を実行する。
*/
@Slf4j
// Spring Cloud Functionを利用することで、ビーンがLambda関数から呼び出し可能になる
@Component("lambdaRunner")
public class LambdaRunner implements Function<LambdaRunnerRequest, LambdaRunnerResponse> {
private final LambdaFunctionStrategyFactory strategyFactory;
public LambdaRunner(LambdaFunctionStrategyFactory factory) {
this.strategyFactory = factory;
}
/**
* Lambda起動時に指定されたfunctionNameをキーに対象のFunctionクラスを実行
* Lambda起動時に受け取ったリクエスト文字列をJSON変換してFunctionクラスに渡す。
*
* @param originalRequest Lambdaリクエスト文字列
* @return Lambda レスポンス
*/
public LambdaRunnerResponse apply(LambdaRunnerRequest originalRequest) {
final String functionName = originalRequest.functionName();
final String requestContent = originalRequest.request().replaceAll("\\\\", "");
// Step Functions側ではJSON文字列を作れなかったためここで{}をつけています
final String jsonFormattedRequest = "{" + requestContent + "}";
// functionNameで実行したいロジックを切り替え(ストラテジーパターン)
final LambdaFunctionStrategy strategy = this.strategyFactory.get(functionName);
if (strategy == null) {
return new LambdaRunnerResponse("NotMatchFunction", "一致するLambda関数がありません");
}
return strategy.apply(jsonFormattedRequest);
}
}
補足(AWS初心者向け)
Lambdaは関数ごとに独立して実行環境が管理されるため、同じ関数が繰り返し呼ばれるほど、SnapStartの効果を受けやすくなります。
工夫④ 将来のスケールを見据えてRDS Proxyをスタンバイ
こちらもLambdaの利用で注意点として挙げられることですが、DBとのコネクション管理になります。Lambdaを利用した場合、Lambdaへのリクエストが大量に行われた場合にはインスタンスも多数稼働することが想定されます。その結果、多数のLambdaインスタンスが並行稼働してDBへの接続も多数行われることになります。
このようなLambdaならではのDBコネクションの問題に対して、AWSはRDS ProxyというDBコネクションを担当するプロキシサーバーのような機能も提供しています。
今回はJavaバッチを1つのLambdaに集約して稼働させる構成としたため、実際に運用している現在でも問題になっていませんが、今後Lambdaの利用増加などを想定してRDS Proxyの利用を想定した実装を行いました。CDK上でフラグを設け、いつでもRDS Proxyをオンオフできるようスタンバイしています。
補足(AWS初心者向け)
Lambdaはアクセス数に応じて自動的に並列実行されるため、
そのままDBに接続すると短時間で大量のDB接続が発生することがあります。
まとめ:ほぼインフラ変更だけでここまで改善できた
この記事を書いている現在、上記の対応がリリースされ数週間というタイミングなのですが特に不具合なく、以下のコスト削減や処理速度改善の効果も得られています。ほぼインフラのみの対応でしたがとても費用対効果の高い対応となり、インフラを見直すことも重要だなと思いました。
- Lambdaで数ドル、ECS on Fargateで数十ドルの増加があったものの、ECS on EC2は約621ドルから約99%の削減
- 数件程度の処理で3分前後かかっていたところを20~30秒程度に改善
- 1000件分のJavaバッチのE2Eテストが従来は1時間ほどかかっていたところ、15分程度に改善
個人的な話になりますが、この対応が弊社に入社して初めてのタスクで、またAWS構築としても初めての経験でした。今村CTOにもサポートいただきつつもやり遂げることができ、最初の一歩としてとても大きな一歩を踏み出せたなと感じています。感謝の気持ちを込めて、本記事の締めとさせていただきます。
Discussion