tech.guitarrapc.cóm

Technical updates

MSIを使ったStorage Account(Blob, Queue) の認証を使ってQueueの監視を行う

Azure の Storage Account アクセスといえばConnection String ですが、Managed Service Identity (MSI) による AzureAD認証が可能です。(2019/3/25 に GAしたはず.... あれ?)

ここでは、Storage Account ではなく MSI を使ったAzure Functions からのアクセスについてみてみます。

目次

TL;DR

Azure Functions の Binding でのMSI認証はできない。

StorageCredentials を作ってから、CloudBlobClient/CloudQueueClient を作ることになる。

ローカルのMSI認証は現在バグあり。

なぜMSIなのか

MSIを使うことで認証情報をいちいちStorage Account からひっぱてきて参照可能な形で渡す必要がなくなります。 KeyVaultを使うにしても、AppSettingsを使うにしてもConnectionStrings を埋めるのは嫌ですし、IAM = RBAC = AzureAD の認証下でアプリケーションのアクセスが制御されるのは望ましいでしょう。

とくにTerraform を使って構成している場合は、MSIの有効化、IAM でのStorageへの割り当てまで構成時点で完了するのでアプリケーションから見ると透過的に扱えてより効果的でしょう。

AppServiceでMSIを使う

AppService (AzureFunctions) でMSIを使うには、SystemIdentity を有効にします。

Azure Portal で設定する

Azure Portal だと「アクセスを持ちたい側でMSIを設定」、「アクセスされる側のIAMでロール設定」を行います。

まずは、Function App でMSIを設定します。

FunctionApp > AppSettings > Identity

SystemAssigned を Onにする

これでAzureAD のAppRegistration にアプリケーションが登録されたので、アクセスされるStorage Account のIAMでアクセス権限を設定してあげます。

アクセスを許可するリソースでIAM > Role assignments でロール設定する

IAM は Subscription > Resource Group > Resource で継承されるので、そのResourceに限定したいなら ResourceのIAMでロール設定すればokです。もしResourceGroup全体で利用したいなら、 Resource Group のIAMでルール設定すればok です。

Terraform で構成する

こんなことをやっていたら時間がなくなるので、Terraform でFunctionAppの作成からロール設定をします。

gist.github.com

Terraform でMSIを設定するポイントについて説明します。MSIを使う側であるazurerm_function_app でMSIを有効化するのはidentityです。これはVMでもACIでも変わらないのでまず設定するといいでしょう。

  identity = {
    type = "SystemAssigned"
  }

続いて、azurerm_role_assignment を使ってIAMを設定します。ここではわかりやすいように、Resource Groupに対してContributerロールを設定しています。AzureRM の返り値的に、principal_idがlookup必須なのがあんまりイケテマセンが公式です。

resource "azurerm_role_assignment" "main" {
  scope                = "${data.azurerm_resource_group.current.id}"
  role_definition_name = "Contributor"
  principal_id         = "${lookup(azurerm_function_app.main.identity[0], "principal_id")}"
}

さぁこれでMSIの準備はできました。

AzureFunctions でMSIを使ってStorage Account にアクセスする

前回の記事で、StorageAccountのConnection String を使って認証をとりましたが、これをMSIに切り替えます。

tech.guitarrapc.com

とはいえ、MSIはAzure 上のリソースで利用可能な他、ローカルでも az login や Visual Studio の Option > Account から Azureに接続していれば利用できます。と思うじゃないですか? Storage Account に関しては現状動かないです。

https://github.com/Azure/azure-libraries-for-net/issues/557 Local MSI Login using AAD account · Issue #557 · Azure/azure-libraries-for-net · GitHub

認証ヘッダがうまく動いていないので、直るまでローカルはConnection String にバイパスします。バイパスはAzure環境かどうかを WEBSITE_INSTANCE_ID環境変数でチェックして分岐することにしましょう。

gist.github.com

前回のコードから、CloudQueueClient の取得部分をMSIに変えるようにしてみます。変更箇所はCloudQueueClientの作成部分だけです。

gist.github.com

MSI から取得するときのポイントは、3つあります。

  1. AzureServiceTokenProvider を使って認証TokenのProvider経由で認証を作ります
    • MSIは認証プロバイダー経由で認証を作りましょう
  2. azureServiceTokenProvider.GetAccessTokenAsync("アクセストークンの請求先") で適切なURIを指定する必要があります
  3. new CloudQueueClient(new StorageUri(new Uri($"https://{storageAccountName}.queue.core.windows.net")), storageCredentials);のように、URIとCredentialを指定してクライアントを作成します
    • ConnectionString を使っている時は接続先のStorageAccountが明示されていたので、account.CreateCloudQueueClient(); と書けましたが、MSIではStorageAccountが不明なので接続先が作れません
    • このため、StorageCredential からStorageAccountを作るのではなく、URIでアクセス先のStorageAccountNameと一緒に指定してあげます

MSIがローカルで使えるようにバグ修正されるまで、Azure環境とローカルで分岐してあげます。

// connect to Azure Storage
var queueClient = context.IsAzureEnvironment()
    ? await CreateQueueClientAsync("YOUR_STORAGE_ACCOUNT_NAME")
    : CreateQueueClient(Environment.GetEnvironmentVariable("queue_storage_connection_string"));

ということでMSIに対応したコード全体像です。

gist.github.com

まとめ

MSI を使えるなら使いましょう。ConnectionStringとは使うのやめましょ。

MSI、Azureの仕組みは分かりやすいです。一方で、アプリからの利用が分かりにくいというか整理されたドキュメントがないので認証先のドキュメント把握、仕組みの整理に手間取ったです。

余談

2019/3/25 にStorage Account (Blob, Queue) の AzureAD認証がGAしたはずの記事が出ているのですが、現在アクセスできません。

https://azure.microsoft.com/en-us/blog/azure-storage-support-for-azure-ad-based-access-control-now-generally-available/

これがキャッシュです。

Google Cache : Azure Storage support for Azure Active Directory based access control generally available | Blog | Microsoft Azure

ブログ一覧にもないのと、アクセス先URIでもPreviewになっているので、GAキャンセルでまだPreview っぽい?

Azure Services that support managed identities for Azure resources | Microsoft Docs

Azure updates | Microsoft Azure

Ref:

Azure リソース (プレビュー) の Azure Active Directory 管理 ID を持つ blob およびキューへのアクセスの認証 - Azure Storage | Microsoft Docs

仮想マシン上で Azure リソースのマネージド ID を使用してアクセス トークンを取得する方法 | Microsoft Docs

c# - Azure Storage authentication via AzureServiceTokenProvider for CloudTableClient - Stack Overflow

Azure AD Authentication with Azure Storage + Managed Service Identity - Joonas W's blog

Local MSI Login using AAD account · Issue #557 · Azure/azure-libraries-for-net · GitHub

*1:びっくりするぐらいわかりにくい。

Azure Storage Queue を Application Insightsで監視する

あるあるな Queue の監視ですが、自前でやらなきゃいけないなら Serverless でぺちって任せるのは楽ちんですよ、というのはよくあるパターンです。 実際にQueue Storage のモニタリングをしてみましょう。

TL;DR

Azure Storage Queue や Service Bus などのキューサービスは、疎結合な構成を組んだ時に中心を担うため、そのキュー長が想定よりたまっていないか、推移はどうなっているかなどが気になります。

この監視を、Azure Functions の Timer Trigger で行ってみましょう。

Disclaimer

Datadog や 複数のモニタリングサービスは、Azure のAPIをたたいて自分でクラウドリソースのメトリクスを見に行ってくれます。もしそれらで済むならこの記事は不要です、すぐに閉じましょう。この記事は、自分でリソースの状態をポーリングするときの対応例です。

Consumption Planを前提に毎30秒監視しています。(2 * 60 * 24 * 30 = 86400run) 無料枠内ですが、料金がどうなっても私は責任は取りかねます。(免責事項)

なぜ AzureFunctions なのか

モニタリングはアプリケーションの内部リソースと、クラウドなどの外部リソースで分けて考えられます。

アプリがスケールしたときに個別のアプリのメトリクスが見たければ、アプリケーションで監視するのが妥当な場合が多いのは同意を得られるかと思います。一方で、アプリケーションの外にあるリソースは、アプリケーションからモニタリングする必然性はありません。おのずと次のような欲求が高まってきます。

  • アプリケーション自身のリソースをモニタリングに消費したくない
  • モニタリング自身がスケールして重複した値が取れてほしくない
  • アプリケーション自身が、どのようなリソースに依存んして動いているのか関心がない

自前でメトリクスを見たいときは、どこかで実行する必要があります。しかしアプリケーションでは実行したくありません。監視対象を定義しておいて、メトリクスを取得するだけのシンプルな監視の場合、Serverless でアプリケーションとは別個の存在として実行させるのが便利です。

Azure Storage Queue なら、Azure Functions の Consumption Plan + Timer Trigger が定時ポーリング監視になり、定性モニタリングとして負荷なく実行できるでしょう。

監視対象

Azure でキューといえば、Azure Storage Queue と Service Bus がありますが、さくっと Storage Queue で確認してみましょう。ここでは myqueue というキューを用意して監視することにします。

  • myqueue : Storage Queue API で通常投げつけるQueueです。主にこの子が気になる
  • myqueue-poison : WebJobs で失敗した時に自動的に作成されるQueueで-posison が末尾につきます。手動でなんとかすることになります

なお、Storage Queue は Dead Letter Queue (DLQ) に対応していないのでまぁほげもげ。

実行環境

  • Function Runtime: v2
  • Language: C# (.NET Core 2.1)
  • Trigger: Timer Trigger (毎30秒)
  • Metrics: Queue Length
  • Monitoring: Application Insights

Azure Functions

Azure Functions は、v2 においても裏で Application Insights を使っています。 とはいえ、明示的にMicrosoft.ApplicationInsights パッケージを取り込まないと、アプリからApplication Insights をたたけないので入れておきます。

dotnet add package Microsoft.ApplicationInsights

メトリクスを送信するため、Azure Functions の Application Configで Application Insights の Instrumentation Key を設定します。もし Azure Functions の Application Insights にメトリクスを飛ばすのでいいなら APPINSIGHTS_INSTRUMENTATIONKEY でいいでしょう。

Queueに接続するため、QueueがあるStorage Account のConnection Strings を設定しておきます。ここでは、queue_storage_connection_string としました。

では実際に実行してみましょう。コードはこのような感じになります。

gist.github.com

これで毎10秒ごとにQueue の長さをモニタリングして、Application Insights に投げられます。ローカルで実行した場合、コンソールにQueueの長さがでるでしょう。

Application Insights の確認

今回はAzure FunctionsのApplication Insights 連携に乗っかているので見てみます。

ちょうどQueue を投げてなかったので0 が継続して取れているのがわかります。

QueueLength を Application Insights で監視

あとは Azure Monitor という名のApplication Insights の閾値による Action で Webhook なりを投げるといいでしょう。Body の調整できない子ですが。

Azure Monitor | Microsoft Azure

Tips

Cloud Table とか絡んでくると、いまだにMicrosoft.WindowsAzure.Storage パッケージが安定なので、Azureの NuGet パッケージつらい。

Ref

やろうとしたら、だいたいすでにやってるKloudさんつよい。v1 ですが、だいたいやっていることは一緒です。

Monitoring Azure Storage Queues with Application Insights and Azure Monitor - Kloud Blog

Slotを用いたAppService のStaging環境とAzureDevOps PipelineのリリースによるBlueGreen Deployment

Azure の App Service には Slotがあります。

Slotはただ利用してもそれなりにうれしいのですが、Terraform での構成とAzure DevOps の リリースパイプラインでの展開を行えるようにすることで、「CI/CD による App Service の Slot による展開前のStaging環境での確認」と「リリースゲートを使った任意でのSwapによるStaging->Production展開」、「Azure上のSlot環境の明確な管理」ができるようになります。

AzureをTerraform で展開しておくことで、AppService + Slot 構成がDev/Production の両方で展開でき、運用上の負担も小さいのがいい感じです。 実際にどんな感じで組むのか紹介します。

こういうのよくありますが、実際に設定を含めてどう組めばいいのかまで含めた公開はあんまりされないのでしておきましょう。

www.edmondek.com

目次

TL;DR

Slotを用いることで、Prod環境への適用前のStaging環境確認が簡単に行えます。 また、IISなどのAppSettingsの入れ替えに伴う再起動でWebAppsが応答できず500を返すのもSwap入れ替え中のWarm upで解消されます。 Terraform + AzureDevOps のCI/CD で自動化しつつ組んでみましょう。

ここで注意するべきは、Terraform の azurerm_app_service_active_slot を使ったSwapではないということです。このリソースは制約が大きいので、CDに利用することは避けたほうがいいでしょう。

www.terraform.io

概要

Azure AppService にはSlot機能があり、本番に適用する前にStaging環境で検証、Prod環境にSwapで反映できます。 Azure DevOps のPipelineでリリース時にDeployとSwapを分けることで、「本番に反映」のタイミングだけ承認を求めて、普段のStaging展開は自動CI/CD を行い開発者はステージング環境で常に確認できます。

想定されるワークフロー

  • Git Commit により、PRごとにCIが実行
  • CIの成功をtriggerに、CDのdeployタスクにより成果物を常にstaging slot にデプロイ
  • 開発者は AppService の staging slotを確認することでステージング環境を確認(ここまで毎PRで自動CD)
  • ステージングの動作に問題がなければ、CDのswapのゲートの承認を行う
  • CDはswap承認を受けて、自動的にswap を実行し ステージング環境が production と入れ替わり本番リリースされる (Swapによるユーザー影響は明確にリリース制御できる)

App Service Slot と AzureDevOps のリリースタスクによるBlue/Green デプロイ

Azure環境はTerraform で構成されており、AppService は各Envごとに自動的に構成されます。

Slotとは

どんな資料をみるよりも公式資料がわかりやすいので見てみましょう。

https://docs.microsoft.com/en-us/azure/app-service/deploy-staging-slots

Slotでポイントとなるのは、Slot専用の環境変数を持てることです。

  • Slot環境ではStaging へ接続
  • Slot環境では環境変数をStagingにして、アプリの動作を変える

といったことが可能です。

Slot の用意

ここではTerraform でSlotを用意します。Azure Portal から用意したいからはそちらでどうぞ。

今回は ASP.NET Coreで組んでいますが、JavaでもGolang でもほぼ変わりません。 Golangなどを使うなら Linux Container がいい感じです。*1

https://www.terraform.io/docs/providers/azurerm/r/app_service_slot.html

このようなWebAppsの構成がある前提です。

resource "azurerm_app_service_plan" "webapps" {
  name                = "prod-plan"
  location            = "${local.location}"
  resource_group_name = "${data.azurerm_resource_group.current.name}"
  kind                = "Windows"
  tags                = "${local.tags}"

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

resource "azurerm_app_service" "webapps" {
  name                    = "prod-webapp"
  location                = "${local.location}"
  resource_group_name     = "${data.azurerm_resource_group.current.name}"
  app_service_plan_id     = "${azurerm_app_service_plan.webapps.id}"
  client_affinity_enabled = false
  tags                    = "${local.tags}"

  app_settings {
    ASPNETCORE_ENVIRONMENT                   = "Production"
    APPINSIGHTS_INSTRUMENTATIONKEY           = "${azurerm_application_insights.webapps.instrumentation_key}"
    "ApplicationInsights:InstrumentationKey" = "${azurerm_application_insights.webapps.instrumentation_key}"
    "ConnectionStrings:Storage"              = "${azurerm_storage_account.webapps.primary_connection_string}"
    "ConnectionStrings:LogStorage"           = "${azurerm_storage_account.logs.primary_connection_string}"
  }
  site_config {
    dotnet_framework_version = "v4.0"
    always_on                = "true"
    remote_debugging_enabled = false
    remote_debugging_version = "VS2017"
    http2_enabled            = true
  }
  identity {
    type = "SystemAssigned"
  }
  lifecycle {
    ignore_changes = [
      "app_settings.%",
      "app_settings.MSDEPLOY_RENAME_LOCKED_FILES",
      "app_settings.WEBSITE_NODE_DEFAULT_VERSION",
      "app_settings.WEBSITE_RUN_FROM_PACKAGE",
      "app_settings.WEBSITE_HTTPLOGGING_CONTAINER_URL",
      "app_settings.WEBSITE_HTTPLOGGING_RETENTION_DAYS",
    ]
  }
}

Slot は次の内容で作成してみます。

  • staging というSlot名にする
  • ASPNETCORE_ENVIRONMENT をProductionとStaging で入れ替える

この場合、Slot名となるnamestagingapp_settingsASPNETCORE_ENVIRONMENT=Staging を設定し、他はProductionとなるApp Service設定と同様に組んでおきます。

resource "azurerm_app_service_slot" "staging" {
  name                    = "staging"
  app_service_name        = "${azurerm_app_service.webapps.name}"
  location                = "${local.location}"
  resource_group_name     = "${data.azurerm_resource_group.current.name}"
  app_service_plan_id     = "${azurerm_app_service_plan.webapps.id}"
  client_affinity_enabled = false
  https_only              = true
  tags                    = "${local.tags}"

  app_settings {
    ASPNETCORE_ENVIRONMENT                   = "Staging"
    APPINSIGHTS_INSTRUMENTATIONKEY           = "${azurerm_application_insights.webapps.instrumentation_key}"
    "ApplicationInsights:InstrumentationKey" = "${azurerm_application_insights.webapps.instrumentation_key}"
    "ConnectionStrings:Storage"              = "${azurerm_storage_account.webapps.primary_connection_string}"
    "ConnectionStrings:LogStorage"           = "${azurerm_storage_account.logs.primary_connection_string}"
  }
  site_config {
    dotnet_framework_version = "v4.0"
    always_on                = "true"
    remote_debugging_enabled = false
    remote_debugging_version = "VS2017"
    http2_enabled            = true
  }
  identity {
    type = "SystemAssigned"
  }

  lifecycle {
    ignore_changes = [
      "app_settings.%",
      "app_settings.MSDEPLOY_RENAME_LOCKED_FILES",
      "app_settings.WEBSITE_NODE_DEFAULT_VERSION",
      "app_settings.WEBSITE_RUN_FROM_PACKAGE",
      "app_settings.WEBSITE_HTTPLOGGING_CONTAINER_URL",
      "app_settings.WEBSITE_HTTPLOGGING_RETENTION_DAYS",
    ]
  }
}

あとはTerraformを実行することで、Slotが生成され維持されます。

AzureDevOps Pipeline

DevOps Pipeline でリリースパイプラインを組みます。Azure Portal でSwapしたい方はやる必要がありません。

完成図は次の通りです。

リリースパイプラインのステージ構成はDeployとSwapで分割する

  • Deploy: Staging までのPackage Deploy を行います
  • Swap: Staging Slot と Production を Swap します

DeployをSwapを分けることで、実際にProductionが入れ替わるタイミングだけGateをかけることができるようになります。

  • Deployまでを自動CI/CDにして事前動作確認
  • Swap は、Slackやほかの手段でチームの合意があった時だけデプロイ

Deploy

Deployタスクは、WebApps のSlot環境へのデプロイを行います。

DeployはRun as package の構成まで(app servce in linux ならcliやFTP展開)

デプロイはRun from package を用いていますが、このRun from zip/Run from packageはWindows Web Apps でのみ有効です。

https://docs.microsoft.com/en-us/azure/devops/pipelines/targets/webapp?view=azure-devops&tabs=yaml

もしLinuxにしたい場合は別の手段を使いましょう。Container であればDevOps Pipeline があります。

https://docs.microsoft.com/en-us/azure/devops/pipelines/apps/cd/deploy-docker-webapp?view=azure-devops

このリリースタスクは、CIでPackageをArtifactに上がっている前提です。 あとは、CD時点の時間を取得、Zip生成、Blobにアップロード、SASを取得してSlot のAppSettings に埋め込みをすることでRun from Packageが実行できます。

PowerShell Scriptのタスクは次の通りです。time環境変数を作っています。

steps:
- powershell: |
   $date=$([System.DateTimeOffset]::UtcNow.AddHours(9).ToString("yyyyMMddHHmmss"))
   Write-Host "##vso[task.setvariable variable=time]$date"
   
  displayName: 'PowerShell Script'

Zipタスクは次の通りです。time環境変数の時間をzipのファイル名に利用しています。

steps:
- task: ArchiveFiles@2
  displayName: 'Archive $(System.DefaultWorkingDirectory)/$(RELEASE.PRIMARYARTIFACTSOURCEALIAS)/drop'
  inputs:
    rootFolderOrFile: '$(System.DefaultWorkingDirectory)/$(RELEASE.PRIMARYARTIFACTSOURCEALIAS)/drop'
    includeRootFolder: false
    archiveFile: '$(Release.DefinitionName)-$(time)-$(Release.ReleaseName)-$(Build.SourceVersion).zip'

Blob コピータスクは次の通りです。Storage Blobにアップロードして、storageUri 環境変数にURIを取得します。

steps:
- task: AzureFileCopy@1
  displayName: 'AzureBlob File Copy'
  inputs:
    SourcePath: '$(Release.DefinitionName)-$(time)-$(Release.ReleaseName)-$(Build.SourceVersion).zip'
    azureSubscription: YOUR_SUBSCRIPTION
    Destination: AzureBlob
    storage: YOUR_STORAGE_ACCOUNT
    ContainerName: YOUR_STORAGE_CONTAINER
    BlobPrefix: YOUR_STORAGE_BLOB_PREFIX
    outputStorageUri: storageUri

SAS生成タスクは次の通りです。SasTokenをstorageToken 変数にかき出しています。

steps:
- task: pascalnaber.PascalNaber-Xpirit-CreateSasToken.Xpirit-Vsts-Release-SasToken.createsastoken@1
  displayName: 'Create SAS Token for Storage Account'
  inputs:
    ConnectedServiceName: YOUR_SUBSCRIPTION
    StorageAccountRM: YOUR_STORAGE_ACCOUNT
    SasTokenTimeOutInHours: 87600
    Permission: 'r'
    StorageContainerName: packages
    outputStorageUri: 'storageUri'
    outputStorageContainerSasToken: 'storageToken'

最後にRun from Package を行います。前のタスクまでで生成した、time環境変数、アップロードしたstorageUri変数、SasトークンのstorageToken 変数を利用します。 ポイントは、slotです。ここで作成しておいたstagingを指定することで、production環境ではなく、staging Slotへデプロイします。

steps:
- task: hboelman.AzureAppServiceSetAppSettings.Hboelman-Vsts-Release-AppSettings.AzureAppServiceSetAppSettings@2
  displayName: 'Run from package : Set App Settings'
  inputs:
    ConnectedServiceName: YOUR_SUBSCRIPTION
    WebAppName: 'prod-webapp'
    ResourceGroupName: 'YOUR_RESOURCE_GROUP_NAME'
    Slot: staging
    AppSettings: 'WEBSITE_RUN_FROM_PACKAGE=''$(storageUri)/prod/$(Release.DefinitionName)-$(time)-$(Release.ReleaseName)-$(Build.SourceVersion).zip$(storageToken)''

Swap

Deploy後のSwap タスクのpre-deployment condition で、実行前の条件を付けることができます。 ここで、承認を要するようにすることで、チームメンバーのだれかの承認があったときだけ実行、ということが可能です。

Swap実行前にPre-deployment approval でチームの承認による任意展開をひっかける

タスクを見てみましょう。タスクは簡潔にSwapを行うだけです。

SwapはSlot Swap を実行するだけ

Swapは、Deployタスクでデプロイしたstaging Slot とProductionの入れ替えを行うだけです。

steps:
- task: AzureAppServiceManage@0
  displayName: 'Manage Azure App Service - Slot Swap'
  inputs:
    azureSubscription: '$(Parameters.ConnectedServiceName)'
    WebAppName: '$(Parameters.WebAppName)'
    ResourceGroupName: '$(Parameters.ResourceGroupName)'
    SourceSlot: '$(Parameters.SlotName)'
    SwapWithProduction: True

これで CIが実行されると Releaseパイプラインで、Deploy タスク、承認後にSwap タスクが実行されます。

CIからトリガーしてCD実行完了時の様子

Deployタスク

Deployの各タスクの状態

Swap タスク

Swapの各タスクの状態

Swap はおおよそ 60sec - 90sec かかりますが、これは Azure RM APIのレスポンスとApp Serviceの制約なので諦めます。

改善点

Deploy と Swap を直接でつなぐことで、Deployタスクの完了時に連動するように依存性を組んでいます。しかしタスクを分けたために、DeployとSwapそれぞれで CIの成果物(Artifact) のダウンロード処理が2sec程度かかっています。無視してokなレベルですが、無駄は無駄。

Swap ごときに60-90sec かかるのなんというか、シカタナイとは言えやりようあると思うんですが... Azureの制約にかかるので諦めです。(Slot Warmup とこのSwapの時間により無停止でアプリが展開できるのは皮肉というべきか)

Azure Functionsの場合も、ほぼ同様に行けますが、Durable Functions で Slot でちょっと挙動がおかしい感じです。(Slot変数が展開しないような動きが見えるような)

Ref

blogs.msdn.microsoft.com

docs.microsoft.com

www.azuredevopslabs.com

mikepfeiffer.io

*1:現在、同一Resource Group で Windows/Linux のApp Service Plan が混在できないので注意が必要ですが!

PowerShell 6.0 のImport-Csv に W3C 拡張ログ ファイル形式のサポートを追加 #2482 について調べてみた

これを調べていたのは本を書いていたときなので、そろそろ一年経つのですがお蔵入りの前に出しておきます。 PowerShell 6.0 において、W3C 拡張ログが Import-Csv で読み込み可能になったという内容でリリースノートが出ているのですがその内容について。

docs.microsoft.com

目次

TL;DR

Import-Csv がW3Cログに対応したといっても、それはIISログではなくMicrosoft Exchange のログ (W3Cログ形式)となる。 なんという期待を裏切る罠。

まとめおいておきます。

Import-Csv in PowerShell 6.0 supports w3c log format, if delimiter is `,`. https://github.com/PowerShell/PowerShell/pull/2482 · GitHub

W3C 拡張ログとは

W3C に定義があります。

www.w3.org

Introduction で用途がだいたいわかるので引用します。

Most Web servers offer the option to store logfiles in either the common log format or a proprietary format. The common log file format is supported by the majority of analysis tools but the information about each server transaction is fixed. In many cases it is desirable to record more information. Sites sensitive to personal data issues may wish to omit the recording of certain data. In addition ambiguities arise in analyzing the common log file format since field separator characters may in some cases occur within fields. The extended log file format is designed to meet the following needs:

* Permit control over the data recorded.
* Support needs of proxies, clients and servers in a common format
* Provide robust handling of character escaping issues
* Allow exchange of demographic data.
* Allow summary data to be expressed.

重要な箇所はFormat にあります、引用します。

Format
An extended log file contains a sequence of lines containing ASCII characters terminated by either the sequence LF or CRLF. Log file generators should follow the line termination convention for the platform on which they are executed. Analyzers should accept either form. Each line may contain either a directive or an entry.

Entries consist of a sequence of fields relating to a single HTTP transaction. Fields are separated by whitespace, the use of tab characters for this purpose is encouraged. If a field is unused in a particular entry dash "-" marks the omitted field. Directives record information about the logging process itself.

Lines beginning with the # character contain directives. The following directives are defined:

Version: <integer>.<integer>
The version of the extended log file format used. This draft defines version 1.0.
Fields: [<specifier>...]
Specifies the fields recorded in the log.
Software: string
Identifies the software which generated the log.
Start-Date: <date> <time>
The date and time at which the log was started.
End-Date:<date> <time>
The date and time at which the log was finished.
Date:<date> <time>
The date and time at which the entry was added.
Remark: <text>
Comment information. Data recorded in this field should be ignored by analysis tools.
The directives Version and Fields are required and should precede all entries in the log. The Fields directive specifies the data recorded in the fields of each entry.

Example
The following is an example file in the extended log format:

#Version: 1.0
#Date: 12-Jan-1996 00:00:00
#Fields: time cs-method cs-uri
00:34:23 GET /foo/bar.html
12:21:16 GET /foo/bar.html
12:45:52 GET /foo/bar.html
12:57:34 GET /foo/bar.html

ここで最も重要なのは、Fields are separated by whitespace という箇所で、いわゆるデリミター(Delimiter/区切り文字) が 空白スペースであるという記述です。Exampleもそのようになっています。

Import-Csv でIIS ログを読んでみる

リリースノートを見ただけだと、W3Cログ = IIS ログが読み込めるのかと思った方もいらっしゃるかもしれません。 IISログを試していましょう。

#Software: Microsoft Internet Information Services 7.5
#Version: 1.0
#Date: 2013-06-24 10:56:45
#Fields: date time s-ip cs-method cs-uri-stem cs-uri-query s-port cs-username c-ip cs(User-Agent) sc-status sc-substatus sc-win32-status time-taken
2013-06-24 10:56:45 192.168.0.1 POST /xas/ - 80 - 222.222.222.222 Mozilla/5.0+(Windows+NT+6.1;+WOW64)+AppleWebKit/537.36+(KHTML,+like+Gecko)+Chrome/27.0.1453.110+Safari/537.36 404 0 2 471

しかし次のようにデリミターは空白文字であり、Import-Csvでは区切りを認識できません。

PS> import-csv .\iis_log.log
 
 <#
 date time s-ip cs-method cs-uri-stem cs-uri-query s-port cs-username c-ip cs(User-Agent) sc-status sc-substatus sc-win32-status time-taken
------------------------------------------------------------------------------------------------------------------------------------------
2013-06-24 10:56:45 192.168.0.1 POST /xas/ - 80 - 222.222.222.222 Mozilla/5.0+(Windows+NT+6.1;+WOW64)+AppleWebKit/537.36+(KHTML
 #>

-Delimiter に " " を指定すると読み込みエラーが起こることから、ログのデリミターを変えるしかなさそうです。 IISログに関して、デリミターを変更できるか IIS_SCHEMA.xml で調整を試みるとできないことが分かります。

<!-- just for logFile section -->
<!-- there's no attribute, value to change delimiter -->
      <element name="logFile">
        <attribute name="logExtFileFlags" type="flags" defaultValue="Date, Time, ClientIP, UserName, ServerIP, Method, UriStem, UriQuery, TimeTaken, HttpStatus, Win32Status, ServerPort, UserAgent, HttpSubStatus, Referer">
          <flag name="Date" value="1"/>
          <flag name="Time" value="2"/>
          <flag name="ClientIP" value="4"/>
          <flag name="UserName" value="8"/>
          <flag name="SiteName" value="16"/>
          <flag name="ComputerName" value="32"/>
          <flag name="ServerIP" value="64"/>
          <flag name="Method" value="128"/>
          <flag name="UriStem" value="256"/>
          <flag name="UriQuery" value="512"/>
          <flag name="HttpStatus" value="1024"/>
          <flag name="Win32Status" value="2048"/>
          <flag name="BytesSent" value="4096"/>
          <flag name="BytesRecv" value="8192"/>
          <flag name="TimeTaken" value="16384"/>
          <flag name="ServerPort" value="32768"/>
          <flag name="UserAgent" value="65536"/>
          <flag name="Cookie" value="131072"/>
          <flag name="Referer" value="262144"/>
          <flag name="ProtocolVersion" value="524288"/>
          <flag name="Host" value="1048576"/>
          <flag name="HttpSubStatus" value="2097152"/>
        </attribute>
        <attribute name="customLogPluginClsid" type="string" defaultValue=""/>
        <attribute name="logFormat" type="enum" defaultValue="W3C">
          <enum name="IIS" value="0"/>
          <enum name="NCSA" value="1"/>
          <enum name="W3C" value="2"/>
          <enum name="Custom" value="3"/>
        </attribute>
        <attribute name="logTargetW3C" type="flags" defaultValue="File">
          <flag name="File" value="1"/>
          <flag name="ETW" value="2"/>
        </attribute>
        <attribute name="directory" type="string" expanded="true" defaultValue="%SystemDrive%\inetpub\logs\LogFiles" validationType="nonEmptyString" />
        <attribute name="period" type="enum" defaultValue="Daily">
          <enum name="MaxSize" value="0"/>
          <enum name="Daily" value="1"/>
          <enum name="Weekly" value="2"/>
          <enum name="Monthly" value="3"/>
          <enum name="Hourly" value="4"/>
        </attribute>
        <attribute name="truncateSize" type="int64" defaultValue="20971520" validationType="integerRange" validationParameter="1048576,4294967295" />
        <attribute name="localTimeRollover" type="bool" defaultValue="false"/>
        <attribute name="enabled" type="bool" defaultValue="true" />
        <attribute name="logSiteId" type="bool" defaultValue="true" />
        <attribute name="flushByEntryCountW3CLog" type="uint" defaultValue="0" />
        <attribute name="maxLogLineLength" type="uint" validationType="integerRange" validationParameter="2,65536" defaultValue="65536" />
        <element name="customFields">
          <attribute name="maxCustomFieldLength" type="uint" validationType="integerRange" validationParameter="2,65536" defaultValue="4096" />
          <collection addElement="add" clearElement="clear">
            <attribute name="logFieldName" type="string" required="true" isUniqueKey="true" validationType="nonEmptyString" />
            <attribute name="sourceName" type="string" required="true" validationType="nonEmptyString" />
            <attribute name="sourceType" type="enum" required="true" >
              <enum name="RequestHeader" value="0"/>
              <enum name="ResponseHeader" value="1"/>
              <enum name="ServerVariable" value="2"/>
            </attribute>
          </collection>
        </element>
      </element>
      <element name="traceFailedRequestsLogging">
        <attribute name="enabled" type="bool" defaultValue="false" />
        <attribute name="directory" type="string" expanded="true" defaultValue="%SystemDrive%\inetpub\logs\FailedReqLogFiles"/>
        <attribute name="maxLogFiles" type="uint" defaultValue="50" validationType="integerRange" validationParameter="1,10000"/>
        <attribute name="maxLogFileSizeKB" type="uint" defaultValue="1024" validationType="integerRange" validationParameter="0,1048576"/>
        <attribute name="customActionsEnabled" type="bool" defaultValue="false"/>
      </element>

web.config でも変更できないのは、IIS10 でも変わっていません。

stackoverflow.com

https://docs.mendix.com/refguide5/review-log-files-ms-iis-serverdocs.mendix.com

Advanced Logging ならわんちゃん....?

forums.iis.net

Import-Csv でExchange ログを読んでみる

Import-Csv が対象にしているのは、Delimiter が カンマ , つまり、csv のフォーマットです。 W3C でCSV フォーマットのログ、パッとでませんでしたがあります、Microsoft Exchange です。

docs.microsoft.com

これは実際に、PowerShell チームが Import-Csv で W3C を読むときのテストデータからもわかります。

https://github.com/PowerShell/PowerShell/blob/master/test/powershell/Modules/Microsoft.PowerShell.Utility/assets/TestImportCsv_W3C_ELF.csv

#Software: Microsoft Exchange Server
#Version: 15.0.0.0
#Log-type: Transport Connectivity Log
#Date: 2016-09-16T23:30:07.338Z
#Fields: Column1,Column2,Column 3
data1,1,A
data2,2,B
data3,3,C
data4,4,D

これを読んでみると、ヘッダ部分の # が無視されてデータが読み込まれたのが分かります。

PS> import-csv .\iis_log.csv
 
 # https://github.com/iSazonov/PowerShell/blob/0818b6c921c1970dc294669134266f878352891a/test/powershell/Modules/Microsoft.PowerShell.Utility/assets/TestImportCsv_W3C_ELF.csv
 <#
 Column1 Column2 Column 3
------- ------- --------
data1   1       A
data2   2       B
data3   3       C
data4   4       D
 #>

まとめ

Import-Csv で IISログの W3C ログは読めない、Exchange は読める。なるほど、Import-Csv ですからね。

base64urlを扱えるNuGetライラブラリと.NET Core Global Toolを作りました(MicroBatchFrameworkも使ったよ)

最近 JWT を取り扱っているのですが、仕様上base64url フォーマットを頻繁に利用します。 C# で base64 というと、Convert.FromBase64String あたりですが、base64url にしてくれるような気の利いた仕組みはなく、入力がbase64url仕様に沿ってないとすぐに例外を吐いて使いにくさが目立ちます。*1

個人的には、base64url への変換、base64 と base64url の相互変換 をしてくれれば十分で、フォーマットもstring で上出来です。そこで、自分のJWT操作用に base64url / base64 の対応をするnuget ライブラリ、それとJWTをコンソール上で検証するために、CLIを .NET Core Global Toolとして作りました。

今回はその内容と、dotnet global tool (要はコンソールアプリ) で MicroBatchFramework を使えるようにフィードバックしてた話です。

目次

TL;DR;

C#CLIでbase64url をさくっと操作できます。

.NET Core のCLIもMicroBatchFrameworkで書きやすくなったのでオススメです。

GitHub

ライブラリと .NET Core Global Tool はnuget に置いてあります。普段使っており、npmなどほか言語実装との挙動チェックはしているので問題ないと思いますが、何かあればリポジトリまでお願いします。

github.com

今回、CLIを提供するにあたり.NET Core Global Tool を作りました。*2 当初自前コマンドライン処理で書いたのですが、MicroBatchFrameworkに改善フィードバックを送り続けた結果、CLIでも使いやすくなり MicroBatchFramework へ移行完了しました。

github.com

ゴールとなる使い心地

NuGet ライブラリ

文字列やバイト配列を受けて、utf8 *3 のbase64urlでエンコード/デコードします。また、base64url と base64 の文字列をお互いに切り替えます。普通です。

CLI

次のコマンド入力を満たしつつ、MicroBatchFramework で提供することを目指します。

base64urls [-version] [-help] [encode|decode|escape|unescape] [args]

これはnpm で提供されている b64-cli や base64-url-cli の base64url [encode|decode|escape|unescape|binarydecode] [input] がちょうどいい使い勝手のバランス、かつよく利用されているので、コマンド入力から想定されていない入力時のエンコード/デコードも含めてこの挙動に合わせました。*4

www.npmjs.com

www.npmjs.com

NuGet ライブラリ

.NET Standard 2.0で作っています。

www.nuget.org

仕様に沿ってもくもくと書くだけなので余り書くことがありません。ほとんどすべて1行の処理で、コメントを除くと全部で20行程度なことからも察しかと。

Base64UrlCore/Base64Url.cs at d0538fdb8aa7386b01a9644b2563b0f8b88c5d1d · guitarrapc/Base64UrlCore · GitHub

base64url では、エンコーディング時に文字列長が4の倍数となるようにパディング(=)を末尾追加するのですが、ショートハンドでこう書けます。*5

base64String.Length + (4 - base64String.Length % 4) % 4, '='

.NET Core Global Tool

.NET Core 2.2以上で作っています。実は .NET Core 2.1 でもいいのですが、私はもう 2.2 未満は作らないので上げておきます。*6

www.nuget.org

さて、.NET Core Global Tool はただの.NET Core なコンソールアプリで、配布、インストール、アップグレード、アンインストールをNuGet基盤を利用できます。

利用側は、.NET Core SDKがあればokすぐに使えます。

docs.microsoft.com

開発側は、.NET Core Console テンプレートで作ればokです。詳細は公式Docs で十分書かれているので参照してみるといいでしょう。

docs.microsoft.com

ポイントは、csproj にいれるPackAsToolです。また、私は base64urls とパッケージの名称でC# プロジェクトをわざわざ切りましたが、ToolCommandName を使うことでパッケージに別名を付けることができるので厳守したい方はこっちでどうぞ。*7

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

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>netcoreapp2.1</TargetFramework>

    <PackAsTool>true</PackAsTool>
    <ToolCommandName>botsay</ToolCommandName>
    <PackageOutputPath>./nupkg</PackageOutputPath>

  </PropertyGroup>

</Project>

自前のコマンドパーサーで組んでみる

さて、 base64urls としてはじめにCLIを組んだときは自分で引数からコマンドまで書いていました。その時に書いていたコードを置いておきます。

Base64UrlCore/Program.cs at d0538fdb8aa7386b01a9644b2563b0f8b88c5d1d · guitarrapc/Base64UrlCore · GitHub

ごく少量のコマンドをサクッと処理すればいいだけだったので、MonoOptions なども使わずざくっとゴールと同じ挙動を作っています。引数を検査して、コマンドに導いて処理をするだけのよくある小規模なものです。

MicroBatchFramework をこの時使わなかったのは、バッチ用途に限定されており、CLIとして使うには違和感が大きかったためです。例えば次のことがはじめはできませんでした。

* `base64urls encode 入力` という、提供したいコマンド入力の提供はできない。
    * BatchBaseを継承してメソッドをたたくのですが、`-p params` 形式で解釈されるため、一般的なCLIとして提供するのは苦しいです。
* メソッドごとにコマンドにマッピングして提供したいが、`Class.Method` が強制される。
    * バッチとしてはいいのですが、CLIとしては困ります。
* helpとversionはGNUスタイルに沿うようにしたいが、任意のフォーマットでのパラメーター形式ができない
    * クロスプラットフォームで利用するので、GNU スタイルで、-h と -help、--help を提供したいもの対応できません。
* help はデフォルトの引数で、オーバーライドが許可されていない
    * どうしようもない
* コマンド入力なしの時に help を出したいがhelp をオーバーライドできず、独自内容になる。
    * バッチとしては理解して使う分にはいいのですが、CLIとしては初めて使う人にとってはヘルプになってないのでアウト
* 想定されていないコマンド入力時にヘルプを出したいがエラーに回される。
    * しょうがない

MicroBatchFramework で CLI を組む

CLIとしてはいまいち使いにくいことを作者にフィードバックしたところ、「メソッドに対してサブコマンドのマッピングが[Command("decode")]のようなフォーマットで可能になった」、「引数も([Option(0)]T param) で可能になった「と連絡が来ました。

つまり、base64urls decode 値 を次のように表現できるようになったということです。

        [Command("encode")]
        public void Encode([Option(0)]string input) => コマンド;

連絡を受けて、MicroBatchFramework に移行することにしたのが次のPRです。

github.com

MicroBatchFramework 0.45-beta3 の状態で組んでみたところ、次のようになりました。

Base64UrlCore/Program.cs at 8a36ea0f4692581fb65eb1516bc820b3b6ab5c07 · guitarrapc/Base64UrlCore · GitHub

自作コマンドと比較してみると、処理に集中できるようになってきているのが分かります。

左: MicroBatchFramework / 右 : Self command parser

一方で、次の課題が残っています。

* MicroBatchFramework のデフォルト引数である help の挙動をオーバーライドできない。
    * 引数段階で`help` を`-help` に差し替えている
* MicroBatchFramework のデフォルト引数である list の挙動をオーバーライドできない
    * 引数段階で`list` を`-help` に差し替えている
* `[Command]` 属性が複数のコマンドを受け付けない
    * `-v`、`-version`、`--version` や -helpのメソッドを冗長に組んでいる
* コマンドに引数を空で渡したときに、オーバーライドしたhelp が表示されない
* 誤ったコマンドを渡したときに、オーバーライドしたhelpが表示されない

これらは、0.4.5-beta9 のリリースで次のように改善されました。

* MicroBatchFramework のデフォルト引数である help の挙動をオーバーライドできない。
    * コマンドでオーバーライド可能になりました。
* MicroBatchFramework のデフォルト引数である list の挙動をオーバーライドできない
    * コマンドでオーバーライド可能になりました。
* `[Command]` 属性が複数のコマンドを受け付けない`
    * `[Command(new [] {"-v", "-version", "--version"})]` のようなフォーマットで指定できるようになりました。
* コマンドに引数を空で渡したときに、オーバーライドしたhelp が表示されない
    * 空の引数を渡したときにオーバーライドされたhelp が表示できるようになりました。

残りの課題は1つですが、これはすぐに対応はしないとのことだったので、受け付けるコマンドのホワイトリストを作って事前検査することで対応しました。

* 誤ったコマンドを渡したときに、オーバーライドしたhelpが表示されない

CLI のAPI、挙動的にはこの時点で自前コマンドからMicroBatchFrameworkで差し替え可能になりました。コードを比較するとかなりシンプルになっています。

左: MicroBatchFramework / 右 : Self command parser
残った課題は、0.4.5-beta10 で 対応されたので私がGolang や C# で書くCLI的には要件がすべて達成されています。各種処理がほぼ1行に収まりユニットテストと処理内容的にデバッガを挟む必要もないので Expression Methodに書き換えて、MicroBatchFramework への移行が完了しました。

Merge pull request #3 from guitarrapc/chore/ready_for_new_release · guitarrapc/Base64UrlCore@0a6d836 · GitHub

コードを比較してみましょう。自前コマンドパーサーの時から見ると、引数の取り扱いやヘルプにどうやって回すかを考えることなく、やりたい処理だけに集中できるようになっていることが分かるかと思います。特に、Mainメソッドが1行になったことで見通しが良くなり、とっつきやすくなっています。

左: MicroBatchFramework / 右 : Self command parser

まとめ

MicroBatchFramework は、私も注目しているGenericHost の仕組みをうまく活用しつつ、.NET Core でCLIを書くときも書きやすいレベルまで改善されたのでオススメです。*8

*1:NuGet を見ても使いやすいライブラリがなく、 Convert.FromBase64String と Convert.FromBase64String しているだけのものだったりしてカオス

*2:いろいろあるのどうかと思いつつ、あってもいいし、配布機構を考えたくなかった

*3:base64はutf8を想定していますが、一応任意のエンコーディングも

*4:私も自作しても時にこれを使っているので同じ体験がうれしい。

*5:採用していませんが

*6:下げたのをあげるのは大変なので初めから上げておくスタイル

*7:私はこういうところは気にしないというか、あえて合わせに行く方が後日分かりやすいので別名を避けています

*8:NuGet上はPreleaseなので注意です