しばやん雑記

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

Azure Pipelines で利用できる Agentless jobs を一通り試した

Classic Pipelines では GUI で Agentless jobs を選べたようですが、YAML ではそういった専用の Job はなく、使いたかった Delay などの Task を選ぶとエラーが出るという状態だったので、一通り調べて試しました。

Agentless jobs と言いますが、YAML では Server jobs とも呼ばれていて多少混乱します。

とはいえ、基本的には pool: server を設定すると Agentless jobs を使うことが出来ます。適当にテンプレートを書いてみると、以下のようになるでしょう。

trigger:
- master

pool: server

steps:
# ここには Agentless jobs のみ定義できる

Azure Pipelines の YAML Editor では Agentless jobs が混ざって表示されるので、気軽に使おうとしてみると実行時エラーになってしまいます。見分ける方法がぱっと見なかったので、一覧にしておきました。

最近 Multi-stage pipelines でも Approvals and checks で利用可能な条件が以下のように増えていますが、各 Checks は Agentless jobs だと分かれば仕組みから理解できるはずです。

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

Classic Pipelines では Pre deployment / Post deployment でいろいろ条件を設定していましたが、Multi-stage pipelines では Agentless jobs を使って組み上げていく形になっています。

とりあえず概要はこんなところにして、後は実際にそれぞれの Task を試してみたのでまとめます。

Delay

名前の通り指定した時間だけ後続の処理を遅らせる Task です。もはや説明は不要という感じですが、Agentless jobs なので Agent が必要なビルド処理中に組み込むことはできません。

なので必然的に Multi-stage pipelines を使って、それぞれで stage を分けて書くことになるはずです。今回は真ん中の stage は Agentless jobs 用にして、後続の stage が開始されるのを 1 分遅らせています。

trigger:
- master

stages:
- stage: Linux
  jobs:
  - job: Test
    pool:
      vmImage: 'ubuntu-latest'
    steps:
    - script: echo 'Hello, world'

- stage: Agentless
  jobs:
  - job: Test
    pool: server
    steps:
    - task: Delay@1
      inputs:
        delayForMinutes: '1'

- stage: Windows
  jobs:
  - job: Test
    pool:
      vmImage: 'windows-latest'
    steps:
    - script: echo 'Hello, world'

わざわざ stage を分けなくても job で分ければ良いじゃないかと思われそうですが、job を複数書いた場合はデフォルトだと並列実行されるので、dependsOn を使って順番を明示的に指定する必要があります。割とめんどくさいので、stage として分けた方がシンプルです。

実行してみると、ちゃんと Delay Task が完了するまでは後続の stage が実行されません。

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

Agentless jobs は Azure Pipelines のサーバー側で処理されているようなので、シェルスクリプトなどでスリープさせるより効率的ですね。

Invoke REST API

これもそのままの名前ですが、任意の REST API を実行する Task です。汎用的な名前が付いていますが、実体としては ARM REST API を実行することに特化しているようです。

Service connection として ARM か Generic を選べるようになっていますが、基本的には ARM が良く使われることになりそうです。後で出てくる Azure Monitor Alert の Task はほぼこの仕組みを使っているようでした。

Azure Resource Manager

ARM を選ぶと必ず https://management.azure.com がホストとして設定されるので、地味に使える API は少なめです。Service Bus や Storage Queue は操作できないので、テストで何を使うか悩みました。

とりあえず Azure CDN の Cache Purge をする API を叩いてみることにしました。

trigger:
- master

stages:
- stage: Agentless
  jobs:
  - job: Test
    pool: server
    steps:
    - task: InvokeRESTAPI@1
      inputs:
        connectionType: 'connectedServiceNameARM'
        azureServiceConnection: 'Azure Sponsorship'
        method: 'POST'
        headers: |
          {
          "Content-Type":"application/json"
          }
        body: |
          {
            "contentPaths": [
              "/"
            ]
          }
        urlSuffix: '/subscriptions/***/resourceGroups/***/providers/Microsoft.Cdn/profiles/daruyanagi/endpoints/static-daruyanagi/purge?api-version=2019-04-15'
        waitForCompletion: 'false'

実行すると 202 Accepted が返ってきたので、正しく Purge リクエストが受け付けられました。

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

ちょっとすぐには用途が思いつかなかったですが、他の Agentless jobs と組み合わせて ARM API を叩きたい時に使う感じでしょうか。主に Gates の条件として使うっぽいです。

Generic

もう一つの Generic を選ぶと、Service connection に Generic として追加した API を実行できます。これも使い道がすぐに浮かばなかったので、適当に SendGrid の API を叩いてみることにしました。

trigger:
- master

stages:
- stage: Agentless
  jobs:
  - job: Test
    pool: server
    steps:
    - task: InvokeRESTAPI@1
      inputs:
        connectionType: 'connectedServiceName'
        serviceConnection: 'SendGrid'
        method: 'POST'
        headers: |
          {
          "Content-Type":"application/json",
          "Authorization":"Bearer $(SendGridToken)"
          }
        body: |
          {
            "personalizations": [
              {
                "to": [
                  {
                    "email": "me@shibayan.jp"
                  }
                ],
                "subject": "Hello, World!"
              }
            ],
            "from": {
              "email": "mail@daruyanagi.com"
            },
            "content": [
              {
                "type": "text/plain",
                "value": "Hello, World!"
              }
            ]
          }
        urlSuffix: '/v3/mail/send'
        waitForCompletion: 'false'

Generic な Service connection は Basic 認証しか標準では対応していないようなので、Variables を使って Bearer Token を指定するようにしました。とりあえずリクエストはサンプルのままです。

実行すると、正しく API が実行されてメールも届きました。特に難しいことはないです。

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

ここまで書いている間に、例えば Performance Test や E2E Test サービスを使っている場合に、開始用の API をキックして処理の完了を待つという使い方がありそうだと気が付きました。

面白いのが waitForCompletiontrue に設定すると、外部から Azure Pipelines の API を叩いて完了通知をするまで待機し続けるので、外部サービスで時間がかかっても低コストで実行できます。

Invoke Azure Function

これも名前の通りですが、Azure Functions の実行に特化したパラメータ設計になっています。いい感じの入力補完は用意されていないので、URL と API キーは手動で入力する必要があります。

折角 Azure Functions を用意するので Durable Functions を使って、完了通知を非同期で行うようなコードを書いて試してみました。

雑な実装ですが、Timer を使って 1 分待った後に Azure Pipelines に完了通知を行っています。完了通知に必要な情報は Task のデフォルトでリクエストヘッダーに入ってきます。

public class Function1
{
    private static readonly HttpClient _httpClient = new HttpClient();

    [FunctionName("Function1")]
    public async Task RunOrchestrator(
        [OrchestrationTrigger] IDurableOrchestrationContext context)
    {
        var info = context.GetInput<PipelineInfo>();

        await context.CreateTimer(context.CurrentUtcDateTime.AddMinutes(1), CancellationToken.None);

        await context.CallActivityAsync("Function1_Complete", info);
    }

    [FunctionName("Function1_Complete")]
    public async Task Complete([ActivityTrigger] PipelineInfo info, ILogger log)
    {
        var content = new
        {
            name = "TaskCompleted",
            taskId = info.TaskInstanceId,
            jobId = info.JobId,
            result = "succeeded"
        };

        var request = new HttpRequestMessage(HttpMethod.Post, $"{info.PlanUrl}/{info.ProjectId}/_apis/distributedtask/hubs/{info.HubName}/plans/{info.PlanId}/events?api-version=2.0-preview.1")
        {
            Content = new StringContent(JsonConvert.SerializeObject(content), Encoding.UTF8, "application/json")
        };

        request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", info.AuthToken);

        await _httpClient.SendAsync(request);
    }

    [FunctionName("Function1_HttpStart")]
    public async Task<IActionResult> HttpStart(
        [HttpTrigger(AuthorizationLevel.Function, "get", "post")] HttpRequest req,
        [DurableClient] IDurableOrchestrationClient starter,
        ILogger log)
    {
        var info = new PipelineInfo
        {
            PlanUrl = req.Headers[nameof(PipelineInfo.PlanUrl)],
            ProjectId = req.Headers[nameof(PipelineInfo.ProjectId)],
            HubName = req.Headers[nameof(PipelineInfo.HubName)],
            PlanId = req.Headers[nameof(PipelineInfo.PlanId)],
            JobId = req.Headers[nameof(PipelineInfo.JobId)],
            TaskInstanceId = req.Headers[nameof(PipelineInfo.TaskInstanceId)],
            AuthToken = req.Headers[nameof(PipelineInfo.AuthToken)]
        };

        // Function input comes from the request content.
        var instanceId = await starter.StartNewAsync("Function1", info);

        log.LogInformation($"Started orchestration with ID = '{instanceId}'.");

        return starter.CreateCheckStatusResponse(req, instanceId);
    }
}

Azure DevOps の REST API を叩くのに必要な Access Token が渡されているので、このタイミングで Azure Board にクエリを投げてタスクの状態を確認することも出来そうです。このあたりは Durable Functions と相性が非常に良い仕組みだと感じました。

Function をデプロイしたら、適当に Function を実行する Task を YAML に追加します。

trigger:
- master

stages:
- stage: Agentless
  jobs:
  - job: Test
    pool: server
    steps:
    - task: AzureFunction@1
      inputs:
        function: 'https://pipeline-event.azurewebsites.net/api/Function1_HttpStart'
        key: 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'
        method: 'POST'
        waitForCompletion: 'true'

実行すると Durable Functions は即座に 202 Accepted なレスポンスを返してきますが、Azure Pipelines の処理自体は継続したままになっています。

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

その間に Durable Functions 側では Timer に従って処理が進んでいて、時間になると Azure Pipelines 側に完了通知を実行します。Application Insights を開くと API を実行していることが確認できます。

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

完了通知を受け取ると、正常に Task が完了したとして Pipeline は終了します。失敗扱いにしたい場合には result の値を failed にすれば良いです。

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

アプリケーションのリリース時に独自のチェックを追加したい場合には、今回のように Durable Functions を使って書くのが簡単だと思いました。

このあたりの処理を簡単に書くためのサンプルコードが提供されていますが、微妙に古いのでまだちゃんと動くのかは確認していないです。

メンテナンスはされているみたいですが、全体的に放置されている感があるのは厳しいです。

Query Work Items

Azure Board で作成された Query を実行する Task です。

予め Azure Board で Query を作っておく必要がありますが、リリース前チェックリストを Work Item として作っておけば、完了する前にリリースされることを防いだり出来そうです。

何らかの Work Item が必要なので、とりあえず適当に Sprint / Issue / Task を作成しておきました。

これが Sprint 単位でのリリースを想定して、その Sprint に紐づいている Issue が完了するまではリリースをさせない設定を用意してみます。

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

サイドバーから Queries を選択して、新しく現在の Sprint かつ State が Doing になっている Issue を絞り込む Query を作成しました。保存先を Shared Queries にしないとエラーになるので注意。

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

作成した Query を実行する Task 定義を書いていきますが、この時に Query ID を指定する必要があるので Query 編集画面の URL から GUID を拾ってきます。ここだけ非常にイケてない仕様になっています。

閾値を設定する必要がありますが、今回は 1 件でも残っていればエラーにしたいので両方に 0 を設定します。

trigger:
- master

stages:
- stage: Agentless
  jobs:
  - job: Test
    pool: server
    steps:
    - task: queryWorkItems@0
      inputs:
        queryId: '0834963b-7987-4069-9afe-be24572f432d'
        maxThreshold: '0'
        minThreshold: '0'

これで実行すると、現在の Sprint に完了していない Issue があるのでエラーになります。

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

もちろん Issue を完了すると正常終了になるので、後続の処理を走らせることが出来ます。ここでは本番へのデプロイなどを指しています。

Query Azure Monitor Alerts

Work Item と同じような Task ですが、こっちは Azure Monitor Alerts の状態を確認する Task です。

用途としては説明がほぼ要らないと思いますが、Canary リリースなどで実際に一部へリリース中のアプリケーションで問題が発生した場合に、後続の処理を止めるといった条件に使えます。

これも本番へのデプロイ前の Gates 用という感じですが、Azure Monitor の Alerts が発火するのが遅いので定義で結構悩みます。とりあえず YAML を書いてみました。

trigger:
- master

stages:
- stage: Agentless
  jobs:
  - job: Test
    pool: server
    steps:
    - task: AzureMonitor@1
      inputs:
        connectedServiceNameARM: 'Azure Sponsorship'
        ResourceGroupName: 'WebAppDeploy-RG'
        filterType: 'none'

何故か個別の Alert Rule を選べなかったので、フィルタは無しにしています。

Alert が発火中に実行しようとすると Task がエラーになるので後続の処理が止まります。

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

Azure Monitor ベースなので CPU 使用率が非常に高い間のデプロイを防ぐ用途としても使えそうです。

複雑な条件は YAML には書けないので Azure Monitor 側で頑張るか、Durable Functions で専用のチェックを実装するのが良さそうでした。

Publish To Azure Service Bus

最後は Service Bus Queue にメッセージを追加する Task です。これも完了通知に対応しているので、Queue を受け取った側で独自のチェックを行い、問題なければ継続させるといった処理が書けます。

とはいえ Durable Functions で書いた方が良いと思うので、個人的な優先度は低いです。Service Bus 用の Service connection を作成する必要もあるので、多少は手間もかかります。

trigger:
- master

stages:
- stage: Agentless
  jobs:
  - job: Test
    pool: server
    steps:
    - task: PublishToAzureServiceBus@1
      inputs:
        azureSubscription: 'ServiceBus'
        messageBody: |
          {
          "PlanUrl": "$(system.CollectionUri)", 
          "ProjectId": "$(system.TeamProjectId)", 
          "HubName": "$(system.HostType)", 
          "PlanId": "$(system.PlanId)", 
          "JobId": "$(system.JobId)", 
          "TimelineId": "$(system.TimelineId)", 
          "TaskInstanceId": "$(system.TaskInstanceId)", 
          "AuthToken": "$(system.AccessToken)"
          }
        signPayload: false
        waitForCompletion: true

実行すると Service Bus Queue に指定したメッセージが追加されます。今回は完了通知は使わないので、即座に処理が完了しています。

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

適用に Queue からメッセージを読み取るコードを書いてみたところ、メッセージを受信出来ました。

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

これだけのために Service Bus を用意するのはコスパが悪いので、オンプレとの連携が必要な時が用途としてはメインな気がしています。