🐢

(GCP)Cloud Buildの実行完了時にSlack通知とE2Eテストを行う

2024/12/17に公開

はじめに

この記事は GMOメディア株式会社 Advent Calendar 2024の17日目の記事です。
Cloud Buildの実行完了時にビルド結果をSlack通知し、E2Eテストを自動で実行する仕組みを作ったのでご紹介します。

今までは、プルリクエストをマージしてCloud Buildが走った後、Cloud Buildの履歴ページでビルド結果を確認していましたが、確認の手間を減らすためにSlack通知するようにしました。

また、アプリケーションの重要な機能のE2Eテストを、テスト環境でのデプロイ完了時に自動で走らせることで、重要な機能が壊れていないことを確認できる仕組みも合わせて作りました。

どんなものができたか

Cloud Buildの結果の通知 E2Eテストの実行結果の通知

構成図

全体の構成図は以下です(青い矢印の部分を今回作成しました)

手順

Slack Appの作成とWebhook URLの取得

システム通知をSlackに飛ばすには、Slack Appを作成してIncoming Webhooksという機能のWebhook URLを取得する必要があります。
Slack APIのページからCreate New Appをクリックし、From scratchを選択します。

Incoming WebhooksActivate Incoming WebhooksをONにします。

Add New Webhook to Workspaceをクリックして投稿先チャンネルを指定するとWebhook URLが作成されるのでコピーします。

後ほどCloud Functionsで使用するので、Secret Managerに保管します。
使い方は公式ドキュメントを参考にしました。
https://cloud.google.com/secret-manager/docs/creating-and-accessing-secrets?hl=ja#create

E2Eテストの作成とCloud Runでの実行

実行結果をSlack通知するE2Eテストを作成します。
E2EテストではPlaywrightを使っています。
https://playwright.dev/

コードの概要
src/test/e2e/〇〇.spec.ts
import { expect, test } from "@playwright/test";

test("ここにテストの説明を書く", async ({
  browser,
}) => {
  (省略)テストコードをここに書く〜
  try {
    (省略)テストコードをここに書く〜

    await sendSlackNotification(
      "〇〇のE2Eテストが成功しました",
      "#通知先のSlackチャンネル",
      "E2Eテスト結果を知らせる〇〇",
      "Slackアイコンのコード",
    );
  } catch (error: unknown) {
    if (error instanceof Error) {
      await sendSlackNotification(
        `〇〇のE2Eテストが失敗しました: ${error.message}`,
        "#通知先のSlackチャンネル",
        "E2Eテスト結果を知らせる〇〇",
        "Slackアイコンのコード",
      );
    } else {
      await sendSlackNotification(
        "〇〇のE2Eテストが失敗しました: 不明なエラーが発生しました。",
        "#通知先のSlackチャンネル",
        "E2Eテスト結果を知らせる〇〇",
        "Slackアイコンのコード",
      );
    }
  } finally {
    await page.close();
  }
});

// Slack通知するメソッド
async function sendSlackNotification(
  message: string,
  channel: string,
  username: string,
  icon_emoji: string,
) {
  const postMessageUrl = "https://slack.com/api/chat.postMessage";
  const slackToken = process.env.SLACK_TOKEN;

  const payload = {
    text: message,
    channel: channel,
    username: username,
    icon_emoji: icon_emoji,
  };

  if (!slackToken) {
    console.info(message);
    return;
  }

  // SlackAPIトークンが設定されている場合のみ通知を行う
  try {
    const response = await fetch(postMessageUrl, {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        Authorization: `Bearer ${slackToken}`,
      },
      body: JSON.stringify(payload),
    });

    if (!response.ok) {
      console.error(`Slack通知エラー: ${response.statusText}`);
    }
  } catch (error) {
    console.error("Slack通知の送信中にエラーが発生しました:", error);
  }
}

続いて、Dockerfileを作成してImageをビルドし、Artifact Registryへpushします。

docker push asia-northeast1-docker.pkg.dev/(リポジトリ名)/(リポジトリ内のパス):(タグ名)

Cloud Run Jobを作成し、先ほどpushしたイメージをコンテナイメージとして設定します。
https://cloud.google.com/run/docs/create-jobs?hl=ja#console

Cloud Pub/Subのトピック・サブスクリプションの作成

公式ドキュメントの手順通りにトピックを作成します。
https://cloud.google.com/pubsub/docs/create-topic?hl=ja#create_a_topic_2

サブスクリプションも同様に作成します。
https://cloud.google.com/pubsub/docs/create-push-subscription?hl=ja#create_a_push_subscription

サブスクリプションはpush型にして、Cloud FunctionsのエンドポイントURLを指定します。(この後Cloud Functionsを作成していきますが、エンドポイントを用意するために中身を作り込んでないCloud Functionsを事前に作成しておきます)

Build結果の通知とE2EのCloud Runを実行するCloud Run Functionsの作成

Build結果を先ほど作成したSlack Webhookを使った通知とE2Eテスト(Croud Run)の実行を行うCloud Functionsを作成します。

トリガーのタイプはCloud Pub/Subを選択し、先ほど作成したトピックを選択します。

ランタイム、ビルド、接続、セキュリティの設定のセキュリティとイメージのリポジトリのシークレットには、先ほどSecret Managerに保管したSlackのWebhook URLを指定します。

続いて、コードを作成します。

作成したCloud Run Functionsのコード
const functions = require('@google-cloud/functions-framework');
const { Datastore } = require('@google-cloud/datastore');
const datastore = new Datastore({ projectId: '(プロジェクトIDを書く)', databaseId: "(データベースIDを書く)" });

// Slackに通知を送信する関数
async function sendSlackNotification(webhookUrl, message) {
    const response = await fetch(webhookUrl, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ text: message }),
    });
    return response.ok;
}

// Base64デコードしたデータをJSON形式でコンソール出力する関数
function decodeAndLogBuildResultData(buildResult) {
    // Base64エンコードされたデータを取得
    const encodedData = buildResult.message.data;

    // Base64デコード
    const decodedData = Buffer.from(encodedData, "base64").toString();
    console.log("decodedData:", JSON.parse(decodedData));
    return JSON.parse(decodedData);
}

// 重複したメッセージIDの確認と保存を行う関数(2回通知の防止)
async function checkAndSaveMessageId(messageId) {
    const messageKey = datastore.key(['MessageId', messageId]);
    // トランザクションの開始
    const transaction = datastore.transaction();
    await transaction.run();

    try {
        const [entity] = await transaction.get(messageKey);

        if (entity) {
            console.log(`重複したmessageIdが見つかりました: ${messageId}`);
            return true; // 既にメッセージIDが存在する場合は処理をやめる
        } else {
            const newEntity = {
                key: messageKey,
                data: {
                    messageId: messageId,
                    timestamp: new Date()
                }
            };
            transaction.save(newEntity);
            await transaction.commit();
            console.log(`messageId ${messageId} が保存されました。`);
            return false;
        }
    } catch (error) {
        console.error('Error in transaction', error);
        await transaction.rollback();
        throw error;
    }
}

functions.cloudEvent('notifyToSlack', async (cloudEvent) => {
  console.log("buildResult", buildResult);
    // Datastoreに保存するデータを取得(2回通知の防止)
    const messageId = buildResult.message.messageId;
    console.log("messageId", messageId);

    // ビルド結果の取得
    const decodedBuildResult = decodeAndLogBuildResultData(buildResult);
    const buildStatus = decodedBuildResult.status;
    const repoName = decodedBuildResult.substitutions.REPO_NAME;

    // WebhookのURLを環境変数から取得
    const webhookUrl = process.env.WEBHOOK_URL;

    // ビルドが成功した場合は、重複メッセージIDを確認する
    if (buildStatus === 'SUCCESS') {
        const isDuplicate = await checkAndSaveMessageId(messageId);
        if (isDuplicate) {
            console.log("重複したmessageIdがあるため処理を終了します。");
            return; // 重複した場合は処理を終了
        }

        // Slackにビルド結果を通知
        const message = `:white_check_mark: production環境へのデプロイが成功しました\n` +
                        `*リポジトリ*: ${repoName}\n`;
        await sendSlackNotification(webhookUrl, message);
    } else if (buildStatus === 'FAILED') {
        const message = `:x: production環境へのデプロイが失敗しました\n` +
                        `*リポジトリ*: ${repoName}\n`;
        await sendSlackNotification(webhookUrl, message);
    }
});

Datastoreを使って重複したメッセージIDの確認と保存を行っているのは、1回のFunctionの実行で2回通知が来てしまったことへの対処です。

以下の記事の通り、イベントトリガーのCloud Functionsは、1回のイベントに対し2回以上起動してしまうことがあるらしいです。
https://dev.classmethod.jp/articles/use-firestore-to-avoid-cloud-functions-duplicate-execution/

保存先のデータベースは公式ドキュメントを参考に作成しました。
https://cloud.google.com/datastore/docs/manage-databases?hl=ja#create_a_database

おわりに

以上がCloud Buildの完了時にビルド結果をSlack通知し、E2Eテストを自動で実行する仕組みの作成方法です。
ビルド後の通知によって、リリース後の共有・確認が時間の無駄なくできるようになりました。
また、テスト環境へデプロイしたタイミングでE2Eテストを走らせることで、重要な機能を壊していないことを確認することができ、心理的安全性に繋がるのでおすすめです。

GMOメディアテックブログ

Discussion