AKARI Tech Blog

燈株式会社のエンジニア・開発メンバーによる技術ブログです

Amplifyのビルド失敗をEventBridge+Lambda+Slackで検知

こんばんは。今週のAKARI Tech Blogは、AI SaaS 事業本部でDigital Billderのインフラの管理をしている小谷が担当します。

はじめに

AI Saas 事業本部で開発・提供しているDigital Billderは、建設業界における「見積・発注・請求」といった一連の業務をクラウドで一元管理できるWebアプリケーションです。

建設業界では、協力会社とのやり取りが紙やFAX、電話で個別に行われることが多く、書類の作成・管理やコミュニケーションに多大な時間がかかるという課題がありました。Digital Billderは、こうしたアナログな業務プロセスをデジタル化し、協力会社とのやり取りも含めたバックオフィス業務全体の効率化を実現します。

このDigital Billderでは、フロントエンドのデプロイにAWS Amplifyを利用しています。先日、Amplifyを利用してステージング環境へのデプロイを行っていたところ、本来反映されているはずの機能が反映されていないという事象がありました。調査した結果、Amplifyのデプロイ自体が失敗していたことが原因でした。Amplifyのデフォルト設定ではビルドの成否に関する通知機能が特に設定されておらず、コンソールを確認しにいかない限り失敗に気づきにくいという課題がありました。

この経験から、Amplifyのビルドが失敗した際に迅速に検知できるよう、Slackチャンネルに通知を送る仕組みを構築することにしました。今回はその構築方法についてご紹介します。

カスタム通知の必要性

AWSマネジメントコンソールのAmplify設定画面には、Eメールによる通知機能が標準で用意されています。当初は、この機能を使ってSlackチャンネルに設定したメールアドレス宛に通知を送ることを検討しました。

しかしこの標準のEメール通知はビルドの失敗時だけでなく、開始時・成功時・終了時にも送信ます。これでは失敗時の通知が他の通知に紛れてしまい、重要な失敗を見逃す可能性があります。 そこで、ビルド失敗時のみに特化した通知システムを独自に構築することにしました。

以下は実際の失敗通知のイメージです。

構築したシステムの構成

今回構築したシステムの構成は以下の通りです。シンプルかつ効果的な構成を目指しました。

graph LR
    subgraph "AWS Cloud"
        A[AWS Amplify] -- ビルドステータスイベントを発行 --> B(Amazon EventBridge Rule);
        B -- 'FAILED' ステータスでフィルタ<br>Lambdaをトリガ --> C{AWS Lambda Function};
    end

    C -- "メッセージを整形し<br>Webhook経由で通知" --> D[Slack Channel];

    style A fill:#FF9900,stroke:#333,stroke-width:2px
    style B fill:#C71585,stroke:#333,stroke-width:2px
    style C fill:#FF4F5B,stroke:#333,stroke-width:2px
    style D fill:#4A154B,stroke:#333,stroke-width:2px,color:#fff
  1. Amplify: フロントエンドのビルド・デプロイを実行します。ビルドが失敗すると、ステータス変更イベントがAWS内で発生します。
  2. EventBridge: Amplifyのイベントを監視し、「ビルド失敗 (FAILED)」のイベントのみをフィルタリングしてLambda関数をトリガーします。
  3. Lambda: EventBridgeからイベント情報を受け取り、Slackに通知するためのメッセージを整形し、指定されたSlackチャンネルのIncoming Webhook URLにPOSTリクエストを送信します。今回はNode.js で実装しました。
  4. Slack: Lambdaから通知を受け取り、チャンネルに表示します。

この構成により、「Amplifyのビルドが失敗したとき」という特定のイベントのみをトリガーとして、Slackに必要な情報が通知されます。

Terraformによる実装

インフラのコード管理にはTerraformを使用しました。以下に、EventBridgeとLambdaを構築するためのTerraformコードを示します。

1. EventBridgeの設定

Amplifyのビルド失敗イベントを検知し、後述するLambda関数をトリガーするためのEventBridgeルールとターゲットを設定します。

# EventBridge Rule: Amplifyのビルド失敗イベントをフィルタリング
resource "aws_cloudwatch_event_rule" "amplify_build_fail_rule" {
  name        = "amplify-build-fail-rule" # EventBridgeルール名
  description = "Trigger Lambda when Amplify build fails"

  event_pattern = jsonencode({
    source      = ["aws.amplify"],
    "detail-type" = ["Amplify Deployment Status Change"],
    detail = {
      # jobStatusがFAILEDの場合のみイベントをトリガー
      jobStatus = ["FAILED"],
      # 通知対象のAmplifyアプリケーションIDを指定 (複数指定可)
      # Terraformで管理しているAmplify Appリソースを参照する場合
      appId     = [for app in aws_amplify_app.amplify : app.id]
      # 特定のIDを直接指定する場合
      # appId     = ["YOUR_AMPLIFY_APP_ID_1", "YOUR_AMPLIFY_APP_ID_2"]
    }
  })
}

# EventBridge Target: トリガーするLambda関数を指定
resource "aws_cloudwatch_event_target" "lambda_target" {
  rule      = aws_cloudwatch_event_rule.amplify_build_fail_rule.name
  target_id = "amplify-build-fail-slack-notifier-target"
  arn       = aws_lambda_function.slack_notifier.arn # 作成するLambda関数のARN
}

# Lambda Permission: EventBridgeがLambda関数を呼び出す権限を付与
resource "aws_lambda_permission" "allow_cloudwatch_to_call_lambda" {
  statement_id  = "AllowExecutionFromCloudWatch"
  action        = "lambda:InvokeFunction"
  function_name = aws_lambda_function.slack_notifier.function_name # 作成するLambda関数名
  principal     = "events.amazonaws.com"
  source_arn    = aws_cloudwatch_event_rule.amplify_build_fail_rule.arn # 作成するEventBridgeルールのARN
}
  • event_patternsource, detail-type, detail.jobStatus を指定し、Amplifyのビルド失敗イベントのみを捕捉します。
  • detail.appId で通知対象のAmplifyアプリケーションを指定します。この例では、Terraformで管理されている aws_amplify_app リソース(amplifyという名前)のIDを参照しています。必要に応じて、直接App IDのリストを指定することも可能です。
  • aws_cloudwatch_event_target で、イベント発生時に呼び出すLambda関数を指定します。
  • aws_lambda_permission で、EventBridgeサービスがLambda関数を呼び出すことを許可します。

2. Lambdaの設定

Slackへの通知処理を行うLambda関数と、その実行に必要なIAMロール、そして関数コード(index.js)をデプロイするための設定です。

# Lambda関数コード(index.js)をzip化
data "archive_file" "lambda_zip" {
  type        = "zip"
  # Lambda関数コード(index.js)が配置されているパスを指定
  source_file = "${path.module\}/\.\./lambda/amplify\_build\_notification/index\.js"
output\_path \= "</span>{path.module}/../lambda/amplify_build_notification/index.zip"
}

# Lambda実行用のIAMロールを作成
resource "aws_iam_role" "lambda_exec_role" {
  name = "amplify-build-fail-lambda-role" # IAM Role名

  # Lambdaサービスがこのロールを引き受けることを許可
  assume_role_policy = jsonencode({
    Version   = "2012-10-17",
    Statement = [
      {
        Action    = "sts:AssumeRole",
        Effect    = "Allow",
        Principal = {
          Service = "lambda.amazonaws.com"
        }
      },
    ]
  })
}

# Lambda関数がCloudWatch Logsにログを書き込むための基本的な権限を付与
resource "aws_iam_role_policy_attachment" "lambda_logs" {
  role       = aws_iam_role.lambda_exec_role.name
  policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
}

# Lambda関数本体を定義
resource "aws_lambda_function" "slack_notifier" {
  filename         = data.archive_file.lambda_zip.output_path # zip化されたコードのパス
  function_name    = "amplify-build-fail-slack-notifier"      # Lambda関数名
  role             = aws_iam_role.lambda_exec_role.arn        # 上で作成したIAMロールのARN
  handler          = "index.handler"                          # 実行するハンドラ (index.jsのexports.handler)
  source_code_hash = data.archive_file.lambda_zip.output_base64sha256 # コードの変更を検知するため
  runtime          = "nodejs22.x"                             # Node.js ランタイムを使用

  # Lambda関数の環境変数を設定
  environment {
    variables = {
      # SlackのWebhook URLを環境変数として渡す
      SLACK_WEBHOOK_URL = var.slack_webhook_url
    }
  }

  # 必要に応じてタイムアウトやメモリを調整
  # timeout     = 30
  # memory_size = 128
}

# Slack Webhook URLを受け取るためのTerraform変数を定義 (variables.tfなどに記述)
variable "slack_webhook_url" {
  description = "Slack Incoming Webhook URL for Amplify build failure notification"
  type        = string
  # Webhook URLは機密情報のため、sensitive=trueを設定
  sensitive   = true
}
# Lambda関数コード(index.js)をzip化
data "archive_file" "lambda_zip" {
  type        = "zip"
  # Lambda関数コード(index.js)が配置されているパスを指定
  source_file = "${path.module}/index.js"
output\_path \= "${path.module}/index.zip"
}

# Lambda実行用のIAMロールを作成
resource "aws_iam_role" "lambda_exec_role" {
  name = "amplify-build-fail-lambda-role" # IAM Role名

  # Lambdaサービスがこのロールを引き受けることを許可
  assume_role_policy = jsonencode({
    Version   = "2012-10-17",
    Statement = [
      {
        Action    = "sts:AssumeRole",
        Effect    = "Allow",
        Principal = {
          Service = "lambda.amazonaws.com"
        }
      },
    ]
  })
}

# Lambda関数がCloudWatch Logsにログを書き込むための基本的な権限を付与
resource "aws_iam_role_policy_attachment" "lambda_logs" {
  role       = aws_iam_role.lambda_exec_role.name
  policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
}

# Lambda関数本体を定義
resource "aws_lambda_function" "slack_notifier" {
  filename         = data.archive_file.lambda_zip.output_path # zip化されたコードのパス
  function_name    = "amplify-build-fail-slack-notifier"      # Lambda関数名
  role             = aws_iam_role.lambda_exec_role.arn        # 上で作成したIAMロールのARN
  handler          = "index.handler"                          # 実行するハンドラ (index.jsのexports.handler)
  source_code_hash = data.archive_file.lambda_zip.output_base64sha256 # コードの変更を検知するため
  runtime          = "nodejs22.x"                             # Node.js ランタイムを使用

  # Lambda関数の環境変数を設定
  environment {
    variables = {
      # SlackのWebhook URLを環境変数として渡す
      SLACK_WEBHOOK_URL = var.slack_webhook_url
    }
  }

  # 必要に応じてタイムアウトやメモリを調整
  # timeout     = 30
  # memory_size = 128
}

# Slack Webhook URLを受け取るためのTerraform変数を定義 (variables.tfなどに記述)
variable "slack_webhook_url" {
  description = "Slack Incoming Webhook URL for Amplify build failure notification"
  type        = string
  # Webhook URLは機密情報のため、sensitive=trueを設定
  sensitive   = true
}
  • data "archive_file" で、指定したパスにある index.js をzipファイルにアーカイブします。
  • aws_iam_role でLambda関数が実行時に使用するIAMロールを作成します。assume_role_policy でLambdaサービスからの信頼関係を設定します。
  • aws_iam_role_policy_attachment で、Lambda関数がCloudWatch Logsにログを出力できるように、AWS管理ポリシー AWSLambdaBasicExecutionRole をアタッチします。
  • aws_lambda_function でLambda関数本体を定義します。
    • runtime でNode.jsのバージョンを指定します。
    • handler で、実行するファイル名(拡張子なし)とエクスポートされた関数名を指定します。
    • environment.variables ブロック内で、環境変数 SLACK_WEBHOOK_URL を設定します。 この値はTerraformの変数 var.slack_webhook_url から取得するようにしています。variables.tf などでこの変数を定義し、実際のURLは .tfvars ファイルや環境変数経由で安全に渡すようにします。

3. Lambda関数コード (index.js)

EventBridgeから受け取ったイベント情報を処理し、Slackに通知を送信するNode.jsコードです。

// 必要なモジュールをインポート
const https = require('https');
const { URL } = require('url');

// 環境変数からSlack Webhook URLを取得
const SLACK_WEBHOOK_URL = process.env.SLACK_WEBHOOK_URL;

// Lambda関数のメインハンドラ
exports.handler = async (event, context) => {
  // 受信したイベントをログに出力 (デバッグ用)
  console.log("Event received:", JSON.stringify(event, null, 2));

  // 環境変数SLACK_WEBHOOK_URLが設定されているか確認
  if (!SLACK_WEBHOOK_URL) {
    console.error("Slack Webhook URL is not set in environment variables.");
    // 設定されていない場合はエラーとして扱い、Lambdaの実行を失敗させる
    throw new Error("Internal server error: Slack URL not configured");
  }

  try {
    // イベントの詳細情報 (event.detail) を安全に取得
    const detail = event.detail ?? {};
    const appId = detail.appId ?? 'N/A'; // AmplifyアプリID
    const branchName = detail.branchName ?? 'N/A'; // ブランチ名
    const jobId = detail.jobId ?? 'N/A'; // ジョブID
    const jobStatus = detail.jobStatus ?? 'N/A'; // ジョブステータス ("FAILED"のはず)
    const awsRegion = event.region ?? 'N/A'; // イベントが発生したAWSリージョン

    // Amplifyコンソールの該当ビルドへのリンクを生成
    const consoleLink = `https://${awsRegion}.console.aws.amazon.com/amplify/home?region=${awsRegion}#/${appId}/branches/${branchName}/${jobId}`;

    // Slackに送信するメッセージをBlock Kit形式で作成
    const slackMessage = {
      blocks: [
        {
          type: "header",
          text: {
            type: "plain_text",
            text: ":x: Amplify Build Failed! :x:", // タイトル
            emoji: true
          }
        },
        {
          type: "section",
          fields: [ // ビルド情報をフィールド形式で表示
            { type: "mrkdwn", text: `*App ID:*\n${appId}` },
            { type: "mrkdwn", text: `*Branch:*\n${branchName}` },
            { type: "mrkdwn", text: `*Job ID:*\n${jobId}` },
            { type: "mrkdwn", text: `*Status:*\n\`${jobStatus}\`` }, // ステータスはコード形式で
            { type: "mrkdwn", text: `*Region:*\n${awsRegion}` }
          ]
        },
        {
          type: "section",
          text: {
            type: "mrkdwn",
            // Amplifyコンソールへのリンクボタン
            text: `<${consoleLink}|View Build in Amplify Console>`
          }
        },
        {
          type: "divider" // 区切り線
        },
        {
            type: "context", // 補足情報エリア
            elements: [
              {
                type: "mrkdwn",
                text: `Event Time: ${event.time ?? 'N/A'}` // イベント発生時刻
              }
            ]
        }
      ]
    };

    // Slackへメッセージを送信する非同期関数を呼び出す
    const response = await postToSlack(SLACK_WEBHOOK_URL, slackMessage);

    // Slack APIからのレスポンスをログに出力
    console.log("Message posted to Slack. Status code:", response.statusCode);
    console.log("Response data:", response.body);

    // Slackへの送信結果を確認
    if (response.statusCode === 200) {
      // 成功した場合
      return { statusCode: 200, body: "Message sent to Slack successfully." };
    } else {
      // 失敗した場合
      console.error(`Failed to send message to Slack. Status: ${response.statusCode}, Response: ${response.body}`);
      // エラーをthrowしてLambdaの実行を失敗させる (再試行やDLQ処理のため)
      throw new Error(`Error sending message to Slack: ${response.body}`);
    }

  } catch (error) {
    // tryブロック内でエラーが発生した場合
    console.error("Error processing event or sending notification:", error);
    // エラーをLambdaランタイムに伝え、実行を失敗させる
    throw error;
  }
};

// SlackにHTTPS POSTリクエストを送信する非同期関数
function postToSlack(webhookUrl, message) {
  return new Promise((resolve, reject) => {
    const messageData = JSON.stringify(message); // 送信するメッセージをJSON文字列化
    const parsedUrl = new URL(webhookUrl); // Webhook URLをパース

    // HTTPSリクエストのオプションを設定
    const options = {
      method: 'POST',
      hostname: parsedUrl.hostname,
      path: parsedUrl.pathname + (parsedUrl.search || ''), // パスとクエリパラメータ
      headers: {
        'Content-Type': 'application/json',
        'Content-Length': Buffer.byteLength(messageData) // 正確なバイト数を指定
      }
    };

    // HTTPSリクエストを作成
    const req = https.request(options, (res) => {
      let responseBody = '';
      // レスポンスデータを受信
      res.on('data', (chunk) => {
        responseBody += chunk;
      });
      // レスポンス受信完了時
      res.on('end', () => {
        // ステータスコードとレスポンスボディを含むオブジェクトでPromiseを解決
        resolve({ statusCode: res.statusCode, body: responseBody });
      });
    });

    // リクエストエラー発生時
    req.on('error', (error) => {
      console.error("Request error:", error);
      reject(error); // Promiseをリジェクト
    });

    // リクエストボディにメッセージデータを書き込む
    req.write(messageData);
    // リクエストを終了(送信実行)
    req.end();
  });
}
  • 環境変数 SLACK_WEBHOOK_URLprocess.env から取得します。設定されていない場合はエラーを throw します。
  • event オブジェクトから必要な情報 (appId, branchName, jobId, jobStatus, region, time) を安全に取り出します。
  • Slackのメッセージフォーマット機能である Block Kit を使用して、見やすく分かりやすい通知メッセージを組み立てています。ヘッダー、詳細情報フィールド、コンソールへのリンクボタン、区切り線、イベント発生時刻を含みます。
  • 標準モジュール https を使ってSlackのWebhook URLにPOSTリクエストを送信します。非同期処理を扱いやすくするため、Promise を返す postToSlack 関数を作成しています。
  • Slack APIからのレスポンスステータスコードを確認し、成功 (200) か失敗かで処理を分岐します。失敗時やコード実行中にエラーが発生した場合は throw error することで、Lambdaの再試行設定(デフォルトまたはカスタム)やDead Letter Queue (DLQ) によるエラーハンドリングに繋げることができます。

注意点

Slack Webhook URLの安全な管理

SlackのIncoming Webhook URLは、そのURLを知っていれば誰でもチャンネルにメッセージを投稿できてしまうため、取り扱いには十分注意が必要です。 TerraformコードやGitリポジトリに直接ハードコードすることは絶対に避けましょう。 今回の例のようにTerraform変数 (sensitive = true) を使用し、実際のURLは .tfvars ファイル (Git管理外にする)、CI/CDの環境変数AWS Secrets Manager、AWS Systems Manager Parameter Storeなどを利用して安全にLambda関数に渡すようにしてください。

最後に

今回は、AWS Amplifyのビルド失敗を迅速に検知するために、EventBridgeとLambda、そしてTerraformを活用してSlack通知システムを構築する方法をご紹介しました。

この仕組みを導入することで、開発チームはデプロイの失敗にすぐに気づけるようになり、問題解決までのリードタイム短縮に繋がりました。Amplify標準の通知機能では要件を満たせない場合に、このようなカスタム通知を検討する価値は十分にあると思います。

Terraformでインフラをコード化しているため、設定の再現性や変更管理も容易です。Amplifyをご利用中で、デプロイ失敗の検知に課題を感じている方は、ぜひ参考にしてみてください。

We’re Hiring!

燈では、インフラの保守管理の自動化が得意なエンジニアを募集しています!

もし興味がありましたら、まずはカジュアル面談から!↓ akariinc.co.jp