しばやん雑記

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

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 を登録しても良い気がしてきた

Azure Functions v2 の Graceful Shutdown の挙動を試した

これまで Azure Functions では再起動やデプロイなどでホストが再起動されるタイミングを正しく検知できず、Function App が処理を実行中でも強制的にシャットダウンされていましたが、最近にリリースされたバージョンでシャットダウンの検知が出来るようになっています。

自分の環境では Runtime Version 2.0.12562.0 で動作を確認出来ています。

対応されたコミットを見る限りでは Azure Functions v2 だけ対応のようなので、v1 では動作しないでしょう。アップデートもほぼ無くなってきたので、さっさと移行するのが吉です。

Graceful Shutdown への基本的な対応

ホストのシャットダウンが検知できるということは、ユーザーコード側でシャットダウンに備えた処理が行えるようになるということです。ひとまず検証のため以下のようなコードを用意しました。

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

        try
        {
            await Task.Delay(TimeSpan.FromSeconds(60), cancellationToken);
        }
        catch (OperationCanceledException)
        {
            log.LogInformation("C# HTTP trigger function canceled.");

            return new BadRequestResult();
        }

        log.LogInformation("C# HTTP trigger function end.");

        return new OkObjectResult("OK");
    }
}

コードから分かるように、これまでは CancellationToken は受け取れてもキャンセル状態にならないという致命的な問題があったのですが、それが今回直ったという話です。

このシャットダウンに使われる CancellationToken は ASP.NET Core 側の IApplicationLifetime が提供しているものなので、挙動に関しては IIS + In-Prcess でホスティングする ASP.NET Core と同じです。*1

この Function の実行中に再起動やデプロイを実行すると、ちゃんと 400 エラーが返ってきます。

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

Function は Task.Delay によって 60 秒ほど待機するようなコードになっていますが、応答時間から CancellationToken によって待機が途中でキャンセルされたことが分かります。

そしてログを見ると OperationCanceledException をハンドリングできていることが確認できます。

2019-07-05T06:04:27.494 [Information] Executing 'Function1' (Reason='This function was programmatically called via the host APIs.', Id=1d4ecf1a-243d-49fb-9339-4d4230122a04)
2019-07-05T06:04:27.510 [Information] C# HTTP trigger function start.
2019-07-05T06:04:51.449 [Information] C# HTTP trigger function canceled.
2019-07-05T06:04:51.449 [Information] Executed 'Function1' (Succeeded, Id=1d4ecf1a-243d-49fb-9339-4d4230122a04)
2019-07-05T06:05:43.382 [Information] Executing 'Function1' (Reason='This function was programmatically called via the host APIs.', Id=bbb71852-f75c-4d4b-a28a-347c7b3a2d2c)
2019-07-05T06:05:43.499 [Information] C# HTTP trigger function start.
2019-07-05T06:05:51.443 [Information] C# HTTP trigger function canceled.
2019-07-05T06:05:51.453 [Information] Executed 'Function1' (Succeeded, Id=bbb71852-f75c-4d4b-a28a-347c7b3a2d2c)

強制的なシャットダウンではアプリケーションの状態が完全に不明になってしまいますが、Graceful Shutdown への対応を行うことで状態を壊すことなく安全にアプリケーションを終了できます。

シャットダウン中の処理について

これまでは常にホストが強制的にシャットダウンされていたのが、改善されていることが確認出来ました。

そこで気になるのは CancellationToken によってキャンセルが通知されてから、シャットダウンまでどのくらいの時間的猶予があるかという点です。

公式に情報は出ていないようですが、Azure Functions v2 は ANCM v2 によって In-Process でホスティングされていることから、基本的には shutdownTimeLimit で指定された時間だけシャットダウンを待つようです。

デフォルトでは shutdownTimeLimit は 10 秒になっています。別の Function を用意して検証した感じでは、確かに CancellationToken でキャンセルが通知されてから約 10 秒後にシャットダウンされていました。

HTTP リクエスト中にホストが強制的にシャットダウンされた場合は ARR が 502 を返します。もちろんシャットダウン中は 503 を返すので、新規のリクエストは受け付けません。

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

このタイムアウト値である 10 秒はユーザー側からは変更が不可能なので、Function 側では最低限の処理だけを行ってエラーなりで終了させるのが良さそうです。

現在の Trigger や Binding がどのような実装になっているかわからないですが、QueueTrigger に関してはキャンセル後には新しく処理は行われないようでした。処理中の場合は ThrowIfCancellationRequested などを使って例外を投げてしまえば、後ほどリトライされるはずです。

Azure Functions では稼働中のアプリケーションに対するデプロイで悩む部分がありましたが、適切に CancellationToken を使っておけば安全にシャットダウン後、デプロイが行えるようになりそうです。

注意:Durable Functions での動作について

状態を持つ Function として最強なのが Durable Functions ですが、確認した限りでは ActivityTrigger で受け取れる CancellationToken はちゃんと動作していないようで、キャンセルが通知されませんでした。

Event Sourcing のおかげで、途中で強制シャットダウンされても問題なく続きから処理は再開されますが、Activity 内の処理によっては状態が壊れる可能性がありそうです。

Function Runtime 側が直ったので、上の Issue で議論を再開していく予定です。

Durable Functions は Long-running なワークフローが簡単に書けるため、特にデプロイには気を使う必要がありましたが、上手くシャットダウン時の処理を制御できれば安全にデプロイが可能になると考えています。

*1:ANCM が app_offline.htm を利用したシャットダウン検知を行っている

Azure Web Apps に VuePress / Hugo を簡単にデプロイ出来るテンプレートを作った

深夜に Twitter で駄弁ってる時に「App Service に用意された仕組みを使えば、いい感じに静的サイトジェネレータを使ったサイトのビルドが行えるのでは?」と思ったので 1 日かけて作ってみました。

Firebase Hosting や Netlify と比べると App Service は設定項目が多すぎるので複雑です。今回のテンプレートは App Service ではありがちな「何でもできるよ?スクリプトを書けばね☆」みたいな状態なのを、ある程度は改善したいという思いがあります。

基本的なサイトを App Service でビルドして、そのままデプロイという流れまで動くようになっています。

実際にデプロイして試す場合は、以下の手順を参考にしてもらえれば良いです。

Azure Storage ではなく App Service を静的サイトのホスティングに使うメリットとしては、フロントに CDN を置くことなく HTTPS の設定が出来ることや、Twitter / Facebook / Google などのアカウントを使った認証を簡単に組み込めることです。

デメリットはやはり価格になってきますが、静的サイトの場合はかなりサイトを積み込めると思うので、そこで 1 サイトあたりのコストを圧縮は出来そうです。*1

Windows と IIS だとパフォーマンスが気になるかも知れないですが、静的なファイルの場合は簡単にキャッシュが効くのと、HTTP2 にも標準で対応しているので大きな差は出ないでしょう。

静的サイトジェネレータ向けに Web App を作成

ARM Template を工夫して、Web App 作成時に利用する静的サイトジェネレータとビルドに使うコマンド、そして生成されたサイトが含まれるディレクトリパスを入力するようにしました。

この辺りは Netlify を意識した設定項目にしています。そして静的サイトジェネレータは今のところ VuePress と Hugo のみ選べますが、拡張可能な実装なので増やすことも出来ます。

VuePress 向けの場合

VuePress 向けに作成する場合は Site Generator として VuePress を選択します。

Build Command と Publish Directory は環境によって異なりますが、一般的には npm run build.vuepress/dist であることが多いでしょう。

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

入力が終わればデプロイします。これで VuePress 向けのビルドが行える Web App が完成です。

Hugo 向けの場合

Hugo の場合も VuePress と流れは同じなので、Site Generator は Hugo を選びます。

Build Command と Publish Directory は環境によって合わせる必要がありますが、こちらも一般的には hugopublic が多いでしょう。

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

これでデプロイを行えば Hugo 向けの Web App が完成します。後はリポジトリを選ぶぐらいなので、App Service の設定はこれで実質終わりです。

ちなみにここで入力した Build Command と Publish Directory は App Settings に保存されているので、変更したい場合はここから上書きすれば問題ないです。

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

出来るならこの辺りの設定画面も上手く隠したいのですが、そう頻繁に変えるものではないと思うので、とりあえずはこのままにしています。

Deployment Center からデプロイの設定

作成した Web App は既に VuePress / Hugo のビルド向けに最適化されているので、後はどのようにデプロイするかを設定するだけです。

デプロイに関する設定は Deployment Center からサクッと行えます。数多くのソースに対応していますが、基本的にはどれを選んでも問題なくビルドとデプロイは行えるはずです。

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

注意点は Build Provider として App Service build service を選ぶ必要があることぐらいです。

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

Azure Pipelines を選ぶと別にビルドパイプラインが作られるので、今回のテンプレートでは対応不可能です。

今回は VuePress を GitHub からデプロイ、Hugo をローカルの Git からデプロイというように、それぞれ別のソースで設定してみます。

GitHub からデプロイする場合

デプロイの設定はこれまでと全く変わらないので、ドキュメントがそのまま参考になります。

とは言っても Deployment Center の UI がシンプルなので、最初の画面から GitHub をクリックして、ポチポチと認証やデプロイしたいリポジトリとブランチを選んでいくぐらいで終わります。

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

設定が完了すれば、自動的に最新のコミットがデプロイされます。初回は npm install が走るので少し遅いですが、それ以降のビルドは早くなります。

新しいコミットを GitHub にプッシュすれば、Web App 側も自動でビルドが走ります。

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

Git の操作を行わなくても、Deployment Center の履歴から特定のコミットへの再デプロイも出来ます。

Local Git を使ってデプロイする場合

こちらもドキュメントがそのまま参考になるので、分からない部分は参照してください。

基本的には Local Git の設定後に表示された Git の URL をクローンして、その中でコミットを積み上げていくか、既存のリポジトリに新しく remote として追加するかのどちらかです。

ドキュメントにはユーザー名込みの以下のようなコマンドが載っていますが、Git Credential Manager が入っていればダイアログで認証情報を聞いてくれるので便利です。

git remote add azure https://<username>@<app-name>.scm.azurewebsites.net/<app-name>.git

既に Hugo の Quick Start に従ってリポジトリを用意していたので、新しく remote を追加しました。

追加して git push azure master を実行すればデプロイが行われます。ビルド中のログはクライアントに送信されるので、どのような処理が行われているか把握できます。

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

Local Git の場合も Deployment Center から履歴の確認と、特定のコミットの再デプロイが行えます。

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

Hugo に関しては使用するバージョンを App Settings に HUGO_VERSION キーを追加すると指定できます。今は現時点での最新バージョン 0.55.6 が使われるようになっています。

生成されたサイトを確認

VuePress と Hugo の両方ともデプロイが完了したので、ブラウザから確認しておきました。静的な HTML なので当然ですが、Windows の App Service でも問題なく動作しています。

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

ARM Template を静的サイト向けに調整しているので、HTTP2 もデフォルトで有効化されています。

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

404 ページはデフォルトでルートにある 404.html を設定しているので、存在しない URL にアクセスした場合でも正しく 404 ページが返って来ます。

これで一通り VuePress と Hugo を使ったサイトのデプロイまで終わりました。結局やったことは Web App のデプロイとリポジトリの設定だけで済みました。かなり簡単になったと思います。

おまけ:作成したサイトに認証を追加する

作成したサイトに認証を追加するのも簡単です。Azure Portal から App Service Authentication を有効にして、利用したい認証プロバイダを設定するだけで完了です。

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

Basic 認証は用意されていないですが、大抵は用意されているプロバイダで事足りるかと思います。裏仕様っぽいですが OIDC に対応したプロバイダならカスタム追加も出来るようです。

*1:Shared がカスタムドメインでの HTTPS に対応して GA とかになれば強い

Azure Pipelines で VuePress のビルドとデプロイを自動化する

これまで Grav を使って shibayan.jp にペライチのコンテンツを配置していたのですが、メンテナンスが面倒だったりパフォーマンスに問題があったので移行を考えていました。

そして今日もアップデートが失敗して Composer からやり直しになった結果、三宅さんに教えてもらった VuePress への移行を決意しました。

さっと公式サイトとドキュメントを眺めた感じでは、Markdown で書いてしまえばビルド時に静的な html やアセットが生成されるようでした。

それを App Service にデプロイすれば快適に動作すると確信したので、一気に CI / CD 含めて移行しました。

VuePress で簡単なサイトを作る

この辺りはドキュメントを読みながら進めましたが、CI のことを考えて VuePress はグローバルにインストールしないようにしました。

適当に package.json を用意して、簡単に開発とビルドが行えるようにします。

{
  "name": "shibayan.jp",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "dev": "vuepress dev src",
    "build": "vuepress build src"
  },
  "author": "",
  "license": "ISC",
  "dependencies": {
    "vuepress": "^1.0.2"
  }
}

Node.js 力が低いのでこれであってるのか分からないですが、問題なく動いてはいます。

最初はリポジトリのルートにそのまま Markdown ファイルを置いてましたが、分かりにくくなったので src という名前でディレクトリを切って、その中に VuePress 用のファイルを入れるようにしました。

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

VuePress の設定が出来れば、後は Grav から Markdown を殆どそのまま移行しました。ほぼペライチのサイトだったので、ページ自体は一瞬で移行出来ました。

元々のサイトでは Web.config を使って、カスタムドメインへのリダイレクト強制や HSTS などの設定を行っていたので、.vuepress/public に Web.config を置いてデプロイされるようにしました。

Azure Pipelines で VuePress をビルドする

サイト自体は一瞬で完成したので、次は Azure Pipelines を使ってビルドとデプロイのパイプラインを構築します。これまでは Classic Editor を使って書いてきましたが、今回は YAML を使ってみました。

UI に従ってビルドするリポジトリを選ぶと、ファイルをスキャンして推奨されるテンプレートを表示してくれます。今回は通常の Node.js テンプレートを使いました。

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

Node.js のテンプレートを選ぶと npm installnpm run build だけ行われる定義が生成されます。テンプレートを選ぶだけで、必要なタスクの半分は完成しています。

Azure Pipelines の YAML エディタが結構使いやすかったのと、GitHub とのインテグレーションがかなり強力だったので結構いけてました。

YAML を編集し、保存する際には同じブランチにコミットを作るか、それとも別ブランチに作るか聞いてくれます。GitHub のエディタと近い挙動です。

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

Pull Request をまだ作っていなくても、保存したコミットを Queue に入れてビルドする機能もあるので、ビルド定義を書いている時のテストが行いやすかったです。

最終的には以下のような YAML を書いて VuePress のビルドを行います。

# Node.js
# Build a general Node.js project with npm.
# Add steps that analyze code, save build artifacts, deploy, and more:
# https://docs.microsoft.com/azure/devops/pipelines/languages/javascript

trigger:
- master

pool:
  vmImage: 'ubuntu-latest'

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

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

- task: ArchiveFiles@2
  inputs:
    rootFolderOrFile: '$(Build.SourcesDirectory)/dist'
    includeRootFolder: false
    archiveType: 'zip'
    archiveFile: '$(Build.ArtifactStagingDirectory)/$(Build.BuildNumber).zip'
    replaceExistingArchive: true
  displayName: 'Archive artifact'

- task: PublishBuildArtifacts@1
  inputs:
    PathtoPublish: '$(Build.ArtifactStagingDirectory)'
    ArtifactName: 'drop'
    publishLocation: 'Container'
  displayName: 'Publish artifact'

VuePress のビルド結果を zip で圧縮して、Azure Pipelines の Artifact として保存するだけの簡単な定義です。

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

ビルドは 1 分以内で大体終わっていました。ビルド結果は保存されているので、後は Release パイプラインで App Service にデプロイするだけです。

App Service に Run From Package としてデプロイ

VuePress でのビルド結果は静的なファイルなので Run From Package を使ってデプロイします。

実行時に処理する部分がないので Azure Storage の Static website hosting でも良いですが、カスタムドメインと HTTPS のことを考えると App Service の方が楽です。

Release パイプラインは例によって Azure Web App タスクを使ってデプロイします。

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

Deployment options で Run From Package を選ぶのを忘れないように気を付けましょう。

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

後は Continuous deployment trigger の設定をすれば、ビルド完了後にデプロイが行われます。

実際にコミットすると、自動的にデプロイまで実行されることが確認できます。

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

もちろん App Service にアクセスすると、VuePress で作成したサイトが表示されます。静的なサイトかつ Run From Package で動作しているので、App Service の弱点である I/O 周りを改善できます。

まだ全然 VuePress の機能を使えていないですが、CI 周りを組んでしまえば更新が非常に楽になるので、これから時間のある時を見つけて作業していく予定です。

あともう少し Azure Pipelines の YAML 周りを深堀したいとも思ってます。

ASP.NET Core アプリケーションにも Azure Pipelines でバージョンを付ける

.NET Core から AssemblyInfo.cs に書いていたバージョンなどを、MSBuild を使ってビルド時に自動生成されるようになったので、簡単にアセンブリのバージョンを CI で埋め込めるようになりました。

NuGet 向けでは普通にビルド時にバージョンを付けてきましたが、ASP.NET Core などのアプリケーションでは付けてこなかったので、付けるメリットを考えつつ活用シナリオを探りました。

これ以降は、全体として Azure Pipelines を使って話を進めていきます。

App Service への Run From Package を使ったデプロイが簡単に書けるので、最近は App Service / Azure Functions 向けには Azure Pipelines を使うようにしています。

ビルド時にバージョンを自動でつける

基本的な考え方は NuGet パッケージ作成の時と同じですが、ASP.NET Core アプリケーションの場合はタグを付けてデプロイというより、特定のブランチにマージでデプロイというパターンが多いと思います。

なのでバージョンは Azure Pipelines が持っている情報から自動で振るようにします。

Build Number をバージョンとして使う

最初に考えられるのは Build Number をそのままバージョンとして使う方法です。非常に分かりやすいのと、設定が簡単なので大体はこれで良いと思います。

注意点としては特定のテンプレートからパイプラインを作成すると、Build Number のフォーマットが日付ベースになっている点です。これはバージョンとして使えないので、設定からシンプルな値に変更しておきます。

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

日付はそのままでは使えないですが、上手くバージョンとして扱えるようにフォーマットするのも手です。

ビルド時にバージョンを付けるには -p:Version=1.0.0 のようにパラメータを追加するだけで OK です。作成した YAML は以下の通りになります。

pool:
  name: Hosted Windows 2019 with VS2019

steps:
- task: DotNetCoreCLI@2
  displayName: Build
  inputs:
    projects: '$(Parameters.RestoreBuildProjects)'
    arguments: '--configuration $(BuildConfiguration)'

- task: DotNetCoreCLI@2
  displayName: Publish
  inputs:
    command: publish
    publishWebProjects: True
    arguments: '--configuration $(BuildConfiguration) --output $(build.artifactstagingdirectory) -p:Version=1.0.$(Build.BuildNumber)'
    zipAfterPublish: True

- task: PublishBuildArtifacts@1
  displayName: 'Publish Artifact'
  inputs:
    PathtoPublish: '$(build.artifactstagingdirectory)'
  condition: succeededOrFailed()

ASP.NET Core 向けのテンプレートから不要なタスクを削除しています。

これでビルドされた dll にバージョンが埋め込まれます。ビルド結果をダウンロードすれば確認できます。

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

色々なツールを使ったりすることなく実現出来るので、圧倒的に楽になりました。

Git のコミットハッシュも付ける

デプロイされたアプリケーションは絶対にある時点でのコミットが元になっているので、Azure Pipelines の Build Number だけではなくコミットハッシュも付けることにします。

Azure Pipelines では Git のコミットハッシュは Build.SourceVersion に入っているので、それを切り詰めて使います。10 桁まで切り詰めるために PowerShell をまた使いました。

コミットハッシュは SourceRevisionId としてビルド時に渡すことにしました。最初から Version に付けても良いのですが、この辺り色々方法があるので紹介を兼ねて使っています。

pool:
  name: Hosted Windows 2019 with VS2019

steps:
- task: DotNetCoreCLI@2
  displayName: Build
  inputs:
    projects: '$(Parameters.RestoreBuildProjects)'
    arguments: '--configuration $(BuildConfiguration)'

- powershell: 'echo "##vso[task.setvariable variable=ShortSourceVersion]$($env:Build_SourceVersion.Substring(0, 10))"'
  displayName: 'PowerShell Script'

- task: DotNetCoreCLI@2
  displayName: Publish
  inputs:
    command: publish
    publishWebProjects: True
    arguments: '--configuration $(BuildConfiguration) --output $(build.artifactstagingdirectory) -p:Version=1.0.$(Build.BuildNumber);SourceRevisionId=$(ShortSourceVersion)'
    zipAfterPublish: True

- task: PublishBuildArtifacts@1
  displayName: 'Publish Artifact'
  inputs:
    PathtoPublish: '$(build.artifactstagingdirectory)'
  condition: succeededOrFailed()

文字列操作が出来ればもっとシンプルになるのですが、何故関数が無いのか謎です。

こっちもビルドした結果を確認すると、ちゃんとコミットハッシュが付いていることが確認できます。

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

今回は ASP.NET Core アプリケーションでしたが、最近の NuGet では SemVer 2.0 に対応しているので、上のように "+" でコミットハッシュを繋げても問題なく扱えます。

付けたバージョンの利用

完全にビルドとデプロイは Azure Pipelines で自動化されるので、バージョンを機械的に付けること自体は簡単に出来ましたが、これをどのように利用していくかという話です。

パッと思いついたのは以下の 2 つでした。Application Insights の方は利用価値がありそうです。

App Settings に追加する

以前に App Service にデプロイされているバージョンが分からなくなるという話があったので、Release パイプラインで App Settings にもバージョンを設定するようにしてみます。

App Service へのデプロイタスクには App Settings の更新機能があるので簡単です。

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

これでデプロイを行うと App Settings にデプロイされているバージョンが追加されました。

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

デプロイと App Settings の更新は同じタスクで行われるので、CI と Run From Package と組み合わせている限りはバージョンがずれたりしないはずです。*1

複数リージョンで App Service を展開している場合には、Azure Portal から簡単にそれぞれにデプロイされたバージョンを確認出来るので便利かもしれません。

Application Insights から参照する

あまり知られていないっぽいですが、Application Insights はデフォルトでアプリケーションのバージョンをテレメトリと一緒に送信しているので、CI でバージョニングを行っておくとデプロイされたバージョン単位での集計が行えるようになります。

ASP.NET Core 向けのデフォルトでは AssemblyVersionAttribute の値を見ているようなので、今回のようにコミットハッシュを付けている場合は、以下のようにカスタマイズが必要です。

public void ConfigureServices(IServiceCollection services)
{
    services.Configure<ApplicationInsightsServiceOptions>(options =>
    {
        options.ApplicationVersion = Assembly.GetEntryAssembly()
                                             ?.GetCustomAttribute<AssemblyInformationalVersionAttribute>()
                                             ?.InformationalVersion;
    });

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

単純に AssemblyInformationalVersionAttribute の値をセットしているだけなので、難しくはないです。

これでデプロイすると、Application Insights にコミットハッシュ付きのバージョンが送信されます。

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

後は KQL を使ってバージョン別に集計することが出来ます。サンプルとして適当にリクエスト数と平均応答時間をバージョン別に集計してみました。

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

設定しておくことで Application Insights の Smart Detection や Insights が、特定のバージョンのみエラーレート上がっていることや、パフォーマンスが改善したといった気づきを与えてくれるでしょう。

特に Release パイプラインを使ってカナリアリリースを行っている場合に、バージョンが付いていることで問題の特定を素早く行う手助けをしてくれそうです。

結論としては、バージョンを付けるコストは非常に低い割に、Application Insights と組み合わせた時のメリットが大きかったので、設定しておいて損はないということです。

*1:当然ながら手動でデプロイしたりすると狂う