しばやん雑記

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

Terraform Provider for Azure v2.3.0 で Service Tags の Data Source が追加されたので試した

今日 Azure Resource Manager 向けの Terraform Provider の新バージョンがリリースされていたので、リリースノートを眺めていたら Service Tags を取るための Data Source が追加されていました。

Service Tags を取るには巨大な JSON を読み取るしかないと思ってましたが、ARM REST API が Public Preview として公開されているので、それを利用して実装しているようです。

ドキュメントは微妙に typo が多いですが、パラメータが少ないので簡単に使えます。ARM REST API の仕様がイマイチで、Service Tags を取るために location を指定する必要があり、それが Service 自体の location と混同しそうになります。

App Service などの各リージョンにデプロイされているサービスで、対象リージョンを絞り込む場合には location_filter を指定して行います。

実際に Japan East にある App Service の IP Address のリストを取得する場合には、以下のような定義を書いて terraform refresh を実行すると表示されます。

data "azurerm_network_service_tags" "default" {
  location = "Japan East"
  service  = "AppService"
  location_filter = "Japan East"
}

output "address_prefixes" {
  value = data.azurerm_network_service_tags.default.address_prefixes
}

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

JSON ではなく ARM REST API を使っているからか、微妙にドキュメントに書いてある Service Tags とは名前付けが異なっているケースもありました。プレビュー中は同期されるのにラグがありそうです。

VNET と NSG を使っている場合は、直接 Service Tags を条件に指定できるので IP Address のリストを使う機会はほぼなさそうですが、App Service の IP 制限では欲しいときがたまにあります。

Service Endpoints が使えるので特定の Subnet からのトラフィックのみ許可の場合は簡単に行えますが、それ以外の場合は IP Address でチマチマと書く必要がありました。*1

サンプルとして、よく使いそうな Front Door からのトラフィックのみ許可するような定義を書いてみました。

現状 Front Door の Outbound IP Range は 1 つしかないので手動でも変わらない気もしますが、もし増えた場合には自動で対応できるメリットはあります。

data "azurerm_network_service_tags" "frontdoor" {
  location = "Japan East"
  service  = "AzureFrontDoor"
}

resource "azurerm_resource_group" "default" {
  name     = "appservice-test"
  location = "Japan East"
}

resource "azurerm_app_service_plan" "default" {
  name                = "ASP-Default-01"
  location            = azurerm_resource_group.default.location
  resource_group_name = azurerm_resource_group.default.name

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

resource "azurerm_app_service" "default" {
  name                = "backend-appservice-01"
  location            = azurerm_resource_group.default.location
  resource_group_name = azurerm_resource_group.default.name
  app_service_plan_id = azurerm_app_service_plan.default.id

  site_config {
    dynamic "ip_restriction" {
      for_each = data.azurerm_network_service_tags.frontdoor.address_prefixes
      content {
        ip_address = ip_restriction.value
      }
    }
  }
}

複数の IP Address が返ってくることを考慮して、Dynamic blocks を使って ip_restriction を生成するようにしています。ちょっと冗長さは否めないです。

この定義を使うと、Front Door の IP Address のみ許可する設定が追加された状態で作成されます。

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

必要な時にサクッと Terraform の Data Source として特定リージョンのサービスの IP Address を参照できるのは便利なのですが、割とニッチな需要という感じはします。

本当なら NSG + Service Tags や Private Link などで解決したい世界でした。

*1:Service Tags を条件に使えるようになって欲しさがある

Azure Functions SDK 3.0.4 以降を使う場合は依存関係に注意

今月頭にリリースされた Azure Functions SDK 3.0.4 では Function Runtime が持っているアセンブリを、Function のデプロイパッケージから除外するという実装が追加されています。

ASP.NET Core 周りの明らかに必要ないだろうというアセンブリがごっそり除外されるので、以下のツイートで紹介されているようにデプロイパッケージのサイズは大幅に縮んでいます。

今は 3.0.5 がリリースされていますが、WebJobsStartup が読み込まれないという致命的な問題が修正されているので 3.0.4 を使っている場合はアップデートしましょう。

不要なアセンブリを除外することでデプロイの高速化はもちろん見込めますし、Run From Package を使った実行時に不要なアセンブリを展開し、読み込む必要がないので全体的に良いことが多いです。

除外されるアセンブリのリストは GitHub にあり、ここにないファイルがデプロイされるようになってます。

テストとして HttpTrigger だけの Function V3 プロジェクトを作成して、SDK バージョンは 3.0.3 のままの状態で dotnet publish を実行しデプロイ用のファイルを生成しました。

ASP.NET Core 周りのアセンブリが大量に出力されているのがわかります。

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

次は SDK バージョンを 3.0.5 にアップデートした後に、同様にデプロイ用のファイルを作成しました。

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

大幅に減って、Azure Storage SDK と NCrontab の厳密名付きアセンブリだけになりました。NCrontab が含まれているのは意図した結果ではなさそうなのですが、気にしないことにします。

Run From Package 用として zip にするとサイズの差が歴然なことがわかるはずです。

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

これでデプロイと実行も効率的になり、嬉しいことしかないと思ってしまいそうですが罠があります。

今の SDK 実装には問題があり、単純にリストにある同名のアセンブリを出力結果からごっそり消しているだけなので、バージョンの違いなどは完全に無視されます。なので以下のような問題が発生しています。

Azure Functions の Extensions を特定の組み合わせでインストールすると、アセンブリが読み込めないというエラーが出るという問題ですが、デプロイ時のアセンブリ削除によって必要なバージョンのアセンブリも消されてしまっているのが原因です。

Function の開発時に参照されているアセンブリと同名のものがリストに載っていれば削除されるので、強制的に Function Runtime 側のバージョンに合わせられるという鬼のような挙動です。そして実行時には指定されたバージョンのアセンブリが存在しないので実行時エラーになるという話でした。

現在は発生しにくいですが将来的に JSON.NET がアップデートされた時、新機能を使うために明示的にパッケージ参照を追加すると Function Runtime と異なるバージョンになるので実行時エラーになるでしょう。割と怖い問題です。

ちなみに Durable Functions 2.2.0 以前のバージョンにも同じような問題がありましたが、Issue で指摘して直してもらったので最新バージョンにアップデートすると問題なく動作します。

回避策としては以下のように csproj へ _FunctionsSkipCleanOutput を追加すると、以前の SDK バージョンと同じ動作になります。

この場合はデプロイパッケージのサイズ削減は行われないので、アセンブリバージョン問題は発生しません。

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>netcoreapp3.1</TargetFramework>
    <AzureFunctionsVersion>v3</AzureFunctionsVersion>
    <_FunctionsSkipCleanOutput>true</_FunctionsSkipCleanOutput>
  </PropertyGroup>
  <ItemGroup>
    <PackageReference Include="Microsoft.NET.Sdk.Functions" Version="3.0.5" />
  </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 バージョンを上げると Azure 上にデプロイした時、急にエラーになって焦ることになるので注意しましょう。

せめてバージョンも見て同じものを削除する実装なら問題なかったはずなんですけどね。

Azure App Service の Private Link サポートを一通り試した

ちょっと前に SQL Database や Cosmos DB などの Private Link サポートが一気に GA になったタイミングで、新たに App Service の Private Link サポートが Preview になりました。

発表の数日前から App Service の Networking 設定に Private Link が増えていたので、先取りして試してましたが 500 エラーが出て上手くいかず、そのまま放置していたのでした。

Preview 発表後も上手くいかないので怪しんでいましたが、今のところは East US と West US 2 でのみ試せるようです。他のリージョンでも使えるようになるにはしばらくかかりそうです。

ドキュメントが追加になっているので読んでおくと良いです。特に難しいことは書いていなくて、VNET にデプロイした VM から Private Link 経由で App Service にアクセスしているだけです。

実際にデプロイして試してみると、ちょいちょい課題が出てきたので都度潰しつつまとめました。詳しくは後述しますが、App Service から Private Link 経由で他のサービスを使えないのが割と致命的です。

Private Link (Private Endpoints) を作成する

とりあえず適当に VNET と App Service を作成して、Private Link を追加してみました。App Service の Networking 設定から追加できますが、ここから追加すると Private DNS Zone が作成されなかったので、Private Link Center から追加した方が楽です。

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

Private Link Center から Private Endpoints を選択して項目を入力していくと、Private DNS Zone を作成する設定が出てくるので On にして進めます。On にしておくと必要な A レコードも作成してくれます。

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

後から手動で Private DNS Zone を作成して、必要な A レコードを追加して、VNET にリンクすれば同じですが地味に手間です。ただし ARM Template や Terraform を使う場合には手動で書く必要がありそうでした。

Private Endpoints のデプロイが完了すると以下のような状態になります。

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

他の Private Link 対応サービスと基本的には同じですが VNET 内には Network Interface が追加されて、そいつに Private IP が割り振られています。そこから先は Private Link でいい感じに各 Azure Resources に直接繋がるようになっているみたいです。

App Service の場合は公開側と SCM 側で 2 つホスト名が振られるようになっているので、Private Endpoints でもそれぞれのホスト名が同じ Private IP を指すように設定されます。

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

ここまで完了すれば、VNET に別途 VM をデプロイすれば簡単に動作が確認できます。

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

App Service のホスト名ですが Private IP でアクセスされていることが確認できます。もちろん VNET 外から同じアドレスにアクセスすると 403 エラーが返ってきます。

Service Endpoints との違いが判らなくなりそうですが、Service Endpoints は Public IP を使ってアクセスされるのに対して、Private Endpoints では Private IP でアクセスされているというのが特徴です。これまでも Azure Services へのアクセスに関しては、ルーティングによってインターネットには出ないと言われてましたが、Private Link ではその辺りの挙動が明確になっています。

Private IP でのアクセスになるので、NSG などで Global IP へのアクセスを全て遮断も出来ますね。

Regional VNET Integration は Private DNS Zone に非対応

今回確認用に VM を使いましたが、本来なら App Service / Azure Functions から利用したい人が多いはずです。しかし現在 Regional VNET Integration では Private DNS Zone を利用した解決が行えないため、Private Link を使ったアクセスが実質不可能です。

試しに VNET に入れてある App Service からホスト名の解決を行いましたが、Global IP が返ってきます。

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

GA で新しく追加された WEBSITE_VNET_ROUTE_ALL オプションを使っても結果は同じです。古い Gateway が必要な VNET Integration の場合は動作するようですが、そっちは使いたくありません。

もちろんこの問題は認識されているので対応されるはずですが、試してみたかったので裏技っぽい方法で動作を確認してみることにしました。Private DNS Zone が見えないだけでネットワーク的には問題ないので、確認自体は比較的簡単です。まずはサクッと curl で試しました。

curl https://privatelink-webapp.azurewebsites.net/ -s --resolv privatelink-webapp.azurewebsites.net:443:10.0.0.4 --dump-header -

--resolv オプションを使うと hosts ファイルに追加したのと同じ効果が得られるようです。

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

問題なく HTTPS で App Service にアクセス出来ています。単純に Host ヘッダーを追加する方法の場合は、SNI を使っている大半の App Service では TLS 接続を確立できないためエラーになります。

ただし .NET Core の HttpClient の場合は TLS 接続の確立時に Host ヘッダーの値を見る実装になっているので、以下のようなコードでアクセスが可能です。

class Program
{
    static async Task Main(string[] args)
    {
        var httpClient = new HttpClient();

        var request = new HttpRequestMessage(HttpMethod.Get, "https://10.0.0.4/");

        request.Headers.Host = "privatelink-webapp.azurewebsites.net";

        var response = await httpClient.SendAsync(request);
        var content = await response.Content.ReadAsStringAsync();

        Console.WriteLine(content);
    }
}

なのでちょっとコードを書けば対応は出来ますが、Regional VNET Integration が Private DNS Zone に対応すれば済む話なので今回のように動作確認ぐらいで留めておくべきでしょう。

Private Link 追加後のデプロイ方法

今回 App Service の Private Link サポートを試して気になったのが、SCM 側も Private Link の対象になるので Visual Studio や Azure Pipelines がデプロイに使っている API に VNET 外からアクセス出来ないことです。

試しに Visual Studio からデプロイしようとすると、403 エラーが返ってきて失敗します。

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

恐らくは FTP / FTPS や古いエンドポイントを使った MS Deploy といった古典的な方法なら動作する可能性はありますが、2020 年に使うようなものではないので、もっとモダンな方法で解決する必要があります。

同一 VNET 内に Self-hosted Agent をデプロイすれば解決する話でしょうが、それをメンテナンスするといった作業をしたくないのでもっと良い方法が必要です。

Run From Package で URL を指定してデプロイ

最初に思いついたのは Run From Package でも URL を指定してデプロイする方法です。

WEBSITE_RUN_FROM_PACKAGE に Service Endpoints で保護した Blob Storage への URL をセットすることで、外部からのパッケージ保存とデプロイを実現します。

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

良くサンプルやドキュメントで見る Run From Package では SAS を使ってエンドポイントを保護していましたが、Regional VNET Integration と Service Endpoints を組み合わせているので SAS は不要です。

設定を保存して App Service が再起動されると、指定した URL のアプリケーションがデプロイされます。

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

Run From Package の処理は Kudu が担っているので、Regional VNET Integration 経由で保護された Blob Storage にアクセスできるという仕組みでした。

この方法はこれまでのように Zip をプッシュするのではなく、App Service が Zip をプルしに行くのが特徴です。再起動の度に Zip をダウンロードしに行くので、サイズが大きい場合にはオーバーヘッドが乗っかってくるのと、コールドスタートのパフォーマンスに影響が出ます。

Azure Resource Manager ベースでのデプロイ

もう一つは Azure Resource Manager に用意されている Zip Deploy の API を使ってデプロイする方法です。

以前に ARM Template で Zip Deploy を実現する時に使った API と同じものを使いますが、これもまたドキュメントには書いていない API になります。Private Link の GA 前には提供してほしいところです。

リクエストは非常にシンプルで、以下のようなペイロードを用意して PUT で投げるだけです。

{
  "properties": {
    "packageUri": "https://appservicezipdeploy.blob.core.windows.net/deployment/WebApplication60.zip"
  }
}

Kudu の Zip Deploy API とは異なり URL で Zip を指定しないといけないので、Service Endpoints で保護された Blob Storage にアップロードしておくのが無難です。

Azure CLI の REST コマンドがこういった非公開 API を試すときに便利です。以下のようなコマンドで ARM API 経由での Zip Deploy が実行できます。

az rest -m PUT -u "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/PrivateLink-RG/providers/Microsoft.Web/sites/privatelink-webapp/extensions/ZipDeploy?api-version=2019-08-01" -b @body.json

実行に成功するとほぼ空っぽのレスポンスが返ってきますが、裏では非同期でデプロイが行われています。

デプロイのログは ARM Explorer などで確認できるようになっています。

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

デプロイが完了すると、ちゃんとアプリケーションが動作していることが確認できます。

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

この ARM の Zip Deploy API は裏で Kudu の API を呼んでいるだけなので、挙動自体は全く同じです。なので WEBSITE_RUN_FROM_PACKAGE1 を設定しておくと、Run From Package として動作します。

現状、このような動作をする Azure Pipelines の Task は存在しないので、コマンドで処理をガリガリ書く必要がありそうです。実際のところ、どのようにデプロイされるのを想定していたのか気になります。

Azure Functions v3 で快適に REST API を書くためのライブラリを公開した

前にサンプルコード的に書いた REST API を作りやすくするコードを、ちゃんとライブラリとして切り出して NuGet で公開するようにしました。

使い方とかは前回と変わっていないので、新規追加した点についてだけ書きます。

リポジトリは前回と同じなので、興味がある方は適当に参照してください。サンプルコードも少しだけ用意していますが、恐らくは実際に試した方がわかりやすいと思います。

NuGet パッケージは以下になります。.NET Core 3.1 をターゲットにしているので Azure Functions v3 専用になっています。v2 への対応も出来なくはないですが、v3 を使うのが正解なのでこうしています。

名前空間とパッケージ名が微妙なので変更したい気持ちもありますが、良いのが浮かびませんでした。

プロジェクトの作成とパッケージインストール

使う前の準備としては Azure Functions v3 プロジェクトを作成して、NuGet からパッケージをインストールします。エラーが出る場合は Azure Functions のバージョンを間違っているか、テンプレートが古いです。

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

インストール後は適当に HttpTrigger な Function を作成して、以下のようにクラスを修正します。具体的には static を外して HttpFunctionBase を継承するようにします。

public class Function1 : HttpFunctionBase
{
    public Function1(IHttpContextAccessor httpContextAccessor)
        : base(httpContextAccessor)
    {
    }
}

コンストラクタの追加は QuickFix や ReSharper がいい感じにやってくれるはずです。

これで準備が出来たので、実際に Function を定義して実装を書いていくことになります。ユースケース毎で適当に紹介していくことにします。

基本的な ActionResult ヘルパー

テンプレートから HttpTrigger な Function を作成すると以下のようになると思いますが、理解のためにこれを HttpFunctionBase を使って書き換えてみます。

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

        string name = req.Query["name"];

        string requestBody = await new StreamReader(req.Body).ReadToEndAsync();
        dynamic data = JsonConvert.DeserializeObject(requestBody);
        name = name ?? data?.name;

        string responseMessage = string.IsNullOrEmpty(name)
            ? "This HTTP triggered function executed successfully. Pass a name in the query string or in the request body for a personalized response."
            : $"Hello, {name}. This HTTP triggered function executed successfully.";

        return new OkObjectResult(responseMessage);
    }
}

実際に書き換えたコードが以下の通りです。何故か毎回書いていた OkObjectResultOk メソッドの呼び出しに変わったり、クエリ文字列には Request.Query でアクセスしています。

あまり関係ないですが、おまけでリクエストボディを Azure Functions のバインディングに任せることで、JSON デシリアライズ周りのコードをごっそりと削除しています。

public class Function1 : HttpFunctionBase
{
    public Function1(IHttpContextAccessor httpContextAccessor)
        : base(httpContextAccessor)
    {
    }

    [FunctionName("Function1")]
    public IActionResult Run(
        [HttpTrigger(AuthorizationLevel.Function, "get", "post", Route = null)] dynamic data,
        ILogger log)
    {
        log.LogInformation("C# HTTP trigger function processed a request.");

        string name = Request.Query["name"];

        name = name ?? data?.name;

        string responseMessage = string.IsNullOrEmpty(name)
            ? "This HTTP triggered function executed successfully. Pass a name in the query string or in the request body for a personalized response."
            : $"Hello, {name}. This HTTP triggered function executed successfully.";

        return Ok(responseMessage);
    }
}

HttpFunctionBase は ASP.NET Core MVC の ControllerBase と同じようなプロパティやメソッドを提供するようにしているので、ASP.NET Core MVC で Web API を書いた人なら取っ付きやすいと思います。

一部しか使わなかったですが、IActionResult のヘルパーはリダイレクト以外は大体用意しています。

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

Web API でリダイレクトが必要な場面が正直思い浮かばなかったのと、非同期 HTTP API 周りのパターンは CreatedAtFunctionAcceptedAtFunction といったメソッドで対応可能なのが理由です。

バリデーションの追加と結果の返却

リクエストのバリデーションは絶対に必要なのにシンプルに実装する方法がなくて地味に辛かったですが、TryValidateModel を使うことで Core MVC と同じような動作が行えます。

ここまでは前のバージョンと同じですが、クライアントに対してバリデーション結果を RFC 7807 に沿った形式で返せるようになりました。このあたりも Core MVC と同じです。

public class Function2 : HttpFunctionBase
{
    public Function2(IHttpContextAccessor httpContextAccessor)
        : base(httpContextAccessor)
    {
    }

    [FunctionName("Function2")]
    public IActionResult Run(
        [HttpTrigger(AuthorizationLevel.Function, "post")] SampleRequest model,
        ILogger log)
    {
        if (!TryValidateModel(model))
        {
            return ValidationProblem();
        }

        return Ok(model);
    }
}

public class SampleRequest
{
    [Required]
    public string Name { get; set; }

    [Required]
    [Range(0, 100)]
    public int? Age { get; set; }
}

ValidationProblem のパラメータを全て省略すると、デフォルトの ModelState を使うようになっているので簡単です。バリデーション結果をサクッと返す場合に使えます。

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

適当に空っぽのデータを投げた時に返ってきたバリデーション結果が上になります。あまり使われていない気がしますが、標準的なフォーマットに乗っかっておくと後が楽でしょう。

Function URL の柔軟な生成

これも地味に使い勝手が悪かった URL の生成周りですが、Azure Functions の実装を調べると Function 名でルートが追加される仕組みになっていたので、それを利用すると簡単に URL を生成出来ます。

API という観点では 201 Created や 202 Accepted で使われることが多いと思うので、専用の CreatedAtFunctionAcceptedAtFunction メソッドを用意しています。

public class HttpApi : HttpFunctionBase
{
    public HttpApi(IHttpContextAccessor httpContextAccessor)
        : base(httpContextAccessor)
    {
    }

    [FunctionName(nameof(Function3))]
    public IActionResult Function3(
        [HttpTrigger(AuthorizationLevel.Function, "post")] HttpRequest req,
        ILogger log)
    {
        return CreatedAtFunction(nameof(Function4));
    }


    [FunctionName(nameof(Function4))]
    public IActionResult Function4(
        [HttpTrigger(AuthorizationLevel.Function, "get")] HttpRequest req,
        ILogger log)
    {
        return Ok();
    }
}

非同期 HTTP API ではよくある、クライアントには素早く 201 Created を返しつつ Location ではステータス確認用の URL を返すパターンが一瞬で用意できました。

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

認証に関しては別の話なのと Function Key をクエリ文字列に含める方法はいまいちだと思っているので、基本は Easy Auth を使った Bearer Token 認証か Function Key を HTTP ヘッダーに含める方法が良いです。

最後に HttpTriggerRoute も指定した時の例を紹介します。Route にはパラメータを定義することが出来ますが、以下のようなコードでその辺りもいい感じに解決できます。

public class HttpApi : HttpFunctionBase
{
    public HttpApi(IHttpContextAccessor httpContextAccessor)
        : base(httpContextAccessor)
    {
    }

    [FunctionName(nameof(Function3))]
    public IActionResult Function3(
        [HttpTrigger(AuthorizationLevel.Function, "post", Route = "start")] HttpRequest req,
        ILogger log)
    {
        return CreatedAtFunction(nameof(Function4), new { id = Guid.NewGuid().ToString() }, null);
    }


    [FunctionName(nameof(Function4))]
    public IActionResult Function4(
        [HttpTrigger(AuthorizationLevel.Function, "get", Route = "status/{id}")] HttpRequest req,
        string id,
        ILogger log)
    {
        return Ok();
    }
}

単純に CreatedAtFunction のオーバーロードで routeValues を指定するだけなので簡単です。

実行してみると、ちゃんと指定したパラメータを使って URL が生成されています。

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

一応は HttpFunctionBase には動作する形で IUrlHelper を用意しているので、Function 名 == ルート名ということを知っていれば、自由に URL を生成できます。

暇なときにでも拡張メソッドを足してみるかという気持ちです。必要なものは継続的に足していきます。

Terraform Provider for Azure を 2.0 へアップグレードする

少し前に Terraform Provider for Azure を 2.0 へアップグレードしようとして、軽い気持ちでバージョンを上げたらいきなりエラーが出たり、terraform plan で予期しない変更が大量に出て心が折れました。

即オチ 2 コマみたいなツイートをしてしまうぐらいでしたが、冷静に対応すると大したことなかったです。

Azure Provider のドキュメントは既に 2.0 ベースになっているので、早いうちに対応しておいた方が良いです。アップグレードガイドに従って対応すれば、自分のようにいきなりエラーではまることもないです。

今のドキュメントでは Provider のバージョンは明示的に指定した方が良いと書いてあったので、provider での定義は大体以下のようになるでしょう。

既に 2.1.0 がリリースされているので、バージョンだけ少し変更しています。

provider "azurerm" {
  version = "=2.1.0"
  features {}
}

上のように変更するだけで 2.0 へのアップグレードが完了する環境もあるはずですが、Azure Functions を使っている場合には terraform plan で replaced が大量に出てきて焦ることになります。

実際に Azure Functions を含む定義に対して 1.44.0 から 2.1.0 へのアップグレードを行うと、大量にリソースの作り直しが行われるプランが出てきます。

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

本番で利用していた Terraform の場合は Azure Functions をたくさん使っていたので、プランを見た瞬間に心が折れてしまいましたが、作り直しになった原因を調べると全て Storage Account の種類でした。

account_kind の値が Storage から StorageV2 に変わったので Storage Account が作り直しになり、関連するリソースが全て作り直しになるという話でした。

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

このようなプランになった理由は、バージョン 2.0 から一部のプロパティのデフォルト値が変わっていることにありました。アップグレードガイドからデフォルト値が変更されたリソースを引っ張ってきました。

Resource: azurerm_application_gateway
The default value for the body field within the match block will change from * to an empty string.

Azure Resource Manager: 2.0 Upgrade Guide - Terraform by HashiCorp

Resource: azurerm_availability_set
The default value for the field managed has changed from false to true.

Azure Resource Manager: 2.0 Upgrade Guide - Terraform by HashiCorp

Resource: azurerm_storage_account
The default value for the field enable_https_traffic_only has changed from false to true.
The default value for the field account_kind has changed from Storage to StorageV2.

Azure Resource Manager: 2.0 Upgrade Guide - Terraform by HashiCorp

Application Gateway と Availability Set は良いとして、Storage Account は account_kind のデフォルト値が Storage から StorageV2 に変わっています。そして account_kind は変更されると作り直しになるように設計されているので、明示的に指定しないと上のようなプランになります。

なので、今回の例の場合は provider の変更と同時に account_kindStorage を追加すれば解決します。enable_https_traffic_onlytrue が望ましいのでそのままにしておきます。

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

新規で作成する場合でも明示的に指定しない限り StorageV2 になりますが、StorageV2 は課金方法が少し変わっているので、ワークロードによっては割高になる可能性があるので注意が必要です。

修正後に terraform plan を実行すると、replaced は発生せず enable_https_traffic_only の変更だけになりました。これまで enable_https_traffic_onlyfalse だったのがあり得ないので、1.x 系を使い続ける場合には注意したいです。

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

デフォルト値が変わった部分以外は廃止扱いだったプロパティがごっそり削除されているぐらいなので、大抵は terraform plan などで容易に捕捉可能です。

単純に削除していけば大体問題ないので、このデフォルト値の変更だけ気を付けましょう。

補足 : Storage V2 への移行方法

本来なら Storage から StorageV2 への移行はダウンタイム無しで行えるはずですが、Terraform の仕様によって現在は作り直し扱いになっているようです。

しかし ARM レベルでは問題なく対応可能なので、Azure CLI や Portal から StorageV2 に手動で変更してから Terraform の定義を直すことでダウンタイム無しで変更が行えます。

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

将来的には StorageV2 への移行がすんなり行えるようになると思いますが、現状は手動での対応が必要になりそうです。1 回だけなので我慢は出来るレベルです。

App Center と Azure Pipelines を利用した WPF (.NET Core) アプリケーション開発の効率化

App Center SDK 3.0.0 で WPF (.NET Core) に正式対応したのと、ずっと SR を投げていた Azure Pipelines の問題が解消されたので、この二つを使って WPF アプリケーションのビルドを自動化しました。

例によって対象の WPF アプリケーションは WinQuickLook です。すでにこのアプリケーションは .NET Core への移行を行っていて、Desktop Bridge を使って Windows Store へ公開しています。

WPF アプリケーションのモニタリングに対応したサービスは App Center ぐらいしか選択肢がないので、正式に対応されたのは非常に喜ばしいです。

.NET Core への移行に関する話は以前に書いた以下のエントリを参照してください。Tiered Compilation と ReadyToRun を有効にしていますが、Desktop Bridge との組み合わせは設定が少し複雑でした。

今回の目標は App Center を使ってアプリケーションのクラッシュログを収集することと、Azure Pipelines を使って署名付きの msixupload ファイルを作成して、簡単に Windows Store へのアップロードが行える状態にすることです。特に署名周りの自動化は必須でした。

リリース用ビルドは GitHub Release を利用して、タグが打たれたタイミングで自動でバージョン付きでビルドを行います。これぐらいやっておかないとリリースの手間が省けないです。

App Center を組み込む

WPF アプリケーションへの App Center の組み込みは、UWP などと同じなので迷うことはないはずです。ドキュメントも用意されているので WPF / Win Forms のどちらでも簡単に対応できます。

手順を要約すると、以下のパッケージをインストールした後に、OnStartup をオーバーライドして初期化コードを追加するだけです。バージョンは 3.0.0 以上をインストールします。

Analytics だけではなく Crashes も WPF がサポートされているので、以下のような初期化コードを追加すればクラッシュログも簡単に App Center で確認できるようになります。

public partial class App
{
    protected override void OnStartup(StartupEventArgs e)
    {
        base.OnStartup(e);

        AppCenter.Start("00000000-0000-0000-0000-000000000000", typeof(Analytics), typeof(Crashes));
    }
}

以前は Application Insights が提供されていましたが、App Center を使うようにした方が良いでしょう。

設定後、アプリケーションを立ち上げると App Center で各メトリックが確認できるようになります。

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

ちゃんとクラッシュログも収集されるので、不具合の特定と修正が格段に行いやすくなりました。UWP では Partner Center からある程度見れましたが、WPF の場合は何のログも残らないので苦労してました。

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

まだ標準で送信されるイベントは少ないので、必要な場所でカスタムイベントを送信するようにすれば良さそうです。個人的にはクラッシュログが見れる時点でかなり楽になりました。

Azure Pipelines で CI を組む

Windows Store へアプリケーションをアップロードする際には署名された msixupload ファイルが必要なので、当然ながら証明書を用意する必要があります。

Visual Studio だとあまり意識しなくても行えますが、署名周りはかなり属人化してしまうので自動化した方が良いです。UWP / MSIX に関しては Azure Pipelines を前提としたドキュメントが公開されてます。

ドキュメントを読んでも MSBuild に渡すパラメータが結構わかりにくいですが、以下のようなパラメータを渡せば署名付きの msixupload ファイルが作成されます。

ちなみに msixupload の作成は .NET Core CLI では行えないので、MSBuild を使う必要があります。

msbuild WpfApp.wapproj /p:Configuration=Release /p:UapAppxPackageBuildMode=StoreUpload \
    /p:AppxBundlePlatforms="x86|x64" /p:AppxPackageDir=".\packed" /p:AppxBundle=Always \
    /p:AppxPackageSigningEnabled=true /p:PackageCertificateThumbprint="" \
    /p:PackageCertificateKeyFile=WpfApp.pfx /p:PackageCertificatePassword=PFX_PASS

MSBuild を使って msixupload の作成が出来れば、後は Azure Pipelines の YAML 定義を書くだけです。

msixupload の作成には証明書とパスワードが必要なので、それぞれ Secure files と Variable groups を使って Azure Pipelines 上で実現します。

パスワードなどの機密情報を扱う場合には、そのまま Variable groups を使って機密情報のフラグを立てるシンプルな方法がありますが、今回は Key Vault を参照するようにしてみました。

Azure Subscription の追加と、参照する Key Vault の Access Policy の設定が必要ですが、Access Policy で許可していないユーザーやサービスプリンシパル以外はアクセス出来ないので安全です。

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

Variable groups に Key Vault をリンクすると、どの設定を参照するかを設定できます。

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

実行時のみ値を参照するようになっているので、安心して保存することが出来ますが、もちろん Key Vault 側の Access Policy がザルになっていては意味がないので注意します。

なんやかんやで、作成した YAML は以下のようになりました。msixupload のビルドには MSBuild が必要ですが、WPF (.NET Core) のビルドには最新の .NET Core が必要なので明示的にインストールしています。NuGet もデフォルトで入っているバージョンではエラーになったので最新を入れています。

trigger:
- master

variables:
- group: Secrets
- name: BuildConfiguration
  value: Release
- name: DotNetSdkVersion
  value: 3.1.x
- name: BundlePlatforms
  value: x86|x64

pool:
  vmImage: 'windows-latest'

steps:
- task: DownloadSecureFile@1
  name: signingCert
  inputs:
    secureFile: 'WpfApp.pfx'

- task: UseDotNet@2
  inputs:
    packageType: 'sdk'
    version: '$(DotNetSdkVersion)'

- task: NuGetToolInstaller@1
  inputs:
    versionSpec: '5.x'

- task: NuGetCommand@2
  inputs:
    command: 'restore'
    restoreSolution: '**/*.sln'
    feedsToUse: 'select'
    verbosityRestore: 'Normal'

- task: MSBuild@1
  inputs:
    solution: '**/*.wapproj'
    configuration: 'Release'
    msbuildArguments: '/p:UapAppxPackageBuildMode=StoreUpload
                       /p:AppxBundlePlatforms="$(BundlePlatforms)"
                       /p:AppxPackageDir="$(Build.SourcesDirectory)/packed"
                       /p:AppxBundle=Always
                       /p:AppxPackageSigningEnabled=true
                       /p:PackageCertificateThumbprint=""
                       /p:PackageCertificateKeyFile="$(signingCert.secureFilePath)"
                       /p:PackageCertificatePassword="$(PfxPassword)"'

- publish: packed
  artifact: msix

この定義を使ってビルドを実行すると、Pipeline Artifacts に msixupload などのビルド結果が保存されます。

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

Windows Store へのアップロードも以下のような拡張をインストールすれば、Azure Pipelines から実行できるはずですが Azure AD 周りの設定がめんどくさそうだったので試してはいません。

暇なときにでも試してみようかと思います。とりあえず拡張のインストールだけは行いました。

GitHub Release を使ってバージョンを自動的に付ける

最後は GitHub Release を使って、自動的にタグ名からバージョンを付けるようにしてみます。これまでもタグ名からバージョンを自動で付与する定義はたくさん書いてきましたが、Desktop Bridge を使っている場合には少し手間がかかります。

理由としては Desktop Bridge でパッケージ化したアプリケーションのバージョンと、アセンブリにメタデータとして付与されているバージョンは別ものだからです。

アセンブリにバージョンを付ける場合は MSBuild のパラメータに /p:Version=1.0.0 のように付ければ良いですが、パッケージ化したアプリケーションのバージョンは Package.appxmanifest で定義されています。なのでビルド中にバージョンを書き換えてあげる必要があります。

- powershell: 'echo "##vso[task.setvariable variable=ApplicationVersion]$($env:Build_SourceBranchName.Substring(1))"'
  displayName: 'Set ApplicationVersion'

- powershell: |
    [Reflection.Assembly]::LoadWithPartialName("System.Xml.Linq")
    $path = "WpfApp/Package.appxmanifest"
    $doc = [System.Xml.Linq.XDocument]::Load($path)
    $xName = [System.Xml.Linq.XName]"{http://schemas.microsoft.com/appx/manifest/foundation/windows10}Identity"
    $doc.Root.Element($xName).Attribute("Version").Value = "$(ApplicationVersion).0";
    $doc.Save($path)
  displayName: 'Update Package Manifest'

- task: MSBuild@1
  inputs:
    solution: '**/*.sln'
    configuration: $(BuildConfiguration)
    msbuildArguments: '/p:Version="$(ApplicationVersion)"
                       /p:UapAppxPackageBuildMode=StoreUpload
                       /p:AppxBundlePlatforms="$(BundlePlatforms)"
                       /p:AppxPackageDir="$(Build.SourcesDirectory)/packed"
                       /p:AppxBundle=Always
                       /p:AppxPackageSigningEnabled=true
                       /p:PackageCertificateThumbprint=""
                       /p:PackageCertificateKeyFile="$(signingCert.secureFilePath)"
                       /p:PackageCertificatePassword="$(PfxPassword)"
                       /verbosity:minimal'
  displayName: Build MSIX Package

複雑そうな PowerShell スクリプトを書いていますが、公式ドキュメントに書いてある内容をほぼそのまま持ってきただけです。タグ名からバージョン部分を ApplicationVersion という変数名に保存して、マニフェストと MSBuild のパラメータに渡しています。

実際に WinQuickLook で GitHub Release を作成してビルドを行ってみましたが、ちゃんと Desktop Bridge 側のバージョンとアセンブリ側のバージョンが一致した形で msixupload が作成されました。

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

これまではアセンブリ側のバージョンは捨てて、Desktop Bridge 側だけ手動で変えていたのですが、GitHub Release と Azure Pipelines を使って自動化したのでコードとの関連付けとミスを防ぐことが出来ます。

アセンブリのバージョンも適切に設定されるようになったので、App Center からバージョン別にテレメトリを確認できるようになりました。

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

これまでは全てが 1.0.0 になっていたのですが、UWP と同様の挙動になりました。クラッシュログや利用頻度をバージョン毎に確認できるようになって捗ります。

やはり Windows Store へのアップロードも自動で行いたくなってきました。暇なときにやります。

Durable Functions を使って実行時間が長い処理の進捗をクライアントに返す

既に何回も書いていますが Durable Functions は非常に便利で、並列処理や時間のかかる処理をスケーラブルかつ高い信頼性を保ったまま実行できます。

並列処理の場合は全体として処理時間は短くなる傾向にあるのでまだ良いのですが、時間のかかる処理を書いた場合にはどこまで処理が完了したのかというモニタリングが結構難しいです。

ドキュメントにもある非同期 HTTP API パターンで完了するのをポーリングで待機することは簡単に出来ますが、当然ながらそこには処理の進捗などは含まれていないです。

実際に最近書いた処理が入力によっては処理に時間がかかるもので、進捗がわからないと利用者側が不安になりそうだったので、いくつかの実装方法を検討して試しました。

Application Insights に送信されたログを KQL で調べることもできますが、それを組み込むのは結構面倒なのでシンプルな方法で攻めました。

Custom Status を利用するパターン

最初はだれでも考え付く Custom Status を使った方法です。ドキュメントにも Custom Status を使ってオーケストレーターの状態を返す方法が載っているので、割と手堅い方法ではあります。

以下のような単純なオーケストレーターの場合は、適度なタイミングで Custom Status に設定するだけなので実装も簡単です。とりあえず雑にパーセンテージを返すようにしました。

public class Function1
{
    [FunctionName("Function1")]
    public async Task<List<string>> RunOrchestrator(
        [OrchestrationTrigger] IDurableOrchestrationContext context)
    {
        var inputs = context.GetInput<string[]>();
        var outputs = new List<string>();

        for (var i = 0; i < inputs.Length; i++)
        {
            // Replace "hello" with the name of your Durable Activity Function.
            outputs.Add(await context.CallActivityAsync<string>("Function1_Hello", inputs[i]));

            context.SetCustomStatus(new { progress = (i + 1) * 100 / inputs.Length });
        }

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

Custom Status はサイズ制限以内でシリアライズ可能なデータなら設定できるので、もっと複雑な情報を返すこともできます。とはいえ乱用しないように注意したいところです。

実際に上のオーケストレーターを動かしてステータスチェック API を叩くと値が返ってきます。

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

実行時間の長い処理の場合は、このステータスチェック API を叩き続けることになるので、そこに進捗の情報が含まれているというのは扱いやすいです。

Durable Entities を利用するパターン

シンプルなオーケストレーターの場合は単純でしたが、サブオーケストレーターを使っている場合には Custom Status を使う方法は利用できません。

理由としてはオーケストレーター間で Custom Status は分離されているので、サブオーケストレーター内で Custom Status を設定しても親のオーケストレーターのステータスチェック API には含まれないからです。

オーケストレーター間でデータを安全に保持する方法として Durable Entities を使うことにしました。

Durable Entities では受信したメッセージは 1 つずつ順番に実行されます。なので複数のメッセージが同時に送信されても競合することなく、状態のアップデートが可能というメリットがあります。

サブオーケストレーターを使う場合は並列実行させることが多いはずなので、Durable Entities が持つこの特徴はかなり都合が良いものです。今回は以下のような Entity を用意しました。

[JsonObject(MemberSerialization.OptIn)]
public class ProgressEntity
{
    [JsonProperty("currentItem")]
    public int CurrentItem { get; set; }

    [JsonProperty("totalItem")]
    public int TotalItem { get; set; }

    public int Progress => CurrentItem * 100 / TotalItem;

    public void Reset(int totalItem)
    {
        CurrentItem = 0;
        TotalItem = totalItem;
    }

    public void Increment() => CurrentItem += 1;

    [FunctionName(nameof(ProgressEntity))]
    public static Task Run([EntityTrigger] IDurableEntityContext context) => context.DispatchAsync<ProgressEntity>();
}

もっと複雑な状態を持たせることも出来ますが、考え方は変わらないのでシンプルに全体の件数と処理済みの件数を保持して、パーセンテージだけを返す Entity になります。

それぞれのオーケストレーターでは Entity に対してメッセージを投げるだけです。最初に全体の処理件数をセットしたいので、親オーケストレーターでは最初に Reset を投げています。このタイミングで新しい Entity が作成されます。

public class Function2
{
    [FunctionName("Function2")]
    public async Task<List<string>> RunOrchestrator(
        [OrchestrationTrigger] IDurableOrchestrationContext context)
    {
        var inputs = context.GetInput<string[]>();
        var outputs = new List<string>();

        await context.CallEntityAsync(new EntityId(nameof(ProgressEntity), context.InstanceId), "Reset", inputs.Length);

        foreach (var input in inputs)
        {
            // Replace "hello" with the name of your Durable Activity Function.
            outputs.Add(await context.CallSubOrchestratorAsync<string>("Function2_Sub", input));
        }

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

    [FunctionName("Function2_Sub")]
    public async Task<string> RunSubOrchestrator(
        [OrchestrationTrigger] IDurableOrchestrationContext context)
    {
        var input = context.GetInput<string>();

        var output = await context.CallActivityAsync<string>("Function2_Hello", input);

        context.SignalEntity(new EntityId(nameof(ProgressEntity), context.ParentInstanceId), "Increment");

        return output;
    }
}

サブオーケストレーターでは処理が完了したタイミングで SignalEntity でメッセージを非同期で投げています。EntityId として親の InstanceId を使っているので、ParentInstanceId を見るようにします。

Entity の状態は API でも確認できるので、直接読み取ってみます。API は以下のドキュメントにあります。

実行すると Entity の状態がちゃんと返ってきました。シリアライズ対象のデータのみ返ってきます。

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

実際には Custom Status を使った時のように API を用意して、欲しいデータだけ返すという方法が現実的です。Entity の状態は InstanceId があれば取れるので、作るのは簡単です。

[FunctionName("Function2_HttpPolling")]
public async Task<IActionResult> HttpPolling(
    [HttpTrigger(AuthorizationLevel.Anonymous, "get")]
    HttpRequest req,
    [DurableClient] IDurableClient starter)
{
    var instanceId = req.Query["instanceId"];

    var state = await starter.ReadEntityStateAsync<ProgressEntity>(new EntityId(nameof(ProgressEntity), instanceId));

    return new OkObjectResult(new { state.EntityState.Progress });
}

上の API を実行すると、今度はパーセンテージだけが返ってくるようになります。

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

本来なら Durable Functions のステータスチェック API をオーバーライドするのが正しい方向ですが、カスタマイズではなく置き換えになってしまうので少し面倒です。

仕事では Durable Entities を使って実装しましたが、結局は標準の実装を捨てて独自に API を用意しました。

SignalR Service を利用するパターン

これまでは非同期 HTTP API のパターン上で実装してきましたが、ポーリングではなくプッシュで情報が欲しいときもあるはずです。多少毛色が異なりますが、SignalR Service を使った方法も試しました。

SignalR Service の出力バインディングを使えば、アクティビティ関数からも簡単にクライアントに対して情報をプッシュできるので、SPA と組み合わせている場合には割と有用な気がします。

public class Function3
{
    [FunctionName("Function3")]
    public async Task<List<string>> RunOrchestrator(
        [OrchestrationTrigger] IDurableOrchestrationContext context)
    {
        var inputs = context.GetInput<string[]>();
        var outputs = new List<string>();

        foreach (var input in inputs)
        {
            // Replace "hello" with the name of your Durable Activity Function.
            outputs.Add(await context.CallActivityAsync<string>("Function3_Hello", input));
        }

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

    [FunctionName("Function3_Hello")]
    public async Task<string> SayHello(
        [ActivityTrigger] string name,
        [SignalR(HubName = "progress")] IAsyncCollector<SignalRMessage> signalRMessages,
        ILogger log)
    {
        await Task.Delay(TimeSpan.FromSeconds(5));

        log.LogInformation($"Saying hello to {name}.");

        await signalRMessages.AddAsync(new SignalRMessage { Target = "updateStatus", Arguments = new object[] { name } });

        return $"Hello {name}!";
    }
}

適当に Vue.js で画面を作って、SignalR Service から送信されたデータを表示するようにしました。処理が完了したタイミングで、ほぼリアルタイムに更新されるのは便利です。

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

この方法の課題としては、処理全体の結果を把握するためには工夫が必要という点です。

別途これまで通りポーリングするか、オーケストレーターでエラーハンドリングをきっちり実装して通知させないと、画面側に処理が失敗したことを伝えられないです。上手く使い分けましょう。

Azure App Service の使い方が変わる Regional VNET Integration が GA に

プレビュー公開されてから 1 年近くが経過して、ようやく App Service の Regional VNET Integration が GA されました。ただし GA は Windows のみとなっているので Linux は未だプレビューのままです。

以前からドキュメントには Windows の Regional VNET Integration は、本番ワークロードで利用可能と書いてあったので既に本番向けのいろんな部分で利用を開始してました。

なかなか GA しなかった理由は、RFC1918 以外のアドレスへのトラフィックを VNET 経由にする機能を追加するのに時間がかかったからのようですね。

これまでは RFC1918 なアドレスか Service Endpoints を設定した場合のみ VNET 経由になるようでしたが、これからは App Service からの Outbound トラフィック全てを VNET 経由に出来るので、NSG や UDR を設定できるようになりました。

NSG を使うことで特定の App Service からの通信先を制限することができます。既にドキュメントは新しく追加された機能ベースにアップデートされていたので読んでおきましょう。

日本語版は致命的に間違っている部分があるので、基本的に新しい機能のドキュメントは英語版を参照しておいた方が良いです。これで個人的には完全に ASE を使う必要性がなくなりました。

早速追加されたトラフィックを全て VNET 経由にする機能を試しておきました。Regional VNET Integration については以前に書いた内容から変わってないので、説明は省略します。

App Service のベストプラクティスをまとめたエントリにも Regional VNET Integration と Service Endpoints について書いてあるので、こっちも参照してもらえれば良いです。

最近では VNET は土管的な使い方をしていて、基本は Regional VNET Integration と Service Endpoints を組み合わせたアクセス制限に活用してます。

作成したリソースは以下のようになっています。Service Endpoints の確認用に Storage Account と別の App Service を作成して、それぞれの Service Endpoints を Subnet に追加しています。

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

Service Endpoints の有無で挙動が結構変わるので運用時には注意したいところです。具体的には App Service の場合は送信 IP アドレスが IPv6 に変わったりします。

全ての App Service は Regional VNET Integration を有効にしています。まだ Azure Portal 上では Preview という表記のままですが、実際には GA しています。

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

App Service からの Outbound を全て VNET 経由にするためには WEBSITE_VNET_ROUTE_ALL = 1 を App Settings に追加する必要があります。プロセスレベルでの分離という感じが相変わらず凄いです。

こういったプラットフォームに関する設定は ARM Template や Terraform で管理するようにしましょう。本来なら設定として用意してほしいですが、App Settings に追加するというアドホックな方法が多いです。

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

保存してワーカープロセスが再起動されれば、全てのトラフィックは VNET 経由に変わるようです。

この設定を有効にしなくとも、Service Endpoints を追加しているサービスへのトラフィックの場合は NSG を使った制限が有効になるようでした。仕組みを考えると納得できる結果ではあります。

分かりやすいように App Service が参加している Subnet に対して、VNET からの Outbound トラフィックを全て拒否するルールを追加してみました。

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

反映されるまで数十秒はかかるようですが、App Service への通信が以下のように通らなくなりました。もちろん Storage やそれ以外の Web サイトに対してのトラフィックも全てです。

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

そこで例として、以下のように HTTPS だけを通すようなルールを追加してみます。

今回は例なので非常に大雑把なルールですが、本来ならフロントエンドやバックエンドというように用途別に Subnet を分けて、細かく NSG や Service Endpoints を使って制限するべきでしょう。

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

これで Storage へのアクセスで HTTP / HTTPS それぞれを試してみると、HTTP の方は通らないですが HTTPS は問題なく通るようになりました。

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

本来なら Service Tag を使って Azure のサービス単位で通信を許可することが出来るはずなのですが、今のところ試している限りでは正しく動作していないようでした。同じ NSG を VM が入ってる Subnet に当てたら動いたので、NSG が間違っていることはないはずです。

検証した限りでは Service Tag で設定した Destination が実質 Any として扱われているような挙動でした。

Azure サービスへのアクセス制限は Service Endpoints を使いつつ、不必要なポートへのトラフィックは NSG で塞ぐというのが現状の最善手な気がしています。全てのアプリケーションでここまでの制御が必要かは別の話ですが、必要なシチュエーションはありそうです。

App Service と ASP.NET Core で Application Insights を有効化する方法を整理する

Azure App Service に ASP.NET Core アプリケーションをデプロイする時には、必ず Application Insights を同時に有効化するようにしていますが、有効化の方法として 2 種類が存在しているので軽くまとめておきます。

自分でもこのあたりは割と混乱していたのと、App Service 側の挙動が微妙に変わっていたりしたので一度しっかりと確認しておきたかった部分です。

現時点では以下の 2 つの方法で Application Insights を有効化できるようになっています。ASP.NET Core アプリケーションと言ってますが、ASP.NET でもほぼ同じ扱いです。

  • ASP.NET Core に Application Insights をインストールする
  • App Service の Application Insights 設定から有効化する

最終的に得られる結果*1は同じですが、その実現方法が異なっています。結論から先に書くと ASP.NET Core アプリケーションを再ビルドして Application Insights を組み込めるなら、前者の方が良いです。

後者の App Service から Application Insights を有効化する方法は、アプリケーションの再ビルドが必要ではないというメリットはありますが、モジュールのアップデートが遅いので新機能を使いたい場合に不利です。あと、雑検証しかしてないですがリソースの消費が少し多いようでした。

それぞれの方法について有効化する手順を整理しておきます。アプリケーションに Application Insights をインストールする方法はちょいちょい手順が変わっているので、それのまとめ的な面もあります。

ASP.NET Core に Application Insights をインストールする

インストールの手順は比較的ドキュメントにまとまっているので、あらかじめ読んでおくと良いです。昔は UseApplicationInsights を初期化時に呼び出してましたが、いつの間にか廃止扱いになっていました。

Application Insights の NuGet パッケージはアップデートが結構多いですが、基本的には最新版を使っておけば良いです。今は 2 か月毎にアップデートされるスケジュールのようです。

パッケージをインストールすれば、後は Startup の中で AddApplicationInsightsTelemetry を呼び出せば完了です。有効化については二転三転している気がしますが、今はこれが正解です。

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

    public IConfiguration Configuration { get; }

    public void ConfigureServices(IServiceCollection services)
    {
        // UseApplicationInsights は使われなくなった
        services.AddApplicationInsightsTelemetry();

        services.AddControllersWithViews();
    }
}

この状態でデバッグ実行するとローカルモードで動作するので、特に appsettings.json などで Application Insights のキーを設定する必要はないです。

Visual Studio から設定すると、Azure 上の Application Insights と関連付けてしまうことがあるので、手動で行った方が安全だと思っています。作業的には難しくはないですし。

後は App Service にデプロイすれば動くようになりますが、ここからが罠が多い部分です。Azure Portal から新しく App Service を作ると同時に Application Insights も作ることが出来ますが、余計な設定がかなり追加されるので好きではありません。

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

昔は Application Insights の InstrumentationKey のみ設定されてましたが、今は以下のように大量のキーが追加されます。大半のキーはアプリケーションに Application Insights をインストールした場合には不要です。

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

この中で必要なのは APPLICATIONINSIGHTS_CONNECTION_STRING だけなので、他はすべて消してよいです。

昔は APPINSIGHTS_INSTRUMENTATIONKEY が必要でしたが、今は接続文字列を使うように変更されたようです。ドキュメントもちゃんとあります。

まだ Azure Portal が対応してないようなので、接続文字列だけだと Application Insights を設定した扱いにはならないようですが、利用する分には全く問題ありません。

ちなみに APPINSIGHTS_INSTRUMENTATIONKEY を追加すると以下のような表示になります。

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

とはいえ、ドキュメントには両方を同時に設定するのは避けた方が良いと書いてあるので従います。

これで Application Insights にテレメトリが送信されるようになります。App Settings でキーを保持しているので ARM Template や Terraform で設定出来るのと、アプリ側に保持する必要がないので管理が楽です。

App Service の Application Insights 設定から有効化する

Azure Portal にある Application Insights から有効化すると、予めシステムにインストールされている Application Insights Agent が使われます。

以下のようなわかりやすい UI が用意されているので、数回のクリックで有効に出来るのは便利です。

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

アプリケーションに組み込む必要がなく、再デプロイすらも不要ですが Application Insights Agent のバージョンは App Service で管理されているので、任意のタイミングでアップデートされます。

再ビルド無しに組み込むために HostingStartup や XDT などを組み合わせて実現されているので、その分リソース周りにオーバーヘッドが発生しているように見えます。

こういった部分を自分の管理下から外したくないので、アプリケーションに組み込んだ方が良いでしょう。

*1:Application Insights にテレメトリが送信されるという結果

Azure Functions v3 向けに REST API を作りやすくするサンプルコードを書いた

最近は Azure Functions でサクッと HttpTrigger を使って REST API を書くことが多いですが、ASP.NET Core のように API 実装に必要な機能が揃っていないので、毎回同じようなコードを書いて対応してました。

具体的にはリクエストをモデルにバインドする部分や、そのバインドとされたモデルに対するバリデーションといった機能です。ASP.NET Core なら何も考えなくても良い部分ですが、Azure Functions では対応していないので再利用可能なサンプルコードを用意しました。

使い方は簡単で、これまで HttpTrigger の実装を行っていた Function のクラスを HttpFunctionBase を継承するようにします。DI を使っているので、static を削除する必要があるはずです。

コンストラクタも追加が必要ですが、Visual Studio や ReSharper を使っていれば自動実装されるはずです。

public class Function1 : HttpFunctionBase
{
    public Function1(IHttpContextAccessor httpContextAccessor)
        : base(httpContextAccessor)
    {
    }

    [FunctionName("Function1")]
    public IActionResult Run(
        [HttpTrigger(AuthorizationLevel.Function, "post")]
        HttpRequest req,
        ILogger log)
    {
        return Ok("Hello, world");
    }
}

この HttpFunctionBase クラスは以下のような機能を実装しています。大体は ASP.NET Core の ControllerBase に近くなるように実装していますが、メソッド数は全然少ないです。

  • IActionResult を作成するためのヘルパーメソッド
  • Request / Response / User へ簡単にアクセスするためのプロパティ
  • モデルのバリデーションを実行し ModelState に結果を格納するメソッド

利用可能なメソッド一覧は実装を見てもらえれば良いです。REST API の実装に便利そうなメソッドのみをピックアップして実装しています。

サンプルコードに用意している例を挙げると、POST で送信されたデータを C# のクラスにバインドし、検証属性を使ってバリデーションを行うという非常に一般的な処理が以下のようにシンプルに書けています。

あまり知られていない気がしますが HttpTrigger を独自のクラスに付けると、いい感じにリクエストから値をバインドしてくれます。なので ASP.NET Core っぽく書けています。

public class Function1 : HttpFunctionBase
{
    public Function1(IHttpContextAccessor httpContextAccessor)
        : base(httpContextAccessor)
    {
    }

    [FunctionName("Function1")]
    public IActionResult Run(
        [HttpTrigger(AuthorizationLevel.Function, "post")]
        SampleModel model,
        ILogger log)
    {
        if (!TryValidateModel(model))
        {
            return BadRequest(ModelState);
        }

        return Ok(model);
    }
}

public class SampleModel
{
    [Required]
    public string Name { get; set; }

    [Range(100, 10000)]
    public int Price { get; set; }
}

ただしバリデーションは自動で行うことはできないため、明示的に TryValidateModel メソッドを呼び出しています。バリデーション結果は ASP.NET Core と同様に ModelState へ格納されるので、ヘルパーメソッドを使えばそのまま検証結果を返せます。

実際に空っぽのリクエストを投げてみると、以下のように 400 エラーと検証結果が返ってきます。

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

もちろん正しい形式のリクエストを投げると 200 とコンテンツが返ってくるようになります。

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

Azure Functions v3 から配列や enum のバインディングが正しく行えない問題が修正されたため、今回のような実装が利用できるようになりました。

地味に使い勝手が悪かったのが生の RequestResponse を触る場合でしたが、ASP.NET Core と同様のプロパティを用意して扱いやすくしました。ログイン中のユーザー情報も User でサクッと扱えます。

以下はレスポンスにヘッダーを付ける例ですが、特に違和感ないコードで完結しています。

public class Function2 : HttpFunctionBase
{
    public Function2(IHttpContextAccessor httpContextAccessor)
        : base(httpContextAccessor)
    {
    }

    [FunctionName("Function2")]
    public IActionResult Run(
        [HttpTrigger(AuthorizationLevel.Function, "get")]
        HttpRequest req,
        ILogger log)
    {
        Response.Headers.Add("Cache-Control", "no-cache");

        return Ok($"Now: {DateTime.Now}");
    }
}

実行してみると、ちゃんとヘッダーが付いていることが確認できます。単純なコードでした。

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

しっかり試したわけではないですが PipeWriter を使って応答に直接コンテンツを書き込むことも可能そうでした。その場合は voidTask を返す Function を定義すればよい感じでした。

余力があれば Azure Functions の DI / Configuration 周りを含めた NuGet パッケージとして公開します。