しばやん雑記

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

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

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

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

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

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

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

AppVeyor を捨てた理由

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

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

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

ログが見にくい

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

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

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

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

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

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

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

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

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

AppVeyor / Azure Pipelines の比較

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

基本的な機能

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

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

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

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

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

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

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

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

苦労した点など

Service Connection の扱い

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

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

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

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

ディレクトリの違い

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

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

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

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

移行したリポジトリ例

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

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

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

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

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

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

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

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

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

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

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

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

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

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

trigger:
- master

variables:
  buildConfiguration: 'Release'

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

System.CommandLine

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

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

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

Warp

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

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

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

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

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

Azure Pipelines

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

キャッシュの効果

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

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

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

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

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

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

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