しばやん雑記

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

新しく Azure Durable Functions に追加された Netherite Storage Provider を一通り試した

これまで Azure Storage (Queue / Table) が使われてきた Durable Functions の Storage Provider ですが、今年に入ってからパフォーマンス重視の Netherite とポータビリティ重視の MS SQL が公開されています。

Azure Functions 上で動かす場合には MS SQL を使いたいモチベーションはかなり小さいので、高いパフォーマンスを実現するという Netherite の方が選択肢に入ってきます。

多くのケースでは Azure Storage でも十分なパフォーマンスが出るのですが、ドキュメントにもあるように Activity 呼び出しのレイテンシが Netherite は低いので、小さい Activity を大量に実行するようなケースや、そもそも多くのインスタンスにスケールアウトさせたい場合には有利です。

そろそろ実際に使いたいシナリオが出てきそうなので、インストールからリソースの作成やデプロイなどを含め、気になった点を一通り試しておきました。

まだプレビューというのもあり、Azure Functions 上で動かす時にいろいろとはまった感があります。フィードバックはしているので正式版までに改善されるのを期待しています。

インストール方法

Netherite のインストール自体は NuGet からMicrosoft.Azure.DurableTask.Netherite.AzureFunctions パッケージをインストールして、host.json を多少修正すれば完了します。DI の設定は必要ないので簡単です。

公式ドキュメントには host.json に関して設定項目についていろいろと書いてありますが、最低限 storageProvider 以下が追加されていれば良いです。

ぱっと見関係なさそうな useGracefulShutdown は Netherite 固有ではありませんが、デプロイや再起動で Function が予期せぬ形で再実行されるのを減らすことが出来ます。

公式のサンプルでは基本的に有効化されているので合わせておいた方が良いです。

{
  "version": "2.0",
  "logging": {
    "applicationInsights": {
      "samplingSettings": {
        "isEnabled": false,
        "excludedTypes": "Request"
      }
    },
  },
  "extensions": {
    "durableTask": {
      "hubName": "NetheriteTest",
      "useGracefulShutdown": true,
      "storageProvider": {
        "type": "Netherite",
        "StorageConnectionName": "AzureWebJobsStorage",
        "EventHubsConnectionName": "EventHubsConnection"
      }
    }
  }
}

それ以外にもログレベルやパーティション数など Netherite 固有の設定はありますが、デフォルトのままで問題ありません。hubName は明示的に指定しておいた方がモニタリングの設定が楽です。

必要なリソースの作成と設定

Netherite を使うには Event Hubs Namespace が必要になるので、いつも通り Azure Functions をデプロイした後に追加でデプロイしておきます。Tier は Standard の 1 TU で十分です。Event Hubs Namespace だけを作成しておけば、必要な Event Hub 自体は Netherite が自動的に作成してくれます。

Event Hub を初回起動時に Netherite が自動生成する関係上、SAS キーには管理権限が付いたものが必要になりますが、デフォルトで生成されている RootManageSharedAccessKey で問題ありません。SAS キーは App Settings に追加しておきます。

Azure Functions の作成後は Platform を 64bit に変更しておきます。忘れると後で大変なことになります。

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

Functions Premium (EP1-3) を選んだ場合は Runtime Scale Monitoring も有効にしておきましょう。

今のところは Scale Controller が Netherite に未対応なので、Runtime Scale Monitoring を有効にしないとイベントベースのスケーリングが行われません。

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

この通り Scale Controller が未対応なので Consumption Plan を使うことは出来ません。対応の予定はあるみたいですが、今のところスケジュールは公開されていませんでした。

ここまでの設定で Netherite を動かす準備は完了なので、後はアプリケーションをデプロイすれば動作します。Event Hub の管理を Netherite がやってくれるので思ったより手間はかかりませんでした。

トラブルシューティング

Azure Functions 上で実行時エラーが発生する

最新の Visual Studio や Azure Functions Core Tools で作成したプロジェクトを使って Netherite の設定を行った後にデプロイすると、以下のように実行時エラーが発生して起動しないことがあります。

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

Could not load file or assembly 'System.Threading.Channels, Version=5.0.0.0, Culture=neutral, PublicKeyToken=cc7b13ffcd2ddd51'. The system cannot find the file specified.

似たようなメッセージを見たことある人は多そうですが、Azure Functions SDK がパッケージサイズ最適化のために必要なライブラリまで削除してしまうのが原因です。

対処方法としては以下のように FunctionsPreservedDependencies を csproj ファイルに追加して、デプロイパッケージ作成時に System.Threading.Channels.dll が削除されないようにします。

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>netcoreapp3.1</TargetFramework>
    <AzureFunctionsVersion>v3</AzureFunctionsVersion>
  </PropertyGroup>
  <ItemGroup>
    <PackageReference Include="Microsoft.Azure.DurableTask.Netherite.AzureFunctions" Version="0.4.0-alpha" />
    <PackageReference Include="Microsoft.Azure.WebJobs.Extensions.DurableTask" Version="2.5.0" />
    <PackageReference Include="Microsoft.NET.Sdk.Functions" Version="3.0.13" />
  </ItemGroup>
  <ItemGroup>
    <FunctionsPreservedDependencies Include="System.Threading.Channels.dll" />
  </ItemGroup>
  <ItemGroup>
    <None Update="host.json">
      <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
    </None>
    <None Update="local.settings.json">
      <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
      <CopyToPublishDirectory>Never</CopyToPublishDirectory>
    </None>
  </ItemGroup>
</Project>

これで再デプロイするとエラーが解消されます。対処方法はいくつかあるのですが、Netherite 側で対応してくれた方が良いのでフィードバックしています。

EP1 を使うとハングする場合がある

最初は 1 コアの EP1 を使って試していましたが、思ったように Function が動作しないことがありました。Issue を調べると 1 コアのインスタンスだとハングする場合があるようです。

コメントにもあるように、今のところは EP2 以上にするのが簡単な解決策のようです。

バージョン変更時に Function が起動しなくなる

上の問題を探っているときに Netherite 自体のバージョンを下げてみると、今度は別のエラーが出て Function が動作しなくなりました。

The listener for function 'Function1' was unable to start. The taskhub has an incompatible storage format Value cannot be null. (Parameter 'value')

エラーメッセージにある通り、Blob Storage に保存されているフォーマットが Netherite のバージョンによって変わっているようなので、一先ず自動生成された Blob Container を削除して対応しました。

バージョンを上げる場合は問題ないですが、下げる場合にはこういうことも起こるようです。実際に運用する場合には、事前に検証環境で確認しておきたい部分です。

突然 StartNewAsync が極端に遅くなる

今回試していて一番はまったのがこの問題です。デプロイして暫くは問題なく動作するのですが、負荷テストで大量に Orchestrator や Activity を実行した後に StartNewAsync が急に遅くなる現象に遭遇しました。

Application Insights を確認してみると、以下のような FASTER のエラーが大量に出ていました。

Part** !!! Encountered exception while working on store in StoreWorker.Process: FASTER.core.FasterException: Overflow in AddressInfo - consider running the program in x64 mode for larger address space support
   at FASTER.core.AddressInfo.set_Address(Int64 value)
   at FASTER.core.GenericAllocator`2.WriteAsync[TContext](Int64 flushPage, UInt64 alignedDestinationAddress, UInt32 numBytesToWrite, DeviceIOCompletionCallback callback, PageAsyncFlushResult`1 asyncResult, IDevice device, IDevice objlogDevice, Int64 intendedDestinationPage, Int64[] localSegmentOffsets)
   at FASTER.core.GenericAllocator`2.WriteAsync[TContext](Int64 flushPage, DeviceIOCompletionCallback callback, PageAsyncFlushResult`1 asyncResult)
   at FASTER.core.AllocatorBase`2.AsyncFlushPages(Int64 fromAddress, Int64 untilAddress)
   at FASTER.core.AllocatorBase`2.OnPagesMarkedReadOnly(Int64 newSafeReadOnlyAddress)
   at FASTER.core.AllocatorBase`2.<>c__DisplayClass88_0.<ShiftReadOnlyToTail>b__0()
   at FASTER.core.LightEpoch.BumpCurrentEpoch(Action onDrain)
   at FASTER.core.AllocatorBase`2.ShiftReadOnlyToTail(Int64& tailAddress, SemaphoreSlim& notifyDone)
   at FASTER.core.FoldOverCheckpointTask.GlobalBeforeEnteringState[Key,Value](SystemState next, FasterKV`2 faster)
   at FASTER.core.SynchronizationStateMachineBase.GlobalBeforeEnteringState[Key,Value](SystemState next, FasterKV`2 faster)
   at FASTER.core.FasterKV`2.GlobalStateMachineStep(SystemState expectedState)
   at FASTER.core.VersionChangeTask.OnThreadState[Key,Value,Input,Output,Context,FasterSession](SystemState current, SystemState prev, FasterKV`2 faster, FasterExecutionContext`3 ctx, FasterSession fasterSession, List`1 valueTasks, CancellationToken token)
   at FASTER.core.SynchronizationStateMachineBase.OnThreadEnteringState[Key,Value,Input,Output,Context,FasterSession](SystemState current, SystemState prev, FasterKV`2 faster, FasterExecutionContext`3 ctx, FasterSession fasterSession, List`1 valueTasks, CancellationToken token)
   at FASTER.core.FasterKV`2.ThreadStateMachineStep[Input,Output,Context,FasterSession](FasterExecutionContext`3 ctx, FasterSession fasterSession, List`1 valueTasks, CancellationToken token)
   at FASTER.core.ClientSession`6.Refresh()
   at DurableTask.Netherite.Faster.FasterKV.StartStoreCheckpoint(Int64 commitLogPosition, Int64 inputQueuePosition) in C:\source\durabletask-netherite\src\DurableTask.Netherite\StorageProviders\Faster\FasterKV.cs:line 182
   at DurableTask.Netherite.Faster.StoreWorker.Process(IList`1 batch) in C:\source\durabletask-netherite\src\DurableTask.Netherite\StorageProviders\Faster\StoreWorker.cs:line 327 terminatePartition=True

最初に試していた時には Azure Functions をデプロイしてそのまま使っていたので、Platform が 32bit になっていました。それによって FASTER が扱えるアドレス空間の上限を超えてしまい、特定のパーティションでエラーが発生していました。

後で FASTER のコードを確認したところ、unsafe なポインターが大量に使われていたので納得しました。徐々に動かなくなっていったのも、パーティション毎にアドレス空間が別に割り当てられているので、それぞれが次々に 32bit を超えていったのが原因のようです。

64bit が必須だとはドキュメントには書かれていませんが、用意されているリソース構築用の PowerShell スクリプト内で 64bit がひっそりと有効化されていたので気が付きませんでした。

モニタリング方法

Application Insights を使う

少し前から Azure Portal から Function の詳細を開くと、Durable Functions の Orchestrator Trigger の場合はタブに Orchestrations が増えているはずなので、ここから Application Insights ベースでの Orchestrator 単位での実行ログを確認できます。

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

Application Insights ベースなので Storage Provider に関係なくモニタリング可能ですが、入力値や実行結果などはログに明示的に書いておかないと出力されないです。

エラーの確認と簡単な調査には十分使えますが、若干の機能不足感は否めません。

Durable Functions Monitor を使う

詳細なモニタリングが必要な場合は Durable Functions Monitor を使うのが非常に簡単です。CSA の方が作っているツールで VS Code からや Azure Functions にデプロイして利用できます。

Azure Storage の場合は VS Code からも使えますが、Netherite を使う場合には DurableFunctionsMonitor.DotNetBackend パッケージを Function App にインストールする必要があります。

インストールと設定方法についてはぶちぞう RD が書いているので、こっちを参照してください。

バックエンドのパッケージをインストールする方法では Durable Functions の API を利用するので、以下のように Netherite を使っていても Orchestration の実行ログが確認できるようになっています。

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

認証周りが若干設定が面倒になる感じですが、Netherite を使っている場合は API 経由でしか情報が取れないので仕方ないですね。出来れば SCM 側にインストールしたいですが、それも難しそうです。

パフォーマンス関連

パーティション数は後から変更できないので注意

これは実質 Event Hubs の制約になりますが、パーティション数は作成時のみ指定可能なので host.jsonPartitionCount を後から変更しても反映されません。

パーティション数はスケールアウトの上限に関わってくる部分なので、事前にどのくらいの処理を行うか予測と開発環境での検証を行ってから決定したい部分です。

Event Hubs を手動で削除すれば指定したパーティション数での再作成は可能ですが、本番環境でこの方法はまず使えないので負荷テストはしっかり行っておきましょう。

CPU コア数とインスタンス数のバランスを取る

Netherite は Event Hubs を使っている関係上、最大のパーティション数は 32 個になるので Functions Premium を使っていたとしても、最大 32 インスタンスがスケールアウトの事実上の上限となるようです。*1

ドキュメントにあるように Netherite のスループットは、コンピューティングリソースを増やすとリニアに伸びるので扱いやすいですが、当然ながら実際のアプリケーションでは Activity の実装によって左右されます。

Activity での処理によっては EP3 を使った方が有利なこともあるはずなので、Application Insights と Azure Monitor で継続的にメトリックを見て最適化する必要があるでしょう。

注意したいのは Netherite を使うようにしても Activity の実装が悪いと、結局は思ったようにスループットが上がらない割にコストが高くなる可能性もあることです。これは Azure Storage の時から同じですが、とりあえず Netherite 入れると早くなるみたいな幻想は捨てておいた方が良いです。

Event Hubs はボトルネックにほぼならない

実際に負荷テストをする前までは、Event Hubs の Throughput Unit は増やすか Auto Inflateを有効にした方がいいケースもあるのかと考えていましたが、1 TU で十分すぎるほどのスループットが出たのでほぼ気にする必要は無さそうでした。

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

Azure Monitor から恒常的にスロットリングが発生している場合のみ、TU を増やすぐらいで十分でしょう。とはいえ大体の場合は Function の処理の方がボトルネックになるはずです。

参考 : Netherite リソース構築用 Terraform 定義

最後に検証環境の構築に利用した Terraform 定義を載せておきます。Event Hubs Namespace の SAS キーの追加から Azure Functions の 64bit 有効化まで全てやってくれます。

provider "azurerm" {
  features {}
}

terraform {
  required_providers {
    azurerm = "~> 2.0"
  }
}

resource "random_string" "suffix" {
  length = 4
  number = true
  lower = true
  upper = false
  special = false
}

resource "azurerm_resource_group" "default" {
  name     = "rg-netherite-${random_string.suffix.id}"
  location = "japaneast"
}

resource "azurerm_application_insights" "default" {
  name                = "appi-netherite-${random_string.suffix.id}"
  location            = azurerm_resource_group.default.location
  resource_group_name = azurerm_resource_group.default.name
  application_type    = "web"
}

resource "azurerm_storage_account" "default" {
  name                     = "stfuncnetherite${random_string.suffix.id}"
  location                 = azurerm_resource_group.default.location
  resource_group_name      = azurerm_resource_group.default.name
  account_kind             = "Storage"
  account_tier             = "Standard"
  account_replication_type = "LRS"
}

resource "azurerm_eventhub_namespace" "default" {
  name                = "evhns-netherite-${random_string.suffix.id}"
  location            = azurerm_resource_group.default.location
  resource_group_name = azurerm_resource_group.default.name
  sku                 = "Standard"
  capacity            = 1
}

resource "azurerm_app_service_plan" "default" {
  name                = "plan-netherite-${random_string.suffix.id}"
  location            = azurerm_resource_group.default.location
  resource_group_name = azurerm_resource_group.default.name

  sku {
    tier = "ElasticPremium"
    size = "EP2"
  }
}

resource "azurerm_function_app" "default" {
  name                       = "func-netherite-${random_string.suffix.id}"
  location                   = azurerm_resource_group.default.location
  resource_group_name        = azurerm_resource_group.default.name
  app_service_plan_id        = azurerm_app_service_plan.default.id
  storage_account_name       = azurerm_storage_account.default.name
  storage_account_access_key = azurerm_storage_account.default.primary_access_key

  client_affinity_enabled = false
  enable_builtin_logging  = false
  https_only              = true
  version                 = "~3"

  site_config {
    pre_warmed_instance_count = 1
    use_32_bit_worker_process = false
  }

  app_settings = {
    "APPINSIGHTS_INSTRUMENTATIONKEY" = azurerm_application_insights.default.instrumentation_key
    "EventHubsConnection"            = azurerm_eventhub_namespace.default.default_primary_connection_string
    "FUNCTIONS_WORKER_RUNTIME"       = "dotnet"
    "WEBSITE_RUN_FROM_PACKAGE"       = 1
  }
}

関係ないですが random_string を使って Azure のリソース命名規約に合わせた suffix を作るのが便利でした。大文字やハイフンが使えないリソースもあるので、多少のカスタマイズは必要です。

*1:1 パーティションに対して 1 インスタンスが引っ付く形になる模様