はじめに

Azure DevOpsは開発から運用までサポートするツールをひとまとめにしたサービスです。
今回はその中のAzure Pipelinesというツールの使い方を調べてみました。

公式ページによると、Azure Pipelinesの概要は次のとおりです。

あらゆる言語、プラットフォーム、クラウドに対応した CI/CD を使用して、ビルド、テスト、デプロイできます。GitHub や他の Git プロバイダーに接続し、継続的にデプロイすることができます。

Azure PipelinesはCI/CDをサポートしてくれるようです。


HoloLens2のビルド

今回、Azure Pipelines を調べようと思ったのは、
HoloLens2向けアプリをビルドする時の2つの問題を解決したかったためです。

まず、並行して他の作業ができないほど、ビルド中のマシンのパフォーマンスが低下します。
マシン性能や常住ツールにもよるでしょうが、経験された方は多いかと思います。

加えて、ビルドに要する時間が長いです。こちらも経験された方には共感していただけるかと思います。

解決策としては2つの案があります。
ビルドマシンを用意して遠隔操作するか、クラウド上でビルド環境を構築するかです。

Azure Pipelinesはどちらもフォローしています。

”Self-hosted Agent” : 前者をサポート。ビルドマシンに”Agent”をインストールすることでAzureサービス配下で遠隔操作ができる。
”Microsoft-hosted Agent” :
後者をサポート。Microsoftが予め用意したVMをビルド環境として利用する。

今回は手軽に試せる、Microsoft-hosted Agentを検証していきます。

公開プロジェクトなら10プロジェクト分、
非公開プロジェクトなら1800分/月分のビルドジョブを無料で実行可能なので、
まずは使い勝手を検証してみるのもいいでしょう。


設定手順

まずはMicrosoftのアカウントにログインして、Azure DevOpsの利用を開始します。


上記画像の赤枠を選択後、アカウントの認証処理を進めていきます。

次にプロジェクトを作成します。
プロジェクトの配下にPipeline等のサービスが配置されるイメージです。

プロジェクトが作成できたらPipelineを作成します。
下記画像の赤枠の箇所を選択すれば作成可能です。


Pipelineを作成できたら連携するバージョン管理ツールを選択します。
今回はGitHubで連携を進めていきます。

認証後にリポジトリを選択したらYAMLファイルの編集画面に遷移します。
MicrosoftがHoloLens2用のYAMLファイルのテンプレートを用意してくれています。

YAMLファイルを編集する前にUnity Tools for Azure DevOpsをプロジェクトにインストールします。
Unityに関するタスクが簡単に利用可能となるAzure DevOps向けに用意されたプラグインです。
右上の紙袋マークを選択し、遷移先のページで”Unity”と検索をかけます。

Get it freeを選択して次に進みます。

どのプロジェクトにプラグインをインストールするか選択し、Installを押します。

これでプロジェクトにUnityに関するタスクをYAMLファイルで定義して利用できるようになりました。

それでは、早速YAMLファイルを編集していきます。
下記がテンプレートを元に一部編集したYAMLファイルです。

# Variables predefined for this build process:
variables:

  # If true, will run tests, otherwise skip them. If you do not have tests, set to false to increase build speed.
  runTests: false

  # The path to the folder which contains the Assets folder of the project.
  # If your Unity project is located in a subfolder of your repo, make sure it is reflected in this.
  unity.projectPath:        '$(System.DefaultWorkingDirectory)/'

  # If you are using Unity 2019 or later, leave this alone!
  unity.installComponents:  'Windows, UWP'

  # The build method of the Unity project. This assumes you have MRTK in your project, and uses its build script.
  # If you want to customize your build script, change the method name here:
  unity.executeMethod:      'Microsoft.MixedReality.Toolkit.Build.Editor.UnityPlayerBuildTools.StartCommandLineBuild'

  # Are we buolding an .appx for x86 or ARM?
  vs.appxPlatforms:         'ARM'

  # I would not expect you to have to change the rest of these unless you had a special reason:
  unity.targetBuild:        'WindowsStoreApps'
  unity.outputPath:         '/Builds/WSAPlayer'
  unity.editorPath:         '/Editor/Unity.exe'
  vs.packagePath:           '/AppPackages'

  # This also needs to be passed to the install template, along with unity.projectPath
  unity.installFolder:      'C:/Program Files/Unity/Hub/Editor/'
  
# What causes us to build? A push to master or a feature branch causes us to build...
trigger:
  batch: true
  branches:
    include:
    - master
    - feature/*

# Windows machine with Visual Studio 2019:
pool:
  vmImage: 'windows-latest'


# Two jobs in this pipeline:
# - Build the Unity
# - Run Unity tests
# Note: The build job can be uncommented and broken up into two jobs, for when that makes sense.
jobs:

# Install Unity (from cache or download) then create Visual Studio project from Unity
- job: unity
  displayName: Unity Build
  variables:
    installCached: true
  # Try to ensure that we have the right secrets set up to continue, otherwise fail the job:
  condition: or( not(variables['unity.username']), not(variables['unity.password']), not(variables['unity.serial']) )
  steps:
  # What version of Unity does the project say that it wants?:
  - task: UnityGetProjectVersionTask@1
    name: unitygetprojectversion
    displayName: Calling UnityGetProjectVersionV1 from unity-azure-pipelines-tasks extension
    inputs:
      unityProjectPath: '$(unity.projectPath)'

# TODO: This is the start of code that is repeated in other jobs, and ought to be done via a seperate file template.
  # Do we have that Unity installation cached? If so, install from cache:
  # (Note: The key is the hashed contents of the ProjectVersion.txt file)
  # What is this? See https://docs.microsoft.com/en-us/azure/devops/pipelines/caching/index?view=azure-devops
  - task: CacheBeta@0
    displayName: Check if Unity installation is cached
    inputs:
      key: $(Agent.OS) | "$(unitygetprojectversion.projectVersion)" | "$(unity.installComponents)"
      path: "$(unity.installFolder)$(unitygetprojectversion.projectVersion)"
      cacheHitVar: installCached

  # Install the Unity setup module (if we aren't cached):
  - task: PowerShell@2  
    displayName: Install Unity
    condition: and(succeeded(), ne(variables['installCached'], true))
    inputs:
      targetType: 'inline'
      script: |
        Install-Module -Name UnitySetup -AllowPrerelease -Force -AcceptLicense

  # Download and run the installer for Unity Components defined in unity.installComponents:
  - task: PowerShell@2
    displayName: Installing Unity Components '$(unity.installComponents)'
    condition: and(succeeded(), ne(variables['installCached'], true))
    inputs:
      targetType: 'inline'
      script: |   
        Install-UnitySetupInstance -Installers (Find-UnitySetupInstaller -Version '$(unitygetprojectversion.projectVersion)' -Components $(unity.installComponents)) -Verbose

  # Activate the Unity license (In theory, should deactivate the licence after use!):
  - task: UnityActivateLicenseTask@1
    displayName: Calling UnityActivateLicenseTask@1 from unity-azure-pipelines-tasks extension
    inputs:
      username: '$(unity.username)'
      password: '$(unity.password)'
      serial: '$(unity.serialkey)'
      unityEditorsPathMode: 'unityHub'
      unityProjectPath: '$(unity.projectPath)'

  # Build the project with Unity using the script defined in unity.executeMethod:
  - task: UnityBuildTask@3
    displayName: Calling UnityBuildTask@3 from unity-azure-pipelines-tasks extension
    name: runbuild
    inputs:
      buildScriptType: existing
      scriptExecuteMethod: '$(unity.executeMethod)'
      buildTarget: '$(unity.targetBuild)'
      unityProjectPath: '$(unity.projectPath)'
      outputPath: '$(Build.BinariesDirectory)'

  # Publish the Solution folder:
  - task: PublishPipelineArtifact@0
    displayName: 'Publish Pipeline Artifact'
    inputs:
      artifactName: 'sln'
      targetPath: '$(unity.projectPath)$(unity.outputPath)'

  # Find, download, and cache NuGet:
  - task: NuGetToolInstaller@1
    displayName: 'Install NuGet'

  # Restore the NuGet packages for the solution:
  - task: NuGetCommand@2
    displayName: 'NuGet restore'
    inputs:
      restoreSolution: '$(unity.projectPath)$(unity.outputPath)/*.sln' # Change to '$(unity.projectPath)$(unity.outputPath)/*.sln' to resume two jobs

  # Build the solution with Visual Studio to make an .appx:
  - task: MSBuild@1
    displayName: 'Build solution'
    inputs:
      solution: '$(unity.projectPath)$(unity.outputPath)' # Change to '$(unity.projectPath)$(unity.outputPath)' to resume two jobs
      configuration: Release
      msbuildArguments: '/p:AppxBundle=Always /p:AppxBundlePlatforms="$(vs.appxPlatforms)"'

  # Publish the package (.appxbundle/.msixbundle) that we just built:
  - task: PublishPipelineArtifact@0
    displayName: 'Publish Pipeline Artifact'
    inputs:
      artifactName: 'apppackages'
      targetPath: '$(unity.projectPath)$(unity.outputPath)$(vs.packagePath)'  # Change to '$(unity.projectPath)$(unity.outputPath)$(vs.packagePath)' to resume two jobs


# Build the Visual Studio solution generated from the previous job and create a package.
- job: unitytests
  dependsOn: unity
  condition: and(succeeded(), eq(variables['runTests'], 'true'))
  displayName: Unity Tests
  variables:
    installCached: false
  steps:
  # What version of Unity does the project say that it wants?:
  - task: UnityGetProjectVersionTask@1
    name: unitygetprojectversion
    displayName: Calling UnityGetProjectVersionV1 from unity-azure-pipelines-tasks extension
    inputs:
      unityProjectPath: '$(unity.projectPath)'

  - task: CacheBeta@0
    displayName: Check if Unity installation is cached
    inputs:
      key: $(Agent.OS) | "$(unitygetprojectversion.projectVersion)" | "$(unity.installComponents)"
      path: "$(unity.installFolder)$(unitygetprojectversion.projectVersion)"
      cacheHitVar: installCached

  # Install the Unity setup module (if we aren't cached):
  - task: PowerShell@2  
    displayName: Install Unity
    condition: and(succeeded(), ne(variables['installCached'], true))
    inputs:
      targetType: 'inline'
      script: |
        Install-Module -Name UnitySetup -AllowPrerelease -Force -AcceptLicense

  # Download and run the installer for Unity Components defined in unity.installComponents:
  - task: PowerShell@2
    displayName: Installing Unity Components '$(unity.installComponents)'
    condition: and(succeeded(), ne(variables['installCached'], true))
    inputs:
      targetType: 'inline'
      script: |   
        Install-UnitySetupInstance -Installers (Find-UnitySetupInstaller -Version '$(unitygetprojectversion.projectVersion)' -Components $(unity.installComponents)) -Verbose

  # Activate the Unity license (In theory, should deactivate the licence after use!):
  - task: UnityActivateLicenseTask@1
    displayName: Calling UnityActivateLicenseTask@1 from unity-azure-pipelines-tasks extension
    inputs:
      username: '$(unity.username)'
      password: '$(unity.password)'
      serial: '$(unity.serialkey)'
      unityEditorsPathMode: 'unityHub'
      unityProjectPath: '$(unity.projectPath)'

  # Play mode tests:
  - powershell: |
      Write-Host "======================= PlayMode Tests ======================="

      $logFile = New-Item -Path .\playmode-test-run.log -ItemType File -Force

      $proc = Start-Process -FilePath "$(unity.installFolder)$(unitygetprojectversion.projectVersion)$(unity.editorPath)" -ArgumentList "-projectPath $(unity.projectPath) -runTests -testPlatform playmode -batchmode -logFile $($logFile.Name) -editorTestsResultFile .\test-playmode-default.xml" -PassThru
      $ljob = Start-Job -ScriptBlock { param($log) Get-Content "$log" -Wait } -ArgumentList $logFile.FullName

      while (-not $proc.HasExited -and $ljob.HasMoreData)
      {
          Receive-Job $ljob
          Start-Sleep -Milliseconds 200
      }
      Receive-Job $ljob

      Stop-Job $ljob

      Remove-Job $ljob
      Stop-Process $proc
    displayName: 'Run PlayMode tests'

  # Publish test results:
  - task: PublishTestResults@2
    displayName: 'Publish Test Results'
    inputs:
      testResultsFormat: NUnit
      testResultsFiles: 'test*.xml'
      failTaskOnFailedTests: true

job: unityの中に記述のあるinstallCached: true と記述してある箇所は
テンプレートファイルにおいてfalseとなっています。
trueにしていないと、ビルドのたびにUnityをインストールし直すのでfalseにしておくことをお薦めします。

UnityActivateLicenseTask@1の箇所でライセンス認証を行っています。

username: '$(unity.username)'
password: '$(unity.password)'
serial: '$(unity.serialkey)'

ビルド時にライセンスをアクティベートしています。
この際、アクティベートするアカウントはUnity Pro ライセンスである必要があります。

アクティベート時には事前にPipeline上の編集画面で定義している変数を利用しています。
つまり、unity.username、unity.password、unity.serialkeyの3つの変数は前もって登録しておく必要があります。

変数の登録はPipeline上の編集画面からVariablesを選択して行います。

下記をそれぞれ登録します。シリアライズキーはUnity IDにログインして確認が可能です。

  • unity.username ( Unity アカウントのメールアドレス )
  • unity.password ( Unity アカウントのパスワード )
  • unity.serialkey ( Untiy Plus/Pro のシリアライズキー )

ここまで適切に設定を終えてPipelineを実行した場合、
ビルドしたアプリを下記画像赤枠の箇所からダウンロードできます。


おわりに

Azure DevOpsのAzure Pipelinesを利用して、HoloLens2のCIまでを簡単に行うことができました。

冒頭で述べた下記の2つの問題点について結果を見ていきたいと思います。
・ビルド中のマシンのパフォーマンス
・ビルドに要する時間


まず、ビルド中のマシンのパフォーマンスについては
Microsoft-hosted Agentの利用により、開発マシンに影響無くビルドすることができています。

次に、ビルドに要する時間ですが、VM上でUnityエディターのインストールをキャッシュしたとしても
30分ほどビルドに時間がかかってしまい、通常のビルドと大差はありませんでした。

ビルド時間の短縮が課題として残りますが、
“Self-hosted Agent”と”Microsoft-hosted Agent”を組み合わせた高速化の事例もあるようなので
利用料金や規模感に合わせて最適解を選ぶとスマートなCI/CDライフを送れそうですね。



ギャップロを運営しているアップフロンティア株式会社では、一緒に働いてくれる仲間を随時、募集しています。 興味がある!一緒に働いてみたい!という方は下記よりご応募お待ちしております。
採用情報をみる