しばやん雑記

Azure とメイドさんが大好きなフリーランスのプログラマーのブログ

Azure SignalR Service がリリースされたのでサクッと試した

Build 2018 が始まったようですね。基本的な内容は明日の朝にブチザッキを見て確認する予定ですが、Cloud Platform Release Announcements に Azure SignalR Service という心惹かれる項目があったので、サクッと確認してみました。

Azure Blog でも紹介されてました。SignalR を大規模に使うと、大体はスケーリングと管理がめんどくさくなってしまうのですが、その辺りは Azure にお任せという何時もの流れです。

内容からして SignalR の WebSocket などを利用した接続部分だけ外部サービスとして切り出すものだと思っていましたが、必然的にクロスドメインになるので少し挙動が気になってました。使えるトランスポートは WS / SSE / XHR のようです。

チュートリアルがまとまっていたので、参考にしつつ適当なアプリケーションを用意しました。

とりあえず先に Azure SignalR Service を作成しておきます。Preview 中は Pricing Tier としては Free / Basic が選べます。Basic を選んだ場合のみ 10 ユニットまで拡張することが出来るようになってます。

将来的には Standard や Premium が追加されて上限が増えるのではないかと予想します。

f:id:shiba-yan:20180508015139p:plain

当然ながら使用可能なリージョンに日本はないので、West US 2 に作成することにしました。Basic での 1 ユニット当たりの価格は非常にお手軽ですね。おそらく裏側の Redis 分も含まれているはずです。

グローバル IP は固定みたいなので、カスタムドメインも設定できるようになりそうです。

f:id:shiba-yan:20180508015151p:plain

恐らくこの性能でリミットがかかっているわけではなく、あくまでも目安という感じがします。*1

デプロイ自体は割とあっという間に完了します。数分以内に 3 ユニットが立ち上がってきたので、裏側はそれなりに気合が入っている感じがします。

f:id:shiba-yan:20180508020009p:plain

Pricing Tier に Basic_DS2 とあったのが少し気になりました。VM のサイズ感ありますね。

f:id:shiba-yan:20180508020201p:plain

アプリケーションの開発は ASP.NET Core SignalR と同じですが、追加で Azure SignalR Service 用の NuGet パッケージをインストールします。

この辺りはサンプルコードと同じなので省略しますが、普通に SignalR アプリケーションが動くようになりました。予想通り WebSocket 接続は Azure SignalR Service を向いていました。

f:id:shiba-yan:20180508015302p:plain

negotiate が多いですが、初回の段階で Azure SignalR Service のエンドポイントを返しているので、その後のリクエストは全てローカルには流れてきません。

完全に Hub などの操作を行うサーバーサイドと、WS / SSE などでの配信を行う部分が完全に切り離された実装になっているので、スケールがさせやすそうです。

Azure Functions からも接続文字列だけあれば、簡単にメッセージを投げることが出来るみたいですが、クライアントライブラリが未整備のようなので、いきなり使うにはちょっとハードルが高いと思いました。

API のドキュメントがぱっと見公開されてないみたいなので、割と厳しそうです。とはいえ、アーキテクチャ的には重要な使い方になってくると思うので、GA までにはリリースされるのではないかと。

App Service が HTTP/2 に対応したので ASP.NET で試した

Windows Server 2016 へのアップグレードが行われた際に、一部のリージョンでテスト的に有効化されていた HTTP/2 がグローバルで GA になってました。例によって ASE にはまだデプロイされていないみたいですが、近いうちに対応するみたいです。

HTTP/2 はデフォルトで有効化されているわけではなく、設定から有効にする必要があります。この辺りはブログにも書いてるので省略。

まだ Azure Portal から設定できないので、ARM Explorer を使って設定を変えます。

f:id:shiba-yan:20180414214621p:plain

HTTP/2 よりも気になる設定が増えているので、さらっと試しておきました。とりあえず適当な Web App で HTTP/2 を有効にしたので、適当にブラウザで確認しておきました。

設定を行う前は HTTPS となっています。

f:id:shiba-yan:20180414214307p:plain

設定後は HTTP/2 と表示が変わっているので、ちゃんと有効化されています。今後もデフォルトで有効化という流れにはならないと思うので、必要なケースで都度有効化しましょう。

f:id:shiba-yan:20180414214314p:plain

HTTP/2 と言えば ASP.NET 4.6 から Server Push に対応していたので、App Service でも使えるか確認しておきました。結論としては使えませんでした。

App Service の HTTP/2 は ARR 部分での対応っぽいので、ARR <=> Web Worker の部分が HTTP/2 になっていないので Server Push が使えないのかも知れません。リバースプロキシが絡むと少し面倒ですね。

具体的には Response.PushPromise を呼び出した瞬間にエラーとなってしまいました。

f:id:shiba-yan:20180414223935p:plain

ARR が Preload に対応していれば問題なく使えるようになりそうですが、これに関しては割と望み薄ですね。Service Worker 周りで App Service を使いたい場合に困るケースがあるかも知れませんね。

HTTP/2 に関してはこのくらいにして、もう一つ増えていた minTlsVersion について適当に試しておきました。正式リリースされていないので挙動は不安定っぽいので注意。

デフォルトでは TLS 1.0 が有効になってますが、これを 1.2 に変えると TLS 1.2 のみを有効に出来るはずです。出来るはずなのですが、今の挙動はよくわからない感じです。

f:id:shiba-yan:20180414214028p:plain

謎挙動ですが、この時点で SSL Labs のテスト的には TLS 1.2 のみ有効という扱いになってました。なので、HSTS を追加すれば App Service でも A+ 評価を得ることが出来るはずです。

Web.config を弄って適当に Strict-Transport-Security ヘッダーを追加します。

<?xml version="1.0" encoding="utf-8"?>
<configuration>
  <system.webServer>
    <httpProtocol>
      <customHeaders>
        <clear />
        <add name="Strict-Transport-Security" value="max-age=31536000; includeSubDomains" />
      </customHeaders>
    </httpProtocol>
  </system.webServer>
</configuration>

これでテストを再実行すると無事に A+ 評価になりました。

f:id:shiba-yan:20180414214037p:plain

6 月までには正式リリースされるはずなので、その時には再度挙動を確認しておきたいと思います。

Application Insights で IP が取れない件と ASP.NET Core で Telemetry Initializer を追加する方法

ASP.NET Core アプリケーションに Application Insights を追加して使ってましたが、いつからかクライアントの IP アドレスが 0.0.0.0 になってしまってました。

元々最後のオクテットは 0 になってたのは知ってましたが、なんか不具合化と思ってました。

f:id:shiba-yan:20180319212212p:plain

軽く検索してみると GDPR の関係で IP アドレスを保持するのを止めたみたいです。*1

Client IP logged as 0.0.0.0 but geolocation is logged correctly

All octets of IP address will be set to Zero – Azure Application Insights Service Status Blog

なので、Application Insights が自動的に取っているデータからは IP アドレスを取ることは出来ません。もし IP アドレスが必要な場合は Telemetry Initializer を使ってカスタムデータとして送信しろと書いてます。

適当に IP アドレスを送信する Telemetry Initializer を書きました。IP アドレスは既に Application Insights が Location.Ip に設定してくれているので、これをそのまま送信することにします。

public class CustomIpTelemetryInitializer : ITelemetryInitializer
{
    public void Initialize(ITelemetry telemetry)
    {
        if (!(telemetry is RequestTelemetry requestTelemetry))
        {
            return;
        }

        requestTelemetry.Context.Properties["client-ip"] = requestTelemetry.Context.Location.Ip;
    }
}

テストなので特にマスクなどの処理を入れてないですが、GDPR が関係する場面だともうちょっと考える必要があるのかも知れません。知らんけど。

作成した Telemetry Initializer ですが、ASP.NET の場合は ApplicationInsights.config に追加すれば有効になってましたが、ASP.NET Core の場合は DI に追加すると有効になります。これ知りませんでした。

public void ConfigureServices(IServiceCollection services)
{
    services.AddSingleton<ITelemetryInitializer, CustomIpTelemetryInitializer>();

    services.AddMvc();
}

ITelemetryInitializer としてシングルトンで追加します。XML を書くより簡単になった気がします。

デプロイすれば、それ以降のテレメトリに IP アドレスが含まれるようになります。

f:id:shiba-yan:20180319213412p:plain

ASP.NET Core での Telemetry Initializer の追加方法を知らなかったので良い勉強になりました。それにしても GDPR 絡みは地獄感しかないですね。

*1:GDPR では IP アドレスは個人情報に該当するか微妙そうだけど、安全側に倒したということのようだ

Application Insights の Snapshot Debugger を使って本番環境のデバッグを効率的に行う

仕事でも Application Insights を弄っていたのに全く知らなかった機能である Snapshot Debugger ですが、チャックさんの記事で存在を知ったのと何となく試して見たかったので検証してみることにしました。

Tech Summit 2017 のチャックさんのセッションで話があったっぽいですね。Tech Summit 2017 は登壇側だったのと、登壇者は他のセッションに参加できないパスだったので見れてませんでした。

最近実装されたのかと思ってたらそんなことはなく、地味に前からある機能でした。ドキュメントもちゃんと整備されていて ASP.NET 4.6 以降と ASP.NET Core 2.0 アプリケーションならすぐに使えるようです。

上のスライドでは Visual Studio からの利用になってますが、アプリケーションにインストールしておくと、例外が投げられたタイミングでダンプを自動的に取るという素晴らしい機能です。

使い方も書いてある通りで、NuGet パッケージを 1 つ追加するだけで終わります。

プロダクション環境で発生した例外は Application Insights でも確認できますが、例外が発生したタイミングの変数の値などはデバッグに非常に役立ちます。なので今すぐ有効にした方が良い機能でした。

とりあえず試します。適当に MVC 5 アプリケーションを Web App / Application Insights を用意して、わざと落ちるようなアクションを書いてデプロイしました。

f:id:shiba-yan:20180213225312p:plain

Snapshot が取得できているとリクエストの横にアイコンが表示されます。

今回の Snapshot Debugger とは関係ないですが、Application Insights に Preview として実装されている E2E Transaction Diagnostics は有効にした方が調査が捗ります。

実際に Snapshot を確認するには、例外を選択して表示されるペインから Open debug snapshot を選びます。

f:id:shiba-yan:20180213225323p:plain

Snapshot を開くとコールスタックの確認が行えます。普通に Application Insights で確認出来るものより詳細な気がしますが、まあこっちはおまけみたいなものです。

f:id:shiba-yan:20180213225330p:plain

スクロールするとローカル変数の値を確認できるようになっています。こっちがメインの機能です。

コードが最適化されて消えてしまった場合は確認できないですが、大体のケースでは役に立つ情報が確認できるはずです。変数の値をログに書き出すよりも、ダンプがあった方が圧倒的に捗ります。

f:id:shiba-yan:20180213225341p:plain

少しのオーバーヘッドはあるでしょうが、圧倒的に便利な機能なので気にしません。

Visual Studio Enterprise を使っている場合には、収集された Debug Snapshot をダウンロードして Visual Studio でデバッグすることが出来ます。実際にデバッグするとソースコードと紐づいた形で確認できます。

f:id:shiba-yan:20180213230449p:plain

チャックさんの記事にもあったように、実行中のアプリケーションに対しても Visual Studio から Snapshot Debugger をアタッチすることが出来るので、プロダクション環境でデバッグが簡単に行えます。

f:id:shiba-yan:20180214000930p:plain

これまで Application Insights は他社の APM に比べると機能が少ないし、処理が遅くて残念に思っていましたが、最近は Profiler や Snapshot Debugger でかなりテコ入れがされた感じがあります。

そういえば Application Insights Profiler について書いてなかったので、暇な時にまとめます。

AppVeyor と CircleCI 2.0 を使って Azure Functions v2 の CI/CD を行う

GitHub を使って Azure Functions の管理をしている場合には、やはりデプロイは自動化しておきたいです。VSTS は標準で対応してるみたいですが、AppVeyor は見た記憶がないので試しました。

少し試した感じでは WebDeploy Package を Azure Functions プロジェクトで作るのは難しそうだったので、dotnet publish を実行してから普通の zip を作るようにします。

dotnet publish -c Release

Azure Functions v2 は .NET Core なので dotnet publish を実行すれば良いです。

最近は zip をプッシュするだけでデプロイ出来るようになったので利用します。App Service Team のブログに zip デプロイについての解説があるので、参照しておくと良さそうです。

https://blogs.msdn.microsoft.com/appserviceteam/2017/10/16/zip-push-deployment-for-web-apps-functions-and-webjobs/

デプロイする zip を作ってしまえば、後は curl を使って Kudu の API を呼び出すだけです。

AppVeyor

AppVeyor は App Service に zip でのデプロイを行うプロバイダーが追加されているので、これまでの WebDeploy とは異なり、非常に簡単に行えるようになっています。

今回作成した appveyor.yml は以下のようになりました。

dotnet publish で生成したファイルは自前で zip にしないといけないのが少し面倒ですが、1 行追加で済むので妥協します。Artifacts に設定しないと後続のデプロイがめんどくさくなるので注意。

version: 1.0.{build}
image: Visual Studio 2017
build_script:
- cmd: dotnet publish -c Release -o ..\publish
after_build:
- cmd: 7z a artifact.zip .\publish\*
artifacts:
- path: artifact.zip
deploy:
- provider: AzureAppServiceZipDeploy
  website: <FUNCTION_NAME>
  username: <USERNAME>
  password:
    secure: <ENCRYPTED_PASSWORD>

AzureAppServiceZipDeploy に指定する値は発行プロファイルに書いてある値なので簡単です。

実際に GitHub にリポジトリを作成して、AppVeyor でビルドを実行しました。

f:id:shiba-yan:20180211015609p:plain

ビルド自体は 1 分以内で完了するので非常に早いです。

ビルドが成功したのを確認した後に、Azure Portal で確認するとちゃんと作成した Function が確認できます。

f:id:shiba-yan:20180211015718p:plain

当然ながらちゃんと動作します。デプロイ後に Azure Functions Runtime のバージョンを変更できないので、そこだけは予め変更しておかないとはまります。

CircleCI 2.0

CircleCI を使う場合も AppVeyor とやることはほぼ同じですが、CircleCI 2.0 では Docker が使えるので Microsoft の公式 SDK イメージを利用してビルドを行います。

作成した config.yml は以下のようになりました。7z が入っていないのでインストールします。

version: 2
jobs:
  build:
    docker:
      - image: microsoft/dotnet:sdk
    steps:
      - checkout
      - run:
          name: Install 7zip
          command: apt-get update && apt-get install -y p7zip-full
      - run:
          name: Build Azure Function
          command: dotnet publish -c Release -o ../publish
      - run:
          name: Pack to Zip
          command: 7z a artifact.zip ./publish/*
      - run:
          name: Push to App Service
          command: curl -X POST -u $SITE_USER:$SITE_PASS --data-binary @artifact.zip https://***.scm.azurewebsites.net/api/zipdeploy

App Service へのデプロイは curl を使って 1 発で終わります。

CircleCI 2.0 の対応言語には .NET Core がありませんでしたが、今回利用した SDK イメージはキャッシュ済みのようで、一瞬で環境の構築が完了しました。

f:id:shiba-yan:20180211024925p:plain

ビルドは AppVeyor よりも早く感じました。デプロイが完了後は Azure Portal から確認できます。

.NET Core になってからはビルド環境の構築が非常に楽になり、プラットフォームも気にする必要がほぼなくなったため SaaS の選択肢が大幅に広がり便利になりました。

Azure Functions のコールドスタートを Application Insights で確認してみる

何かと(一部?で)話題になる Azure Functions のコールドスタートが非常に遅いという話ですが、App Service Team Blog にてどのような動きとなっているか解説記事が公開されていました。

ぶちぞう RD のアンテナは高いので、フォローしておくと有益な情報が得られます。

https://blogs.msdn.microsoft.com/appserviceteam/2018/02/07/understanding-serverless-cold-start/

曰く 20 分ぐらいアイドルだとインスタンスが落とされて、次からはコールドスタートになるようです。

Azure Functions というか App Service のコールドスタートを早くするために、予め Function Runtime を ngen しておいたり、placeholder を使ってプロビジョニングを短縮してるようです。

常に ping を飛ばしてコールドスタートに落ちないように維持する方法はありますけど、ぶちぞう RD の言う通りスケールした時に問題となるケースが出てきます。

コールドスタートを早くする努力をしようとしても、Kudu にアクセスした瞬間 Web App はプロビジョニングされてしまうので、コールドスタートを検知するのは地味に難しいという罠があります。

なので今回は Application Insights の Live Metrics Stream を使って試しました。いろいろ試した結論としては、ちゃんとコールドスタートを検知できているように思います。

f:id:shiba-yan:20180208235500p:plain

送られてくるログを確認すると、ロックを取ったり拡張機能を読み込んだりという処理が見えます。

ウォームスタートでは行われない処理なのと、前後でインスタンス ID が変わっているので別のマシンに移動したことが分かります。インスタンスが落ちると Servers から消えるので分かりやすいです。

f:id:shiba-yan:20180209001325p:plain

実際に Application Insights から消えた後に HTTP リクエストを投げると 5-10 秒ぐらいかかるので、コールドスタートになっていると考えられます。w3wp が死んでから、App Service 自体が落ちるまでは多少のタイムラグがありそうです。

.NET Core 版を実行した時のログでは host.ini が読み込まれてから、実際に Function が実行されるまで Application Insights 上の時間で 6 秒ぐらいかかっているので、やはり読み込み周りが辛いみたいです。

f:id:shiba-yan:20180209002839p:plain

まだ .NET Core 版は最適化されていないらしいので、GA 時には改善されているはずですが、結局のところ Azure Functions の Consumption Plan が遅い原因は Azure Files だろうと思っています。

本末転倒ですが App Service Plan で Premium V2 に Azure Functions を作れば劇的に改善しそうです。

Application Insights を利用した Durable Functions の診断を試してみた

仕事でも Durable Functions を使っていますが、一般的な HttpTrigger などと異なり Durable Functions は実行される仕組みが特殊なので、Azure Functions のログだけでは分かりにくいことが多いです。

なので、とりあえず Application Insights は有効にしておきましょう。非常に便利で捗ります。

Azure Functions の作成時に Application Insights を有効に出来るので、最初から作っておくのが楽です。後からでも追加できますが、手動で Application Insights のキーを設定しないといけない場合があります。

とりあえず動かして理解するのがモットーなので、テンプレートで作られる Durable Functions をデプロイし、数回実行して Application Insights にデータを流し込みました。

f:id:shiba-yan:20180204222929p:plain

この辺りは普通の Azure Functions と同じですが、Function1 の呼び出し回数が多いことに注意。オーケストレーターはリプレイされるので、アクティビティよりも多く実行されています。

リクエストに紐づいている処理は Application Insights から "All traces for this operation" を選べば、実行 ID 単位で検索してくれるのでこれもまた捗ります。Azure Functions に限らず、Web Apps でもセッション・ユーザー単位で簡単に検索できるので便利です。

f:id:shiba-yan:20180204223010p:plain

ちゃんとロガーで書き出したメッセージも Application Insights から確認できます。

次はアクティビティで落ちるように仕込んだものをデプロイして、再度実行してみました。Application Insights の Failures を選ぶと一目でエラーが発生したアクティビティが確認出来ました。

f:id:shiba-yan:20180204223319p:plain

失敗したリクエストを辿っていくと、ちゃんとコールスタックの確認まで出来ます。

f:id:shiba-yan:20180204223250p:plain

この辺りまで確認できればエラーの原因を特定して、修正することが出来ると思います。昔に比べると Application Insights はかなり使いやすくなったと思う瞬間でした。

最後にドキュメントに書いてあった AIQL を使って実行履歴を取得するコードを試しました。インスタンス ID と開始した大体の時間を設定して、Analytics からクエリを実行します。

f:id:shiba-yan:20180204222937p:plain

出力されるログが非常にわかりやすいので、どのような仕組みで Durable Functions が実行されているのか一目で把握することが出来ます。

全てのログは Table Storage にも残っているので、AIQL で詳しく引いてから Table Storage を調べるという方法が効率良さそうです。

Azure Functions v2 でアプリケーション設定と接続文字列を読み込む

.NET Framework 版では ConfigurationManager を使っておけば、アプリケーション設定と接続文字列を App Service がいい感じに環境変数からオーバーライドしてくれましたが、.NET Core 版の Azure Functions を使っている場合はクラスが無いので調べました。

GitHub の Issue が引っ掛かったので読むと、ASP.NET Core の仕組みを使ってねとありました。

Azure Functions v2 は ASP.NET Core ベースなので納得いく回答ですが、ConfigurationManager を使う場合と比べて準備が必要になるので少し手間です。

ここ数日の間にコメントで動くコードが紹介されていたので、ありがたく使わせてもらいました。

ConfigurationBuilder を使って local.settings.json をオプションとして読みつつ、環境変数からも読み込むように設定を行って IConfigurationRoot を作成すれば読めるようになります。

上の記事のサンプルでは SetBasePath を使っていましたが、カレントディレクトリがちゃんと設定されているのでファイル名だけにしました。ExecutionContext を取るのが嫌だったんです。

public static class Function1
{
    static Function1()
    {
        var builder = new ConfigurationBuilder()
                        .AddJsonFile("local.settings.json", true)
                        .AddEnvironmentVariables();

        Configuration = builder.Build();
    }

    private static IConfigurationRoot Configuration { get; }

    [FunctionName("Function1")]
    public static IActionResult Run([HttpTrigger(AuthorizationLevel.Function, "get", "post", Route = null)]HttpRequest req, TraceWriter log)
    {
        log.Info("C# HTTP trigger function processed a request.");

        log.Info($"AppSettings:SlotName {Configuration["SlotName"]}");
        log.Info($"ConnectionString:DefaultSqlConnection = {Configuration.GetConnectionString("DefaultSqlConnection")}");

        return new OkObjectResult($"Hello, world");
    }
}

起動時に 1 回だけ ConfigurationBuilder が走るようにしてますが、設定が変更されると再起動されるはずなので問題ありません。非常に単純なコードですが、ConfigurationManager よりは手間です。

ちなみに local.settings.json は以下のように適当にキーを追加しておきました。

f:id:shiba-yan:20180203012828p:plain

これで Function をローカルで実行してみると、ちゃんと local.settings.json に書いた内容が読み込まれます。

f:id:shiba-yan:20180203010632p:plain

ローカルでは local.settings.json に書かれた値が読み込まれますが、Azure 上では例によって Azure Portal から設定して、特殊な環境変数経由で読み込む必要があります。

これまで特に違和感なく使っていたのですが、ASP.NET Core 側で App Service 向けのコードが追加されていたので、気にせずとも値を読み込めるようになってました。

Azure .NET Core Application Settings – Http Client Protocol Issues (and other fun stuff I support)

なので安心して Azure Functions の設定からアプリケーション設定と接続文字列を追加します。

f:id:shiba-yan:20180203011322p:plain

設定後に Function を実行すると、ログに設定した値がちゃんと出力されているので、環境変数経由で正しく読み込まれたことが確認出来ました。この簡単さは相変わらず良いです。

f:id:shiba-yan:20180203011517p:plain

Function が 1 つの場合ならこれでもよいかもしれないですが、大体は複数の Function から設定を参照したいことばかりだと思うので、専用のクラスを作って対応しておきます。

文字列でキー名を指定するのは嫌いなので、大体プロパティにしてしまうタイプです。

public class Settings
{
    public Settings()
    {
        var builder = new ConfigurationBuilder()
                        .AddJsonFile("local.settings.json", true)
                        .AddEnvironmentVariables();

        _configuration = builder.Build();
    }

    private readonly IConfigurationRoot _configuration;

    public string SlotName => _configuration[nameof(SlotName)];

    public string DefaultSqlConnection => _configuration.GetConnectionString(nameof(DefaultSqlConnection));

    public static Settings Instance { get; } = new Settings();
}

Azure Functions は ASP.NET Core で言うところの Startup クラスが無いので、全体で 1 回だけ行いたい場合の処理が地味に書きにくいと思いました。CodePageEncodingProvider の追加とかも書きにくい。

このクラスを使って Function を微修正すれば、アプリケーション全体で参照できるようになってます。

public static class Function1
{
    [FunctionName("Function1")]
    public static IActionResult Run([HttpTrigger(AuthorizationLevel.Function, "get", "post", Route = null)]HttpRequest req, TraceWriter log)
    {
        log.Info("C# HTTP trigger function processed a request.");

        log.Info($"AppSettings:SlotName {Settings.Instance.SlotName}");
        log.Info($"ConnectionString:DefaultSqlConnection = {Settings.Instance.DefaultSqlConnection}");

        return new OkObjectResult($"Hello, world");
    }
}

みんな違和感なく ConfigurationBuilder を使って書いてるとも思いにくいし、ドキュメントやテンプレートが無いのは地味に困りましたね。

実際に悩んだ部分かつ、検索しても見つからなかったのでメモ代わりに残します。

Durable Functions を使って時間のかかる処理を非同期で行う

前回は Durable Functions について大雑把に試したりしていましたが、ちょうど今仕事で Durable Functions を適用するのにちょうどよいタスクがあったので、実際に使ってみることにしました。

内容はタイトルの通りです。Durable Functions については前回のエントリを。

あまり実装したくない部類の機能ですが、どうしても必要となるのがデータのエクスポート機能だったりします。一瞬で終わるようなデータ量なら良いのですけど、こういうのに限って大量のデータだったり、複雑なデータ構造を要求されたりします。

今回はデータ量はそこまで多くないはずですが、全て Cosmos DB で作られているので考慮せずにクエリを投げまくると RU が爆発して即死します。今回の機能は要件としては以下のような感じです。

  1. 時間はかかっても良いからリソースを集中して消費しないように
  2. エラーが発生しても柔軟にリトライしたい
  3. ファイルの準備が完了したかどうか確認したい
  4. 間違ったときのためにキャンセルもしたい

Durable Functions を使うだけで上の 4 つを全て満たす処理を書くことが出来るわけです。

今回のデータエクスポートで必要な処理は以下の通りです。

  1. エクスポートの対象をリストアップする
  2. 実際のエクスポートするデータを取得する
  3. データを整形して Blob Storage に書き出す

流石に本番のコードを出すことは出来ないので、同じようなサンプルを用意したので各アクティビティ単位で見ていくことにします。サンプルなのでわざと Task.Delay を入れてます。

エクスポートする対象を抽出するアクティビティ

対象を抽出するためのアクティビティです。サンプルなので適当に10 件分のデータを返しています。並列処理が必要な場合はいい感じに分割しても良いと思います。

[FunctionName("AggregateTargets")]
public static async Task<string[]> AggregateTargets([ActivityTrigger] DurableActivityContext context)
{
    await Task.Delay(TimeSpan.FromSeconds(10));

    return Enumerable.Range(0, 10).Select(x => $"target-{x}").ToArray();
}

単純に 1 クエリ引くだけで終わる処理の場合は Durable Functions にする必要はないわけです。複数のテーブルやコレクションから引っ張ってくるからこそ、今回 Durable Functions を使うように実装しました。

実際のデータを取得するアクティビティ

実際のデータを何かしらのストレージから取得するアクティビティです。仕事では全て Cosmos DB なので地味につらかったですが、今回はあくまでもサンプルなので入力値を適当に加工して返すだけです。

ActivityTrigger では DurableActivityContext を指定することも出来るので、覚えておいて損はないです。実行中の InstanceId が必要になった場合には、このコンテキスト経由で取得できます。

[FunctionName("GetDataFromStorage")]
public static async Task<string> GetDataFromStorage([ActivityTrigger] DurableActivityContext context)
{
    var input = context.GetInput<string>();

    await Task.Delay(TimeSpan.FromSeconds(5));

    return $"{input}: sample data";
}

Durable Functions では一度実行されたアクティビティの結果は Table Storage に保存されるようになってますが、当然ながら Table Storage のエンティティサイズの上限という壁があることをお忘れなく。

Table Storage では 1MB 以上のデータは保存できないので、アクティビティで巨大なデータを返すと死にます。その場合はいったんファイルに書き出して、そのパスを返すなどしましょう。

Storage Blob へ書き出すアクティビティ

取得したデータを整形して Blob Storage に保存するアクティビティです。Blob を使うのは地味に面倒ですが、Azure Functions なら Binder を使って楽が出来ます。

[FunctionName("WriteToBlobFile")]
public static async Task WriteToBlobFile([ActivityTrigger] DurableActivityContext context, Binder binder)
{
    var outputs = context.GetInput<IList<string>>();

    var filePath = "outputs/sample.txt";

    using (var destination = await binder.BindAsync<CloudBlobStream>(new BlobAttribute(filePath, FileAccess.Write)))
    using (var writer = new StreamWriter(destination))
    {
        foreach (var value in outputs)
        {
            writer.WriteLine(value);
        }
    }
}

GetInput を使ってデータを取得して、BindAsync を使って Blob へ書き込んでいきます。拍子抜けするぐらい簡単なコードで実現することが出来ました。

アクティビティの入力も Table Storage に保存されているので、出力と同様にサイズ制限があることをお忘れなく。1MB が上限といっても、他のカラムにもデータが入っていて目減りするので注意。

アクティビティを実行するオーケストレーター

最後は作成したアクティビティを実行していくオーケストレーターを用意します。といっても大したコード量ではないですし、await で分かりやすいので説明は不要な気がします。

[FunctionName("ExportFile")]
public static async Task<int> RunOrchestrator([OrchestrationTrigger] DurableOrchestrationContext context)
{
    var targets = await context.CallActivityAsync<string[]>("AggregateTargets");

    var outputs = new List<string>();

    foreach (var target in targets)
    {
        outputs.Add(await context.CallActivityAsync<string>("GetDataFromStorage", target));
    }

    await context.CallActivityAsync("WriteToBlobFile", outputs);

    return outputs.Count;
}

今回は並列処理をさせたくなかったので foreach の中で await して結果を待つようにしています。これで Durable Functions が順番を保ったままアクティビティを実行してくれます。

動作を確認する

コードを完成したので実際に動かしてテストします。とりあえずサクッとオーケストレーターを起動。

f:id:shiba-yan:20180130163145p:plain

起動に成功すると 202 とオーケストレーターの状態を確認できる URL などが返ってきます。この URL を叩けばオーケストレーターの状態を JSON で取得できます。

実際に叩いてみると Running と返ってきました。ちゃんと動いているようですね。

f:id:shiba-yan:20180130163153p:plain

地味に起動の応答は素早く返して、非同期で実行させながら状態を確認する API を作るのはめんどくさいですが、Durable Functions ならデフォルトで用意されてます。

何回か確認しているうちに runtimeStatus が Completed に変わりました。そして output には 10 が返ってきていますが、これは実際に処理された件数となります。

f:id:shiba-yan:20180130163200p:plain

オーケストレーターの戻り値が output の値になるので、正常終了時に追加の情報を返すことも簡単です。

Storage Explorer で Blob を確認すると、ファイルがちゃんと生成されています。

f:id:shiba-yan:20180130163246p:plain

ダウンロードして開いてみると、実行順を保ったまま書き出されていることが確認できます。

f:id:shiba-yan:20180130164521p:plain

これまで Azure Functions では画像のサイズ変換などを行うサンプルが多かったですが、Durable Functions を使うと処理の状態や結果を返すように出来ますね。処理を中断出来るのもポイントです。

補足 : シングルトンで処理を行う

良くある話として、同じデータに対する処理を同時に実行させたくないことが多いと思います。特に Durable Functions のように非同期でオーケストレーターが走る場合には簡単に並列実行が出来てしまいます。

同じデータへの並列実行を防ぐためにシングルトンでの実行をさせます。Durable Functions のシングルトンは単純でインスタンス ID を入力値から一意に決めてしまえば実装出来ます。

エクスポートする場合には ID や日付などで範囲を指定することがあると思うので、そういったデータからハッシュを計算してインスタンス ID とすれば良いでしょう。

[FunctionName("ExportFile_HttpStart")]
public static async Task<HttpResponseMessage> HttpStart([HttpTrigger(AuthorizationLevel.Anonymous, "post")] HttpRequestMessage req,
                                                        [OrchestrationClient] DurableOrchestrationClient starter,
                                                        TraceWriter log)
{
    // POST されたデータから InstanceId を一意に生成
    var instanceId = "...";

    var existingInstance = await starter.GetStatusAsync(instanceId);
    if (existingInstance == null)
    {
        // Function input comes from the request content.
        await starter.StartNewAsync("ExportFile", instanceId, null);

        log.Info($"Started orchestration with ID = '{instanceId}'.");
        return starter.CreateCheckStatusResponse(req, instanceId);
    }

    return req.CreateErrorResponse(HttpStatusCode.Conflict, $"An instance with ID '{instanceId}' already exists.");
}

これで同じパラメータに対して、複数のオーケストレーターが実行されることは無くなりました。Durable Functions はもっと早くから触っておけばよかったと実感する日々です。

今更ながら Azure Functions の Durable Functions を試した

前から Durable Functions が面白そうだと思いつつ、Visual Studio 2017 15.5 が必要とかで中々試していなかったのですが、少し時間があったので一通り調べて動かしてみました。

当然ながら全ては .NET Core の v2 ランタイム向けで試してあります。まずはドキュメントを。

後は牛尾さんの Qiita エントリを読んでおくと基本的な知識が入るはずです。

Visual Studio 向け拡張もアップデートが進んで、Durable Functions が簡単に追加できるようになっているので Qiita に書かれた当時と比べて、さらにイケてる機能となっています。

元々 App Service も Azure Functions も Cloud Design Pattern の集大成という感じなのですが、Durable Functions は更にいろんなパターンが内部で使われていて面白いです。とりあえず話を進めます。

Durable Functions の作成とデプロイ

Visual Studio で Azure Functions プロジェクトを新規作成し、新規追加から Azure Function を選ぶとダイアログが表示されます。そこで Durable Functions Orchestration を選べば必要なものが追加されます。

f:id:shiba-yan:20180129154551p:plain

ローカルでの実行はストレージが必要になるっぽいので、Azure Storage Emulator か Azure Storage の接続文字列を設定すればよいと思います。私は久し振りに Storage Emulator を起動しました。

Azure へのデプロイはこれまで通りですが、.NET Core (v2) を選んでいるのでベータ版のランタイムを有効化する必要があります。Visual Studio が教えてくれるので簡単です。

f:id:shiba-yan:20180129162512p:plain

デプロイはこれで終わりです。Azure 環境に置いておいた方がログとか見やすいのでデプロイしておきました。次は Durable Functions を構成する要素を大雑把につまみ食いして説明します。

ちなみに Durable Functions で実装されているクラスはこれだけしかないので、簡単に理解できます。

f:id:shiba-yan:20180129190627p:plain

分かりやすいようにサンプルコードベースで説明することにします。

オーケストレーターは起点

OrchestrationTrigger が付けられているものが Durable Functions ではオーケストレーターとして扱われます。

渡される DurableOrchestrationContext には後述するアクティビティを実行するメソッドや、更にオーケストレーターを実行するもの、外部からのイベントを待機したり、入力値を取得するものがあります。

[FunctionName("Function1")]
public static async Task<List<string>> RunOrchestrator(
    [OrchestrationTrigger] DurableOrchestrationContext context)
{
    var outputs = new List<string>();

    // Replace "hello" with the name of your Durable Activity Function.
    outputs.Add(await context.CallActivityAsync<string>("Function1_Hello", "Tokyo"));
    outputs.Add(await context.CallActivityAsync<string>("Function1_Hello", "Seattle"));
    outputs.Add(await context.CallActivityAsync<string>("Function1_Hello", "London"));

    // returns ["Hello Tokyo!", "Hello Seattle!", "Hello London!"]
    return outputs;
}

オーケストレーターはべき等である必要があるので、内部でネットワーク・ファイル I/O などは厳禁です。行える処理は DurableOrchestrationContext を介したものだけです。タスクベースのインターフェースなので、特に説明もなく使えると思います。

CallActivityAsync を呼び出すと、Queue Storage を経由して実際のアクティビティが実行されます。実行が Queue を経由するという部分は非常に重要なポイントとなってきます。

アクティビティが実際の処理を行う

オーケストレーターではネットワーク・ファイル I/O などの、べき等ではない処理は行えませんがアクティビティでは書くことが出来ます。SQL DB や Cosmos DB へのアクセスも問題なく行えます。

アクティビティはスケーリングされて実行される可能性があるので、基本的にはステートレスで書いた方がパフォーマンスが良いです。状態が必要になるとも思いにくいですし。

[FunctionName("Function1_Hello")]
public static string SayHello([ActivityTrigger] string name, TraceWriter log)
{
    log.Info($"Saying hello to {name}.");
    return $"Hello {name}!";
}

本来ならネットワークアクセスや CPU を食う処理などを適切な粒度で行わせた方が良いのですが、あくまでもサンプルコードなので気にしない方向で。

タスクベースで抽象化されているので、アクティビティがどのように実行されているか考える必要がないですが、スレッドプールではなく別インスタンス上で実行される可能性もあることを頭に入れて、オーバーヘッドのことも考慮しつつ設計する必要があるでしょう。

非常に細かくアクティビティを作成することも出来ますが、同一プロセス内の別スレッドで実行するのと比べて、数桁のオーバーヘッドが発生する可能性も十分あります。そもそも Queue Storage で処理されるので数秒は遅延が発生します。

オーケストレーターを起動する

ドキュメント的にはオーケストレーターを直接実行できるみたいですが、何故か上手くいかなかったので HttpTrigger と StartNewAsync を使って実行するようにします。

StartNewAsync を実行後に返ってくるインスタンス ID を使ってオーケストレーターを識別するので、レスポンスとして返す必要があります。

[FunctionName("Function1_HttpStart")]
public static async Task<HttpResponseMessage> HttpStart(
    [HttpTrigger(AuthorizationLevel.Anonymous, "get", "post")]HttpRequestMessage req,
    [OrchestrationClient]DurableOrchestrationClient starter,
    TraceWriter log)
{
    // Function input comes from the request content.
    string instanceId = await starter.StartNewAsync("Function1", null);

    log.Info($"Started orchestration with ID = '{instanceId}'.");

    return starter.CreateCheckStatusResponse(req, instanceId);
}

DurableOrchestrationClient には名前の通りオーケストレーターの開始、状態取得、終了を行うメソッドがあります。後述する RaiseEventAsync といったイベントを発火させるメソッドもあります。

Durable Functions は同期的に動く部分が存在しないので、インスタンス ID を使ってオーケストレーターの状態を取得することは重要です。

外部イベントを受け取る

面白いと思ったのが外部イベントの仕組みです。オーケストレーター内で WaitForExternalEvent を待機させると、Webhook か RaiseEventAsync でイベントが発火されるまでは待ち続けます。

公式のサンプルでは SMS を使った 2FA を実装していましたが、外部に DB などを必要することなく実装出来ていて興味深いサンプルとなっています。

使い方もタスクベースなので説明はほぼ不要ですね。イベント名だけ決めれば良いです。

var eventValue = await context.WaitForExternalEvent<string>("SampleEvent");

イベントを発火させるには HttpTrigger などで DurableOrchestrationClient の RaiseEventAsync を呼び出すのが簡単です。当然ながらインスタンス ID が必要なので保持しておく必要があります。

一緒に渡すパラメータは普通にクラスでも良いです。

await client.RaiseEventAsync(instanceId, "SampleEvent", "testvalue");

もう一つは Durable Functions が標準で提供している Webhook を使う方法です。

Webhook なので上手く使えば、GitHub にマージされたタイミングでリリース確認のメールを送信し、更にメール内のリンクを踏めばリリース処理が継続といったことも出来そうです。

POST http://{host}/runtime/webhooks/DurableTaskExtension/instances/{instanceId}/raiseEvent/{eventName}?taskHub=DurableFunctionsHub&connection=Storage&code=***

この辺りを活用すれば Logic Apps とほぼ同じことが実現できます。

スケーリングを検証

使っていて気になるのが大体スケーリング周りですが、Durable Functions は内部的に Queue Storage が使われているので、ステートレスなアクティビティを書いている限りは幾らでもスケールアウト出来そうです。

ドキュメントには仕組みが分かりやすく絵入りで書いてあったので必見ですね。

実際にどんな感じでスケールするのか確認したかったので、以下のようなオーケストレーターを書いてステートレスなアクティビティを大量に実行するようにしました。サンプルを少し変えただけです。

元々は CallActivityAsync の時点で await していたのを、最後に Task.WhenAll するように変えただけです。

public static async Task<IList<string>> Run([OrchestrationTrigger] DurableOrchestrationContext context)
{
    var outputs = new List<Task<string>>();

    for (int i = 0; i < 100; i++)
    {
        outputs.Add(context.CallActivityAsync<string>("SayHello", i.ToString()));
    }

    return await Task.WhenAll(outputs);
}

[FunctionName("SayHello")]
public static string SayHello([ActivityTrigger] string name, TraceWriter log)
{
    return $"Hello {name}! from {Environment.MachineName}";
}

Durable Functions のランタイムによって Azure Storage にいくつかキューが作成されます。

ドキュメントによるとステートレスなアクティビティの場合は workitems が使われるらしいので、スケーリングの上限は秒間 2000 メッセージといったところでしょうか。

f:id:shiba-yan:20180129182343p:plain

オーケストレーターを実行すると workitems に大量のメッセージが追加されます。このキューの長さを元にして Azure Functions のランタイムがオートスケールを行ってくれます。

f:id:shiba-yan:20180129173801p:plain

Azure Functions の Consumption プランでは何インスタンスが動作しているか確認しにくいですが、ARM Explorer を使えば現在のインスタンス ID が取得できるので大体は把握が可能です。

実際に確認するとインスタンス ID が 2 つ取れたので、2 インスタンスが割り当てられています。

f:id:shiba-yan:20180129173824p:plain

今回試して分かったこととして、Azure Functions のオートスケールは思っていたよりも速いということでした。素早くインスタンスが増えて、処理が終わればあっという間にいなくなっていて驚きました。

最後に出力結果を確認しておきます。マシン名を追加したので一目でスケールしたことが分かります。

f:id:shiba-yan:20180129173925p:plain

ARM Explorer 上では 2 インスタンスしか確認できませんでしたが、マシン名を見るとユニークなものが 4 つほどあるので、実際には 4 インスタンスで実行されたということになります。

普通に Azure Functions を直接使うより、Durable Functions を被せておいた方が有利な場面が多そうに感じました。こっちの方が洗練されたサーバーレスという感じがします。