こんにちは。クラウドインテグレーション事業部の上原です。
前回の記事では、とりあえず Azure上でバッチ処理を動かすためにシンプルな構成を作ってみました。
しかしシンプルゆえに問題点も多かったので、少しずつ改善していきましょう。
今回は必要なときだけバッチが稼働する構成 、いわゆるサーバレスアーキテクチャを検討してみます。
対象アプリケーション
環境
要件
15分間隔で以下の処理を行う。
- 支社(Branch)の一覧をDBから取得する
- 支社毎に、管理されている社用車(Vehicle)の走行データを外部システムから収集する
- 社用車がどのように移動したかを分析し、取引先への訪問履歴データ(VisitHistory)を生成する
- 訪問履歴データをDBに登録する
サーバレスアーキテクチャ
今回目指す構成はこんな感じです。
大まかに説明すると以下の通りです。
- バッチ処理を、分析対象となる支店の抽出処理(Scheduler)と解析処理(Worker)の2つに分ける
- 実行制御サービスを用いて、Schedulerを15分間隔で実行する
- Schedulerの実行結果を、Workerへのリクエストとしてキューに送る
- キューを監視し、リクエストの量に応じた数のインスタンスを起動してWorkerを実行する
ここで重要なのがキューの存在です。
キューを挟むことによって、Workerは必要なときに必要なぶんだけ動かせるようになります。
SchedulerとWorkerが疎結合になるため、保守性の向上も期待できます。
構成を決めたら、次はこれを実現するために利用するサービスを選定します。
構成2(没案):Azure Container Instances
最初に候補に挙がったのはAzure Container Instancesというサービスでした。
Azure Container Instances(以下Container Instances)は、Dockerイメージをサーバなしで実行できるシンプルなサービスです。
課金は従量制で、実行環境の性能と実行時間(秒)から算出されます。
実行するイメージはAzure Container Registry(以下ACR)やDocker Hubから選択できます。
実行制御にはAzure Logic Apps(以下Logic Apps)を選択しました。
Azure Logic Appsは、ワークフローを視覚的に構築するサービスです。こちらも実行時間に応じた従量課金制。
ワークフローを開始するトリガーには、スケジュール、HTTPリクエスト、キュー、メール、Twitterのツイート等々、さまざまな条件が用意されています。
接続できるサービスにはもちろんContainer Instanceも含まれており、仕様を満たすことができそうです。
キューにはService Busを採用しました*1。ダッシュボードの可視性もよいスグレモノです。
これでリソースは揃ったので、いざ実行…というところで、致命的な問題が発覚。
Container Instanceはスケーリング機能を持っていなかったのです。
https://docs.microsoft.com/ja-jp/azure/container-instances/container-instances-faq
コンテナの作成件数にも上限があり*2、都度コンテナ数を変えるというわけにもいかず…
軽量で非常に良いサービスだったのですが、今回の要件にはフィットしないとして断念し、別のサービスを探しました。
構成3(採用案):Azure Functions
最終的に採用したのは、Azure Functions( 関数アプリ, Function App*3 )というサービスでした。
Azure Functionsは、コードをデプロイすると様々なトリガをきっかけに実行してくれるサービスです。類似サービスとしてはAWS Lambdaが有名です。
Azure Functions自体が実行制御の機能を持っているため、サービスを別途用意する必要がなくなりました。
関数アプリ向けの実装が必要なのは難点ですが、後述する「Spring Cloud Function」で幾らか吸収できそうだったので利用することにしました。
また、監視のためにApplication Insightsを利用します。
Azure Functionsの監視機能はほぼApplication Insightsに実装されているため、必須といってよさそうです。
構築
それではAzure上に環境を作っていきましょう。
Azure Functions
まずは基本となるAzure Functionsから作っていきます。
最初に関数をコードとDockerコンテナどちらで動かすかを選択します。
既存の資産がDockerコンテナで動いていたのでDockerかな…と思っていたのですが、ここにとある制約が立ち塞がります。
Azure Functionsには3つの料金プランがあります。
- 従量課金(サーバレス)…関数が実行された時間に応じた料金がかかるプラン。
- Premium…タイムアウトや性能などの制約を緩和したプラン。
- 専用プラン(App Serviceプラン)…AppServiceインスタンスを使うプラン。
専用プランは、通常のバッチ用サーバを用意するのと実質同じです。自動スケーリング機能もないため今回は対象外。
Premiumプランは、コールドスタートを避けるために最低1台のインスタンスが常時稼働します。起動時のラグがないのは良い点ですが、コスト面でのサーバレスの利点がちょっと失われます。*4
最後に従量課金プランですが、Dockerコンテナに対応していません。
色々と悩みましたが…性能的には従量課金プランで十分だったこともあり、従量課金プラン&コードベースでいくことにしました。*5
そしてもう一つ注意点が。
同じリソースグループ・リージョン内にApp Service Planが既に存在する場合、関数アプリを作成しようとするとエラーが発生します(2020年8月時点)。
これにより、既存のリソースグループとは別途、関数アプリ専用のものを作成することになりました。
あとは流れに沿って作成していきます。Storage Accountの紐付けが必要ですが、ここで新規作成しても問題ありません。
Application Insightsは有効にしておきましょう。ただし権限が足りないとエラーになるので要注意です。
Service Bus
Service Busで作成が必要なのは「名前空間」「キュー」「共有アクセス ポリシー」の3つです。
名前空間
作成→Service Busを選択すると、名前空間の作成画面になります。
料金プランですが、Basicだと機能がかなり制限されてしまうためStandardにしました。
キュー
名前空間を作成したら、続いてキューを作成します。
作成リンクは画面上部にあります。
キューには様々なパラメータがあるので、要件に合わせて入力していきます。
「最大配信数」は「maxDeliverlyCount(最大リトライ件数)」のことです。
今回のアプリは、もし処理に失敗しても15分後にリカバーできる仕組みにしており、古いメッセージはさっさと捨てたいので「1」。TTLも同じ理由で15分にしました。
厄介なのが「ロック期間」。
バッチ処理が期間内に終わらなかった場合、メッセージのロックが解除されて同じ処理が別途実行されてしまう可能性があります。
ですが、この期間の上限は5分(入力欄に日まであるのに?)。そのため、処理が5分に収まるようにチューニングする必要があります。
共有アクセス ポリシー
最後に「共有アクセス ポリシー」。これはキューの制御を認可するためのキーで、のちほどAzure Functionsに付与します。
必要なのはメッセージのやり取りをする権限だけなので、「管理」は外しておきます。
実装
Azure側の構築はこれで完了。続いてアプリケーションを実装していきます。
今回は「Spring Cloud Function」を使います。Spring Cloud Functionは、AzureをはじめAWS、GCPなどの関数アプリの実装に特化したSpring用ライブラリです。
ここでは重要なところだけかいつまんで説明します。
導入
ライブラリとして導入します。Spring Initializrで雛形作成時に選択することも可能です。
dependencies { compile('org.springframework.cloud:spring-cloud-function-context') compile('org.springframework.cloud:spring-cloud-function-adapter-azure') }
Handler
Azure FunctionsとSpringアプリケーションの接続部です。AzureSpringBootRequestHandler
クラスを継承します。
@FunctionName
アノテーションに名前を付与することで、同名のAzure Functions関数に紐付けられます。
関数の起動条件はアノテーションで行います。
スケジュール起点であれば @TimerTrigger
、Service Busキューのメッセージ起点であれば @ServiceBusQueueTrigger
((@QueueTrigger
もありますが、こちらはStorage Accountのキューです)) を使います。
使用できるアノテーションのリストは以下を参照してください。
com.microsoft.azure.functions.annotation Package | Microsoft Docscom.microsoft.azure.functions.annotation Package | Microsoft Docs
@Component public class SchedulerHandler extends AzureSpringBootRequestHandler<Date, List<MyQueueMessage>> { @FunctionName("scheduler") @ServiceBusQueueOutput( name = "branchId", queueName = "my-service-bus-queue", connection = "AZURE_SERVICE_BUS_CONNECTION" ) public List<MyQueueMessage> execute( @TimerTrigger(name = "SchedulerTrigger", schedule = "0 */15 * * * *") String timerInfo, ExecutionContext context ) { context.getLogger().info("TimerTrigger: " + timerInfo); List<MyQueueMessage> output = handleRequest(new Date(), context); // Functionの実行 return output; } } @Component public class WorkerHandler extends AzureSpringBootRequestHandler<MyQueueMessage, Void> { @FunctionName("worker") public void execute( @ServiceBusQueueTrigger( name = "message", queueName = "my-service-bus-queue", connection = "AZURE_SERVICE_BUS_CONNECTION" ) final MyQueueMessage myQueueMessage, ExecutionContext context ) { try { handleRequest(myQueueMessage, context); } catch (Exception e) { context.getLogger().severe(e.getMessage()); throw e; } } }
Function
handlerから呼び出される関数クラスです。処理の実体はこちらのクラスに実装します。
クラウド依存の部分はHandlerで吸収できるようになっているので、Functionクラスはロジックの実装に集中することが望ましいです。
入出力の有無によって Function
Consumer
Supplier
のいずれかを実装します。
Schedulerは日付を受け取ってメッセージを返すので Function
。Workerはメッセージを受け取って結果を返さないので Consumer
を使います。
@Component("scheduler") public class SchedulerFunction implements Function<Date, List<MyQueueMessage>> { @Override public List<MyQueueMessage> apply(Date triggeredAt) { // 対象支店を抽出し、キューメッセージクラスに変換して返す } }
設定
設定ファイルは host.json
と local.settings.json
の2種類です。
前者がクラウド上、後者がローカル上での動作を制御します。
まずは以下リンクを参考にファイルを作成してください。
その後、必要に応じて設定を追加していきます。
以下が今回実装したアプリのhost.jsonです。
メッセージを大量に抱えて捌ききれずエラーになるという不具合に見舞われたため、
serviceBusの設定で最大取得数(maxConcurrentCalls*6)を制御しています。
▼host.json
{ "version": "2.0", "functionTimeout": "00:10:00", "extensionBundle": { "id": "Microsoft.Azure.Functions.ExtensionBundle", "version": "[1.*, 2.0.0)" }, "extensions": { "serviceBus": { "messageHandlerOptions": { "maxConcurrentCalls": 8 } } } }
実行・デプロイ
プロジェクトをGradleで管理していたため、Gradleプラグインを利用します。
CI環境(Jenkins)からのデプロイもgradleコマンドです。
# 実行 ./gradlew batch:clean batch:bootJar batch:azureFunctionsRun # デプロイ ../gradlew clean bootJar azureFunctionsDeploy -Psubscription=${azureSubscriptionId} -PresourceGroup=${azureResourceGroup} -Dorg.gradle.daemon=false"
詳しい使い方は以下の記事をご覧ください。
Azure CLIやAzure Functions Core Toolsも必要なので、インストールをお忘れなきよう。
動作確認
デプロイが完了したら、ポータル上で動作を確認してみます。
関数アプリのページでも稼働状況は分かりますが、ApplicationInsightsでより詳細な情報を確認できます。
「ライブメトリック」ではログやインスタンス数、「失敗」ではエラー件数やエラーコード、例外の種類などが確認できます。
うまく動かない場合、関数アプリが起動状態になっているか、コードの関数名やキュー名に誤りがないか等を確認してみてください。
まとめ
駆け足でバッチの構成、実装方法についてまとめてみました。
他にもApplication Insightsのアラートを設定したり、キューの設定値やプランなど調整が必要な部分があるので、要件に合わせていろいろ試してみてください。
ここまで読んでいただきありがとうございました!
*1:Storage Accountにもキューの機能があるのですが、機能面を考慮してService Busにしました。最近Queue Storageという名前がついたようです。
*2:https://docs.microsoft.com/ja-jp/azure/container-instances/container-instances-quotas
*3:実装当時はFunction Appという名前でしたが、名前が変わったようです。ただしまだ整備が追いついていないらしく、ポータル上ではFunction Appと表示されています。
*4:ドキュメントによると「最も予測可能な価格が提供されます」とのことです。物は言いよう…
*5:なお、料金プラン選択はリソース作成画面の2ページ目にあるため、1ページ目でDockerを選択すると、次のページで初めて従量課金プランが使えないことに気付かされます。
*6:https://docs.microsoft.com/en-us/azure/azure-functions/functions-bindings-service-bus-output?tabs=csharp#host-json