しばやん雑記

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

AppVeyor を捨てて Azure Pipelines に全て移行した話

4 年ほど使ってきた AppVeyor にあった 30 近いプロジェクトを今日、隅田川花火大会という外に出たくないタイミングで断捨離しつつ全て Azure Pipelines に移行しました。

最近は Azure Pipelines をガッツリ弄っていたので、すんなりと移行出来ました。*1

貴重な Windows に対応した CI SaaS としてお世話になったので感謝しかありません。とはいえ、最近は Azure Pipelines の方がメリットが多くなってきたので、徐々に移行は進めてきました。

AppVeyor を捨てた理由

アップデートが少なくなった

単純に AppVeyor の今後が怪しいと感じるようになってきたからです。2018 年は毎月こまめに VM イメージがアップデートされてきましたが、今年に入ってからはアップデート頻度が急に下がりました。

GitHub の方を見ると Ubuntu と Visual Studio 2019 についてはアップデートされているようでしたが、こういった部分の情報発信が滞ってくると注意信号という感じです。

ログが見にくい

AppVeyor のログは Azure Pipelines や CircleCI のようにステップ毎にグループ化されることはなく、フラットに出力されるだけです。

ビルドログが長い場合や、NuGet パッケージの復元のようなログが多く出る場合は確認しにくかったです。どのコマンドまで処理されたかは、ログをじっくり眺めて確認しないといけないのは辛いです。

プライベートリポジトリを使う場合は割と高い

Basic Plan を $29 で契約しても、使えるプライベートリポジトリは 1 つだけという割と鬼仕様です。

2 年ほどは $59 の Pro Plan を契約して使ってきましたが、最近はちょっと価格に見合わないと考えるようになってきました。そもそも Azure Pipelines はプライベートリポジトリが無料で使えましたし。

Pull Request 作成時の挙動が嫌いだった

デフォルトの設定だと Pull Request を作成した場合に pr と branch の両方のトリガーが走ってしまい、2 度同じコミットがビルドされるという無駄な挙動になっていました。

最後の方にはビルドブランチを master のみに制限すれば解決すると分かりましたが、ビルドに 10 分近くかかる C++ のプロジェクトの時には本当に困らされました。

移行の決め手になったのは AppVeyor ではどうしてもビルドが通らない C++ のプロジェクトが、Azure Pipelines だとすんなりと通ってしまったことでした。

AppVeyor / Azure Pipelines の比較

代表的な部分について自分の知識の整理も兼ねて、違いをまとめておきました。

基本的な機能

全体的に Azure Pipelines の方が機能が多くて、制限が緩くなっています。並列ジョブが OSS 向けだと多く使えるので、各プラットフォーム向けに並列でビルドして高速化出来ます。

AppVeyor Azure Pipelines
対応プラットフォーム Windows / Linux Windows / Linux / macOS
公開リポジトリ 1 並列ジョブが無料 10 並列ジョブまで無料
プライベートリポジトリ 有償 1 並列ジョブ / 1800 分まで無料
ビルド定義 YAML / GUI YAML / GUI
マシンサイズ 2 cores 6 GB / 2 cores 7.5 GB 2 cores 7 GB (Standard_DS2_v2)
コンテナー対応 Docker 対応 Docker + Container job 対応
ビルドキャッシュ 対応 対応(プレビュー)
パッケージリポジトリ NuGet NuGet / npm / Maven / Python / Universal

パッケージリポジトリは Azure Artifacts になりますが、ほぼ一体なので同じように扱っています。

詳細はそれぞれのサービスのドキュメントを見てもらえれば良いです。

対応プラットフォームが多く、プライベートリポジトリのビルドも 1 並列であれば利用できるのが Azure Pipelines のメリットですね。なおビルド速度は AppVeyor は基本ベアメタル使ってるので、Azure Pipelines よりは全体的に早いです。

Build Agent で利用可能なコンポーネント

インストールされているコンポーネントはドキュメントや GitHub 上で公開されています。AppVeyor は Visual Studio 2019 のイメージも用意されていますが、ドキュメントには記載がないです。

Azure Pipelines の場合は Container job を使って、独自に用意した Docker Image を使ってビルド実行も出来るので、環境の自由度は高いですね。インストーラータスクも各言語向けに用意されているので便利。

苦労した点など

Service Connection の扱い

Azure Pipelines の面倒な部分として、Azure Resource Manager や NuGet へのアクセスはそれぞれ Service Connection を作成しておいて、それをタスクから参照という形を多くとります。

Service Connection は組織で共有できず、プロジェクト毎に作らないといけないので、いくつかのリポジトリは同じプロジェクトにまとめてしまいました。

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

Azure Storage ぐらいアクセスキーで雑に利用したかったですが、ARM 必須だったので仕方ないです。

ディレクトリの違い

利用するタスクによって起点となるパスが異なることが多いので、ディレクトリについては注意しつつ書く必要があります。AppVeyor ではソースがチェックアウトされたディレクトリだけなのでシンプルでした。

よく使ったディレクトリとしては以下の 3 つです。一部同じように見えますが、ドキュメント上は違うような説明もあるので、正直かなり混乱します。

  • Build.SourcesDirectory
  • System.DefaultWorkingDirectory
  • Pipeline.Workspace

Pipeline Artifacts を使う場合には、特に気を使う必要があったので注意したいです。

移行したリポジトリ例

移行したリポジトリは全て GitHub で公開しているものなので、パイプライン定義が気になった方は自由に参照してください。C# / C++ 中心にビルドしています。

最後の方に作業したリポジトリの方が定義が洗練されているので、時間がある時に同期を取るつもりです。

*1:AppVeyor がシンプルな機能しか持っていないというのもある

Azure Artifacts で Public Feed を作れるようになっていた(おまけで Credential Provider も試した)

久し振りに Azure Artifacts を開いたら、いつの間にかに New public feed というボタンが追加されていたので試すことにしました。MyGet のように独自の公開リポジトリを利用出来るので、Azure Pipelines と組み合わせて Nightly Build の公開などに向いています。

現在 Preview 中ですが、特に機能が大きく変わったりすることはないでしょう。

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

ドキュメントも公開されています。日付から見るに、最近リリースされたっぽいです。

これまでの Private Feed とは異なり、Upstream が無効化されているのが Public Feed の特徴という感じです。普通は Upstream は扱えないので、一般的な動作になりました。

Azure Artifacts については以前に書いたエントリを参照してください。Azure Pipelines からの利用方法などは変わっていないので、そのままで大丈夫です。

とりあえずサンプルとして Public Feed を作成してみます。適当な名前を付けて作成します。

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

この画面で Public Feed についての注意点が書いてあるので確認しておきます。当然ながらだれでもアクセス出来るようになるので、その点だけは頭に入れておく必要があります。

Feed に NuGet パッケージを追加するために、適当なパイプラインを作成しました。今回は CI で Nightly Build を作成するという想定なので、バージョンはビルド番号から付けるようにしました。

trigger:
- master

variables:
  buildConfiguration: 'Release'

stages:
- stage: Build
  jobs:
  - job: Build_Nupkg
    pool:
      vmImage: 'windows-2019'
    steps:
    - powershell: 'echo "##vso[task.setvariable variable=PackageVersion]1.0.0-ci-$env:Build_BuildNumber"'
      displayName: 'PowerShell Script'

    - task: DotNetCoreCLI@2
      displayName: 'dotnet pack'
      inputs:
        command: pack
        packagesToPack: '**/*.csproj'
        packDirectory: '$(System.DefaultWorkingDirectory)/dist'
        versioningScheme: byEnvVar
        versionEnvVar: PackageVersion
        verbosityPack: Normal

    - publish: dist
      artifact: nupkg
      displayName: 'Publish artifacts'

- stage: Publish
  dependsOn:
  - Build
  jobs:
  - job: Publish_Nupkg
    pool:
      vmImage: 'windows-2019'
    steps:
    - download: current
      artifact: nupkg
      displayName: 'Download artifact'

    - task: DotNetCoreCLI@2
      inputs:
        command: 'push'
        packagesToPush: '$(Pipeline.Workspace)/**/*.nupkg'
        nuGetFeedType: 'internal'
        publishVstsFeed: 'xxxx/xxxx'

適当なクラスライブラリのプロジェクトをこのパイプライン定義でビルドすると、ビルド番号でバージョンが付けられた NuGet パッケージが Public Feed に追加されます。

NuGet が採用している SemVer 2.0 では、ハイフンで区切るとプレリリース扱いになります。すぐ後ろにビルド番号を付けるのではなく、Nightly であることが分かるように ci を間に付けています。

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

何回かコミットを行ってビルドさせると、バージョンが追加されていきます。ちゃんとバージョンが新しいものが上に来ていることも確認できます。

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

この Public Feed は NuGet クライアントからだけではなく、ブラウザからアクセスすると登録されているパッケージとバージョンを確認することが出来ます。この辺りも MyGet と似ていますね。

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

昔みたいにプライベートで NuGet Server をホストしていると、API 経由でしかパッケージを確認できなかったりしましたが、Azure Artifacts の Public Feed では便利な UI 付きなので簡単です。

価格に関しては正確な情報が見当たりませんでしたが、おそらく Private と同じく容量課金だと思います。

Public Feed のパッケージを利用する

Public Feed に登録されているパッケージを参照するには、Private と同じようにパッケージソースとしての追加が必要です。Visual Studio がインストールされている場合は、これまで通り設定から追加できます。

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

Visual Studio が無い環境では、NuGet CLI を使ってパッケージソースを追加できます。

Linux / macOS 環境では少し面倒で、Mono がインストールされている場合は NuGet CLI を利用してソースを追加できますが、それ以外の場合は手動で nuget.config を作成する必要があります。

一応 .NET Core CLI でテンプレートは作成できますが、あまり手軽ではないです。

パッケージソースを追加してしまえば、後はこれまで通り Install-Packagedotnet add package を使ってパッケージのインストールが行えます。

プレリリース扱いの場合は、バージョンを指定しないとエラーになるので注意です。

# Package Manager Console
Install-Package AzureArtifactsTest -Version 1.0.0-ci-20190724.1

# .NET Core CLI
dotnet add package AzureArtifactsTest --version 1.0.0-ci-20190724.1

認証が不要な分、すんなりと Public Feed を使うことが出来ました。

Private Feed やローカルからパッケージを追加する場合は、Credential Provider をインストールしないと地味にはまります。Azure Artifacts の Connect to feed に書いてある設定はイマイチなので、ついでに Credential Provider のインストールについても試しておきました。

おまけ : Credential Provider のインストール

Azure Artifacts の Private Feed を使う場合やパッケージを手動でプッシュする場合には、認証のために Credential Provider をインストールしておく必要があります。

Connect to feed から NuGet と一緒にダウンロードできますが、.NET Core を使う場合には以下のリポジトリからスクリプトを使ってインストールしないと dotnet コマンドで動きません。

Windows の場合は ps1 を、Linux / macOS の場合は sh をダウンロードして実行すれば、NuGet のプラグインとして Credential Provider がインストールされます。

ワンライナーだと Windows 向けは以下のようになるかと思います。ちなみにユーザー権限で良いです。

iex ((New-Object System.Net.WebClient).DownloadString('https://raw.githubusercontent.com/microsoft/artifacts-credprovider/master/helpers/installcredprovider.ps1'))

実行すると GitHub Release から最新の zip をダウンロードして、インストールまで行ってくれます。

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

インストールが終われば dotnet nuget push を使ってプッシュしていくのですが、その時に登録したフィードの名前と API キー、そして --interactive オプションを指定して実行します。

今回の例では以下のようなコマンドとなります。API キーは AzureDevOps を指定します。

dotnet nuget push KazuakixWallet.nupkg -s "Azure Artifacts Feed" -k AzureDevOps --interactive

コマンドを実行すると、途中で Azure CLI などと同じように Device Flow を利用したログインが行われます。表示された URL にブラウザでアクセスして、Code を入力するとログインが完了するやつです。

Device Flow でのログインに成功すると、NuGet パッケージのプッシュが完了します。

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

パッケージの復元時に認証周りでのエラーが出た場合は、同じように dotnet restore--interactive を付けて実行すると Device Flow でのログインが行われるようになります。

一度認証が通ってしまえば、次からはセッションが切れるまでは再ログイン不要です。

Azure Pipelines の Logging Commands が扱いにくいので CLI を作った

Azure Pipelines は Job から標準出力に特殊なテキストを書き出すと、それをコマンドとして扱う機能があります。Logging Commands と呼ばれているみたいですが、ログ以外にも機能があります。

この形式がとにかく分かりにくいし、毎回 echoWrite-Host を書くのも面倒なので、C# での CLI 実装の勉強を兼ねて作ってみました。

CommandLine 用の Parser はいろんな種類がありますが、今回は System.CommandLine を使ってみました。

クロスプラットフォーム向けの CLI には Go を使うのが一般的な流れだと思いますが、C# で書きたかったので .NET Core を使っています。

GitHub Release から適当なプラットフォーム向けのバイナリをダウンロードして叩けば動きます。ヘルプは System.CommandLine が自動的に用意してくれています。

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

サブコマンド毎にヘルプが出るので、手書きよりは分かりやすいと思います。

ちなみにスクリプトから変数を定義する場合は、以下のような引数で叩きます。やっていることは引数を解析し、Logging Commands の形にフォーマットして標準出力に書き出しているだけです。

> pipeline task setvariable testvar kazuakix
##vso[task.setvariable issecret=False;isoutput=False;variable=testvar]kazuakix

パイプライン内で実行すれば、後続の Job で変数を使った処理が行えるようになります。

とまあ、機能自体は非常に大したことがないですが、実装のためにいろんなライブラリやサービスを組み合わせて使ったので、むしろそっちの方を紹介したいので書きます。

利用しているライブラリ・サービス

System.CommandLine

リポジトリ名から .NET Core での標準になりたい雰囲気を感じたので使ってみました。

正直なところ使い勝手はイマイチかなと思います。今回は Command を継承したクラスをコマンド毎に用意して、コレクション初期化子を使って RootCommand に追加する方法を選びました。

ドキュメントとサンプルが全く足りていないという点が割と致命的な感じでした。

Warp

.NET Core 3.0 からは単体の実行ファイルとしてパッケージング出来る予定ですが、まだ Go Live も付いていないので .NET Core 2.2 +Warp という構成で実行ファイルとしてパッケージングしました。

.NET Global Tool としてインストール出来るので非常に簡単です。

Scott Hanselman もブログで紹介しているので、非常に参考になりました。仕組みとしては初回起動時に一時ディレクトリへ展開してから起動しているので、初回は少し遅いです。

Linker Option を設定すると必要ないアセンブリを削ってくれるので、最終的な実行ファイルのサイズを抑えることが出来ます。一応 crossgen とかも使っているようです。

通常の Self-contained App なら 66MB 近くになりますが、Warp 後は 17MB ぐらいです。

Azure Pipelines

ビルドとリリースは Azure Pipelines の Multi-stage pipelines を使って行いました。

今回は対応プラットフォームとして Windows / Linux / macOS の 3 つを用意したかったので、それぞれの VM Image を使ってビルドするようにパイプラインを書きました。

.NET Core では RID を指定すれば別プラットフォーム向けのバイナリを生成できますが、Azure Pipelines の各 Hosted Agent を使ってみたかったので。

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

プロジェクトが Public の場合は Windows / Linux / macOS 向け Job が並列に実行されるので、全体としたビルド時間を短縮できます。Warp を使ったビルドは Linker Option によっては時間がかかるので好都合です。

例によってリリースはタグを打った時に行うようにしていますが、今回は GitHub Release でビルドしたファイルを公開したかったので、GitHub Release Task を使ってアップロードしています。

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

それぞれのプラットフォーム毎に zip がアップロードされているのが確認できるはずです。

各プラットフォーム向けのビルドは strategy / matrix を使って書きましたが、プラットフォームでの差異を吸収するのが少し面倒でした。*1

もちろん YAML で書いたパイプライン定義を公開しているので、良ければ参考にしてください。

*1:特に実行ファイルの .exe の有無とか script での cmd / bash の差など

Azure Pipelines の Pipeline Caching (Preview) を試してみた

Azure Pipelines の弱点としてキャッシュが使えないことがちょいちょい言われているみたいですが、最近になってキャッシュ用のタスクがプレビューとして公開されたようです。

2 月ぐらいに開発が始まったようですが、まあまあ時間がかかりましたね。

ドキュメントがひっそりと追加されているので、読めば大体のパッケージマネージャ周りでの設定を学ぶことが出来ます。割と充実した内容となっているので、特に説明も不要な感じです。

何故か NuGet に関しては記載がないですが、元々メジャーなパッケージはキャッシュされているので、あまり効果が出なかったのかも知れません。

ドキュメントにある通り CacheBeta@0 というタスクが追加されています。

YAML と Classic Editor の両方で Cache と検索すると出てくるので、見つけやすいはずです。

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

設定自体はキャッシュキーと対象のパスを設定するぐらいなので簡単です。

VuePress で作っているサイトは npm を使っているので、ドキュメントに書いてある通りの設定を追加して動作と効果を試しておきました。

Pipeline Caching を有効にした YAML は以下のようになりました。

variables:
  npm_config_cache: $(Pipeline.Workspace)/.npm

steps:
- task: NodeTool@0
  inputs:
    versionSpec: '10.x'
  displayName: 'Install Node.js'

- task: CacheBeta@0
  inputs:
    key: $(Build.SourcesDirectory)/package-lock.json
    path: $(npm_config_cache)
  displayName: Cache npm

- script: |
    npm ci
    npm run build -- --dest dist
  displayName: 'npm ci and build'

最近は node_modules ではなく .npm をキャッシュしつつ、npm ci を使うのが良いと知りました。

これでビルドを行うと、キャッシュにヒットした場合は復元が行われます。npm なのでファイル数は結構多いはずですが、処理は比較的短時間で終わりました。

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

内部で CacheBeta@0 を使うようにしておくと、自動的に Post-job が追加されてキャッシュが保存されます。

キャッシュキーが変化していない場合はスキップされるので安心です。

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

Fingerprint が同じでも PR と master 間でキャッシュがヒットしなかったりと少し謎な挙動もありますが、やっと他の CI SaaS が当たり前に持っている機能が Azure Pipelines でも使えるようになってきました。

キャッシュの効果

まだプレビューなので意味はない気がしますが、キャッシュの有無での処理時間の違いを簡単に調べました。

Cache あり Cache なし
npm install / ci 5-7sec 30-35sec
キャッシュにかかる時間 10-12sec 0sec

正直なところ、VuePress ぐらいしか使ってないリポジトリだと効果はあまりなさそうです。

もうちょっと大量のパッケージを参照してる場合には差が出るのかも知れません。インストール時にビルドが必要なパッケージの場合は特に期待できそうです。

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

Pipeline Analysis を見ると、ちょびっとだけ改善はしているようでした。

とは言ってもキャッシュの保存と復元はそれなりに重たい処理にはなるので、何も考えずに設定というのは止めた方が良さそうです。

Azure Pipelines での Multi-stage pipelines の利用と既存パイプラインの移行

Azure Pipelines のドキュメントを読んでいると Release Pipeline に Classic と付けられていたので、先行きが少し怪しいです。Microsoft 的には YAML + Multi-stage pipelines を推奨して行くということのようです。

Build 2019 で発表されていたらしいですが、余り真面目に読んでませんでした。

まだ一部の機能は対応していないようですが、継続的に対応を進めていくらしいです。

これまでの GUI で設定してきた Build / Release Pipeline の構成は、Build stage と Release stage として扱えば、そのまま Multi-stage pipelines として移行出来ます。

簡単かと思っていましたが、情報が少ないのもあって地味に Multi-stage pipelines への移行で苦労してしまったので、引っかかった部分と実際に移行した例を紹介します。

Multi-stage pipelines を使い始める

これまでは step だけ書くことが多かったかも知れませんが、Multi-stage pipelines では stage > job > step の順で書いていきます。複数の job をまとめる単位として stage が導入されたという形です。

まだドキュメントがかなりしょぼいので、どう書くのがシンプルか試しながらという感じです。

試している限りでは依存関係周りが少し不可解な挙動をしていました。ちなみに dependsOn を使って各 stage の依存関係を定義できますが、何も定義しなければ書いた順に処理されます。

GitHub にサンプルの YAML がいくつか置いてありますが、まあサンプルだなという感じです。

ドキュメントにある YAML schema が個人的には一番参考になりました。

Multi-stage pipelines はプレビューなので、使う場合は Preview features から有効化する必要があります。

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

有効化するとサイドバーから Build が消えて Pipeline に置き換わります。以下のように UI もガラッと新しくなりますが、新しい UI の方がビルドの状態が見やすくなったと思います。

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

これで準備は出来たので、YAML で各 stage を書いていくという流れになります。

Multi-stage pipelines を使う基本的な YAML

YAML Editor は Multi-stage pipelines 向けの便利機能はパッと見なさそうなので、頑張って書いていくことになります。何もなしで書くのはしんどいので、テンプレ的な YAML を用意しました。

trigger:
- master

stages:
- stage: Build
  jobs:
  - job: Build
    steps:
    - script: echo 'Build'

- stage: Release
  dependsOn:
  - Build
  jobs:
  - job: Release
    steps:
    - script: echo 'Release'

dependsOn を使って Release stage は Build stage に依存していることを定義します。

これまで Release Pipeline は master や特定のブランチ、タグの時だけ動くようにするケースも多かったと思います。その場合は Release stage の condition に条件を書いて対応します。

trigger:
- master

stages:
- stage: Build
  jobs:
  - job: Build
    steps:
    - script: echo 'Build'

- stage: Release
  dependsOn:
  - Build
  condition: and(succeeded(), eq(variables['Build.SourceBranch'], 'refs/heads/master'))
  jobs:
  - job: Release
    steps:
    - script: echo 'Release'

これで Build stage が成功かつ master ブランチの時のみ Release stage が実行されるようになります。忘れずに succeeded() を条件に加えないと、Build stage が失敗しても動くようになってしまいます。

ビルドに使う VM Image を設定

デフォルトでは Ubuntu 16.04 が使われますが、それ以外を使う場合は job 単位で vmImage を設定する必要があります。これが地味に面倒なので、変数にするのが良いでしょう。

trigger:
- master

variables:
  imageName: windows-2019

stages:
- stage: Build
  jobs:
  - job: Build
    pool:
      vmImage: $(imageName)
    steps:
    - script: echo 'Build'

- stage: Release
  dependsOn:
  - Build
  jobs:
  - job: Release
    pool:
      vmImage: $(imageName)
    steps:
    - script: echo 'Release'

これで Visual Studio 2019 + Windows Server 2019 な VM 上で実行されるようになります。デフォルトの VM Image を指定することが YAML からは出来ないのが少し残念です。*1

Pipeline Artifacts への移行

地味に分かりにくかったのが Build Artifacts から Pipeline Artifacts への移行です。あまり違いが無さそうに感じますが、ドキュメント曰く出力先のストレージが速くなっているらしいので移行が推奨されています。

We recommend upgrading from build artifacts to pipeline artifacts (preview) for faster output storage speeds.

Publish and consume build artifacts in builds - Azure Pipelines and TFS | Microsoft Docs

Pipeline Artifacts はドキュメントが比較的整備されているので参考になります。

これまではビルドパイプラインで PublishBuildArtifacts タスクを使って Artifacts を保存していましたが、これからは publishdownload を使って書いていくことになります。

publish を使って保存された Artifacts はビルドログから簡単に参照できます。

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

見た感じ保存時にいい感じに圧縮してくれるようなので、単純にディレクトリを指定すれば良さそうです。

VuePress でビルドしたサイトを Pipeline Artifacts に保存する時には以下のように定義を書きました。publish で指定するパスは $(System.DefaultWorkingDirectory) からの相対パスになります。

steps
- publish: dist
  artifact: site

Artifact 名は適当に分かりやすい名前を付けておけば良いです。後続のダウンロード時のディレクトリ名になるので、変な名前にすると少し面倒になります。

保存した Artifacts を Release stage から読みだす場合は download を使います。結構オプションが多いですが、基本は以下のような形で使えば問題ないでしょう。

steps
- download: current
  artifact: site

上のように current を指定すると、現在実行している Pipeline Artifacts からファイルをダウンロードしてきます。別のパイプラインから取ってくることも出来るみたいですが、使う機会が浮かびませんでした。

ダウンロードされるパスがデフォルトだと $(Pipeline.Workspace) 以下になるので注意が必要です。

Deployment job と Environments を使ってデプロイ

ここまで Release Pipeline に相当する stage を単純な job として書いてきましたが、Multi-stage pipelines では新しく Deployment jobs というものが導入されたので、これを使うとデプロイに特化した Job とデプロイ履歴といった機能が提供されます。

具体的には Deployment jobs ではソースのチェックアウトが行われず、自動で Pipeline Artifacts のダウンロードが行われるようになります。ドキュメントも一応用意されています。

同時に追加された Environments と組み合わせることで、デプロイ先リソースを事前に定義しつつ柔軟なデプロイが実現可能になるはずですが、現在は Kubernetes のみ対応なので積極的に使いたい理由が残念ながらあまり見当たりません。今後の対応リソース拡充に期待しています。

Deployment jobs を使うための YAML は以下のようになります。ちょっと深いです。

trigger:
- master

stages:
- stage: Build
  jobs:
  - job: Build
    steps:
    - script: echo 'Build'

- stage: Release
  dependsOn:
  - Build
  jobs:
  - deployment: Release
    environment: production
    strategy:
      runOnce:
        deploy:
          steps:
          - script: echo 'Release'

今後 strategy で指定できる値は runOnce の他に rolling が増える予定らしいです。

App Service へのデプロイを行う場合は、空の Environment を作成して対応します。空の Environment でもデプロイの履歴は残るので Pipeline の履歴から辿るよりは便利になります。

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

それぞれの Deployment を選択すると、そのデプロイに含まれているコミットを見ることも出来るので、障害発生時にどのコミットが原因になったのかという調査が楽になりそうです。

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

特に難しくないので Deployment job を使っておいても良いと思いますが、今後 App Service などに対応してきた場合に移行で苦労しそうな気もするので、今やる必要があるのかといわれると悩みます。

App Service に Environments が対応したタイミングで移行しようかと考えています。

実際に Multi-stage pipelines へ移行した例

手持ちのプロジェクトをいくつか Multi-stage pipelines へ移行したので紹介します。まだ試行錯誤しているのでこれが正解という感じはしないですが、YAML の参考にはなると思います。

App Service へのデプロイ

ASP.NET Core アプリケーションと VuePress で作成したサイトを App Service へ Run From Package を使ってデプロイする YAML 定義です。

master にコミットされた時だけデプロイするように条件を組んでいます。

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

地味に条件が複雑に見えてしまうので、シンプルにビルド&テスト用とデプロイ用で Pipeline を分けた方がいい気がしてきました。YAML も別々で管理するので、分かりやすくなりそうです。

静的サイトの Azure Storage へのデプロイ

VuePress で作成したサイトを Azure Storage の静的サイトホスティングを使って公開する定義です。

基本は App Service へのデプロイと同じですが、このサンプルでは Deployment jobs を使っています。

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

ビルドの依存関係も単純に Build => Deploy という形になっています。案外 Job 名を決めるのが面倒です。

NuGet パッケージのビルドと発行

v から始めるタグが打たれた時に nupkg を作成し、そのまま NuGet に公開する定義です。

これもまた App Service へのデプロイの時と同じような流れです。タグが打たれた時という条件を付けるために、少しややこしくはなっています。

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

やっぱり Pipeline を分割した方が分かりやすいし、管理も楽になりそうな気がしてきました。

*1:Trigger 設定から辿ればデフォルトの VM Image の変更はできる

Azure Storage と Azure Pipelines で静的サイトのホスティングとデプロイ自動化を行う

静的サイトのホスティングを App Service で行うことが多いのですが、まあ高確率で Azure Storage の Static website について言及されます。Static website は便利なんですが、フロントに Azure CDN がほぼ必須かつデプロイが行いにくいので避けてきました。

とはいえ、そろそろデプロイ含め自動化をしたいと思ったので、使い道のないドメインを使って試しておくことにしました。VuePress を使って適当に作ったサイトをデプロイします。

上のリポジトリを Azure Pipelines がビルドして、Azure Storage にデプロイするようにパイプラインを組みます。とはいえビルド部分は割とどうでもよい感じです。

基本的には公式ドキュメントやチュートリアルにある内容なので、デプロイ部分だけが重要です。

今回は手軽さを重視して Azure CDN を使いましたが、Front Door でも同じことが行えます。Front Door は高機能ですがその分設定が少し複雑なので、サクッと試す分には Azure CDN が楽です。

Azure Storage の環境を構築する

ほぼドキュメント通りかつ、Static website の設定は既にいろいろと情報があるので端折ります。GPv2 なストレージアカウントを好きなリージョンに作成しておけば良いです。

Static website を有効化する

デプロイするサイトは VuePress を使っているので、エラードキュメントのパスは 404.html となります。

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

デフォルトのままで良いはずですが、ちゃんと設定されているか確認しておきます。

Azure Pipelines からデプロイ

VuePress のビルドは前に書いた通りなので省略します。今回も同じように zip にしたものを Artifact としてプッシュするようにしておきます。

リリースパイプラインは以下のように組みました。わざわざ受け渡しを zip にしているのは、ファイル数が多くなった時に固めておいた方が処理が早いからです。

今回は Azure Storage にファイルをコピーする方法として AzCopy を使いました。

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

Azure Storage にファイルをコピーする方法としては Azure File Copy というタスクもありますが、不要な Blob を削除してくれないので相性が悪いです。

本来ならアトミックにデプロイしたいところですが、Azure Storage だと AzCopy が限界な気がします。

AzCopy v10 は入っていないようなので、以下のようなスクリプトを書いて適当にダウンロードします。Linux 向けを落とすので、ホストは Ubuntu を使います。

wget https://aka.ms/downloadazcopy-v10-linux
tar -xvf downloadazcopy-v10-linux
cp ./azcopy_linux_amd64_*/azcopy ./

ダウンロードした AzCopy を使って Azure Storage とビルドした結果を同期するわけですが、SAS 周りの扱いが割と面倒なのでスクリプトがごちゃごちゃします。

この辺りは AzCopy を使うタスクがあればシンプルに使えそうですが、見当たりませんでした。

end=`date -d "5 minutes" '+%Y-%m-%dT%H:%M:%SZ'`
sas=`az storage container generate-sas -n '$web' --account-name $1 --https-only --permissions dlrw --expiry $end -otsv`
./azcopy sync "$(System.DefaultWorkingDirectory)/dist/" "https://$1.blob.core.windows.net/\$web?$sas" --recursive --delete-destination=true

スクリプトをコピペして、ストレージアカウント名を Arguments として設定すれば終わりです。

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

適当に新規リリースを作成すると、パイプラインが動いて Azure Storage にファイルがコピーされます。

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

Static website の URL を確認すると、VuePress で作成したページが表示されます。

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

これで今回の作業はほぼ終わりです。後はおまけ程度に Azure CDN の設定を行うだけです。

Azure CDN の設定を行う

既に Azure CDN のカスタムドメインや HTTPS については書いているので、今回はさらっと流します。

CDN Endpoint 作成時に Custom origin を選ぶことを忘れないようにすれば大体 OK です。

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

Storage Account を選ぶと通常の Blob Endpoint になるのは地味に罠っぽいです。

カスタムドメインの設定

Azure DNS を使って Alias record set を作成して対応します。CDN Endpoint がドロップダウンに出てくるので、この辺りは簡単に設定できます。

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

検証に必要な CNAME を同時に作成してくれるので、Alias record set の作成後は Azure CDN にカスタムドメインを追加すれば完了です。

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

少し待てば、設定したカスタムドメインでアクセス出来るようになっているはずです。

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

必要であれば HTTPS の有効化も行いましょう。折角なので最後まで行っておきました。

HTTPS を有効化する

Azure CDN は無料で DigiCert の証明書が使えますが、プロビジョニングに時間がかかるので今回は Key Vault に入っていた Let's Encrypt の証明書を使いました。

適当に Key Vault や証明書、バージョンを選択して保存すれば CDN POP へのデプロイが行われます。

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

2 時間ぐらいかかるとありますが、実際にはもう少し早く終わることが多い印象です。

証明書のデプロイが終われば、問題なく HTTPS でアクセス出来るようになります。これで完成です。

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

Azure CDN / Front Door に Key Vault から証明書をデプロイした場合は自動更新されないという弱点がありますが、ロードマップには入っていて来年頭ぐらいに対応予定のようです。

ちょいちょい Azure CDN / Front Door について呟くと、中の人から教えてもらえます。

補足 : Front Door の場合

Front Door はバックエンドは Azure CDN と同じなので、大体は CDN と同じような設定で使えます。Front Door の方が HTTPS リダイレクトが使えたり、複雑なルールも書けるので便利ではあります。

気になった部分については、前にまとめを書いているので参照してください。

追記 : CDN の Purge をリリースパイプラインに入れる

単純に Azure CDN を被せただけだとファイルのキャッシュがなかなか消えてくれないので、Azure Pipelines で CDN キャッシュのパージまで行った方が良かったです。

Azure CLI を使うと、以下のようなコマンドで CDN エンドポイントのパージが行えます。

az cdn endpoint purge -g <resourcegroup> -n <endpoint> --profile-name <cdnprofile> --content-paths '/*'

リリースパイプラインに入れると、Storage へのファイルコピー後にパージを行えるので安心。

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

Azure CDN の Microsoft / Verizon では 2 分ほど、Akamai だと 10 秒ほどかかるとドキュメントにあります。上のコマンドは --no-wait を付けない限り、完了するまで返ってこないので分かりやすいです。

Azure Cosmos DB .NET SDK v3 GA 記念チートシート

5 月の Build で月末 GA が発表されていた Azure Cosmos DB の .NET SDK v3 ですが、昨日ついに正式版がリリースされました。特に Public Preview の時からは API が大きく変わっているので注意です。

元は JavaScript SDK に近い API でしたが、途中で大幅に変更されています。

そして Cosmos DB チームがセマンティックバージョンをミスったようで、プレビュー版の NuGet パッケージをインストールしていると、正式版が更新対象として出てこないので更に注意が必要です。

新しい v3 SDK の特徴としては .NET Standard 2.0 ベースになったことでしょう。これで、これまでのように .NET Framework 向けと .NET Core 向けで別々のパッケージを使う必要がなくなります。

他にも Stream ベースの API が追加されたり、LINQ への対応も行われました。

設計が凄くイマイチで拡張性が皆無だった v2 からさっさと移行したいので、よく使うパターンについて正式版向けにメモを残しておきます。足りないものは後で追加するかもしれません。

これぐらいあれば v2 からスムーズに移行出来そうな気がします。v3 から追加された機能は、v2 にはなかった拡張性を提供してくれるものがあるのでかなり嬉しい部分です。

基本的な Cosmos DB の操作

CosmosClient の作成

// Direct mode を使う CosmosClient を作る
var cosmosClient = new CosmosClient("AccountEndpoint=https://**.documents.azure.com:443/;AccountKey=**;", new CosmosClientOptions
{
    ConnectionMode = ConnectionMode.Direct
});

// Builder を利用して CosmosClient を作る
//var cosmosClient = new CosmosClientBuilder("AccountEndpoint=https://**.documents.azure.com:443/;AccountKey=**;")
//                   .UseConnectionModeDirect()
//                   .Build();

// 操作対象の Container を取得
var container = cosmosClient.GetContainer("TestDB", "Users");

アイテムの作成

// 新しいアイテムを用意する
var user = new User
{
    Id = Guid.NewGuid().ToString(),
    Name = "kazuakix",
    Age = 50,
    Prefecture = "wakayama"
};

// アイテムを新規作成
await container.CreateItemAsync(user);

// Upsert も同じように実行できる
//await container.UpsertItemAsync(user);

アイテムの更新(入れ替え)

// アイテムのプロパティを更新
user.Age = 60;

// Id を明示的に指定する
await container.ReplaceItemAsync(user, user.Id);

アイテムの削除

// 削除時は PartitonKey を明示的に指定する、型引数に注意
await container.DeleteItemAsync<User>(user.Id, new PartitionKey(user.Prefecture));

アイテムの読み込み

// 読み込み時は PartitionKey を明示的に指定する(Preview の時から大きく変わっているので注意)
var response = await container.ReadItemAsync<User>(user.Id, new PartitionKey(user.Prefecture));

Console.WriteLine($"{response.Resource.Name},{response.Resource.Age}");

クエリを実行する

全体的に FeedIterator<T> を使ってデータを読み込むようになっています。

SQL を利用したクエリの実行

// PartitionKey を指定しないと Cross-Partition Query になるので注意
var queryRequestOptions = new QueryRequestOptions { PartitionKey = new PartitionKey("wakayama") };

// SQL を使ってアイテムを読み込む
var iterator = container.GetItemQueryIterator<User>("SELECT * FROM c WHERE c.age > 70", requestOptions: queryRequestOptions);

// SQL にパラメータを渡す場合は QueryDefinition を使う(パラメータの型には注意)
//var query = new QueryDefinition("SELECT * FROM c WHERE c.age > @age").WithParameter("@age", 70);

// QueryDefinition を使ってアイテムを読み込む
//var iterator = container.GetItemQueryIterator<User>(query, requestOptions: queryRequestOptions);

do
{
    // 結果セットを取得する
    var result = await iterator.ReadNextAsync();

    foreach (var item in result)
    {
        Console.WriteLine($"{item.Name},{item.Age}");
    }

    // 続きがあれば繰り返す
} while (iterator.HasMoreResults);

LINQ を利用したクエリの実行

// PartitionKey を指定しないと Cross-Partition Query になるので注意
var queryRequestOptions = new QueryRequestOptions { PartitionKey = new PartitionKey("wakayama") };

// 同期的にアイテムを読み込む (allowSynchronousQueryExecution = true は必須)
var result = container.GetItemLinqQueryable<User>(allowSynchronousQueryExecution: true, requestOptions: queryRequestOptions)
                      .Where(x => x.Age > 70)
                      .ToArray();

foreach (var item in result)
{
    Console.WriteLine($"{item.Name},{item.Age}");
}

// 非同期の場合は FeedIterator<T> に変換してから取得する
var iterator = container.GetItemLinqQueryable<User>(requestOptions: queryRequestOptions)
                        .Where(x => x.Age > 70)
                        .ToFeedIterator();

do
{
    // 結果セットを取得する
    var result = await iterator.ReadNextAsync();

    foreach (var item in result)
    {
        Console.WriteLine($"{item.Name},{item.Age}");
    }

    // 続きがあれば繰り返す
} while (iterator.HasMoreResults);

v3 で追加された機能

RequestHandler の追加

v3 から独自の RequestHandler を追加して、Cosmos DB へのリクエスト前後に処理を追加できるようになりました。HttpClient で言うところの DelegatingHandler に相当する機能です。

public class MyCosmosRequestHandler : RequestHandler
{
    public override async Task<ResponseMessage> SendAsync(RequestMessage request, CancellationToken cancellationToken)
    {
        var response = await base.SendAsync(request, cancellationToken);

        Console.WriteLine($"RequestCharge: {response.Headers.RequestCharge}");

        return response;
    }
}

上のサンプルのように RequestHandler を追加すると、消費された RUs を簡単に Application Insights に送信して可視化といったことも出来るはずです。

作成した RequestHandler は Builder を使ったパターンだと設定が楽です。

var cosmosClient = new CosmosClientBuilder("AccountEndpoint=https://**.documents.azure.com:443/;AccountKey=**;")
                   .WithConnectionModeDirect()
                   .AddCustomHandlers(new MyCosmosRequestHandler())
                   .Build();

これで独自の RequestHandler を使う CosmosClient が作成出来ます。v2 から比べると格段に便利です。

JsonSerializer の入れ替え

Cosmos DB で面倒なのが camelCase にしないと id がそもそも認識されないことです。これまではモデルが持つプロパティ全てを小文字で定義するか、属性で別名を付けてきたかと思います。

v3 SDK からは Serializer の拡張が行えるので、自動で camelCase にする Serializer を作れば解決します。

public class MyCosmosJsonSerializer : CosmosSerializer
{
    private static readonly Encoding DefaultEncoding = new UTF8Encoding(false, true);
    private static readonly JsonSerializer Serializer = new JsonSerializer()
    {
        NullValueHandling = NullValueHandling.Ignore,
        // 自動で camelCase に変換するように設定する
        ContractResolver = new CamelCasePropertyNamesContractResolver()
    };

    public override T FromStream<T>(Stream stream)
    {
        using (stream)
        {
            if (typeof(Stream).IsAssignableFrom(typeof(T)))
            {
                return (T)(object)(stream);
            }

            using (StreamReader sr = new StreamReader(stream))
            {
                using (JsonTextReader jsonTextReader = new JsonTextReader(sr))
                {
                    return MyCosmosJsonSerializer.Serializer.Deserialize<T>(jsonTextReader);
                }
            }
        }
    }

    public override Stream ToStream<T>(T input)
    {
        MemoryStream streamPayload = new MemoryStream();
        using (StreamWriter streamWriter = new StreamWriter(streamPayload, encoding: MyCosmosJsonSerializer.DefaultEncoding, bufferSize: 1024, leaveOpen: true))
        {
            using (JsonWriter writer = new JsonTextWriter(streamWriter))
            {
                writer.Formatting = Formatting.None;
                MyCosmosJsonSerializer.Serializer.Serialize(writer, input);
                writer.Flush();
                streamWriter.Flush();
            }
        }

        streamPayload.Position = 0;
        return streamPayload;
    }
}

上の実装はデフォルトの Serializer をそのまま持ってきて、camelCase にする設定だけ追加したものです。

Serializer も Builder を使った方が分かりやすく設定出来るのでお勧めです。

var cosmosClient = new CosmosClientBuilder("AccountEndpoint=https://**.documents.azure.com:443/;AccountKey=**;")
                   .WithConnectionModeDirect()
                   .WithCustomSerializer(new MyCosmosJsonSerializer())
                   .Build();

これで自動的に Cosmos DB へは camelCase になったモデルが保存されるようになります。デフォルトが camelCase でも良いぐらいです。

Stream API の追加

v3 SDK からは自動で JSON をシリアライズ、デシリアライズするのではなく、Stream を受けたり返したりするためのメソッドが用意されています。

とはいえ、個人的には使い道がまずないなという印象です。最初から最後まで UTF-8 な JSON を扱う場合のみエンコード変換やシリアライズのコストがかからないので、パフォーマンスは上がりそうです。

.NET Standard 2.1 になれば Span<T> や System.Text.Json などが使われて改善すると思います。

Change Feed Processor / Estimator

以前は別パッケージだった Change Feed Processor が v3 SDK からは統合されました。割とシンプルなコードで Change Feed の処理を追加できるようになっています。

var cosmosClient = new CosmosClientBuilder("AccountEndpoint=https://**.documents.azure.com:443/;AccountKey=**;")
                   .WithConnectionModeDirect()
                   .Build();

var container = cosmosClient.GetContainer("TestDB", "Users");
var leaseContainer = cosmosClient.GetContainer("TestDB", "leases");

var processor = container.GetChangeFeedProcessorBuilder<User>("processor1", OnChangeFeed)
                         .WithInstanceName("testinstance")
                         .WithLeaseContainer(leaseContainer)
                         .Build();

await processor.StartAsync();

実際に動かしてみましたが、別パーティションでも問題なく変更を取れていました。

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

大半のケースでは Azure Functions の CosmosDBTrigger を使っていると思うので、そっちの対応待ちです。

おまけ的な紹介になりますが、変更通知がどのくらい溜まっているかを通知する機能も追加されています。スケーリングの指標に使ったりは出来るのではないかと思います。

Azure Resource の Tags を使って Service Discovery を実現するライブラリを作った

こないだ Azure App Service / Functions 向けに App Configuration を使ったシンプルな Service Discovery を作ってみたのですが、 Azure Resource は全て Azure Resource Manager の API を使えば探せます。

そして各 Resource にはタグを設定できるので、こっちの方が優れている気がしたので ARM を利用する Service Discovery を実装しました。

ドキュメントは未整備なので、とりあえず軽く仕組みと使い方を紹介しておきます。

最近実装した SimpleDiscovery に対する拡張して SimpleDiscovery.AzureResourceManager というパッケージを用意しています。SimpleDiscovery については前回のエントリを参照してください。

結局のところ、環境ごとに変わってしまう URL をそのまま扱うのではなく、識別子を使って実行時にサービスの接続先を解決することで、柔軟な依存関係を構築できるようにしたいという目的です。

今回の ARM 拡張では、その識別子に Azure Resource のタグを使ったという話です。

特定のタグが付いている Azure Resource を抽出するためには、Resource Graph と Kusto を使ってクエリを書きます。前回 Resource Graph で遊んだのはこれを実装していたからです。

実装の解説はこれぐらいにして、実際に使ってみます。現在の制約としては、サービスの接続先は App Service (Web Apps / Functions) かつ HTTPS を受け付けるようになっている必要があります。*1

サンプルとしてフロントエンドとなる ASP.NET Core MVC アプリケーションと、サービス API を提供している Azure Function という構成を用意します。この例では呼び出しは一方通行です。

呼び出される Azure Function を用意

先に API として Function を用意しておきました。ほぼテンプレートのままで、API 名だけ変更しています。

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

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

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

        return name != null
            ? (ActionResult)new OkObjectResult($"Hello, {name}")
            : new BadRequestObjectResult("Please pass a name on the query string or in the request body");
    }
}

適当な Resource Group に Azure Function を新しくデプロイして、作成した Function App もデプロイするわけですが、忘れないようにタグを追加しておきます。

タグ名は今のバージョンでは Registry となっていますが、微妙な気がしてきたので正式版までに変える気がします。値には API のサービス名を設定しておきます。

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

この辺りは ARM Template を使ったデプロイを行えば、タグの追加も行えるので楽です。Azure Function 側の設定はこれでほぼ終わりです。

呼び出し側 Core MVC アプリケーションの用意

まずはサービスを呼び出す Core MVC 側に SimpleDiscovery をインストールします。

Install-Package SimpleDiscovery.AzureResourceManager -Pre

インストールが済んだら Startup で DI に登録するコードを追加します。Managed Identity を Azure 上では使うので、基本的にコード内での設定は不要ですが、開発環境のために必要な情報を設定可能にしています。

この辺りのオプションは、正式リリースまでには Configure<TOptions> も使えるようにします。

public class Startup
{
    public void ConfigureServices(IServiceCollection services)
    {
        services.AddSimpleDiscovery()
                .AddAzureResourceManager();
                // 必要であれば TenantId / SubscriptionId / ResourceGroup の設定をする
                //.AddAzureResourceManager(options =>
                //{
                //    options.TenantId = "<tenant id>";
                //    options.SubscriptionId = "<subscription id>";
                //    options.ResourceGroup = "<resource group>";
                //});

        services.AddHttpClient<DemoService>()
                .WithSimpleDiscovery();

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

後は AddHttpClient<TClient>WithSimpleDiscovery を呼び出して、サービス名で接続先を解決するように設定します。設定はこれで終わりです。

登録した DemoService の実装は HttpClient を使って、Function App に実装されている /api/SayHello にリクエストを投げているだけなので省略します。

サービスを使う側ですが、コントローラを少し弄って POST で渡された名前を、そのまま DemoService の呼び出しに使うようにしたという、非常に簡単なコードです。

public class HomeController : Controller
{
    public HomeController(DemoService demoService)
    {
        _demoService = demoService;
    }

    private readonly DemoService _demoService;

    public IActionResult Index()
    {
        return View();
    }

    [HttpPost]
    public async Task<IActionResult> Index(string name)
    {
        var result = await _demoService.SayHelloAsync(name);

        ViewBag.ApiResult = result;

        return View();
    }
}

後はビューを用意しましたが、本質的な部分ではないので省略します。今回の重要なポイントは ARM の情報を使って接続先を動的に解決して、ちゃんと通信が確立できるかという部分です。

呼び出し側のアプリケーションはこれで完成したので、既に Azure にデプロイされているサービスを使ってローカルからテストしてみたのが以下の図です。

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

意図したとおりに Managed Identity を使って ARM から接続先のサービスを解決できています。

動作は問題なかったので、このアプリケーションも Azure にデプロイしますが、当然ながら Managed Identity の設定が必要になるのでそこだけは注意が必要です。

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

アクセス先となる Function App に対して個別に IAM を設定しても良いですし、リソースグループに対して付けても良い気がします。複数のサービスがある場合には共通の User Assigned Managed Identity を作成して、それを使いまわした方が便利でしょう。

Managed Identity を設定済みの Web App にデプロイすると、ローカルと同じようにすんなり動きます。

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

これで環境が変わったとしても適切なタグが設定されていれば、サービスの URL を気にせずに扱えるようになりました。ちなみに同名のサービスが複数見つかった場合は、ランダムでどちらかが選ばれます。

更に Application Insights を ASP.NET Core アプリと Azure Functions で共通化しておけば、分散トレーシングもちゃんと動作するので共通化はお勧めしています。

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

ネットワーク周りの課題に関しては VNET Integration と Service Endpoint、そして Azure Functions の Premium Plan でほぼ解決しそうです。

これで Azure Serverless を使って Microservices Architecture を実現し、更に ARM Template で展開まで出来そうな感じがしてきました。暇な時に大きめのサンプルを書いてみたいところです。

*1:普通に App Service を作るとデフォルトで HTTPS が使えるので、特に気にする必要はないです。

Azure Resources に対して Kusto を使ってクエリが書ける Resource Graph で遊んだ

Azure にデプロイ済みのリソースをいい感じにクエリしたかったので、その辺りについてぶちぞう RD に聞いたら Resource Graph を使えと言われたので色々触って遊んでみました。

割と前からあるサービスでした。ドキュメントを読む限りでは、Azure Resource Manager のデータを Data Explorer に詰めて、柔軟にクエリを実行出来るようにしたもののようです。

変更通知を使ってデータを更新してるらしいので、ほぼリアルタイムで情報は反映されるようです。

肝心のデータは Kusto を使って取得することになります。Azure のクエリでは Kusto は避けて通れない感ありますが、まあ得意な人がクエリを書けばよいと思います。

Azure Monitor では Kusto は必須なので、興味があればいくつかパターンを覚えておけば良いです。

最近、またいろんな KQL を書いたので暇な時にチートシート的に残しておこうと思います。

とりあえず Resource Graph を Cloud Shell と C# から使ってみたので、使い方を残しておきます。

Cloud Shell から利用する

Cloud Shell の Azure CLI には Resource Graph の拡張が入っていないので、最初にインストールします。

az extension add --name resource-graph

Resource Graph の API はクエリ実行しかないので簡単です。az graph query だけ覚えておけば使えます。

例としてリソース名に "daru" が含まれているものを 3 件取得するクエリを書いてみました。

# リソース名に daru が含まれているものを 3 件取得
az graph query -q "where name contains 'daru' | project name, type | take 3"

Azure Monitor の時のようにテーブルを最初に指定する必要はないです。いきなりオペレータを書きます。

Cloud Shell で実行すると、ちゃんと指定したクエリの結果が返って来ます。

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

使い方はこれだけです。多少の Azure Resource Manager の理解と Kusto が書ければ自由な条件で、リソースに対するクエリを柔軟に書けるので便利です。

Resource Provider を絞り込めば、そのリソースが持つ固有のプロパティも簡単に参照できます。

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

上のクエリでは App Service が使っている Plan の SKU をテーブル表示しています。条件を工夫すれば、設定抜けのチェックなどをクエリで書くことも出来そうです。

C# SDK から利用する

NuGet で公開されている以下のパッケージを使うと、C# からも同じように Kusto で Azure Resources へのクエリを実行することが出来ます。

少し使い方に癖があるので、簡単なクエリを実行するサンプルを載せておきます。

サブスクリプション ID は最低でも 1 つ必要なのと、QueryRequestOptions#ResultFormatObjectArray を指定する部分がポイントです。特に ResultFormat はデフォルトでは DataTable のようなカラムとデータが分かれた形で返ってくるので扱いにくいです。

class Program
{
    static async Task Main(string[] args)
    {
        var resourceGraphClient = new ResourceGraphClient(new TokenCredentials(new AppAuthenticationTokenProvider()));

        var query = new QueryRequest
        {
            Subscriptions = new[] { "<subscription_id>" },
            Query = "where type =~ 'microsoft.web/sites' | project name, location, properties.sku",
            Options = new QueryRequestOptions
            {
                ResultFormat = ResultFormat.ObjectArray
            }
        };

        var resources = await resourceGraphClient.ResourcesAsync(query);

        var result = ((JToken)resources.Data).ToObject<QueryResultModel[]>();

        foreach (var item in result)
        {
            Console.WriteLine($"{item.Name},{item.Location},{item.Sku}");
        }
    }
}

public class QueryResultModel
{
    [JsonProperty("name")]
    public string Name { get; set; }

    [JsonProperty("location")]
    public string Location { get; set; }

    [JsonProperty("properties_sku")]
    public string Sku { get; set; }
}

クエリの実行結果は Data プロパティに入っているので、適当にキャストしてモデルにバインドしてあげれば扱いやすい形で取り出せます。resourcesAsync が Generics Method になっていれば便利でしたね。

上のコードを実行すると、Azure CLI で実行したのと同じ結果が返って来ます。

本題とは関係ないですが、Managed Identities を使って各 ARM クライアントを使う際には、以下のような ITokenProvider を実装したクラスを用意しておけば便利に使えます。

internal class AppAuthenticationTokenProvider : ITokenProvider
{
    private static readonly AzureServiceTokenProvider TokenProvider = new AzureServiceTokenProvider();

    public async Task<AuthenticationHeaderValue> GetAuthenticationHeaderAsync(CancellationToken cancellationToken)
    {
        var accessToken = await TokenProvider.GetAccessTokenAsync("https://management.azure.com/", cancellationToken);

        return new AuthenticationHeaderValue("Bearer", accessToken);
    }
}

ASP.NET Core や Azure Functions などで DI を使ってクライアントを取りたい時など、Task ベースの非同期が使えない場面でも ITokenProvider を用意すれば非同期のままアクセストークンを取得できます。

取得したアクセストークンはキャッシュされているので、何回呼び出しても有効期限内は同じものが返って来ます。DI で都度インスタンスを作成しても安心です。

Azure App Service 向けに簡単な Service Discovery を提供するライブラリを作った

Azure App Service や Azure Functions でアプリケーションを書いている時に、別でホストされている API を叩きたい時にはデプロイされた環境単位で向き先を変える必要がありますが、大抵のケースでは App Settings に入れることになるので設定が面倒になっていました。

アプリが 1 つならまだ我慢できそうですが、複数の API を呼び出す場合などは大体破綻します。

そこでこれまで App Settings に追加していた設定をプレビュー中の Azure App Configuration に入れると、中央集権的に管理できるので楽になります。

そもそもコンテナーを使っている場合は DNS を使ってサービス名で簡単に参照できるので、App Service でも同じようにサービス名でアクセス出来るようにしたいと思っていました。

というような話を六本木でしていたところ HttpClientFactory と組み合わせると、もっとシンプルかつ使いやすく出来るのではないかと思ったのでライブラリとして作ってみました。

これは俗にいう Service Discovery だなと思ったので、雑に名前を付けました。

ただし App Service や Azure Functions をターゲットにしているので、Self-registration 周りの機能は用意しませんでした。App Service には既に LB とインスタンス管理が付いているので、デプロイ時に登録すれば変更することはまずありません。*1

Service Registry としては App Configuration をそのまま使うようにしていますが、DI で拡張可能なので Key Vault や他のストアを追加することも出来ます。

特徴としては HttpClientFactory に乗っかるように作っているので、Polly や Refit と組み合わせて利用できます。この辺りは公式ドキュメントでも紹介されています。

Retry と Circuit Breaker は必要な機能なので、HttpClientFactory に乗っかっておいたのは正解でした。

とりあえず前置きが長くなりすぎたので、簡単に使い方を紹介しておきます。

基本的な使い方

最初に App Configuration をデプロイして Services:<ServiceName> というキー名で API エンドポイントを登録します。必要であれば環境名でラベルを追加しても良いです。

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

ここで指定したサービス名は HttpClientFactory で使います。

Azure Functions を例に説明します。普通に HttpClientFactory を使う時と同じコードを書いていきますが、利用する Service Registry の登録と Service Discovery を利用することを宣言します。

public class Startup : FunctionsStartup
{
    public override void Configure(IFunctionsHostBuilder builder)
    {
        // App Configuration を Service Registry として利用する
        builder.Services
               .AddSimpleDiscovery()
               .AddAzureAppConfiguration(Environment.GetEnvironmentVariable("ConnectionStrings:AppConfig"));

        // Buchizo という名前のサービスを Service Registry から参照する
        builder.Services
               .AddHttpClient("Buchizo")
               .WithSimpleDiscovery();
    }
}

この設定で App Configuration から API エンドポイントを解決するようになります。

設定さえ終わってしまえば、使う側は同じです。普通に HttpClientFactory を使う時と同じですが、サービス名を指定して CreateClient を呼び出す必要があります。*2

public class Function1
{
    public Function1(IHttpClientFactory httpClientFactory)
    {
        _httpClientFactory = httpClientFactory;
    }

    private readonly IHttpClientFactory _httpClientFactory;

    [FunctionName("Function1")]
    public async Task<IActionResult> Run(
        [HttpTrigger(AuthorizationLevel.Function, "get", "post", Route = null)] HttpRequest req,
        ILogger log)
    {
        var httpClient = _httpClientFactory.CreateClient("Buchizo");

        // BaseAddress が設定済みなのでホスト名は不要
        var response = await httpClient.GetStringAsync("/");

        return new OkObjectResult(response);
    }
}

実際にこの Function を実行すると、ちゃんと App Configuration で設定した通りのエンドポイントに対してリクエストが実行されます。

良い API が思い浮かばなかったので、適当にブチザッキを叩くようにしています。

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

利用する側でサービス名を文字列で指定するのは残念ですが、Typed Client を使うと上手く解決できます。

Typed Client と組み合わせる

これも特に難しい事はないですが AddHttpClient<TClient> を使うように変更するだけです。それ以外の設定は先ほどとほぼ同じですが、サービス名の扱いだけは注意します。

public class Startup : FunctionsStartup
{
    public override void Configure(IFunctionsHostBuilder builder)
    {
        builder.Services
               .AddSimpleDiscovery()
               .AddAzureAppConfiguration(Environment.GetEnvironmentVariable("ConnectionStrings:AppConfig"));

        // Typed Client を使うとクラス名がサービス名になるのでオーバーライドする
        builder.Services
               .AddHttpClient<BuchizoService>()
               .WithSimpleDiscovery("Buchizo");
    }
}

public class BuchizoService
{
    public BuchizoService(HttpClient httpClient)
    {
        _httpClient = httpClient;
    }

    private readonly HttpClient _httpClient;

    public Task<string> GetAsync(string relativePath)
    {
        return _httpClient.GetStringAsync(relativePath);
    }
}

これでコンストラクタで Typed Client を受け取れるようになっているので、後は適当に使います。

App Configuration のオプション変更

全体的に App Configuration に乗っかっているので、オプションで LabelFilter を指定したり、変更があった場合に自動で読み込み直すことも可能です。

ちなみに AddAzureAppConfiguration で App Configuration のオプションを設定できます。

public class Startup : FunctionsStartup
{
    public override void Configure(IFunctionsHostBuilder builder)
    {
        builder.Services
               .AddSimpleDiscovery()
               .AddAzureAppConfiguration(options =>
               {
                   // ラベルが Test のものを使う
                   options.Connect(Environment.GetEnvironmentVariable("ConnectionStrings:AppConfig"))
                          .Use(KeyFilter.Any, "Test");
               });

        builder.Services
               .AddHttpClient("Buchizo")
               .WithSimpleDiscovery();
    }
}

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

上の例のように設定すると、接続先が Test というラベルが付いている方に切り替わります。

今回は接続文字列を使いましたが、App Service 上では Managed Identities を使うようにすると、個別に管理しないといけない項目がさらに減って幸せになれます。

*1:ARM Template で App Configuration に追加できれば凄く便利になりそう

*2:自動で HttpClient を登録しても良い気がしてきた