AWSのメンテナンス情報をSlackに通知する

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

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


## はじめに
EC2の予定されたイベントなどはルートアカウントのメールアドレス宛に通知されますが、
これだけでは関係者で共有したい場合は不便な場面もあります。

CloudWatch Events では AWS Health イベント を イベントパターンに指定できるため、
アクションでLambda関数の実行を指定し、Slackへの通知を行ってみたいと思います。

**Amazon CloudWatch Events での AWS Health イベントのモニタリング**
https://docs.aws.amazon.com/ja_jp/health/latest/ug/cloudwatch-events-health.html

## Incoming Webhookの設定(Slack)
まず Slack 側の設定を済ませておきます。
https://\<your-team-domain\>.slack.com/apps/manage/custom-integrations
から Incoming WebHooks の Configuration を追加します。

通知先のチャネル名を選択し、Add します。

![image.png](https://qiita-image-store.s3.amazonaws.com/0/125105/49406b20-40d6-2573-9cf5-53a572544c54.png)

追加後、発行される Webhook URL を控えておきます。

## Lambda関数
ひよコードですが、ご容赦ください:hatched_chick:
環境変数で WEBHOOK_URL を設定します。
Description に関しては長すぎる場合があるので、以下では512文字までを表示するようにしています。

```LambdaFunction.py
import json
import os
import logging

from urllib.request import Request, urlopen
from urllib.error import URLError, HTTPError

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

def lambda_handler(event, context):
    webhook_url = os.environ['WEBHOOK_URL']
    message = 'AWS Healthイベントを検知しました。対象のリソースを確認してください。' 

    slack_message = {
        'username': 'AWS Health Event Notification',
        'icon_emoji': ':warning:',
        'text': message,
        'attachments': [
            {
                'fallback': 'AWS Health Event Description.',
                'color': 'warning',
                'title': event['detail']['eventTypeCode'],
                'title_link': 'https://phd.aws.amazon.com/phd/home',
                'fields': [
                    {
                        'title': 'Account ID',
                        'value': event['account'],
                        'short': True
                    },
                    {
                        'title': 'Region',
                        'value': event['region'],
                        'short': True
                    },
                    {
                        'title': 'Service',
                        'value': event['detail']['service'],
                        'short': True
                    },
                    {
                        'title': 'Start Time',
                        'value': event['detail']['startTime'],
                        'short': True
                    },
                    {
                        'title': 'Description',
                        'value': event['detail']['eventDescription'][0]['latestDescription'][0:511],
                        'short': False
                    }
                ]
            }
        ]
    }

    req = Request(webhook_url, json.dumps(slack_message).encode('utf-8'))
    try:
        response = urlopen(req)
        response.read()
        logger.info("Message posted.")
    except HTTPError as e:
        logger.error("Request failed: %d %s", e.code, e.reason)
    except URLError as e:
        logger.error("Server connection failed: %s", e.reason)

```

## CloudWatch Events の設定
イベントソースの設定で Health を設定します。
イベントタイプを **特定のヘルスイベント** に設定すると任意のサービスやイベントに絞って通知を行えますが、
ここでは **すべてのイベント** を設定します。

ターゲットには上記で作成した Lambda 関数を指定します。
入力の設定は、発生したイベント全体を関数に渡したいため、デフォルトの **一致したイベント** で OK です。

![image.png](https://qiita-image-store.s3.amazonaws.com/0/125105/4c452d1b-f345-f1bf-4888-d062406698dc.png)

ルール名を入力し、ルールの作成を完了します。

![image.png](https://qiita-image-store.s3.amazonaws.com/0/125105/89d64313-9248-21ca-e733-e96aa4493321.png)

対象の Lambda 関数が自動的に更新され、トリガーに CloudWatch Events が設定されます。

![image.png](https://qiita-image-store.s3.amazonaws.com/0/125105/a7ffa22d-4dc7-3c5c-8a14-949a26a57bd7.png)


## 結果サンプル
![image.png](https://qiita-image-store.s3.amazonaws.com/0/125105/1be18e25-334d-b48c-21b9-09d8d5f2b961.png)


## 注意点など
CloudWatch Events で検知できるのは EBS ボリュームの消失イベント、
すべての予定された変更イベントなど自アカウントに関連するもののみです。
サービスの障害情報など AWS アカウントに固有でないものは対象外です。

### 2018/7/23 追記
面倒ですが、通知が必要なリージョン毎に CloudWatch Events と Lambda を設定する必要があります。
通常、イベントパターンに設定するサービスがグローバルサービスである場合は、エンドポイントが
us-east-1 米国東部（バージニア北部）にのみ存在するため、CloudWatch Events も us-east-1 に設定します。
AWS Health もグローバルサービスなのですが、各リージョン毎にイベントが発生するため
今回の設定もリージョン毎に行う必要があります。（サポート確認済）

毎回手作業で設定するのは厳しいので、CloudFormation StackSets で一括設定できるよう
テンプレートを作成できればと考えています。

## 8/9 追記 テンプレート書いた
```yaml
AWSTemplateFormatVersion: '2010-09-09'
Description: AWS Health Events to Slack

Resources:
# Create IAM Role
  LambdaExecutionRole:
    Type: "AWS::IAM::Role"
    Properties:
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: Allow
            Principal:
              Service: lambda.amazonaws.com
            Action:
              - sts:AssumeRole
      Path: "/"
      Policies:
        - PolicyName: LambdaBasicExecution
          PolicyDocument:
            Version: '2012-10-17'
            Statement:
              - Effect: Allow
                Action:
                  - logs:CreateLogGroup
                  - logs:CreateLogStream
                  - logs:PutLogEvents
                Resource: arn:aws:logs:*:*:*

# Create Lambda Function
  LambdaFunction: 
    Type: "AWS::Lambda::Function"
    Properties: 
      Handler: "index.lambda_handler"
      Role: !GetAtt LambdaExecutionRole.Arn
      Code: 
        ZipFile: !Sub |
          import json
          import os
          import logging
          from urllib.request import Request, urlopen
          from urllib.error import URLError, HTTPError

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

          def lambda_handler(event, context):
            webhook_url = os.environ['WEBHOOK_URL']
            message = 'AWS Healthイベントを検知しました。対象のリソースを確認してください。' 
            slack_message = {
              'username': 'AWS Health Event Notification',
              'icon_emoji': ':rain_cloud:',
              'text': message,
              'attachments': [
                {
                  'fallback': 'AWS Health Event Description.',
                  'color': 'warning',
                  'title': event['detail']['eventTypeCode'],
                  'title_link': 'https://phd.aws.amazon.com/phd/home',
                  'fields': [
                    {
                      'title': 'Account ID',
                      'value': event['account'],
                      'short': True
                    },
                    {
                      'title': 'Region',
                      'value': event['region'],
                      'short': True
                    },
                    {
                      'title': 'Service',
                      'value': event['detail']['service'],
                      'short': True
                    },
                    {
                      'title': 'Start Time',
                      'value': event['detail']['startTime'],
                      'short': True
                    },
                    {
                      'title': 'Description',
                      'value': event['detail']['eventDescription'][0]['latestDescription'][0:511],
                      'short': False
                    }
                  ]
                }
              ]
            }
            
            req = Request(webhook_url, json.dumps(slack_message).encode('utf-8'))
            try:
              response = urlopen(req)
              response.read()
              logger.info("Message posted.")
            except HTTPError as e:
              logger.error("Request failed: %d %s", e.code, e.reason)
            except URLError as e:
              logger.error("Server connection failed: %s", e.reason)
      Runtime: "python3.6"
      MemorySize: "128"
      Timeout: "5"
      Environment:
        Variables:
          WEBHOOK_URL: "https://hooks.slack.com~"   

# Create CloudWatch Events Rule
  EventRule:
    Type: "AWS::Events::Rule"
    Properties: 
      Description: "メンテナンス情報をSlackに通知する"
      EventPattern:
        source: 
          - aws.health
      Name: AWSHealth-to-Slack
      State: "ENABLED"
      Targets:
        - 
          Arn: !GetAtt LambdaFunction.Arn
          Id: "TargetFunctionV1"

# Create Invoke Lambda Permission
  PermissionForEventsToInvokeLambda: 
    Type: "AWS::Lambda::Permission"
    Properties: 
      FunctionName: !Ref LambdaFunction
      Action: "lambda:InvokeFunction"
      Principal: "events.amazonaws.com"
      SourceArn: !GetAtt EventRule.Arn
```

WEBHOOK_URL は書き換えてください。
テンプレートの見通しが悪くなってしまいますが、
以下の理由により、直接Lambdaのコードを埋め込んでいます。

* SAM が使えない(Transformを使用しているテンプレートに対応していない)
    * https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/stacksets-limitations.html
* Code で S3 上のファイルを指定する場合は、作成するLambda関数と同一リージョンでなければならない
    * https://docs.aws.amazon.com/ja_jp/AWSCloudFormation/latest/UserGuide/aws-properties-lambda-function-code.html
    * 事前に全リージョンにZIPを上げておくのはつらい(複数アカウントに展開する場合はよいかも)

直接コードを埋め込む場合の制限として、サイズが4096バイトまで、
アップロードされたコードのファイル名は index.py になります。
https://docs.aws.amazon.com/ja_jp/AWSCloudFormation/latest/UserGuide/aws-properties-lambda-function-code.html

### スタックセット の事前準備
スタックセットを作成するアカウントに AWSCloudFormationStackSetAdministrationRole を
実際のスタックを作成するターゲットに AWSCloudFormationStackSetExecutionRole を
作成する必要があります。以下のドキュメントにロール作成用のテンプレートが用意されています。

![image.png](https://qiita-image-store.s3.amazonaws.com/0/125105/d466caa1-ce11-92aa-e65f-812451e1b6ca.png)

前提条件: スタックセットオペレーションのアクセス権限の付与
https://docs.aws.amazon.com/ja_jp/AWSCloudFormation/latest/UserGuide/stacksets-prereqs.html

今回は、今回は1アカウント内で設定しますので、作業アカウントで両方のロールを作成しておきます。

### スタックセットの作成
作成したテンプレートを直接アップロードするか、予め格納したS3URLを指定します。

![image.png](https://qiita-image-store.s3.amazonaws.com/0/125105/0d5f9451-695d-0448-743f-a65cfa580c31.png)

詳細の指定ではスタックセット名を指定します。

![image.png](https://qiita-image-store.s3.amazonaws.com/0/125105/77e675bc-0efc-6f3a-6159-04272a2dd8ca.png)

デプロイオプションのアカウントの指定で、スタックをアカウントにデプロイを選択し
対象のアカウントIDを入力します。

![image.png](https://qiita-image-store.s3.amazonaws.com/0/125105/d97aad16-8ae6-1dd9-8519-25d84ee62cb7.png)

リージョンの指定で、デプロイ対象のリージョンを指定します。
デプロイオプションは必要に応じて設定を変更します。

![image.png](https://qiita-image-store.s3.amazonaws.com/0/125105/dcd004f8-0fd7-3717-0743-91021fbfd3a5.png)

オプションで任意のタグを設定し、アクセス権限で事前準備で作成したIAMロールを指定します。

![image.png](https://qiita-image-store.s3.amazonaws.com/0/125105/7414bb15-fef1-ac57-4964-83fdc52b2f6a.png)

確認画面で設定項目を確認します。
CloudFormationによってIAMリソースが作成されるため、CAPABILITYにチェックを入れて
スタックセットを作成します。

![image.png](https://qiita-image-store.s3.amazonaws.com/0/125105/fa5eb503-0562-03ee-1d46-53a0cd02162b.png)

スタックセットの作成が成功し、指定したリージョンのでスタックが作成されると、
ステータスが OUTDATED から CURRENT に変化します。

![image.png](https://qiita-image-store.s3.amazonaws.com/0/125105/d055db47-71aa-8476-d8b0-3e8bcdaa7e68.png)

### スタックセットの更新、削除
スタックセット作成後に対象のリージョンの追加や削除等が可能です。
スタックセット自体を削除するには、スタックセットで管理されているスタックをすべて削除する必要があります。

![image.png](https://qiita-image-store.s3.amazonaws.com/0/125105/00e20761-9646-bd91-a05f-4120d6791a85.png)

管理しているスタックを削除するには、スタックセットの管理からスタックの削除へ進みます。

![image.png](https://qiita-image-store.s3.amazonaws.com/0/125105/33e4dd6f-9bbf-4d79-6d95-3c5b207a066b.png)

デプロイ時と同様に、スタックを削除するアカウントと削除するリージョンの指定を行います。

![image.png](https://qiita-image-store.s3.amazonaws.com/0/125105/a160edc4-98cf-707a-21da-1e0f2e4c0a25.png)

DELETEオペレーションが成功し、管理対象のスタックインスタンスが無くなれば
スタックセットを削除できます。

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