しばやん雑記

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

Azure Functions の Retry Policy 機能を使って信頼性の高い処理を実装する

GitHub 上で開発が進んでいることは把握していましたが、ついに待望の Retry Policy 機能が Azure Functions に実装されました。まだ Preview 扱いのようですが、1 年以上は待った気がします。

単純にリトライするだけであれば Polly などを使えば済みますが、Cosmos DB や Event Hubs といった一部のトリガーでは Checkpoint が関係するので簡単にはいきません。Retry Policy が必要な理由はここです。

現在は Fixed Time と Exponential Backoff の 2 種類が選べますが、多少カスタマイズ出来るようには計画されているようです。大体のケースではこれで十分でしょう。

Azure Functions のエラーハンドリングとリトライのガイドラインが更新されて、新しく実装された Retry Policy について追加されています。Function の実装が重要になるので読んでおきましょう。

ドキュメントにもあるようにリトライが組み込まれているトリガー向けではありません。QueueTrigger では指定した回数までリトライを行い、失敗した場合は Dead Letter Queue へ送ってくれますし、Event Grid はそれ自身がリトライ機能を持っています。組み合わせて使うことも出来ますが、混乱の元でしょう。

Retry Policy と紹介されていますが、以前は Checkpoint control for Event Hubs / Cosmos DB と呼ばれていた機能なので、主なユースケースは Event Hubs と Cosmos DB Change Feed となります。

この 2 つのサービスは Checkpoint と呼ばれる、メッセージをどこまで処理したかという情報を持っていますが、これまでの Azure Functions の実装では Function が失敗しても Checkpoint が進んでしまっていました。何故このような挙動になったかは不明ですが、DLQ のようなものを Function が独自に実装するよりユーザーに任せることを選んだのかもしれません。

ぶっちゃけ挙動としては最悪で、失敗したメッセージは失われてしまうのですが、今回追加された Retry Policy を使うと Checkpoint は処理が完了するまでは進まないため、メッセージ処理の信頼性が高まります。

Event Hubs and Azure Cosmos DB checkpoints won't be written until the retry policy for the execution has completed, meaning progressing on that partition is paused until the current batch has completed.

Azure Functions error handling and retry guidance | Microsoft Docs

Function の処理が完了するまでは Checkpoint が進まないため、実行中にアプリケーションのデプロイを行っても処理が冪等になっていれば安全です。確実に最低 1 回の処理が行われるため、Event Hubs や Change Feed を使ったアーキテクチャが更に捗ります。

従って Checkpoint を持っていないトリガーに対して使うメリットは、正直あまりないという印象を持っています。既に多くの Azure Functions で作られたアプリケーションは、Queue や Event Grid といった信頼性の高いトリガーが使われているはずです。

Retry Policy を実際に試す

前置きが長くなったので、実際に Cosmos DB Trigger と Event Hubs Trigger でエラーを発生させて Checkpoint が進まずにリトライが行われるのか、そして修正したアプリケーションをデプロイすれば続きから実行されるのかを確認しておきました。

何故か Azure Functions SDK が更新されておらず、そのままでは新しい属性が使えませんでした。昨日試した時には WebJobs SDK も更新されておらず全く使えない状態でしたが、Azure Functions の PM である Jeff に相談したところ対応してくれました。

ExtensionsMetadataGenerator のバージョンも古いままなので、SDK のアップデートを期待しています。一応 GitHub に Issue が上がっていたので、近日中にアップデートされる予感がします。

今のところは FixedDelayRetryExponentialBackoffRetry を使う際にはバージョン 3.0.23 以降の Microsoft.Azure.WebJobs パッケージを NuGet からインストールしてください。

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>netcoreapp3.1</TargetFramework>
    <AzureFunctionsVersion>v3</AzureFunctionsVersion>
  </PropertyGroup>
  <ItemGroup>
    <PackageReference Include="Microsoft.Azure.WebJobs" Version="3.0.23" />
    <PackageReference Include="Microsoft.NET.Sdk.Functions" Version="3.0.9" />
  </ItemGroup>
  <ItemGroup>
    <None Update="host.json">
      <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
    </None>
    <None Update="local.settings.json">
      <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
      <CopyToPublishDirectory>Never</CopyToPublishDirectory>
    </None>
  </ItemGroup>
</Project>

Visual Studio を使って Azure Functions 開発を行っている場合は、自動的に Azure Functions Core Tools がアップデートされるので気にする必要はありませんが、VS Code などを使っている場合は忘れずに最新版にアップデートしてください。

Cosmos DB Trigger で使った場合

現実的な話をすると、Cosmos DB の Change Feed や Event Hubs で受け取ったメッセージを失っても良いケースは存在しないはずなので、どちらのトリガーを使うにしてもリトライ回数に上限を設定するべきではないでしょう。上限なしは -1 を設定すれば良いです。

今回は固定の待ち時間でリトライ回数の上限なしに設定しています。パブリッククラウドで良くあるリトライすれば回復する障害向けには、Exponential Backoff に最大待ち時間を指定する方が良いです。

public class Function1
{
    [FixedDelayRetry(-1, "00:01:00")]
    [FunctionName("Function1")]
    public void Run([CosmosDBTrigger(
                        databaseName: "HackAzure",
                        collectionName: "TodoItem",
                        ConnectionStringSetting = "CosmosConnection",
                        LeaseCollectionName = "Lease")]
                    IReadOnlyList<Document> input, ILogger log)
    {
        log.LogInformation("Documents modified " + input.Count);

        foreach (var document in input)
        {
            log.LogInformation("Changed document Id " + document.Id);
        }

        throw new Exception();
    }
}

ただし回復不要なエラーが発生した状態で無限にリトライを行うのは、処理が完全に停止することを意味するので Application Insights を使ったモニタリング・アラートの設定は必須でしょう。

リトライが本当に行われて、その時に Change Feed が進まないことを確認するために常に例外を投げるコードを書いたので、しばらくデプロイして動かしておくと以下のようにエラーが出続けることが確認できます。

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

暫く動かした後に例外を投げるコードを削除して再デプロイしたので、その後はエラーが止まっています。

この時には 3 つのドキュメントを変更したので、Change Feed には 3 件分の変更が入っているはずです。エラーの数とは一致しませんが、リトライ間隔が 1 分間で Change Feed はバッチでデータを取るからです。

Application Insights にはドキュメントの id を書き出しておいたので、id で検索すると 1 分間隔でログが見つかります。これでリトライの度に Change Feed が進んでいないことが確認できます。

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

途中で何回かデプロイを行いましたが、Change Feed はエラーを修正するまで進むことはありませんでした。これまで求めていた挙動が Retry Policy によって実現出来ていることが確認できました。

Cosmos DB SDK v3 の Change Feed Processor や Polly などで独自のリトライ処理を組み込まずとも、Azure Functions だけで信頼性の高いメッセージの処理が行えるようになったので、非常に便利になりました。

Event Hubs Trigger で使った場合

Event Hubs Trigger の場合も基本は Change Feed と同じですが一応デプロイして動作を確認しておきました。例によって処理の最後で例外を投げるようにして、Functions を失敗扱いにさせています。

内部エラーなどでイベントを失ってよいはずは無いので、リトライ回数は無制限です。

public class Function2
{
    [FixedDelayRetry(-1, "00:01:00")]
    [FunctionName("Function2")]
    public void Run([EventHubTrigger("events", Connection = "EventHubConnection")]
                    EventData[] events, ILogger log)
    {
        log.LogInformation("Received events " + events.Length);

        foreach (var eventData in events)
        {
            var messageBody = Encoding.UTF8.GetString(eventData.Body.Array, eventData.Body.Offset, eventData.Body.Count);

            log.LogInformation($"C# Event Hub trigger function processed a message: {messageBody}");
        }

        throw new Exception();
    }
}

今回テストに使用した Event Hubs は上限いっぱいの 32 パーティションを持っているので、それぞれのパーティション単位で Function が実行され、同様にリトライも行われていました。

なので Application Insights を見ると 1 分間隔で出力されているログが 2 つ見つかります。イベントを 2 つ投げたので、それぞれでリトライが実行されていることが分かります。

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

途中でデプロイを行ってもイベントは失われませんし、エラーを修正すれば続きから実行されます。

Retry Policy と冪等な Function を用意すれば信頼性の高いイベント処理が行えるので簡単になりました。リトライを Function の実装で組み込むのは Checkpoint のコントロールが出来ないので限界がありました。

利用上の注意点

基本的には Cosmos DB Trigger と Event Hubs Trigger を使う場合には必須になった Retry Policy ですが、当然ながら注意点も色々とあるので理解した上で組み込んでいきましょう。

とは言え Cosmos DB / Event Hubs 向けに使う限りほぼ弱点は無いです。積極的に使っていきましょう。

最大リトライ回数はベストエフォート

ドキュメントにも書いていますが、現在のリトライ回数はインスタンス単位でメモリ上に保持されているので、インスタンスの移動やデプロイなどでプロセスが再起動すれば失われます。

リトライ回数の信頼性を高めるために値の永続化とか無駄なので、この仕様は妥当だと考えています。

Other triggers, like HTTP and timer, don't resume on a new instance. This means that the max retry count is a best effort, and in some rare cases an execution could be retried more than the maximum, or for triggers like HTTP and timer be retried less than the maximum.

Azure Functions error handling and retry guidance | Microsoft Docs

Cosmos DB と Event Hubs は Checkpoint を持っているおかげで、途中でプロセスが再起動したとしても自動的に続きから実行されて、エラーが発生すれば自動的にリトライが行われるため影響はありません。

それ以外のトリガーでは再起動が走ればリトライは無かったことになるので、リトライ回数を無制限にしたとしても成功するまで必ず実行されるという保証はありません。そもそも Cosmos DB / Event Hubs 以外で使うべきではないでしょう。

Consumption Plan との組み合わせ

誰もが疑問に思う Consumption Plan を使っている場合のリトライの待ち時間中に CPU 課金が行われるかどうかという点ですが、これも Jeff に確認したところ待ち時間中は課金されないらしいです。

ただし待ち時間を 10 分以上に設定した場合、Consumption Plan の場合はインスタンスが 0 まで落ちてしまう可能性があるらしいです。言われてみれば当たり前の挙動ではありますが、はまりやすそうなので特に Exponential Backoff を使う場合には設定に注意しましょう。

Durable Functions が適したケースも多い

リトライが組み込まれている Queue Trigger や Event Grid、そして Checkpoint によって続きからの実行が容易な Cosmos DB や Event Hubs 以外のトリガーでリトライが必要な場合は、多くの場合 Durable Functions を利用した方が信頼性が高まるはずです。

単なるリトライに留まらずに、Orchestrator と Activity の組み合わせで大量データの処理やワークフローも簡単に実装出来るので、Retry Policy を使うよりも圧倒的に便利でしょう。

そういった意味でも Azure Functions のエラーハンドリングとリトライのガイダンスはちゃんと確認しておいた方が良いです。その処理に適したアプローチを正しく見極めて選択する必要があります。

Retry Policy は正しいシチュエーションで使えば非常に便利で信頼性を高めてくれる機能ですが、これで全てのリトライ処理が解決するというような甘い話ではないことを理解しておきましょう。

複数の App Service / Azure Functions へのデプロイを GitHub Actions を使って並列に実行する

少し前に書いたエントリの続きに近い内容ですが、実際に GitHub Actions を使って複数の App Service / Azure Functions へのデプロイを並列実行するという話です。

ビルドに関しては並列実行しても効果が薄いケースが多いですが、デプロイは前回書いたようにネットワーク I/O やウォームアップの待ち時間が多く、依存関係がほぼないため並列実行に向いています。

ただし効率良く並列デプロイを行うには事前の準備が必要になってきます。

具体的にはビルド時にデプロイ用パッケージとなる zip ファイルを生成しておくことと、それぞれの Job では必要な Artifact のみをダウンロードするという点です。

発行時に zip ファイルを自動生成

ぶっちゃけ今回の肝となるのはこの dotnet publish のタイミングで zip ファイルを生成しておくことです。Azure Pipelines では zip にするオプションがありましたが、GitHub Actions や .NET Core CLI にはそんな機能はありません。

だからと言って手動で 7zip などを使って圧縮するのもアレなので、今回は MSBuild の ZipDirectory Task を使って行います。詳細は坂本さんが既に書いてくれているので、そっちを参照してください。

今回は複数のアプリケーションに対して行う必要があったので、それぞれの csproj に書くのではなく以下のような内容の Directory.Build.targets を用意して、一括で処理を追加するようにしました。

このファイルはソリューションと同じ位置に置いておけば良いです。

<Project>

  <PropertyGroup>
    <_MakeZipTarget Condition="$(_IsFunctionsSdkBuild) == 'true'">Publish</_MakeZipTarget>
    <_MakeZipTarget Condition="$(_IsFunctionsSdkBuild) != 'true'">AfterPublish</_MakeZipTarget>
  </PropertyGroup>

  <Target Name="MakeZipPackage" AfterTargets="$(_MakeZipTarget)">
    <ZipDirectory SourceDirectory="$(PublishDir)" DestinationFile="$(ProjectDir)$(AssemblyName).zip" Overwrite="true" />
  </Target>

</Project>

Azure Functions 向けだと AfterTargetsPublish にしないと正しい構造にならなかったため変更しています。そして Linux 上でビルドする場合には publishUrl を使うとディレクトリ名の case 問題に引っ掛かるので、同時に PublishDir を使うように変更しました。

この状態でローカル環境で dotnet publish を実行すると zip が同時に生成されます。

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

ASP.NET Core と Azure Functions のプロジェクトを用意していたので、それぞれの zip には以下のような構造でファイルが圧縮されています。

特に Azure Functions は SDK で後処理が行われるので、正しいタイミングで zip にしないと狂います。

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

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

これで 1 回の dotnet publish 実行で全てのアプリケーションに対して zip が作成されるようになりました。Azure Pipelines でもソリューション単位で行うことでビルド時間の短縮は図れると思います。

生成した zip ファイルをアップロード

アプリケーション毎に zip が作成できたので、次はその zip を Artifact としてアップロードします。

本来ならアップロードも step を分けずに 1 度に行いたいところですが、現在の Action では対応していないので、個別にアップロードする処理を書いています。

build:
  runs-on: ubuntu-latest
  steps:
  - uses: actions/checkout@v2

  - name: Use .NET Core ${{ env.DOTNET_VERSION }}
    uses: actions/setup-dotnet@v1
    with:
      dotnet-version: ${{ env.DOTNET_VERSION }}

  - name: Publish projects
    run: dotnet publish -c Release

  - name: Upload artifact (webapp)
    uses: actions/upload-artifact@v2
    with:
      name: webapp-test-1
      path: WebApplication1/*.zip

  - name: Upload artifact (function)
    uses: actions/upload-artifact@v2
    with:
      name: function-test-2
      path: FunctionApp1/*.zip

アップロード時に name で実際の App Service 名を指定するようにしました。App Service 名で Artifact を分けることで、後続のデプロイ処理では必要なパッケージだけを簡単にダウンロードできるわけです。

ここまでの Workflow を実際に実行してみると、以下のように 1 つずつ zip がアップロードされます。

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

GitHub の Artifact は自動で圧縮されるのではなくファイル毎にそのままアップロードされるようなので、予め zip に圧縮してまとめておくことでアップロードの時間が短縮されます。

App Service / Azure Functions へのデプロイも Run From Package で行うので zip は都合が良いです。

strategy / matrix を使ってデプロイを並列実行

Artifact としてアップロードしてしまえば、後は Workflow に strategymatrix を追加して App Service / Azure Functions 単位でデプロイ Job を実行するだけです。

デプロイ用に azure/webapps-deployazure/functions-action が用意されていますが、この Action を使うと Job を App Service 用と Azure Functions 用に分ける必要が出てくるので、今回は Azure CLI にある zip デプロイを行うコマンドを使いました。

deploy-all:
  runs-on: ubuntu-latest
  needs: build
  strategy:
    matrix:
      appName: [webapp-test-1, function-test-2]
  steps:
  - name: Download artifact
    uses: actions/download-artifact@v2
    with:
      name: ${{ matrix.appName }}

  - name: Login to Azure
    uses: azure/login@v1
    with:
      creds: ${{ secrets.AZURE_CREDENTIALS }}

  - name: Deploy to Dev
    run: az webapp deployment source config-zip -g ${{ env.resourceGroup }} -n ${{ matrix.appName }} --src $(find -name *.zip)

matrix には App Service 名を配列で指定しているので、App Service 単位で Job が並列実行されます。

その後は Artifact から必要なパッケージだけをダウンロードし、Azure CLI で zip をデプロイしているだけなので簡単です。zip ファイルのパスだけは指定しないといけないので、適当にコマンドを叩いて拾っています。

これで Workflow は完成したので実行してみると、Artifact が作成されてデプロイ用の Job が App Service 単位で実行されていることが確認できます。この例のように 2 つだと差は小さいですが、アプリケーションが増えるとデプロイにかかる時間が増えるので効果が大きくなります。

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

Workflow はシンプルに保ちつつ、全体としての実行時間を短縮できたのでかなり満足しています。

今回はサンプルを用意して試しましたが、既に実際のアプリケーション向けに組み込んでいて 3 アプリケーションで実行時間が 20% ほど削減できました。アプリケーションは増えるので更に差が広がるはずです。

並列実行するかどうかは別として dotnet publish を使って同時に zip ファイルを生成する方法を確立できたのが、個人的には大きな収穫でした。

Azure Functions のストレージを VNET 経由に制限する機能が Preview になったので試した

Azure Functions は Premium V2 / V3 や Premium Consumption を使うことで Regional VNET Integration を有効化しつつ、更に Service Endpoint や Private Endpoint で Azure リソースへの制限が行えますが、同時に作成されるストレージアカウントはパブリックにする必要があります。

そして以前に書きましたが、これまで Private Endpoint を使う場合には Subnet への Service Endpoint の追加や、完全にストレージアカウントを分ける必要がありました。ファイル共有のマウントは Web Worker 単位で行われるので、ある意味仕方ない挙動とも言えます。

このストレージアカウントにはファイル共有や Function Key などの動作に必要なファイルが含まれていて、全体を Private Endpoint で制限していてもここだけ開いているのは気になる人もいるでしょう。

この辺りの制限を取っ払う話が 8 月の Azure Functions Live で予告されていましたが、10 月の Live で Early Preview として公開されたことが発表されました。

要するにファイル共有のマウントも VNET 経由で行えるようになったという話のようです。Regional VNET Integration は Windows の場合はプロセス単位のはずなので、その辺りを拡張したのだと思われます。

ドキュメントが公開されていますが、Early Preview の名に恥じない制約の多さです。現在は West Europe の Windows Premium Consumption でのみ利用が可能になっています。

Private Endpoint がメインで扱われていたように思いますが、Service Endpoint で制限しているストレージアカウントに対しても同じように動作します。ファイル共有が VNET 経由になったというのが肝のはずです。

Premium V2 / V3 でも使えそうな気配はありますが、今回は Premium Consumption でのみ試しています。

とりあえず West Europe に Premium Consumption な Azure Functions と VNET をデプロイし、Regional VNET Integration の設定や Private Endpoint の追加などを行っていきました。

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

ストレージアカウントへの Private Endpoint は Blob と File に対して追加します。個別に追加しないといけないのは若干のめんどくささを感じますが、命名規約に気を付けて追加します。

設定後はこのままだと Web Worker がストレージアカウントへアクセス出来ないため、以下のように Azure Functions へのアクセスは全く行えない状態になります。

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

エラーメッセージは度々変わりますが気にしないでください。この状態では Kudu へのアクセスも行えないので、Azure Portal 上でも色々なエラーが出ます。

ここで App Settings に今回追加された WEBSITE_CONTENTOVERVNET というキーを追加します。名前の通り Azure Functions のコンテンツを VNET 経由で参照するための設定になっています。

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

Early Preview なので若干の不安定さはありますが、設定を追加して再起動を行うと Kudu へのアクセスが復活し、ファイル共有に使われているエンドポイントが Private IP になっていることが確認できます。

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

設定しても上手く動作しない場合には、別の Tier への Scale up を行って VM を切り替えると上手くいくことがありました。分かっていましたがまだまだ不安定です。

ここで適当に作成しておいた HttpTrigger を実行すると、Function Key での認証を行って実行できることが確認できます。Blob へのアクセスが行えない場合は、Function Key が取得できないので落ちます。

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

Regional VNET Integration と Private Endpoint をフルに利用したアーキテクチャの場合に、宙ぶらりんになっていた Azure Functions のストレージアカウントがやっと制限することが出来るようになりそうです。

これまでのようにストレージアカウントを分ける必要もないので、Private Endpoint を使いやすくもなったかなと思います。使う場面は少ないかもしれませんが、突っ込まれるポイントを潰せるのは良いことです。

GitHub Actions / Azure Pipelines 上での .NET Core アプリケーションのビルド時間を短縮する

最近は Azure DevOps で開発していたアプリケーションを GitHub に移行しつつ、ビルドとデプロイ周りを Azure Pipelines から GitHub Actions に切り替えていたのですが、アプリケーションのビルドに時間がかかっていたので短縮するために色々と作業をしました。

Azure Pipelines と GitHub Actions は Runner が同一のものが使われているので直接比較できます。先にどのくらい改善したかを簡単に出しておきます。まずは元々のビルドパイプラインです。

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

まだデプロイを全て移し切れていないので Pull Request 時の処理で比較しています。実際にコードを書いていて PR にコミットを積むたびに 5-6 分待たされるのは正直かなりストレスフルでした。

GitHub Actions に切り替えた後は以下のように 2 分ちょいで完了するようになりました。

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

この時に 10 近くのプロジェクトをビルドしていますが、このくらいなら十分許容範囲です。

これまでにもビルド時間の短縮のために色々と試してきたので、その時の経験をまとめて共有しておきます。当たり前ですが全てのケースに当てはまるとは限らないので、必ずそれぞれ個別に効果を確認してから導入してください。*1

無駄なパッケージの復元・ビルドを避ける

Pull Request でテストを実行する場合は大体その前にビルドを行って、そもそもビルドが通るかどうかのチェックを行うことが多いと思います。

コマンドとしては dotnet builddotnet test を使うだけなのでシンプルではありますが、これらのコマンドは暗黙的にパッケージの復元とビルドを行います。明らかに不要な場合には --no-restore / --no-build を指定して処理をスキップさせると実行時間の短縮になります。

最近は .NET Core 向けの場合には deterministic オプションがデフォルトで有効になっているはずなので、一応はビルドした後にはキャッシュが効くようになっていますが、不要な場合は明示的にオプションを指定した方が関連する処理が行われないので高速です。

今回は dotnet test を例に上げましたが、他にも dotnet publishdotnet pack などのコマンドでも同じことがいえるので、多くのプロジェクトを対象にする場合には注意しましょう。

可能な場合はソリューション単位で処理を行う

GitHub hosted な Runner は Azure 上の Standard_DS2_v2 インスタンスで実行されるので、2 コアまで CPU を利用できます。ちなみに Hyper-Threading ではない物理コアが割り当てられるインスタンスです。

つまり 2 並列までは効率よく実行できるということです。実際に .NET Core のビルドに使われている MSBuild では、デフォルトでマシンの CPU の数だけ並列で処理を行おうとします。

ビルドに関してはプロジェクト単位での並列実行になるので、プロジェクトが多い場合にはソリューションを指定することでビルド時間を短縮できます。ただしソリューションに .NET Core に対応していないプロジェクト*2が含まれていると失敗するので、事前の準備が必要になります。

Azure Pipelines だとワイルドカードでビルド対象のプロジェクトを指定出来るので、意図しないまま並列実行が無効化されているケースも多そうです。

キャッシュの利用は慎重に

パッケージの復元に時間がかかる場合には、ダウンロードしたパッケージをキャッシュして高速化したいと考えることが多いです。特に npm に関してはキャッシュは良く行われている印象で、ドキュメントでも例として npm が挙がっています。

確かに効果的な場合もあるのですが逆に遅くなることも良くあります。キャッシュの保存と復元のコストと、パッケージを都度ダウンロードするコストを比較してから導入しましょう。

経験上、NuGet に関してはキャッシュをするより都度ダウンロードした方が早いことが多いです。

ただしキャッシュのメリットとしては NuGet などが落ちていてもビルドが継続できる可能性があるという点があります。最近は静的ファイルと CDN が使われているので、大規模な障害はあまりお目にかからないですが、頭の片隅にでも入れておいて損はありません。

並列実行が常に早いとは限らない

これもキャッシュに近い話になるのですが、GitHub Actions や Azure Pipelines での並列実行は複数の VM を使って行われるため、スピンアップの時間や Artifacts を使っている場合はダウンロードと展開などのオーバーヘッドを意識する必要があります。

それぞれの OS やランタイムバージョン毎に実行させたい場合には間違いなく並列実行が有利ですが、複数のプロジェクトのビルドに感がかかるからと言って並列にしてもオーバーヘッドの方が大きくなるでしょう。

大した処理ではない割に時間がかかるアプリケーションのデプロイに関しては、アプリケーションの数次第ではありますが並列実行にした方が全体的な処理時間を短縮できます。実際に App Service / Azure Functions のデプロイではウォームアップも行うため、待ち時間が長くなりがちなので効果が出やすいです。

当然ながら Artifacts のアップロードとダウンロードがオーバーヘッドとして乗ってくるので、ちゃんと試してから組み込みましょう。パフォーマンス改善では常に計測することが重要になります。

可能な場合は Linux の Runner を利用する

正直なところ、何じゃそれと言われそうですが Windows の Runner を使うよりも Linux の Runner の方が早いです。インスタンスのスペックは変わらないですが、主に I/O 周りで差が付いているように思います。

.NET Core で Framework Dependent なアプリケーションをビルドする場合には Windows と Linux のどちらでも問題がないため、最近は Windows の App Service / Azure Functions で動かすアプリケーションであっても ubuntu-latest を使っています。時間単価も安いのでお得です。

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

Azure Pipelines では時間ではなく並列実行数での制限なのであまり意識しませんでしたが、GitHub Actions は使用した時間での制限となり、時間単価が Windows は Linux の 2 倍に設定されています。

ASP.NET 4.8 や Visual Studio が必要な Desktop Bridge 向けのビルドは流石に行えませんが、それ以外は Linux の Runner を使った方がビルド時間の短縮も行えつつ、節約も行えるのでメリットが大きいです。

ただし ReadyToRun を使っている場合には注意が必要です。現在クロスターゲットがサポートされていないため、Linux でビルドした ReadyToRun 済みアセンブリを Windows で動かすことは出来ません。Windows の Runner のままにするか ReadyToRun をオフにするかの判断が必要です。

*1:パフォーマンス改善では考えられること全て一度に入れてしまい、何が効いたか分からなくなるケースが多い

*2:SQL Project や Desktop Bridge Project など

Synapse Link for Cosmos DB が Synapse SQL Serverless から使えるようになったので試した

期待していた Synapse Link for Cosmos DB ですが、これまで Spark でしか扱えなかったのでスルーしていましたが、やっと SQL on-demand や Synapse SQL Serverless と呼ばれるサーバーレスモデルから扱えるようになったので気になった部分を試しました。リージョンが限定されてるので注意。

Ignite 2020 の時に再度発表されて、その勢いで Synapse Analytcis Workspace を作ったのに対応していなかったという悲しみがありました。最後の方にこっそり数週間後みたいに書くのは止めてほしいと思います。

今回も例によって気になった部分だけなので、基本は Cosmos DB の Analytical Store を Synapse Link + Synapse SQL Serverless の組み合わせだけを試しています。

Synapse SQL Serverless はインスタンスの管理が必要なく、課金体系も処理した分だけという分かりやすいものになります。SQL や Spark のプールを用意する方法にもパフォーマンスやスケール面でのメリットはありますが、正直プールの管理とかはしたくないです。

Synapse Analytics Workspace と Synapse Link については世界のムッシュが既にまとめてくれているので、こっちを読んでおくと良いと思います。

Analytical Store を有効にした Cosmos DB を用意する

Synapse Link を使うためには、Synapse Link を有効化した Cosmos DB と Analytical Store を有効化した Container が必要になるので、ポチポチと Azure Portal から作成しました。

この辺りはドキュメントが比較的充実しているので、はまるポイントはないでしょう。

Container の作成よりも中に入れるデータの方が大変なので、適当に Data.gov をうろついて面白そうなデータを探してきました。今回は以下の SFO への航空機着陸データを Cosmos DB に投入してみました。

ドキュメントでは Covid-19 絡みのデータセットですが、飛行機が好きなのでこっちを使います。

何故か Azure Portal 上は Analytical Store を有効化したかの判別方法は、Analytical Storage TTL の設定が出てくるかぐらいしかない気がします。

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

データは適当に Cosmos DB SDK を使ってバルクで投入しました。Data Factory を使っても良かったのですが、リソースを作る方が面倒だったので C# で解決しています。

適当に 1 つを開いてみると、以下のような形式になっています。月毎に集計されたデータです。

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

面白そうなプロパティがいくつもあるので、これらに対して Synapse Link を使ってクエリを実行します。

Synapse Analytics Workspace に Cosmos DB をデータソースとして追加することは出来ますが、今のところ追加したデータソースからサクッとクエリを各機能が提供されていないので、ADLS に保存された CSV / JSON を読み取るときのように OPENROWSET を直接書いてクエリを実行します。

アクセスキーの設定はなかなかに怠いので、RBAC + Managed Identity を使いたい気持ちになります。

SELECT TOP 10 *
FROM OPENROWSET( 
       'CosmosDB',
       'account=shibayantest;database=AnalyticalTest;region=westus2;key=ACCESS_KEY',
       AirTraffic) AS documents

このクエリで Analytical Store に自動同期されたデータを対象に出来ます。とりあえずサンプル通り 10 件だけ取ってくるシンプルなクエリなので、Cosmos DB の SQL とあまり差を感じません。

実行すると Cosmos DB に保存されたデータからスキーマが自動的に認識されてデータが返ってきます。Query Acceleration もスキーマは自動認識だったので楽ですね。

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

Cosmos DB が内部で利用している _ts_etag も含まれるのが少し邪魔だなとは思います。そして接続先は SQL on-demand になっているので、Pool を用意することなくクエリが実行できています。

次はもうちょっと複雑なクエリを書いてみました。2020 年を対象に航空機のメーカー毎に着陸した回数を集計するものですが、Cosmos DB の SQL では集計関数が不安定なので確実に書けないです。

SELECT
    AircraftManufacturer,
    SUM(LandingCount) AS TotalLandingCount
FROM OPENROWSET( 
       'CosmosDB',
       'account=shibayantest;database=AnalyticalTest;region=westus2;key=ACCESS_KEY',
       AirTraffic) AS documents
WHERE
    ActivityPeriod LIKE '2020%'
GROUP BY
    AircraftManufacturer
ORDER BY
    SUM(LandingCount) DESC

Synapse Link を使うと普通の T-SQL と同じ感覚で書けるので、集計が圧倒的に楽ですね。

このクエリも実行して、今度はチャートとしてレンダリングしてみました。Power BI を使わずともビジュアライズが簡単に出来るのは便利です。

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

同じように今度は国際線の月毎の合計着陸回数を 10 年間分集計してみます。元データセットには国内線と国際線が混ざっているので、フィルタリングしつつ月毎に集計をすれば良いです。

SELECT
    ActivityPeriod,
    SUM(LandingCount) AS TotalLandingCount
FROM OPENROWSET( 
       'CosmosDB',
       'account=shibayantest;database=AnalyticalTest;region=westus2;key=ACCESS_KEY',
       AirTraffic) as documents
WHERE
    ActivityPeriod > '201006' AND GeoSummary = 'International'
GROUP BY
    ActivityPeriod
ORDER BY
    ActivityPeriod

2020 年 6 月のデータまでが含まれているので、Covid-19 の影響を受けていることが可視化できました。

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

ここまで色々なクエリを投げて試してみましたが、Monitor からは Analytical Store へのリクエスト数やデータサイズなどを確認することは出来ませんでした。

課金に影響する部分なので、何らかの確認方法が欲しいですが Preview 中の制約っぽいです。

View を作成してからクエリを実行

正直なところ毎回 OPENROWSET を使うのは面倒だと思っていましたが、Power BI から利用する方法が書いてあるドキュメントには View を作成して使う方法が紹介されていたので、これを使ってみました。

View の作成のためにはまず Database から作成しないといけないので、以下のようなクエリを流して Database と同時に Synapse Link を使う View を作成しました。

CREATE DATABASE AirTrafficCosmosDB
GO

USE AirTrafficCosmosDB
GO

CREATEVIEW AirTraffic
ASSELECT *
FROM OPENROWSET( 
       'CosmosDB',
       'account=shibayantest;database=AnalyticalTest;region=westus2;key=ACCESS_KEY',
       AirTraffic) AS documents
GO

一度 View を作成して Database を master から作成したものに切り替えれば、後は簡単な FROM 指定でクエリを書けるようになります。接続文字列的なものがクエリに出てこないので分かりやすくなりました。

SELECT
    ActivityPeriod,
    GeoRegion,
    SUM(LandingCount) AS TotalLandingCount
FROM
    AirTraffic
GROUP BY
    ActivityPeriod,
    GeoRegion
ORDER BY
    ActivityPeriod DESC

当然ながら実行結果は OPENROWSET を直接使ったものと同じです。当たり前すぎて面白みはありません。

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

View では不要なカラムの除外も出来るので、基本は作成しておくのが良さそうだと感じました。

日本語が文字化けするのを直す

これまで使ってきたデータセットはアルファベットと数字しか出てこないため問題になりませんでしたが、Cosmos DB は UTF-8 な JSON を扱うストレージなので、当然ながら日本語などの非 ASCII 文字がデータに含まれている可能性があります。

試しに Hack Azure で使った適当なデータセットを使ってみると、日本語は見事に化けました。

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

これだから ASCII 圏はという感じがしますが、ドキュメントにも記載のあるようにデフォルトの照合順序が UTF-8 向けになっていないのが原因らしいので、指示に従って LATIN1_GENERAL_100_CI_AS_SC_UTF8 に変更しておきます。

If you see unexpected characters in your text like Mélade instead of Mélade then your database collation is not set to UTF8 collation. Change collation of the database to some UTF8 collation using some SQL statement like ALTER DATABASE MyLdw COLLATE LATIN1_GENERAL_100_CI_AS_SC_UTF8.

Query Azure Cosmos DB data using SQL serverless in Azure Synapse Link (preview) - Azure Synapse Analytics | Microsoft Docs

ちなみに master に対しては実行できないので Database を作成してから実行する必要があります。以下のようなクエリを流して Database の照合順序を UTF-8 向けに変更しました。

CREATE DATABASE TodoItemCosmosDB
GO

USE TodoItemCosmosDB
GO

ALTER DATABASE TodoItemCosmosDB COLLATE LATIN1_GENERAL_100_CI_AS_SC_UTF8
GO

これで再度クエリを実行すると、日本語が化けることなく返ってくるようになりました。

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

もっと日本語に適した照合順序はあると思いますが、基本は UTF-8 向けのものを選べば問題なさそうです。この辺りはきっとムッシュが書いてくれると思うので期待しています。

追記

安心と信頼のムッシュが書いてくれました。日本語を扱う前にはまずこちらを確認しましょう。

SQL の照合順序周りは難しいなと感じる日々です。全て UTF-8 にすると楽できる世界になって欲しい。

ネストされたデータの扱いは少しめんどくさい

非リレーショナルで JSON を採用した Cosmos DB では、1 つのドキュメントにオブジェクトがネストされていることが多々あります。というかデータモデリングの時に大体はネストする構造を選ぶはずです。

そのようなデータに対してクエリを投げると、ネストされた部分は JSON のまま返ってきました。これは Synapse Link だからという訳ではなく、JSON を読み込んだ時にも普通に発生するようです。

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

対応方法としては OPENROWSET 時に WITH を使ってスキーマと該当するプロパティのパスを指定したり、JSON_VALUE などの関数でパスを展開したりと色々な方法があるようです。

1,2 個のプロパティだけ必要な場合は JSON_VALUE、もっと多くのプロパティが必要なら WITH でスキーマ定義、ネストされた配列を使う場合は OPENJSONCROSS APPLY というような使い分けになりそうです。

今回は 1 つのプロパティだけが欲しかったので JSON_VALUE でサクッと終わらせました。

SELECT TOP 10
    Title,
    Body,
    JSON_VALUE([User], '$.id') AS UserId
FROM OPENROWSET( 
       'CosmosDB',
       'account=shibayantest;database=AnalyticalTest;region=westus2;key=ACCESS_KEY',
       TodoItem) AS documents

実行すると JSON から指定したパスのプロパティだけが返ってくることが確認できます。

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

JSON なストレージから RDB へのマッピングが現実的にしんどいのは容易に想像がつくので、Synapse Link を使うことが分かっている場合にはデータモデリング時に考慮した方が良さそうです。

価格について

最後に気になる価格ですが、Cosmos DB の Analytical Store に関してはデータ容量と読み書きの 1 万操作ごとの金額が記載されていますが、現在はモニタリング方法が無いので金額を推定することすら困難です。

Synapse Analytics の SQL Serverless も現在は ADLS 向けの記載になっているので、Synapse Link での扱いは分からないままです。恐らくは処理した分だけの課金でしょうが単価は変わるかもしれません。

ADLS を使うよりは Synapse Link 経由で Cosmos DB を使う方が高くなるのは間違いないでしょうが、もうちょっと金額に関しては待つ必要がありますね。かなり使い勝手は良かったので期待しています。

Azure App Service の新しい Premium V3 インスタンスが使えるようになった

Ignite 2020 で発表された Premium V3 が最近になってようやく試せるようになったので、気になっていた点を実際にデプロイして一通り試したので残します。

9 月末から限られたリージョンでは Azure CLI から試せるようになっていましたが、利用可能なインスタンス数の制限や Azure Portal でのサポートが遅れたため結局は今日まで待つ必要がありました。

まだ多少の表示上の問題*1はあるようですが、デプロイ自体は問題なく行えます。

以下のように App Service の作成時に Premium V2 と Premium V3 から選べるようになっているはずです。そろそろ Windows の Recommended pricing tiers から S1 を外して欲しい気持ちが凄くあります。

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

Pricing ページがまだ更新されていないので、ひとまず Azure Portal から金額を確認しておきます。Ignite 2020 で公開された時間単価を使って確認しましたが、この金額は正しそうです。

10/8 追記

Pricing ページが更新されて Premium V3 のリージョン毎価格が確認出来るようになりました。

Azure Portal で確認できる金額と同じですが。計算ツールを使うことで Dev / Test 向け Pricing の確認も行えるようになっていました。

先に言っておくと Premium V3 は良い意味で普通だなと思いました。Premium V2 からの順当なアップグレードなので当たり前ではありますが、コストパフォーマンスは良くなっていてインスタンス世代も最新です。

App Service としてのインフラも最新の VMSS ベースになっているので、新機能の追加も期待できます。*2

Premium V3 の特徴

後半の裏側を弄った結果が長すぎるので、先に Premium V3 と Web App for Containers (Windows) の特徴をまとめておきました。ここを読んでおけば Premium V3 を理解した状態になるはずです。

基本的なこと

  • 全ての App Service で Premium V3 を利用可能
    • Windows / Linux / Containers (Linux, Windows) / Functions (ASP) の全て
  • Premium V3 の稼働する Stamp は VMSS ベースになった
    • Windows / Linux / Containers (Linux, Windows) の全て
    • Stamp 単位の対応なのでスケールダウンしても VMSS のまま
    • Additional Outbound IP Addresses の数がかなり多いのでスケールダウンには注意
  • 利用可能なリージョンは限定的
    • Japan East は対応、Japan West は Dv4 がそもそも使えないので非対応
    • 一覧は Azure CLI で az appservice list-locations --sku P1V3 を実行すると取得可能

金額について

  • Linux の Premium V3 はかなり安い (Windows 比 50% ぐらい)
    • 2 コア以上を使うなら Premium V3 を選ぶべき
    • 長期で使うことが確定しているなら Reserved Instance の利用も検討したい
  • Windows の Premium V3 もそこそこ安い (同コア数比 20% ぐらい)
    • 2 コア以上を使うなら Premium V3 を選ぶべき
    • 長期で使うことが確定しているなら Reserved Instance の利用も検討したい
  • Windows Containers は普通の Windows よりも 10% ほど高い
    • PC2-4 の頃を考えるとお得になったとは思う
    • 使い捨て用途ではないと思うので Reserved Instance を使いたい
  • Visual Studio Subscriber 向けの価格が凄い
    • P1V3 が S2 より安くなっている衝撃
    • 予告通りとにかく安くなっているので最高

デプロイ・アップグレード

  • 古い App Service Plan をデプロイ済みリソースグループにはデプロイ出来ない
    • 新しいリソースグループを作成してデプロイする必要がある*3
    • Retrofit は基本的に行われないと思っておいて良さそう
  • 既存の App Service Plan は Premium V3 へのアップグレードは出来ない
    • 新しい Stamp が必要なので新しいリソースグループへのデプロイが必要
    • 本当に極めて運のよいケース*4に限りアップグレードが可能
  • Windows と Linux の App Service Plan の混在は出来ないまま
    • ただし Windows と Windows Containers の混在は出来る(前から?)

プラットフォーム

  • App Service (Windows) は Windows Server 2016
    • 現行インスタンスから変更なし
    • インストールされているパッケージ類も全く同じ
  • Web App for Containers (Windows) は Windows Server, version 2004
  • Web App for Containers (Windows) から Private Endpoint が使える
    • つまり WEBSITE_VNET_ROUTE_ALL が動作するということ

Premium V2 の時にも発生しましたが、既存の App Service Plan のアップグレードや、同一リソースグループへの新規デプロイはほぼエラーになります。例外的に VMSS ベースの Stamp を掴んだ時だけ可能ですが、この 1,2 か月以内にデプロイしていて凄く運が良ければというレベルだと思います。

VMSS ベースの App Service について興味がある極々一部の方は、以前に書いたエントリを参照ください。

Windows / Linux / Containers 全てが VMSS ベースになったのと、今後リリースされる ASE v3 も VMSS ベースになることが確定しているので、何年越しか分かりませんが Cloud Services とはおさらばです。

さようなら Cloud Services、さようなら何故か D:\ 以下にいた Windows。

Premium V3 の裏側を調べた話

ここから先は普通に App Service で Premium V3 を使う場合には必要ない情報ばかりなので、裏側に興味がある人以外は読み飛ばしてよい内容です。

App Service (Windows)

流石に App Service (Windows) 側の OS バージョンは WIndows Server 2016 から上がることはありませんでした。既存のインスタンスが 2019 にアップデートされない限り、同じバージョンのままになるでしょう。

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

プラットフォームとしては VMSS 由来のドライブレター以外は全く変わっていないので、アプリケーションの互換性について気にする必要はないです。

CPU はドキュメント通り Xeon Platinum 8272CL が使われていました。ベースクロックが Dv2 でよく使われている Xeon E5-2673 v3 より少し高いですが、Hyper Threading が有効になった仮想コアなので、ACU は Dv2 より若干低めになっているのは前回書いた通りです。

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

全コア 3.4 GHz までブーストするらしいので、単純なシングルスレッド性能は Dv2 より高そうです。

Turbo Boost が有効になっているからか、App Service Plan を選んだ時に表示される ACU は minimum 付きです。本当にブーストするのかを確認する手段が App Service だとありませんが、ここは信用しましょう。

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

もちろん Azure Functions も Premium V3 な App Service Plan にデプロイ出来ます。これまで通りですね。

プレビュー中にも行えたのかは確認していませんが、App Service (Windows) と Web App for Containers (Windows) は同じリソースグループ内にデプロイすることが出来ました。

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

Windows と Linux を同じリソースグループにデプロイ出来ないのはこれまで通りです。

App Service (Linux), Web App for Containers (Linux)

Docker ベースの App Service (Linux) では Web Worker の調査をするのが非常に難易度が高いというかほぼ無理ですが、マシン名や DNS から引いた名前から VMSS ベースになっていることは確認できます。

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

IMDS v2 を使って情報を取れないか試しましたが、当然ながら塞がれていたので分かりませんでした。

去年に Linux に関しては Premium V2 の大幅な値下げが行われていますが、Premium V3 はそれよりもさらに値下げされているのでかなりコストパフォーマンスが良くなっています。かなり驚いています。

Premium V2 よりもメモリが増えているので、これまでよりも Container 向けではあると思います。32GB メモリまで選べるという余裕は重要だと思いました。

Web App for Containers (Windows)

2 年間のプレビューを経て GA となった Web App for Containers (Windows) ですが、プレビュー中を含め OS バージョンが 2 回上がっています。最終的には LTSC ではない SAC な 2004 が使われるようになりました。

Windows Containers はそれなりにリソースを食うので、最新世代 VM が使われている Premium V3 の中でも 8 コア 32GB メモリの P3V3 が、本番環境向けでの現実的な選択肢になると思います。

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

2004 の Docker Image はプレビュー開始当時の 2016 に比べるとかなりコンパクトになっているので、ようやく Windows Containers に周りの環境が追いついたという感じすらあります。

Docker Image サイズの縮小と .NET Framework での対応については結構面白い話が多いので、参考までにリンクを共有しておきます。NGEN 周りで本当に苦労していたようです。

最近は Docker すら全く使っていなかったので、Windows Containers を触るのは久しぶりでしたが、WinRM を使ったリモート接続が Cloud Shell から行えるようになっていたのが便利でした。

クライアント側で WinRM の起動やパスワードなどを扱うことなく、Cloud Shell からであればリソースグループ名と Web App 名を指定するだけで接続できます。

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

Private Endpoint へのアクセスが行えるのかが気になっていたので、適当に Private Endpoint を有効にした Storage Account を作成して WinRM 経由で通るか確認したところ、問題なく接続できました。

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

これで機能としては Windows Containers は Linux Containers とほぼ同等になったと考えて良さそうです。

*1:Premium V3 に対応していない App Service Plan でもスケールアップが可能なように見える

*2:Regional VNET Integration は新しい Stamp にしか対応していなかった

*3:これは Premium V2 や Regional VNET Integration の時にも発生した

*4:VMSS ベースの Stamp にデプロイされていたケース

Azure Functions で ReadyToRun を有効化してコールドスタートの高速化を図る

以前に Azure Functions のアップデートで ReadyToRun が使えるようになったと書きましたが、64bit OS 上では win-x64 向けの ReadyToRun コンパイルしか行えなかった問題が直ったので真面目に計測しました。

ReadyToRun の説明は既に何回か書いたので省略しますが、単純に言うと AOT コンパイルです。

Tiered Compilation や ReadyToRun の効果があるかどうかを Azure Functions で計測するのは簡単ではないのですが、コールドスタートにかかる時間を短縮する方法としては有効だろうと思っていたので試しています。

単純な Function では差が付きにくいことは分かっていたので、依存関係が比較的多くなる Durable Functions と DI の組み合わせで試します。2020 年の Function 開発では DI は当たり前に使われるようになっています。

ReadyToRun を有効化する

最近行われた修正によって win-x86 向けでの ReadyToRun のコンパイルでエラーが発生しなくなっていますが、Function SDK 自体はまだアップデートされていないので、更新された Extensions Metadata Generator を直接プロジェクトに追加して対応します。

これでコンパイルが出来るようになります。SDK がアップデートされたら消してよいです。

公式ドキュメントでは直接 csproj に ReadyToRun の設定を追加していましたが、デプロイ時のみ実行すればよいので Visual Studio からデプロイする場合には pubxml に追加すれば実現できます。

<?xml version="1.0" encoding="utf-8"?>
<Project ToolsVersion="4.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
  <PropertyGroup>
    <WebPublishMethod>ZipDeploy</WebPublishMethod>
    <PublishProvider>AzureWebSite</PublishProvider>
    <!-- 省略 -->
    <RuntimeIdentifier>win-x86</RuntimeIdentifier>
    <PublishReadyToRun>true</PublishReadyToRun>
  </PropertyGroup>
</Project>

GitHub Actions や Azure Pipelines からデプロイする場合は .NET Core CLI にパラメータを追加します。

dotnet publish -c Release -r win-x86 -p:PublishReadyToRun=true

.NET Core CLI の -p オプションは MSBuild のプロパティの指定なので覚えておいて損はないです。

今回は Visual Studio を使って Zip Deploy + Run From Package を行っているので、ReadyToRun の設定は pubxml に追加しました。Function App 自体を 2 つ作成して、それぞれに ReadyToRun を有効・無効にしたものをデプロイすることで比較します。

計測に使用した環境

計測に使用した環境は以下の通りになります。ReadyToRun 設定の有無以外は全く同じです。

  • Azure Functions Runtime: v3.0.14492
  • リージョン: East US 2
  • インスタンスの種類: Consumption
  • デプロイ方法: Run From Package
  • OS: Windows
  • プラットフォーム: 32bit (win-x86)
  • テストに使った Function: Durable Functions v2.3.0 と DI
  • その他: 同一リソースグループ、同一 Webspace

今回 win-x86 を選んでいる理由は Consumption は 1 コア 1.5GB メモリにリソースが制限されるため、64bit を使うメリットが基本的に存在しないからです。当然ながら 32bit の方がメモリ使用量も少なくなります。

計測結果

Application Insights の Webtest 機能を使って、一定間隔でそれぞれの Function の HttpTrigger に対してリクエストを投げて、HTTP のレスポンス時間と Function Host の起動時間を調べています。

ウォームスタートにならないように大きく時間を空けて、1 リージョンからのみ送信します。

HTTP レスポンス時間

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

Function Host 起動時間

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

グラフを見ると分かるように、ReadyToRun の有無ではっきりと差が出ました。多少の外れ値はありますが、Consumption は共有型のホスティングかつファイルシステムを Azure Files を SMB でマウントしているので、その辺りの影響をダイレクトに受けるはずです。

これだけだとたまたま早いインスタンスに当たっただけという可能性もあるので、ReadyToRun の設定を逆転させてデプロイした結果が以下になります。

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

切り替えを行った後には値も逆転しているので、ReadyToRun が影響していることは明らかです。

Azure Functions はコールドスタートへの最適化が継続的に行われているので、1,2 年前と比べると何もしなくてもかなり早くなっていますが、ReadyToRun はそこから更に一押しする効果はあるようです。

物事には当然ながらメリットとデメリットが存在するので、最後にその辺りを軽くまとめます。

メリット

  • 良いケースでは 20% 近くコールドスタートにかかる時間を削減できる
    • Function Host だけを見ると 35% 近く削減できている
  • 規模の大きな Function App ではさらに効果が期待できる
    • 逆に依存関係が少なく、アセンブリサイズが小さい Function App では効果は薄そう

デメリット

  • AOT コンパイルを行うためにビルド時間が若干が無くなる
  • AOT コンパイル後のアセンブリサイズがかなり大きくなる
    • アセンブリに IL とネイティブコードの両方を含むため
  • Function App のプラットフォーム (32bit / 64bit) を変更するとリビルドが必要

検証のまとめ

ReadyToRun を有効にするデメリットは確かに存在していますが、大体は AOT コンパイルに関連する内容なので、Azure Functions がスケールアウト時に毎回 JIT で行っていたことを削減していると考えると、AOT で行うのは効率的だと言えます。

一番のデメリットはアセンブリサイズがかなり大きくなることだと思います。依存するライブラリにもよりますが、圧縮前で平均して 2 倍ぐらいになるようです。.NET Native や CoreRT とは仕組みが異なるので仕方ないですが、URL を設定する Run From Package の場合は問題になりやすいでしょう。

個人的には Consumption と Pre-Warming を無効化した Premium Plan の時に有効化すると思います。Pre-Warming が有効の場合はコールドスタートが原理上発生しないので、有効化しても意味がないです。

App Service から Azure Monitor Private Link Scope 経由で Application Insights を利用する

先日 Workspace ベースの Application Insights を弄っていた時に Network Isolation という設定が目に入り、そういえば Private Link Scope ってよく分かっていないと思ったのでデプロイして試しました。

単純に Azure Monitor へのアクセスを Private Endpoint 経由するためのサービスですが、Azure Monitor は他のサービスと異なり色々なもの組み合わせで作られているので、この Private Link Scope で境界を定義する必要があるようです。微妙に分かりにくい感あります。

ドキュメントでは使うためにサインアップが必要とありましたが、手持ちのサブスクリプションでは何もせずにデプロイが出来ました。片倉さん曰く GA しているらしいので、全てで使えるようになっていそうです。

一応は今回紹介する機能は Workspace ベースの Application Insights 限定のようです。*1

Private Link Scope と Private Endpoint のデプロイ

Private Link Scope のデプロイについては片倉さんのブログに丸投げします。ターゲットは Linux の VM ですが、構築の手順が順を追って分かりやすく書かれています。

Private Link Scope のデプロイ後、Private Endpoint のデプロイも必要になるのを少し忘れそうでした。

実際に Private Link Scope に対して Private Endpoint を追加すると沢山の Private DNS Zone がデプロイされます。ドメイン名の一貫性のなさに闇を感じつつ、Private Link Scope が必要な理由を垣間見られます。

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

この VNET には Cosmos DB と Key Vault の Private Endpoint をデプロイ済みなので更に増えています。

後は Private Link Scope に対して Private Endpoint 経由でアクセスしたい Application Insights や Log Analytics Workspace を追加します。ちなみに 1 つの Application Insights / Log Analytics Workspace に対して、複数の Private Link Scope を紐づけることも出来るようです。

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

これで Private Link Scope の設定は完了しましたが、最後に Application Insights の Network Isolation から Public アクセスを有効にするかどうかを設定します。この設定をしないとロックダウン出来ません。

今回は Ingest だけ Public アクセスをオフにしました。Query に対してオフにすると Azure Portal からの確認するために VNET 経由で行う必要があり、流石に面倒だったからです。

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

Application Insights に紐づけている Log Analytics Workspace に対しても、同じように Public アクセスをオフにしておきます。何故か Azure Portal には Network Isolation の設定が見当たりませんでしたが、ARM Explorer を使うことで変更できました。

ここまでの作業で Application Insights へのテレメトリ送信は Private Endpoint 経由でしか出来なくなっているはずです。動作確認は App Service や Azure Functions からいつも通りテレメトリを送信すれば良いです。

App Service / Azure Functions からテレメトリを送信

当然ながら Private Endpoint 経由で送信するためには、App Service では Regional VNET Integration の追加かつ WEBSITE_VNET_ROUTE_ALLWEBSITE_DNS_SERVER の設定が必要になります。

この辺りは何回も書いているので以前のエントリを参照してください。再起動が必要な時もあります。

無事に Private Endpoint を使う準備が出来ていれば、Kudu の Debug Console などを使って Application Insights の IngestionEndpoint への IP アドレスを引いてみると、Network Interface に割り当てられた IP アドレスが返ってくるはずです。

nslookup を使うと 2 回タイムアウトしましたが、App Service 向けに用意されている nameresolver を使うと綺麗に IP アドレスを引けます。

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

この状態で Azure Portal から Live Metrics を確認しつつ、適当なページへのアクセスや Function を実行してテレメトリを送信すると、これまで通りリクエスト数などの情報を確認できます。

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

確認できたので App Service / Azure Functions 側で WEBSITE_VNET_ROUTE_ALLWEBSITE_DNS_SERVER の設定を落として再度試すと、今度は全くテレメトリが流れてこないことが確認できます。

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

これで Application Insights へのテレメトリを Private Endpoint 経由で、これまで以上に安全に送信できるようになりました。Query の Public アクセスについては本番でら閉じましょう。

今回は Azure Functions だったので試していませんが、Profiler や Snapshop Debugger を使う場合には、以下のように独自のストレージアカウントを持ち込んで設定する必要があるようです。

正直なところあまり使う機会は無いかもしれませんが、Private Link Scope が必要なケースではこの辺りもしっかり押さえておく必要があるでしょう。

そもそもログにセンシティブなデータを書き出すなという話ですが、メモリダンプには何が含まれるのか分からないのでかなり注意深く扱う必要があります。*2

*1:Classic でも使えそうな気はしたけど試してません

*2:本番で Snapshot Debugger を使うかどうかはまた別の話

既存の Application Insights を Workspace ベースに移行した

これまでのリソースが Classic 扱いになって今後が気になる Application Insights ですが、実際に Log Analytics Workspace ベースに移行してみないとよく分からないと思ったので、手持ちの Azure Functions と組み合わせて使っていたリソースをいくつか移行してみました。

Ignite 2020 合わせで Workspace ベースが GA していたことにブチザッキで気が付きました。

Log Analytics Workspace ベースに移行してメリットがあるのかと言われると、正直なところ劇的なものは無さそうでした。移行ドキュメントに新機能としてまとめられているので、目を通しておくと良いです。

新機能として挙げられている Private Link や CMK / BYOS などは完全にエンタープライズ向けの機能といった感じです。確かに Snapshot Debugger で収集されたデータはメモリダンプが含まれているので、セキュアに扱いたいものですが本番ではまあ使わないです。

Azure Functions との組み合わせでカジュアルに使っている場合には、何も変わらない代わりにメリットもあまり無いように見えます。一応データインジェストが高速化されるとありますが、数値として出ていないため改善されたかどうかの把握が難しいです。

とは言え最近は RBAC を使ったアクセスコントロールが必要な場面があったり、App Service / Front Door などのサービスが Log Analytics へアクセスログなどを転送できるようにもなっているので、Application Insights のデータも同じ Log Analytics に送信できると確かに便利そうです。

1 日に100GB 以上といった大量のデータを扱う場合には 20% 以上の割引も効いてくるので、お得なケースもあるでしょう。Consumption でサクサク作っていた場合には、やはりマッチしないなという印象はあります。

とりあえず実際に Workspace ベースに移行してみます。当然ながら Log Analytics Workspace を作成しておく必要がありますが、移行作業自体は非常にシンプルです。Azure Portal を使う場合は Properties から "Migrate to Workspace-based" を選ぶだけです。

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

移行を選択すると送信先の Log Analytics を選ぶ画面と元に戻せないという警告が表示されますが、分かっていることなので作成した Log Analytics を選んで保存します。

ちなみに Workspace ベースに移行すると、後から送信先の Log Analytics を変更も出来ます。

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

今回は Azure Functions と使っていた Application Insights なので、適当に Function を実行してログを送信しました。Log Analytics でクエリ可能になるまで 1,2 分という感じだったので、5 分程度の遅延と言われている Application Insights よりは速くなっている気がします。

Workspace ベースになると Table の名前とスキーマが変わるので、既にクエリを書いている場合には注意が必要になりそうです。既存のアラートなどは Application Insights 側で問題なく動作するようです。

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

Prefix として App が付いているものは大体 Application Insights と考えておいてよいです。

Table 名とスキーマの差分についてはドキュメントが公開されているので、これを見て読み替えればよいです。Application Insights の Logs から扱う分にはこれまで通りのスキーマで扱えるので、サポートが終わるまで使うというのもありな気はします。

KQL で書くのは変わらずにスキーマが変わっているぐらいなので、適当にデータを見てクエリを書きなおすというのも良いかもしれません。こちらの方が一般的なスキーマになっているので、他のログと組み合わせやすいというメリットがあります。

すぐに違いが分かるのは camelCase から PascalCase になっている点だと思います。地味にはまりそう。

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

個人的には timestampTimeGenerated に統一されたのが嬉しいです。割と混乱するので。

これまでの Application Insights から唯一落ちた機能として連続エクスポートがありますが、これは Diagnostic settings を使うことでほぼ同じことが実現できるようです。

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

ここまで Workspace ベースに移行した Application Insights を触ってきましたが、ぶっちゃけた話どっちでも良いなと思いました。今後を考えると Workspace ベースの一択ではありますが、今すぐ既存のリソースを移行する必要性をあまり感じませんでした。

ただし既に Log Analytics を使っている場合には Application Insights のログも統合した方が楽になりそうです。移行自体はとても簡単なので、サクサク終わるはずです。

結局は Application Insights の機能はほぼ全て使えるのと課金体系も同じなので、新しくアプリケーションを作る時には Log Analytics を中心に置いた設計にすればよいかなという結論です。Application Insights 自体を分けるべきか、それとも統合するべきかはまた別途悩むポイントです。

Azure Data Lake Storage の Query Acceleration が GA になったので試したら最高だった

ブチザッキによると Build 2019 での Mark Russinovich 御大のセッションで発表されていた Blob の内容に対してクエリを実行できる、当時は Quick Query と呼ばれていた機能が Ignite 2020 前に GA していたようです。

今年の 5 月ぐらいから名前が Quick Query ではなく Query Acceleration に変わっていたようですが、長いし typo しそうなので Quick Query と言ってしまいそうです。

AWS の Athena に似た機能のように見えますが、Query Acceleration は事前にテーブルを作る必要がなく、クエリを投げるだけで使えるので簡単です。データフォーマットとしては CSV*1 と JSON を扱えます。

Ignite 2020 の "What’s New in Azure Storage" というセッションでも GA の発表がありました。Microsoft によると読み取られたデータの 20% 以下しか実際の分析クエリに使われないらしく、データに近い部分でフィルタリングを行えるようにすることで全体的な最適化を図る仕組みです。

セッション曰く "Deeply integrated into Azure Synapse Analytics" らしいのでかなり期待が持てます。Azure Storage 周りのアップデートは多すぎなのでセッションを見ておくと良いです。

機能名は Query Acceleration for Azure Data Lake Storage となっていますが、Hierarchical namespace が無効な GPv2 のストレージアカウントに対しても利用できるので、若干の分かりにくさを生んでいます。

SDK 的には Blob Storage と Data Lake Storage それぞれに名前は違いますが同じインターフェースで用意されているので、特に悩むことは無いでしょう。ぶっちゃけ Blob Storage の SDK で ADLS も使えます。

Query Acceleration の機能と価格

日本語版は GA 向けに更新されていないので、これまで通り基本は英語版を参照するようにすると間違いがありません。特に NuGet パッケージの扱い周りが全然違うので、日本語版を見ているとはまります。

ドキュメントは比較的充実しているので、一通り目を通しておきたいですね。特に SQL に関しては T-SQL とはある程度の互換性はありますが、当然ながら使えない機能が多いです。

フィルタリングを行うチュートリアルも用意されています。ドキュメントにも手順として記載されていますが、使うためには BlobQuery の Resource Provider を登録する必要があるので注意してください。

今後は自動で登録されると思いますが、少なくとも手持ちのサブスクリプションでは登録が必要でした。

新しい Azure SDK から 4 つの言語で使えるようになっています。PowerShell を使う例も載っていますが、Azure CLI でも az storage blob query コマンドが実装されているので、サクッと試す分には使えます。

そして気になる課金体系ですが、ドキュメントにはスキャンされたデータとクライアントに返されたデータに対して課金されるとあります。Pricing にも一応書かれていますが、単位が謎なので計算が出来ません。

こっちはまだプレビューの情報のままのようなので、更新されるのを待ちたいと思います。

Synapse Analytics の SQL on-demand や AWS の Athena を見ると課金体系としてはよくあるものなので、飛びぬけて高いということにはならないでしょう。

ここからは実際にアプリケーションから Query Acceleration を使ってみます。CSV と JSON で若干使い方が異なっているので、それぞれのフォーマットで試しています。

CSV に対してクエリを実行する

実際に Query Acceleration を C# SDK を使って試してみます。今回は Blob Storage SDK を使っていますが、前述の通り ADLS SDK では名前が変わっているだけなので適宜読み替えてください。

試すにあたってはそれなりのデータ量のファイルを用意しないと面白くないので探し回ったのですが、以下のサイトで国勢調査ベースの人口統計が CSV で手に入ったのでこれを使いました。

C# SDK は NuGet で公開されている 12.6.0 以上をインストールします。これが対応バージョンです。

最初はコンテナー単位でクエリが書けるのかと期待しましたが、Blob 単位でクエリを実行する必要があったので、大量のファイルからフィルタリングする場合には少し手間がかかります。

正直なところワイルドカードで Blob を指定できるとかなり良さそうでしたが、今回は 1 つのファイルに対してなので単純なコードで実現することにします。本質的な部分は BlobQueryOptions の用意と QueryAsync の実行だけです。それ以外はいつも通りのコードです。

var connectionString = "DefaultEndpointsProtocol=https;AccountName=***;AccountKey=***;EndpointSuffix=core.windows.net";

var blobServiceClient = new BlobServiceClient(connectionString);
var containerClient = blobServiceClient.GetBlobContainerClient("sampledata");

var blobClient = containerClient.GetBlockBlobClient("c03.csv");

var options = new BlobQueryOptions
{
    InputTextConfiguration = new BlobQueryCsvTextOptions
    {
        HasHeaders = false
    }
};

var result = await blobClient.QueryAsync("SELECT * FROM BlobStorage WHERE _7 < 1000", options);

var content = await new StreamReader(result.Value.Content).ReadToEndAsync();

Console.WriteLine(content);

InputTextConfiguration には BlobQueryCsvTextOptions を指定しています。このクラスはデリミタやエスケープシーケンスのプロパティを持っているので、必要に応じて変更しましょう。

本来なら HasHeaderstrue に設定すると SQL からカラム名でアクセス出来るので便利ですが、後述する問題によって今回は false を設定しています。

カラムに対して名前でアクセスは出来ませんが _1, _2, _3 のようにインデックスでアクセス出来るので、これを使ってフィルタリングのクエリを書いています。今回の例では _7 は人口に該当します。

このコードを実行してみると、人口でフィルタリングされたデータが返ってくることが確認できます。

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

もちろん SELECT を使って必要なカラムに絞り込んだり、集計関数を 1 つだけ使うことも出来ます。CSV には型が無いので、集計関数を使うときはキャストが必要で少し面倒でした。

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

現時点で把握している問題としては、SQL 内でマルチバイト文字を使うとエラーになるという点です。従って CSV のヘッダーやフィルタリングする値が日本語の場合はエラーになります。

今回使用した国勢調査の人口統計はヘッダーや値に日本語が含まれているので、ヘッダーなしのデータとして扱うしか方法がありませんでした。フィードバック済みなので対応待ちです。

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

この程度の問題であれば Public Preview に気が付いていれば、GA する前に報告が出来たと思うので残念です。とは言えマルチバイト文字を使うケースは少ない思うのであまり実害はないでしょう。

JSON に対してクエリを実行する

次は JSON に対してのクエリを試します。対応するデータは 1 行が 1 つの JSON として表現されているものになるので、Azure Monitor が出力するログなどが分析対象としては面白いと考えるでしょう。

残念ながら Azure Monitor からのログは Append Blob が使われているので Query Acceleration は使えません。

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

もちろん何らかの方法で Block Blob としてコピーしなおせば使えますが、正直かなり面倒なので分析用は Log Analytics に入れて KQL を書いた方が良い気がします。

今回はデータを用意するのが面倒だったので App Service が Azure Monitor 経由で出力するアクセスログを、Block Blob としてコピーしなおしたものを使います。ファイルが 300 近くあるので Blob の一覧を取得して順次クエリを実行しています。

var connectionString = "DefaultEndpointsProtocol=https;AccountName=***;AccountKey=***;EndpointSuffix=core.windows.net";

var blobServiceClient = new BlobServiceClient(connectionString);
var containerClient = blobServiceClient.GetBlobContainerClient("httplogs");

await foreach (var blobItem in containerClient.GetBlobsAsync(BlobTraits.Metadata))
{
    var blobClient = containerClient.GetBlockBlobClient(blobItem.Name);

    var options = new BlobQueryOptions
    {
        InputTextConfiguration = new BlobQueryJsonTextOptions(),
        OutputTextConfiguration = new BlobQueryJsonTextOptions()
    };

    var result = await blobClient.QueryAsync("SELECT CsMethod, UserAgent FROM BlobStorage[*].properties WHERE CsUriStem = '/'", options);

    var content = await new StreamReader(result.Value.Content).ReadToEndAsync();

    Console.Write(content);
}

JSON を対象にする場合は InputTextConfiguration に明示的に BlobQueryJsonTextOptions を設定しないと、CSV として読み込もうとしてエラーになります。同様に OutputTextConfiguration にも指定しないと CSV として返ってきます。*2

クエリの説明はあまり要らないと思いますが、サイトのルート / へのログだけをフィルタリングして、HTTP Method と User-Agent だけ出力しています。

FROM の書き方があまり見ない形ですが、Azure Monitor からの JSON は以下のように properties でネストされた形になっているので、そのプロパティだけに絞り込むという指定をしています。

{
  "time": "2020-08-27T00:52:17.7598888Z",
  "resourceId": "/SUBSCRIPTIONS/00000000-0000-0000-0000-000000000000/RESOURCEGROUPS/DEFAULT-WEB-JAPANEAST/PROVIDERS/MICROSOFT.WEB/SITES/APPSERVICEINFO",
  "category": "AppServiceHTTPLogs",
  "properties": {
    "CsMethod": "GET",
    "CsUriStem": "/api/siteextension",
    "SPort": "443",
    "CIp": "0.0.0.0",
    "UserAgent": "Mozilla/5.0+(Windows+NT+10.0;+Win64;+x64)+AppleWebKit/537.36+(KHTML,+like+Gecko)+Chrome/86.0.4223.0+Safari/537.36+Edg/86.0.608.2",
    "CsHost": "appservice.info",
    "ScStatus": 200,
    "ScSubStatus": "0",
    "ScWin32Status": "0",
    "ScBytes": 1188,
    "CsBytes": 1205,
    "TimeTaken": 46,
    "Result": "Success",
    "Cookie": "-",
    "CsUriQuery": "X-ARR-LOG-ID=00000000-0000-0000-0000-000000000000",
    "CsUsername": "-",
    "Referer": "https://appservice.info/"
  }
}

このコードを実行してみると、順次該当するログが出力されていきます。

Blob 単位でのクエリになるので、良い感じのパス規約や Blob Index Tags と組み合わせると、必要なものだけ取れるようにするとさらに効率的でしょう。

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

最近は Synapse Analytics の SQL on-demand で Azure Data Lake Storage に保存した CSV に対してクエリを書きたいことが多かったのですが、複雑なクエリが必要なくアプリケーションから使いたい場合に Query Acceleration はかなり便利だと思います。

実際に仕事でも早速 Query Acceleration を使いたい場面があるので、更に利用できる SQL の検証や ADLS の設計に励んでいきたいところです。

*1:デリミタなどはクエリ時に指定できるので TSV なども可能のはず

*2:CSV => JSON / JSON=>CSV にも使えそうな感じがする