tech.guitarrapc.cóm

Technical updates

Azure DevOps の Template を利用してビルドする

Azure DevOps Pipeline で何度も同じ処理をYAMLに書いていた場合、Templateを使うとまとめられて便利になります。

実際にビルドが多く重複した定義の多いプロジェクトに適用したところ、表向き300行 (template 含めると100行) 減らせて見通しは良くなりました。

# before
$ find . -type f | xargs cat | wc -l
788

# after
$ cat *.yml | wc -l
483

$ find . -type f | xargs cat | wc -l
628

今回は、Template について書いておきます。

目次

TL;DR

Template を使うとパラメーターだけ変えてほぼ同じ条件のビルド定義を簡単に複数作成できるのでおすすめ。 単一ビルドの場合はまとめ上げる単位によってはメリットが薄くなるので、逐次書いたほうがむしろわかりやすく選択しない選択が妥当なことが多い。(なんでもやればいいってものじゃない。)

サンプルリポジトリ

GitHub にサンプル構成を用意してあります。 リポジトリの内容に沿って説明します。

github.com

Azure DevOps Pipeline Build の構造

Azure DevOps Pipeline Build は、Stages/Jobs/Steps の3つを使って構造を分割しています。 構造を知っておくとTemplateの範囲が予想できるので、先にこれを簡単に説明しますがStages と Jobs は普段使っていると出てこないことが多いと思います。

AzureDevOps のWeb UI で自動生成される azure-pipelines.yml でも省略されていることからも察してください。(つまり読み飛ばしてok)

# Starter pipeline
# Start with a minimal pipeline that you can customize to build and deploy your code.
# Add steps that build, run tests, deploy, and more:
# https://aka.ms/yaml

trigger:
- master

pool:
  vmImage: 'ubuntu-latest'

steps:
- script: echo Hello, world!
  displayName: 'Run a one-line script'

- script: |
    echo Add other tasks to build, test, and deploy your project.
    echo See https://aka.ms/yaml
  displayName: 'Run a multi-line script'

構造に興味ある人はYAML定義のリンクに詳細があります。

docs.microsoft.com

Stages

パイプラインの最も大きな分割単位で、複数のJobs を持つことができます。"build this app", "run these tests", and "deploy to pre-production" はわかりやすい分割例です(まとめてやらないだろうと思いつつ)

Stage の構造は次のとおりですが、正直 Stages はまずかかないです。

stages:
- stage: string  # name of the stage, A-Z, a-z, 0-9, and underscore
  displayName: string  # friendly name to display in the UI
  dependsOn: string | [ string ]
  condition: string
  variables: { string: string } | [ variable | variableReference ] 
  jobs: [ job | templateReference]

もしYAMLでstages: を指定しなくても、暗黙的に1つのstageが割り当てられて実行されます。(これも書かない要因です)

stages:
- stage: A
  jobs:
  - job: A1
  - job: A2

- stage: B
  jobs:
  - job: B1
  - job: B2

Stage は、ポーズしたり各種チェックやStage間の依存関係を指定することもできます。

stages:
- stage: string
  dependsOn: string
  condition: string

これを利用して fan-in/fan-out も組めます。

stages:
- stage: Test

- stage: DeployUS1
  dependsOn: Test    # this stage runs after Test

- stage: DeployUS2
  dependsOn: Test    # this stage runs in parallel with DeployUS1, after Test

- stage: DeployEurope
  dependsOn:         # this stage runs after DeployUS1 and DeployUS2
  - DeployUS1
  - DeployUS2

Stage レベルで変数を持って、Job や Step で利用することもできます。

Jobs:

Jobs は Steps(実際にやる処理) の塊で中のStep が直列で実行されます。

Jobs の構造は次のとおりです。この Jobs もまず書かないです。

jobs:
- job: string  # name of the job, A-Z, a-z, 0-9, and underscore
  displayName: string  # friendly name to display in the UI
  dependsOn: string | [ string ]
  condition: string
  strategy:
    matrix: # matrix strategy, see below
    parallel: # parallel strategy, see below
    maxParallel: number # maximum number of agents to simultaneously run copies of this job on
  continueOnError: boolean  # 'true' if future jobs should run even if this job fails; defaults to 'false'
  pool: pool # see pool schema
  workspace:
    clean: outputs | resources | all # what to clean up before the job runs
  container: containerReference # container to run this job inside
  timeoutInMinutes: number # how long to run the job before automatically cancelling
  cancelTimeoutInMinutes: number # how much time to give 'run always even if cancelled tasks' before killing them
  variables: { string: string } | [ variable | variableReference ] 
  steps: [ script | bash | pwsh | powershell | checkout | task | templateReference ]
  services: { string: string | container } # container resources to run as a service container

もしYAMLでjobs: を指定しなくても、暗黙的に1つのjobが割り当てられて実行されます。(これも書かない要因です)

YAML に直接 container と書いて、Docker Hub のイメージを使うことがあると思います。 あるいは、strategy と書いて Matrix Build していることがあると思います。 これらはJobの機能を使っているということです。

pool:
  vmImage: 'ubuntu-16.04'

strategy:
  matrix:
    ubuntu14:
      containerImage: ubuntu:14.04
    ubuntu16:
      containerImage: ubuntu:16.04
    ubuntu18:
      containerImage: ubuntu:18.04

container: $[ variables['containerImage'] ]

steps:
  - script: printenv

1つのビルドエージェントは1つのjob を実行できます。 そのため、並列にジョブを実行したい場合は、並列度の分だけエージェントが必要になります。

例えばこれは直列実行になります。

jobs:
- job: Debug
  steps:
  - script: echo hello from the Debug build
- job: Release
  dependsOn: Debug
  steps:
  - script: echo hello from the Release build

これは依存を互いに持たないので並列実行です。

jobs:
- job: Windows
  pool:
    vmImage: 'vs2017-win2016'
  steps:
  - script: echo hello from Windows
- job: macOS
  pool:
    vmImage: 'macOS-10.13'
  steps:
  - script: echo hello from macOS
- job: Linux
  pool:
    vmImage: 'ubuntu-16.04'
  steps:
  - script: echo hello from Linux

多くの場合は、matrixで自動生成された job を 最大並列度を指定したりすることが多いでしょう。

jobs:
- job: BuildPython
  strategy:
    maxParallel: 2
    matrix:
      Python35:
        PYTHON_VERSION: '3.5'
      Python36:
        PYTHON_VERSION: '3.6'
      Python37:
        PYTHON_VERSION: '3.7'

Job レベルで変数を持って、Step で利用することもできます。

Steps

steps では、一連の実際にやりたい処理をstepとして定義します。job の中に、1つのsteps があるはずです。(CircleCI や TravisCI、drone.ioなど他のCIサービスでやるときと同じですね。)

steps:
- script: echo This runs in the default shell on any machine
- bash: |
    echo This multiline script always runs in Bash.
    echo Even on Windows machines!
- task: ProvidedUsefulTask
    ParamA: "This multiline script always runs in PowerShell Core."
    ParamB: "Even on non-Windows machines!"

Azure DevOps は stepごとに一つのプロセスを起動して実行します。そのため、プロセス環境変数はstep間で共有されず、ファイルシステムやUser/Machineレベルの環境変数、Stages/Jobs/Steps ごとの変数で共有できます。

steps では、CIのために複数のステップを書くことになります。 中には1つのCIで10step 書くこともあるでしう。

steps:
- script: OpA
- script: OpB
- script: OpC
- script: OpD
- script: OpE
- script: OpF
- script: OpG
- script: OpH
- script: OpI
- script: OpJ

同じ処理を条件ごとに渡すパラメーターを変えて実行する

もし一度のトリガーで実行されたPipelineで、複数組み合わせのパラメーターで実行する場合 matrix を使うと思います。 matrixを使えば、自動的にmatrix定義した条件ごとにJobが生成され実行されるので、steps を複数書く必要がありません。

matrix:
linux:
  imageName: 'ubuntu-16.04'
mac:
  imageName: 'macos-10.13'
windows:
  imageName: 'vs2017-win2016'

pool:
  vmImage: $(imageName)

steps:
- task: NodeTool@0
  inputs:
    versionSpec: '8.x'

- script: |
    npm install
    npm test

- task: PublishTestResults@2
  inputs:
    testResultsFiles: '**/TEST-RESULTS.xml'
    testRunTitle: 'Test results for JavaScript'

- task: PublishCodeCoverageResults@1
  inputs: 
    codeCoverageTool: Cobertura
    summaryFileLocation: '$(System.DefaultWorkingDirectory)/**/*coverage.xml'
    reportDirectory: '$(System.DefaultWorkingDirectory)/**/coverage'

- task: ArchiveFiles@2
  inputs:
    rootFolderOrFile: '$(System.DefaultWorkingDirectory)'
    includeRootFolder: false

- task: PublishBuildArtifacts@1

しかし、特定のパスやブランチをフックしてビルドするときなど、matrix が使えない条件で同じ処理でパラメーターだけ違うものを書くこともあります。

// azure-pipelines-debug.yml
steps:
- script: OpA
- script: OpB -c Debug
- script: OpC -c Debug
- script: OpD

// azure-pipelines-release.yml
steps:
- script: OpA
- script: OpB -c Release
- script: OpC -c Release
- script: OpD

こういった「渡すパラメーターだけが違って処理は同じ」時に便利なのが、Templates機能 です。

Templates

Templates は単純にいうと、処理を別YAMLに定義しておいて呼び出す機能です。呼び出すときにパラメーターを渡すことができるため、Templateでパラメーターを使って処理を書くことでパラメーターごとに同じ処理を呼び出すことができます。

YAML schema/Template Reference - Azure Pipelines | Microsoft Docs

CircleCI だと、Commands を外部YAMLに定義して利用するイメージに近いでしょう。

circleci.com

Templates は Stages/Jobs/Steps あとはVariables(変数) として持つことができます。一番使われやすい「JobとStepを取りまとめた」例でTemplateを見てみます。

docs.microsoft.com

まずはわかりやすい例で、npm の install/test だけを外に出してみましょう。 template を使わないときに次のように書いているイメージです。

# File: azure-pipelines.yml

jobs:
- job: macOS
  pool:
    vmImage: 'macOS-10.13'
  steps:
  - script: npm install
  - script: npm test

- job: Linux
  pool:
    vmImage: 'ubuntu-16.04'
  steps:
  - script: npm install
  - script: npm test

- job: Windows
  pool:
    vmImage: 'vs2017-win2016'
  steps:
  - script: npm install
  - script: npm test
  - script: sign              # Extra step on Windows only

step で同じことをしているbuild/test処理を一つのYAMLに書き出して テンプレート化します。仮にsteps/build.yml に置きます。

# File: steps/build.yml

steps:
- script: npm install
- script: npm test

あとは、先程のazure-pipelines.yml で template を使ってテンプートにしたbuild.ymlファイルを呼び出すだけです。

# File: azure-pipelines.yml

jobs:
- job: 
  displayName: macOS
  pool:
    vmImage: 'macOS-10.13'
  steps:
  - template: steps/build.yml # Template reference

- job
  displayName: Linux
  pool:
    vmImage: 'ubuntu-16.04'
  steps:
  - template: steps/build.yml # Template reference

- job: 
  displayName: Windows
  pool:
    vmImage: 'vs2017-win2016'
  steps:
  - template: steps/build.yml # Template reference
  - script: sign              # Extra step on Windows only

パラメーターを利用する

先程の処理で、Job の環境もほぼ同じに見えます。 Tempalte を呼び出すときにパラメーターを渡して共通化してみましょう。

パラメーターは、parameters で定義して、${{ parameters.YOUR_PARAMETER_KEY }} でテンプレート内部で参照します。

# File: templates/npm-with-params.yml

parameters:
  name: ''  # defaults for any parameters that aren't specified
  vmImage: ''

jobs:
- job: 
  displayName: ${{ parameters.name }}
  pool: 
    vmImage: ${{ parameters.vmImage }}
  steps:
  - script: npm install
  - script: npm test

実際にTemplate の利用時にパラメーターを渡すには、template: を指定したときにparameters: で定義したパラメーターキー:渡したい値とします。

# File: azure-pipelines.yml

jobs:
- template: templates/npm-with-params.yml  # Template reference
  parameters:
    name: Linux
    vmImage: 'ubuntu-16.04'

- template: templates/npm-with-params.yml  # Template reference
  parameters:
    name: macOS
    vmImage: 'macOS-10.13'

- template: templates/npm-with-params.yml  # Template reference
  parameters:
    name: Windows
    vmImage: 'vs2017-win2016'

Steps でTemplateを利用する

先程 Job sでみてみましたが、step で利用するときも同様です。

「追加テストを一部だけでやりたい」時に、Template利用時に true/false を渡して実行するか決めてみます。 初期値だけ設定しておかないといけないことに気をつけてください。

# File: templates/steps-with-params.yml

parameters:
  runExtendedTests: 'false'  # defaults for any parameters that aren't specified

steps:
- script: npm test
- ${{ if eq(parameters.runExtendedTests, 'true') }}:
  - script: npm test --extended

Template の参照時に、true を渡したときだけ実行されます。

# File: azure-pipelines.yml

steps:
- script: npm install

- template: templates/steps-with-params.yml  # Template reference
  parameters:
    runExtendedTests: 'true'

サンプル

ASP.NET Core でビルドするときに、dotnet と docker の両方を用意してみました。

github.com

steps/dotnetcore_publish.yml にdotnet でビルドするものを用意しておきます。

parameters:
  ProjectName: ''
  ExtraBuildArguments: ''
  BuildConfiguration: 'Debug'
  DotNetCoreInstall: '2.2.100'

steps:
- task: DotNetCoreInstaller@0
  inputs:
    packageType: 'sdk'
    version: '${{ parameters.DotNetCoreInstall }}'    
- task: DotNetCoreCLI@2
  displayName: 'dotnet restore'
  inputs:
    command: restore
    projects: '${{ parameters.ProjectName }}/**/*.csproj'
- task: DotNetCoreCLI@2
  displayName: 'dotnet publish'
  inputs:
    command: publish
    publishWebProjects: false
    projects: '${{ parameters.ProjectName }}/**/*.csproj'
    arguments: '-c ${{ parameters.BuildConfiguration }} -o $(Build.ArtifactStagingDirectory)'
    zipAfterPublish: false
    modifyOutputPath: false

あとは呼び出すだけです。気にするのはプロジェクト名のみなのは、チーム内のテンプレートとしては便利です。

trigger:
  batch: true
  branches:
    include:
      - '*'
jobs:
- job: build
  displayName: dotnet core (Debug)
  pool:
    name: Hosted Ubuntu 1604
  steps:
  - checkout: self
  - template: steps/dotnetcore_publish.yml
    parameters:
      ProjectName: 'WebApplication'
      BuildConfiguration: 'Debug'
  - task: PublishBuildArtifacts@1
    displayName: 'Publish Artifact: drop'

同様に、Azure Container Regisotry に docker build > push するときもかけます。docker タスクでのビルドがめちゃめちゃ使いにくいのでちょっと楽です。

parameters:
  Subscription: ''
  SubscriptionId: ''
  Registry: ''
  DockerImageName: ''
  DockerFile: ''
  ResourceGroup: ''
  ImageName: ''

steps:
- bash: 'docker build -t ${{ parameters.Registry }}.azurecr.io/${{ parameters.DockerImageName }}:$(Build.SourceVersion)_$(Build.BuildId) -t ${{ parameters.Registry }}.azurecr.io/${{ parameters.DockerImageName }}:latest -f ${{ parameters.DockerFile }} .'
  displayName: 'docker build'

- task: Docker@0
  displayName: 'Push an image'
  inputs:
    azureSubscription: ${{ parameters.Subscription }}
    azureContainerRegistry: '{"loginServer":"${{ parameters.Registry }}.azurecr.io", "id" : "/subscriptions/${{ parameters.SubscriptionId }}/resourceGroups/${{ parameters.ResourceGroup }}/providers/Microsoft.ContainerRegistry/registries/${{ parameters.Registry }}"}'
    action: 'Push an image'
    imageName: '${{ parameters.ImageName }}'
    includeLatestTag: true

あとは呼び出すだけです。 実際のプロジェクトでは、Subscription や SubscriptionIdは埋めてしまっていいでしょう。

trigger:
  batch: true
  branches:
    include:
      - "*"
jobs:
- job: build
  displayName: web docker build (dev)
  pool:
    name: Hosted Ubuntu 1604
  steps:
  - checkout: self
  - template: steps/docker_push.yml
    parameters:
      Subscription: 'your_azure_subscription'
      SubscriptionId: '12345-67890-ABCDEF-GHIJKL-OPQRSTU'
      Registry: 'your_azure_container_registry_name'
      DockerImageName: 'webapplication'
      DockerFile: 'WebApplication/Dockerfile'
      ResourceGroup: 'your_azure_resource_group'
      ImageName: 'webapplication:$(Build.SourceVersion)_$(Build.BuildId)'

Refs

YAML schema - Azure Pipelines | Microsoft Docs

Stages in Azure Pipelines - Azure Pipelines | Microsoft Docs

Jobs in Azure Pipelines, Azure DevOps Sever, and TFS - Azure Pipelines | Microsoft Docs

Job and step templates - Azure Pipelines | Microsoft Docs