しばやん雑記

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

ARM Template を使ってリソースへのアクセス権限を付ける

Template Deployment を使って Azure リソースをデプロイする際に、同時に RBAC 周りの設定を行いたいケースがあったので、世界のぶちぞう RD に聞いたら教えてくれました。

結局は後述する制約によって上手くいかなかったのですが、折角なのでメモとして残します。

ドキュメントはイマイチな感じですが、RBAC は Microsoft.Authorization Provider で管理されているので、そこに対してロール割り当てのリソースを新しく作る形になります。

Azure Portal からはデプロイ先のリソースグループと含まれるリソースに対してのみ操作が行えます。サブスクリプションスコープで RBAC を弄る必要がある場合は Azure CLI を使ってデプロイする必要があります。

あまり意識してなかったですが、コマンドは明確に分離されています。

  • az deployment
    • サブスクリプションスコープに対してデプロイ
  • az group deployment
    • リソースグループに対してデプロイ

お分かりの通り Azure Portal の Template Deployment はリソースグループに対してのデプロイなので、リソースグループとその中のリソース以外を対象に出来ません。これが非常に都合が悪かったです。

従って ARM Template で同時にデプロイするリソースに対して割り当てる場合は上手くいきます。

ロール定義の GUID を探す

リソースを作成するためには定義ロールの GUID が必要です。ロール定義もリソースとして ARM 上に用意されているので、Azure CLI を使えば簡単に引っ張ってこれます。

必要なのは GUID と名前ぐらいなので、項目を絞り込むようにすると見やすいです。

az role definition list --query "[].[roleName,name]" --output table

実行すると定義済みのロールが全て表示されます。組み込みのロールが持つ GUID はサブスクリプションなど関係なくグローバルで共通の値です。

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

ドキュメントにもありましたが、よく使うロールの GUID は以下のような感じです。

  • Owner = 8e3af657-a8ff-443c-a75c-2fe8c4bcb635
  • Contributor = b24988ac-6180-42a0-ab88-20f7382dd24c
  • Reader = acdd72a7-3385-48ef-bd42-f606fba81ae7

ロールの GUID が分かれば、後はロール割り当てのリソースを作成するだけですが、地味に癖があります。とりあえずリソースグループと各リソースで書き方が違うので、それぞれ分けて紹介します。

リソースグループにロール割り当てを追加

サブスクリプションとリソースグループに対してロール割り当てを追加する場合は、シンプルに typeMicrosoft.Authorization/roleAssignments を指定すれば良いです。

割り当てを追加する最小限の ARM Template は以下のようになります。

{
  "$schema": "https://schema.management.azure.com/schemas/2015-01-01/deploymentTemplate.json#",
  "contentVersion": "1.0.0.0",
  "parameters": {
    "principalId": {
      "type": "string"
    },
    "roleNameGuid": {
      "type": "string",
      "defaultValue": "[newGuid()]"
    }
  },
  "resources": [
    {
      "type": "Microsoft.Authorization/roleAssignments",
      "apiVersion": "2018-09-01-preview",
      "name": "[parameters('roleNameGuid')]",
      "properties": {
        "roleDefinitionId": "[resourceId('Microsoft.Authorization/roleDefinitions', 'acdd72a7-3385-48ef-bd42-f606fba81ae7')]",
        "principalId": "[parameters('principalId')]"
      }
    }
  ]
}

この例では入力された PrincipalId に対して Reader ロールを割り当てています。実際にテンプレートでは PrincipalId は Managed Identity の System Assigned で作成したものを使うことが多いはずです。

今回は既に作成済みの PrincipalId を使ってデプロイします。謎の roleNameGuid ですが、ロール割り当てのリソース作成時に絶対必要なものです。

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

ARM Template の仕様で newGuid 関数はパラメータの defaultValue でしか使えないので、こういう不格好な形になっています。冪等性のためには仕方ない部分ですが、不格好です。

デプロイが完了するとリソースグループに対して、ロール割り当てが追加されたのが確認できます。

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

App Service の Managed Identity で作成された PrincipalId を使ったので、このような表示となります。

各リソースにロール割り当てを追加

リソースグループ内の各リソースに割り当てる場合もほぼ同じですが、typename の値が割り当てたいリソース毎に異なってくるので複雑です。

以下の例のように type にはリソースプロバイダを指定しつつ、name には対象となるリソース名を含める形する必要があります。

{
  "$schema": "https://schema.management.azure.com/schemas/2015-01-01/deploymentTemplate.json#",
  "contentVersion": "1.0.0.0",
  "parameters": {
    "principalId": {
      "type": "string"
    },
    "webAppName": {
      "type": "string"
    },
    "roleNameGuid": {
      "type": "string",
      "defaultValue": "[newGuid()]"
    }
  },
  "resources": [
    {
      "type": "Microsoft.Web/sites/providers/roleAssignments",
      "apiVersion": "2018-09-01-preview",
      "name": "[concat(parameters('webAppName'), '/Microsoft.Authorization/', parameters('roleNameGuid'))]",
      "properties": {
        "roleDefinitionId": "[resourceId('Microsoft.Authorization/roleDefinitions', 'acdd72a7-3385-48ef-bd42-f606fba81ae7')]",
        "principalId": "[parameters('principalId')]"
      }
    }
  ]
}

微妙に手間な書き方ですが、このテンプレートをデプロイすると App Service に対してロール割り当てを追加できます。実行結果はリソースグループの時と同じなので省略します。

Managed Identity (System Assigned) にアクセス権限を付ける

もはやアプリ向けに手動で Service Principal を作る時代は終わっているので、Managed Identity を空気のように利用していきます。

今回は App Service に対するテンプレートを書きます。Managed Identity を自動生成する場合は identity プロパティを追加するだけなので簡単です。

{
  "$schema": "https://schema.management.azure.com/schemas/2015-01-01/deploymentTemplate.json#",
  "contentVersion": "1.0.0.0",
  "parameters": {
    "functionAppName": {
      "type": "string"
    },
    "roleNameGuid": {
      "type": "string",
      "defaultValue": "[newGuid()]"
    }
  },
  "resources": [
    {
      "type": "Microsoft.Web/sites",
      "name": "[parameters('functionAppName')]",
      "apiVersion": "2018-11-01",
      "location": "[resourceGroup().location]",
      "kind": "functionapp",
      "identity": {
        "type": "SystemAssigned"
      },
      "properties": {
        /* 省略 */
      }
    },
    {
      "type": "Microsoft.Authorization/roleAssignments",
      "apiVersion": "2018-09-01-preview",
      "name": "[parameters('roleNameGuid')]",
      "dependsOn": [
        "[resourceId('Microsoft.Web/sites', parameters('functionAppName'))]"
      ],
      "properties": {
        "roleDefinitionId": "[resourceId('Microsoft.Authorization/roleDefinitions','acdd72a7-3385-48ef-bd42-f606fba81ae7')]",
        "principalId": "[reference(resourceId('Microsoft.Web/sites', parameters('functionAppName')), '2018-11-01', 'Full').identity.principalId]"
      }
    }
  ]
}

作成した App Service に紐づいた PrincipalIdreference 関数を使えば参照出来るので、後はその値を使ってロール割り当てを追加するだけです。

reference 関数では Full を指定しないと値が取れなかった気がします。とても簡単でした。

User Assigned Managed Identity にアクセス権限を付ける(問題あり)

つい先日 App Service 向けの User Assigned Managed Identity が GA したので、今後は利用することが増えていく気がしています。

System Assigned は 1 つの App Service に必ず 1 つ生成されますが、User Assigned Managed Identity は個別に作成して、自由に App Service へ割り当て出来るので管理しやすいです。

User Assigned Managed Identity も単なるリソースの一つなので、ARM Template を使って生成できます。

{
  "$schema": "https://schema.management.azure.com/schemas/2015-01-01/deploymentTemplate.json#",
  "contentVersion": "1.0.0.0",
  "parameters": {
    "identityName": {
      "type": "string"
    },
    "roleNameGuid": {
      "type": "string",
      "defaultValue": "[newGuid()]"
    }
  },
  "resources": [
    {
      "type": "Microsoft.ManagedIdentity/userAssignedIdentities",
      "apiVersion": "2018-11-30",
      "name": "[parameters('identityName')]",
      "location": "[resourceGroup().location]"
    },
    {
      "type": "Microsoft.Authorization/roleAssignments",
      "apiVersion": "2018-09-01-preview",
      "name": "[parameters('roleNameGuid')]",
      "dependsOn": [
        "[resourceId('Microsoft.ManagedIdentity/userAssignedIdentities', parameters('identityName'))]"
      ],
      "properties": {
        "roleDefinitionId": "[resourceId('Microsoft.Authorization/roleDefinitions', 'acdd72a7-3385-48ef-bd42-f606fba81ae7')]",
        "principalId": "[reference(parameters('identityName')).principalId]"
      }
    }
  ]
}

なので組み合わせると、ロール周りの設定も同時に出来て非常に便利!と思っていたのですが、どうも Managed Identity の作成完了が AAD への反映より早いらしくロール割り当てを追加時にエラーとなります。

数秒遅延させると上手くいくという残念な状態なので、App Service を作る前に User Assigned Managed Identity を作成しておいて、最後にロール割り当てを追加すると上手くいきます。*1

*1:直してほしいのでフィードバックする予定

ASP.NET Core と Azure Functions でのローカライズについて一通り試した

Twitter で ASP.NET Core / Azure Functions のローカライズに関する話になった時に、そういえば試してなかったなと思ったので一通り触って理解を深めておきました。

考えられる部分、全て触ってみたので非常に長くなりました。概要を先にまとめます。

  • 規約ベースで使用するリソースが解決される
    • ビューはファイルパスを、コントローラなどの型は名前空間を利用する
    • ResourcesPath で設定したパスをベースとして使う
  • リソースにはデフォルトで resx が使われる
    • 拡張可能なので JSON や DB などをソースとしても使える
  • リソースキーとしてデフォルトのカルチャ向けテキストを使うと導入が楽
    • 日本語で書くのは少し抵抗がある…
  • ReSharper を使うとリソースキーの存在チェックをしてくれるので便利

規約ベースでリソースが解決されるので、コントローラやビュー単位でリソースを用意することで保守性が上がります。共通して使うリソースはダミークラスを用意して解決します。

後から度々出てくる Culture と UICulture については、先に正しく理解しておかないとはまります。

  • Culture (例 : CultureInfo.CurrentCulture)
    • 日付、数値のフォーマットなどに使われるカルチャ
  • UICulture (例 : CultureInfo.CurrentUICulture)
    • インターフェースの表示言語に使われるカルチャ

今回は UI 部分だけの対応ですが、日付や数値などのフォーマットも考慮する必要があります。

Web の世界だとあまり見ないかも知れないですが、Azure Portal は別々に設定できます。

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

Azure Portal の設定で言語とフォーマットが別々で指定出来るように、.NET の世界でも Culture と UICulture は区別されています。

ReSharper を使っていると、日付や数値を文字列に変換する時に InvariantCulture を使えと言われるのは、この辺りが理由になっています。単純に ToString を呼ぶとカルチャによって結果が変わる可能性があります。

実際に今回は UI 周りのローカライズを試していくわけですが、その時にメインとなるのが .NET Core から用意された Microsoft.Extensions.Localization です。

ASP.NET Core に依存しないライブラリなので、DI が使えればコンソールアプリケーションでも使えます。

ASP.NET Core でのローカライズ

基本はドキュメントが充実しているので、まず読んでおくと良いです。色々と書いてありますが、文字列リソースを DI で切り替えて使う仕組みが用意されているという話です。

少し古い情報になりますが、こちらの記事が実際のローカライズ実装にとても参考になります。

ASP.NET Core のローカライズを構成する要素は以下の 4 つです。

  • 使用するカルチャを決定する RequestLocalizationMiddleware / RequestCultureProvider
  • カルチャと名前空間に従って言語リソースを読み込む IStringLocalizer<T>
  • ビューのローカライズを行う IViewLocalizer / IHtmlLocalizer<T>
  • DataAnnotations のローカライズを行う DataAnnotationsLocalizationServices

一般的にはリクエスト単位でカルチャを選択する必要があるので、Core MVC では Middleware を使って使用するカルチャを決定する仕組みが用意されています。

デフォルトの設定では以下の順番でカルチャが決定されますが、カスタマイズは可能です。

  1. Query String (culture / ui-culture)
  2. Cookie (.AspNetCore.Culturec / uic)
  3. Accept-Language

テンプレート的な使い方が出来る Startup クラスを載せておきます。対応するカルチャは en / ja です。

public class Startup
{
    public Startup(IConfiguration configuration)
    {
        Configuration = configuration;
    }

    public IConfiguration Configuration { get; }

    public void ConfigureServices(IServiceCollection services)
    {
        // AddMvc より先に追加する
        services.AddLocalization(options => options.ResourcesPath = "Resources");

        services.AddMvc()
                .AddViewLocalization()
                .AddDataAnnotationsLocalization()
                .SetCompatibilityVersion(CompatibilityVersion.Version_2_2);

        services.Configure<RequestLocalizationOptions>(options =>
        {
            var supportedCultures = new[]
            {
                "en",
                "ja"
            };

            options.DefaultRequestCulture = new RequestCulture("en");

            options.AddSupportedCultures(supportedCultures);
            options.AddSupportedUICultures(supportedCultures);
        });
    }

    public void Configure(IApplicationBuilder app, IHostingEnvironment env)
    {
        // UseMvc より先に呼び出す
        app.UseRequestLocalization();

        app.UseHttpsRedirection();
        app.UseStaticFiles();
        app.UseCookiePolicy();

        app.UseMvc(routes =>
        {
            routes.MapRoute(
                name: "default",
                template: "{controller=Home}/{action=Index}/{id?}");
        });
    }
}

対応するカルチャには ja-jpen-us を設定したくなりますが、Chrome や Edge などのブラウザは Accept-Language にニュートラル言語な ja を含めてくるので、ja-jp を設定すると動きません。

ハイフンが増える度に詳細なカルチャを表していますが、Provider はデフォルトでカルチャのフォールバックが有効となっているので、ja を設定しておけば ja-jp が来ても日本語で表示されます。

DI で各種 Localizer を取得するパターン

Core MVC では以下の 3 つのパターンを使ってローカライズが行えます。規約ベースで解決したリソースファイルから、該当するキーの文字列を引っ張ってくる機能。という感じです。

  • IStringLocalizer / IStringLocalizer<T>
    • ローカライズ結果を LocalizedString として返す
    • タグが含まれている場合は HTML エンコードされる
  • IHtmlLocalizer / IHtmlLocalizer<T>
    • ローカライズ結果を LocalizedHtmlString として返す
    • キー名は HTML エンコードせず、パラメータのみエンコードするのでタグが使える
  • IViewLocalizer
    • 基本は IHtmlLocalizer と同じで、リソース解決時の規約だけが異なる
    • コンパイル後の Razor は _Views_Home_Index のようなクラス名になるので型ベースだと詰む

ビュー以外は名前空間とクラス名ベースでリソースが解決されるので、コントローラなどの役割に依存せずにシンプルにローカライズされた文字列を引っ張ってくることが出来ます。

とりあえず Controller / Tag Helper / View Components / View それぞれで試しました。プロジェクトの構造は以下のようにしています。

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

それぞれの実装は非常にシンプルに文字列を引っ張ってくるだけです。フォーマットや HTML タグを使ってはいますが、エンコードされるかどうかの違いしかありません。

public class HomeController : Controller
{
    public HomeController(IStringLocalizer<HomeController> localizer)
    {
        _localizer = localizer;
    }

    private readonly IStringLocalizer<HomeController> _localizer;

    public IActionResult Index()
    {
        ViewBag.Message = _localizer["Hello, {0}", "かずあき"];

        return View();
    }
}
public class LocalizedTagHelper : TagHelper
{
    public LocalizedTagHelper(IHtmlLocalizer<LocalizedTagHelper> localizer)
    {
        _localizer = localizer;
    }

    private readonly IHtmlLocalizer<LocalizedTagHelper> _localizer;

    public override async Task ProcessAsync(TagHelperContext context, TagHelperOutput output)
    {
        var content = await output.GetChildContentAsync();

        output.Content.SetHtmlContent(_localizer["<b>Hello, {0}</b>", content.GetContent()]);
    }
}
public class LocalizedViewComponent : ViewComponent
{
    public LocalizedViewComponent(IStringLocalizer<LocalizedViewComponent> localizer)
    {
        _localizer = localizer;
    }

    private readonly IStringLocalizer<LocalizedViewComponent> _localizer;

    public IViewComponentResult Invoke(string name, int age)
    {
        ViewBag.Message = _localizer["{0} is {1} years old.", name, age];

        return View();
    }
}

ビューでは作成した Tag Helper や View Components を呼び出しているのでごちゃっとしています。

日付を出しているのは、カルチャが切り替わったことを分かりやすくするためです。

@{
  ViewData["Title"] = "Home Page";
}

@inject IViewLocalizer Localizer

DateTime.Now = @DateTime.Now.ToString()

<hr />

IStringLocalizer = @ViewBag.Message

<hr />

IViewLocalizer = @Localizer["<b>Mr. {0}</b>", "かずあき"]

<hr />

Tag Helper = <localized>かずあき</localized>

<hr />

View Component = @await Component.InvokeAsync("Localized", new { name = "かずあき", age = 50 })

作成したクラス、ビューに対応するリソースは以下のように準備しました。

今回はカルチャ無しのリソースは用意しませんでしたが、見つからない場合はリソースキーがそのまま使われるので、導入が楽という訳です。デフォルトは en にしているのでリソースキーは英語で書いています。

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

アプリケーションを実行して、クエリ文字列でカルチャを切り替えてアクセスすると、言語が切り替わっていることが確認できます。もちろん culture=enui-culture=ja というように別々に指定すると、日付のフォーマットと表示言語がそれぞれ別になります。

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

クエリ文字列を指定しない場合には、ブラウザの言語設定が使われるので日本語で表示されています。

リソースキーをデフォルトのカルチャとして実装するか、それともリソースファイルを用意するかは悩ましいところです。リソースを作成するのが結局のところ一番大変です。

DataAnnotations のエラーメッセージをローカライズ

ASP.NET MVC では DataAnnotations のメッセージは ErrorMessageResourceTypeErrorMessageResourceName を指定してローカライズしていましたが、Core MVC では ErrorMessage をリソースキーとして自動で扱ってくれるようになりました。

なので以下のようなクラスを書いておくと、自動でローカライズ対象になります。

public class FormRequest
{
    [Required(ErrorMessage = "The {0} field is required.")]
    [Display(Name = nameof(Name))]
    public string Name { get; set; }

    [Required(ErrorMessage = "The {0} field is required.")]
    [Display(Name = nameof(Age))]
    public int? Age { get; set; }
}

この場合も規約ベースでリソースが解決されるので、以下のような構造にしておきます。

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

適当にフォームを用意してエラーメッセージを表示されると、ちゃんとローカライズされています。

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

.NET Core では言語パックが提供されなくなったので、常に英語メッセージになっていますが、元々デフォルトのメッセージは使ったことが無かったので特に困らないかなと思っています。

ビューを個別に用意するパターン

最後に少し特別なパターンとして、ビューをカルチャ単位で分ける方法も提供されています。特に _Layout.cshtml などはリソースファイルに起こすのが大変なので、ビュー毎分けるのも一つの手です。

分ける方法としてはディレクトリとサフィックスが選べます。割と分かりやすい方法です。

public void ConfigureServices(IServiceCollection services)
{
    services.AddLocalization(options => options.ResourcesPath = "Resources");

    // デフォルトだと Suffix が使われる
    services.AddMvc()
            .AddViewLocalization(LanguageViewLocationExpanderFormat.SubFolder)
            //.AddViewLocalization(LanguageViewLocationExpanderFormat.Suffix)
            .SetCompatibilityVersion(CompatibilityVersion.Version_2_2);
}

ビューは以下のような形で置いておくと、自動でカルチャに合わせて使われます。

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

全てのカルチャに対してビューを用意するのは正直凄く面倒なので、この辺りは上手く使い分けが必要です。

ASP.NET Core に関しては大体このくらい理解しておけば、問題なくアプリケーションのローカライズが行えると思います。MVC 5 に比べるとかなり洗練されたなという感想を持ちました。

Azure Functions でのローカライズ

Azure Functions は ASP.NET Core ベースなので同じ方法が使えるはずですが、ランタイムとアプリケーションが分離されている関係上、サテライトアセンブリが読めない問題があるようです。

サテライトアセンブリを正しく読めないなら、自分で予め読み込んでおけば良いのではと思ったので試すと、すんなりと上手くいきました。

Startup を作成して、その中で指定したカルチャのサテライトアセンブリを読み込みます。

public class Startup : FunctionsStartup
{
    public override void Configure(IFunctionsHostBuilder builder)
    {
        builder.Services.AddLocalization(options => options.ResourcesPath = "Resources");

        LoadSatelliteAssembly("ja", "es");
    }

    private void LoadSatelliteAssembly(params string[] uiCultures)
    {
        foreach (var supportedCulture in uiCultures)
        {
            var assemblyDirectory = Path.Combine(Environment.CurrentDirectory, "bin", supportedCulture);

            foreach (var file in Directory.EnumerateFiles(assemblyDirectory, "*.resources.dll"))
            {
                Assembly.LoadFrom(file);
            }
        }
    }
}

後は ASP.NET Core の時と同じように DI を使って IStringLocalizer<T> を受け取って使えますが、カルチャを決定するための Middleware は組み込めないため、自力で決定する必要があります。

今回は適当にクエリ文字列から CultureInfo.CurrentUICulture を設定するようにしました。

public class Function1
{
    public Function1(IStringLocalizer<Function1> localizer)
    {
        _localizer = localizer;
    }

    private readonly IStringLocalizer<Function1> _localizer;

    [FunctionName("Function1")]
    public async Task<IActionResult> Run(
        [HttpTrigger(AuthorizationLevel.Function, "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;

        // query string からカルチャを拾って UI 向けに設定
        string culture = req.Query["culture"];

        CultureInfo.CurrentUICulture = new CultureInfo(culture ?? "en");

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

コンストラクタで IHttpContextAccessor を取ることも出来るので、良い感じに自動でカルチャを決定する仕組みは Function でも実現できるとは思います。

リソースも ASP.NET Core と同じように規約ベースで配置しておけば良いです。

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

Function を実行して、適当にカルチャをクエリ文字列で渡すとリソースが切り替わることが確認できます。

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

HttpTrigger の場合は簡単ですが、それ以外の Queue などでは別の情報を使ってカルチャを決定する必要があります。ユーザー毎に言語設定を持っておいて、それを Function からは読み取るといった形ですね。

結局のところはリソースを個別に用意するのが大変なので、割と悩ましいです。しかし便利になっています。

Task ベースの初期化が必要なクラスを .NET Core の DI で利用する

.NET Core / ASP.NET Core で追加された Dependency Injection は Task ベースの Factory は用意されておらず、ドキュメントではサービスの解決は同期的に行うよう推奨されています。

とはいえ、Task ベースでの初期化が必要な場面がちょいちょいあるので、何とかする必要があります。

async/await and Task based service resolution is not supported. C# does not support asynchronous constructors; therefore, the recommended pattern is to use asynchronous methods after synchronously resolving the service.

Dependency injection in ASP.NET Core | Microsoft Docs

例えば Managed Identity のように Access Token を取得する際にネットワークアクセスが必要な場合や、他にも SqlConnection など準備が必要なクラスを扱う場合に、同期処理にするのは避けたいです。

Azure Functions v2 で書いていたアプリを DI に移行した際に、いくつかパターンを利用しました。

今回は初期化を遅らせて、必要になるタイミング(= await が使えるメソッド)で実行する方法と、Factory を DI に追加して必要な時に Factory 経由でインスタンスを作成する方法の 2 つを使いました。

.NET Core 向けにクライアントライブラリを設計する場合に、今後は DI で使うことを考えていく必要がありそうです。IoC というより、生存期間の管理としての使い方がかなり便利です。

Managed Identity + Azure SDK (AutoRest) の例

既に何回も使っている Managed Identity と Azure SDK の組み合わせでは、SDK 側が ITokenProvider を受け取るようになっているので、そこで認証に関する処理を Task ベースで書けます。

サンプルだと Access Token を取得してから各 Management クライアントを作成するケースが多いですが、以下のように ITokenProvider を実装したクラスを用意しておくと非常に便利です。

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

    public async Task<AuthenticationHeaderValue> GetAuthenticationHeaderAsync(CancellationToken cancellationToken)
    {
        // Managed Identity を使って実行時に Access Token を取得
        var accessToken = await _tokenProvider.GetAccessTokenAsync("https://management.azure.com/", cancellationToken: cancellationToken);

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

上の例で使っている ITokenProvider は AutoRest 向けに用意されているものなので、Azure SDK 以外のシチュエーションでも同様に使えるようになっています。

実際に Azure Function の DI で使う場合には、以下のように Startup クラスで DI に追加します。

public class Startup : FunctionsStartup
{
    public override void Configure(IFunctionsHostBuilder builder)
    {
        // Key Vault 向けに用意されている Callback を指定する
        builder.Services.AddSingleton(provider => new KeyVaultClient(new KeyVaultClient.AuthenticationCallback(new AzureServiceTokenProvider().KeyVaultTokenCallback)));

        // 作成した TokenProvider を渡して初期化する
        builder.Services.AddSingleton(provider => new DnsManagementClient(new TokenCredentials(new AppAuthenticationTokenProvider()))
        {
            SubscriptionId = "<subscription id>"
        });
    }
}

Key Vault は特別扱いされているので、特にクラスを用意する必要無く使えるので便利です。

使う側はこれまで通りで、コンストラクタインジェクションでインスタンスを受け取るだけです。

public class Function1
{
    public Function1(DnsManagementClient dnsManagementClient)
    {
        _dnsManagementClient = dnsManagementClient;
    }

    private readonly DnsManagementClient _dnsManagementClient;

    [FunctionName("Function1")]
    public async Task<IActionResult> Run([HttpTrigger(AuthorizationLevel.Function, "get")] HttpRequest req)
    {
        // REST API の呼び出し直前に TokenProvider が実行される (async / await が使える)
        var zones = await _dnsManagementClient.Zones.ListAsync();

        return new OkResult();
    }
}

必要になるまで Access Token の取得は行われないのと、Access Token は AzureServiceTokenProvider が適切にキャッシュしてくれるのでシンプルに利用できます。

同じような処理を HttpClient で実現する場合は DelegatingHandler を使うと手っ取り早いです。

public class DemoHttpHandler : DelegatingHandler
{
    protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
    {
        await ProcessRequestAsync(request, cancellationToken);
        
        return await base.SendAsync(request, cancellationToken);
    }

    private readonly AzureServiceTokenProvider _tokenProvider = new AzureServiceTokenProvider();

    private async Task ProcessRequestAsync(HttpRequestMessage request, CancellationToken cancellationToken)
    {
        var accessToken = await _tokenProvider.GetAccessTokenAsync("https://management.azure.com/", cancellationToken: cancellationToken);

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

この例では Authorization ヘッダーを弄るだけですが、SendAsync では HttpRequest を自由に触れるのでリクエストのカスタマイズが簡単です。

実装した Handler を HttpClientFactory と組み合わせて使っても良いですね。

Factory を作成して DI に追加する例

Azure SDK (AutoRest) ではコンストラクタで初期化用のインターフェースが用意されていたので簡単に対応出来ましたが、用意されていない場合は Factory を作成するのが簡単です。

SqlConnection の場合は利用前に OpenAsync を実行して接続を開いておく必要がありますが、そういったケースにも Factory で対応出来ます。

public interface IAsyncDemoFactory
{
    Task<DemoClient> CreateClientAsync();
}

// 呼び出しの度に新しいインスタンスが必要な場合
public class AsyncDemoFactory : IAsyncDemoFactory
{
    public async Task<DemoClient> CreateClientAsync()
    {
        var demoClient = new DemoClient();

        // 初期化を async / await で行う必要があるクラス
        await demoClient.InitializeAsync();

        return demoClient;
    }
}

// 全体でインスタンスは 1 つだけ必要な場合
public class SingletonAsyncDemoFactory : IAsyncDemoFactory
{
    // AsyncLazy<T> (https://devblogs.microsoft.com/pfxteam/asynclazyt/)
    private readonly AsyncLazy<DemoClient> _initializer = new AsyncLazy<DemoClient>(async () =>
    {
        var demoClient = new DemoClient();

        await demoClient.InitializeAsync();

        return demoClient;
    });

    public async Task<DemoClient> CreateClientAsync()
    {
        return await _initializer;
    }
}

Factory は実際にインスタンスを作成するメソッドを提供していて、内部で初期化するメソッドを呼び出します。Factory 自体は DI で解決されるので、IOptions<T> や他のインスタンスを参照出来ます。

まだ CoreFX で提供されていないですが、ここで AsyncLazy<T> を使えば Singleton にも出来ます。

Factory は Singleton として DI に追加すれば良いので、特に難しいことはありません。

public class Startup : FunctionsStartup
{
    public override void Configure(IFunctionsHostBuilder builder)
    {
        // DI に Factory を追加
        builder.Services.AddSingleton<IAsyncDemoFactory, AsyncDemoFactory>();
    }
}

使う側ではコンストラクタで Factory を受け取り、実際に使う直前に CreateClientAsync を呼び出して新しくインスタンスを作成、もしくは既に作成されたものを取得します。

この辺りは HttpClienFactory と同じ考え方なので、既に使っている場合は理解しやすいはずです。

public class Function1
{
    public Function1(IAsyncDemoFactory asyncDemoFactory)
    {
        _asyncDemoFactory = asyncDemoFactory;
    }

    private readonly IAsyncDemoFactory _asyncDemoFactory;

    [FunctionName("Function1")]
    public async Task<IActionResult> Run([HttpTrigger(AuthorizationLevel.Function, "get")] HttpRequest req)
    {
        // await を使ってクライアントを作成する
        var demoClient = await _asyncDemoFactory.CreateClientAsync();

        var message = await demoClient.HelloAsync();

        return new OkObjectResult(message);
    }
}

インスタンスの作成を利用直前まで遅延させることで、例外が投げられた時のハンドリングが簡単になるというメリットもあります。DI に用意された Factory を使った場合は、コンストラクタへのインジェクション時に例外が投げられるので、どうしようもなくなります。

Factory を用意せずに Lazy<T> を DI に入れる方法もありますが、保守性が下がるので止めた方が良いです。

.NET Core / ASP.NET Core の DI が async / await に対応することは直近ではまず無いはずなので、クライアントの設計と Factory で対応していくことになるかと思います。

Azure Functions から送信される Application Insights テレメトリにバージョンを付ける

勝手に付いてるだろうと思っていましたが、Azure Functions の場合は Application Insights の各テレメトリにバージョンが付いていなかったので、ITelemetryInitializer を使ってカスタマイズすることにしました。

ASP.NET Core の場合はデフォルトで付いているので特に気にする必要はないです。Azure Pipelines と組み合わせて自動でバージョンを付ける場合は以下のエントリを参照してください。

バージョンをテレメトリに付けることで、自動的に Application Insights がグルーピングを行ってくれたり、Smart Detection が賢くなったりとメリットが多いです。

実際にログから Groupted results を開くと、アプリケーションのバージョンでグルーピングされています。

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

バージョン管理を行うのはモニタリング面でも非常に重要なので、ちゃんと送っておきたいところです。

ASP.NET Core では簡単に設定できましたが、Azure Functions の場合はカスタム ITelemetryInitializer を使って解決しました。

ITelemetryInitializer を実装する

Azure Functions SDK には Application Insights SDK は含まれていないので、別途インストールする必要があります。バージョンは最新を使っておけば問題なさそうです。

最新のランタイムに含まれているバージョンを調べましたが v2.10 でした。

既に Application Insights のテレメトリに用意されているプロパティへバージョンをセットするだけなので、非常に簡単なコードになりました。

public class ApplicationVersionInitializer : ITelemetryInitializer
{
    public string ApplicationVersion { get; set; }

    public void Initialize(ITelemetry telemetry)
    {
        telemetry.Context.Component.Version = ApplicationVersion;
    }
}

分かりにくいですが、アプリケーションのバージョンは Component.Version の値が使われます。

実装した Telemetry Initializer を DI に追加

Telemetry Initializer を実装すれば、後は DI に追加すれば Application Insights のパイプラインで使われます。

DI を使うためには、もはやお馴染みとなったパッケージをインストールすれば良いです。

DI には Singleton として追加します。インターフェースを指定しないと動かないので注意。

アセンブリからバージョンを取得する処理を Telemetry Initializer に入れても良かったのですが、型名からアセンブリを特定した方が分かりやすいのでプロパティで初期化するようにしました。

public class Startup : FunctionsStartup
{
    public override void Configure(IFunctionsHostBuilder builder)
    {
        builder.Services.AddSingleton<ITelemetryInitializer>(new ApplicationVersionInitializer
        {
            ApplicationVersion = typeof(Startup).Assembly
                                                .GetCustomAttribute<AssemblyInformationalVersionAttribute>()
                                                ?.InformationalVersion
        });
    }
}

特に説明することのない単純なコードでした。これでアセンブリのバージョンが Application Insights のテレメトリと一緒に送信されるようになります。

適当な Function App にデプロイして、Application Insights 側でテレメトリを確認しておきます。

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

全てのテレメトリに Application Version が確認できますね。バージョン番号自体は分かりやすいように付けたので、特に意味のない値です。本来なら CI 側でバージョンを付けます。

Kusto を使ってアプリケーションのバージョン毎での集計も簡単に行えます。

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

エラーレートやパフォーマンスに関係するメトリクスをアプリケーションのバージョン毎に集計して、グラフとして表示などすればトレンドが簡単に読み取れるので便利です。

特にパフォーマンス改善では、そのバージョンで本当に効果があったのかを検証できないと全く意味がないです。Azure Pipelines の Deployment job と組み合わせることで、デプロイされたコミットを一目で確認できるので更に便利になります。

Templates を使って Azure Pipelines から複数 App Service へのデプロイを効率化する

現実問題として 1 つのリポジトリに 1 つだけアプリケーションが存在するというケースはあまりなく、大体は複数のアプリケーションをそれぞれデプロイする必要があります。

アプリケーションが 1 つだけであっても、別のリージョンに DR としてデプロイしておく必要があるかも知れませんし、ステージング向けに別スロットにデプロイすることも多いです。

アプリケーションやデプロイ先が異なるといっても、処理自体は同じことの繰り返しなので Templates を使って共通化しておくと、大幅に分かりやすくかつメンテナンス性を保てます。

記法は難しい部分もありますが、非常に柔軟な処理を YAML で書くことが出来ます。処理の共通化が行えるのは勿論のこと、中でも each でループが書けるのはかなり便利です。上手く使えばかなり共通化出来ます。

今回テスト用に Web App と Azure Function を別々のリージョンに作成しました。

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

このリソースに対して、Azure Pipelines から一度にデプロイを実行します。

作成した YAML はベースとなる azure-pipelines.yml 以外に templates 以下にいくつか存在します。

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

デプロイするアプリケーションはそれぞれ FunctionApp1 と WebApplication1 です。単にテンプレートから作成した Azure Function と ASP.NET Core を使ったアプリケーションです。

方針としてはリソースの種類毎にデプロイ処理をテンプレート化しつつ、更にデプロイする環境毎に更にテンプレートを用意することにしました。ベースとなる azure-pipelines.yml では Deployment job で環境毎のテンプレートを参照するだけにしました。

Web App / Function 向けテンプレート

1 つのアプリケーションパッケージを複数の Web Apps / Azure Functions にデプロイする必要があるため、テンプレートのパラメータはアプリ名を配列で受け取ってループでデプロイタスクを生成しています。

ループでタスクを生成すると displayName をちゃんと指定しないと実行ログが分かりにくくなるので、パラメータを使って分かりやすい名前を表示するように設定します。

parameters:
  package: ''
  azureSubscription: ''
  appNames: []
  slotName: ''

steps:
- ${{ each appName in parameters.appNames }}:
  - task: AzureWebApp@1
    inputs:
      azureSubscription: ${{ parameters.azureSubscription }}
      appType: 'webApp'
      appName: ${{ appName }}
      slotName: ${{ parameters.slotName }}
      package: ${{ parameters.package }}
      deploymentMethod: 'runFromPackage'
    displayName: Deploy to ${{ appName }} (${{ coalesce(parameters.slotName, 'production') }})

Web Apps の場合はスロットへのデプロイも行いたかったので、パラメータで受け取るようにしました。未指定の場合は Production にデプロイされるので、必要な場合だけスロット名を指定すれば良い仕組みです。

Azure Functions もほぼ同じテンプレートでタスク名だけが異なっています。こっちはスロットを使わないので少しシンプルになっています。

parameters:
  package: ''
  azureSubscription: ''
  appNames: []

steps:
- ${{ each appName in parameters.appNames }}:
  - task: AzureFunctionApp@1
    inputs:
      azureSubscription: ${{ parameters.azureSubscription }}
      appType: 'functionApp'
      appName: ${{ appName }}
      package: ${{ parameters.package }}
      deploymentMethod: 'runFromPackage'
    displayName: Deploy to ${{ appName }}

両方のテンプレートはデプロイ先の ARM Service Connection が異なる可能性もあるので、パラメータで azureSubscription を受け取るようにしています。大体は環境毎で同じになるはずなので、上位のテンプレートで上手く解決します。

Production 向けテンプレート

各リソースへのデプロイは先ほどのテンプレートで行えるようになりましたが、デプロイというのは環境やリージョンなどで何らかの単位を持っているため、その単位ごとに実際にリソースをデプロイするテンプレートを作成しました。

例えば今回の場合は本番環境向けにデプロイを行いたいため、FunctionApp1 と WebApplication1 のそれぞれを Production 用リソースやスロットへデプロイするように YAML で定義を書きます。

今回の例では FunctionApp1 は East US と West US の両方にデプロイし、WebApplication1 は Japan West にデプロイする必要があるため、以下のような YAML 定義を用意しました。

parameters:
  azureSubscription: 'Azure Sponsorship'

steps:
- template: deploy-function.yml
  parameters:
    package: $(Pipeline.Workspace)/**/FunctionApp1.zip
    azureSubscription: ${{ parameters.azureSubscription }}
    appNames:
    - pipeline-test-westus
    - pipeline-test-eastus

- template: deploy-webapp.yml
  parameters:
    package: $(Pipeline.Workspace)/**/WebApplication1.zip
    azureSubscription: ${{ parameters.azureSubscription }}
    appNames:
    - pipeline-app-japanwest

この時 azureSubscription パラメータのデフォルト値を変数として利用することで、それぞれのタスクに渡す値を共通化しています。

今回は 1 つのテンプレートで複数のリージョンにデプロイを行っていますが、もう少し規模が大きい場合の DR や HA で使う場合には、全く同じリソースをクローンのようにデプロイしているはずなので、リソースの命名規約を利用してリージョン名をパラメータとして受け取ってしまう方法もあります。

parameters:
  azureSubscription: 'Azure Sponsorship'
  regionName: ''

steps:
- template: deploy-function.yml
  parameters:
    package: $(Pipeline.Workspace)/**/FunctionApp1.zip
    azureSubscription: ${{ parameters.azureSubscription }}
    appNames:
    - pipeline-test-${{ parameters.regionName }}

- template: deploy-webapp.yml
  parameters:
    package: $(Pipeline.Workspace)/**/WebApplication1.zip
    azureSubscription: ${{ parameters.azureSubscription }}
    appNames:
    - pipeline-app-${{ parameters.regionName }}

テンプレートを参照する時に、パラメータとして westusjapanwest といったリージョン名を渡せば、命名規約に従って特定のリージョンのリソースのみデプロイが行われます。

デプロイ単位でテンプレートを分けておくのは冗長かと感じるかも知れませんが、過剰な共通化は逆にメンテナンス性を下げるので、多少冗長でも分かりやすさ重視で分けていく方が良いです。

Deployment job からテンプレートを参照

最後にこれまで作成したテンプレートと Deployment job を組み合わせてデプロイを行います。Deployment jobs は何回も使っていますが、内容については例によって以下のエントリを参照してください。

非常に残念なことに Deployment jobs は Kubernetes にしか対応していないので、そのポテンシャルの半分も引き出すことが出来ないですが、App Service に対応しない未来はあり得ないので、使うことにします。

Deployment jobs と組み合わせる Environments はデプロイ単位を定義するための機能なので、今回のテンプレートの分け方と相性は最高です。今回は Production しか用意していないので、以下のように production だけ作成していますが、環境名 + リージョン名といった分け方も当然あり得ます。

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

要するにリソースのグルーピング機能なので、リソースグループと同じような分け方で良いと思います。

今回は Deployment job 自体をテンプレートにはせず、Steps をテンプレートにしたので、以下のような形でテンプレートを参照します。現在は runOnce だけですが、将来的には rollingblueGreen なども追加が予定されているので、かなり便利になるでしょう。

- stage: Deploy
  jobs:
  - deployment: Deploy_to_Prod
    environment: production
    strategy:
      runOnce:
        deploy:
          steps:
          - template: templates/production.yml

Environment に App Service が追加可能になれば、1 リソースへのデプロイあたり 1 つ Deployment job を書くことになるので、Job レベルでテンプレートにすることになります。

それぞれの環境へのデプロイは Stage を分けて対応する方向にもなるかと思います。

多少脱線しましたが、テンプレートは全て準備できたので Azure Pipelines で実行させます。デプロイが成功すると Environment からトラッキング可能なので便利です。

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

Job のログを確認すると、配列で指定したデプロイタスクが展開されていることが確認できます。

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

単純に定義をずらずら上から並べるだけだとあっという間にメンテナンス性が下がってしまうので、上手く規約やテンプレートを利用してメンテナンスしやすい形でパイプラインを維持するのが大切ですね。

その為にはリソースも ARM Template や Terraform を使ってコードで管理するのも重要になってきます。

Azure Pipelines の Hosted Agent を使うと C# 7.1 以降のコードがビルド出来ないのを直す

Azure Functions なプロジェクトのビルドで Azure Pipelines を使っていたところ、手元ではビルド出来ていたコードが Azure Pipelines だとビルドエラーになってしまったので、最適な解決策を調べていました。

具体的には C# 7.1 以降のコードが Azure Pipelines の Hosted Agent だとビルド出来ません。以下のように default の型推論をつかったコードを書くとエラーになります。

string test = default;

実際に Azure Pipelines でビルドさせると、この機能は C# 7.0 では使えないので 7.1 以降のバージョンを使うように、というエラーが出ます。

そしてよく見ると分かるように、MSBuild のバージョンが 15.x です。

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

原因としては Azure Pipelines の Hosted Agent に Visual Studio 2017 向けの .NET Core SDK 2.2.105 がインストールされているので、MSBuild のバージョンが古いというオチです。

ちなみに Visual Studio 2019 や Ubuntu の Hosted Agent を使っても同じ結果です。

開発環境とビルド環境で使える言語バージョンが異なっているのは避けたいので、以下の 3 つの方法のどれかを使って回避することにします。

  • csproj で LangVersion を latest に設定する
  • 最新の .NET Core SDK を手動でインストールする
  • Container jobs で最新バージョンの .NET Core SDK Docker Image を使う

設定や使い方を実際に動かした例を交えながら紹介します。割とこの問題には悩まされたので、自分用のメモを兼ねて YAML 定義も残しておきます。

LangVersion を設定

C# 7.x がデフォルトでコンパイルできるようになったのは MSBuild 16.x 系なので、Hosted Agent が使っている MSBuild 15.x 系では LangVersion で指定しない限り C# 7.0 までしか有効化されていません。

csproj で LangVersionlatest を指定すると最新のバージョンが使えます。

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

  <PropertyGroup>
    <TargetFramework>netcoreapp2.1</TargetFramework>
    <AzureFunctionsVersion>v2</AzureFunctionsVersion>
    <!-- ↓ を追加 -->
    <LangVersion>latest</LangVersion>
  </PropertyGroup>

</Project>

これで MSBuild 15.x でもC# 7.1 以降のコードがビルド出来るようになりますが、プロジェクトを弄るのは正直あまりやりたくないです。

Visual Studio 2019 では設定なしで使えるので、開発環境側に合わせておきたい気持ちが大きいです。

最新の .NET Core SDK をインストール

そもそもの問題としては、Visual Studio 2019 な Hosted Agent に 2019 向けの .NET Core SDK が入っていないことなので、明示的に最新の SDK をインストールするようにしても解決します。インストールされている .NET Core SDK が古いのは別の問題です。

ちなみに Hosted Agent の .NET Core SDK は 4 月あたりから更新されていないです。いくつも Issue が上がっていますが、正直なところ最新の SDK が Agent 更新の度に入るようになるのには時間がかかりそうです。

trigger:
- master

pool:
  vmImage: 'windows-2019'

variables:
  DOTNET_SKIP_FIRST_TIME_EXPERIENCE: true
  NUGET_XMLDOC_MODE: skip

steps:
- task: UseDotNet@2
  inputs:
    packageType: 'sdk'
    version: '2.2.x'

- task: DotNetCoreCLI@2
  inputs:
    command: 'build'
    projects: '**/*.csproj'
    arguments: '-c Release'

UseDotNet@2 を使って 2.2.x 系で最新の SDK をインストールするようにしています。

これでパイプラインを実行すると、正常にビルド出来るようになります。新しい MSBuild を使うと NuGet パッケージの復元が早くなる気がします。少なくともログは少なくなって見やすいです。

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

そのままでは .NET Core SDK インストール直後にパッケージキャッシュの展開が走ってしまって、パッケージの復元で異常に時間がかかるようになるので、DOTNET_SKIP_FIRST_TIME_EXPERIENCE を環境変数に追加して展開しないようにします。これで処理時間が多少は短縮できます。

この辺りは他の CI SaaS でも共通の方法なので、以前 CircleCI でも同じ設定を使い高速化しました。

Ubuntu のイメージには最初からスキップする設定が入っているようですが、何故か Windows のイメージには入ってませんでした。とても不具合くさいです。

Container jobs で最新の .NET Core SDK を使う

タスクを使って .NET Core SDK をインストールする以外に、公式提供されている SDK の Docker Image と Container jobs を使って、最新の SDK でビルドする方法もあります。

こっちの方がタスクはシンプルに保てますが、Docker Image のサイズと必要なプラットフォームと相談という感じです。Windows が必要な場合は時間的に厳しいです。

trigger:
- master

pool:
  vmImage: 'ubuntu-latest'

container: 'mcr.microsoft.com/dotnet/core/sdk:2.2'

steps:
- task: DotNetCoreCLI@2
  inputs:
    command: 'build'
    projects: '**/*.csproj'
    arguments: '-c Release'

Ubuntu の場合は下手に .NET Core SDK をインストールするよりも、処理時間を短縮できそうです。

最新の .NET Core SDK 2.2.x な Docker Image を使うので、もちろん正常にビルドが行えます。

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

Docker Image にはパッケージキャッシュが含まれているので、手動で .NET Core SDK をインストールするのに比べて、NuGet パッケージの復元にかかる時間が少なくて済みます。

本来なら Hosted Agent に最新の SDK がインストールされていて、そのままの状態で MSBuild 16.x 系を使ったビルドが行えるのが理想ですが、暫くは各自対策する必要がありそうです。

Azure Pipelines の Multi-stage pipelines でデプロイの承認機能を利用する

Azure Pipelines の YAML だと使えない機能だったデプロイの承認が、ちょっと前に使えるようになっていたらしいです。Pipeline Caching ばかり見てたので気が付きませんでした。

承認を使いたいケースが地味にあるので、手元のプロジェクトで試してみました。

承認は Deployment jobs と Environments が必要なので、デプロイを通常の Job で書いている場合は変更する必要があります。以前にその辺りの使い方は書いてあるので参考にしてください。

残念ながら Environments が現状はほぼ Kubernetes 専用になっていますが、App Service / Azure Functions に対応してくれると最高に使い勝手が良くなりそうなので期待しています。

Environments に承認の設定を追加

Environments を開くと右上のボタンに Checks が増えているはずなので、そこから承認の設定を行います。

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

将来的にはもう少し機能が増えそうな名前をしていますが、今は人による承認のみです。

誰がデプロイを承認出来るかを追加するだけの、非常に簡単な設定です。

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

個別のユーザーだけではなく、グループも追加出来るようになっています。こういう時に AAD と連携していると、案外便利なのかもしれません。

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

これで設定が完了なので、実際に何かをデプロイさせてみて試してみます。

デプロイを実際に承認する

承認機能を設定した状態でパイプラインを動かすと、Deployment job でレビュー待ちとなって止まります。この時に Review ボタンが表示されるので、そこから今回のデプロイを継続するか、却下するか選択します。

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

レビュー待ちのタイムアウト時間は謎ですが、極端に短いというわけではなさそうです。

Review ボタンを選ぶと、デプロイの可否やコメントを付けたりと、コントロールが可能です。

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

Approve を選ぶと、そのまま Deployment job が継続されてデプロイが行われます。

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

誰が許可したかという情報も残っているので、監査という観点でも便利そうです。

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

Environments 毎に設定が出来るので、Multi-stage pipelines でもステージング環境への自動デプロイが完了後、本番へデプロイする前に承認待ちにするという運用が簡単に組めるようになりました。

後は Environments の機能拡充が待たれますね。Kubernetes だけでは勿体ないです。

Azure Pipelines でよく使う YAML のチートシートを作った

Azure Pipelines は YAML を使ってスクリプトを書くだけではなく、予め用意されたタスクを使って面倒な処理をシンプルに書くことが出来ますが、地味にはまるポイントも多いのでよく使う定義をメモしておきます。

YAML Schema 読めば大体は理解できるはずですが、多少ドキュメントが古いタスクがあったりします。

この間、ディレクトリを指す変数はどれを使った方が良いか調べたエントリもあるので、こっちも一緒に参照して貰えるとよりスムーズにビルドパイプラインを定義できると思います。

タスクで使われるベースとなるディレクトリについても、メモがてら調べて追記しておきました。これで悩まずにパス周りの定義を書くことが出来そうです。

他にも見つけたら追記するかもしれません。とりあえず自分がよく使う範囲で書いています。

タスクで共通な部分

ファイルのパターンマッチ

内部では minimatch を使っているみたいなので、ドキュメント曰く多くのパターンが使えるはずです。

よく使われるのは *** ぐらいかと思います。! を付けると否定になります。

# 1 行で指定するパターン
searchPatternPack: '**/*.csproj;-:**/*.Tests.csproj'

# 複数行で指定するパターン
projects: |
  **/*.csproj
  !**/*.Tests.csproj

1 行で指定するパターンは内部でレガシー扱いになってるようだったので、先行きが怪しいです。

ツールバージョンの指定

一部のタスクではバージョンを柔軟に指定できます。以下のパターンを知っておけば大体困らないです。

# .NET Core の例
version: '2.x'
version: '2.1.x'
version: '3.0.100-preview7-012821'

# Node / Python / Ruby / NuGet の例
# タスクによって version と versionSpec と差があるので注意
version: '10.6.x'
versionSpec: '>= 2.4'

微妙にプロパティ名が統一されていないのが不満です。

GitHub Tag トリガーを判別

Azure Pipelines の各 Stage / Job は condition で実行条件を指定できますが、GitHub の Tag が打たれた時だけ実行という条件を組むのは少し面倒です。

最近は変数を使ってタグかどうかを判別するようにすることが多いです。

trigger:
  branches:
    include:
    - master
  tags:
    include:
    - v*

variables:
  isGitHubTag: ${{ startsWith(variables['Build.SourceBranch'], 'refs/tags/v') }}

この時 isGitHubTag は true / false となるので、後は condition で参照します。

condition: and(succeeded(), eq(variables['isGitHubTag'], 'true'))

カスタム関数が書ければもっと上手く書けるはずですが、今はこれで妥協しておきます。

checkout 系

checkout を無効化

steps:
- checkout: none

submodule を同時に checkout する

パッと見 submodule は true / false かと思いきや、recursive も受け付けるので注意。

# 直下の submodule だけ checkout する場合
steps:
- checkout: self
  submodule: true

# 再帰的に submodule を checkout する場合
steps:
- checkout: self
  submodule: recursive

checkout する履歴を減らす

履歴が大きなリポジトリをチェックアウトする際に時間がかかるのを改善出来ます。

steps:
- checkout: self
  fetchDepth: 1

ツールインストール系

.NET Core をバージョン指定でインストール

インストールするバージョンを変数にして strategy / matrix で使うことも出来ます。

steps:
- task: UseDotNet@2
  inputs:
    packageType: 'sdk'
    version: '2.2.x'
  displayName: 'Install .NET Core SDK 2.2.x'
steps:
- task: UseDotNet@2
  inputs:
    packageType: 'sdk'
    version: '3.0.100-preview7-012821'
  displayName: 'Install .NET Core SDK 3.0.100-preview7-012821'

Node.js をバージョン指定でインストール

.NET Core や Node.js に限らず、言語系は Use*** というタスクに変更されました。

steps:
- task: UseNode@1
  inputs:
    version: '10.x'
  displayName: 'Install Node.js 10.x'

NuGet CLI をバージョン指定でインストール

NuGet CLI のデフォルトは 4.1.0 と古いので、新しいバージョンを入れておいた方が良いです。

steps:
- task: NuGetToolInstaller@1
  inputs:
    versionSpec: '5.0.x'
  displayName: 'Install NuGet CLI 5.0.x'

.NET Core CLI 系

アプリケーションを発行

ビルド設定と出力先ディレクトリは arguments で指定する必要があります。

ASP.NET Core の場合は publishWebProjects を設定しなくても問題ないですが、Azure Functions の場合は false を設定する必要があります。

variables:
  BuildConfiguration: 'Release'

steps:
- task: DotNetCoreCLI@2
  inputs:
    command: 'publish'
    publishWebProjects: false
    projects: '**/*.csproj'
    arguments: '-c $(BuildConfiguration)-o $(Build.SourcesDirectory)/dist'

普通に dotnet publish で実行するのとは違って、発行するプロジェクトをワイルドカードで指定したり、発行後のディレクトリを自動で zip にしたりと便利な機能が使えます。

NuGet パッケージを作成

outputDir のベースとなるディレクトリは $(Build.SourcesDirectory) です。相対パスが楽です。

variables:
  BuildConfiguration: 'Release'

steps:
- task: DotNetCoreCLI@2
  displayName: 'dotnet pack'
  inputs:
    command: pack
    searchPatternPack: '**/*.csproj;-:**/*.Tests.csproj'
    outputDir: 'dist'

.NET Core CLI ではなく NuGet CLI を使って作成する場合でも、あまり変わらないです。以下の例では nuspec から NuGet パッケージを作成しています。

steps:
- task: NuGetCommand@2
  displayName: 'nuget pack'
  inputs:
    command: 'pack'
    packagesToPack: '.\Demo.nuspec'
    packDestination: 'dist'
    basePath: '.\build'

この時の packDestination などのベースとなるディレクトリは $(Build.SourcesDirectory) です。

NuGet パッケージの公開

.NET Core CLI と NuGet CLI の両方でパッケージの公開を行えます。機能的にも差はないです。パッケージを探すパターンのベースとなるディレクトリは $(Build.SourcesDirectory) です。

steps:
- task: DotNetCoreCLI@2
  displayName: 'dotnet nuget push'
  inputs:
    command: push
    searchPatternPush: '**/*.nupkg'
    nugetFeedType: external
    externalEndPoint: NuGet
steps:
- task: NuGetCommand@2
  displayName: 'nuget push'
  inputs:
    command: push
    packagesToPush: '**/*.nupkg'
    nuGetFeedType: external
    publishFeedCredentials: NuGet

多少 NuGet CLI の方が古い印象を受けますが、やっていることは同じなので好きな方を使えば良いです。

コンテナー系

Container jobs を使う

Windows と Ubuntu の場合は Docker Image の内部でタスクを実行出来ます。

pool:
  vmImage: 'ubuntu-latest'

container: 'mcr.microsoft.com/dotnet/core/sdk:2.2'

使える Docker Image には多少の制限があるので、何でも使えるわけではないです。

具体的には Bash と glibc ベースで、Node.js が実行可能かつ ENTRYPOINT が無いイメージじゃないとダメなようです。なので Alpine ベースだと動きませんでした。

ファイル操作系

ファイルコピー

targetFolder のベースとなるディレクトリは $(Build.SourcesDirectory) です。

steps:
- task: CopyFiles@2
  inputs:
    contents: |
      dist/**/*
      package*.json
    targetFolder: 'dist'

パターンは除外も書けるのでなかなか強力です。VM Image に依存しないコピーとして便利に使えます。

アーカイブ作成

rootFolderOrFilearchiveFile のベースとなるディレクトリは $(Build.SourcesDirectory) です。

steps:
- task: ArchiveFiles@2
  inputs:
    rootFolderOrFile: 'dist'
    includeRootFolder: false
    archiveType: 'zip'
    archiveFile: '$(Build.BuildNumber).zip'

Pipeline Artifacts のことを考えると、全体的に $(Build.SourcesDirectory) へ統一した方が楽です。

Artifacts 系

Pipeline Artifacts へプッシュ

ベースとなるディレクトリは $(Build.SourcesDirectory) です。artifact を省略した場合は自動で "Stage 名 + Job 名" という名前が付けられます。

steps:
- publish: dist
  artifact: nupkg

Pipeline Artifacts からダウンロード

ベースとなるディレクトリは $(Pipeline.Workspace) となります。artifact を省略した場合は全ての Artifacts をダウンロードします。

steps:
- download: current
  artifact: nupkg

ちなみに Deployment jobs を使うと自動で行われるので便利です。

デプロイ系

Azure Web App にデプロイ

Windows の Web App へのデプロイ時には Run From Package を使うようにした方が、デプロイが Atomic なので安定します。パッケージは $(Pipeline.Workspace) から拾ってくるケースが大半だと思います。

steps:
- task: AzureWebApp@1
  inputs:
    azureSubscription: 'AzureRMConnection'
    appType: 'webApp'
    appName: 'deploy-test'
    package: '$(Pipeline.Workspace)/**/*.zip'
    deploymentMethod: 'runFromPackage'

Deployment Slot に対してデプロイする場合は slotName でデプロイ先スロットを指定すれば良いです。

ドキュメントなどではリソースグループ名が必要っぽく書いてますが、実際には不要でした。

steps:
- task: AzureWebApp@1
  inputs:
    azureSubscription: 'AzureRMConnection'
    appType: 'webApp'
    appName: 'deploy-test'
    slotName: 'staging'
    package: '$(Pipeline.Workspace)/**/*.zip'
    deploymentMethod: 'runFromPackage'

リソースグループ名の設定は地味に面倒なので、アプリ名とスロット名だけでデプロイ出来るのは楽です。

Azure Function にデプロイ

Web App へのデプロイとほとんど同じですが、タスクと appType が異なっています。

steps:
- task: AzureFunctionApp@1
  inputs:
    azureSubscription: 'AzureRMConnection'
    appType: 'functionApp'
    appName: 'deploy-test'
    package: '$(Pipeline.Workspace)/**/*.zip'
    deploymentMethod: 'runFromPackage'

Azure Function の場合も Run From Package を使うことで、Consumption での Cold Start にかかる時間を削減できますし、デプロイ自体もやはり安定するのでもはや必須です。

Node.js アプリを Azure App Service へ最適な形でデプロイする

何となく package.json を眺めていて、これまでは devDependencies も含めた形でデプロイしていたことがあったと思ったので、最適なパッケージをビルドしてデプロイする方法を確認しました。

実際は Docker Image を Multi-stage build で作成するのと考え方は同じだと思います。以下の 2 点を理解しておけば問題なさそうです。

  • 動作に必要なものだけデプロイする
    • 開発時のみ必要なパッケージを node_modules に含めない
  • Run From Package / Docker Image を使う

Linux の App Service 向けではビルド済みの Docker Image を使っておけば間違いないです。

テスト用の Hello World アプリは流行ってそうな雰囲気があった Nest.js を使ってみました。軽くドキュメントを読んだ感じでは ASP.NET Core MVC に近い書き方な気がします。

適当に CLI をインストールして、そのままサンプルアプリを用意しました。今回は Nest.js を使っていますが、基本的には Node.js で動くアプリなら同じなはずです。

デプロイ用のパッケージ作成は、これまで通り Azure Pipelines を使いました。以下、定義となります。

アプリケーションをビルド

ビルドは深く考えずに npm cinpm run build を使ってアプリをビルドし、その結果を Pipeline Artifacts に保存しているだけの簡単な定義です。

stages:
- stage: Build
  jobs:
  - job: Build
    pool:
      vmImage: 'ubuntu-latest'
    steps:
    - task: NodeTool@0
      inputs:
        versionSpec: '10.x'
      displayName: 'Install Node.js'

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

    - publish: dist
      artifact: dist

ビルドなので devDependencies を含めたパッケージをインストールしています。

実行すると Artifacts にビルドした結果のファイルが保存されているはずです。

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

Pipeline Caching を使っても良いですが、今回の本質からずれるので省略しています。

デプロイ用にパッケージング

肝心なのがデプロイ用のパッケージを作成する処理です。Multi-stage build で言うところの最後のイメージ生成に当たる部分です。

この場合は実行に必要なパッケージだけ含めれば良いので、package*.json を別ディレクトリにコピーしてから、npm ci --production を実行するようにしています。

- stage: Packaging
  dependsOn: Build
  jobs:
  - job: Publish
    pool:
      vmImage: 'ubuntu-latest'
    steps:
    - task: DownloadPipelineArtifact@2
      inputs:
        buildType: 'current'
        targetPath: '$(Build.SourcesDirectory)'

    - task: CopyFiles@2
      inputs:
        Contents: |
          dist/**/*
          package*.json
          web.config
        TargetFolder: 'publish'

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

    - script: |
        npm ci --production
      workingDirectory: 'publish'
      displayName: 'npm ci --production'

    - task: ArchiveFiles@2
      inputs:
        rootFolderOrFile: 'publish'
        includeRootFolder: false
        archiveType: 'zip'
        archiveFile: '$(Build.BuildNumber).zip'

    - publish: $(Build.BuildNumber).zip
      artifact: packed

Run From Package でデプロイするためには zip が必要なので、このタイミングで zip にしておきました。

Artifacts を見ると、新しく zip が追加されていることが確認できるはずです。

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

実行に必要なファイルは全てこの zip に含まれているので、後は zip を Azure Web App のタスクを使ってデプロイすれば完了です。

補足 : iisnode の代わりに HttpPlatformHandler を使う

App Service (Windows) では Node.js アプリの実行には iisnode が使われるようになっていますが、一般的な 実行方法とは異なるので挙動が違ったり、設定が煩雑だったりします。

今回は HttpPlatformHandler を使って、Node.js アプリを実行するようにしました。Web.config は以下のようにシンプルですが、arguments の値はアプリ毎に変える必要があります。

<?xml version="1.0" encoding="utf-8"?>
<configuration>
  <system.webServer>
    <handlers>
      <add name="httpPlatformHandler" path="*" verb="*" modules="httpPlatformHandler" resourceType="Unspecified" />
    </handlers>
    <httpPlatform processPath="node" arguments=".\dist\main.js">
      <environmentVariables>
        <environmentVariable name="PORT" value="%HTTP_PLATFORM_PORT%" />
        <environmentVariable name="NODE_ENV" value="production" />
      </environmentVariables>
    </httpPlatform>
  </system.webServer>
</configuration>

Nest.js の場合は npm run start:prod を使うので、実際のコマンドを拾ってきて設定しています。ここでは npm は使えないようになっているので、注意したいところです。

使用するポート番号は環境変数から取る必要があるので main.ts を少しだけ弄ります。

import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  await app.listen(process.env.PORT || 3000);
}
bootstrap();

process.env.PORT は割とよく見るコードですね。これで全ての準備が整いました。

デプロイと動作確認

Azure Pipelines を使って作成しておいた App Service へデプロイを行います。

デプロイタスクは何回も書いているので省略しますが、動かすと以下のような実行履歴になります。

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

Run From Package を使っているので、デプロイが短時間で済んでいます。

これが通常の ZipDeploy の場合は node_modules の展開に時間がかかってしまいますが、Run From Package では zip のまま扱うので非常に高速です。

デプロイ後に App Service をブラウザで開いてみると、アプリが動いていることが確認できるはずです。

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

Hello World をデプロイしただけなので味気ないですが、Nest.js で作成したアプリが動作しています。

App Service のストレージが遅い点を Run From Package で回避しているので、所謂コールドスタート問題を割と改善出来るはずですが、サンプルアプリ程度では大きな差は出ませんでした。

Azure Pipelines のディレクトリを指す変数はどれを使うべきなのか調べた

Azure Pipelines を使っていて地味に悩むのがディレクトリの扱いです。一般的な CI SaaS だとソースがチェックアウトされたディレクトリを基準に、ビルドなり発行を行っていきますが、Azure Pipelines はディレクトリが複数用意されています。

Predefined variables に一通りまとまっていますが、実体は同じディレクトリを指しているのに別名で用意されている変数が多いので、タスクのリファレンスを読むだけでは混乱します。

そろそろちゃんと理解しておこうと思ったので、各 VM Image での値を確認してどの変数を使うのがベストなのか調べました。将来的にパスは変わる可能性がありますが、変数名は変わらないはずです。

重要な定義済み変数

以下の 4 つだけ覚えておけば、大体困らないでしょう。ドキュメントにあるデフォルト値は無視して、相対パスの扱いにだけ気を付ければスムーズに扱えるようになります。

  • Build.SourcesDirectory
    • ソースコードがチェックアウトされたディレクトリ
    • Pipeline Artifacts へ Publish する際の基準となるディレクトリでもある
  • Build.ArtifactStagingDirectory / Build.StagingDirectory
    • ビルド成果物を置いておくのに向いているディレクトリ
    • 向いているだけで、自動での Publish はされないので注意。使わなくても問題ない
  • Pipeline.Workspace
    • Pipeline Artifacts をダウンロードする際の基準となるディレクトリ

変数は BuildPipeline から始まるものを使うように統一すると混乱しないので良いです。

たまにドキュメントなどで見る Build.BinariesDirectory は形骸化したディレクトリという印象です。これから使う必要性は特に感じませんでした。

変数の読み替え表

Azure Pipelines のドキュメントでは System から始まる変数をよく使っていますが、タスクによってバラバラだったりするので、同じ値を指す変数に置き換えた方が良いです。

  • Agent.BuildDirectory
    • Pipeline.Workspace
  • Build.RepositoryLocalPath
  • System.DefaultWorkingDirectory
    • Build.SourcesDirectory
  • System.ArtifactsDirectory
    • Build.ArtifactStagingDirectory / Build.StagingDirectory

特に System.DefaultWorkingDirectory は至る所に出てきますが、実体はソースがチェックアウトされたディレクトリなので、分かりやすい名前の変数にしておいた方が理解が楽です。

この中だと System.ArtifactsDirectoryBuild.ArtifactStagingDirectory が同じというのが罪深いと思います。変数名からは全く別物のように取れるので最悪ですね。

使わないと思いますが Agent.RootDirectory / Agent.WorkFolder / System.WorkFolder は同じ値です。

各 VM Image での変数例

Windows (windows-2019)

AGENT_BUILDDIRECTORY           d:\a\1
AGENT_HOMEDIRECTORY            C:\agents\2.155.1
AGENT_ROOTDIRECTORY            d:\a
AGENT_TEMPDIRECTORY            d:\a\_temp
AGENT_WORKFOLDER               d:\a

BUILD_ARTIFACTSTAGINGDIRECTORY d:\a\1\a
BUILD_BINARIESDIRECTORY        d:\a\1\b
BUILD_REPOSITORY_LOCALPATH     d:\a\1\s
BUILD_SOURCESDIRECTORY         d:\a\1\s
BUILD_STAGINGDIRECTORY         d:\a\1\a

COMMON_TESTRESULTSDIRECTORY    d:\a\1\TestResults

PIPELINE_WORKSPACE             d:\a\1

SYSTEM_ARTIFACTSDIRECTORY      d:\a\1\a
SYSTEM_DEFAULTWORKINGDIRECTORY d:\a\1\s
SYSTEM_WORKFOLDER              d:\a

Linux (ubuntu-16.04)

AGENT_BUILDDIRECTORY           /home/vsts/work/1
AGENT_HOMEDIRECTORY            /home/vsts/agents/2.155.1
AGENT_ROOTDIRECTORY            /home/vsts/work
AGENT_TEMPDIRECTORY            /home/vsts/work/_temp
AGENT_WORKFOLDER               /home/vsts/work

BUILD_ARTIFACTSTAGINGDIRECTORY /home/vsts/work/1/a
BUILD_BINARIESDIRECTORY        /home/vsts/work/1/b
BUILD_REPOSITORY_LOCALPATH     /home/vsts/work/1/s
BUILD_SOURCESDIRECTORY         /home/vsts/work/1/s
BUILD_STAGINGDIRECTORY         /home/vsts/work/1/a

COMMON_TESTRESULTSDIRECTORY    /home/vsts/work/1/TestResults

PIPELINE_WORKSPACE             /home/vsts/work/1

SYSTEM_ARTIFACTSDIRECTORY      /home/vsts/work/1/a
SYSTEM_DEFAULTWORKINGDIRECTORY /home/vsts/work/1/s
SYSTEM_WORKFOLDER              /home/vsts/work

macOS (macos-10.14)

AGENT_BUILDDIRECTORY           /Users/vsts/agent/2.155.1/work/1
AGENT_HOMEDIRECTORY            /Users/vsts/agent/2.155.1
AGENT_ROOTDIRECTORY            /Users/vsts/agent/2.155.1/work
AGENT_TEMPDIRECTORY            /Users/vsts/agent/2.155.1/work/_temp
AGENT_WORKFOLDER               /Users/vsts/agent/2.155.1/work

BUILD_ARTIFACTSTAGINGDIRECTORY /Users/vsts/agent/2.155.1/work/1/a
BUILD_BINARIESDIRECTORY        /Users/vsts/agent/2.155.1/work/1/b
BUILD_REPOSITORY_LOCALPATH     /Users/vsts/agent/2.155.1/work/1/s
BUILD_SOURCESDIRECTORY         /Users/vsts/agent/2.155.1/work/1/s
BUILD_STAGINGDIRECTORY         /Users/vsts/agent/2.155.1/work/1/a

COMMON_TESTRESULTSDIRECTORY    /Users/vsts/agent/2.155.1/work/1/TestResults

PIPELINE_WORKSPACE             /Users/vsts/agent/2.155.1/work/1

SYSTEM_ARTIFACTSDIRECTORY      /Users/vsts/agent/2.155.1/work/1/a
SYSTEM_DEFAULTWORKINGDIRECTORY /Users/vsts/agent/2.155.1/work/1/s
SYSTEM_WORKFOLDER              /Users/vsts/agent/2.155.1/work

環境によって、上の値は変わる可能性があります。今後のアップデートで変わることもあります。