ここ数年は Azure Functions をフルに活用したアプリケーションを実装することが多かったのですが、同時に Azure Functions を失敗しないように使う方法も分かってくるので、ここらでちゃんと言語化しておきます。
最近は特に Azure Light-up というハッカソンを行うことが多いのですが、Azure Functions を使う場合には必ずこの辺りは毎回説明するようにしています。要するに Azure Functions の利点・特性を理解して賢く使いこなそうという話です。
- Binding / Trigger で実現出来ないか考える
- Function の実装は出来る限り小さく保つ
- リトライのしやすい実装を重視する
- 最新の .NET での作法に沿ったコードを書く
- Graceful Shutdown に対応したコードを書く
- 機能単位で Function App プロジェクトを分ける
- 早いうちから CI / CD パイプラインを組む
図があった方がわかりやすい気もしましたが、作成する元気がなかったので長文になりました。あくまでも私が意識していることなので、こういう考え方もあると捉えて貰えれば良いです。
Binding / Trigger で実現出来ないか考える
Azure Functions 最大の特徴といえる、豊富かつ拡張可能な Binding / Trigger は要件に合えば必ず使っています。Azure Storage や Event Hub などの SDK の使い方を知らずとも使える便利な機能です。
HttpTrigger
や TimerTrigger
はほぼ使ったことがあると思いますが、今ではそれ以外にもかなりの数が提供されています。最近では CosmosDBTrigger
を使うことが非常に多いです。
Blob や Queue の読み書きはほぼ同じコードになるので割と退屈な部分の割に、コード量はそこそこ必要になるので Binding / Trigger を使って隠してしまいます。この辺りはアプリケーションの本質的ではないコードになるので、実際のビジネスロジックに集中するためにも重要です。
Function の実装は出来る限り小さく保つ
1 つの Function の実装が大きいと、冪等性の担保やリトライが難しくなることが多いので、基本的に 1 つの Function には 1 つの処理だけをさせるようにしています。
単機能の Function を Queue Storage や Cosmos DB Change Feed で組み合わせることで、目的のアプリケーションを実装するようにしています。Function の責務をはっきりさせないとデプロイすら難しくなります。
今では Durable Functions を使うことで、Queue やインスタンスを意識することなく複数 Function を操作するアプリケーションを実装できるので、以前より Function の実装を小さく保ちやすいです。
ただし実際のビジネスロジックは別途サービスやリポジトリクラスとして分離して、それらを DI で組み合わせて作り上げていくので、コード量自体はもう少し多くなります。
最近は Azure Functions でも DI が使いやすくなっているので初期化が肥大化気味ですが、各 Function App から共通で参照されるクラスライブラリを作成し、DI の登録周りと処理の共通化を行うようにしています。
リトライのしやすい実装を重視する
Function を単機能として実装することで、考えることが減るのでリトライへの対応が簡単になります。冪等性の担保も同様にしやすくなります。
Azure SDK には最初からリトライは組み込まれているので、基本は組み込みをそのまま使いつつ、必要に応じて Polly や Azure Function の Retry Policy を利用するようにしています。
特に最近は避けるようにしている実装として、1 つの Function が複数のストレージへの書き込みを行うようなケースがあります。何らかの処理結果を SQL DB に書き込みつつ Blob へも書き込むような実装は、後者の処理が失敗した場合にリカバリーが難しくなります。実装もそれぞれに対してのエラーハンドリングが必要になるため、異常に見通しが悪くなることがあります。
そういった場合には Blob であれば Event Grid を使って、イベントドリブンで Blob への書き込みを検知して後続の書き込みを行うようにします。
最近では Cosmos DB の Change Feed を使って、大本の Function では Cosmos DB への書き込みを行い、SQL DB や Blob への書き込みはそれぞれ別の Function として実装するケースが多いです。それぞれの Function の処理がシンプルになるので、リトライが行いやすくなります。
最新の .NET での作法に沿ったコードを書く
.NET の作法といっても最新の C# 言語機能を使うというわけではなく、ベースとなっているフレームワークに沿ったコードを書くように意識しています。
Azure Functions v3 は .NET Core 3.1 / ASP.NET Core 3.1 ベースなので、その作法に従っておくとシンプルなコードに出来ます。特に DI は必須になっているので、Visual Studio を作成した時に生成される static
なクラスとメソッドは即座に捨てています。
ただし複雑な DI の使い方をすると逆効果になるので、やりすぎない DI としてシンプルな依存関係の解決と、インスタンスの生存期間管理をメインに使っています。
昔は App Settings を取るのにも苦労をしましたが、最新の Microsoft.Azure.Functions.Extensions
をインストールするとシームレスな統合が実現されているので、積極的に使うようにしています。上記の static
なクラス削除と DI の設定はテンプレートに入っていて欲しいぐらいです。
少し .NET から外れますが Managed Identity や Key Vault も利用するようにしています。
Graceful Shutdown に対応したコードを書く
昔は稼働中の Azure Functions へのデプロイを行う前に、Application Insights で処理が行われていないことを確認することもありましたが、少し前に Graceful Shutdown が正しく動作するようになったので対応するように実装して、デプロイはタイミングを意識せずに行えるようにしています。
Azure Functions は .NET で標準となっている CancellationToken
を使ってシャットダウンを検知できるので、処理が壊れないように必要な処理だけを行って Function を完了させることが出来ます。
英語ブログの方に実際にアプリケーションを実装して得た知見を、Graceful Shutdown のベストプラクティスとしてまとめています。常時データが流れてくるアプリケーション上で入念に検証を行いました。
Deployment Slot を使えば Graceful Shutdown は不要と考える方もいると思いますが、App Service / Azure Functions の Deployment Slot は本番ではない側のスロットのウォームアップが完了後、ルーティングを入れ替えて本番だったスロットを再起動するため、Graceful Shutdown に対応したコードを書いていない場合は処理中でも一定時間後に問答無用でシャットダウンされます。
従ってスワップ前に確実に処理が正常終了する保証はないため、Deployment Slot を使ったデプロイの場合でも Graceful Shutdown への対応は重要です。
それ以前の話として、App Service / Azure Functions はプラットフォーム更新などの要因で、1 か月に数回自動的にインスタンスの移動が行われて、アプリケーションの再起動が行われています。そのタイミングは制御不可能なので、アプリケーションはシャットダウンに備える必要があります。
機能単位で Function App プロジェクトを分ける
正確にはスケーリングとデプロイの単位で Function App プロジェクトを分けるのが正解なのですが、最初からその辺りが見えるケースは少ないのでまずは機能単位で分けることを考えるようにしています。
たまに 1 つの Function App に大量の Function を実装しているアプリケーションを見ますが、もし 1 つの Function App にまとめている理由がコストなら検討しなおしたほうが良いです。
非常に誤解されやすく、正しく理解されていないことが多い App Service や Azure Functions の課金単位ですが、簡単にまとめると以下の通りになります。
- Consumption Plan は実行回数と実行時間 (GB 秒) で課金
- App Service Plan はインスタンスのサイズと数で課金
- Premium Plan は最小インスタンスのサイズと数 + 追加インスタンスは使用した秒単位で課金
重要なのは App Service Plan 単位での課金であって、その上で動作する App Service / Azure Functions の数は無関係ということです。従って 1 つの App Service Plan に対して 5 つの Function を持つ Function App を 1 つデプロイするのと、1 つの Function を持つ Function App を 5 つデプロイするのは同じ価格になります。
当然ながら App Service Plan が持つリソースは有限なので、大量の Function App を載せるのはパフォーマンス面でも現実的ではないですが、1 つの Function App に全ての機能をまとめたからと言って安くならないことは知っておく必要があります。
そもそもどのプランを選ぶべきか分からない場合は、公式ドキュメントにそれぞれの機能比較表があるので、どのプランを使えば要件が満たせるか確認してください。
本番向けのアプリケーションであれば、大体の場合は App Service Plan か Premium Plan になると思います。特に最近は VNET Integration が必要となるケースが多いので、まず Consumption Plan は候補から消えます。
インスタンスのサイズはアプリケーションの特性によって変更しますが、ほとんどのケースでは P1V2 か P1V3 で十分なことが多いです。スケールアップは簡単なのでリソースを確認しつつ対応しています。
早いうちから CI / CD パイプラインを組む
もはや言うまでもないと思いますが、ごく小規模のアプリケーションを除き大体は Function App は 1 つ以上作成することが多いです。それらに Visual Studio から手動でデプロイすることは現実的ではないので、GitHub Actions や Azure Pipelines を使って自動化します。
デプロイ自体は Action / Task が用意されているのと、非常に簡単なので特に悩むこともないはずです。最近ではハッカソンの最中に CI / CD パイプラインまで組むことも多々あるぐらいです。
普段ハッカソンで話している内容を出来るだけまとめたつもりですが、抜け漏れがあるかも知れないので追記するかもしれません。Azure Light-up に興味がある場合は連絡ください。