アクトインディ開発者ブログ

子供とお出かけ情報「いこーよ」を運営する、アクトインディ株式会社の開発者ブログです

CloudWatch alertをLambdaでSlackに通知する

morishitaです。 アクトインディではAWS上でサーバを運用しており、監視には CloudWatch も活用しています。

CloudWatchではAmazon SNSを通じて メールやSMS(Simple Message Service)にアラートの通知を 送信することができます。 それはそれで便利なのですが、やはりみんな大好きSlackにも通知したいところです。

標準ではAmazon SNSの通知先としてSlackは用意されていないのですが、Amazon Lambdaを利用すると可能になります。
今回はその方法について紹介したいと思います。

全体像はこんな感じです。 全体像

Slackの設定

Incoming Webhooksの設定をします。

通知したいworkspaceのCustom Integrations設定画面(/apps/manage/custom-integrations)より設定します。

既存の webhook を利用しても良いのですが、新たに追加したほうが他と干渉を気にせず作業できます。
また、webhookでは通知先のチャネルを選択しますが、Lambda関数が完成するまでは、他の人に見えないチャネルに設定したほうが良いです。大勢の人が参加するチャネルにテストで通知を流すと鬱陶しいですし、驚かせてしまうかもしれません。
作成したwebhookのURLは後で使うのでメモしておきます。

Amazon SNSの設定

次節で作成するLambda関数のトリガーとなるAmazon SNSのトピックを作っておきます。

AWSコンソールで「Simple Notification Service」を選択します。SNSに移動したら、「Create Topic」を選択して、Topic nameDisplay name を適当に入力してトピックを作成します。
ここで登録した Topic name はLambda関数作成時に使うので覚えておきます。

Lambda関数の作成

実はLambdaには関数の雛形が設計図1として多数用意されています。その中に、Slackへの通知もあるので、雛形として利用しない手はありません。以下の説明はこの雛形ベースで進めます。

AWSコンソールで Lambdaのページに移動したら、「関数の作成」をクリックします。
「設計図の選択」というステップに進むので、slack で検索します。検索結果の中からnodejs版の cloudwatch-alarm-to-slack を選択します。

続いて、「トリガーの設定」ステップに進みます。ここでは先に登録したSNSのトピックを選択します。
次のステップでLambda関数のコードを作成します。

Lambda関数のコード

cloudwatch-alarm-to-slackを選択しているので最初から次のコードが入力されています。 ここでランタイムを変更してしまうとコードが消えるので、Node 6.10にしたくなるところですが、Node 4.3のままにしておきます。 今回利用する機能の範囲ではどちらのバージョンでも大差ありません。

'use strict';

const AWS = require('aws-sdk');
const url = require('url');
const https = require('https');

// The base-64 encoded, encrypted key (CiphertextBlob) stored in the kmsEncryptedHookUrl environment variable
const kmsEncryptedHookUrl = process.env.kmsEncryptedHookUrl;
// The Slack channel to send a message to stored in the slackChannel environment variable
const slackChannel = process.env.slackChannel;
let hookUrl;

function postMessage(message, callback) {
    const body = JSON.stringify(message);
    const options = url.parse(hookUrl);
    options.method = 'POST';
    options.headers = {
        'Content-Type': 'application/json',
        'Content-Length': Buffer.byteLength(body),
    };
    const postReq = https.request(options, (res) => {
        const chunks = [];
        res.setEncoding('utf8');
        res.on('data', (chunk) => chunks.push(chunk));
        res.on('end', () => {
            if (callback) {
                callback({
                    body: chunks.join(''),
                    statusCode: res.statusCode,
                    statusMessage: res.statusMessage,
                });
            }
        });
        return res;
    });
    postReq.write(body);
    postReq.end();
}

function processEvent(event, callback) {
    const message = JSON.parse(event.Records[0].Sns.Message);
    const alarmName = message.AlarmName;
    //var oldState = message.OldStateValue;
    const newState = message.NewStateValue;
    const reason = message.NewStateReason;
    const slackMessage = {
        channel: slackChannel,
        text: `${alarmName} state is now ${newState}: ${reason}`,
    };

    postMessage(slackMessage, (response) => {
        if (response.statusCode < 400) {
            console.info('Message posted successfully');
            callback(null);
        } else if (response.statusCode < 500) {
            console.error(`Error posting message to Slack API: ${response.statusCode} - ${response.statusMessage}`);
            callback(null);  // Don't retry because the error is due to a problem with the request
        } else {
            // Let Lambda retry
            callback(`Server error when processing message: ${response.statusCode} - ${response.statusMessage}`);
        }
    });
}

exports.handler = (event, context, callback) => {
    if (hookUrl) {
        // Container reuse, simply process the event with the key in memory
        processEvent(event, callback);
    } else if (kmsEncryptedHookUrl && kmsEncryptedHookUrl !== '<kmsEncryptedHookUrl>') {
        const encryptedBuf = new Buffer(kmsEncryptedHookUrl, 'base64');
        const cipherText = { CiphertextBlob: encryptedBuf };

        const kms = new AWS.KMS();
        kms.decrypt(cipherText, (err, data) => {
            if (err) {
                console.log('Decrypt error:', err);
                return callback(err);
            }
            hookUrl = `https://${data.Plaintext.toString('ascii')}`;
            processEvent(event, callback);
        });
    } else {
        callback('Hook URL has not been set.');
    }
};

ざっくり、やっていることを説明すると、このLambda関数は次の3つのパートに分かれています。

  • exports.handler
  • processEvent
  • postMessage

exports.handlerはLambda関数の実行環境に直接呼び出される関数です。kmsEncryptedHookUrl を復号化して、processEvent を読んでいるだけなので、そのまま利用します。
postMessage processEvent は Slackに送るメッセージを作って、それをpostMessageに渡しています。 postMessageはSlackのwebhookにリクエストを送信するだけなので、これもそのまま利用します。

ということで、書き換える必要があるのはprocessEventのメッセージを作っているところです。 上記実装だと、アラートの名前と状態が変わったくらいしかわからないので、 もう少し、内容を増やしたいところです。 exports.handlerには3つの引数 (event, context, callback) が実行環境から渡されます。

このうち event は次のようなオブジェクトが渡されます。 この例は、CPUUtilizationが閾値を超えた場合のアラートの内容です。(テストのために閾値は低めに設定しています。)

{
    "Records": [
        {
            "EventSource": "aws:sns",
            "EventVersion": "1.0",
            "EventSubscriptionArn": "arn:aws:sns:ap-northeast-1:<略>",
            "Sns": {
                "Type": "Notification",
                "MessageId": "<略>",
                "TopicArn": "arn:aws:sns:ap-northeast-1:<略>",
                "Subject": "ALARM: \"cpuutil-alert\" in Asia Pacific - Tokyo",
                "Message": "<後述>",
                "Timestamp": "2017-09-11T10:16:26.865Z",
                "SignatureVersion": "1",
                "Signature": "<略>",
                "SigningCertUrl": "<略>",
                "UnsubscribeUrl": "<略>",
                "MessageAttributes": {}
            }
        }
    ]
}

ここで重要なのはevent.Records[0].Sns.Messageです。この中に文字列化されたJSON形式で、CloudWatchのアラートの情報が格納されています。 パースして見やすくすると次のような感じです。

{
 AlarmName: 'cloudwatch-alert-name',
 AlarmDescription: 'CPU負荷アラート',
 AWSAccountId: '<略>',
 NewStateValue: 'OK',
 NewStateReason: 'Threshold Crossed: 1 datapoint [37.4 (11/09/17 10:33:00)] was not greater than or equal to the threshold (20.0).',
 StateChangeTime: '2017-09-11T10:38:59.203+0000',
 Region: 'Asia Pacific - Tokyo',
 OldStateValue: 'ALARM',
 Trigger:
  { MetricName: 'CPUUtilization',
    Namespace: 'AWS/EC2',
    StatisticType: 'Statistic',
    Statistic: 'AVERAGE',
    Unit: null,
    Dimensions: [ [Object] ],
    Period: 300,
    EvaluationPeriods: 1,
    ComparisonOperator: 'GreaterThanOrEqualToThreshold',
    Threshold: 20,
    TreatMissingData: '',
    EvaluateLowSampleCountPercentile: '' }
}

この情報を利用してい次の様にメッセージを組み立てみました。

const message = JSON.parse(event.Records[0].Sns.Message);

const alarmName = message.AlarmName;
const alarmDesc = message.AlarmDescription;
const oldState = message.OldStateValue;
const newState = message.NewStateValue;
const reason = message.NewStateReason;
const threshold = message.Trigger.Threshold;

let color = 'warning';
let icon = ':warning:';
let userName = 'サーバの負荷が高まっています'
let text = `CPU利用率が${threshold}%を超えました`
if (newState == "OK") {
    color = "good";
    icon = ":oknya:";
    userName = 'サーバの負荷が下がってきました'
    text = `CPU利用率が${threshold}%を下回りました`
}

let slackMessage = {
    channel: slackChannel,
    username: `${userName}`,
    text: `*[${newState}] - ${text}*`,
    icon_emoji: icon
};
const slackAttachment = {
    color: color,
    icon: icon,
    text: `${alarmName}(${alarmDesc})\n詳しくは <[CloudWatchダッシュボードのURL]|ダッシュボード> で確認ください。`,
    footer: "sent by <[Lambda関数のコンソールURL]|Lambda Func (send-alert-to-slack)>"
}

slackMessage.attachments = [slackAttachment];

Slackの message-attachmentsを利用して、次のようにアラートの種類により色が変わるようにしています。

スラックへの通知

あまり、大きなメッセージを送ると邪魔なので、詳細はCloudWatchのダッシュボードへのURLを示してそちらで確認してもらうようにしています。 また、時間が経つと、「この通知どこで出してるんだったっけ?」となりがちなので、Lambda関数へのURLを付けています。

弊社ではこれを見ながら負荷が高そうなときには負荷がかかりそうな作業は待ってもらうというルールになっています。

暗号化ヘルパー

Lambdaでは実行時に環境変数を与えることができます。
同じコードでも環境変数で動作を変更したりできて便利なのですが、他のサービスのアカウント情報等のひみつ情報を設定すると、Lambdaの設定権限を持つユーザ全てにその値が見えてしまいます。それでは困るという場合には暗号化する暗号化ヘルパーを使うことができます。選択した雛形 cloudwatch-alarm-to-slack では kmsEncryptedHookUrlに暗号化したSlackのwebhook URLを設定するように実装されています。

暗号化ヘルパーを利用するには、コードの下にある、「暗号化ヘルパーを有効にする」にチェックを入れ、暗号化キーを選択します。デフォルトで登録されている「aws/lambda」というキーを利用してもいいですが、筆者の環境ではうまく動作しなかったので、別途登録しました。
暗号化キーは IAMのKMSで登録します。一点、ハマリポイントとしてはIAM自体はグローバルスコープなサービスですが、暗号化キーはリージョン毎に管理されます。なのでLambda関数を作成しているリージョンと同じリージョンでキーを登録しないと使えません。

リージョン選択

環境変数kmsEncryptedHookUrl には webhook URLのhooks.slack.com/services/〜を入力してください。スキーム(https://) は不要です。 入力したら、「暗号化」ボタンをクリックします。

Lambda 関数ハンドラおよびロールの設定

Lambda関数の設定として、次の設定を行います。

  • ハンドラ
  • ロール
  • ロール名
  • ポリシーテンプレート


ハンドラはデフォルトのままにします。
ロールは実行時のサービスロールです。「テンプレートから新しいロールを作成」を選択すると適切な権限を持つロールを作成してくれます。
ポリシーテンプレートは次の2つを選択します。

  • 基本的な エッジ Lambda のアクセス権限
  • KMS の復号化アクセス権限

詳細設定

詳細設定では次を設定します。

  • メモリ
  • タイムアウト
  • KMSキー
  • etc


タイムアウトは少し延ばしたほうが良いでしょう。
KMSキーでは暗号化に利用したキーを選択しないと復号に失敗します。
他の項目はデフォルトのままで良いかまいません。

CloudWatch Alertの設定

作成したLambda関数を動かすには先に作成したSNSをCloudwatch Alertの通知先に追加すれば、アラートの発報時にSlackにも通知されるようになります。
実装中は閾値を下げたり、上げたりしながらアラートを発報させ試すと良いと思います。ただし、大勢のメンバーがいるSlackチャネルに通知したり、他の通知手段も設定されているアラートでやると現場を騒然とさせてしまうので注意してください。

Lambdaは、このようにサービス間のグルーコードを作ったり、ちょっとした処理を自動化するのに便利です。AWSのリソースにも実行ロールに権限を与えれば容易にアクセスできますし、CloudWacthイベントと組み合わせれば定期実行も可能です。
しかも、これらがサーバレスで実現できるのが素晴らしい!

アクトインディでは、AWSやLambdaの可能性を追求したいエンジニアを募集中です。

脚注

  1. 英語ではBluePrint。下手に日本語にするよりBluePrintの方がわかりやすいと思う。