Cloud Buildのデプロイ失敗をDiscordに通知する仕組みを構築する

## 概要

Cloud Buildでのデプロイ失敗を検知し、Discordに通知する仕組みを構築してみたので備忘録としてまとめます。Cloud Run、Cloud Pub/Sub、Cloud Loggingを組み合わせて、自動通知システムを実現します。

## アーキテクチャ

今回やりたいことのイメージ図です

```mermaid
graph LR
    A[Cloud Build] -->|ログ出力| B[Cloud Logging]
    B -->|ログシンク| C[Cloud Pub/Sub]
    C -->|メッセージ| D[Cloud Run]
    D -->|通知| E[Discord Webhook]
```

## 前提条件

- GCPプロジェクトの作成済み
- 必要な権限の設定
  - Cloud Run 管理者
  - Cloud Build 管理者
  - Cloud Logging 管理者
  - Cloud Pub/Sub 管理者
- Discord Webhookの設定済み

## 手順

### 1. Cloud Pub/Sub トピックの作成

- トピックID: `cloud-run-deploy-errors`
- デフォルトのサブスクリプションを追加する
- Googleが管理する暗号鍵

![create_topic.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3939741/b4a5cd92-9a6e-42aa-8208-721de4c33eb0.png)


### 2. 通知サービスの実装

#### deploy-notify.ts

```typescript
import express from 'express'
import { fetch } from 'undici'

const app = express()
const port = process.env.PORT || 8080

interface DiscordEmbed {
    title?: string
    description?: string
    color?: number
    fields?: Array<{
        name: string
        value: string
        inline?: boolean
    }>
    timestamp?: string
}

const sendDeploymentNotification = async (
    message: { message: { data: string } },
    context: { eventId: string; timestamp: string },
): Promise<void> => {
    try {
        if (!message.message?.data) {
            throw new Error('Pub/Sub メッセージデータが存在しません')
        }

        const data = Buffer.from(message.message.data, 'base64').toString()
        const logEntry = JSON.parse(data)

        // Cloud Buildのエラー情報を取得
        const buildId = logEntry.resource?.labels?.build_id || '不明なビルドID'
        const projectId = logEntry.resource?.labels?.project_id || '不明なプロジェクト'
        const triggerId = logEntry.resource?.labels?.build_trigger_id || '不明なトリガー'
        const errorCode = logEntry.protoPayload?.status?.code
        const serviceName = logEntry.protoPayload?.serviceName || '不明なサービス'
        const methodName = logEntry.protoPayload?.methodName || '不明なメソッド'
        const timestamp = logEntry.timestamp || context.timestamp

        // エラーメッセージの生成
        const errorMessage = `ビルドエラーが発生しました。\nエラーコード: ${errorCode}\nメソッド: ${methodName}`

        const discordWebhookUrl = process.env.DISCORD_WEBHOOK_URL as string
        if (!discordWebhookUrl) {
            throw new Error('Discord Webhook URL が設定されていません。')
        }

        const embed: DiscordEmbed = {
            title: '⚠️ Cloud Build 失敗',
            description: errorMessage,
            color: 0xdc3545,
            fields: [
                {
                    name: 'プロジェクト',
                    value: projectId,
                    inline: true,
                },
                {
                    name: 'ビルドID',
                    value: buildId,
                    inline: true,
                },
                {
                    name: 'トリガーID',
                    value: triggerId,
                    inline: true,
                },
                {
                    name: 'サービス',
                    value: serviceName,
                    inline: true,
                },
            ],
            timestamp: timestamp,
        }

        await fetch(discordWebhookUrl, {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({ embeds: [embed] }),
        })

        console.log('Discord 通知送信完了:', JSON.stringify(embed))
    } catch (error) {
        console.error('通知送信失敗:', error)
        throw error
    }
}

// JSONパーサーを追加
app.use(express.json())

// ヘルスチェック用エンドポイント
app.get('/', (_req, res) => {
    res.status(200).send('OK')
})

// Pub/Subメッセージを受け取るエンドポイント
app.post('/', async (req, res) => {
    try {
        await sendDeploymentNotification(req.body, {
            eventId: req.body.message?.id || '',
            timestamp: new Date().toISOString(),
        })
        res.status(200).send('OK')
    } catch (error) {
        console.error('Error processing message:', error)
        res.status(500).send('Internal Server Error')
    }
})

app.listen(port, () => {
    console.log(`Server is running on port ${port}`)
})
```


#### Dockerfile

```dockerfile
FROM node:22.13.1-slim

WORKDIR /app

# pnpmのインストール
RUN npm install -g pnpm@10.2.0

# package.jsonをコピー
COPY package.json ./
COPY deploy-notify.ts ./
COPY tsconfig.json ./

# 依存関係のインストール
RUN pnpm install --no-frozen-lockfile

# ビルド
RUN pnpm run build

ENV NODE_ENV=production
ENV PORT=8080
ENV DISCORD_WEBHOOK_URL=""

EXPOSE 8080
CMD [ "pnpm", "start" ]
```

#### cloudbuild.yaml

```yaml
steps:
  # Build the container image
  - name: 'gcr.io/cloud-builders/docker'
    args:
      - 'build'
      - '-t'
      - '${_REGION}-docker.pkg.dev/$PROJECT_ID/${_REPOSITORY}/deploy-notify:$COMMIT_SHA'
      - '-f'
      - 'Dockerfile'
      - '.'
    dir: 'functions'

  # Push the container image to Artifact Registry
  - name: 'gcr.io/cloud-builders/docker'
    args:
      - 'push'
      - '${_REGION}-docker.pkg.dev/$PROJECT_ID/${_REPOSITORY}/deploy-notify:$COMMIT_SHA'

  # Deploy container image to Cloud Run
  - name: 'gcr.io/google.com/cloudsdktool/cloud-sdk'
    entrypoint: gcloud
    args:
      - 'run'
      - 'deploy'
      - 'deploy-notify'
      - '--image'
      - '${_REGION}-docker.pkg.dev/$PROJECT_ID/${_REPOSITORY}/deploy-notify:$COMMIT_SHA'
      - '--region'
      - '${_REGION}'
      - '--platform'
      - 'managed'
      - '--allow-unauthenticated'

  # Clean up old images
  - id: delete-old-images
    name: asia-docker.pkg.dev/gcr-cleaner/gcr-cleaner/gcr-cleaner-cli:latest
    args:
      - --repo=${_REGION}-docker.pkg.dev/$PROJECT_ID/${_REPOSITORY}/deploy-notify
      - --keep=1
      - --tag-filter-any=.*

options:
  logging: CLOUD_LOGGING_ONLY

images:
  - '${_REGION}-docker.pkg.dev/$PROJECT_ID/${_REPOSITORY}/deploy-notify:$COMMIT_SHA'
```

### 3. Cloud Runサービスの作成

Cloud Run > 関数を作成

- Github（monorepoで外出しの関数もリポジトリ内で管理したいので）
    - リポジトリを選択
    - ブランチを選択
    - ビルドタイプ：`Dockerfile`
- サービス名：`notify-deploy-error`
- リージョン：`asia-northeast1（東京）`

![cloudrun-service.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3939741/369d7ccb-dce5-4868-96a4-adc67d0be5f8.png)


ここで[Eventarc トリガーの設定](#4-eventarc-トリガーの設定)もまとめてできます

- 認証
 - 認証が必要

![trigger.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3939741/03a928f7-843d-4553-99ed-82ea18efd9f6.png)

他の設定項目はデフォルトでサービスを作成します
作成したらAPIサーバーなどのコンテナ同様に環境変数を設定します（今回はDiscordのWebhookURL）

### 4. Eventarc トリガーの設定

Cloud Runサービスと Pub/Subトピックを接続するために、Eventarcトリガーを設定します。

1. **Cloud Run** > **deploy-notify** サービスを選択
2. **トリガー** タブを選択
3. **トリガーを作成**をクリック
4. 以下の設定を行う：
   - トリガー名: `deploy-notify-trigger`
   - イベントプロバイダー: `Cloud Pub/Sub`
   - イベント: `google.cloud.pubsub.topic.v1.messagePublished`
   - トピック: `cloud-run-deploy-errors`
   - サービスアカウント: デフォルトのCompute Engine サービスアカウント


### 5. Cloud Logging シンクの設定

1. **Logging** > **ログルーター**に移動
2. **シンクを作成**をクリック
3. 以下の設定を行う：
   - シンク名: `deploy-notify-sink`
   - シンクの宛先: 
     - シンクサービス: `Cloud Pub/Sub`
     - シンクの宛先: `cloud-run-deploy-errors`
   - シンクに含めるログの選択:
   ```
   resource.type="build"
   resource.labels.project_id="${project_id}"
   severity>=ERROR
   ```

シンクに含めるログの選択のときに`ログをプレビュー`からデプロイエラーのログを拾えていることを確認してください

### 6. 動作確認

Cloud Buildで意図的にエラーを発生させ、Discordに通知が送信されることを確認します

![error.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3939741/82d86cd4-4390-45a8-a09e-3ebbeec906f1.png)

### 推奨設定

- Cloud Build トリガー設定

  - デプロイエラー用のコンテナトリガーと、APIサーバーなどのトリガーが分かれているため、含まれるファイルフィルタと無視されるファイルフィルタを適切に設定する
  - 例えば、Cloud Run関数専用のディレクトリを指定することで、同じリポジトリの他のコード変更によって不要なビルドがトリガーされるのを防ぐ

## まとめ

本記事では、Cloud Buildのデプロイ失敗を検知し、Discordに通知する仕組みを構築しました。
Cloud Run関数にリブランディングされてから初めて触れましたが、Gitとの連携や設定の手軽さが大きく向上していると感じました。
今後、機会があれば、より複雑なアーキテクチャの構築にも挑戦してみたいと思います。