CloudWatch AlarmをSlackへ通知する（グラフ 画像 付き）

# はじめに

CloudWatchAlarm を Slack へ通知しているが、もっとわかりやすく出来ないかと思って探していると、
 [CloudWatchAlarmをグラフ付きでSlackに通知する](https://tech.fusic.co.jp/cloud/cloudwatch-snapshot-graph-to-slack/) という素晴らしい記事を見つけたので、試させてもらいました。

記事の内容と異なる点は、 S3 を使わず [Slack file.upload API](https://api.slack.com/methods/files.upload) を利用して、画像をアップロードするようにした点です。
Python の例は何個かあったのですが、node.js で Slack file.upload API を利用してグラフ画像をアップする例はなかったので、参考になればと思います。

ソースは以下です。

- https://github.com/katsuhiko/aws-cloudwatchalarm-to-slack

# SlackBot

Slack file.upload API を使う方法は何通りかあります。利用するトークンにも何種類かあります。
ここでは、 SlackApp を作り Bot とトークンを用意します。

## SlackApp の作成とトークン

https://api.slack.com/apps

上記URLにアクセスし「Create New App」ボタンをクリックして、SlackApp を作ります。

次に、左メニュー「Bot Users」をクリックして、Bot を作ります。
最後に、左メニュー「OAuth & Permissions」をクリックし、「Install App to Workspace」ボタンをクリックし Slack Workspace へ SlackApp をインストールします。

すると「OAuth & Permissions」に「Bot User OAuth Access Token」が表示されます。このトークンが後ほど作成する Lambda に記述するトークンです。


# Source

Lambda は [SAM](https://docs.aws.amazon.com/ja_jp/lambda/latest/dg/serverless_app.html) を使って、デプロイします。

各ファイルを説明します。

## index.js

Lambdaで実行するファイルです。

getMetricWidgetImage メソッドで取得したグラフの画像は `Buffer` として返却されますが、 Buffer のままではアップロードすることができませんでした。
そのため、画像ファイルとして出力後(`fs.writeFileSync`)、読み込むこと（`fs.createReadStream`）で `StreamReader` へ変換しています。
これでアップロードすることができました。

```js
'use strict'

const AWS = require('aws-sdk')
const axios = require('axios')
const FormData = require('form-data')
const fs = require('fs')
const uuidv4 = require('uuid/v4')

const SLACK_BOT_TOKEN = process.env.SLACK_BOT_TOKEN
const SLACK_CHANNEL = process.env.SLACK_CHANNEL

async function getMetricsGraphFromCloudWatch(message) {
  const props = {
    width: 480,
    height: 240,
    start: '-PT3H',
    end: 'PT0H',
    timezone: '+0900',
    view: 'timeSeries',
    stacked: false,
    metrics: [
      [
        message.Trigger.Namespace,
        message.Trigger.MetricName,
        message.Trigger.Dimensions[0].name,
        message.Trigger.Dimensions[0].value
      ]
    ],
    stat:
      message.Trigger.Statistic.charAt(0).toUpperCase() +
      message.Trigger.Statistic.slice(1).toLowerCase(),
    period: message.Trigger.Period,
    annotations: {
      horizontal: [
        {
          color: '#ff6961',
          label: 'Trouble threshold start',
          value: 0
        }
      ]
    }
  }
  const widgetDefinition = {
    MetricWidget: JSON.stringify(props)
  }

  const cloudwatch = new AWS.CloudWatch()
  try {
    const response = await cloudwatch
      .getMetricWidgetImage(widgetDefinition)
      .promise()
    return response.MetricWidgetImage
  } catch (err) {
    console.error(err)
  }
}

async function sendSlackBot(records) {
  for (const record of records) {
    const sns = record.Sns
    const message = JSON.parse(sns.Message)

    let emoji = ':kissing:'
    if (message.NewStateValue == 'ALARM') {
      emoji = ':scream:'
    } else if (message.NewStateValue == 'OK') {
      emoji = ':grinning:'
    }

    const image = await getMetricsGraphFromCloudWatch(message)

    // to stream from buffer
    const file = '/tmp/' + uuidv4() + '-alarm.png'
    fs.writeFileSync(file, image)
    const streamImage = fs.createReadStream(file)

    const formData = new FormData()
    formData.append('token', SLACK_BOT_TOKEN)
    formData.append('filename', message.AlarmName + '.png')
    formData.append('file', streamImage)
    formData.append('filetype', 'png')
    formData.append('initial_comment', emoji + ' ' + sns.Subject + ', ' + message.NewStateReason)
    formData.append('channels', SLACK_CHANNEL)
    formData.append('title', sns.Subject)

    const response = await axios.create({
      headers: form.getHeaders()
    }).post('https://slack.com/api/files.upload', formData)
    console.log(response.data)
  }
}

exports.handler = async (event, context) => {
  // output event
  console.log(JSON.stringify(event))

  const response = {
    statusCode: 200,
    body: JSON.stringify({
      message: "OK"
    })
  }

  try {
    await sendSlackBot(event.Records)
  } catch (err) {
    console.log(err)
    return err
  }

  return response
}
```


## package.json

node.js で Lambda を使う場合、aws-sdk はデフォルトで利用できますが、バージョンが古く 
`CloudWatch.getMetricWidgetImage` メソッドが利用できません。
そのため、別途インストールする必要があります。

```json
{
  "name": "aws-cloudwatchalarm-to-slack",
  "version": "0.0.1",
  "description": "Cloudwatch alarm to Slack.",
  "main": "index.js",
  "scripts": {},
  "dependencies": {
    "aws-sdk": "^2.334.0",
    "axios": "^0.18.0",
    "form-data": "^2.3.3",
    "uuid": "^3.3.2"
  },
  "devDependencies": {}
}
```

## template.yaml

SAM のテンプレートファイルです。

Paramters で指定することにより、deploy 時、コマンド引数で指定することができます。

Resources として、Lambda、SNS Topic、IAM Role を指定し作成します。
今回の IAM Role は、あらかじめ用意されている ManagedPolicy を利用しています。
ManagedPolicyArns に指定している内容は、AWS管理コンソール > IAM > ポシリーの一覧から該当ポリシーを選択し詳細を見ると確認することができます。


```yaml
AWSTemplateFormatVersion: '2010-09-09'
Transform: 'AWS::Serverless-2016-10-31'
Description: 'Cloudwatch alarm to Slack.'
Parameters:
  SnsTopicName:
    Type: String
    Default: 'xxx-alarm-topic'
  SlackBotToken:
    Type: String
    Default: 'xxx-999-999-xxx'
  SlackChannel:
    Type: String
    Default: '#notify_xxx_alarm'

Resources:
  NotifyFunction:
    Type: 'AWS::Serverless::Function'
    Properties:
      Handler: index.handler
      Runtime: nodejs8.10
      Role: !GetAtt NotifyRole.Arn
      CodeUri: .
      Description: 'Cloudwatch alarm to Slack.'
      MemorySize: 128
      Timeout: 300
      Environment:
        Variables:
          SLACK_BOT_TOKEN: !Ref SlackBotToken
          SLACK_CHANNEL: !Ref SlackChannel
      Events:
        SNS:
          Type: SNS
          Properties:
            Topic: !Ref NotifyTopic
  NotifyTopic:
    Type: 'AWS::SNS::Topic'
    Properties:
      TopicName: !Ref SnsTopicName
  NotifyRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Effect: Allow
            Principal:
              Service: lambda.amazonaws.com
            Action: 'sts:AssumeRole'
      ManagedPolicyArns:
        - 'arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole'
        - 'arn:aws:iam::aws:policy/AmazonSNSReadOnlyAccess'
        - 'arn:aws:iam::aws:policy/CloudWatchReadOnlyAccess'
```

# Install & Deploy

AWS CLI を使って deploy するので、事前に AWS CLI を [インストール](https://docs.aws.amazon.com/ja_jp/cli/latest/userguide/cli-chap-install.html) し、[設定（aws configure）](https://docs.aws.amazon.com/ja_jp/cli/latest/userguide/cli-chap-configure.html) を行ってください。

また、 npm のインストールを Docker を利用して行うので、こちらもインストールしてください([Install Docker Desktop for Mac](https://docs.docker.com/docker-for-mac/install/), [Install Docker Desktop for Windows](https://docs.docker.com/docker-for-windows/install/))。

コマンド実行時に変更が必要な引数のみを説明します。

## install

```shell
docker run -it -v $(pwd):/home/app -w /home/app node:8.12 npm install
```

## package

| Argument | Example | Description |
|---|---|---|
| --s3-bucket | xxx-bucket-sam | ソース等（アーティファクト）をアップロードするバケット |

```shell
aws cloudformation package \
--template-file template.yml \
--output-template-file serverless-output.yaml \
--s3-bucket xxx-bucket-sam
```

## deploy

| Argument | Example | Description |
|---|---|---|
| SnsTopicName  | xxx-alarm-topic   | アラームを通知する SNS トピック名 |
| SlackBotToken | xxx-999-999-xxx   | Slack Bot のトークン |
| SlackChannel  | #notify_xxx_alarm | アラームを通知する Slack のチャンネル |

```shell
aws cloudformation deploy \
--template-file serverless-output.yaml \
--stack-name cloudwatchalarm-to-slack \
--capabilities CAPABILITY_IAM \
--parameter-overrides "SnsTopicName=xxx-alarm-topic" "SlackBotToken=xxx-999-999-xxx" "SlackChannel=#notify_xxx_alarm"
```

# CloudWatch Alarm

AWS管理コンソールから CloudWatch Alarm を作成し、作成した SNS Topic名を指定すれば、Slack へアラームが画像付きで通知されます。

CloudWatch Alarm の設定は、[CloudWatch Alarmでメール通知する際に一緒にグラフ画像を添付してみた](https://dev.classmethod.jp/cloud/aws/cloudwatch-snapshot-graph/)の「CloudWatch Alarmの作成」が参考になります。


# さいごに

まだ、この設定を行ってアラームは発生していないので、まわりの反応を見ることはできませんが、テストでアラームを発生させた限りだと、画像があることでかなりわかりやすくなったと思います。

忘れたころに来るアラームにも慌てずに対処できるようになると良いな。