はじめに
CloudWatch に出力されたエラーログを Slack 通知したいと思います。
CloudWatch の通知と言えば、以下の様なアラームの状態変更を通知するパターンもあるかと思います。
この場合、パッと見、エラーの内容が分からず、コンソールなどで詳細を確認しに行く必要があるため少し面倒です。
今回は、エラーの内容を Slack に通知できる様にします。
完成イメージ
エラーログやステータス、発生日時や該当 API の情報を通知しています。
上部の api-error
は、CloudWatch ログストリームへのリンクとなっています。
構成
- CloudWatch
- ログの収集
- メトリクスフィルターで特定用語を含んだログの検知
- アラームで SNS トピックに通知を送信
- SNS
- Pub/Sub
- CloudWatch から受信
- Lambda へ送信
- Lambda
- SNS トリガーで実行
- 通知された情報を元に、CloudWatch のログ情報を取得する
- CloudWatch の通知は、あくまでエラーが発生したことを通知するものであり、詳細な内容は取得する必要がある
- ログデータから Slack 用メッセージを生成
- Slack へ投稿
- Systems Manager
- 機密情報管理
- Slack のトークンなど
全体の流れ
※ CloudWatch にログが出力されている前提で進めます
- Slack App の作成
- Systems Manager パラメータの作成
- SNS の作成
- CloudWatch メトリクスフィルターの作成
- CloudWatch アラームの作成
- Lambda の作成(TypeScript)
Slack App の作成
Slack Apps より App を作成します。今回は Slack Bot を使います。
「Create New App」ボタンから App の作成に進みます。「From scratch」を選択し、App の名前とインストールするワークスペースを選択し、作成。
App を作成後、「OAuth & Permissions」にて「Scopes」を設定します。
Bot Token Scopes に「chat:write」を追加します。
設定後、ワークスペースに App をインストールします。
「Basic Information」にて「Install to Workspace」ボタンから行います。
インストール後に、「Install your app」にチェックが付けば完了です。
※ 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 つの情報それぞれのパラメータを作成します。
以上で、「Systems Manager パラメータの作成」は完了です。
SNS の作成
トピックを作成します。
オプション項目はデフォルトのままです。
サブスクリプションは Lambda 側から設定するため、ここでは作成しません。
以上で、「SNS の作成」は完了です。
CloudWatch メトリクスフィルターの作成
特定の用語がログに出力された際に検知するフィルターを作成します。
対象のロググループを選択し、メトリクスフィルターを作成します。
フィルターパターン以外の項目はデフォルトのままです。
今回は、アプリケーションのエラーログに ERROR
の文字を含む想定のため、フィルターパターンを ERROR
にし、検知できる様にしています。
メトリクスの割り当てもよしなに設定します。
「Review and create」にて「メトリクスフィルターを作成」から作成します。
以上で、「CloudWatch メトリクスフィルターの作成」は完了です。
CloudWatch アラームの作成
メトリクスフィルターで対象のログを検知した後に、アラームで SNS に通知します。
「メトリクスの選択」にて「CloudWatch メトリクスフィルターの作成」で作成したフィルターを選択します。
トリガー条件をよしなに設定します。
通知先は「SNS の作成」で作成したトピックを選択します。
その他の項目はデフォルトのままです。
アラーム名を設定し、アラームを作成します。
以上で、「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;
最後に、上記で取得した情報を元にログを取得します。
startTime
と endTime
は 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/ssm
の getParameter
を使います。
これにより、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 を作成します。
実行ロールに 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 パラメータの作成」で作成したパラメータ名を設定します。これにより、実際の値を隠蔽することができます。
トリガーを設定します。
「SNS の作成」で作成したトピックを選択します。
以上で、全ステップ完了です。
エラーが発生した際、 Slack に通知されることを確認します。