しばやん雑記

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

Ignite 2019 で発表された Azure App Service のアップデート

Surface Pro X を買うためにハワイに行ってたのでキャッチアップが遅れましたが、個人的な興味を持っている App Service 周りに関して Ignite 2019 での発表を軽くまとめました。

App Service Team のブログに関連するセッションリストが載っているので楽です。まあ少ないです。

数は少ないですが、結構良い感じの機能が追加されているので GA が楽しみという感じです。

Azure Monitor Integration (Preview)

これまで Local File storage / Blob / Table に書き出し設定が行えていたログ回りを、Azure Monitor の標準的な仕組みが使えるようになりました。

Blob に書き出す以外にもストリーム処理に適している Event Hub にも書き出せます。

Best practices and tips for operating and monitoring apps on Azure App Service

設定すると Blob の場合はコンテナが設定した項目別に作られて、その中は結構深いパスになっていますが日付時間別で細かく区切られて保存されます。中身は JSON なので扱いやすいです。

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

IIS ログや Azure Functions のログも簡単に転送できるので、Event Hub と組み合わせて Anomaly Detection に利用するとかも出来るのではないかと。

App Service はかなり初期のサービスなので、こういった今となっては標準的な仕組みに乗っかるのが遅れ気味ですが、Key Vault 連携など徐々に対応が進んできています。

Health Check (Preview)

昔から ARM にプロパティがあることは知っていましたが、上のセッションでひっそりと紹介されていました。Kudu の Wiki にもページがあったみたいですが、アナウンスが無いと気が付かないやつです。

これまでも App Service は動いているインスタンスに問題があったときには、自動で他の正常なインスタンスへフェイルオーバーしてくれましたが、この Health Check を設定すればアプリの問題をトリガーに LB からの切り離しと復旧を行ってくれるようになります。

現在は 2 分間隔のチェックで 5 回連続失敗した場合に切り離しが行われるらしいですが、もうちょっとカスタマイズ出来るようになってほしい感があります。

ヘルスチェックが通らない場合は 1 時間に最大で 1 インスタンス、1 日に最大 3 インスタンスまでが入れ替えされます。GA までにはこの辺りの挙動は変わると思われます。

Health Check の結果は Metrics から確認できます。値が微妙に分かりにくいですが % になっています。

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

上のグラフでは 100% 成功しているということになります。Split でインスタンス別に見れるのも良いですね。

Regional VNET Integration (Preview)

割と長い間プレビューが続いている新しい VNET Integration ですが、12 月の GA 予定が発表されました。そして Linux での対応は来年頭になるらしいです。

Web app network security made easy

同じサブネットに複数の App Service Plan を追加できないのは割と罠っぽいですが、将来的には改善されそうな気配ですね。Azure DNS Private Zone 対応もまだみたいです。

この新しい VNET Integration を使うことで Service Endpoint や Express Route と組み合わせることができるようになるので、これまでは ASE が必須だった構成を普通の App Service で組むこと可能です。

GA 後には VNET と Service Endpoint を組み合わせた App Service の構成が定番になると思っています。

Private Link (多分 Private Preview)

上のセッションでは VNET Integration と同時に App Service での Private Link についてもデモが行われました。今のところは Private Preview のようです。

Service Endpoint で良いのではと思いますが、プライベート IP でアクセスできるのは大きいですね。

Azure Portal では項目が出てこないですが、ARM Explorer などを使うと怪しい設定があります。

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

適当にいじれば使えるのかもしれないですが、当然ながら自己責任で試す形になります。

App Service Managed Certificate (Preview)

無料で証明書が発行できる Managed Certificate がプレビュー公開されました。Zone Apex に対応していないので微妙に使い勝手が悪いです。

既に証明書は発行しているので、6 か月後にちゃんと更新されるのかを見守っています。

Azure Functions Premium Plan (GA)

Premium Plan が GA して、ほとんどのリージョンで使えるようになっています。Japan East / West の両方で使えるので、これまで Consumption や Premium V2 を使っていた場合は、Premium Plan に変えることでパフォーマンス改善が図れます。

スケーリングは Function App 単位なので、複数の Function App を 1 つの Premium Plan に乗せると Cold start を避けつつも柔軟なスケーリングを行えるので便利になっています。

全体としてのコストを圧縮できるケースもありそうです。

Azure Functions v3 (Preview)

既にリリースはされていましたが、.NET Core 3.0 ベースになった Azure Functions v3 が発表されました。GitHub や Jeff のブログを読めば簡単に試すことができます。

これで .NET Standard 2.1 になった Entity Framework Core 3.0 が使えると思ったのですが、Metadata Generator 周りでエラーになってしまったので試せていません。

一応 Issue は上がっているので直るとは思いますが、地味に根が深そうな感じがしています。

Durable Functions v2 (GA)

Durable Entities / Durable HTTP が新規追加された Durable Functions v2 がリリースされました。v1 からの移行はインターフェース名や名前空間の変更があるので多少作業量は多いです。

実際に v1 から v2 に移行した際の Pull Request を参考までに紹介しておきます。

Durable Entities と Durable HTTP については既に試しているので一緒に紹介します。Durable HTTP は非同期 HTTP API を利用する際には結構便利だったので、使っていきたいです。

Ignite での発表全体から見ると App Service はあまりアップデートが無かったですが、重要な機能が多かったので既存のアプリケーションで使っていきたいと思います。

とりあえずは VNET Integration が 12 月に GA する前に色々な構成を試しておく予定です。Health Check の詳細な挙動も ASP.NET Core の Health Check API と組み合わせて検証してみたいところです。

無料で SSL/TLS 証明書が発行できる App Service Managed Certificate がプレビューに

待望の App Service 向けに無料 SSL/TLS 証明書が発行できる App Service Managed Certificate がプレビューとして公開されました。既存の App Service Certificate とは全くの別物です。

Azure Updates やドキュメントにあるように、あらかじめカスタムドメインを追加していれば Managed Certificate は簡単に使えます。

これで面倒な証明書管理からおさらばできれば良かったのですが、まだ難しい感じがあります。

プレビュー中だけなのかわかりませんが、今のところ以下の制限があります。Zone Apex 非対応は厳しい。

  • ワイルドカード証明書は非対応
  • Zone Apex なドメインは非対応(CNAME しか使えない気配)
  • 複数ドメインに対応した証明書は非対応
  • PFX のエクスポートは非対応

App Service Certificate で購入した証明書は Key Vault に格納されるので他のサービスにも使えましたが、Managed Certificate は App Service 用のリソースとして追加されるので完全に専用です。

とりあえず実際に適当なドメインに対して証明書を発行してみました。Azure Portal を開くと Managed Certificate のボタンが追加されているので分かりやすいです。

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

その後は対象となるドメインを選択して Create するだけなので、手順は非常にシンプルです。

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

ドロップダウンリストにはサポートされているドメインしか出てこないので安心です。Zone Apex なドメインやワイルドカードドメインも追加していましたが、フィルタリングされて出てきませんでした。

数十秒後には証明書が作成されましたので、適当に中身を確認しておきます。

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

見てわかるように、GeoTrust が発行した 6 か月有効な証明書でした。ちなみに App Service Certificate は Go Daddy、Azure CDN / Front Door は DigiCert が発行した証明書なのでごちゃごちゃしてます。

Microsoft が Root CA になれば AWS の Certificate Manager みたいにシンプルになりそうですが、当分その未来は来ない感じがありますし、まだ改善の余地はありそう。

Azure App Service の Clone App 機能が Standard でも使えるようになっていた

これまでは App Service の Premium 以上でしか使えなかった Clone App 機能が、いつの間にかに Standard でも使えるようになっていました。ドキュメントの履歴を見る限りでは 6 月ぐらいには Standard でも解放されていたようですが、アナウンスはされてない感があります。

Docs の更新日時は 2016 年から止まってますが、単にタイムスタンプが更新されてないだけでした。

Clone App の利用用途はドキュメントに書いてあるようにリージョンを変更したり、App Service から App Service Environment への移行などが考えられますが、個人的には Scale unit の変更や HA / DR サイトの構築に使えるかなと思っています。

Premium V2 や新しい VNET Integration を利用するためには新しい Scale unit 上に App Service Plan を作る必要があります。Webspace の関係で別リソースグループに作る必要があるので複雑ですが、多少は Webspace と App Service Plan の制約については書いてきているので、気になる方は参考にしてください。

Clone 後の App Service Plan が別 Scale unit に載っている場合は Inbound / Outbound IP が変わるので、そういう点では注意が必要です。

これまで Premium 限定だったので真面目に触る気がなかったのですが、Standard なら使いやすくなったと思います。とりあえず Clone App を初めて使ってみることにします。

Clone App を試す

Standard の App Service Plan 上で動いてる App Service を開いて Clone App を選ぶと、以下のようなページが表示されるようになっているはずです。

Clone のタイミングで Application Insights の新規作成や既存へのリンクが行えるのは便利です。

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

それ以外にもいくつかの設定項目があって、Clone 対象の設定をいくつか選べました。

カスタムドメイン周りの設定は移行が面倒なので、サクッと Clone したいですがまだ Azure Portal から対象には選べないようです。この辺りが最近用意されたものなのかはわかりません。

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

気になっていた証明書周りは全く移行されないみたいだったので、基本的には証明書系は Key Vault に格納して App Service から参照するように構築するのが吉です。

Azure Portal からの Key Vault 証明書のインポートは簡単になったので、寄せておくと幸せになれます。

カスタムドメインは Azure Portal からは Clone 対象になりませんでしたが、ドメインの所有確認は出来ているので DNS 周りを弄ることなくドメインの追加作業自体は行えます。

他にも Traffic Manager と連携する機能も用意されているらしいので、DR サイトは簡単に作れそうです。

ARM Template で実行する

Clone App の画面に Automation options のリンクが用意されていて、それを選ぶと ARM Template がダウンロードできました。多少整形したテンプレートが以下になります。

{
  "$schema": "http://schema.management.azure.com/schemas/2014-04-01-preview/deploymentTemplate.json#",
  "contentVersion": "1.0.0.0",
  "parameters": {
    "name": {
      "type": "string"
    },
    "hostingPlanName": {
      "type": "string"
    },
    "hostingEnvironment": {
      "type": "string"
    },
    "location": {
      "type": "string"
    },
    "sku": {
      "type": "string"
    },
    "skuCode": {
      "type": "string"
    },
    "workerSize": {
      "type": "string"
    },
    "serverFarmResourceGroup": {
      "type": "string"
    },
    "subscriptionId": {
      "type": "string"
    }
  },
  "resources": [
    {
      "apiVersion": "2016-09-01",
      "name": "[parameters('hostingPlanName')]",
      "type": "Microsoft.Web/serverfarms",
      "location": "[parameters('location')]",
      "properties": {
        "name": "[parameters('hostingPlanName')]",
        "workerSizeId": "[parameters('workerSize')]",
        "numberOfWorkers": "1",
        "hostingEnvironment": "[parameters('hostingEnvironment')]"
      },
      "sku": {
        "Tier": "[parameters('sku')]",
        "Name": "[parameters('skuCode')]"
      }
    },
    {
      "name": "[parameters('name')]",
      "type": "Microsoft.Web/sites",
      "properties": {
        "cloningInfo": {
          "SourceWebAppId": "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/<ResourceGroup>/providers/Microsoft.Web/sites/<SiteName>",
          "CloneSourceControl": true,
          "CloneCustomHostNames": false
        },
        "name": "[parameters('name')]",
        "siteConfig": {
          "appSettings": []
        },
        "serverFarmId": "[concat('/subscriptions/', parameters('subscriptionId'),'/resourcegroups/', parameters('serverFarmResourceGroup'), '/providers/Microsoft.Web/serverfarms/', parameters('hostingPlanName'))]",
        "hostingEnvironment": "[parameters('hostingEnvironment')]"
      },
      "apiVersion": "2018-11-01",
      "location": "[parameters('location')]",
      "dependsOn": [
        "[concat('Microsoft.Web/serverfarms/', parameters('hostingPlanName'))]"
      ]
    }
  ]
}

App Service Plan から作成しているので多少長いですが、重要なのは cloningInfo の設定だけのようです。

ARM Template のリファレンスに cloningInfo で設定できる項目が載っていますが、何処までちゃんと動くのかは確認できていません。カスタムドメインの設定もあるので、有効化できる気がしますが未確認です。

本来なら Clone など行わずに ARM Template や Terraform の定義レベルで複数同じ設定の App Service が作成できるので、新規開発の場合はそっちを利用するべきです。これはあくまでも既存の App Service 向けです。

日本国内の場合は IaC 導入前に Japan East に作成していた App Service を Japan West に Clone して、Traffic Manager で分散という構成を手間なく構築できそうです。

ARM Template を使って Azure Web Apps / Azure Functions の作成と同時に Zip Deploy まで行う

昔から ARM Template では Web Deploy を使ったトラディショナルなデプロイに対応していましたが、ちょっと前に Windows の Web Apps と Azure Functions では Zip Deploy が使えるようになっていたようです。

ちなみに Web Deploy を使ったデプロイは、昔からちょこちょこドキュメントで出てきます。

例によって Zip Deploy を行う ARM Template のリファレンスは調べた感じでは見つかりませんでしたが、Kudu のコミットログを漁ることで Web Deploy とほぼ同じ書き方で使えることがわかりました。

具体的に Zip Deploy を実行するための定義は以下のようになります。packageUri にはデプロイしたいアプリケーションを zip にしたものを指定します。

{
  "apiVersion": "2019-08-01",
  "type": "extensions",
  "name": "zipdeploy",
  "dependsOn": [
    "[resourceId('Microsoft.Web/sites', variables('webAppPortalName'))]"
  ],
  "properties": {
    "packageUri": "https://shibayan.blob.core.windows.net/AspNetCoreApp.zip"
  }
}

ぱっと見は Run From Package で URL を指定するのと違いが判らないかもしれませんが、この Zip Deploy API を使うと、常にリモートからファイルをダウンロードする前者の方法とは異なり、Visual Studio や Azure Pipelines から行う Zip Deploy と同じになります。

なのでスタートアップ時にリモートからファイルをダウンロードしないので、Run From Package と組み合わせるとコールドスタートの改善が期待できます。ファイル自体も単純な zip で良いので作りやすいです。

デプロイと同時に Zip Deploy を実行

とりあえず Web Apps 向けの ARM Template を用意してデプロイを試してみます。以下のようなテンプレートを作成しましたが、重要なのは Zip Deploy を行う最後のリソースです。

{
  "$schema": "https://schema.management.azure.com/schemas/2015-01-01/deploymentTemplate.json#",
  "contentVersion": "1.0.0.0",
  "parameters": {
    "webAppName": {
      "type": "string",
      "metadata": {
        "description": "Base name of the resource such as web app name and app service plan"
      },
      "minLength": 2
    },
    "sku": {
      "type": "string",
      "defaultValue": "S1",
      "metadata": {
        "description": "The SKU of App Service Plan, by default is Standard S1"
      }
    },
    "location": {
      "type": "string",
      "defaultValue": "[resourceGroup().location]",
      "metadata": {
        "description": "Location for all resources"
      }
    }
  },
  "variables": {
    "webAppPortalName": "[concat(parameters('webAppName'), '-app')]",
    "appServicePlanName": "[concat('ASP-', parameters('webAppName'))]"
  },
  "resources": [
    {
      "apiVersion": "2019-08-01",
      "type": "Microsoft.Web/serverfarms",
      "kind": "app",
      "name": "[variables('appServicePlanName')]",
      "location": "[parameters('location')]",
      "sku": {
        "name": "[parameters('sku')]"
      }
    },
    {
      "apiVersion": "2019-08-01",
      "type": "Microsoft.Web/sites",
      "kind": "app",
      "name": "[variables('webAppPortalName')]",
      "location": "[parameters('location')]",
      "dependsOn": [
        "[resourceId('Microsoft.Web/serverfarms', variables('appServicePlanName'))]"
      ],
      "properties": {
        "serverFarmId": "[resourceId('Microsoft.Web/serverfarms', variables('appServicePlanName'))]"
      },
      "resources": [
        {
          "apiVersion": "2019-08-01",
          "type": "extensions",
          "name": "zipdeploy",
          "dependsOn": [
            "[resourceId('Microsoft.Web/sites', variables('webAppPortalName'))]"
          ],
          "properties": {
            "packageUri": "https://shibayan.blob.core.windows.net/AspNetCoreApp.zip"
          }
        }
      ]
    }
  ]
}

Azure Portal から Template Deployment を使って実行すると、すんなりデプロイが完了しました。

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

多少ははまるかなと思っていましたが、あまりにも簡単だったので拍子抜けです。

デプロイ後に Web App にアクセスしてみると、zip に含まれていたアプリケーションが実行されていました。

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

これで問題なく Zip Deploy が ARM Template でも行えるようになっていることを確認できました。

Run From Package としてデプロイする

単なる Zip Deploy だけでは効果が薄いので、もちろん Run From Package としてデプロイしてみます。テンプレートは省略しますが App Settings に WEBSITE_RUN_FROM_PACKAGE = 1 を追加するだけなので簡単です。

テンプレートのデプロイ後に App Settings を確認すると、ちゃんと設定が追加されています。

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

これだけだとわからないので、Kudu から Zip Deploy のログを確認します。Project Type が Run-From-Zip となっているので、ちゃんと Run From Package としてデプロイされていることが確認できました。

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

もちろん Web App にアクセスすると、問題なくアプリケーションが実行されています。

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

Web Deploy はパッケージを作るのすら面倒だったのと、デプロイが Atomic ではなく不安定になりがちだったので、Zip Deploy が使えるようになったのはとても嬉しいです。

Azure Functions + Run From Package でのデプロイ

最後に Zip Deploy と Run From Package が一番重要となる Azure Functions でも試しておきます。テンプレートは既に何回かブログでも書いてきたので、そっちを参照してください。

重要なのは Zip Deploy を実行する定義なので、それ以外は割とどうでもよい感じです。

一応 ARM Template 全体は Gist に載せておいたので、興味がある方は参照してください。

Azure Functions + Run From Package ARM Template sample · GitHub

このテンプレートを使って Azure Portal からデプロイしてみると、これもすんなり完了しました。

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

デプロイされた Function を開いてみると、ちゃんと Read Only になっているので Run From Package としてデプロイされています。

適当に追加した HttpTrigger の Function も見えていますし、テスト実行も問題なく行えました。

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

Consumption Plan の場合はリモートファイルを指定した Run From Package は起動時のオーバーヘッドが大きいので、自動でのアプリ更新が必要ない場合は ARM Template を使った Zip Deploy が良いと思います。

Linux への ARM 経由 Zip Deploy は未サポート

Linux Functions の Consumption Plan では Zip Deploy が必須ですが、まだ ARM Template を使ったデプロイは対応してませんでした。Pull Request はあったので、次のリリース時に対応されるようです。

Run From Package は Linux Functions でも使えるようになっていますが、ちゃんとしたドキュメントがまだ出ていない気もします。Premium Plan でも重要になってくるので、この辺りは改善を期待したいです。

App Service へのデプロイ方法を Zip Deploy に制限する

既に Azure Pipelines などを使って自動デプロイの環境を構築している場合は、ほぼ全てで Zip Deploy が使われているはずですが、App Service には FTP と Web Deploy がデプロイ方法として提供されているので、塞いでおかないと Visual Studio から手動デプロイされてしまい壊れることがあります。

まだ Visual Studio から本番環境へのデプロイを行っている場合は、速やかに自動デプロイに切り替えましょう。デプロイの安定化とパフォーマンスのために Run From Package を利用するのも良いです。

今の時代、開発中はともかく本番向けデプロイを Visual Studio から行うのは、履歴もバージョン管理も出来ないので速攻で詰みます。

そろそろ Azure Pipelines の Multi-stage pipelines は GA が近いので触っておくのも良いです。

話を戻すと App Service は FTP の無効化は Azure Portal から簡単に行えますが、Web Deploy に関しては設定が存在しないのを何とか無効化しようという話です。

App Service 側を塞いでおくと、どう頑張っても Visual Studio からのデプロイは行えなくなります。

FTP を無効化する

先に FTP をサクッと塞いでおきます。Configuration にある FTP state を Disabled に変えるだけです。

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

これで FTP での接続が行えなくなるので、ファイルシステムを直接弄られる危険性はなくなりました。

Web Deploy を無効化する

App Service では Web Deploy は Site Extension として実装されているので、App Settings に特殊なキーを追加することで個別に無効化できます。

具体的なキー名は SiteExtensionName_EXTENSION_VERSION となります。Site Extension の名前は Kudu を使ってフォルダ名を調べればよいです。

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

キー名が示すように Azure Functions ではランタイムバージョンを指定するのに使っていますが、このキーに disabled を設定すると無効化されます。

実際にキーを設定後に Visual Studio からデプロイを試してみるとエラーとなります。

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

App Service には他にも IIS Manager からの接続を行うための Site Extension などもあるので、必要に応じて無効化しておいても良いかもしれません。

Kudu / API 経由でのアクセスを無効化する

App Service に対してある意味最強の操作が行える Kudu ですが、これを塞ぐことは出来ません。内部の API は Azure 内部でも使われているので、無理やり止めようとすると操作不能に陥ります。

なので現実的な解決策としては RBAC を使って、ユーザーに対して Reader 権限だけ割り当ててしまうことです。これで読み取りのみになるので Kudu へのアクセスがブロックされます。

デプロイ周りの自動化を行うと Azure Portal や ARM API へのアクセスを絞っても問題がなくなるので、いい感じに Azure Pieplines や RBAC を活用して不用意にリソースを弄らせない、そもそも直アクセスする必要がなくなるようにしましょう。

Easy Auth を有効にした Azure Functions と ClaimsPrincipal バインディングの挙動を確認した

API Management を使えば Azure AD を使った認証をサクッと有効化出来ますが、もっとライトに組み込みたいと思ったので Azure Functions の機能を使って同じように実現できるかを調べました。

当然ながら Easy Auth は App Service の機能なので、Azure Functions に限定した内容ではないです。

Easy Auth はいろんなプロバイダが使えますが、基本的には Azure AD を使った認証を使って試しました。Managed Identity や Microsoft Graph と組み合わせたり、地味に使いどころが多いのが Azure AD です。

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

Windows App Service の Easy Auth は IIS Module として実装されているので、アプリケーションにトラフィックが届く前に色々処理が挟まります。

特に未ログイン状態の時の挙動が変わるので、これを機に気になる部分を試しました。

Easy Auth 有効後の未ログイン時レスポンス

Web API の場合は未ログイン時にログインページへリダイレクトされたら困るので、適当な API を作りいろんなパターンでリクエストを投げて確認して結果をまとめました。

  • UA 関係なく X-Requested-With = XMLHttpRequest の場合
    • 403 Forbidden を返す
  • UA がブラウザかつ X-Requested-With = XMLHttpRequest 以外の場合
    • リダイレクト用の HTML が返される (200 OK)
  • それ以外の場合(例 : UA / X-Requested-With がない)
    • 401 Unauthorized を返す

ちょっとわかりにくいですが、Web API として呼ばれたであろう時は 401 か 403 を返します。普通に XHR でブラウザからリクエストを投げた場合は 403 が返ってくるので分かりやすいです。

ブラウザかどうかの判定は Mozilla/ が UA の先頭にあるかを見てるだけっぽいです。結構ザル判定なので、User-Agent を HttpClient で指定する時には気を付けたい感じです。

あとフローによっては 401 と 403 と別々のステータスコードが返ってくるのに注意したいです。エラーハンドリングをステータスコードで見ている場合、素通りしてしまうこともありそうです。

ClaimPrincipal を使って認証状態を確認する

Easy Auth は認証周りを全部 IIS Module が行ってくれるので、実際にアプリから利用する場合は Claims を復元する必要がありますが、Azure Functions は自動で復元して HttpContext.User かバインディングで ClaimsPrincipal を渡してくれます。

以下のような Function を用意して、どのような Claims が渡されるか確認しました。Easy Auth は Azure AD を有効化しているので、他のプロバイダよりも Claims が多いです。

public static class Function1
{
    [FunctionName("Function1")]
    public static async Task<IActionResult> Run(
        [HttpTrigger(AuthorizationLevel.Anonymous, "get", "post", Route = null)] HttpRequest req,
        ILogger log)
    {
        var claimsPrincipal = req.HttpContext.User;

        var builder = new StringBuilder();

        builder.AppendLine($"Name = {claimsPrincipal.Identity.Name ?? "(null)"}");
        builder.AppendLine($"IsAuthenticated = {claimsPrincipal.Identity.IsAuthenticated}");
        builder.AppendLine($"AuthenticationType = {claimsPrincipal.Identity.AuthenticationType ?? "(null)"}");

        foreach (var claim in claimsPrincipal.Claims)
        {
            builder.AppendLine($"{claim.Type} = {claim.Value}");
        }

        return new OkObjectResult(builder.ToString());
    }
}

今回試して初めて知りましたが、Easy Auth 以外に Function Key や Host Key を使った認証の場合でも ClaimsPrincipal はセットされるようでした。

なので HttpContext.User を参照すれば認証の種類に関係なく、簡単にログイン済みか判別できます。

キー・トークン未指定
Name = (null)
IsAuthenticated = False
AuthenticationType = (null)
Function Key
Name = (null)
IsAuthenticated = True
AuthenticationType = WebJobsAuthLevel
http://schemas.microsoft.com/2017/07/functions/claims/authlevel = Function
http://schemas.microsoft.com/2017/07/functions/claims/keyid = default
Host Key (default)
Name = (null)
IsAuthenticated = True
AuthenticationType = WebJobsAuthLevel
http://schemas.microsoft.com/2017/07/functions/claims/authlevel = Function
http://schemas.microsoft.com/2017/07/functions/claims/keyid = default
Host Key (_master)
Name = (null)
IsAuthenticated = True
AuthenticationType = WebJobsAuthLevel
http://schemas.microsoft.com/2017/07/functions/claims/authlevel = Admin
http://schemas.microsoft.com/2017/07/functions/claims/keyid = master
Easy Auth (Azure AD / MSA)
Name = me@shibayan.jp
IsAuthenticated = True
AuthenticationType = aad
aud = 00000000-0000-0000-0000-000000000000
iss = https://sts.windows.net/00000000-0000-0000-0000-000000000000/
iat = 1572192363
nbf = 1572192363
exp = 1572196263
aio = XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
http://schemas.microsoft.com/claims/authnmethodsreferences = pwd
c_hash = _CV4pcEcUNZnJPI3eAsdmg
http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress = me@shibayan.jp
http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname = Shibamura
http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname = Tatsuro
http://schemas.microsoft.com/identity/claims/identityprovider = live.com
ipaddr = xxx.xxx.xxx.xxx
name = shibayan
nonce = 58b2598f76a84491ab5931c862f7fe4c_20191027161602
http://schemas.microsoft.com/identity/claims/objectidentifier = 00000000-0000-0000-0000-000000000000
http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier = XXXXXXXXX-XXXXXXXXXXXXXXXXXXXXX_XXXXXXXXXXX
http://schemas.microsoft.com/identity/claims/tenantid = 00000000-0000-0000-0000-000000000000
http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name = live.com#me@shibayan.jp
uti = hII4-LTwnkqDubPwk0-sAA
ver = 1.0
wids = 00000000-0000-0000-0000-000000000000
Easy Auth (Azure AD / Bearer Token)
Name = 00000000-0000-0000-0000-000000000000
IsAuthenticated = True
AuthenticationType = aad
aud = https://easyauth-api.azurewebsites.net
iss = https://sts.windows.net/00000000-0000-0000-0000-000000000000/
iat = 1572157734
nbf = 1572157734
exp = 1572186834
aio = 42VgYFB78dtXa23ZvvmV0dNO3y3wAQA=
appid = 00000000-0000-0000-0000-000000000000
appidacr = 2
http://schemas.microsoft.com/identity/claims/identityprovider = https://sts.windows.net/00000000-0000-0000-0000-000000000000/
http://schemas.microsoft.com/identity/claims/objectidentifier = 00000000-0000-0000-0000-000000000000
http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier = 00000000-0000-0000-0000-000000000000
http://schemas.microsoft.com/identity/claims/tenantid = 00000000-0000-0000-0000-000000000000
uti = o151aNRqEUuy5TiJ9xBiAA
ver = 1.0

最後に書いてある Easy Auth (Azure AD / Bearer Token) というのはちょっと特殊な使い方です。微妙に Undocumented な使い方なので、いろいろと調べた後にブログにまとめておこうと思います。

Bearer Token を Managed Identity で取得できるようになると、Azure AD を使った認証をユーザーと関係ない部分で簡単に使えるようになるので、認証周りのパターンを作れると便利になると思っています。

ASP.NET Core と Azure でアプリケーション設定が破綻しないように管理したい

最近は App Configuration と Key Vault を使っていい感じにアプリケーションの設定を扱う方法をいろいろと考えていましたが、App Configuration を使う必要が本当にあるのかと思い始めたので書き出して整理します。

今回実現したい内容は以下の通りになります。Infrastructure as a Code のアプリケーション設定版という感じです。Configuration as a Code とか言うのかも知れません(未確認)

  • 設定値のバージョン管理は必須
  • 設定の反映抜けや漏れを無くす仕組み
    • Azure Portal からの設定はさせたくない(絶対事故る)
  • 複数のアプリケーションで設定を共有したい

App Service には WEBSITE_* という Prefix から始まる設定値がいくつか存在していますが、これはインフラ側の設定になるので ARM Template や Terraform で管理します。

今回は ASP.NET Core でいうところの appsettings.json 部分をいい感じに管理する方法を考えます。

CI + App Settings を使う

Azure Pipelines を使うと App Service にある App Settings をデプロイと同じタイミングで設定が出来ます。

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

一応 YAML ベースでアプリケーション設定を管理出来ますが、App Service の App Settings は ASP.NET Core / Azure Functions ではインフラ側設定と接続文字列の管理にのみ使う方が良さそうです。

特に Azure Functions の Trigger が使う接続文字列は Connection Strings じゃなくて App Settings が必須なので Key Vault に格納しにくいです、ARM Template や Terraform で管理するのが鉄板な気がします。

接続文字列系は Key Vault Reference を使うことも出来そうですが、割と使い勝手悪いです。なので ARM Template や Terraform などで設定するようにする方が良いという結論です。

appsettings.json を使う

ASP.NET Core のプロジェクトを作成すると、デフォルトで appsettings.jsonappsettings.Development.json の 2 つが付いてくるので、実行中の再読み込みが必要ない場合は素直にこれを利用するのが手っ取り早いです。

テンプレートでは Development 向けのファイルのみ作られますが、App Service の App Settings を使わない場合は Production 向けのファイルを追加した方が扱いやすいです。

  • appsettings.json
    • appsettings.Development.json
    • appsettings.Production.json ← 新しく作成する

問題となるのが Azure Functions ですが、ランタイムに関係ない部分のアプリケーション設定に関しては ConfigurationBuilder を使うことで appsettings.json に設定を書けるようになります。

この方法だと必ずアプリケーションと設定がセットでデプロイされるのと、履歴も Git で同時に管理出来ます。複数のアプリケーションで共通化する場合には csproj を弄ってしまうのが手っ取り早いかと思います。

以下のような csproj を書くと、別ディレクトリのファイルをプロジェクトにリンク追加出来ます。

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

  <PropertyGroup>
    <TargetFramework>netcoreapp3.0</TargetFramework>
  </PropertyGroup>

  <ItemGroup>
    <Content Include="..\Shared\appsettings.Production.json" Link="appsettings.Production.json">
      <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
    </Content>
  </ItemGroup>

</Project>

編集後、ソリューションエクスプローラーからはこれまで通りプロジェクト内にあるように見えます。プロジェクトの発行時にはちゃんと成果物に含まれるので、特に意識せずに扱えます。

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

更に手っ取り早い方法としては、シンボリックリンクでファイルを共通化する方法もありますが、Windows 上だと扱いが少し面倒な部分もあるので悩ましいところです。

今のところは共通化する部分は開発環境向け以外の設定で良いかなと思っています。

App Configuration を使う

Key Vault のようにアプリケーション設定を管理するサービスが App Configuration ですが、深く使おうとすると中々癖のあるサービスだと気が付いてきました。

これまでに何回か試してブログに書いてきましたが、基本的な考え方として開発環境を含めた全てを App Configuration にまとめることで、環境やアプリでの設定を一元管理してきました。

理想的な構成ではありますが、現実的には開発環境で設定を追加する際に Visual Studio で完結しないのはかなり手間がかかる作業です。接続文字列はともかく、アプリケーション設定を追加する場合は毎回 Azure Portal や Azure CLI での操作が必要になります。

一人やかなり小さいチームなら回るかもしれないですが、それ以上になると混乱が目に見えます。

それらのデメリットを相殺できるのが、実行時に再起動なしに再読み込みする機能だったり、Feature Management の活用だったりするわけですが、裏を返せばこれらの機能を使わない限りメリットはかなり少ないな、というのが触ってみた感想です。

とはいえ、JSON を使って定義する場合とは異なり、柔軟にアプリケーション設定を組み合わせ出来ることは確かです。以下のようにサイト名で Label を定義しておけば、サイト単位での設定を定義できます。

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

App Configuration は初期化時にどのラベルを使うかを定義できるので、以下のように書くとちゃんとサイト名の Label を付けた設定値が読み込まれます。

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

    public static IHostBuilder CreateHostBuilder(string[] args) =>
        Host.CreateDefaultBuilder(args)
            .ConfigureWebHostDefaults(webBuilder =>
            {
                webBuilder.ConfigureAppConfiguration(config =>
                          {
                              if (!context.HostingEnvironment.IsDevelopment())
                              {
                                  var builtConfig = config.Build();

                                  config.AddAzureAppConfiguration(options =>
                                      options.ConnectWithManagedIdentity(builtConfig["AppConfig:Endpoint"])
                                             .Use(KeyFilter.Any)
                                             .Use(KeyFilter.Any, Environment.GetEnvironmentVariable("WEBSITE_SITE_NAME")));

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

適当に App Service にデプロイすると、設定した値が読み込まれていることが確認できます。

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

App Service のスロット毎にしたい場合は WEBSITE_SLOT_NAME を、ASP.NET Core の環境毎にしたい場合は IWebHostEnvironment.EnvironmentName を参照するようにすれば良いです。

Azure CLI を使うと JSON からのインポートが行えるので、リポジトリで JSON を管理しつつ Azure Pipelines を使ってのデプロイ可能ですが、設定とアプリケーションのデプロイタイミングがバラバラになるのは、順番が変わると事故りそうです。

Feature Management を組み合わせるのが一番メリットが大きそうです。新しい機能のリリースはデプロイで行うより、Feature Management を使った方が素早く再起動なく行えるのが便利です。

App Service の Deployment Slot を使って Swap でリリースも出来ますが、ウォームアップに時間がかかったりもするのでメリデメ考えて使いたいところです。

どの方法を選択するのか

とりあえず色々と書き出してみましたが、まとめると以下のような内容になるかなと思います。最初は全て App Configuration で良いかなと思ってましたが、実際はそんなことはなかったです。

  • 共通となる考え方
    • App Service の App Settings は ARM Template / Terraform などの IaC で管理する
  • アプリケーション設定は起動時に 1 度読み込まれれば良い場合
    • appsettings.json を使って環境毎に管理する
  • アプリケーション設定の実行時変更、Feature Management が必要な場合
    • App Configuration を使って管理する
    • 設定値自体は別の方法で管理する必要がある(Azure Portal からの手動設定は無し)

App Configuration を使うと、今度はそこの管理をどうするかという問題が出てきます。変更履歴は持っているので問題ないですが、開発中に設定値を変更した場合のオペレーションが手間になる未来が見えます。

だからといって JSON と Git で管理して CI で反映という形にすると、わざわざ App Configuration を使う必要があるのかという話になってくるので、個人的には大半のケースで appsettings.json に統一で良いかなと考えています。もちろんシークレットなデータは Key Vault を使いますが。

既存の Azure Resource を Terraform での管理に切り替える

最初から Terraform を使って Azure のリソースを作成できれば問題ないですが、多かれ少なかれ既に手動で作成済みのリソースがあって、それを Terraform 管理下に入れたいケースが多いと思います。

既に Azure と Azure Pipelines での Terraform の利用については前回書いたので省略します。

今回は作成済みリソースを Terraform の管理下に入れる手順を試しておいたので、手順とはまったポイントをメモとして残します。今回のターゲットは 8 年前ぐらいに作ったリソースです。

リソースグループを丸ごと Terraform で管理するようにしますが、古いリソースはロケーションがぐちゃぐちゃなので上手くパラメータ化が出来ませんでした。今回、そこは妥協しました。

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

数が少ないので手作業で tf ファイルを書くのはギリセーフという感じです。本当なら terraformer とかを使って tf ファイルと tfstate を自動生成する方が安心です。

一応 terraformer は Azure にも対応していましたが、対応しているリソースが Resource Group だけなので今のところ使いものにならないです。

List of supported Azure resources:

  • resource_group
    • azurerm_resource_group

とはいえサポートが始まったばかりなので、今後対応リソースが増えるとは思います。地味に tf ファイルを書く部分がしんどいので、コントリビュートしようかなという気持ちになってきました。

とりあえず今回は手作業で Azure のリソースを Terraform での管理に切り替えていきました。

terraform import で管理下に入れる

terraform には既存のリソースをインポートするコマンドが用意されているので、これを使って tfstate を更新していきます。これだけで管理下に入れることができます。

terraform import を実行する前に、tf ファイルにインポートするリソースの定義を追加しておきます。中身はこれから書いていくので空のままで良いです。

resource "azurerm_resource_group" "default" {
}

resource "azurerm_app_service_plan" "default" {
}

resource "azurerm_app_service" "shibayan" {
}

resource "azurerm_storage_account" "shibayan" {
}

tf ファイルにリソースを追加したら、後は terraform import で 1 つずつインポートしていきます。

terraform import azurerm_resource_group.default /subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/Default-Web-JapanEast

Azure は全てのリソースが一意な ID を持っているので、何も考えずにリソース ID を指定すれば良いです。

リソース ID は Azure CLI や ARM Explorer を使って確認しても良いですし、Azure Portal の Properties を開いてもリソース ID を確認できます。

tf ファイルを頑張って書く

リソースのインポートが終わったら、後はひたすら tf ファイルを書いていきます。terraform state show を使うと tfstate ファイルからある程度生成できるので、上手いこと使っていきましょう。

\shibayan-terraform>terraform state show azurerm_resource_group.default
# azurerm_resource_group.default:
resource "azurerm_resource_group" "default" {
    id       = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/Default-Web-JapanEast"
    location = "southcentralus"
    name     = "Default-Web-JapanEast"
    tags     = {}
}

ただし全てを追加するのは NG なので、この場合は id は除外して tf ファイルに追加します。

resource "azurerm_resource_group" "default" {
  name     = "Default-Web-JapanEast"
  location = "southcentralus"
}

App Service の場合はリソースグループ名や App Service Plan の ID を指定する必要がありますが、べた書きせずにちゃんと Terraform 内のリソース参照で解決するようにします。

terraform plan で差分を確認

tf ファイルが書けたら terraform plan を実行して差分を確認します。今回の場合は既に存在するリソースが正なので、差分が出ないようにプロパティの追加や修正をします。

闇雲に tf ファイルのプロパティを増やしたくなかったので、ある程度デフォルト値を使いながら書きました。

大体は問題なく Terraform 管理下に切り替えできていましたが、App Service Plan だけ以下のように常にリソースの作り直しが要求されるという状態になりました。

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

大半のプロパティが追加になっていて明らかにおかしいです。調べてみると以下の Issue が見つかりました。

Azure Portal からコピーしたリソース ID は serverFarms と camel case になっていますが、Terraform Provider 側は serverfarms というように全て小文字で扱っているため、terraform import 時に tfstate が壊れるらしいです。最悪の挙動にあたりました。

terraform state rm を使って tfstate から削除した後、修正したリソース ID を使って再度 terraform import を実行すると解消します。

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

これで terraform plan での差分が出なくなったので、管理下に入れることが出来ました。

完成した tf ファイルの例と terraform apply

今回作成した tf ファイルは以下のようになりました。リソースグループが South Central US に居るので、リソースグループのロケーションを使って共通化することが出来ませんでした。

ファイルのインデントは terraform fmt を使うと綺麗に整えてくれます。

resource "azurerm_resource_group" "default" {
  name     = "Default-Web-JapanEast"
  location = "southcentralus"
}

resource "azurerm_app_service_plan" "default" {
  name                = "Default2"
  location            = "japaneast"
  resource_group_name = "${azurerm_resource_group.default.name}"
  kind                = "app"

  sku {
    tier = "Standard"
    size = "S1"
  }
}

resource "azurerm_app_service" "shibayan" {
  name                = "shibayan"
  location            = "${azurerm_app_service_plan.default.location}"
  resource_group_name = "${azurerm_resource_group.default.name}"
  app_service_plan_id = "${azurerm_app_service_plan.default.id}"
  https_only          = true

  app_settings = {
    "WEBSITE_RUN_FROM_PACKAGE" = "1"
  }

  site_config {
    default_documents = [
      "index.html",
    ]
    ftps_state                = "Disabled"
    http2_enabled             = true
    min_tls_version           = "1.2"
    scm_type                  = "VSTSRM"
    use_32_bit_worker_process = true
  }
}

resource "azurerm_storage_account" "shibayan" {
  name                     = "shibayan"
  resource_group_name      = "${azurerm_resource_group.default.name}"
  location                 = "japaneast"
  account_kind             = "StorageV2"
  account_tier             = "Standard"
  account_replication_type = "LRS"
}

何か適当に App Service の設定を変更して terraform apply を実行すると、Azure 上のリソースにちゃんと反映されます。後は Git で管理するようにして、Azure Pipelines で CI を組めば完成です。

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

次は Staging / Production といったように、環境毎にリソースを作れるような Terraform 定義を作って試してみようかと思っています。後は同じ設定で別名のリソースをループで作ってみるとか。

Azure Pipelines の Approval と組み合わせれば、かなり使い勝手が良いと思うので楽しみです。

Cosmos DB .NET SDK v3 を使って快適に LINQ を書くコツ

Cosmos DB .NET SDK v2 の時は LINQ への変換が結構イマイチで、直接 SQL を書くことが多かったですが v3 ではかなり改善されているので、大体のクエリは LINQ だけで書けるようになっていました。

対象が v2 か v3 なのかハッキリしませんが、ドキュメントも用意されています。

.NET SDK v3 の簡単な使い方については、前にチートシートという形で書きました。

今回はもうちょっと LINQ に絞って実際に使いそうなクエリに絞りました。基本は非同期で書いていくべきなので、そういう書き方しかしていません。

テスト用の共通コードは以下のようなものを用意しました。雑なデータモデルです。

public class Entry
{
    [JsonProperty("id")]
    public string Id { get; set; }

    [JsonProperty("title")]
    public string Title { get; set; }

    [JsonProperty("body")]
    public string Body { get; set; }

    [JsonProperty("tags")]
    public IList<string> Tags { get; set; }

    [JsonProperty("authors")]
    public IList<Author> Authors { get; set; }

    [JsonProperty("createdAt")]
    public DateTimeOffset CreatedAt { get; set; }
}

public class Author
{
    [JsonProperty("name")]
    public string Name { get; set; }
}
class Program
{
    static async Task Main(string[] args)
    {
        var connectionString = "<connection string>";

        var cosmosClient = new CosmosClient(connectionString, new CosmosClientOptions
        {
            ConnectionMode = ConnectionMode.Direct
            }
        });

        var container = cosmosClient.GetContainer("Blog", "Entry");
    }
}

テストデータは以下のようなものを入れておきました。モデルに従ってそれっぽいものを入れています。

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

このコードとデータをベースに LINQ をいろいろ書いて試していきます。

LINQ Provider が生成したクエリを調べる

まずは LINQ がどのように SQL に変換されるのか調べる方法ですが、単純に ToQueryDefinition を呼び出すと SQL への変換結果が返ってきます。

var query = container.GetItemLinqQueryable<Entry>()
                     .Select(x => new { x.Id, x.Title, x.Body, x.Authors[0].Name })
                     .ToQueryDefinition();

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

上のクエリ式は実際には以下のような SQL に変換されます。

SELECT VALUE {"Id": root["id"], "Title": root["title"], "Body": root["body"], "Name": root["authors"][0]["name"]} FROM root

返ってきた QueryDefinition はそのまま GetItemQueryIterator に渡せば実行できます。

Paging (OFFSET / LIMIT) の注意点

今年のアップデートで OFFSET / LIMIT が使えるようになったので、これまでのような Continuation Token を使ったページングではなく、RDB などに近い形でページングが実装できるようになりました。

SQL を直接書く分には問題ないですが、v3 SDK では TakeSkip の書き順によって変換される SQL が大きく変わるので注意が必要でした。

var query = container.GetItemLinqQueryable<Entry>()
                     .Take(10)
                     .Skip(10)
                     .ToQueryDefinition();

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

上のコードのように Take を先に書くと SELECT TOP への変換が優先されるらしく、変換された SQL はサブクエリを使ったものになりました。

SELECT VALUE r0 FROM (SELECT TOP 10 VALUE root FROM root ) AS r0 OFFSET 10 LIMIT 2147483647

動きそうな SQL ですが、実行するとサブクエリで TOP は指定できないというエラーになります。サブクエリは ORDER BY が使えなかったり地味に制約が多いです。

今度は先に Skip 書いたコードで試してみます。本当に順番を入れ替えただけです。

var query = container.GetItemLinqQueryable<Entry>()
                     .Skip(10)
                     .Take(10)
                     .ToQueryDefinition();

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

今度は期待しているように OFFSET / LIMIT が使われた SQL に変換されました。

SELECT VALUE root FROM root OFFSET 10 LIMIT 10

この動作は SDK の不具合っぽいのでフィードバックしておきました。ひょっとしたら直るかもしれません。

ThenBy / ThenByDescending は複合インデックスが必要

v3.3.0 から ThenBy / ThenByDescending に対応するようになりましたが、Cosmos DB 側の制約として複数のプロパティに対しての ORDER BY 実行には複合インデックスが必要です。

インデックスを作成せずに実行しようとすると、もちろんエラーになります。

複合インデックスを上手く使うとパフォーマンスを改善できるらしいので、じっくり試したいところです。

どのようなケースに適用できるのかはまだよくわかっていないですが、RUs を減らせるのは魅力的です。

Subquery + EXISTS を活用する

既に LINQ Provider がサブクエリを使う SQL を吐き出していますが、個人的にはサブクエリと EXISTS によって LINQ でいうところの Any への対応が簡単になった点が良かったです。

Cosmos DB は 1 ドキュメントにオブジェクトの配列など JSON で表現できるものは入れることが出来ますが、以前は配列に対してのクエリが JOIN が必要だったりで非常に面倒でした。

単純なプリミティブ型の配列なら Contains を使うと良い感じに変換してくれてました。

var query = container.GetItemLinqQueryable<Entry>()
                     .Where(x => x.Tags.Contains("日記"))
                     .ToQueryDefinition();

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

出力された SQL は以下のように ARRAY_CONTAINS を使うものになります。これは分かりやすいです。

SELECT VALUE root FROM root WHERE ARRAY_CONTAINS(root["tags"], "日記")

問題はオブジェクトの配列があって、その中のプロパティに対して条件をかけたい場合です。今回のデータモデルの場合は AuthorsName でフィルタを実行したい場合です。

これまでは JOIN するか ARRAY_CONTAINS の条件にオブジェクトを指定するというように、直感的ではない書き方をする必要がありました。当然 LINQ では表現出来なかったのですが、サブクエリによって Any を使ってシンプルに書けるようになりました。

var query = container.GetItemLinqQueryable<Entry>()
                     .Where(x => x.Authors.Any(xs => xs.Name == "shibayan"))
                     .ToQueryDefinition();

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

少し出力された SQL は分かりにくいですが、サブクエリと EXISTS を使ったものに変換されています。

SELECT VALUE root FROM root JOIN (SELECT VALUE EXISTS(SELECT VALUE xs0 FROM root JOIN xs0 IN root["authors"] WHERE (xs0["name"] = "shibayan") ) ) AS v0 WHERE v0

RDB に対して Any を使った時と同じようなクエリになりました。サブクエリが使えるようになったので、ドキュメントに含まれるコレクションを条件に使いやすくなったと思います。

拡張メソッドを追加して楽にする

Entity Framework Core には ToListAsyncFirstOrDefaultAsync などの Task に対応した拡張メソッドが用意されていますが、v3 SDK の場合は ToFeedIterator を呼び出してループで結果を取得する必要があるので、拡張メソッドを用意しておくとカジュアルに使えます。

とりあえず必要だったメソッドだけ用意しましたが、基本は ToFeedIterator を隠蔽しているだけです。

public static class CosmosAsyncLinqExtensions
{
    public static async Task<TSource> FirstOrDefaultAsync<TSource>(this IQueryable<TSource> source)
    {
        var iterator = source.ToFeedIterator();

        var result = await iterator.ReadNextAsync();

        return result.FirstOrDefault();
    }

    public static async Task<TSource> FirstOrDefaultAsync<TSource>(this IQueryable<TSource> source, Expression<Func<TSource, bool>> predicate)
    {
        var iterator = source.Where(predicate)
                             .ToFeedIterator();

        var result = await iterator.ReadNextAsync();

        return result.FirstOrDefault();
    }

    public static async Task<IList<TSource>> ToListAsync<TSource>(this IQueryable<TSource> source)
    {
        var result = new List<TSource>();

        var iterator = source.ToFeedIterator();

        do
        {
            result.AddRange(await iterator.ReadNextAsync());

        } while (iterator.HasMoreResults);

        return result;
    }
}

使い方は特に説明不要だと思いますが、クエリ式の最後に await 付きで呼び出すだけです。

var entry = await container.GetItemLinqQueryable<Entry>()
                           .OrderByDescending(x => x.CreatedAt)
                           .FirstOrDefaultAsync();

var result = await container.GetItemLinqQueryable<Entry>()
                            .Where(x => x.Tags.Contains("日記"))
                            .ToListAsync();

foreach (var item in result)
{
    Console.WriteLine(JsonConvert.SerializeObject(item, Formatting.Indented));
}

コードを書いていると ToFeedIterator を使う同じパターンが頻発するので、何かしら共通化がしたくなるはずです。実際に自分は頻発したので拡張メソッドを書きました。

Async Streams に対応させる

拡張メソッドを書いたので、ついでに C# 8.0 で追加された Async Streams にも対応させてみます。メソッド名は微妙な感じですが、以下のような拡張メソッドを用意するだけです。

public static async IAsyncEnumerable<TSource> AsAsyncEnumerable<TSource>(this IQueryable<TSource> source)
{
    var iterator = source.ToFeedIterator();

    do
    {
        var result = await iterator.ReadNextAsync();

        foreach (var item in result)
        {
            yield return item;
        }

    } while (iterator.HasMoreResults);
}

IAsyncEnumerable<T> を返すメソッドを用意するだけなので簡単です。

使い方は await foreach に渡すだけなので、これも特に説明は不要な簡単さです。

var source = container.GetItemLinqQueryable<Entry>()
                      .Where(x => x.Tags.Contains("日記"))
                      .AsAsyncEnumerable();

await foreach (var item in source)
{
    Console.WriteLine(JsonConvert.SerializeObject(item, Formatting.Indented));
}

これで大きなデータセットに対しても、効率的にページングを行いつつ処理できるようになります。

デフォルトでは 1000 件ずつ取ってくるようになっていますが、QueryRequestOptionsMaxItemCount を設定することで 1 回のリクエストで取得する件数をすることも出来ます。

どうしても SQL を書く必要がある場合

ほとんどのケースで LINQ を使ってクエリを表現できるはずですが、どうしても SQL を書く必要が出てきた場合には QueryDefinition を使ってパラメータ化クエリを組み立てて実行します。

クエリを組み立てて実行するにはまた ToFeedIterator を使う必要があるので、Dapper 風にラップした拡張メソッドを用意しておくと楽になると思います。

public static class CosmosQueryExtensions
{
    private static QueryDefinition CreateQuery(string sql, object param)
    {
        var query = new QueryDefinition(sql);

        if (param != null)
        {
            foreach (var propertyInfo in param.GetType().GetProperties())
            {
                query.WithParameter("@" + propertyInfo.Name, propertyInfo.GetValue(param));
            }
        }

        return query;
    }

    public static async Task<IEnumerable<T>> QueryAsync<T>(this Container container, string sql, object param = null)
    {
        var query = CreateQuery(sql, param);

        var iterator = container.GetItemQueryIterator<T>(query);

        var result = new List<T>();

        do
        {
            result.AddRange(await iterator.ReadNextAsync());

        } while (iterator.HasMoreResults);

        return result;
    }

    public static async Task<T> QueryFirstOrDefaultAsync<T>(this Container container, string sql, object param = null)
    {
        var query = CreateQuery(sql, param);

        var iterator = container.GetItemQueryIterator<T>(query, requestOptions: new QueryRequestOptions { MaxItemCount = 1 });

        return (await iterator.ReadNextAsync()).FirstOrDefault();
    }
}

インターフェースはほぼ Dapper と同じなので、使ったことがある人は違和感ないと思います。

パラメータ化クエリの書き方も Cosmos DB は @ Prefix なのでほぼ同じです。

var result = await container.QueryAsync<Entry>("SELECT * FROM c WHERE ARRAY_CONTAINS(c.tags, @tag)", new { tag = "日記" });

あまり使う機会が無いかもしれませんが、パラメータ化クエリは v2 より扱いやすくなっているので SQL を書く場合にも楽になっています。

長々と書いてきましたが、やはり自分的には Subquery + EXISTS が一番便利かなと思っています。RDB とは JOIN 周りの挙動が違いますが、LINQ を使えば SDK がその辺りを吸収してくれるのも良いです。

Azure AD B2C のユーザーを Graph API を使って管理する

Azure AD B2C は後ろが Azure AD なのでユーザー情報は Graph API を使って色々弄れます。

Microsoft Graph を使ってユーザー情報を取得したりできますが、B2C に必要なプロパティが Microsoft Graph だとまだサポートされていないので、Azure AD Graph を使う必要があります。

調べた感じでは Azure AD Graph のクライアントはかなり古くて、今の時代に .NET Standard へ対応してない系なので使わない方が良さそうでした。

公式ドキュメントは自前で Graph API を叩くものばかりなので、それに従うことにします。そして Service Principal を作る部分からわかりにくかったので、メモがてら手順を残します。

Service Principal を作成する

以下の 2 つのドキュメントに Azure AD Graph API を使って Azure AD B2C に新規ユーザーの作成や削除する方法が載っています。サンプルコードのリンクもありますが、それは後回しにします。

必要な Service Principal の作り方に書いてあるように、パスワード変更や削除は別の権限が必要です。

Application Permissions は Read and write directory data にチェックを入れて保存します。ディレクトリに対する読み書きなので結構強い権限です。

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

この権限ではユーザーの作成は出来ますが、パスワードの変更や削除は行えません。権限としてはいけそうな気がしますが、追加で User administrator のロールを Service Principal に割り当てる必要があります。

ディレクトリへのアクセス権限は Azure AD テナント側で追加しますが、ロールは Azure AD B2C 側で割り当てないといけないので結構わかりにくいです。こっちは RBAC です。

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

Service Principal に権限を与え終えたら Client Secret を作成しますが、Azure Portal 上は Key とか Password と呼ばれているので割と混乱します。

更に UI がわかりにくいですが Description と Expires を設定して保存すれば生成されます。

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

あとは Application Id (ClientId) と Azure AD の TenantId (ObjectId) をコピーしておけば完了です。権限設定がいろいろ散らばっているのは正直扱いにくいと感じます。多分 Azure AD が全て悪い。

C# アプリケーションから利用する

先ほどの公式ドキュメントでサンプルコードが紹介されていますが、例によって実装がとっ散らかっていてリポジトリもバラバラというあまり好ましくない状態でした。

Microsoft Graph で扱えるようになればシンプルになりますが、今のところはどうしようもないのでサンプルコードにあったクライアントを修正して使うことにしました。

実装の肝としては Microsoft.IdentityModel.Clients.ActiveDirectory を使って Access Token を取る部分と、Graph API が要求するモデルクラスを用意する部分ぐらいでした。

折角なので修正したクライアントは以下のリポジトリで公開しています。修正とは言っていますが、クラス名以外はほぼ変更している感じがあります。

簡単な確認用のサンプルコードで Azure AD B2C にユーザーを作成と削除を行ってみます。今回必要だった API しか実装していないですが、Graph API はモデルさえあれば扱いは簡単なので問題ないと思います。

ClientId や Client Secret は Service Principal の作成時にコピーしておいたものを指定します。

class Program
{
    static async Task Main(string[] args)
    {
        var clientId = "<client_id>";
        var clientSecret = "<client_secret>";
        var tenantId = "<tenant_id>";

        var graphClient = new B2CGraphClient(tenantId, clientId, clientSecret);

        var user = User.Create("demo@example.com", "P@ssw0rd!", "demo user");

        var createdUser = await graphClient.CreateUserAsync(user);

        Console.WriteLine("Created");
        Console.WriteLine($"ObjectId: {createdUser.ObjectId}, DisplayName: {createdUser.DisplayName}");

        var result = await graphClient.FindByNameAsync("demo@example.com");

        Console.WriteLine("Found");
        Console.WriteLine($"ObjectId: {result[0].ObjectId}, SignInName: {result[0].SignInNames[0].Value}");

        await graphClient.DeleteUserAsync(createdUser.ObjectId);

        Console.WriteLine("Deleted");
        Console.WriteLine($"ObjectId: {createdUser.ObjectId}");
    }
}

コードを見てわかるように、メールアドレスを使ったログインのみに対応しています。

本来なら Social Login にも対応していますが、コードを分かりやすくするために省きました。Provider の種類と ID を追加するぐらいなので特に難しいことはないですが、まずはちゃんと動かしておきたかったので。

Service Principal の設定を追加して実行すると、Azure AD B2C のユーザーの作成と削除が行われます。

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

ちゃんと ObjectId が発行されているので、ユーザーの作成が行われていることがわかります。

もちろん Azure Portal からも確認できますが、反映されるのに少し時間がかかるケースがあるようなので注意です。API レベルでは遅延はないので安心してください。