LoginSignup
3
1

CloudWatch のエラーログを Slack 通知

Posted at

はじめに

CloudWatch に出力されたエラーログを Slack 通知したいと思います。
CloudWatch の通知と言えば、以下の様なアラームの状態変更を通知するパターンもあるかと思います。

2023-12-06_8.20.36.png

この場合、パッと見、エラーの内容が分からず、コンソールなどで詳細を確認しに行く必要があるため少し面倒です。

今回は、エラーの内容を Slack に通知できる様にします。

完成イメージ

2023-12-05_20.47.56.png

エラーログやステータス、発生日時や該当 API の情報を通知しています。
上部の api-error は、CloudWatch ログストリームへのリンクとなっています。

構成

architecture.drawio.png

  • CloudWatch
    • ログの収集
    • メトリクスフィルターで特定用語を含んだログの検知
    • アラームで SNS トピックに通知を送信
  • SNS
    • Pub/Sub
    • CloudWatch から受信
    • Lambda へ送信
  • Lambda
    • SNS トリガーで実行
    • 通知された情報を元に、CloudWatch のログ情報を取得する
      • CloudWatch の通知は、あくまでエラーが発生したことを通知するものであり、詳細な内容は取得する必要がある
    • ログデータから Slack 用メッセージを生成
    • Slack へ投稿
  • Systems Manager
    • 機密情報管理
    • Slack のトークンなど

全体の流れ

※ CloudWatch にログが出力されている前提で進めます

  1. Slack App の作成
  2. Systems Manager パラメータの作成
  3. SNS の作成
  4. CloudWatch メトリクスフィルターの作成
  5. CloudWatch アラームの作成
  6. Lambda の作成(TypeScript)

Slack App の作成

Slack Apps より App を作成します。今回は Slack Bot を使います。
「Create New App」ボタンから App の作成に進みます。「From scratch」を選択し、App の名前とインストールするワークスペースを選択し、作成。

2023-12-06_9.25.25.png

2023-12-06_9.28.20.png

App を作成後、「OAuth & Permissions」にて「Scopes」を設定します。
Bot Token Scopes に「chat:write」を追加します。

2023-12-06_9.33.31.png

設定後、ワークスペースに App をインストールします。
「Basic Information」にて「Install to Workspace」ボタンから行います。
インストール後に、「Install your app」にチェックが付けば完了です。

2023-12-06_9.45.19.png

※ Slack 側では、通知先のチャンネルに App を追加しておきます。

最後に、Lambda から Slack を操作するための情報(チャンネル ID、Signing Secret、Bot User OAuth Token の 3 つ)を取得します。
チャンネル ID は、Slack 側でチャンネルを右クリック > コピー > リンクをコピーから取得できます。リンクの末尾が ID です。→ https://xxxxx.slack.com/archives/<ID>
Signing Secret は、App 画面の「Basic Information」にて「App Credentials」から取得できます。
Bot User OAuth Token は、App 画面の「OAuth & Permissions」にて「OAuth Tokens for Your Workspace」から取得できます。

Slack を操作するための情報は、次の「Systems Manager パラメータの作成」で使いますので、メモしておきます。

以上で、「Slack App の作成」は完了です。

Systems Manager パラメータの作成

Slack を操作するための機密情報を管理します。ここで管理する情報を Lambda から参照し、Slack を操作します。

メモしておいた 3 つの情報それぞれのパラメータを作成します。

2023-12-06_10.15.42.png

以上で、「Systems Manager パラメータの作成」は完了です。

SNS の作成

トピックを作成します。
オプション項目はデフォルトのままです。

2023-12-06_10.21.51.png

サブスクリプションは Lambda 側から設定するため、ここでは作成しません。

以上で、「SNS の作成」は完了です。

CloudWatch メトリクスフィルターの作成

特定の用語がログに出力された際に検知するフィルターを作成します。
対象のロググループを選択し、メトリクスフィルターを作成します。
フィルターパターン以外の項目はデフォルトのままです。

2023-12-06_10.28.13.png

今回は、アプリケーションのエラーログに ERROR の文字を含む想定のため、フィルターパターンを ERROR にし、検知できる様にしています。

メトリクスの割り当てもよしなに設定します。

2023-12-06_10.35.30.png

「Review and create」にて「メトリクスフィルターを作成」から作成します。

以上で、「CloudWatch メトリクスフィルターの作成」は完了です。

CloudWatch アラームの作成

メトリクスフィルターで対象のログを検知した後に、アラームで SNS に通知します。
「メトリクスの選択」にて「CloudWatch メトリクスフィルターの作成」で作成したフィルターを選択します。

トリガー条件をよしなに設定します。

2023-12-06_10.45.12.png

通知先は「SNS の作成」で作成したトピックを選択します。
その他の項目はデフォルトのままです。

2023-12-06_10.47.56.png

アラーム名を設定し、アラームを作成します。
以上で、「CloudWatch アラームの作成」は完了です。

Lambda の作成

TypeScript(Docker)ベースの関数を作成します。

ソースコード(全体)

import { Handler, SNSEvent } from "aws-lambda";
import { getParameter } from "@aws-lambda-powertools/parameters/ssm";
import {
  CloudWatchLogsClient,
  DescribeMetricFiltersCommand,
  FilterLogEventsCommand,
  FilteredLogEvent,
  DescribeLogStreamsCommand,
} from "@aws-sdk/client-cloudwatch-logs";
import { App } from "@slack/bolt";

const AWS_REGION = process.env.AWS_REGION;

type SNSEventMessage = {
  AlarmName: string;
  StateChangeTime: string;
  Trigger: {
    MetricName: string;
    Namespace: string;
  };
};

type Log = {
  alarmName: string;
  filterPattern: string;
  logGroupName: string;
  logStreamName: string;
  events: FilteredLogEvent[];
};

type Slack = {
  token: string;
  channel: string;
  signingSecret: string;
};

export const handler: Handler = async (event: SNSEvent) => {
  console.info("EVENT: \n" + JSON.stringify(event, null, 2));

  if (!event.Records.length) {
    return {
      statusCode: 200,
      body: JSON.stringify({
        message: "No event records",
      }),
    };
  }

  const snsMessage: SNSEventMessage = JSON.parse(event.Records[0].Sns.Message);

  const log = await describeLogs(snsMessage);
  if (!log || !log.events.length) {
    return {
      statusCode: 200,
      body: JSON.stringify({
        message: "No logs",
      }),
    };
  }

  const result = await sendToSlack(log);
  console.info("RESULT: \n" + JSON.stringify(result, null, 2));

  return {
    statusCode: 200,
    body: JSON.stringify({
      message: "Success",
    }),
  };
};

const describeLogs = async (message: SNSEventMessage): Promise<Log | null> => {
  const c = new CloudWatchLogsClient({ region: AWS_REGION });

  const metricFiltersOutput = await c.send(
    new DescribeMetricFiltersCommand({
      metricName: message.Trigger.MetricName,
      metricNamespace: message.Trigger.Namespace,
    })
  );
  const logGroupName = metricFiltersOutput.metricFilters?.[0].logGroupName;
  const filterPattern = metricFiltersOutput.metricFilters?.[0].filterPattern;
  if (!logGroupName || !filterPattern) {
    return null;
  }

  const logStreamOutput = await c.send(
    new DescribeLogStreamsCommand({
      logGroupName,
      descending: true,
      orderBy: "LastEventTime",
      limit: 1,
    })
  );
  const logStreamName = logStreamOutput.logStreams?.[0].logStreamName;
  if (!logStreamName) {
    return null;
  }

  const to = new Date(message.StateChangeTime);
  to.setMinutes(to.getMinutes() + 1);
  const from = new Date(to);
  from.setMinutes(from.getMinutes() - 5);

  const log = await c.send(
    new FilterLogEventsCommand({
      logGroupName,
      filterPattern,
      logStreamNames: [logStreamName],
      startTime: from.valueOf(),
      endTime: to.valueOf(),
      limit: 10,
    })
  );

  return {
    alarmName: message.AlarmName,
    filterPattern,
    logGroupName,
    logStreamName,
    events: log.events || [],
  };
};

const getSlackParameter = async (): Promise<Slack> => {
  return {
    token: (await getParameter(process.env.SLACK_TOKEN || "", {
      decrypt: true,
    })) as string,
    channel: (await getParameter(process.env.SLACK_CHANNEL || "", {
      decrypt: true,
    })) as string,
    signingSecret: (await getParameter(process.env.SLACK_SIGNING_SECRET || "", {
      decrypt: true,
    })) as string,
  };
};

const sendToSlack = async (log: Log) => {
  try {
    const parameter = await getSlackParameter();
    if (!parameter.channel || !parameter.token || !parameter.signingSecret) {
      return;
    }

    const app = new App({
      token: parameter.token,
      signingSecret: parameter.signingSecret,
    });

    return await Promise.all(
      log.events.map((event) => {
        //////////////////////////////////////////
        // get value from log message. This is a sample.
        const code = 500;
        const timestamp = new Date().toLocaleString();
        const api = "GET /users";
        const errorLog = event.message;
        //////////////////////////////////////////

        return app.client.chat.postMessage({
          channel: parameter.channel,
          attachments: [
            {
              mrkdwn_in: ["text"],
              color: code >= 500 ? "danger" : "warning",
              title: log.alarmName,
              title_link: <TITLE_LINK>,
              text: errorLog,
              fallback: errorLog,
              fields: [
                {
                  title: "Timestamp",
                  value: timestamp,
                  short: true,
                },
                {
                  title: "ErrorCode",
                  value: code.toString(),
                  short: true,
                },
                {
                  title: "API",
                  value: api,
                  short: true,
                },
                {
                  title: "Assignee",
                  value: "<!channel>",
                  short: true,
                },
              ],
            },
          ],
        });
      })
    );
  } catch (error) {
    console.error(error);
  }
};

以降、ソースコードの解説になります。

ログ取得

CloudWatch から通知された情報を元に、ログの詳細を取得します。
まずは、メトリクスフィルターの情報を取得し、エラーを検知したロググループとヒットしたフィルターパターンを特定します。
フィルターパターンは「CloudWatch メトリクスフィルターの作成」で作成した値になるはずです。

const metricFiltersOutput = await c.send(
  new DescribeMetricFiltersCommand({
    metricName: message.Trigger.MetricName,
    metricNamespace: message.Trigger.Namespace,
  })
);
const logGroupName = metricFiltersOutput.metricFilters?.[0].logGroupName;
const filterPattern = metricFiltersOutput.metricFilters?.[0].filterPattern;

次に、上記で取得したロググループを元に対象のログストリームを取得します。
エラーが発生した直後に処理されるものと仮定し、最新のログストリームを取得しています。

const logStreamOutput = await c.send(
  new DescribeLogStreamsCommand({
    logGroupName,
    descending: true,
    orderBy: "LastEventTime",
    limit: 1,
  })
);
const logStreamName = logStreamOutput.logStreams?.[0].logStreamName;

最後に、上記で取得した情報を元にログを取得します。
startTimeendTime は unix 時刻を設定する必要があります。ログの抽出対象時刻は、アラーム発生時刻の 1 分後を終了時刻とし、終了時刻の 5 分前までを対象としています。

const to = new Date(message.StateChangeTime);
to.setMinutes(to.getMinutes() + 1);
const from = new Date(to);
from.setMinutes(from.getMinutes() - 5);

const log = await c.send(
  new FilterLogEventsCommand({
    logGroupName,
    filterPattern,
    logStreamNames: [logStreamName],
    startTime: from.valueOf(),
    endTime: to.valueOf(),
    limit: 10,
  })
);

レスポンスの events フィールドにログの情報があります。フィルターパターンにヒットしたログが返却されます。

Slack 通知

ログデータから Slack 用メッセージを生成して、Slack に投稿します。
まずは、Slack を操作するための情報を取得します。「Systems Manager パラメータの作成」で作成したパラメータを取得するコードです。
process.env.<XXXXX> の値は Systems Manager のパラメータ名に該当します。

const getSlackParameter = async (): Promise<Slack> => {
  return {
    token: (await getParameter(process.env.SLACK_TOKEN || "", {
      decrypt: true,
    })) as string,
    channel: (await getParameter(process.env.SLACK_CHANNEL || "", {
      decrypt: true,
    })) as string,
    signingSecret: (await getParameter(process.env.SLACK_SIGNING_SECRET || "", {
      decrypt: true,
    })) as string,
  };
};

値の取得には @aws-lambda-powertools/parameters/ssmgetParameter を使います。
これにより、Lambda の環境変数に直接値を設定するのではなく、Systems Manager を参照する形にできます。

次に、ログデータから Slack 用メッセージを生成し、送信します。
sample の部分をアプリケーションログの出力フォーマットに合わせて置き換えます。
今回は、ログの数分ループをし、取得した`全てのログを Slack に投稿していますが、内容によってまとめてしまうこともありかなと思います。

const app = new App({
  token: parameter.token,
  signingSecret: parameter.signingSecret,
});

return await Promise.all(
  log.events.map((event) => {
    //////////////////////////////////////////
    // This is a sample. Get value from log message
    const code = 500;
    const timestamp = new Date().toLocaleString();
    const api = "GET /users";
    const errorLog = event.message;
    //////////////////////////////////////////

    return app.client.chat.postMessage({
      channel: parameter.channel,
      attachments: [
        {
          mrkdwn_in: ["text"],
          color: code >= 500 ? "danger" : "warning",
          title: log.alarmName,
          title_link: <TITLE_LINK>,
          text: errorLog,
          fallback: errorLog,
          fields: [
            {
              title: "Timestamp",
              value: timestamp,
              short: true,
            },
            {
              title: "ErrorCode",
              value: code.toString(),
              short: true,
            },
            {
              title: "API",
              value: api,
              short: true,
            },
            {
              title: "Assignee",
              value: "<!channel>",
              short: true,
            },
          ],
        },
      ],
    });
  })
);

<TITLE_LINK> の部分を CloudWatch ログストリームのリンクにする場合、↓ こちらの記事が参考になると思います。
Amazon CloudWatch Logs における任意のログストリームページの URL を動的に生成する

Dockerfile

公式が用意しているイメージを使います。

FROM public.ecr.aws/lambda/nodejs:18 as builder

WORKDIR /usr/app
COPY package.json index.ts  ./
RUN npm install
RUN npm run build

FROM public.ecr.aws/lambda/nodejs:18

WORKDIR ${LAMBDA_TASK_ROOT}
COPY --from=builder /usr/app/dist/* ./
CMD ["index.handler"]

Lambda の設定

Lambda を作成します。

2023-12-06_13.58.47.png

実行ロールに CloudWatch と Systems Manager を参照するためのポリシーを追加します。

CloudWatch

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "1",
      "Effect": "Allow",
      "Action": [
        "logs:DescribeLogStreams",
        "logs:DescribeMetricFilters",
        "logs:FilterLogEvents"
      ],
      "Resource": [
        "xxxxx"
      ]
    }
  ]
}

Systems Manager

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "2",
      "Effect": "Allow",
      "Action": [
        "ssm:GetParameter",
        "ssm:GetParametersByPath"
      ],
      "Resource": [
        "xxxxx"
      ]
    }
  ]
}

環境変数を設定します。
値には、「Systems Manager パラメータの作成」で作成したパラメータ名を設定します。これにより、実際の値を隠蔽することができます。

2023-12-06_14.14.04.png

トリガーを設定します。
「SNS の作成」で作成したトピックを選択します。

2023-12-06_14.26.55.png

以上で、全ステップ完了です。
エラーが発生した際、 Slack に通知されることを確認します。

Ref

3
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
1