Datadog CI Visibility で Cloud Build のパフォーマンスを可視化する

はじめに

この記事は Datadog Advent Calendar 2024 の 18 日目の記事です。
こんにちは、プレイドの Developer Experience & Performance チームでエンジニアをしている大矢です。Datadog の好きな機能は CI VisibilitySplit Graph です。

Datadog の CI Visibility は CI のパフォーマンスを可視化できるプロダクトです。 GitHub Actions や CircleCI など多くのインテグレーションがサポートされていますが、GCP の CloudBuild はサポートされていません。また、CloudBuild には継続的にパフォーマンスを可視化できるような機能がありません。

そこで、CloudBuild のパフォーマンスを CI Visibility の API を活用して計測することにしました。

CI Visibility とは

CI Pipeline のパフォーマンスを可視化します。APM のように、Pipeline トレースを可視化できるところが大きな利点だと思っていて、他にもさまざまな機能がサポートされています。サポートされる機能は、プロバイダごとに異なり、こちらで確認することができます。弊社では主に GitHub Actions と CircleCI を利用していますが、GitHub Actions では Step spans や Queue time、Infrastructure metric correlation がサポートされているのに対して、CircleCI ではそれらがサポートされておらず、ボトルネックの特定が難しかったりします。GitHub Actions や CircleCI 自体にもこのような Insight の機能は備わっているのですが、Datadog では使い慣れたクエリで柔軟に集計したり、Dashboard や Monitor との連携もできるので、なるべく Datadog を使うようにしています。

ちなみに CloudBuild は Integration はサポートされていないので OTHER CI PROVIDER というところに書いてある機能が使えます。

CI Visibility Event の Level について

CI Visibility には Event という形でデータを送ります。Event には Pipeline, Stage, Job, Step の4段階の Level があります。

Pipeline は Stage をまとめたもの、Stage は Job をまとめたもの、Job は Step をまとめたもの、というように階層構造になっています。こちらにプロバイダごとの対応づけが書かれています。例えば GitHub Actions であれば下記の対応になります。

Datadog GitHub Actions
Pipeline Workflow
Job Job
Step Step

CloudBuild のパフォーマンスを可視化するにあたっては、下記のように Level を対応づけました。

Datadog CloudBuild
Pipeline Build
Stage キューに格納・ソースの取得・全てのビルドステップ・(ある場合は)イメージの PUSHの4つのステージ
Step Step

Pipeline や Step に関しては議論の余地はあまりなさそうです。Stage という単位は別に使わなくても良いのですが、実際に可視化してみると最初の Step が実行されるまでにかなり時間がかかっていて、その内訳も知れた方が良いと感じたので導入しました。

また、階層構造さえあっていれば可視化はできるので、Stage ではなく Job としてこれらを表現してもよかったのですが、CircleCI や GitHub Actions における Job とはかなり別物という感覚があったので Stage として表現しました。

CircleCI や GitHub Actions では Job は1つのコンテナ(もしくは VM)実行環境という単位になっています。そのため異なる Job 間ではボリュームの永続化などは明示的に書く必要があります。CloudBuild においては、1つの Build 内で複数の実行環境を立ち上げることはなく、前の Step で書き込まれたディスクには後続の Step でアクセスすることができます。

Stage がしっくりくるかというとわからないのですが、消去法で決めました。

実装

各所要時間をどの Level として送るかが決まったので、実装のステップに入ります。

CloudBuild は、実行の各タイミングで Cloud PubSub にイベントを送っています。PubSub イベントをトリガーに Cloud Run Functions を実行することができるので、今回は Cloud Run Functions で実行することにしました。他の方法としては、定期実行して特定の期間内に実行された CI Pipeline の結果を送るというやり方もありそうですが、パフォーマンス改善タスクに取り組む際にはなるべく早く結果が可視化できた方が良いというのもあり、上記の手法にしました。

Queue Time

Queue Time は startTime から createTime を差し引いた時間としています。また、Pipeline の attribute にも queueTime がありますが、GitHub Actions のような良い感じの可視化がされなかったので、Stage としても実装しました。

GitHub Actions の Queue time の良い感じの可視化:
github-actions-queue.png

Status

Cloud Build における Status と、CI Visibility Pipeline で定義されている Status はちょっと違うので、注意が必要です。

Ci Visibility Pipeline で定義されている status は下記になります。

Level Status
Step success, error
Stage, Job success, error, canceled, skipped
Pipeline success, error, canceled, skipped, blocked, running

Cloud Build の場合、 STATUS_UNKNOWN, PENDING, QUEUE, WORKING, SUCCESS, FAILURE, INTERNAL_ERROR, TIMEOUT, CANCELLED, EXPIRED があります。

今回のユースケースとしては実行中の Pipeline に関しては対象外としても良いかなと思ったので、完了後のステータス( SUCCESS, FAILURE, INTERNAL_ERROR, TIMEOUT, CANCELLED )のイベントが来た時だけ処理を実行するようにしました。

Datadog Cloud Build
success SUCCESS
error FAILURE, INTERNAL_ERROR, TIMEOUT
canceled CANCELED
処理しない STATUS_UNKNOWN, PENDING, QUEUE, WORKING, EXPIRED

Git

CI Visibility Pipeline では git というフィールドに git repository の url や commit sha、authorEmail などを入れることができます。sha や repo url は Cloud Build のイベントに入ってるのですが、authorEmail は取り出せなかったので適当に入れました。GitHub API を叩いて commit sha から authorEmail を取得する方法もあると思いますが、実行者が気になったら commit のページに飛べば良いのでそこまでしなくても良いと判断しました。

ソースコード

最終的なソースコードは下記のようになります。依存ライブラリをインストールしてビルドすれば別の環境でも動くと思うので、ぜひ試してみてください。注意点としては DD_API_KEYDD_APP_KEY を secret として Cloud Run Functions で読み込めるようにする必要があるのと、Datadog で us1 以外のリージョンを使っている場合には DD_SITE 環境変数を指定する必要があります。

/**
 * Cloud Build 終了後に Datadog にイベントを送る
 */

import { PubsubMessage } from '@google-cloud/pubsub/build/src/publisher';
import { google } from '@google-cloud/cloudbuild/build/protos/protos';
import { client, v2 } from '@datadog/datadog-api-client';

type Build = google.devtools.cloudbuild.v1.Build;
type Status = keyof typeof google.devtools.cloudbuild.v1.Build.Status;

export async function main(event: PubsubMessage): Promise<void> {
  const jsonstr = Buffer.from(event.data as string, 'base64').toString();

  const data = JSON.parse(jsonstr) as Build;

  if (!isFinishedStatus(data.status as Status)) {
    console.log('event does not meet conditions.');
    return;
  }

  // pipeline
  const { status, createTime, startTime, finishTime, id, logUrl } = data;
  const triggerName = data.substitutions['TRIGGER_NAME'];
  const commitSha = data.substitutions['COMMIT_SHA'];
  const repositoryUrl = data.substitutions['REPO_FULL_NAME'];
  const queueTime = startTime
    ? new Date(startTime as string).getTime() - new Date(createTime as string).getTime()
    : undefined;

  const configuration = client.createConfiguration();
  configuration.unstableOperations['v2.createCIAppPipelineEvent'] = true;
  const apiInstance = new v2.CIVisibilityPipelinesApi(configuration);
  const params: v2.CIVisibilityPipelinesApiCreateCIAppPipelineEventRequest = {
    body: {
      data: {
        attributes: {
          resource: {
            end: finishTime ? new Date(finishTime as string) : new Date(),
            start: new Date(createTime as string),
            name: triggerName,
            status: getDatadogPipelineStatus(status as Status),
            level: 'pipeline',
            uniqueId: id,
            url: logUrl,
            git: {
              sha: commitSha,
              repositoryUrl,
              authorEmail: 'unknown@example.com',
            },
            partialRetry: false,
            queueTime,
          },
          service: 'cloud-build',
        },
        type: 'cipipeline_resource_request',
      },
    },
  };
  const result = await apiInstance.createCIAppPipelineEvent(params);

  // stage
  const stages = Object.entries(data.timing).map(([key, timing]) => {
    return {
      id: key,
      startTime: timing.startTime,
      endTime: timing.endTime,
    };
  });

  const stagesIncludeQueueTime = [
    ...stages,
    {
      id: 'QUEUE',
      startTime: createTime,
      endTime: startTime,
    },
  ];

  for (const stage of stagesIncludeQueueTime) {
    const params: v2.CIVisibilityPipelinesApiCreateCIAppPipelineEventRequest = {
      body: {
        data: {
          attributes: {
            resource: {
              end: stage.endTime ? new Date(stage.endTime as string) : new Date(),
              start: new Date(stage.startTime as string),
              name: stage.id,
              status: 'success',
              level: 'stage',
              pipelineUniqueId: id,
              pipelineName: triggerName,
              url: logUrl,
              id: stage.id,
            },
            service: 'cloud-build',
          },
          type: 'cipipeline_resource_request',
        },
      },
    };
    const result = await apiInstance.createCIAppPipelineEvent(params);
  }

  // step
  for (const step of data.steps) {
    const params: v2.CIVisibilityPipelinesApiCreateCIAppPipelineEventRequest = {
      body: {
        data: {
          attributes: {
            resource: {
              end: step.timing?.endTime ? new Date(step.timing.endTime as string) : new Date(),
              start: new Date(step.timing!.startTime as string),
              name: step.id!,
              status: getDatadogStepStatus(step.status as Status),
              level: 'step',
              id: step.id!,
              pipelineUniqueId: id,
              pipelineName: triggerName,
              url: logUrl,
              stageId: 'BUILD',
              stageName: 'BUILD',
            },
            service: 'cloud-build',
          },
          type: 'cipipeline_resource_request',
        },
      },
    };
    const result = await apiInstance.createCIAppPipelineEvent(params);
  }

  // image
  for (const image of data.results?.images ?? []) {
    const params: v2.CIVisibilityPipelinesApiCreateCIAppPipelineEventRequest = {
      body: {
        data: {
          attributes: {
            resource: {
              end: image.pushTiming?.endTime ? new Date(image.pushTiming.endTime as string) : new Date(),
              start: new Date(image.pushTiming!.startTime as string),
              name: image.name!,
              id: image.name!,
              status: 'success',
              level: 'step',
              pipelineUniqueId: id,
              pipelineName: triggerName,
              url: logUrl,
              stageId: 'PUSH',
              stageName: 'PUSH',
            },
            service: 'cloud-build',
          },
          type: 'cipipeline_resource_request',
        },
      },
    };
    const result = await apiInstance.createCIAppPipelineEvent(params);
  }
}

function isFinishedStatus(status: Status): boolean {
  return ['SUCCESS', 'FAILURE', 'INTERNAL_ERROR', 'TIMEOUT', 'CANCELLED'].includes(status);
}

function getDatadogPipelineStatus(status: Status): 'success' | 'error' | 'canceled' {
  if (status === 'SUCCESS') {
    return 'success';
  } else if (status === 'CANCELLED') {
    return 'canceled';
  } else {
    return 'error';
  }
}

function getDatadogStepStatus(status: Status): 'success' | 'error' {
  if (status === 'SUCCESS') {
    return 'success';
  } else {
    return 'error';
  }
}

Datadog CI Visibility でみてみる

こんな感じで Cloud Build のパフォーマンスを可視化することができました。Critical Path を特定し、最もボトルネックになっている step を効率的に改善することができるようになりました!

datadog-ci-visibility.png

具体的な改善ポイントとしては下記のようなものがありそうです。Stage を可視化することで、よりパフォーマンス改善の選択肢が増えると思います。

時間がかかっている Stage 改善手段の例
QUEUE Cloud Build の同時実行数を増やす、Private Pool を使う
FETCH_SOURCE リポジトリを分割する
BUILD キャッシュを使う、並列化する
PUSH イメージサイズを小さくする、Artifact Registry と Cloud Build の実行のリージョンを合わせる

最後に

Cloud Build のパフォーマンスを Datadog CI Visibility で可視化する方法を紹介しました。

Datadog は各種 Integration 対応が充実していると思いますが、未対応であってもこんな感じで提供されてる API を使って気軽に実装できるのが嬉しいところです。

Cloud Build と Datadog を使っていて、パフォーマンスの改善をしたいと考えている方がいたら参考にしていただけると幸いです!