CloudWatchの監視結果をSlackに流す(AWS Lambdaバージョン)

こんにちは、AWS担当のwakです。

自分が書いた過去の記事を読み返していたところ、

tech.sanwasystem.com

AWS Lambdaが使えるようになった今となっては、こちらの記事の内容は既に用済みになっていることに気付きました。今回は表題通り、連携サーバーのかわりにAWS Lambdaを使ってCloudWatchの監視結果をSlackへ流すための方法について書くことにします。

f:id:nurenezumi:20151222162240j:plain

監視猫(watchcat)

AWS Lambdaについて

AWS Lambdaとは、JavaScript(Node.js)、PythonJavaで書いた任意のコードをAWS上で実行するための仕組みです。「AWS上で」とは、つまり「EC2インスタンスを含むサーバーなしで」を意味し、課金はコードを実行するために消費されたCPU時間に応じて行われます。具体的なユースケースは非常に多岐にわたりますが、今回のような監視・連携サーバーを置き換え、料金と管理のコストを抑えるのにもぴったりのサービスです。

仕組み

f:id:nurenezumi:20151222160543p:plain

前(連携サーバーを使うバージョン)

f:id:nurenezumi:20151222160609p:plain

後(Lambdaに切り替えたバージョン)

こうなります*1。以前と比べて連携サーバーを使う必要がなくなったため、

  • シンプルになった(SQSも消えた)
  • サーバー障害時のことを気にしなくてよくなった
  • 安くなった

といったメリットがあります。

手順

手順概要

  1. SlackでIncoming WebHooksの設定をする
  2. SNSのトピックを作る
  3. CloudWatchの設定を行い、エラー時にSNSへ通知が送られるようにする
  4. Lambdaでコードを書く
  5. SNSをトリガーとしてLambda functionを起動するように設定する
  6. テスト実行する

次に詳細を書いていきます。

1. SlackでIncoming WebHooksの設定をする

外部からSlackにメッセージを送信するための下準備をします。 Add Apps to Slack | Apps and Integrations | Slack App Directory にアクセスし、"Build Your Own"を選んで、Make a Custom Integration→Incoming Webhooksと進み、通知のために使うIncoming Webhooksを作成します(既存のものを使い回すならそれでも構いません)。作成すると、メッセージを送信するためのエンドポイントが表示されます。このURLを確認しておきます。

f:id:nurenezumi:20151221102606p:plain
Incoming WebHooksのテスト

Incoming WebHooksができたら送信テストをしましょう。Incoming WebHooksの編集画面を見ると、上のSetup Instructionsの部分にcurlでテストメッセージを送信するためのコマンドが表示されています。

f:id:nurenezumi:20151221103403p:plain

正しく設定ができていれば、これをそのまま実行するとメッセージが通知されるはずです。PowerShell版も用意しました。

$utf8 = New-Object System.Text.UTF8Encoding
$body = @{channel = "チャンネル名"; text = "メッセージ"} | ConvertTo-Json # 直接JSON形式の文字列で書いてもOK
Invoke-WebRequest https://hooks.slack.com/services/xxxxxxxxB/xxxxxxxxx/xxxxxxxxxxxxxxxxxxxxxxxx -Method POST -Body $utf8.GetBytes($bodyJson)

チャンネルのかわりに自分へのDMを送りたければ、チャンネル名には@usernameのように書いてください。

2. SNSのトピックを作る

CloudWatchがメッセージを送るためのトピックを作成します。後が簡単になるので、これは「エラーが発生したときのためのトピック」ということにします。*2

f:id:nurenezumi:20151221101311p:plain

このトピックはCloudWatchとLambda functionとの間をつなぐためのものなので、トピック側での設定は不要です。もちろん必要があればSQSやメールを流すようにしても構いません。

3. CloudWatchの設定を行い、エラー時にSNSへ通知が送られるようにする

適当にCloudWatchのメトリクスを作成し、何かの条件を満たしたら通知が送られるようにします。

f:id:nurenezumi:20151221104951p:plain

4. Lambdaでコードを書く

AWS Lambdaを開きます。「Create a Lambda function」ボタンで進み、blueprint画面は「Skip」ボタンでスキップして空のLambda function作成画面を表示します。名前と説明は適当に入力し、この記事の末尾に示すコードを入力します。

ロールの登録

AWS Lambdaを初めて使う場合、このfunctionを実行するためのロールが存在しないかもしれません。画面の指示に従って新規作成しておきます。いくつか選択肢がありますが、今回はAWSのリソースには触れないので、"Basic execution role"*3を選んでおけば十分です。

f:id:nurenezumi:20151221161853p:plain

5. SNSをトリガーとしてLambda functionを起動するように設定する

Lambda functionのEvent sourcesタブに移動し、Add event sourceをクリックすると、起動する契機となるイベントの種類を選択できます。SNSを選択し、先ほど作成したトピックを選べば完了です。

f:id:nurenezumi:20151221163125p:plain

6. テスト実行する

APIを叩いてCloudWatchのメトリクスをテスト実行するか、設定の方を変更して監視の条件を満たすようにします。するとほぼ即座にLambda functionが実行され、Slackに通知が飛んでくるはずです。

もしうまく行かない場合は、Lambda functionの実行ログがCloudWatchに残っているので確認してみてください。この「ログ」でLambda functionのログストリームをチェックすることができます。

f:id:nurenezumi:20151221163934p:plain

まとめ

Amazon LambdaはAWSがプッシュしているだけのことはあり、ずいぶんと機能が拡張されていました。非同期処理のおかげで多少戸惑ったりハマったりしましたが(下の注意書き参照)、一度動いてしまえばこっちのものです。今年の漢字は「安」だそうですが、これを使って安全・安定・安価な監視を実現して1年を締めくくろうと思いました。

コード

完全なコードは以下の通りです。

var https = require('https');

/*
 * Slackに通知を行う。
 * path: Slackのエンドポイント。 "/services/xxxxxxxxx/xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" の形式の文字列
 * message : 送信するメッセージ
 * context : Lambdaのコンテキスト
 * otherParameters : icon_emoji, usernameなどをキーに持った配列(省略可能)
 */
function sendToSlack(path, message, channel, context, otherParameters) {
    context = context ? context : {succeed: function(){}, fail: function(){}, done: function(){}};
    
    var options = {
        hostname: "hooks.slack.com",
        port: 443,
        path: path,
        method: 'POST'
    };

    var req = https.request(options, function(res) {
        if (res.statusCode == 200) {
            context.done();
        } else {
            var message = "通知に失敗しました. SlackからHTTP " + res.statusCode + " が返りました";
            console.error(message);
            context.fail(message);
        }
    });
    req.on('error', function(e) {
        var message = "通知に失敗しました. Slackから次のエラーが返りました: " + e.message;
        console.error(message);
        context.fail(message);
    });
    
    var parameters = otherParameters ? otherParameters : {};
    parameters.text = message;
    parameters.channel = channel;
    req.write(JSON.stringify(parameters));
    req.write("\n");
    req.end();
}

// エントリーポイント
exports.handler = function(event, context) {
    for(var i = 0;  i < event.Records.length; i++) {
        var record = event.Records[i];
        var sns = record.Sns;
        var message = JSON.parse(sns.Message);
        
        var text = [];
        text.push("次のエラーが発生しました:");
        text.push("名前 : " + message.AlarmName);
        text.push("内容 : " + message.AlarmDescription);
        text.push("理由: " + message.NewStateReason);
        
        //message, channel, context, otherParameters
        console.log(text.join("\n"));
        sendToSlack(
            "/services/xxxxxxxxx/xxxxxxxxx/xxxxxxxxxxxxxxxxxxxxxxxx",
            text.join("\n"),
            "CHANNELNAME",
            i == event.Records.length - 1 ? context : null,
            {"icon_emoji" : ":ghost:"}
        );
    }
}
  • 最初に実行されるのはexports.handler()です。*4
  • 引数のうち、eventは呼び出し元が作成するパラメーターのオブジェクトです。
    • 今回の設定ではSNSが作成するオブジェクトです。アラームの内容などはJSON形式のテキストで格納されているためにJSON.parse()を呼んでいます。
    • 手動実行する場合は自分で書いたJSONを渡せます。
  • contextはこの関数の結果をAWSに通知するためのコンテキストです。 公式ドキュメント
    • context.succeed(), context.fail()を呼ぶとそこで処理が終了します。
      • したがって、処理途中でこのいずれかを呼ぶと非同期のSlack API呼び出しが中断してしまいます。
    • context.succeed()を明示的に呼ばずに関数を抜けると失敗扱いになります。

おまけ: CloudWatchから渡される値

SNS+CloudWatchから起動されると、eventには次のような値が渡されます。

{
  Records: [{
    EventSource: 'aws:sns',
    EventVersion: '1.0',
    EventSubscriptionArn: '...',
    Sns: {
      Type: 'Notification',
      MessageId: '...',
      TopicArn: 'arn:aws:sns:ap-northeast-1:xxxxxxxxxxxx:YOUR-METRICS-NAME',
      Subject: '...',
      Message: '《JSON形式の文字列》',
      ...
    }
  }]
}

JSON形式の文字列」をJSONとしてパースするとこうなります。

{
  AlarmName : "メトリクスの名前(初回設定から変更不可能)",
  AlarmDescription : "メトリクスの説明(変更可)",
  AWSAccountId : "999999999999",
  NewStateValue : "OK","
  NewStateReason : "なぜstateが変わったかの理由",
  ...
}

確認した範囲ではRecordsが2件以上の要素を持った配列になることはありませんでした(が、対応は必要でしょう)。

*1:SNS Topicが複数あるのは、エラーのレベルによって通知先を変更したりすることができるようにするためです

*2:「エラーから復帰したときのトピック」も別に作り、今回と同様のことをすれば、エラーから復帰したときにもSlack通知を行うことができます。手順は全く同様なので今回は省略します

*3:CloudWatchに実行ログを登録する権限だけが付与されています

*4:デフォルトでそのような設定になっているため。変更することもできます