しばやん雑記

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

Azure App Service / Azure Pipelines で .NET Core 3.0 が利用可能になっています

.NET Core 3.0 が GA してから 3 週間近く経ちましたが、ぼちぼち App Service と Azure Pipelines で .NET Core 3.0 が利用可能になってきました。App Service は今のところ Windows のみ対応しています。*1

両方のサービスに SDK がインストールされて終わりなら良かったのですが、現時点では注意点がいくつかあるので軽くまとめておきます。

Azure App Service

元々 App Service は US 時間 10/11 に .NET Core 3.0 のデプロイが完了する予定でしたが、今日の時点でほぼ全てのリージョンに 3.0 のデプロイが行われているようです。

手持ちの Japan East / West にデプロイ済みの App Service にも 3.0 が入っていることを確認しました。

あと MS の人が作ったサービスでも、載っているリージョンには 3.0 が展開されていることが確認できます。全ての Scale unit に展開済みというわけでは無さそうですが、ここ 1,2 日のうちに完了するでしょう。

ちょっと前に Re-image も行われて最新の ASP.NET Core Module もインストールされているので、ASP.NET Core 3.0 のアプリケーションを動かす環境は整っています。

Azure Pipelines などでビルドしたものをデプロイする場合には問題なりませんが、今の App Service には 3.0 向けの SDK がインストールされていないので、GitHub や LocalGit から Kudu でビルドしているケースでは 3.0 向けだとエラーになります。

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

dotnet --info を叩いた結果ですが、Runtime は 3.0 が入っていますが SDK は 2.2 のままです。最新の MSBuild も入っていないので C# 8.0 を使ったコードはビルドが通らないはずです。

この場合はこれまで通り TFM を netcoreapp2.2 にして App Service 側の対応を待つか、他の CI サービスでビルドさせるかのどちらかになります。

Azure Pipelines

ビルド側の Azure Pipelines は Release Note には書いてませんでしたが、一部の Hosted Agent に .NET Core 3.0 SDK がインストールされていました。

GitHub を見てる感じではイレギュラーな対応っぽいので、暫くすると新しいイメージがデプロイされる気がします。2.2.x の SDK も新しいものが入っていなかったので。

今日の時点で .NET Core 3.0 が入っていたのは Ubuntu / Windows の 2 つでした。

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

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

何故か macOS には 3.0 が入っていなかったので、今回の対応はかなりイレギュラーなものだと思います。

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

他にも .NET Core インストール後の初回起動時に行われるキャッシュの展開が未実行だったり、いろいろと問題のあるイメージがデプロイされていますが、手動でインストールと変わらないので今は目を瞑ります。

アプリケーション側の対応

既に ASP.NET Core 3.0 への移行を終わらせていれば、そのままデプロイすれば Windows の App Service でも .NET Core 3.0 のランタイム上で動きます。

せっかくの .NET Core 3.0 対応なので、Azure Pipelines で ReadyToRun 向けにビルドするようにしました。GA ちょっと前のタイミングで Framework Dependent でも R2R 向けにビルド出来るようになっています。

既に App Service に Self-contained + ReadyToRun でデプロイしていた場合は、false を指定して Framework Dependent にするだけで対応完了です。

実際に App Service に Framework Dependent + ReadyToRun でビルドしたものを Run From Package でデプロイしていますが、問題なく動作しています。

起動時のパフォーマンス改善は規模が小さいアプリケーションなので計測が難しいです。

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

ReadyToRun 向けにビルドをすると、外部アセンブリも同時に R2R Image になるので、依存パッケージが多い場合などで効いてくるかもしれません。

この辺りはちゃんと計測したいところですが、ReadyToRun にするデメリットはビルド時間が少し長くなるのと、アセンブリサイズが少し大きくなるぐらいの認識なので、有効化しておいて損はないと思います。

*1:Docker を使っている場合は問題なく .NET Core 3.0 が使えます

Terraform と Azure Pipelines を使って App Service / Azure Functions をコード化して管理する

ぼちぼち ARM Template を JSON で書くのが限界と思い始めてきたので、Terraform を使って Azure リソースの管理をやっていこうという気持ちになりました。

Terraform で使われている HCL は JSON で書く ARM Template よりも読み書きがしやすいのと、CI/CD に必要なバリデーションや Dry-run も簡単に行えるので便利に使えるはずです。ちょっと前に Cloud Shell にインストールされていたり、Azure と Terraform の関係も悪くない感じです。

多分、自分は Azure VM を作ることはないので App Service でとりあえず試しました。最近よく使うのは Azure Functions なので一緒に試しておきました。*1

Terraform (HCL) で Azure リソースを定義する

AWS Provider では触ったことがあったので HCL に対する抵抗はほぼなく、書き方を思い出すぐらいでしたが、思ったより AzureRM Provider の情報がなかったので公式のドキュメントを中心に触っていったほうが良さそうです。今回もわからないことがあれば、まず以下のページで調べました。

https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs

全ての Azure サービスに対する Provider が用意されているわけでは無さそうなので、必要なサービスに対応しているか先に調べておいたほうが後で苦労しないです。

Azure 側にも Terraform のドキュメントがありますが、基本は VM / IaaS 寄りという感じです。VM には興味はないので適当に流し読んでおきました。

Terraform の状態を Azure Storage に保存するページは有用そうな雰囲気がありますが、後で紹介する Azure Pipelines の Terraform Tasks を使うとほぼ気にする必要がなくなるので、雰囲気だけ掴んでおけばよいです。

早速、それぞれのリソースを作るために HCL を書いていくことにします。結局はバックエンドは ARM なので ARM Template の知識があれば、HCL を少し覚えるだけで簡単に書けるはずです。

App Service を作る

App Service を作るためには azurerm_app_serviceazurerm_app_service_plan を使います。リファレンスを読めばサンプル定義が載っているので簡単です。

https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/app_service

どうしても分からない場合は terraform import を使ってしまうのも手です。App Service はプロパティがかなり多いので、デフォルト値を上手く使っていかないとメンテナンス性が下がる可能性があります。

Premium V2 な App Service を作る定義は以下のようになります。ぱっと見は ARM Template に近いです。

resource "azurerm_app_service_plan" "main" {
  name                = "${var.prefix}-asp"
  location            = "${azurerm_resource_group.main.location}"
  resource_group_name = "${azurerm_resource_group.main.name}"

  sku {
    tier = "PremiumV2"
    size = "P1v2"
  }
}

resource "azurerm_app_service" "main" {
  name                = "${var.prefix}-appservice"
  location            = "${azurerm_resource_group.main.location}"
  resource_group_name = "${azurerm_resource_group.main.name}"
  app_service_plan_id = "${azurerm_app_service_plan.main.id}"

  site_config {
    dotnet_framework_version = "v4.0"
  }
}

ARM Template と Terraform でプロパティ名が対応していない部分があるので、その辺りだけ注意です。とはいっても、基本的には Terraform の方が書きやすくなっています。

Azure Functions (Consumption) を作る

ARM Template を使っても少し複雑になっていた Azure Functions (Consumption) ですが、Terraform は Azure Functions 向けの Provider が用意されているので、少しだけ楽になっていました。

https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/function_app

Consumption の場合は Azure Storage が必要になるので、その辺りも同時に作っておく必要があります。あとはモニタリング用に Application Insights も必要なので追加します。

リソースに必要な定義は以下のようになります。ARM Template よりは簡略化されています。

resource "azurerm_storage_account" "function" {
  name                     = "${var.prefix}azfunctions"
  resource_group_name      = "${azurerm_resource_group.main.name}"
  location                 = "${azurerm_resource_group.main.location}"
  account_tier             = "Standard"
  account_replication_type = "LRS"
}

resource "azurerm_app_service_plan" "consumption" {
  name                = "${var.prefix}-consumption-asp"
  location            = "${azurerm_resource_group.main.location}"
  resource_group_name = "${azurerm_resource_group.main.name}"
  kind                = "FunctionApp"

  sku {
    tier = "Dynamic"
    size = "Y1"
  }
}

resource "azurerm_application_insights" "function" {
  name                = "${var.prefix}-appinsights"
  location            = "japaneast"
  resource_group_name = "${azurerm_resource_group.main.name}"
  application_type    = "web"

  tags = {
    "hidden-link:${azurerm_resource_group.main.id}/providers/Microsoft.Web/sites/${var.prefix}-function" = "Resource"
  }
}

resource "azurerm_function_app" "function" {
  name                      = "${var.prefix}-function"
  location                  = "${azurerm_resource_group.main.location}"
  resource_group_name       = "${azurerm_resource_group.main.name}"
  app_service_plan_id       = "${azurerm_app_service_plan.consumption.id}"
  storage_connection_string = "${azurerm_storage_account.function.primary_connection_string}"
  client_affinity_enabled   = false
  version                   = "~2"

  app_settings = {
    "APPINSIGHTS_INSTRUMENTATIONKEY" = "${azurerm_application_insights.function.instrumentation_key}"
    "FUNCTIONS_WORKER_RUNTIME"       = "dotnet"
  }
}

azurerm_function_app のデフォルトだと Runtime v1 が使われるらしいので、明示的に version で Runtime v2 を使うようにしておきます。v1 はメンテナンス状態になっているはずです。

Terraform だと接続文字列を自分で組み立てる必要がないので、見た目かなりすっきりしました。

Azure Pipelines で Terraform を実行する

当然ながら作成した Terraform の定義は Git に入れて管理するわけですが、デプロイまで自動化しないと意味がなくなります。なので Azure Pipelines を使って自動的にデプロイするようにします。

残念ながら標準では Terraform に対応していないですが、Marketplace から Terraform の拡張をインストールできるので、これを使うと簡単に Terraform の各コマンドが利用できるようになります。

インストールすると CLI と Installer の 2 つが追加されます。Hosted Agent には Terraform が入っていますが、予期せぬバージョンアップを避けるためにも Installer で明示的に入れた方が良さそうです。

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

デフォルトで Terraform は入っていますが、そのままだと Azure Resource Manager の操作に必要な Service Principal を上手く扱えないので、専用の Task を使ったほうが便利です。Azure CLI でログイン済みであっても、Service Principal でログインしている場合は Terraform で直接設定が必要になります。

Terraform CLI を使うと AzureRM の Service Connection の設定が簡単になる以外に、State Backend を簡単に Azure Storage を使うように設定できます。

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

Storage Account などは Task に自動で作ってもらうことも出来ますが、今回は手動で Storage Account と Blob Container を作成して、その情報を設定するようにしました。

とりあえず最低限必要な処理を用意した YAML は以下のようになります。本来なら apply は Approval を使ったり、 Multi-stage pipelines にした方が良いですが、本質的な部分からずれるので今回は省略します。

trigger:
- master

pool:
  vmImage: 'ubuntu-latest'

variables:
  TerraformVersion: '0.12.10'

steps:
- task: TerraformInstaller@0
  inputs:
    terraformVersion: $(TerraformVersion)
  displayName: 'Install terraform $(TerraformVersion)'

- task: TerraformCLI@0
  inputs:
    command: 'init'
    backendType: 'azurerm'
    backendServiceArm: 'Azure Sponsorship'
    backendAzureRmResourceGroupName: 'tfstate-rg'
    backendAzureRmStorageAccountName: 'shibayantfstate'
    backendAzureRmContainerName: 'tfstate'
    backendAzureRmKey: 'terraform.tfstate'
  displayName: 'terraform init'

- task: TerraformCLI@0
  inputs:
    command: 'plan'
    environmentServiceName: 'Azure Sponsorship'
  displayName: 'terraform plan'

- task: TerraformCLI@0
  inputs:
    command: 'apply'
    environmentServiceName: 'Azure Sponsorship'
  displayName: 'terraform apply'

認証情報は Service Connection が使われるので、YAML には名前だけしか出てこないため安全です。

よくあるドキュメントでは Azure CLI で Service Principal を作成して、環境変数に設定といった手順が書かれていますが、Terraform CLI の Task を使うことで全て省略できます。

作成した Pipeline を動かすと、ちゃんと各処理が実行されて Azure にリソースがデプロイされます。

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

Azure Portal からリソースグループを開けば、定義したリソースが作成されていることが確認できます。

Terraform はデフォルトで差分を見てアップデートしてくれるので、ARM Template の Incremental Mode を使うより簡単だと思います。

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

これで Terraform と Azure Pipelines を組み合わせて、リソースのデプロイを行う基盤が完成しました。

あとは HCL を使って必要なリソース定義を書いていくことになりますが、ARM Template より柔軟に書けるので Template Deployment が不要かつ、リソースをちゃんと管理したい規模の場合に使っていこうと思います。

*1:Azure Functions は複数のリソースが必要なのでコード化して管理したい気持ちが強い

Azure AD B2C の知識が古かったのでキャッチアップし直した

ちゃんと触ったのが 2 年前と古く、ASP.NET Core も 2.0 の時だったので最新の情報でもろもろキャッチアップし直しました。基本的に Azure AD が嫌いなので B2C も Azure AD ベースでなければという気持ちが強いのですが、価格的に競合よりも使いやすいので。

とはいえ 2 年前から比べると全体的に使い勝手と機能が改善されていました。Azure Portal での設定もそれなりに分かりやすくなった気がするので、迷うことはあまりなかったです。

ロケーションに APAC が追加された(らしい)

今朝ツイートが流れてきて気が付きましたが、そういえば昔は日本を選べなかったのでした。

先月に作った B2C では特に何も考えずに日本を選んで作っていたので、APAC に作成されていました。

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

実体はどこにあるのか良くわからないですが、たぶん日本にはなさそうな気がします。

b2clogin.com の導入

同じオリジンでホストされていたのでカスタマイズに制限がありましたが、b2clogin.com を使うとサブドメインが割り当てられるので、JavaScript を使ったカスタマイズが出来るようです。

ASP.NET Core の場合はプロジェクト作成時に設定しておくと、勝手に b2clogin.com に置き換えてくれました。なので最初から b2clogin.com で表示されます。

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

実際に JavaScript でのカスタマイズをする場合は User Flow での設定が必要になるっぽいです。

Page Layouts Template が劇的に良くなった

前は B2C を使う場合、デフォルトのテンプレートがしょぼすぎてカスタマイズ必須という感じでしたが、テンプレートを Classic 以外に切り替えるとモダンなデザインで利用できます。

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

切り替えると、以下のようなモダンなデザインでページが表示されるようになります。メッセージなどは言語カスタマイズを使えば変更できるようでした。

ちなみにロゴは AAD 側のブランディングを使っても変わらなかったです。この辺りは HTML をカスタマイズする必要がありそうです。

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

デフォルトで選択されている Classic なテンプレートは以下のようなしょぼいやつです。昔の Azure や Office 365 と同じ見た目なので、懐かしさはありますが使い勝手と見た目が悪いです。

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

新しく作成した場合は最初からモダンなデザインになっていて欲しいですが、そうなってはいません。

ASP.NET Core 向けライブラリ (2.1 から導入)

ASP.NET Core 2.0 の時は GitHub にサンプルコードが転がっているだけでしたが、2.1 からは NuGet にパッケージが用意されるようになったので簡単に使えるようになりました。

ただしパッケージ ID の通り UI も含んでいるので、カスタマイズ性は例によってイマイチです。オーバーライドすればよいですが、ASP.NET Core Identity のような面倒さがあります。

ソースは GitHub でもちろん公開されているので、実装をコピーするのが楽です。

デフォルトの実装ではログイン後のリダイレクト先が "/" で固定になっているので、その辺りを元々見ていたページに戻るようにする場合は、以下のようにコントローラーを作っておけばよいです。

[AllowAnonymous]
public class AccountController : Controller
{
    public AccountController(IOptionsMonitor<AzureADB2COptions> options)
    {
        _options = options;
    }

    private readonly IOptionsMonitor<AzureADB2COptions> _options;

    [HttpGet]
    public IActionResult SignIn(string returnUrl)
    {
        var scheme = AzureADB2CDefaults.AuthenticationScheme;
        return Challenge(
            new AuthenticationProperties { RedirectUri = returnUrl },
            scheme);
    }

    [HttpGet]
    public async Task<IActionResult> SignOut()
    {
        var scheme = AzureADB2CDefaults.AuthenticationScheme;

        var authenticated = await HttpContext.AuthenticateAsync(scheme);

        if (!authenticated.Succeeded)
        {
            return Challenge(scheme);
        }

        var options = _options.Get(scheme);

        var redirectUrl = Url.Action("SignOutComplete", "Home");
        return SignOut(
            new AuthenticationProperties { RedirectUri = redirectUrl },
            options.AllSchemes);
    }
}

特に難しいことはしてないので理解できるはずです。デフォルト実装は使わないほうが良い気がします。

Custom Policy (IEF) が GA

この辺りは難しすぎるので詳しい人に任せたいですが、ドキュメントを色々と読んだのである程度は理解できました。主に XML のせいで理解が難しくなっているように思います。

主なユースケースとしてはバックエンドに Azure Functions などで REST API を用意して、独自の処理を透過的に追加することになるかと思います。

ドキュメントでも基本は User Flow を使えと書いてありますし、Custom Policy (IEF) を使うのは最後の手段とでも考えておけばよさそうです。あの XML はあまり書きたくないです。

REST API の保護は気を使う必要がありますね。ドキュメントではクライアント証明書での認証を使うように書いてありました。Basic 認証は本番向けではないでしょうし、地味に扱いにくいです。

独自 IdP から Azure AD B2C への移行

ユーザーの移行は Graph API を使って行う方法が紹介されていますが、Azure Portal からの Bulk Insert も出来そうでした。個人情報の扱いとしては微妙な感じですが、テストデータを入れるときには使えそうです。

ASP.NET Core Identity からの移行時には、パスワード周りの扱いに注意が必要です。

Core Identity ではパスワードは PBKDF2 と HMAC-SHA256 でハッシュ化されているので、B2C へパスワード付きは移行できないためランダムにパスワードを設定して、初回ログイン時に変更させる必要があります。このあたりの処理は Custom Policy を使えば出来るようです。

MFA / FIDO2

SMS を使った MFA は設定からオンにするだけで使えるようになりました。SMS はセキュリティ面での問題もあるし価格も高いので、将来的には TOTP などに対応してほしいところです。

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

MFA を有効化すると、ログイン時によく見るコードの送信画面が表示されるようになります。

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

他にも FIDO2 や Microsoft Authenticator を使った認証方法を利用するための設定がありましたが、有効にして色々と試してみましたが動きませんでした。

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

FIDO2 / Web Authn の実装は正直自前で行いたくないので、B2C 側で対応してくれると最高です。

メールのカスタマイズ (ブランディング)

メールアドレスの認証時に送信されるメールのカスタマイズは対して出来ないようですが、AAD 側のブランディング設定を使うとロゴや名前ぐらいは差し替え出来るようです。

完全なカスタマイズは予定されているみたいですが、特に進捗はなさそうでした。

planned
How can we improve Azure Active Directory?
  • 893 votes
  • 113 comments

Fully customizable verification emails

Currently, Azure AD B2C sends verification codes via emails to end users during sign-up and password reset flows. These emails have limited customization. Add support for full customization of the email body & content.

IEF を使うと多少は近いことが出来るようですが、正直その方法は選びたくないですね。

サンプルコードは非常にありがたいですが、何をやっているのか良くわかりませんでした。何となくぶちぞう RD が詳しそうなので、適当なタイミングでブログ書いてもらわないといけませんね。

.NET Core 3.0 をインストール後に Azure Functions のデプロイが失敗するケースがある

タイトルの通りですが、.NET Core 2.2 では問題なくデプロイ出来ていた Azure Functions のプロジェクトが、.NET Core 3.0 のインストール後に失敗するケースが一部で発生するようです。

先に解決策を書いておきますが、Microsoft.NET.Sdk.Functions を最新版にアップデートすれば直ります。

ビルドやデバッグ時には発生せず、デプロイ時のみエラーとなるので混乱します。

昔に作った Azure Functions プロジェクトでは、SDK が古いケースも多いと思うので Visual Studio 2019 16.3 や .NET Core SDK 3.0 をインストール後、デプロイに失敗するというパターンが割と発生しそうな予感です。

ちなみにエラーメッセージは SDK のバージョンが関係するものとは読み取りにくいです。

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

基本的には Azure Functions SDK は最新にしておいた方が良いです。昔はワイルドカードを使って自動で最新のバージョンが入るようになっていましたが、最近は固定化されるように戻ったので注意。

細かいバージョンについては面倒だったので調べていないですが、1.0.24 と 1.0.29 の 2 つを使ってエラーの有無を確認しておきました。結果は以下の通りです。

.NET Core 2.2 SDK .NET Core 3.0 SDK
Azure Functions SDK 1.0.24 OK NG
Azure Functions SDK 1.0.29 OK OK

.NET Core 3.0 SDK と古い Azure Functions SDK の組み合わせでのみ発生します。

テストには以下のようにテンプレート通りの csproj を作成し、SDK のバージョンだけ変更しました。どのバージョンから発生するかは特定していませんが、少なくとも 1.0.24 では再現します。

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>netcoreapp2.1</TargetFramework>
    <AzureFunctionsVersion>v2</AzureFunctionsVersion>
  </PropertyGroup>
  <ItemGroup>
    <PackageReference Include="Microsoft.NET.Sdk.Functions" Version="1.0.24" />
  </ItemGroup>
  <ItemGroup>
    <None Update="host.json">
      <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
    </None>
    <None Update="local.settings.json">
      <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
      <CopyToPublishDirectory>Never</CopyToPublishDirectory>
    </None>
  </ItemGroup>
</Project>

利用する SDK バージョンは global.json を作成して、version を 2.2.402 と 3.0.100 に変えて試しました。global.jsondotnet new globaljson を使うと簡単に作成できます。

{
  "sdk": {
    "version": "3.0.100"
  }
}

リポジトリに global.json を含めて SDK バージョンを固定しておけば発生しにくいですが、古い SDK をアップデートした方が圧倒的に手っ取り早いのと正しい解決法となります。

今のところは Azure Pipelines の各 Agent には .NET Core 3.0 SDK がプリインストールされていないので問題は発生しませんが、次の VM Image 更新でインストールされそうなので、直前で焦らないようにさっさと SDK を最新にアップデートしておいた方が良いです。

Visual Studio や Azure Functions Core Tools を使ってプロジェクトを作成する場合は、古い Core Tools がインストールされている場合は古い SDK を使ったテンプレートが使われるのも注意です。

Visual Studio の場合は自動的にプロジェクト作成時やデバッグ実行時に最新の Core Tools をインストールする仕組みになっていますが、稀に古いバージョンがインストールされる現象も発生したことがあるので SDK バージョンには気を付けましょう。

Azure Functions でも appsettings.json と User Secrets を使った設定とシークレットの管理を行う

前に設定周りを全て App Configuration と Key Vault に一元化する方法を紹介しましたが、ローカル環境ではデバッグの度に Access Token と値の取り直しが必要になるので、そこそこオーバーヘッドが大きいです。

ちなみにデプロイすると起動のタイミングで 1 回だけ取りに行くので、オーバーヘッドは無視できます。

ASP.NET Core ではローカル環境では appsettings.json と User Secrets を使い、設定と接続文字列などのシークレットを管理するのが一般的です。それに対して Azure Functions では local.settings.json を使うのと、フォーマットが異なっているので少し扱いが違います。

設定周りはドキュメントにまとまっているので、既に読んでいる人は多いと思います。かなり便利です。

新しい IConfiguration はネストした JSON を扱えますが、local.settings.json は特殊な実装になっていて JSON 内でネスト表記出来ないのも結構不便です。

なので Azure Functions でも ASP.NET Core と同じ形で設定とシークレットを扱えるようにしました。

まずは準備として Azure Functions のプロジェクトを作成し、必要なパッケージのインストールと csproj にいくつかのプロパティを追加します。重要なのは UserSecretsId と appsettings.json 周りの設定です。

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>netcoreapp2.2</TargetFramework>
    <AzureFunctionsVersion>v2</AzureFunctionsVersion>
    <UserSecretsId>xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx</UserSecretsId>
  </PropertyGroup>
  <ItemGroup>
    <PackageReference Include="Microsoft.Azure.Functions.Extensions" Version="1.0.0" />
    <PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="2.2.0" />
    <PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" Version="2.2.0" />
    <PackageReference Include="Microsoft.NET.Sdk.Functions" Version="1.0.29" />
  </ItemGroup>
  <ItemGroup>
    <None Update="host.json">
      <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
    </None>
    <None Update="local.settings.json">
      <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
      <CopyToPublishDirectory>Never</CopyToPublishDirectory>
    </None>
    <None Update="appsettings.json">
      <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
    </None>
    <None Update="appsettings.*.json" DependentUpon="appsettings.json">
      <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
    </None>
  </ItemGroup>
</Project>

UserSecretsId は適当に GUID を生成して設定すれば良いです。appsettings.json 周りはビルド・デプロイ時に出力ディレクトリにコピーする設定です。

プロジェクトを修正後、ソリューションエクスプローラーでは以下のように表示されるはずです。

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

local.settings.json は必須なので消さないように注意してください。特に Consumption / Premium Plan を使う場合には接続文字列などの Scale Controller が必要とする情報を、local.settings.json や App Service 側の App Settings に追加しないと正常に動作しなくなります。

User Secrets に必要な設定を追加すると、Visual Studio から管理できるようになるので便利です。

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

設定は完了したので後はコードから利用するだけですが、ファイルパスと環境の扱いについては少し注意が必要です。特にカレントディレクトリがローカル環境と Azure 上で異なっているのがはまりポイントです。

この辺りの違いを吸収するために、以下のように簡単なクラスを用意しておきました。

public static class FunctionsEnvironment
{
    private const string Development = "Development";
    private const string Staging = "Staging";
    private const string Production = "Production";

    public static string EnvironmentName =>
        Environment.GetEnvironmentVariable("AZURE_FUNCTIONS_ENVIRONMENT");

    public static string RootDirectory =>
        Environment.GetEnvironmentVariable("WEBSITE_SITE_NAME") != null ? Environment.ExpandEnvironmentVariables("%HOME%/site/wwwroot") : Environment.CurrentDirectory;

    public static bool IsDevelopment => IsEnvironment(Development);

    public static bool IsStaging => IsEnvironment(Staging);

    public static bool IsProduction => IsEnvironment(Production);

    public static bool IsEnvironment(string environmentName) =>
        string.Equals(EnvironmentName, environmentName, StringComparison.OrdinalIgnoreCase);
}

DI を使って IHostingEnvironment などを取得しても良いですが、IServiceProvider を作るのを避けたかったので環境変数を使って解決することにしました。

最後に FunctionsStartup の実装で JSON や User Secrets から設定を読み込んでしまえば完了です。User Secrets に関しては Development の場合だけ読み込むようにします。

public class Startup : FunctionsStartup
{
    public Startup()
    {
        var config = new ConfigurationBuilder();

        config.SetBasePath(FunctionsEnvironment.RootDirectory)
              .AddJsonFile("appsettings.json", optional: true, reloadOnChange: true)
              .AddJsonFile($"appsettings.{FunctionsEnvironment.EnvironmentName}.json", optional: true, reloadOnChange: true);

        if (FunctionsEnvironment.IsDevelopment)
        {
            config.AddUserSecrets<Startup>();
        }

        config.AddEnvironmentVariables();

        Configuration = config.Build();
    }

    public IConfiguration Configuration { get; }

    public override void Configure(IFunctionsHostBuilder builder)
    {
        builder.Services.AddDbContextPool<MyDbContext>(options =>
            options.UseSqlServer(Configuration.GetConnectionString("DefaultSqlConnection")));

        builder.Services.Configure<SampleOptions>(Configuration);
    }
}

Function の実装は App Configuration / Key Vault の時と同じものを使いました。ローカル環境で実行してみると、正しく appsettings.Development.json と User Secrets から値を取れていることが確認できます。

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

次は実際に Azure にデプロイして確認してみます。SQL Database の接続文字列は Azure Functions の Connection Strings から直接追加してあります。

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

実行してみると本番では appsettings.Development.json の値が読み込まれないので、ローカル環境とは値が異なっています。接続文字列は同じものを使っているので、データは同じものが返っています。

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

個人的にはこれで Azure Functions で設定とシークレットを扱う方法に悩まなくなりました。

いつかは正式に Azure Functions でサポートされるようになると思いますが、実装の方向性は大きく変わらないはずなので移行も簡単でしょう。

ASP.NET Core / Azure Functions で App Configuration と Key Vault を使って設定を一元化する

アプリケーションが 1 つとかの場合は App Service の App Settings や Connection Strings を使って設定すれば良いのですが、数が多くなったり環境が増えてくると大体管理しきれなくなって破綻する傾向にあります。

Infrastructure as a Code の考えで ARM Template や Terraform を使って App Settings を管理するのも良いですが、設定値はインフラというよりアプリ寄りなのでちょっと違うかなという気もします。なので Azure App Configuration と Key Vault を使います。

基本的な方針は App Service 側には必要最低限かつ最初以外ほぼ変更されないものだけを残します。今回であれば App Configuration と Key Vault のエンドポイントや、App Service 自体の設定値などです。

App Service には出来るだけキーなどを設定したくないので、App Configuration と Key Vault には Managed Identity を使ってアクセスします。これで App Service はエンドポイントだけ保持するようになります。

アクセスキーや接続文字列などの重要な情報は Key Vault を使い、それ以外のアプリケーションの動作に関わる設定は App Configuration に保存します。Feature Management と組み合わせるのも便利ですね。

App Configuration は AAD 認証に対応していないので細かなアクセス制御は行えませんが、重要な情報は Key Vault に保存すれば良いので特に問題はないでしょう。

既に何回か書いている気がしますが、以下のライブラリ 2 つをインストールするだけで実現できます。

  • Microsoft.Extensions.Configuration.AzureAppConfiguration
  • Microsoft.Extensions.Configuration.AzureKeyVault

両方とも Microsoft.Azure.Services.AppAuthentication を含んでいますがバージョンが古いので、新しいバージョンをプロジェクトに追加しておくと、使ってくれるようになります。割とバグ修正が多いです。

予め App Configuration と Key Vault は作成とキーの追加を行っておきました。ドキュメントに従って Managed Identity の設定もしておきます。

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

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

今回は使っていないですが、App Configuration は既存の App Service から設定のインポートと、App Service へのエクスポートまで出来るようになっていたのでかなり便利です。時間のある時に App Configuration を使った設計パターンをいくつか書いておきたいです。

タイトルの通り ASP.NET Core と Azure Functions のそれぞれで使ってみます。割と普通に出来てしまうので面白みはないですが、Azure Functions はやっと自分の中で答えを見つけられた気がします。

ASP.NET Core

特に新しいことはないですが、これまで通り ConfigureAppConfiguration を使って App Configuration と Key Vault の Configuration Provider を追加します。

これまで通り ConfigurationBuilder は後勝ちなので、追加する順番は重要になってきます。

public class Program
{
    public static void Main(string[] args)
    {
        CreateWebHostBuilder(args).Build().Run();
    }

    public static IWebHostBuilder CreateWebHostBuilder(string[] args) =>
        WebHost.CreateDefaultBuilder(args)
               .ConfigureAppConfiguration(config =>
               {
                   var builtConfig = config.Build();

                   config.AddAzureAppConfiguration(options =>
                       options.ConnectWithManagedIdentity(builtConfig["AppConfig:Endpoint"]));

                   config.AddAzureKeyVault(builtConfig["KeyVault:Endpoint"]);
               })
               .UseStartup<Startup>();
}

ドキュメントでは Key Vault 周りが KeyVaultClient を渡す方法になってますが、エンドポイントだけ渡せば勝手に Managed Identity とデフォルトの設定を使ってくれるのでシンプルです。

それぞれのエンドポイントは appsettings.Development.json や User secret に追加しておけば良いです。

{
  "AppConfig": {
    "Endpoint": "https://***.azconfig.io"
  },
  "KeyVault": {
    "Endpoint": "https://***.vault.azure.net"
  } 
}

これで App Configuration と Key Vault から値を取ってきてくれるので、使う側は特に意識する必要ありません。これまで通り IConfiguration を使って接続文字列を取ったり、オプションを組み立てるだけです。

public class Startup
{
    public Startup(IConfiguration configuration)
    {
        Configuration = configuration;
    }

    public IConfiguration Configuration { get; }

    public void ConfigureServices(IServiceCollection services)
    {
        services.AddDbContextPool<MyDbContext>(options =>
            options.UseSqlServer(Configuration.GetConnectionString("DefaultSqlConnection")));

        services.Configure<SampleOptions>(Configuration);

        services.AddMvc()
                .SetCompatibilityVersion(CompatibilityVersion.Version_2_2);
    }
}

あとは適当にコントローラを用意して、値が正しく取れているのかを確認しました。

たまに IConfiguration をそのまま DI で受け取って使っているケースがありますが、メンテナンス性が下がるので IOptions<T> パターンを使った方が良いです。

public class HomeController : Controller
{
    public HomeController(IOptions<SampleOptions> options, MyDbContext myDbContext)
    {
        _options = options.Value;
        _myDbContext = myDbContext;
    }

    private readonly SampleOptions _options;
    private readonly MyDbContext _myDbContext;

    public async Task<IActionResult> Index()
    {
        ViewBag.Value = _options.TestKey;

        var users = await _myDbContext.Users.ToArrayAsync();

        return View(users);
    }
}

開発環境で実行すると App Configuration に追加した値と、Key Vault に保存した接続文字列を使って DB からデータを取得できているのが確認できます。

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

Azure サービス認証のおかげでエンドポイントだけでサクッと扱えました。App Service にデプロイする際もエンドポイントと Managed Identity の設定をすると、全く同じコードで動きます。

Azure Functions

ASP.NET Core 向けに Configuration Builder が設計されているので簡単に使えるのは当たり前なのですが、地味に悩むのが Azure Functions です。正式に Configuration Builder を弄る方法が無いので、暫くは ASP.NET Core のようには設定できないでしょう。

他にも appsettings.json を公式にサポートして欲しいという意見がかなり出てますが、簡単にはいかなさそうな雰囲気です。v2 での解決は難しいかも知れませんね。

なので、Azure Functions は App Configuration と Key Vault を使って設定を管理した方が楽なケースが割とありそうです。環境ごとの設定も App Configuration で一元管理できるようになります。

現在 Azure Functions では DI が使えますが、ASP.NET Core とは異なり IConfiguration が提供されないので IServiceProvider が取れるメソッドを使うか、別で ConfigurationBuilder を使う必要があります。

出来るだけ ASP.NET Core の書き方に寄せたかったので、今回は以下のような Startup を用意しました。

public class Startup : FunctionsStartup
{
    public Startup()
    {
        var config = new ConfigurationBuilder()
                     .AddEnvironmentVariables();

        var builtConfig = config.Build();

        config.AddAzureAppConfiguration(options =>
            options.ConnectWithManagedIdentity(builtConfig["AppConfig:Endpoint"]));

        config.AddAzureKeyVault(builtConfig["KeyVault:Endpoint"]);

        Configuration = config.Build();
    }

    public IConfiguration Configuration { get; }

    public override void Configure(IFunctionsHostBuilder builder)
    {
        builder.Services.AddDbContextPool<MyDbContext>(options =>
            options.UseSqlServer(Configuration.GetConnectionString("DefaultSqlConnection")));

        builder.Services.Configure<SampleOptions>(Configuration);
    }
}

Azure Functions Runtime が管理している IConfiguration は使わずに ConfigurationBuilder を作成して、App Configuration と Key Vault を追加するようにしました。Startup クラス内では IConfiguration を触れるので、接続文字列やオプションなども簡単に設定できます。

Azure Functions 独自の挙動ですが local.settings.json の内容は Runtime 起動時に環境変数に設定されるので、以下のような local.settings.json を用意すると問題なくエンドポイントが参照できます。

{
  "IsEncrypted": false,
  "Values": {
    "AzureWebJobsStorage": "",
    "FUNCTIONS_WORKER_RUNTIME": "dotnet",
    "AppConfig:Endpoint": "https://***.azconfig.io",
    "KeyVault:Endpoint": "https://***.vault.azure.net"
  }
}

Azure 上で動かす場合には App Settings に設定を追加すれば、環境変数に設定されるので同様に動作します。

使う場合は ASP.NET Core とほぼ同じです。IOptions<T> を使ってオプションを受け取れます。

public class Function1
{
    public Function1(IOptions<SampleOptions> options, MyDbContext myDbContext)
    {
        _options = options.Value;
        _myDbContext = myDbContext;
    }

    private readonly SampleOptions _options;
    private readonly MyDbContext _myDbContext;

    [FunctionName("Function1")]
    public async Task<IActionResult> Run(
        [HttpTrigger(AuthorizationLevel.Function, "get")] HttpRequest req,
        ILogger log)
    {
        var result = await _myDbContext.Users.ToArrayAsync();

        return new OkObjectResult(new { Value = _options.TestKey, Users = result });
    }
}

適当に上の HttpTrigger を実行してみると、ちゃんと App Configuration と Key Vault の設定が使われていることが確認できます。DI サポートのおかげで大分楽になりました。

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

Azure App Configuration と Key Vault、そして Managed Identity と Azure サービス認証を組み合わせることで設定の一元化が出来るようになりましたが、これで課題が全て解決したわけではないです。

開発中から App Configuration と Key Vault を使うと、アプリケーションの起動に毎回オーバーヘッドが乗って来ますし、設定項目を追加したい時に Azure Portal に行ったりするのは非常に手間です。

この辺りは開発時のフローを決めたり、Git で設定を管理しつつ CI/CD で App Configuration / Key Vault に反映するといった仕組み作りが必要になって来そうです。

Managed Identity と RBAC を使って Azure Storage をアクセスキー無しで扱ってみる

Azure Storage への Managed Identity と RBAC を使ったアクセスが前に GA しましたが、試してなかったので使い方を確認しました。あとは App Service と User assigned managed identity の組み合わせもちゃんと試してなかったので、折角なので同時に使ってみました。

基本的には以下のドキュメントの通りで、良くまとまっています。なのではまった部分を中心に書きます。

まずは開発環境の Visual Studio から Azure Storage に対して AAD を使った操作が行えるか確認しました。組み込まれている Azure サービス認証のおかげで AzureServiceTokenProvider を使っておけば、開発環境と Azure 上の両方で同じコードが利用できます。

Azure SDK は Preview 版も出てますが、今回は以下の NuGet パッケージをインストールしました。

  • Microsoft.Azure.Services.AppAuthentication
  • Microsoft.Azure.Storage.Blob

基本は Access Token を取得して、各ストレージの処理を呼び出すという流れです。アクセストークンを取得する時の Resource ID は https://storage.azure.com/ を使いました。Blob と Queue でさらに細分化されてますが、とりあえず Azure Storage 全体を触れるもので試します。

普段なら Connection String を Parse して CloudStorageAccount を作ってると思いますが、Access Token を使う場合は StorageCredentials を用意してから、CloudStorageAccount を作成します。コンストラクタの引数がちょっと面倒かも知れません。

var accessToken = await new AzureServiceTokenProvider().GetAccessTokenAsync("https://storage.azure.com/");

var credentials = new StorageCredentials(new TokenCredential(accessToken));
var storageAccount = new CloudStorageAccount(credentials, "demoappstorage1", null, true);

var blobClient = storageAccount.CreateCloudBlobClient();

var container = blobClient.GetContainerReference($"{DateTime.Now:yyyyMMdd}");

await container.CreateIfNotExistsAsync();

var blob = container.GetBlockBlobReference($"{DateTime.Now:HHmmss}.txt");

await blob.UploadTextAsync("kazuakix");

ドキュメントでは TokenCredential に用意した Access Token 取り直し用のメソッドを指定してますが、Managed Identity で取得できるトークンは期限が切れる 5 分前に自動で更新されるので、時間のかかる処理以外では都度 Access Token を取得して使えば良いと思います。

この辺りの設計は Azure Management Library と異なっているので統一感がないです。Preview SDK だと改善されていますが、それは後で述べます。

上のコードで AAD を使った Azure Storage の操作が行えるようになりますが、デフォルトでは Blob を操作するためのロールが付いていないので、Storage か Container の IAM からロールの割り当てを追加します。

Azure Portal から Container を選んで接続方法を AAD に変えると、権限の有無が簡単に確認できます。

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

Azure Storage の AAD 対応に伴って、いくつかロールが追加されています。今回は Storage Account に対して Storage Blob Data Contributor を割り当てました。

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

User assigned managed identity と自分自身にもロールを割り当てておきます。自分にも割り当てておかないと Visual Studio から確認することが出来ないので、忘れないようにします。

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

暫く待つと、先ほどエラーになっていた Container の AAD でのアクセスが行えるようになります。

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

これで先ほどのコードを実行すると、アクセスキー無しで Blob の作成が行えるようになりました。

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

Azure Storage SDK がアクセストークンを使うように最適化されてないので多少面倒な感じですが、RBAC を使ったアクセス制限も問題なく行えるようになっています。

App Service で動かす

アクセストークンが取れれば問題なく動くことを開発環境で確認出来ているので、後は App Service にデプロイして試します。User assigned managed identity を使うので、App Service に割り当てておきます。

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

これだけで最初は動くだろうと思っていましたが、実際にデプロイしてみるとアクセストークンが取れずにエラーとなりました。System assigned だと問題なかったので調べると、User assigned の場合は Client ID を指定する必要がありました。

Managed Identity はドキュメントが散らばっていてアレですが、接続文字列で Client ID を渡せます。

接続文字列は環境変数 AzureServicesAuthConnectionString を参照してくれるので、App Service の場合は App Settings にキーと値を追加すると、指定した User assigned managed identity の Access Token が取れるようになります。期待していたのと動作が異なっていました。

運用する場合には、この Client ID の管理がめんどくさくなりそうなので、基本は System assigned を使って行きたい気持ちになりました。App Service の場合はアプリ単位での権限管理になるので、System assigned だからと言って管理が煩雑になることもないでしょう。

おまけ : Azure SDK Preview について

設計がイマイチだった Access Token 周りですが、Azure SDK Preview では Azure.Identity によって AAD 認証と Managed Identity がデフォルトになっていきそうな気配があります。

現在公開されている SDK は、大体が AAD に対応したサービスです。統一感のある API が提供されているので、現在の秩序の無い SDK に比べると大幅に使い勝手が改善されそうです。

Azure CLI を使って Managed Identity に割り当て済みのロールを確認する

Managed Identity が非常に便利なのでいろいろと使ってしまうわけですが、Azure Portal 上だとリソースに対して割り当てたロールは見れるのに、特定の Managed Identity やユーザーに割り当てたロールが見れないので不便です。権限管理という点では望ましくないですね。

何かいい方法がないかと探していると、Azure CLI を使うと簡単に確認できるようだったので試しました。

az role assignment list には特定のリソースに対してのロール以外に、--assignee でのフィルタが仕えるようになってます。ここに Managed Identity の Object ID を指定すれば取れます。

System Assigned Managed Identity の場合は Portal から Object ID を拾ってこれます。

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

今回は特定のリソースグループなどではなく、サブスクリプション全体で割り当て済みのロールを確認したかったので、オプションとして --all も指定しました。

後は見やすいように JMESPath でロール名とスコープだけフィルタリングしています。

# assignee には Object ID (guid) を指定する
az role assignment list --assignee 00000000-0000-0000-0000-000000000000 --all --query "[].[roleDefinitionName,scope]"

Cloud Shell から実行すると、Managed Identity に割り当てられているロールとスコープが返って来ます。

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

Azure CLI を使うとロールが取れるので便利です。API レベルでは GUID しか返って来ません。

User Assigned Managed Identity の場合も System Assigned とほぼ同じです。Azure Portal から該当のリソースを開くと、Object ID を拾えるので同じように扱えます。

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

適当にロールを割り当てた後に Azure CLI を叩くと、ロール情報が返って来ます。

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

実際のところ IAM が反映されるより先に Azure CLI では情報が取れるようなので、ここでロール割り当てが返ってきたと言っても、アクセスが可能になっているとは限らないです。

反映されるまで 30 分ぐらいかかるケースもあるので、新規に割り当てた場合は注意しましょう。

アプリケーションから使いたい場合は、REST API を叩くか NuGet からクライアントをインストールします。

REST API を直接使う場合は Durable Functions v2 の Durable HTTP を使うと楽です。

ロールの割り当ての監査としては Activity Log を使った方が良いと思いますが、不要な権限が付いてないかのチェックを行うには便利です。Azure CLI でも az role assignment list-changelog を使うと変更ログを取れそうですが、試すとエラーで動かなかったです。

Durable Functions v2 を使った非同期 HTTP API の実装と利用

Durable Functions v2 beta 2 で Durable HTTP という機能が追加されました。クリス氏が Tweet で説明しているように、アクティビティ関数無しでよい感じに 202 Accepted のポーリングを行ってくれる便利機能です。

非同期処理の開始を 202 Accepted で通知して、完了したかどうかを Location ヘッダーで返した URL で確認するパターンを、全く意識させずに扱えます。

しかも完了するまで単純にポーリングするのではなく、Durable Functions の Timer を使っているようなので、待機している間は CPU を使用しないので効率が良いです。とても Consumption 向きです。

非同期 HTTP API の実装

202 Accepted を使った非同期 HTTP API のパターンは一般的なのか気になったので調べてみましたが、仕様として明記されてるわけではないですが使われてはいるようです。ARM API は結構使ってました。

Template Deployment の進捗を見てると、Accepted が返ってくるので分かりやすいです。

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

時間がかかる処理を行う API は地味に作るのが面倒で、大抵の場合はタイムアウト長くして誤魔化しているのではないかと思いますが、Durable Functions を使えば最初から非同期な API として作られるので簡単です。

ちゃんとドキュメントにも非同期 HTTP API パターンが紹介されています。

テンプレートからオーケストレーターを作成すると CreateCheckStatusResponse でレスポンスを作っているはずなので、非同期に対応した API がこれだけで完成です。

public class Function1
{
    [FunctionName("Function1")]
    public Task<string> RunOrchestrator([OrchestrationTrigger] IDurableOrchestrationContext context)
    {
        return Task.FromResult("Hello, world");
    }

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

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

        return starter.CreateCheckStatusResponse(req, instanceId);
    }
}

適当にリクエストを投げてみると、ちゃんと 202 Accepted と Location ヘッダーが返って来ます。

Retry-After は何秒後に再実行するかを表していますが、Durable Functions の場合は 10 秒固定です。

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

Location ヘッダーで返ってきた URL にさらにリクエストを投げると、処理が完了している場合は結果が受け取れます。終わっていない場合は 202 が返ってくるので、同じことの繰り返しです。

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

実行が完了していると 200 が返ってくるので、後はペイロードの値を使って良しなにするだけです。

非同期 HTTP API の利用

上の例では手動でリクエストを投げて、Durable Functions が提供する非同期 API を実行して結果を取得してみましたが、単純に実装するとループとウェイトを入れるような形になってしまいます。

実際にコンソールアプリケーションで API を実行して、結果を取得するコードを書いてみました。

class Program
{
    static async Task Main(string[] args)
    {
        var request = new HttpRequestMessage(HttpMethod.Get, "http://localhost:7071/api/Function1_HttpStart");

        var response = await _httpClient.SendAsync(request);

        while (response.StatusCode == System.Net.HttpStatusCode.Accepted)
        {
            await Task.Delay(response.Headers.RetryAfter.Delta.GetValueOrDefault(TimeSpan.FromSeconds(10)));

            var statusRequest = new HttpRequestMessage(HttpMethod.Get, response.Headers.Location);

            response = await _httpClient.SendAsync(statusRequest);
        }

        Console.WriteLine(await response.Content.ReadAsStringAsync());
    }

    private static readonly HttpClient _httpClient = new HttpClient();
}

見て分かるように、202 Accepted が返ってきた場合には、何らかの方法で処理を待機させる必要があります。この場合は Task.Delay を使っていますが、効率は良いとは言えないです。

代わりに Durable Functions v2 で追加された Durable HTTP を使うと、以下のようにシンプルに書けます。Timer を使っているので効率も良いです。

[FunctionName("Function2")]
public static async Task<string> RunOrchestrator([OrchestrationTrigger] IDurableOrchestrationContext context)
{
    var response = await context.CallHttpAsync(HttpMethod.Get, new Uri("http://localhost:7071/api/Function1_HttpStart"));

    return response.Content;
}

制約としては Durable Functions と同じで、リクエストとレスポンスはシリアライズが可能な必要があるため、Stream 系は扱えないです。基本的には全て string して扱われます。

同じような理由で Multipart なリクエストも扱えないので、ファイルアップロードは難しいですね。実装する場合は Blob にアップロードしてから、URL を渡す形になると思います。

実行する HTTP リクエストは DurableHttpRequest を直接扱うことで、もう少し弄れます。この辺りは HttpClientHttpRequestMessage に近いです。

[FunctionName("Function2")]
public async Task<string> RunOrchestrator([OrchestrationTrigger] IDurableOrchestrationContext context)
{
    var request = new DurableHttpRequest(
        HttpMethod.Get, 
        new Uri("https://***.azurewebsites.net/api/Function1_HttpStart"), 
        content: JsonConvert.SerializeObject(new { foo = "bar" }));

    request.Headers.Add("X-Function-Key", new StringValues("<functionkey>"));

    var response = await context.CallHttpAsync(request);

    return response.Content;
}

コンストラクタでいろいろ指定しないといけないのが少し面倒な感じです。オブジェクト初期化子で書きたい気持ちが高まるので、暇な時にフィードバックしてみようかと思いました。

ちなみに透過的なリトライは行ってくれないので、その辺りだけは気を付ける必要があります。

元から Long-running なタスクを実行するのに適していた Durable Functions ですが、v2 で更に進化を遂げそうです。利用シーンは結構広いので、今後も積極的に使って行きます。

Azure Pipelines を使った Azure Web Apps / Azure Functions (Linux) へのデプロイを試した

Run From Package に関するドキュメントを眺めていたら、Linux の Premium Plan に対して Run From Package が使えそうな記述があったので試しました。

暫く Linux の App Service を触っていなかったので、知識のアップデートを兼ねてます。

まずは Linux の App Service について簡単にまとめました。ちょっと前に Consumption Plan が GA したり、Premium Plan が Preview で使えるようになったりと、インフラ側は Windows とほぼ同じになっています。

特に Premium V2 が値下げされたので、Windows の Premium V2 よりかなり安く使えます。*1

  • Web Apps (Linux)
    • App Service Plan (F1 / B1-B3 / S1-S3 / P1v2-P3v2)
  • Azure Functions (Linux)
    • Consumtpion Plan
    • App Service Plan (F1 / B1-B3 / S1-S3 / P1v2-P3v2 / EP1-EP3)

この中で EP1-EP3 が Premium Plan を表しています。少し混乱しそうですが、Premium Plan は App Service Plan のインスタンスサイズの一つです。

作成した Web Apps / Azure Functions へのデプロイ方法は以下の 3 種類が選べます。

  • Code
    • git push でビルド & デプロイ
    • Zip を作成してデプロイ
  • Docker
    • Docker Image を用意してデプロイ

今回は Docker を使わない方法を扱うので、Zip を使ったデプロイを行います。ちなみに Consumption と Premium Plan*2 では Docker Image を使った Azure Function のデプロイはサポートされていません。

Azure Pipelines には Web Apps / Azure Functions のデプロイタスクが用意されているので簡単です。

Web App の場合

Linux Web App へのデプロイは Windows Web App とほぼ同じなので難しくないです。デプロイタスクで appTypewebAppLinux に設定すれば、よい感じに ZipDeploy を実行してくれます。

trigger:
- master

variables:
  BuildConfiguration: 'Release'

pool:
  vmImage: 'ubuntu-latest'

steps:
- task: DotNetCoreCLI@2
  inputs:
    command: 'publish'
    publishWebProjects: false
    projects: '**/*.csproj'
    arguments: '-c $(BuildConfiguration) -o $(Build.SourcesDirectory)/publish'
  displayName: 'Build Application'

- task: AzureWebApp@1
  inputs:
    azureSubscription: 'Azure Sponsorship'
    appType: 'webAppLinux'
    appName: 'linuxwebapp-1'
    package: '$(Build.SourcesDirectory)/publish/**/WebApplication1.zip'
  displayName: 'Deploy to Web App (Linux)'

こんな感じで YAML を書けば終わりなのですが、Premium Plan で Run From Package が使えると書いてあったので、Web Apps でも使える気がしたので設定を追加して試しました。

Configuration からサクッと WEBSITE_RUN_FROM_PACKAGE = 1 を追加します。

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

設定後に Azure Pipelines から Zip Deploy を実行すると、パッと見は問題なく Run From Package として動作していました。

SSH でコンテナにアクセスして、wwwroot 以下にファイルを作ろうとするとエラーになります。

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

ちゃんと Zip が Read-only としてマウントされていて動作はしましたが、Web Apps に関しては正式にサポートされている方法ではないので、何かしら問題はありそうです。

Azure Functions (Consumption Plan / App Service Plan) の場合

Azure Functions でも Web Apps と同様にデプロイタスクを使えば、自動的に Zip Deploy が実行されるので簡単なのですが、Consumption Plan のみ Run From Package としてデプロイされるようになっています。

古い情報ですが、Linux の Consumption Plan では Run From Package のみ使えると書いてあります。ただし URL を指定して、外部からパッケージを取得する形の Run From Package です。

ローカルの Zip を使う Run From Package を試しては見ましたが、ランタイムが起動しなくなったので GA した現在も非対応のようです。大人しくデプロイタスクに従いましょう。

App Service Plan の場合はこれまで通り wwwroot 以下に Zip が展開されます。

trigger:
- master

variables:
  BuildConfiguration: 'Release'

pool:
  vmImage: 'ubuntu-latest'

steps:
- task: DotNetCoreCLI@2
  inputs:
    command: 'publish'
    publishWebProjects: false
    projects: '**/*.csproj'
    arguments: '-c $(BuildConfiguration) -o $(Build.SourcesDirectory)/publish'
  displayName: 'Build Application'

- task: AzureFunctionApp@1
  inputs:
    azureSubscription: 'Azure Sponsorship'
    appType: 'functionAppLinux'
    appName: 'linuxconsumption-1'
    package: '$(Build.SourcesDirectory)/publish/**/FunctionApp1.zip'
  displayName: 'Deploy to Consumption Plan (Linux)'

Consumption Plan の場合はデプロイタスクを使うと、URL を使った Run From Package としてデプロイされていることが確認できます。

SAS を使っていますが、有効期限は 2029 年とかでしたので 10 年間は有効です。

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

10 年間もほったらかしにすることはないと思いますが、間違って Blob を消さないよう気を付けましょう。

デプロイが頻繁に行われる場合はあっという間にファイルが増えるので、Lifecycle Management を使って古い Blob を消す処理などが必要になりそうです。

Azure Functions (Premium Plan) の場合

Premium Plan では作成時に Run From Package を使ったデプロイが必要だと警告が出ます。Premium Plan は App Service Plan と Consumption Plan を足したような動きをするので理解できます。

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

Consumption Plan と同じようにデプロイタスクを使えば、勝手に Run From Package としてデプロイしてくれるかと思っていましたが、タスクが Premium Plan に対応していませんでした。

デプロイ方法を変更できないため、仕方なく Azure Functions Core Tools を使ってデプロイします。

Azure Pipelines の Hosted Agent には Azure Functions Core Tools がインストールされていないので、ドキュメントにあるコマンドの通りにインストールしておきます。

デプロイには認証済みの Azure CLI が必要なので、実行は AzureCLI タスクで行います。

trigger:
- master

pool:
  vmImage: 'ubuntu-latest'

steps:
- task: CmdLine@2
  inputs:
    script: |
      wget -q https://packages.microsoft.com/config/ubuntu/16.04/packages-microsoft-prod.deb
      sudo dpkg -i packages-microsoft-prod.deb
      
      sudo apt-get update
      sudo apt-get install azure-functions-core-tools
  displayName: 'Install Azure Functions Core Tools'

- task: AzureCLI@1
  inputs:
    azureSubscription: 'Azure Sponsorship'
    scriptLocation: 'inlineScript'
    inlineScript: |
      cd FunctionApp1/FunctionApp1
      func azure functionapp publish linuxpremiumapp2 --csharp
  displayName: 'Deploy to Premium Plan (Linux)'

地味にはまった点として func azure functionapp publish を実行した時に、実行に使うランタイムを判別できないと言われてエラーになったことです。

ローカルだと local.settings.json にランタイム名が含まれているので問題ないですが、自動生成された .gitignore で除外されているのでエラーとなってしまいました。流石にこのファイルを含めたくないので、コマンド実行時に --csharp オプションを付けて対応しました。

これで Premium Plan でも Run From Package を使ったデプロイが行えるようになりました。GA までにはデプロイタスクでも対応されるとは思いますが、それまでは準備が面倒ですが Core Tools を使ってデプロイする必要があります。

*1:元々 S1 ではメモリ少なすぎて辛かったので、その辺りを考慮した可能性あり

*2:Create Azure Functions on Linux using a custom image | Microsoft Docs