ROMANCE DAWN for the new world

Microsoft Azure を中心とした技術情報を書いています。

Azure Pipelines で Azure Web Apps for Containers のパイプラインを構築する

以前の記事で、Azure Pipelines を使って Azure Web Apps にデプロイする内容を記載しました。
gooner.hateblo.jp
今回は、Azure Web Apps for Containers 向けのパイプラインを構築します。Azure Web Apps との違いは少ないので、相違点のみを記載します。

パイプラインの全体設計

以前の記事と同様に、パイプラインは、自動デプロイされる開発環境向け(Commit Stage)と承認デプロイされる本番環境向け(Production Stage)を作ります。YAML を Build と Release を分けて、可変部をパラメータで切り替えできるテンプレートを作ることで再利用性を向上させます。

ビルド用パイプライン

ビルド用の build-pipelines.yml です。

parameters:
  imageName: ''
  dockerRegistryServiceConnection: ''
  imageRepository: ''
  dockerfilePath: ''
  tag: ''
  buildConfiguration: ''
  projects: ''
  testProjects: ''
  dotnetSdkVersion: ''

jobs:
- job: Build
  pool:
    vmImage: ${{parameters.imageName}}
  steps:
  - task: DotNetCoreCLI@2
    displayName: 'Install .NET Core SDK $(dotnetSdkVersion)'
    inputs:
      packageType: 'sdk'
      version: $(dotnetSdkVersion)
  - task: DotNetCoreCLI@2
    displayName: 'Build'
    inputs:
      command: 'build'
      projects: ${{parameters.projects}}
      arguments: '--configuration ${{parameters.buildConfiguration}}'
  - task: DotNetCoreCLI@2
    displayName: 'Test'
    inputs:
      command: 'test'
      projects: ${{parameters.testProjects}}
      arguments: '--configuration ${{parameters.buildConfiguration}}'
  - task: Docker@2
    displayName: 'Build and push an image to container registry'
    inputs:
      command: buildAndPush
      repository: ${{parameters.imageRepository}}
      dockerfile: ${{parameters.dockerfilePath}}
      containerRegistry: ${{parameters.dockerRegistryServiceConnection}}
      tags: |
        $(tag)

Azure Web Apps との違いは、Docker task の Docker@2 を使って、Docker build と Azure Container Registry への Push を行っている部分のみです。docs.microsoft.com

リリース用パイプライン

リリース用の release-pipelines.yml です。

  
parameters:
  imageName: ''
  azureSubscription: ''
  webAppsName: ''
  containerRegistry: ''
  imageRepository: ''
  tag: ''
  environment: ''
  webAppsSlotName: 'production'

jobs:
- deployment: Deploy_Azure_WebApps
  displayName: 'Release'
  pool:
    vmImage: ${{parameters.imageName}}
  environment: ${{parameters.environment}}
  strategy:
    runOnce:
      deploy:
        steps:
        - task: AzureWebAppContainer@1
          displayName: 'Deploy to Azure Web Apps for Container'
          inputs:
            azureSubscription: ${{parameters.azureSubscription}}
            appName: ${{parameters.webAppsName}}
            containers: ${{parameters.containerRegistry}}/${{parameters.imageRepository}}:${{parameters.tag}}
            slotName: ${{parameters.webAppsSlotName}}

Azure Web Apps と違って、Azure Web App for Container task の AzureWebAppContainer@1 を使っています。
docs.microsoft.com
スワップは、Azure Web Apps 向けのパイプラインをそのまま使えます。

開発環境向けパイプライン

開発環境向けの azure-pipelines.yml です。

trigger:
- main

variables:
- group: variable-group-commit
- name: imageName
  value: 'ubuntu-latest'
- name: azureSubscription
  value: 'AzureSponsorships'
- name: dockerRegistryServiceConnection
  value: 'ACR'
- name: imageRepository
  value: 'item-service'
- name: dockerfilePath
  value: '$(Build.SourcesDirectory)/item-service/Dockerfile'
- name: tag
  value: '$(Build.BuildId)'
- name: containerRegistry
  value: 'gooner.azurecr.io'
- name: environment
  value: 'Commit-Stage'
- name: buildConfiguration
  value: 'Release'
- name: projects
  value: '**/item-service.csproj'
- name: testProjects
  value: '**/item-service.Tests.csproj'
- name: dotnetSdkVersion
  value: '5.0.x'

stages:
- stage: Build
  jobs:
  - template: pipelines/build-pipelines.yml
    parameters:
      imageName: $(imageName)
      dockerRegistryServiceConnection: $(dockerRegistryServiceConnection)
      imageRepository: $(imageRepository)
      dockerfilePath: $(dockerfilePath)
      tag: $(tag)
      buildConfiguration: $(buildConfiguration)
      projects: $(projects)
      testProjects: $(testProjects)
      dotnetSdkVersion: $(dotnetSdkVersion)

- stage: Release
  dependsOn:
  - Build
  condition: and(succeeded(), eq(variables['Build.SourceBranch'], 'refs/heads/main'))
  jobs:
  - template: pipelines/release-pipelines.yml
    parameters:
      imageName: $(imageName)
      azureSubscription: $(azureSubscription)
      webAppsName: $(webAppsName)
      containerRegistry: $(containerRegistry)
      imageRepository: $(imageRepository)
      tag: $(tag)
      environment: $(environment)

Azure Web Apps との違いは、ACR のリポジトリ名や Dockerfile の場所を指定している部分です。Docker Image のタグは、パイプラインの BuildId を使っています。ACR の Service Connection は、あらかじめ作成しておきましょう。
本番環境向けパイプラインは、リリース前の承認と Deployment Slot を使ったスワップが追加されるくらいで、大きな違いはありません。

まとめ

リポジトリへのマージがトリガーとなり、ビルドとリリースのパイプラインが実行され、Azure Web Apps for Containers へのデプロイまでが自動で実行されます。

f:id:TonyTonyKun:20210228121514p:plain

こちらから、パイプライン YAML の全体を確認できます。
github.com

余談

公式ドキュメントで Azure Web Apps for Containers の CI/CD を調べると、ACR Webhook の記事が見つかります。
docs.microsoft.com
この Webhook は Docker Image のタグが固定なので、はっきり言って使えません。どうやらコンテナを再起動するだけの Webhook みたいです。
latest タグで固定した CI/CD はやりたくないので、結局 Azure Pipelines YAML を書くことになります。これは便利そうだと思って試したときの残念な感じが辛かった・・・

ASP.NET Core アプリケーションを Azure Active Directory v2.0 で認証する

Azure Active Directory(Azure AD)の v2.0 は Microsoft ID プラットフォームと呼ばれていて、OpenID Connect 準拠のエンドポイントが提供されていたり、アクセス許可を一度にまとめて確認するでのはなく、必要に応じてアクセス許可を確認(増分および動的な同意)できたりします。
ただし、Azure AD v1.0 のすべての機能が v2.0 で提供されているわけではないので、注意が必要です。
docs.microsoft.com
今回は、ASP.NET Core で作った Web アプリケーションと Web API に Azure AD v2.0 認証を組み込み、フロントエンドの Web アプリケーションからバックエンドの Web API を呼び出してみます。
基本的にはこちらのサンプル通りですが、必要最低限のコードで試してみました。
github.com

事前準備

Visual Studio 2019 のプロジェクトテンプレートを使って、ASP.NET Core 3.1 で Web アプリケーション(MVC)と Web API のプロジェクトを作成しておきます。
テンプレートで認証を設定すると、Azure AD v1.0 向けの Active Directory Authentication Library(ADAL)が使われるので、今回は指定しません。
Azure AD v2.0 向けの Microsoft Authentication Library(MSAL)を手動で組み込みます。現時点のバージョンは 0.3.1-preview となっており、プレビュー版です。
docs.microsoft.com

Azure AD にアプリケーションを登録する

Azure Portal で Azure AD のアプリケーションを2つ登録します。

  • フロントエンドの Web アプリケーション
  • バックエンドの Web API
必須項目 設定値
Redirect URIs https://localhost:xxxxx/signin-oidc(Web アプリと Web API の URL)
Implicit grant ID tokens にチェックをいれる
Client secrets 自動生成(後から確認できないのでメモしておく)

バックエンドの Web API のみ、Expose an API メニューから Application ID URIScopes を登録します。
f:id:TonyTonyKun:20200920165840p:plain

フロントエンドの Web アプリケーションを作る

Web アプリケーションのプロジェクトに対して、MSAL の NuGet パッケージをインストールします。

PM>Install-Package Microsoft.Identity.Web.UI -Version 0.3.1-preview

Azure AD のアプリケーションに登録した情報をもとに、appsettings.json に AzureAd のセクションを追加します。

{
  "AzureAd": {
    "Instance": "https://login.microsoftonline.com/",
    "Domain": "[テナントのドメインを入力します。e.g. contoso.onmicrosoft.com]",
    "TenantId": "[Directory (Tenant) ID を入力します。]",
    "ClientId": "[Application (Client) ID を入力します。]",
    "CallbackPath": "/signin-oidc",
    "ClientSecret": "[Client secrets を入力します。]"
  },
}

Startup クラスにおいて、MSAL の初期化処理を実装します。

public void ConfigureServices(IServiceCollection services)
{
    // appsettings.json の AzureAd セクションから構成を読み取り、認証サービスを構成します。
    services.AddMicrosoftIdentityWebAppAuthentication(Configuration, "AzureAd")
            .EnableTokenAcquisitionToCallDownstreamApi()
            .AddInMemoryTokenCaches();

    services.AddHttpClient();

    services.AddControllersWithViews()
                .AddMicrosoftIdentityUI();    // アカウント管理ページを追加
}
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
    // (ここまでのコードは省略)

    app.UseRouting();
    app.UseAuthentication();    // パイプラインに認証ミドルウェアを追加
    app.UseAuthorization();

    // (これ以降のコードは省略)
}

HomeController クラスには、Authorize 属性を指定します。

[Authorize]
public class HomeController : Controller
{
}

ログインしたユーザー名とサインアウトをヘッダーに追加しておきます。Views/Sharedディレクトリに _LoginPartial.cshtml を追加し、_Layout.cshtml に組み込みます。
asp-area には、MicrosoftIdentity を指定します。

@using System.Security.Principal

<ul class="navbar-nav">
@if (User.Identity.IsAuthenticated)
{
  <li class="nav-item">
      <span class="navbar-text text-dark">Hello @User.Identity.Name!</span>
  </li>
  <li class="nav-item">
      <a class="nav-link text-dark" asp-area="MicrosoftIdentity" asp-controller="Account" asp-action="SignOut">Sign out</a>
  </li>
}
else
{
  <li class="nav-item">
      <a class="nav-link text-dark" asp-area="MicrosoftIdentity" asp-controller="Account" asp-action="SignIn">Sign in</a>
  </li>
}
</ul>

以上で、フロントエンドの Web アプリケーションは実装完了です。Visual Studio からデバッグ実行して、動作確認してみます。

f:id:TonyTonyKun:20200920173849p:plain

バックエンドの Web API を作る

Web API のプロジェクトに対して、MSAL の NuGet パッケージをインストールします。

PM>Install-Package Microsoft.Identity.Web -Version 0.3.1-preview

Azure AD のアプリケーションに登録した情報をもとに、appsettings.json に AzureAd のセクションを追加します。
Web アプリケーションと同様ですが、API なので CallbackPath は必要ありません。

{
  "AzureAd": {
    "Instance": "https://login.microsoftonline.com/",
    "Domain": "[テナントのドメインを入力します。e.g. contoso.onmicrosoft.com]",
    "TenantId": "[Directory (Tenant) ID を入力します。]",
    "ClientId": "[Application (Client) ID を入力します。]",
    "ClientSecret": "[Client secrets を入力します。]"
  },
}

Startup クラスにおいて、MSAL の初期化処理を実装します。

public void ConfigureServices(IServiceCollection services)
{
    // appsettings.json の AzureAd セクションから構成を読み取り、認証サービスを構成します。
    services.AddMicrosoftIdentityWebApiAuthentication(Configuration, "AzureAd");

    services.AddControllers();
}
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
    // (ここまでのコードは省略)

    app.UseRouting();
    app.UseAuthentication();    // パイプラインに認証ミドルウェアを追加
    app.UseAuthorization();

    // (これ以降のコードは省略)
}

WeatherForecastController クラスには、Authorize 属性を指定します。

[Authorize]
[ApiController]
[Route("[controller]")]
public class WeatherForecastController : ControllerBase
{
}

以上で、バックエンドの Web API は実装完了です。Visual Studio からデバッグ実行して、Postman で動作確認してみます。

f:id:TonyTonyKun:20200920174759p:plain

Postmanを使ったアクセストークンの取得は、こちらの記事を参照してください。
gooner.hateblo.jp

フロントエンドからバックエンドの Web API を呼び出す

Azure AD のアプリケーションを変更する

フロントエンドの Web アプリケーションに対して、バックエンドの Web API を呼び出せるアクセス権を追加します。

f:id:TonyTonyKun:20200921115951p:plain

API のアクセス権は、add a permission をクリックして選択できます。忘れずに、Grant admin consent for 既定のディレクトリも設定しておきます。

フロントエンドの Web アプリケーションを変更する

Web アプリケーションのプロジェクトに対して、appsettings.json に CallApi のセクションを追加します。

{
  "CallApi": {
    "Scope": "[Web API の Scope を入力します。e.g. api://<GUID>/user_impersonation]",
    "BaseAddress": "[Web API の URL を入力します。e.g. https://localhost:xxxxx]"
  }
}

Startup クラスにおいて、MSAL の初期化処理にバックエンドの Web API の Scope を追加します。EnableTokenAcquisitionToCallDownstreamApi メソッドの引数に Scope を渡します。

public void ConfigureServices(IServiceCollection services)
{
    // バックエンドの Web API の Scope を追加
    services.AddMicrosoftIdentityWebAppAuthentication(Configuration)
            .EnableTokenAcquisitionToCallDownstreamApi(new string [] { Configuration["CallApi:Scope"], })
            .AddInMemoryTokenCaches();
}

バックエンドの Web API を呼び出すアクセストークンを取得したいので、HomeController クラスのコンストラクタで ITokenAcquisition のインスタンスを DI コンテナから受け取ります。

[Authorize]
public class HomeController : Controller
{
    private readonly ITokenAcquisition _tokenAcquisition;
    private readonly IHttpClientFactory _clientFactory;
    private readonly string _webApiScope = string.Empty;
    private readonly string _webApiBaseAddress = string.Empty;

    public HomeController(ITokenAcquisition tokenAcquisition, IConfiguration configuration, IHttpClientFactory clientFactory)
    {
        _tokenAcquisition = tokenAcquisition;
        _clientFactory = clientFactory;
        _webApiScope = configuration["CallApi:Scope"];
        _webApiBaseAddress = configuration["CallApi:BaseAddress"];
    }
}

Privacy ページがクリックされた際に、バックエンドの Web API を呼び出します。アクションメソッドには、AuthorizeForScopes 属性でバックエンドの Web API の Scope を付与します。

[AuthorizeForScopes(ScopeKeySection = "CallApi:Scope")]
public async Task<IActionResult> Privacy()
{
    // アクセストークンを取得する
    var scopes = new string[] { _webApiScope };
    var accessToken = await _tokenAcquisition.GetAccessTokenForUserAsync(scopes);

    // バックエンドの Web API を呼び出し
    var client = _clientFactory.CreateClient();
    client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
    var response = await client.GetAsync($"{_webApiBaseAddress}/weatherforecast");
    if (response.StatusCode == HttpStatusCode.OK)
    {
        ViewData["ApiResult"] = await response.Content.ReadAsStringAsync();
    }
    else
    {
        ViewData["ApiResult"] = $"Invalid status code in the HttpResponseMessage: {response.StatusCode}.";
    }
    return View();
}

実行結果

Azure AD で認証されたフロントエンドの Web アプリケーションから、バックエンドの API を呼び出せることを確認します。

f:id:TonyTonyKun:20200921123219p:plain

まとめ

ASP.NET Core で作った Web アプリケーションと Web API に Azure AD v2.0 認証を組み込み、フロントエンドの Web アプリケーションからバックエンドの Web API を呼び出してみました。
認証ライブラリの MSAL はまだプレビュー版ですし、もうすぐ .NET 5 がリリースされるタイミングなので、継続してキャッチアップしていきたいと思います。
今回のソースコードは、こちらで公開しています。
github.com

Azure App Service の Easy Auth を有効にした Web Apps から Functions にアクセスする

Azure App Service では、組み込みの認証機能が提供されています。この機能は Easy Auth と呼ばれていて、アプリケーション側に最低限のコードを実装するだけで、Azure AD などのさまざまなプロバイダーを使って保護できます。
docs.microsoft.com
今回は、Web Apps と Functions で Easy Auth を有効にして、Azure AD で認証されたフロントエンドの Web アプリケーションからバックエンドの API を呼び出します。

  • Web Apps で ClaimsPrincipal を取得する
  • Functions のアクセストークンを取得するように構成する

この2つの構成がポイントになるので、まとめておきます。

Azure App Service の Easy Auth を有効にする

Azure ポータルを使って、Web Apps と Functions の Easy Auth を有効にします。
認証プロバイダーに Azure AD を選択して設定する際に Express モードを選択すると、Azure AD のアプリケーションも同時に登録できます。

f:id:TonyTonyKun:20200912111521p:plain

バックエンドの API をデプロイする

Functions には、Visual Studio 2019 のプロジェクトテンプレートで作成した Http Trigger の Functions をデプロイします。
Azure AD 認証するので、Functions の AuthorizationLevel は Anonymous に変更しておきます。

[FunctionName("Function1")]
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;

    // ClaimsPrincipal のユーザー名を取得
    var identityName = req.HttpContext.User.Identity.Name;

    var responseMessage = string.IsNullOrEmpty(name)
        ? "This HTTP triggered function executed successfully. Pass a name in the query string or in the request body for a personalized response."
        : $"Hello, {name}. IdentityName is {identityName}.";
    return new OkObjectResult(responseMessage);
}

Functions の場合、ClaimsPrincipal を自動的に HttpContext.User に格納してくれますので、ユーザー名などを取得できます。

フロントエンドの Web アプリケーションをデプロイする

Web Apps には、Visual Studio 2019 のプロジェクトテンプレートで作成した ASP.NET Core 3.1 の MVC をデプロイします。
Easy Auth が設定してくれる HTTP ヘッダーの X-MS-TOKEN-AAD-ACCESS-TOKEN にアクセストークンが格納されています。
docs.microsoft.com
このアクセストークンを使って、バックエンドの API を呼び出すコードを実装しておきます。

public async Task<IActionResult> Privacy()
{
    var client = _clientFactory.CreateClient();
    if (HttpContext.Request.Headers.ContainsKey("X-MS-TOKEN-AAD-ACCESS-TOKEN"))
    {
        var accessToken = HttpContext.Request.Headers["X-MS-TOKEN-AAD-ACCESS-TOKEN"];
        client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
    }
    var response = await client.GetAsync($"https://gooner-api.azurewebsites.net/api/Function1?name=gooner");
    if (response.StatusCode == HttpStatusCode.OK)
    {
        ViewData["ApiResult"] = await response.Content.ReadAsStringAsync();
    }
    else
    {
        ViewData["ApiResult"] = $"Invalid status code in the HttpResponseMessage: {response.StatusCode}.";
    }
    return View();
}

ログインしたユーザー名とサインアウトをヘッダーに追加しておきます。Views/Sharedディレクトリに _LoginPartial.cshtml を追加し、_Layout.cshtml に組み込みます。
Easy Auth では、サインアウトのエンドポイントに /.auth/logout を使います。

@using System.Security.Principal

<ul class="navbar-nav">
@if (User.Identity.IsAuthenticated)
{
    <li class="nav-item">
        <span class="navbar-text text-dark">Hello @User.Identity.Name!</span>
    </li>
    <li class="nav-item">
        <a class="nav-link text-dark" href="/.auth/logout">Sign out</a>
    </li>
}
</ul>

Web Apps の場合、ClaimsPrincipal が自動的に HttpContext.User に格納されません。従来の ASP.NET の場合は、ClaimsPrincipal が自動的にバインドされていました。
ASP.NET Core の場合、アプリケーション側での対応が必要となります。

Web Apps で ClaimsPrincipal を取得する

前述の通り、ASP.NET Core では ClaimsPrincipal を取得するために、アプリケーション側での対応が必要となります。
docs.microsoft.com
HTTP ヘッダー(X-MS-CLIENT-PRINCIPAL-NAMEなど)を解析するか、/.auth/me エンドポイントから取得するか、どちらかの方法になります。
これらの機能を実装する ASP.NET Core 向けの認証ミドルウェアは公式には提供されていませんが、一時的な回避方法として MaximeRouiller.Azure.AppService.EasyAuth が提供されているので、今回はこちらを使います。
github.com

認証ミドルウェアの NuGet パッケージをインストールします。

PM>Install-Package MaximeRouiller.Azure.AppService.EasyAuth -Version 1.0.0-ci141

Startup クラスで、ミドルウェアパイプラインに Easy Auth の ClaimsPrincipal を取得する認証ミドルウェアを追加します。

public void ConfigureServices(IServiceCollection services)
{
    services.AddHttpClient();

    // Easy Auth の ClaimsPrincipal を取得する認証ミドルウェア
    services.AddAuthentication().AddEasyAuthAuthentication((configure) => {});

    services.AddControllersWithViews();
}

Functions のアクセストークンを取得するように構成する

HTTP ヘッダーの X-MS-TOKEN-AAD-ACCESS-TOKEN にアクセストークンが格納されていますが、このままでは Functions の認証が通りません。
docs.microsoft.com
フロントエンドの Web アプリケーションを認証する際に、バックエンドの API を呼び出すトークンも含めるように構成する必要があります。
Azure ポータルから構成できないので、Azure Resource Explorer を使います。
Azure Resource Explorer から Web Apps の authsettings で、additionalLoginParams を構成します。

"additionalLoginParams": [
  "response_type=code id_token",
  "resource=<Azure AD に登録された Functions の Application (client) ID>"
],

実行結果

Azure AD で認証されたフロントエンドの Web アプリケーションから、バックエンドの API を呼び出せることを確認します。

f:id:TonyTonyKun:20200920113903p:plain

Web Apps と Functions で ClaimsPrincipal を取得できました。

まとめ

Web Apps と Functions で Easy Auth を有効にして、Azure AD で認証されたフロントエンドの Web アプリケーションからバックエンドの API を呼び出しました。
アプリケーション側で認証機能を実装せずとも、要件を満たすのであれば、Easy Auth をうまく活用する選択肢もありだと思います。