しばやん雑記

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

Durable Functions の E2E 分散トレーシングは最高だった話

恐らく最初に話をしてから 2 年近く経過している気がしますが、ついに待望の Durable Functions での E2E 分散トレーシングがプレビューとしてリリースされました。牛尾さんの努力の結晶とも言います。

α リリースと言われていますが、現時点での完成度はかなり高いと感じています。

以前に .NET Core での分散トレーシングについて調べた時には、Durable Functions については特に動きがないと書いていたのが懐かしいです。

大注目されている Durable Functions の場合は、Queue の値が隠蔽されているので比較的簡単に対応は行えそうですが、今のところは特に動きは無さそうです。

.NET Core における Activity と Application Insights による分散トレーシング - しばやん雑記

これで Durable Functions に必要だと考えていた 2 つの機能のうち、1 つに目処がついたことになります。残りの 1 つは Graceful Shutdown ですが、こっちも最近は多少動きが Azure Functions 全体としてあります。

大体の進捗は知っていましたが、実際に動くのを試したことはなかったので Acmebot で試しました。

分散トレーシングが特に必要な理由

Durable Functions はステートフルなオーケストレーターを簡単に記述できる一方で、その表現力の高さから非常に複雑なオーケストレーションも簡単に書けてしまいます。となると Application Insights でもオーケストレーター単位で確認する必要が出てきます。

しかしアクティビティは単独の Function として実行されるので、Application Insights 上では単なる呼び出しにしか見えません。アクティビティの自動リトライや Event Sourcing によるリプレイが組み合わさると、ログの量が非常に膨れ上がるため Application Insights から該当のログを追うのにも苦労することになります。

とどめに長時間実行や Consumption Plan でのスケーリングが追加されると、1 オーケストレーターの挙動を追うのはかなりの重労働になります。要するにめっちゃしんどかったです。

分散トレーシング対応バージョンをインストール

分散トレーシング対応バージョンを試す話に戻します。パッケージは別名で MyGet で配布されています。

バージョンが 2.2.0-alpha なので、Durable Functions v2.2.0 相当のようです。試した限りでは Analyzer 周りが古いようで、最新版だと出ない警告が出るようになりました。

牛尾さんの紹介記事では Visual Studio から NuGet パッケージソースを追加する手順でしたが、CI と環境のことを考えると nuget.config を作成してリポジトリに入れておくのが安全です。

<?xml version="1.0" encoding="utf-8"?>
<configuration>
  <packageSources>
    <!--To inherit the global NuGet package sources remove the <clear/> line below -->
    <clear />
    <add key="nuget" value="https://api.nuget.org/v3/index.json" />
    <add key="azure-appservice-staging" value="https://www.myget.org/F/azure-appservice-staging/api/v3/index.json" />
  </packageSources>
</configuration>

パッケージ名から分散トレーシング部分がプラガブルになったのかと思いましたが、実際には Durable Functions 自体のコードが含まれているので、既存の PackageReference は削除しないとエラーになります。

Acmebot では早速大量のエラーが出たので、最初は意味が分かりませんでした。

原因としては Durable Functions のアセンブリ名が変わっていた + 厳密名が付けられていたので同じ型が複数存在するというエラーになっていたようです。

アクティビティを型安全に呼び出すプロキシで Durable Functions を参照していたので発生しました。

バージョン違いぐらいなら何とかできますが、今回はどうしようもなかったので使っているライブラリ側に手を入れて対応しました。正式リリースの時には気にせずに済みます。

後は host.json で設定を行うだけでした。詳細は牛尾さんのブログを参照してください。

Application Insights から見つけるコツ

修正したアプリケーションをデプロイした後に、適当に動かして Application Insights にログを送信してみましたが、いつものように Performance => Operations の順に E2E Transaction に辿っていくと、上手くトレース結果は表示されませんでした。

何かコツがあるのではないかと思ったので、牛尾さんにいろいろ聞いてみました。このエントリを書いた理由は、この辺りのやり取りのログが流れるともったいないからです。

曰く DtActivityDtOrchestrator から始まる Request を探すのが良いらしいです。確かに Logs から Request で絞り込んでみたところ、該当のログが見つかりました。

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

Operations には出てこないようですが、同時に送信している依存関係のテレメトリは Dependencies に出てきているので、ここから追うことも出来ました。正常系の確認は追うのに最初はコツが必要な感じですが、例外の場合は Failures から簡単に追えます。

HTTP で開始するオーケストレーターの場合は Operations から E2E Transaction を開くと、謎の HttpRequestIn というログがあるので、これを展開することで全体のトレースを確認できます。

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

実際に HTTP-01 を利用して Let's Encrypt で証明書を発行した時のトレーシング結果が以下の通りです。

非常に綺麗にオーケストレーターの処理フローが確認できますし、それぞれのアクティビティで実行されている API の確認も簡単です。エラーが発生したタイミングも一目瞭然なので、運用では確実に役立ちます。

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

Durable Functions の分散トレーシングが便利すぎて、正式リリースされるのが待ちきれない気分です。

実際に運用している環境で使っていきたいですが、今のところは 2.2.12.2.2 でのリリース差分が取り込まれるのを待とうと思っています。

Azure CDN / Front Door の Key Vault 証明書を自動更新するツールを作った

Azure CDN と Front Door には DigiCert の証明書を無料で発行して、自動で更新までしてくれる Managed Certificate が用意されていますが、Zone Apex 向けの証明書は対応していません。

そのため何処かの CA で証明書を発行し、Key Vault を使って持ち込む必要があります。実際に以前 Azure CDN の Alias record set を使って Zone Apex 対応を試したときには Key Vault を使いました。

Key Vault は安全に証明書を管理できるので便利ですが、Azure CDN と Front Door は何故か Key Vault 証明書の更新タイミングで、新しい証明書の自動デプロイを行ってくれません。

それぞれのドキュメントの FAQ にて手動でのデプロイが必要だと記載されています。

7. How do cert renewals work with Bring Your Own Certificate?

To ensure a newer certificate is deployed to PoP infrastructure, simply upload your new certificate to Azure KeyVault, and then in your TLS settings on Azure CDN, choose the newest certificate version and hit save. Azure CDN will then propagate your new updated cert.

Tutorial - Configure HTTPS on an Azure CDN custom domain | Microsoft Docs

Does Front Door support autorotation of certificates?

For your own custom TLS/SSL certificate, autorotation isn't supported. Similar to how it was set up the first time for a given custom domain, you will need to point Front Door to the right certificate version in your Key Vault and ensure that the service principal for Front Door still has access to the Key Vault.

Azure Front Door - Frequently Asked Questions | Microsoft Docs

殆どのケースでは DigiCert が発行する Managed Certificate で問題ないのかもしれませんが、Front Door では Zone Apex やワイルドカードを使いたいこともあるはずです。

その場合には Key Vault で証明書を持ち込む以外の方法はありません。2020Q1 に自動更新に対応する予定と聞いていましたが、全く音沙汰がないのと Let's Encrypt の 90 日証明書を手動で更新するのは現実的ではないので、自動更新するための簡単なツールを作りました。

仕組みは単純で RBAC で対象となる CDN Profile や Front Door に権限を与えると、Key Vault 証明書が更新されてから 24 時間以内に自動でデプロイするというものです。1 日 1 回だけ証明書のバージョンと CDN / Front Door にデプロイされている証明書のバージョンをチェックしています。

README を全然書いていないので、まずはこっちで設定方法を軽く書いておきます。今は daruyanagi.com を Azure CDN + Let's Encrypt で運用しているので、これの自動更新を設定します。

CDN の場合は親となる CDN Profile に Contributor ロールを Function App に割り当てます。

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

Key Vault の Access Policy にも設定を追加しますが、このあたりの設定は色々な所で出てくるので省きます。

Let's Encrypt で発行した証明書は Key Vault Acmebot を使ってサクサク作成して、初回の証明書だけは手動で有効化しておきます。そうしないと Key Vault 証明書を特定できないので必須の作業となります。

設定自体はこれだけなので、後は Key Vault 証明書を更新して Function App が動作するのを待ちます。

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

ログを都度確認する必要はないですが、今回はテストなので Azure Portal から実行ログを確認しておきます。

現在 CDN に設定されている証明書の Secret Version と Key Vault 証明書の最新 Secret Version が異なっているので、最新バージョンへの更新が行われます。

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

CDN POP への証明書デプロイは非同期で行われるので多少時間がかかりますが、しばらく待った後にブラウザでサイトへアクセスすると新しい証明書の Thumbprint になっていることが確認できます。

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

Front Door の場合も全く同じ設定と動作になるので、今回は書いていませんが動作確認は終わらせています。

これで Key Vault 証明書を更新するだけで、CDN / Front Door の証明書が自動的に更新される世界になりました。正直、最初からあって当たり前の機能だと思うのですが、何故か実装が遅いので困ったものです。

Azure Functions でカスタム HTTP ヘッダーや HSTS のグローバル設定を行う

ドキュメント化されていないようですが、Azure Functions でも host.json によるカスタム HTTP ヘッダーの追加と HSTS の設定が行えるようようになっていました。Function App 全体で適用されるので、セキュリティに関係するヘッダーを追加するには最適です。

Azure Functions Proxy で HTML を返している場合には、通常の Web アプリと同じ考慮が必要です。一般的に必要になるヘッダーは以下に書いた時とほぼ変わっていないと思います。

HSTS や CSP もヘッダーで追加できますが、HSTS はともかく CSP はなかなか適用が難しいですね。とはいえ、グローバルに設定しないと意味がないので、抜け漏れなく対応できる host.json での設定は有益です。

カスタム HTTP ヘッダーを追加する

ドキュメントには載っていないですが、host.jsonextensions/http 以下に customHeaders という要素が書けるようになっています。

たまたま関係ない調べ物をしていた時に、以下の Pull Request を見つけたので気が付きました。

微妙にテストコードが間違っている気がしますが、Azure Functions v2 / v3 の場合は以下のように書けばカスタム HTTP ヘッダーを追加できます。

{
  "version": "2.0",
  "extensions": {
    "http": {
      "customHeaders": {
        "X-Kazuakix": "50"
      }
    }
  }
}

条件付きでの追加などは出来ないので、前述したように基本はセキュリティ向けヘッダーなどの固定値になるもので使うのが良いでしょう。

実行してみると、ちゃんと追加した HTTP ヘッダーが返ってきます。

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

HttpTrigger ではなく Azure Funcion Proxy を使った場合も試してみましたが、問題なくカスタム HTTP ヘッダーが付いた状態で返ってきました。

Function Filter や個別に対応する必要がないので、結構使い勝手は良いです。

HSTS に対応させる

カスタム HTTP ヘッダーを使っても対応できますが、Azure Functions は HSTS に関しては個別設定になっているので、こっちを使った方がローカルホストでは HSTS のヘッダーが出ないので便利です。

これもまたドキュメントには載っていなかったので Pull Requrst で気が付きました。

ベースは ASP.NET Core の HSTS なので、オプション項目も大体共通です。有効化したい場合に isEnabled を明示的に指定すればデフォルトのヘッダーが出力されます。

{
  "version": "2.0",
  "extensions": {
    "http": {
      "hsts": {
        "isEnabled": true
      }
    }
  }
}

ローカルホストでは HSTS のヘッダーは出力されないようになっているので、Azure にデプロイして実行する必要があります。これは地味にはまりそうなので注意したいです。

ASP.NET Core のデフォルト HSTS は max-age が 30 日なので、以下のようなヘッダーになります。

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

以前に別のエントリで書いたように、HSTS が 30 日だと短すぎるので本番向けではもっと長くしておきます。同時にサブドメインに対しても有効化しておきたいので、設定を追加します。

オプション名は camelCase にしたものを使えば良いですが、maxAge だけは TimeSpan 型なので指定に少し悩みそうです。以下のように日数だけ書いても良いですし、ddd.hh:MM:ss 形式で指定しても良いです。

{
  "version": "2.0",
  "extensions": {
    "http": {
      "hsts": {
        "isEnabled": true,
        "maxAge": 365,
        "includeSubDomains": true
      }
    }
  }
}

再度 Azure にデプロイして実行すると、max-ageincludeSubDomains が設定されています。

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

Windows の Azure Functions の場合は XDT を使ってカスタマイズという、何でもありな方法も取れそうでしたが、host.json を使った方法は Linux でも問題なく動作するはずなので安心です。

Easy Auth と Managed Identity を使ってグループ情報でのアクセス制限を行う

App Service の Easy Auth を使うと簡単に Azure AD でログインするアプリケーションが作れますが、Azure AD と連携しているならユーザーのロールを利用した認証が行いたくなるはずです。

Security Group をしっかりと使っている場合には、アプリケーション側での制御が必要になりますが、単純に実装しようとすると Object ID しか取れずに詰むので、Security Group 名ベースで制御してみます。

今回必要になる処理は以下の通りです。Azure AD がグループ名を Claims に入れてくれれば楽なのですが、いろいろ試しても Object ID しか入ってこないので諦めました。

  1. Microsoft Graph API を使ってグループ情報を引いておく
  2. Easy Auth の Claims にユーザーに紐づいているグループ情報を追加する
  3. Object ID を使ってグループ名とのマッピングを行う

ちなみに Azure Static Web Apps の認証にはロールという概念が入っていますが、Azure AD と連携したものではなく自分で定義する仕組みなのでイマイチな感じです。

Managed Identity を有効化して Graph API の権限を追加

Graph API を叩く方法としては Service Principal を作成するのが一般的のようですが、Service Principal はあまり好きではないので Managed Identity を使って API を叩きます。

完璧な記事が既にあるので、Managed Identity の有効化の後は以下を参考に Graph API を叩くための権限を追加してください。自分は Cloud Shell で行いました。

たったこれだけの作業で Managed Identity で Graph API の実行が可能になります。Service Principal に必要な認証情報を App Service に別途持たせる必要がないので最高ですね。

Easy Auth を有効化して Claims に Security Group を追加

次に Easy Auth を有効化しますが、Azure AD を選んで Express でアプリケーションを作成すれば良いです。

アプリケーションの作成後は Azure AD のアプリケーション一覧から、作成したアプリケーション設定の中にある Token configuration を開きます。

昔は Manifest を直接弄る必要がありましたが、今は GUI でポチポチ設定できます。

Add groups claim を選ぶと、どのタイプのグループを Claims に含めるか選べるので、今回は Security groups を選択して保存します。複数選べるので必要なものを選べばよいです。

Token の種類によって値として何を含めるかをカスタマイズできますが、Azure AD をソースとして使っている場合は Object ID しか実質入ってこないようになっているみたいです。

Emit groups as role claims にチェックを入れると、Claim 名が groups から roles になります。

roles に含めるようにすると ASP.NET Core などから使いやすくなるのかもしれませんが、結局は Object ID しか入ってこないのでロール名へのマッピングは必要になるでしょう。

これで Easy Auth での認証時に Claims に紐づいているグループの Object ID が入るようになりました。

Azure Functions で Claims と Graph API を利用

後はアプリケーションで Claims に含まれている Object ID と Graph API から取得した Security Group 一覧を使ってマッピングすれば良いです。簡単にするために Azure Functions で書いています。

実際にアプリケーションで使いたいので Startup と DI を使っていろいろ分離しておきました。

public class Startup : FunctionsStartup
{
    public override void Configure(IFunctionsHostBuilder builder)
    {
        builder.Services.AddSingleton<IGraphServiceClient>(provider =>
        {
            var graphServiceClient = new GraphServiceClient(new DelegateAuthenticationProvider(async requestMessage =>
            {
                var accessToken = await new AzureServiceTokenProvider().GetAccessTokenAsync("https://graph.microsoft.com/");

                requestMessage.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
            }));

            return graphServiceClient;
        });

        builder.Services.AddSingleton<AzureAdGroupsAccessor>();
    }
}

GraphServiceClient はシングルトンにしたいので、DI を使って作成するようにしています。Managed Identity を使って Access Token を取る部分も含めています。

実際に Graph API を使って Security Group 一覧を取る部分は Accessor として別クラスにしています。

public class AzureAdGroupsAccessor
{
    public AzureAdGroupsAccessor(IGraphServiceClient graphServiceClient)
    {
        _graphServiceClient = graphServiceClient;
    }

    private readonly IGraphServiceClient _graphServiceClient;

    public async Task<IList<Group>> GetSecurityGroupAsync()
    {
        var groups = await _graphServiceClient.Groups
                                              .Request()
                                              .Filter("securityEnabled eq true")
                                              .GetAsync();

        return groups;
    }
}

起動時に 1 回だけ取得して、更新が必要な場合は Azure Functions 再起動で十分だと思いますが、簡単にするためのその辺りは省きました。必要なら AsyncLazy<T> などで初期化してあげれば良いです。

ここまで準備が出来れば、後は Function 本体でメソッド呼び出せば Security Group の一覧が取れます。今回は roles に追加するオプションを有効にしたので、Claims から取る時の名前も roles になっています。

public class Function1 : HttpFunctionBase
{
    public Function1(IHttpContextAccessor httpContextAccessor, AzureAdGroupsAccessor azureAdGroupsAccessor)
        : base(httpContextAccessor)
    {
        _azureAdGroupsAccessor = azureAdGroupsAccessor;
    }

    private readonly AzureAdGroupsAccessor _azureAdGroupsAccessor;

    [FunctionName("Function1")]
    public async Task<IActionResult> Run(
        [HttpTrigger(AuthorizationLevel.Anonymous, "get")] HttpRequest req,
        ILogger log)
    {
        var groups = await _azureAdGroupsAccessor.GetSecurityGroupAsync();

        var assignedGroups = User.FindAll("roles").Select(x => x.Value).ToArray();

        return Ok(new { assignedGroups, groups });
    }
}

最近 Azure Functions で HTTP API を書くときは HttpFunctionBase を使うようにしているので、ClaimsPrincipal へのアクセスとレスポンスを返すのが楽です。

作成した Function App をデプロイすれば、後は確認のためにユーザーに Security Group を割り当てます。

Azure AD の UI がちょいちょい良くなっているので、割り当て自体はサクッと行えるはずです。

最後に作成した Function へブラウザからアクセスすると、Easy Auth での認証が走った後に紐づいている Object ID と Security Group の一覧が返ってくるはずです。

ブラウザだと JSON が見にくかったので、Postman を使って同じ API を叩いたのが以下の例です。

これで API 毎に必要なロールを定義して、アクセス制限を実装できるようになりました。

ASP.NET Core だと ClaimsPrincipal のオーバーライドや、ポリシーの定義が可能なので Authorize 属性で許可ロールを指定できますが、Azure Functions だとチェックするメソッドを用意するしかないかなと思います。Functions Filter は先行きがかなり怪しいです。

Azure Pipelines の VMSS Preview を使って Ubuntu / Windows のスケーラブルな Agent を作成する

Build 2020 で Azure Pipelines の Self-hosted Agents に VMSS 版がプレビューとして追加されました。VMSS 版ということは柔軟なスケーリングが可能ということなので、選択肢としてはかなり有望です。

大規模なプロジェクトで CI / CD を行っていると、Agent がすぐに足りなくなってしまいがちですが、VMSS の特徴を生かして必要な時に必要なインスタンスを確保できます。

とはいえ基本的には Microsoft-hosted な Agents を使うことをお勧めしますが、Microsoft-hosted な Agents はインスタンスサイズが Standard_DS2_v2 なので明らかに足りない場合もあると思います。MLOps とかで GPU が必要なケースにも対応できません。

VMSS 版は管理が楽になっているのでインスタンスサイズ以外にも、VNET と Private Link でガチガチに塞がれた App Service などへのデプロイにも使えそうです。通常は Self-hosted Agent が必要になりますが、Agent のメンテナンスに手間を掛けたくないのが自然な発想です。

まずはドキュメントに従って Ubuntu の VMSS Agents を作成してみます。後で作る Windows の VMSS Agents と比較するために Azure CLI のコマンドを載せておきます。

az vmss create --name vmssagentspool --resource-group vmssagents --image UbuntuLTS \
               --vm-sku Standard_D2_v3 --storage-sku StandardSSD_LRS --authentication-type SSH \
               --instance-count 2 --disable-overprovision --upgrade-policy-mode manual --load-balancer ""

スケーリングやアップグレードは Azure Pipelines 側で管理するので、その辺りの設定はオフにします。オーバープロビジョニングやロードバランサーも不要です。インスタンス数の指定も要らないらしいですが、とりあえずドキュメント通りにします。

VMSS のデプロイは比較的早いですが、実際に内部の VM が立ち上がるまで少しかかりました。

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

VMSS の作成が終われば、Azure DevOps の設定から VMSS Agent Pool を追加します。Service Connection の設定が微妙に分かりにくいですが、組織単位では Service Connection を持てないのでどれかプロジェクトを選ぶという形です。

とりあえず使う予定のプロジェクトを選んでおけば良いです。その後 VMSS を選べるようになります。

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

Pool のオプションはデフォルトでも良いですが、インスタンスの最大と最小は課金に影響するので気を付けたい部分です。最小を 0 に設定すると必要な時にオンデマンドで VM が作成されるようになります。

Job 毎に常に新しいインスタンスを使うオプションもありますが、挙動が不安定な感じでした。起動コストをペイできるような Long-running かつ、セキュリティが要求される処理じゃないと使うのは難しいでしょう。

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

VMSS Agent Pool を作成すると、自動的に VMSS が更新されて Azure Pipelines Agent がインストールされた VM が作成されます。最小インスタンス数を 0 にしても初回は 2 台作成されるようでした。

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

インスタンスの作成が完了すると、Agent Pool から確認できるようになります。

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

後は Pipeline で作成した Agent Pool を選択して実行すれば、作成した VMSS Agent 上で実行されます。VM での Self-hosted Agent のように自分で Agent をインストールする必要がないのでお手軽です。

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

今回は最小インスタンス数を 0 にしているので、1 時間ぐらいアイドル状態が続くと自動でインスタンスが削除されました。スケールダウンにはアイドル状態が続く必要があるので、継続的に Job が実行される場合は 1 インスタンス以上が動き続けることになります。

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

0 になってから Job を実行すると、Azure Pipelines によって自動的にインスタンスが作成されます。

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

5 分毎のサンプリングらしいので、思ったようにスケールアウトしないこともあるようです。夜間には完全に 0 になることが多そうなので、全体的なコストは削減できるでしょう。

まだ Preview なので実装は変わりそうですが、オートスケールは時間のかかる処理向けという感じです。とはいえ全体的にはインスタンスの管理が不要になったので、かなり運用が楽になりました。

ドキュメントには Ubuntu 向けのコマンドは書いてありましたが、Windows 向けのコマンドは分かりにくい場所にあるので紹介しておきます。ついでに作成して試しておきます。

az vmss create --name vmssagentspool --resource-group vmssagents --image Win2019Datacenter \
               --vm-sku Standard_D2s_v3 --storage-sku Premiun_LRS \
               --admin-username agent --admin-password P@ssw0rd123446
               --instance-count 2 --disable-overprovision --upgrade-policy-mode manual --load-balancer ""

Ubuntu との違いはユーザー名とパスワードが追加になったぐらいです。Windows Server 2019 は Ubuntu と比べてストレージが遅いと困るので Premium Storage を使うようにしました。

同様に VMSS Agent Pool を作成すると、Windows 版の Agent が自動的に作成されます。

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

Pipeline で作成した VMSS Agent Pool を指定すると、サクッと Job が実行されました。インスタンスが立ち上がっている場合は当然ながらすぐに実行されます。

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

個人的には VM を直接使うの嫌いなのですが、VMSS であればいろいろと隠蔽されていて PaaS に近い形で使えるので割と好きです。

本来なら Spot VM を使って試そうと思ったのですが、従量課金や EA などのサブスクリプションが必要だったので作成すらできませんした。動くとは思いますが GA の時には正式サポートされてほしいです。

App Service Static Web Apps の仕組みを探る(非公式)

Build 2020 では App Service に関する話は非常に少なかったですが、唯一大きなリリースとしては Static Web Apps がありました。名前の通り静的コンテンツをホスティングするためのサービスですが、同じドメインで API (Azure Functions) が付いてくるのが特徴です。

詳しくは Daria の公式ブログや三宅さんの記事を読んでおいてください。このエントリではその辺りの紹介は全くしないので、知識がある前提で進めていきます。

自分は Static Web Apps がどのように構築されているのかのが気になるので、現在分かっていて触れる範囲で内部アーキテクチャを探ってみました。当然ながら非公式ですし、正しい保証はありません。

Static Web Apps でお手軽ホスティングしたい人には必要ないエントリです。普通は気にせずに GitHub からサクサクデプロイして配信するだけで良い部分です。

TL;DR

  • エンドポイントには App Service (Windows, ASE) が使われている
  • API 部分は Linux Consumption で動作していて、エンドポイントからリライト
    • Linux Consumption は Run From Package で実行されている
  • Static Web Hosting 部分はエンドポイントから恐らく Blob Storage などへリライト
  • デプロイ用 GitHub Actions では app / api それぞれでビルドして zip を作成
    • 良くある Push 型ではなく Pull 型が使われている
  • Global Content Distribution = 全世界の App Service にデプロイ + Traffic Manager でルーティング
    • ただし API 部分は作成時に指定したリージョンのみ

基本的な設定

Static Web Apps は Global Content Distribution を謳っているので、リソース作成時のリージョン指定は何の意味があるのか、冷静になって考えると疑問です。とりあえず一番遠そうな East US 2 に作成しました。

現時点では API は単一リージョンのデプロイになるので、その指定と考えておけば良いです。

リソース作成後に設定から弄れる内容は結構少ないです。Static Content 向けなので当たり前感はありますが、Azure Functions 向けに App Settings の追加だけは行えるようになっています。

ARM レベルだともうちょっと弄れるのではと思いましたが、特に違いはありませんでした。Private Link 対応は比較的近いうちに来るんだろうなと思わせる程度です。

プラットフォーム

とりあえず最初に nslookup でホスト名を調べるのは義務という感じなので、azurestaticapps.net なホスト名に対して名前解決を行います。

この時点で CDN が使われておらず、Traffic Manager と App Service Environment が使われていることが分かりますね。p.azurewebsites.net は External ASE を作成した時に付けられるホスト名だからです。

更に East US 2 に作成したはずなのに East Asia の ASE のアドレスが返ってきていることも分かります。各リージョンに存在する ASE に自動でデプロイされていて、Traffic Manager が近いものを返しているようです。

今日に App Service Team への Ask the Team というセッションがあったので目いっぱい質問をしてみたところ、現時点では世界中のリージョンにデプロイされていて、GA までには CDN が追加されるようです。

Static Content

少なくとも App Service が使われているのは分かったので、Postman などを使って静的コンテンツに対してリクエストを投げて、レスポンスヘッダーを確認してみます。

特徴的なのは ServerX-Powered-By ですね。明らかに Windows Server 2016 と IIS 10.0 がリクエストのどこかに紛れ込んでいます。全ての App Service はフロントに ARR 3.0 がいますが、今は特殊なヘッダーを付けないので無視して良いです。

普通に考えると、この App Service に静的コンテンツをデプロイして IIS でホストしているのかと思いますが、気になったので .env ファイルをルートにデプロイして、アクセスできるか試してみました。

結果としてはエラーにならず、問題なくファイルが返ってきました。この時点で IIS が静的コンテンツを返しているという線はなくなります。

理由としては IIS は MIME Type が登録されているファイル以外は応答を返さないからです。昔の IIS で .json へのアクセスがエラーになって困った人もいるはずですが、それと同じ理由です。

.env という普通は絶対に応答を返さないファイルであってもアクセスが出来るので、IIS ではなく別途リバースプロキシによって別のストレージから取得していると想像できます。公式にルーティング機能が提供されていることから考えて、ARR などではなく独自のリバースプロキシだと思われます。

API (Azure Functions)

次は API 部分ですが、適当に HttpTrigger な Function をデプロイして、これもまた Postman などでレスポンスヘッダーを確認してみます。

X-Powered-By が付いているのは Static Content 側と同じです。全く同じ Function を別の Linux Function にデプロイしたところ chunked が付いていたので、これもリバースプロキシで処理されていそうです。

Azure Functions は環境変数で様々な情報がやり取りされていることは有名なので、以下のように環境変数をごそっと返す Function を作成すると様々な情報が見えてきます。

module.exports = async function (context, req) {
    context.log('JavaScript HTTP trigger function processed a request.');

    context.res = {
        body: process.env
    };
};

デプロイして API を叩いてみると、一瞬で Service Fabric Mesh 上で動作していることが分かるので、裏側は Linux Consumption というのが確定します。

リージョン名も環境変数に入ってくるので、East US 2 で動作していることも分かります。

中でも WEBSITE_HOSTNAME という環境変数には Azure Functions 実体のホスト名が入ってくるので、ブラウザから叩いてみると Azure Functions v3 が動作していることも判明しますね。

つまり /api 以下にアクセスすると、このホストへ転送されているということですね。

なんとなく host.json にある routePrefixapi 以外にしてみましたが問題なくアクセス出来ました。システム的に Prefix は固定されていそうです。

環境変数からは分かることが多く、例えば Run From Package を使って Function がデプロイされていることも分かります。ストレージアカウントはシステムが持っているものが使われています。

URL を指定した Run From Package は Pull 型のデプロイになるので、Geo Replication 向けではあります。

ビルド・デプロイ

最後にデプロイ周りですが、Static Web Apps 専用の GitHub Actions がビルドからデプロイまで行うので、相当にブラックボックスになっています。

ビルドコマンドはデフォルトでは npm run buildnpm run build:Azure が使われますが、パラメータを渡せばカスタマイズ可能です。

ビルドには Oryx が使われているので、実体としては App Service on Linux とような仕組みです。

ビルドログを見ると、app と api でそれぞれ zip が作られてアップロードされているようです。Oryx を使っているので、もうちょっとこみ入ったカスタマイズも出来そうですが、情報待ちにしときます。

そして polling を行いビルドが完了したかをチェックしています。全リージョンにデプロイするために非同期で処理が実行されているようですね。デプロイだけを単独で行うことも出来そうですが、API が公開されるかクライアントが OSS にならないと手を出しにくいです。

ASP.NET Core Web API と NSwag を使って OpenAPI 定義を自動生成する

昔は ASP.NET で Swagger / OpenAPI 定義を出力するには Swashbuckle が定番でしたが、あまりアクティブではない時期が長かったせいか最近は NSwag を使うようにしています。

今では GitHub の Star 数も Swashbuckle より NSwag の方が多くなっています。

Docs でも Swashbuckle と共に NSwag を使ったドキュメントが公開されています。

機能的には Swashbuckle と NSwag で大きな差はないですが、NSwag は MSBuild 向けパッケージや CLI を使った静的な Swagger / OpenAPI 定義の生成が行えます。

Swagger / OpenAPI 正義は実行時にわざわざリフレクションを使用して生成する必要はゼロで、ビルド時や CI といったタイミングで生成できれば全く問題ないです。なので 2 つの方法で自動生成を行ってみます。

ローカルビルド時に OpenAPI 定義を生成する

手っ取り早いのが Visual Studio / .NET Core CLI でのビルド時に OpenAPI 定義を同時に生成してしまう方法です。NSwag の場合は NSwag.MSBuild をインストールすると比較的簡単に実現できます。

NSwag を使った静的な OpenAPI 定義の生成には nswag.json ファイルを用意した方が楽なので、今回の場合は以下のような定義を用意しておきました。

ASP.NET Core 向けのジェネレータを使って、ビルド済みアセンブリから定義を生成します。ファイルは csproj と同じディレクトリに置いておくと色々と楽です。

{
  "runtime": "NetCore31",
  "documentGenerator": {
    "aspNetCoreToOpenApi": {
      "output": "openapi.json",
      "outputType": "OpenApi3",
      "assemblyPaths": [
        "bin/$(Configuration)/netcoreapp3.1/WebApplication1.dll"
      ]
    }
  }
}

正直アセンブリパスの指定が面倒な感じですが、ビルド後に実行する処理なので仕方ない感じです。Visual Studio から生成物のフルパスを渡すことも出来ると思いますが、まずはシンプルな設定にしました。

ビルド後に NSwag を実行するために、csproj に Build 後のターゲットを追加します。Wiki に書いてある通りで大体は問題ないですが、今回は .NET Core 3.1 向けなので NSwagExe を 3.1 向けにします。

<Project Sdk="Microsoft.NET.Sdk.Web">

  <PropertyGroup>
    <TargetFramework>netcoreapp3.1</TargetFramework>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="NSwag.AspNetCore" Version="13.5.0" />
    <PackageReference Include="NSwag.MSBuild" Version="13.5.0">
      <PrivateAssets>all</PrivateAssets>
      <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
    </PackageReference>
  </ItemGroup>

  <Target Name="NSwag" AfterTargets="Build">
    <Exec Command="$(NSwagExe_Core31) run nswag.json /variables:Configuration=$(Configuration)" />
  </Target>

</Project>

これで Visual Studio や .NET Core CLI を使ったビルドを開始すると、アプリケーションのビルド後に NSwag が実行されて OpenAPI 定義が生成されるようになりました。

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

出力された OpenAPI 定義をリポジトリに含めて、そのままコミットすることになると思いますが、この方法だとビルドを行わない限りは新しいバージョンが生成されないので、ビルドを忘れると古い OpenAPI 定義のままになってしまいます。

なので Pull Request 向けの CI では、最新の OpenAPI 定義が出力されているかの確認が必要でしょう。

CI では間違いなくアプリケーションのビルドを行うので、そのタイミングで最新の OpenAPI 定義が生成されます。そして HEAD との差分が発生していれば、生成忘れなのでエラーにすれば良いですね。Azure Pipelines を利用して以下のような Pipeline を作成しました。

trigger:
- master

pool:
  vmImage: 'ubuntu-latest'

steps:
- checkout: self
  persistCredentials: true
  clean: true

- script: dotnet build
  displayName: Build application

- script: |
    if [ -n "$(git status --porcelain)" ]; then
      git status
      exit 1
    fi
  displayName: Ensure up to date

git status --porcelain を使う方法は openapi-generator の CI を参考にしました。openapi-generator は最新のコードが生成されていないと CI で落ちるようになっているので、今回の目的と完全に一致しました。

例えばビルドせずに API 実装を変更してコミットした場合、以下のように CI で落ちるようになります。

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

もちろんビルドしなおしてコミットすれば CI は成功します。

Pull Request で実行しておけば、master に古い定義が混ざることを防ぐことが出来ます。OpenAPI 定義をコードから生成している場合は、最低限このくらいのチェックは入れておきたいところです。

CI を使って OpenAPI 定義を生成する

Azure Pipelines などの CI を使って OpenAPI 定義のチェックを行うなら、むしろ CI で生成すれば良いという話にもなるので、Azure Pipelines で最新のコミットから OpenAPI 定義を生成するようにしてみます。

NSwag には CLI が提供されていますが、何故か .NET Core Global Tool としては提供されていないので、NPM で公開されているものを使うのが楽です。

今回も MSBuild を使った時と同様に nswag.json を作成しますが、アセンブリのパスではなくプロジェクトを指定する方法を選んでみました。アセンブリのパスは不要です。

{
  "runtime": "NetCore31",
  "documentGenerator": {
    "aspNetCoreToOpenApi": {
      "project": "WebApplication1.csproj",
      "output": "openapi.json",
      "outputType": "OpenApi3"
    }
  }
}

このファイルを使って npx nswag を呼び出せば OpenAPI 定義が生成されます。

最後に生成された OpenAPI 定義をリポジトリにコミットする必要があるので、Azure Pipelines で各種 Git コマンドが使えるように、ドキュメントを確認しつつセットアップします。

Build Service に対して git push のための権限を付ける必要がありますが、ドキュメントの内容が微妙に古くてはまりました。

以下のように Users に中にある Build Service に対して権限を付けると、コマンドが通るようになりました。

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

今回作成した Pipeline は以下のようになります。どのタイミングで OpenAPI 定義を生成させるべきか悩みましたが、master にマージされた時で良いかなと思ったので、シンプルな形になっています。

Pull Request でも都度生成させることは出来ますが、feature branch に対してサーバー側でコミットを積んでいくと、ブランチが先に進んでしまうので手間かなと思いました。

trigger:
- master

pool:
  vmImage: 'ubuntu-latest'

steps:
- checkout: self
  persistCredentials: true
  clean: true

- script: dotnet build
  displayName: Build application

- script: npx nswag run WebApplication1/nswag.json /runtime:NetCore31
  displayName: Generate OpenAPI document

- script: |
    git config --global user.email "me@shibayan.jp"
    git config --global user.name "Tatsuro Shibamura"
    git checkout master
    git status
    git add .
    git commit -m "[skip ci] Update OpenAPI document"
    git push
  displayName: Commit new document

変更点がない場合は途中で処理を抜けても良いですが、オプションを付けない限り空のコミットは生成されないので、今のコマンドでも問題はないです。

Git の user.emailuser.name は自動生成したことが分かるように別名にした方が良いでしょうが、今回は Azure Pipelines での git push を行うテストも兼ねていたので適当なものを設定しています。

この状態で master に対して何かコミットすると、Azure Pipelines が最新の OpenAPI 定義を生成して、その定義をリポジトリに push してくれます。

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

ちなみにコミットメッセージには [skip ci] など CI をスキップするための文字列を入れないと、コミットがトリガーになるので CI がループします。

作成されたコミットを確認すると、CI で自動生成された OpenAPI 定義が含まれています。

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

これで OpenAPI 定義の生成を意識しなくても、常に CI によって最新の定義が生成されるようになりました。今回はリポジトリに追加しましたが、Azure Storage にコピーして公開するといった方法も選べます。

逆に公開されている OpenAPI 定義から Azure Pipelines などを使って自動的にクライアントを生成して、リポジトリにコミットするという処理も簡単に組めそうです。また別途試してみたいところです。

Azure Front Door の Rules Engine (Preview) を一通り試した

Build 2020 合わせでのリリースだと思いますが、Front Door にも Rules Engine が追加されました。

これまで Front Door ではホスト名とパスベースのルーティングは行えましたが、Rules Engine を使うことで HTTP ヘッダーやパラメータベースでのルーティングが可能になります。

バックエンドが同じ Azure CDN では Rules Engine は既に使えるようになっている関係上、Front Door に追加された Rules Engine も殆ど同じ機能を持っています。

ドキュメントを見比べた限りでは、Front Door 固有の機能を追加したぐらいという感じです。Front Door は Azure CDN の上位互換と言えるので、Azure CDN で行えることは Front Door でも問題なく行えます。

公式ドキュメントなどで実現できることに関しては書いてあるので、調べて分かった実現出来ないことを先にまとめておきます。GA までにはもうちょっと機能拡充が欲しいところです。

後は自分が必要になりそうなルールをメインに作成しつつ、ドキュメントを読んで挙動が良くわからなかった部分を中心に実際に試しています。

Rules Engine で出来ないこと

マッチした部分文字列を使った高度なルーティング

IIS の URL Rewrite のように正規表現を使って、部分文字列を取得した後に HTTP ヘッダーに付けるといった高度なルーティングは行えません。

例えばサブドメインを使ったマルチテナントのシチュエーションで、Front Door はワイルドカードで受けつつもサブドメイン部分を抽出して、バックエンドには HTTP ヘッダーなどで渡すといった処理は書けないです。なのでバックエンド側で X-Forwarded-Host を解析する必要があります。

正規表現まではいかなくても、一応 Azure CDN の Rules Engine や Front Door のルーティングルールでは一部ワイルドカードを使ったマッチが可能でしたが、Front Door の Rules Engine では有効化されていないようでした。仕組みは同じだと思うので、GA には有効になると期待しています。

バックエンドにリクエストせずにレスポンスを返す

アクセスさせたくない URL や IP アドレスなどの場合に、バックエンドへリクエストさせずに 403 や 404 といったエラーのレスポンスを返すような処理は書けません。

現時点では IP 制限を実装する場合はエラー用の URL やバックエンドを作成して、常にルーティングするようなルールを作成することになります。エラーページのカスタマイズが出来るのは利点かもしれません。

GA までには独自のレスポンスを返す機能が実装されてほしいところです。

バックエンドのレスポンスを条件にする

たまにありますが、拡張子がないリクエストパスの場合に Content-Type を見てキャッシュするかどうかを決めたい時があると思いますが、Front Door の Rules Engine はリクエスト時に条件が評価されるので、レスポンスの HTTP ヘッダーは条件に使えません。

css や jpg といった静的ファイルだけをキャッシュしたい場合には、ディレクトリや拡張子を決め打ちで行う必要があります。専用のドメインを Front Door に追加して、丸ごとキャッシュ対象にする方法もありです。

Rules Engine を使ってみた例

思いつくままにいくつか典型的なルールを作成して試してみました。

Rules Engine Configuration の変更は試した限りでは平均 2 分ぐらいで有効になるので、思ったよりも早いです。設定できるはずに制限はありますが、それなりに柔軟なルールを作成できます。

HTTPS へのリダイレクトを行う

リリースブログにも書いてあるように、HTTPS へのリダイレクトを Rules Engine を使って実現することが出来ます。HTTP の場合はアクションでデフォルトのルーティング設定をオーバーライドし、ホスト名やパスなどの情報を保持したまま HTTPS にリダイレクトさせます。

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

ちなみに Azure CDN でも同じようにして HTTPS を強制することが出来ます。

作成した Rules Engine はルーティングの設定から選択します。1 つしか指定できないので、汎用的な Rules Engine を作成して使いまわすことは期待しない方が良さそうでした。

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

これで Front Door の設定を保存すれば、2,3 分後には反映されて HTTPS へリダイレクトされます。

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

非常に簡単でしたが、1 つのルーティングには 1 つの Rules Engine しか指定できないので、HTTPS への強制は以前に書いたようにルーティング側で解決した方が汎用的だと思いました。

この方法ならフロントエンドが増えても 1 つのルーティングで対応できるので、無駄にルールを増やす必要がなくなります。各 Rules Engine に個別に HTTPS 強制を入れるのは非常に冗長です。

レスポンスヘッダーを操作する

利用頻度が高そうなのが、バックエンドの HTTP レスポンスヘッダーへの操作です。キャッシュ用やセキュリティ関連のヘッダーの追加や、バックエンドから送信された不要なヘッダーの削除に使えます。

ただしドキュメントに書いていない制約として x-ms から始まるヘッダーは対象に出来ないようでした。追加しようとすると以下のようにエラーになります。

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

追加はともかく、削除ぐらいはさせてほしいのですが、フィードバックするしかなさそうです。

ヘッダーを追加・上書きする

まずはレスポンスにヘッダーを追加してみます。サンプルに使っているバックエンドは Azure Storage なのでキャッシュ系のヘッダーが付いてこないため、それを補うルールを作成します。

今回はシンプルに特定のディレクトリ以下を対象に Cache-Control を追加します。

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

非常に罠だと思ったのが Request Path を対象にした場合、マッチさせる値としては先頭に / を含まないものを指定する必要があります。

もちろん Contains を使う場合はその限りではないですが、大体はルートから指定すると思うので / を含めているとマッチせずに悩むことになります。

作成した Rules Engine をルーティングに設定すると、Cache-Control が返ってきます。

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

Azure CDN や Front Door は自動で Cache-Control を付けてくれはしないので、必要であれば明示的に Rules Engine を使って実装する必要があります。

ヘッダーを削除する

今度は逆にレスポンスからヘッダーを削除します。Azure Storage の場合は x-ms-version などのヘッダーが返ってくるので削除したいですが、前述の理由により削除できないので ASP.NET MVC アプリケーションを使ってヘッダーを削除してみました。

ASP.NET MVC アプリケーションはデフォルトでは、以下のようなバージョンヘッダーを返します。

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

本来ならアプリケーションの設定で削除すべきですが、今回は Rules Engine を使います。

アクションとしてレスポンスヘッダーの削除を選べば、後は削除したいヘッダー名を入力して終わりです。

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

設定すると不必要だった各ヘッダーが消えていることが確認できるはずです。

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

バックエンド側を弄れない場合の最後の手段として、Front Door 側での操作が行えるのは良い感じです。

デフォルトのキャッシュルールを上書きする

Front Door の Rules Engine でのメイン用途はデフォルトのルーティングルールの上書きです。各条件とバックエンドを組み合わせることで、柔軟なルーティングが可能になります。

A/B テストや Canary Release 時にヘッダーやクエリ文字列の値で、強制的に特定のバックエンドへのルーティングも行えますが、ここではキャッシュルールを設定してみます。Front Door は CDN ですが、明示的にキャッシュルールを有効にしないとキャッシュはされないのです。

ファイルパスによって変更する

良くある静的ファイルを CDN にキャッシュさせるルールを作成してみます。まずはパスベースでルールを組み立ててみますが、パスの指定については前述した通り先頭の / を省いています。

CachingEnabled にすると設定が追加で出てくるので、適当なものを選びます。キャッシュしないと HTTP 圧縮もされない仕様なので、地味にはまりそうな感じです。

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

今回のテストサイトは VuePress で作っているので、ビルドの度にファイル名にハッシュが付いてユニークなものになります。なのでキャッシュ期間を非常に長く設定しています。

この Rules Engine を設定すると X-Cache ヘッダーが追加されて、キャッシュがヒットしたかどうかを返してくれます。キャッシュを有効にしないと付かないので判別に使えます。

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

静的ファイルは専用のホストから配信するのが理想ではありますが、それはなかなかに難しいので Rules Engine を使って適切なキャッシュ設定を入れておきましょう。

拡張子によって変更する

先ほどとアクションは全く同じですが、条件が拡張子に変更になります。キャッシュ対象にしたいファイル種類は複数ありますが、Request File Extension ではマッチする値を複数指定できます。

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

メッセージにあるように Enter で改行すれば複数の拡張子を入力できます。ちなみに先頭の . は削除してから指定します。警告などは全く出ないので、これもはまりそうです。

それ以外はファイルパスと同じなので説明と実行結果は省略します。

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

Content-Type を使った判別は行えないので、キャッシュ戦略としてホストやディレクトリの分離や、拡張子を必須にするといった決めが必要になるでしょう。

Geo Filtering を追加する

Azure CDN には専用の設定が用意されていますが、Front Door では Rules Engine を使うことでアクセス元の地域によっての処理を用意することが出来ます。

良くある日本国内からのアクセスのみに限定するルールを作成してみました。Remote Address を選択すると、オペレーターとして地域を選択できるようになります。

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

適当に West US に VM を作成してアクセスしてみると、設定したように 404 ページに飛ばされます。

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

現状の Front Door のルーティングでは、URL Rewrite を行うと元々のパスをフォワーディング先に引き継いでしまうので、綺麗に動作させるためには専用のバックエンドを作成して、パスに関係なくエラーを返すようにした方が良さそうでした。

やはりバックエンド無しでレスポンスを返す機能が欲しくなってきます。

Rules Engine のデバッグ

Front Door の Rules Engine は設定後、実際に反映されるまでに 2,3 分かかるのと、条件式にマッチしたかどうか判別するのがそのままだと難しい場合もあるので、以下のようにレスポンスヘッダーを追加しておくとマッチしたかどうか簡単に区別がつきます。

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

正しく条件にマッチしていた場合には、以下のようにレスポンスヘッダーが付いてきます。

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

Diagnostic settings を有効にして、Front Door の各種ログを出力するようにしてみましたが、Rules Engine にマッチしたかどうかはログに出ていなかったので、現状は上のように工夫する必要がありそうです。

Azure App Service へのカスタムドメイン追加方法が変更されているので注意

何気なく Azure Portal から App Service を開いてみると、カスタムドメインのブレードに見慣れぬ Custom Domain Verification ID という項目が増えていました。

一昨日ぐらいまでは出ていなかったので、本当に最近追加されたようです。タイミング的に Build 2020 向けという感じがありますが、新機能という雰囲気はありません。

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

Azure Portal からカスタムドメインを追加する際には、新しいドメイン検証 ID を使うようなメッセージが出てくるので、それに従って作業すれば追加できます。

ドキュメントも先月にひっそりと更新されていました。既にチュートリアルはドメイン検証 ID を使う方法に書き直されているので、新規の人は迷うことはないでしょう。

ちなみにこれまでとの差分は、以下のコミットを見ると分かりやすいです。結構罠っぽいです。

まとめると以下のようになります。App Service Team Blog などで詳細が出てくるのを期待していますが、これまでの傾向からすると望み薄です。

  • これまで
    • A レコードを使う場合のみ同名の TXT レコードを作成する
    • 値は App Service のデフォルトホスト名を追加する
  • これから
    • A / CNAME レコードともに asuid から始める TXT レコードを作成する
    • 値は(恐らく)サブスクリプション単位で発行されるドメイン検証 ID を追加する

とりあえず実際に追加して試しておきました。とはいえ大した内容ではないのでサクッと終わります。

まずは Azure Portal から始めるのが分かりやすいので、シンプルに追加したいカスタムドメインを入力して検証します。するとエラーになって必要なレコードの情報が表示されます。

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

今回は CNAME を使っていたので、CNAME と検証用の TXT レコードの作成を指示されます。

ドメイン検証 ID も載っているので適当にコピーして、それぞれの DNS レコードを作成します。Azure DNS の場合は以下のような形で TXT レコードを作成します。

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

Azure DNS を使っている場合は確認が早いので、レコード作成後にもう一度カスタムドメインの追加を行うと、すんなりと検証が通るようになります。これで追加が完了です。

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

ドキュメント化されていた記憶はないですが、昔から 1 度所有を確認したドメインに対するサブドメインの追加は、検証用のレコード無しで行えるようになっています。今回も asuid を大量に作る必要はないです。

そして既に追加済みのドメインに対して変更する必要はないので、そのままの設定のまま使い続けることが出来ます。ただし新しくドメインを追加する際に、Terraform などの IaC や Azure CLI などを使っていると検証エラーになって悩む可能性が高そうです。

f:id:shiba-yan:20200515164627j:plain:w550

告知無く変えるのはいい加減やめてほしいと思いますが、何か事情があるのかもしれません。とはいえ動いていた方法が急に動かなくなるのは困るので、こういう変更はマジでやめてほしい。

今回のドメイン検証 ID はサブスクリプション単位になっているようなので、App Service の移行時や IaC を使って作り直すようなシチュエーションで役に立つ気がしました。

とても重要な追記 (2020/05/21)

Build 2020 の Ask the App Service Team のセッションで今回の変更を何故行ったか聞いたところ、セキュリティ的な観点でドメイン検証 ID を使うように変更したと回答がありました。

具体的には以下のようなシチュエーションを防ぐためらしいです。

  1. これまでは App Service のホスト名を TXT レコードにセットしていた
  2. App Service は既存のアプリが削除された後に、同じ名前で再度作成することが出来る
  3. ドメインの TXT レコードが放置されている場合、第三者がドメインを乗っ取ることが出来る

App Service は同じ Scale unit に当たった場合は Inbound IP Address も変わらないですし、CNAME を使っていた場合は簡単にドメインの乗っ取りが可能になってしまいます。

それをサブスクリプション単位で変わるドメイン検証 ID を使うことで、第三者がカスタムドメインを設定することを回避できるということのようです。それ以外にも攻撃を避けるためには、使い終わったドメインのレコードはちゃんと削除しておきましょう。

Azure Storage Blob Index (Preview) を試してみた

去年の Ignite で Mark Russinovich がセッションで話していたという Blob Index がプレビューとして公開されました。セッションでは Blob Quick Query も紹介されていましたが、こっちは公開されていないようです。

これまでも Blob はメタデータとしてコンテンツ以外に属性を持たせることが出来ていましたが、Blob Index は Tags が新規追加され、その値によって Blob Container 横断でクエリが書ける機能です。

プレビューとして利用可能なリージョンはフランスだけなので、Resource Provider の登録後に新しくストレージアカウントを作成すれば、即使える状態になっています。

Azure Portal でのサポートも追加されているので、アップロード時に Tags を追加できます。

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

コンテナーを選択すると Filter が増えていますが、これが Blob Index を使ったフィルタリングになっています。API 的には == 以外も使えるはずですが、Azure Portal では固定になっています。

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

Tags は 1 Blob あたり 10 個まで付けることが出来、キーと値それぞれにサイズ上限があるので設計時には注意したいです。特に全て文字列として扱われる点は注意したいです。

複雑なクエリを書くことも無理なので、上手く Azure Cognitive Search と使い分ける必要がありそうです。

C# SDK を使って Tags をセットする

Azure Storage SDK v12 のプレビュー版に Blob Index への対応が行われているので、ドキュメントに従って特定バージョンのパッケージをインストールすれば使えます。

ドキュメントでは Visual Studio の設定からパッケージソースを追加しろとありますが、グローバルに設定してしまうので nuget.config を用意した方がいろいろと便利です。

最近は dotnet new nuget を使えばひな型を作成できるので簡単です。

<?xml version="1.0" encoding="utf-8"?>
<configuration>
  <packageSources>
    <!--To inherit the global NuGet package sources remove the <clear/> line below -->
    <clear />
    <add key="nuget" value="https://api.nuget.org/v3/index.json" />
    <add key="azure-sdk" value="https://azuresdkartifacts.blob.core.windows.net/azure-sdk-for-net/index.json" />
  </packageSources>
</configuration>

これで特定のプロジェクトに対してのみパッケージソースを追加できました。

サンプルとしてどのような情報を Blob Index Tags に追加しようかと考えたのですが、あまり良いものが思いつかなかったので位置情報付きの写真に対して Reverse Geocoding を実行して国と州を取得し、その情報と撮影日を Blob Index Tags に追加することにしました。

class Program
{
    static async Task Main(string[] args)
    {
        var connectionString = "DefaultEndpointsProtocol=https;AccountName=****;AccountKey=****;EndpointSuffix=core.windows.net";

        var serviceClient = new BlobServiceClient(connectionString);

        var container = serviceClient.GetBlobContainerClient("photos");

        foreach (var file in Directory.GetFiles(@"C:\Users\shibayan\Documents\images", "*.jpg"))
        {
            var blob = container.GetBlobClient(Guid.NewGuid() + Path.GetExtension(file));

            var reader = new ExifReader(file);

            reader.GetTagValue(ExifTags.DateTimeOriginal, out string dateTimeOriginal);

            var date = DateTime.ParseExact(dateTimeOriginal, new[] { "yyyy:MM:dd HH:mm:ss.fff", "yyyy:MM:dd HH:mm:ss" }, CultureInfo.InvariantCulture, DateTimeStyles.None);

            var (state, country) = await ReverseGeocodingAsync(reader.GetLatitude(), reader.GetLongitude());

            var options = new UploadBlobOptions
            {
                Tags = new Dictionary<string, string>
                {
                    { "Type", Path.GetExtension(file).ToLower() },
                    { "Date", date.ToString("yyyy-MM-dd") },
                    { "State", state },
                    { "Country", country }
                }
            };

            await blob.UploadAsync(file, options);
        }
    }

Reverse Geocoding 周りや Exif の処理に関しては Blob Index Tags と関係ないので省略しています。

Blob の作成と同時に Blob Index Tags の追加を行っていますが、後から Blob Index Tags だけ更新することも出来るので、上手く使い分けていきましょう。Case-sensitive なのでキーと値の作成には注意します。

とりあえず Azure Portal から Blob Index を使ったフィルタリングを試してみました。前に Surface Pro X を買うためにハワイに行った時の写真だけを絞り込んでみます。

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

適当な Blob を開いてみたところ、ちゃんとハワイの写真でした。フィルタリングは一瞬で行われます。

次は 2019/03/19 にアメリカで撮影した写真のフィルタリングを行いました。この日はレドモンドで MVP Global Summit が開催されていたので、写真がそれなりにあります。

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

フィルタリング結果を選択すると、Blob Index Tags の確認が出来ます。正しい写真が返ってきています。

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

現実的には Azure Portal からのフィルタリングは行わないし、今の Azure Portal では特定のコンテナー内でしかフィルタリングが行えないので、SDK を使ってフィルタリングを実施してみます。

C# SDK を使って Tags でフィルタリング

フィルタリングのクエリは SQL っぽいよく見るやつですが、クオートの書き方が少し特殊です。

class Program
{
    static async Task Main(string[] args)
    {
        var connectionString = "DefaultEndpointsProtocol=https;AccountName=****;AccountKey=****;EndpointSuffix=core.windows.net";

        var serviceClient = new BlobServiceClient(connectionString);

        var query = @"""Country"" = 'United States' AND ""State"" = 'Hawaii'";

        Console.WriteLine($"Query: {query}");
        Console.WriteLine("================================================================");

        await foreach (var result in serviceClient.FindBlobsByTagsAsync(query))
        {
            Console.WriteLine($"Result: {nameof(result.ContainerName)} = {result.ContainerName}, {nameof(result.Name)} = {result.Name}");
        }
    }
}

Blob Index のクエリではキー名はダブルクオートで括る必要があり、値はシングルクオートで括る必要があります。キーと値を分けるための仕様かと思いましたが、左右入れ替えるとエラーになるので謎です。

フィルタリング結果は IAsyncEnumerable<T> を使って取得しています。

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

Azure Portal を使った時と同じ結果が返ってきました。正しくフィルタリングは動作しています。

現状の Blob Index には残念な仕様が多くあるので、以下のセクションに目を通しておくとよいと思います。自分は先に読んでいなかったので割と時間を無駄にした感があります。

具体的には一部のオペレーターを同時に使うことが出来ない問題があります。例えば以下のようなクエリはエラーになるので Blob Index では現在使うことが出来ません。

"Country" = 'United States' AND "Date" >= '2019-03-19'

一番クエリとしては使いたい系だとは思いますが、これはエラーになるので諦めるしかないです。

Lifecycle Management

設定した Blob Index Tags は Lifecycle Management のフィルタリングにも使えるので、これまで以上に柔軟な制御が行えるようになりました。

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

Lifecycle Management では利用できるオペレーターが増えているので、通常の Blob に対するフィルタリングより柔軟な定義が書けるようになっています。

考えられるユースケース

これまでも Blob のメタデータを Cosmos DB に入れて検索に使っていた場合には、数に寄りますが Cosmos DB を排除して全て Blob Index に移行することも出来そうです。

とはいえ Blob Index は補助的に使うのにとどめておいた方が良い感じですね。やはりセカンダリインデックス的な。これまで Blob Storage ではコンテナーとファイルパスを工夫して、日付や ID / パーティションキーでアクセス出来るようにしてきたと思います。その設計と Blob Index を組み合わせるのが最適でしょう。

基本はパス設計で解決しつつ、処理ステータスなどの変更可能な状態に関しては Blob Index に、不変かつクエリが必要ないものはメタデータに入れるといった設計上の工夫が必要だろうと感じました。

Lifecycle Management と組み合わせて、ステータスが完了なっていて変更から 30 日経過した Blob を自動削除や Cool Storage に移動といった処理も書けるので、結構使い道はあると思います。これまでは処理が完了した時には別コンテナーに移動していたと思いますが、Blob Index を使うと必要なさそうです。

Change Feed と組み合わせると結構面白そうな気がするんですが、同時に使えるリージョンが無いので GA かもうちょっとプレビュー対象のリージョンが広がるまで待つ必要があります。