CloudWatchアラームとグラフを一緒にSlack通知する

## 2019/7/25 追記
[AWS Chatbot](https://aws.amazon.com/jp/chatbot/)というサービスで簡単にCloudWatchアラームをSlackに通知できるようになりました。

個別に通知内容をカスタマイズしたい場合は変わらずLambda関数を作る必要がありますので
そのような場合に本記事の内容が参考になれば幸いです。

## はじめに
Amazon CloudWatch に、AWS コンソール以外でカスタムダッシュボードを作成できる機能が追加されました。
https://aws.amazon.com/jp/about-aws/whats-new/2018/09/amazon-cloudwatch-adds-ability-to-build-custom-dashboards-outside-the-aws-console/

実装例として、GetMetricWidgetImage APIとSESを使用してCloudWatchアラームと一緒に
グラフ画像をメール通知する方法が公式Blogで紹介されています。

**Reduce Time to Resolution with Amazon CloudWatch Snapshot Graphs and Alerts | Amazon Web Services**
https://aws.amazon.com/jp/blogs/devops/reduce-time-to-resolution-with-amazon-cloudwatch-snapshot-graphs-and-alerts/

Slackで画像も通知できたらな。。。と思い、上記を参考にして作ってみました。

## ざっくり構成
![image.png](https://qiita-image-store.s3.amazonaws.com/0/125105/9dfa0dbb-d6f9-a123-e702-29b510d34d8c.png)

Lambdaは python3.6 で実装していますが、基本的な構成はメール通知版とほとんど変わりません。
CloudWatchでアラームを検知したら、SNSに通知します。
Lambda関数がSNSトリガーで起動します。GetMetricWidgetImage APIから対象アラームの
グラフを取得し、Slack APIのfiles.uploadメソッドでアラーム情報とグラフをPostします。


## 結果イメージ
アラームが発生すると以下のように対象メトリクスのグラフがアップロードされます。
![image.png](https://qiita-image-store.s3.amazonaws.com/0/125105/ea589226-8ca2-dff1-ff1a-da5118b0e9dd.png)

注釈を挿入できるので、しきい値を超えた状態が簡単に確認できると思います。
Slack Botを使用してるため、後述のLambda関数の設定にはBotユーザのTokenと
通知先のチャンネルIDが必要です。
プライベートチャンネルの場合は、Botユーザをinviteしてください。

OK状態のアラームを定義していれば、しきい値を下回った場合も通知されます。
以下が GetMetricWidgetImage API で取得した画像です。
![image.png](https://qiita-image-store.s3.amazonaws.com/0/125105/d957d5be-7f6c-777f-d795-d3b4172813c9.png)


## Lambda関数
ひよコードかもしれませんが、ご容赦ください:hatched_chick:
環境変数 CHANNEL にチャンネルIDを、TOKENにBotユーザのTokenを設定する必要があります。

```lambda_function.py
import base64
import boto3
import json
import logging
import os
import requests

from botocore.exceptions import ClientError

logger = logging.getLogger()
logger.setLevel(logging.INFO)

def get_params(title, message):
    token = os.environ['TOKEN']
    channel = os.environ['CHANNEL']
    reason = message['NewStateReason']
    slack_params = {
        'token': token, 
        'channels': channel,
        'initial_comment': reason,
        'title': title
    }
    return slack_params

def get_properties(alarm_state):
    if alarm_state == "ALARM":
        properties = { 'color': '#ff6961', 'label': 'Trouble threshold start' }
    elif alarm_state == "OK":
        properties = { 'color': '#009933', 'label': 'Trouble threshold end' }
    return properties

def get_image(title, message):
    threshold = message['Trigger']['Threshold']
    metrics = [ message['Trigger']['Namespace'], message['Trigger']['MetricName'],
        message['Trigger']['Dimensions'][0]['name'], message['Trigger']['Dimensions'][0]['value'] ]
    alarm_state = message['NewStateValue']

    annotation_properties = get_properties(alarm_state)

    widget_definition = json.dumps(
        {
            "width": 600,
            "height": 400,
            "start": '-PT2H',
            "end": "PT0H",
            "timezone": '+0900',
            "view": "timeSeries",
            "stacked": False,
            "stat": "Average",
            "title": title,
            "metrics": [ metrics ],
            "period": 300,
            "annotations": {
                "horizontal": [
                {
                    "color": annotation_properties['color'],
                    "label": annotation_properties['label'],
                    "value": threshold
                }]
            }
        }
    )

    cloudwatch = boto3.client('cloudwatch')

    try:
        response = cloudwatch.get_metric_widget_image(
            MetricWidget = widget_definition
        )
    except ClientError as e:
        print(e.response['Error']['Message'])
    else:
        image = {'file': response['MetricWidgetImage']}
        print("Get Image succeeded:")
        return image
    
def upload_slack(upload_url, params, files):
    req = requests.post(upload_url, params=params, files=files)
    try:
        req.raise_for_status()
        logger.info("Message posted.")
        return req.text
    except requests.RequestException as e:
        logger.error("Request failed: %s", e)

def lambda_handler(event, context):
    title = event['Records'][0]['Sns']['Subject']
    message = json.loads(event['Records'][0]['Sns']['Message'])
    upload_url = 'https://slack.com/api/files.upload'
    params = get_params(title, message)
    files = get_image(title, message)
    response = upload_slack(upload_url, params, files)
    return response
```
widget_definition でAPIの入力パラメータを定義しています。
全体の構造については以下のドキュメントを参照してください。

**GetMetricWidgetImage: Metric Widget Structure and Syntax**
https://docs.aws.amazon.com/AmazonCloudWatch/latest/APIReference/CloudWatch-Metric-Widget-Structure.html


上記を元に boto3 の get_metric_widget_image メソッドでグラフを取得します。

**Boto 3 Documentation CloudWatch**
https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/cloudwatch.html#CloudWatch.Client.get_metric_widget_image

2018/9/26 時点で、東京リージョンのLambda実行環境に含まれるboto3では
get_metric_widget_image を使用できませんでした。いずれアップグレードされるとは思いますが、
現時点ではデプロイパッケージ(zip)に最新版のboto3を含めるようにしてください。
また通信ライブラリにrequestsを使用しているのでこちらもデプロイパッケージに含めます。

## 設定補足
### SNS
空のTopicを作成しておきます。
### Lambda
トリガーにSNSを追加します。

![image.png](https://qiita-image-store.s3.amazonaws.com/0/125105/bebd913d-38e4-fb9d-c888-68894641399e.png)

トリガーの設定で対象のSNS Topicを指定します。

![image.png](https://qiita-image-store.s3.amazonaws.com/0/125105/f0eb46a2-5bb1-7da5-a099-bd0dca0838c6.png)

繰り返しになりますが、環境変数にCHANNEL(通知対象チャンネルID)と
TOKEN(BotユーザのToken)を指定します。

![image.png](https://qiita-image-store.s3.amazonaws.com/0/125105/094abdf5-4754-406d-d6be-5e6d99df562d.png)

実行ロールはメトリクスデータを取得できるようポリシーを設定してください。
タイムアウトはデフォルトの3秒ではタイムアウトする可能性があるので5秒にしておきました。

![image.png](https://qiita-image-store.s3.amazonaws.com/0/125105/96adacc1-b9f7-885c-9690-77996edf870c.png)

### CloudWatch
対象のCloudWatchアラームの通知アクションに作成したSNS Topic を指定します。
![image.png](https://qiita-image-store.s3.amazonaws.com/0/125105/1efdae6b-a5ac-6e49-6181-c5c952cf4ae8.png)

## 参考URL
https://github.com/aws-samples/aws-cloudwatch-snapshot-graphs-alert-context
https://dev.classmethod.jp/cloud/aws/lambda-python-tips-all-events-are-not-dict/

以上です。
参考になれば幸いです。