AWS Healthを定期的にチェックして、AWSサービス障害情報を自動通知＆CloudFormationテンプレート化

先日のAWS障害を受けて、AWS Healthで障害情報が上がった場合に一次情報として自動通知できるようにしたいと上司から言われ、作ってみたものです。

####1.要件
・AWS Health上に新しく障害情報が掲載された際に、自動で通知を行う。（今回はとりあえずメール通知）
・その他のメンテナンス情報等は通知対象外
・あくまで一次的にAWS障害に気づくための用途であり、そもそも監視・モニタリングでAWS障害に起因するシステム異常を検知するのは別のお話

####2.構成
CloudWatchEventsでAWS Healthイベントを検知可能ですが、メンテナンス等の一部イベントしか検知できないため先日のAZ障害のようなケースでは役に立ちません。
そのため、定期的にLambdaでAWS HealthのAPIを叩く構成にしました。

####3.Lambdaの中身
AWSサービスで障害が発生した場合のイベントカテゴリは'Issue'、かつ対応中であるものはイベントステータスが'Open'なものなので、それらをフィルター指定してDescribe Events APIを実行しています。

対象が存在する場合にはそのイベントの開始時刻をチェックして、N分前～現在時刻の範囲（＝直近のN分間で新たに発生したイベント）である場合のみ検知対象とします。（時刻判定をすることで、重複検知させない）
また、N分の部分は環境変数とすることで可変としています。

※該当時間内に発生したイベントを拾えれば良いので、ステータス判定はいらないかもしれません

```aws-health-check.py
import json
import boto3
import datetime
import dateutil.tz
import os
health = boto3.client('health')
sns = boto3.client('sns')

def json_serial(obj):
  return obj.isoformat()

def lambda_handler(event, context):
  aws_health_events = list(health.describe_events(
          filter={
              'eventTypeCategories':[
                  'issue'
              ],
              'eventStatusCodes':[
                  'open'
              ]
          }
      )['events'])
      
  open_issue_list = []
      
  for aws_health_event in aws_health_events:
      start_time = aws_health_event['startTime']
      
      current_check_time = datetime.datetime.now(dateutil.tz.tzlocal())
      pre_check_time = current_check_time - datetime.timedelta(minutes=int(os.environ['CHECK_CYCLE']))
      if pre_check_time < start_time <= current_check_time:
          open_issue_list.append(aws_health_event)
  
  if len(open_issue_list) == 0:
      return 'AWS Health Check is OK.'
  else:
      sns.publish(
          TopicArn=os.environ['SNS_TOPIC_ARN'],
          Subject='AWS Health check Alert!',
          Message=json.dumps(open_issue_list, default=json_serial)
      )
      return 'New issue detected.'
```

####4.CFnテンプレート作成
社内各部への展開もできるようにテンプレート化しておきたかったため、各リソース作成も併せてCFnテンプレートにしました。

・とりあえずLambdaのコード部分は直書きですが、実際に使う場合はS3からの取得に直す予定です
・AWS Healthをチェックさせるサイクル（分）と通知先のメールアドレスは入力パラメータにしています
・入力値のサイクル（分）はLambdaの環境変数にも埋め込み、PGM内の判定ロジックに使用しています

```yaml
AWSTemplateFormatVersion: '2010-09-09'
# ------------------------------------------------------------#
# Input Parameters
# ------------------------------------------------------------# 
Parameters:
  NotificationEmailAddress:
    Type: String
  CheckCycle:
    Type: String
    Default: 60
    AllowedPattern: "[0-9]*"
    Description: 'Input Check Cycle. (minutes)'
# ------------------------------------------------------------#
# Resource
# ------------------------------------------------------------# 
Resources:
  AWSHealthCheckRole:
    Type: "AWS::IAM::Role"
    Properties:
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement: 
          - 
            Effect: "Allow"
            Principal: 
              Service: 
                - "lambda.amazonaws.com"
            Action: 
              - "sts:AssumeRole"
      Path: "/"
      RoleName: "aws-health-check-role"
      ManagedPolicyArns:
        - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
  AWSHealthCheckPolicy:
    Type: AWS::IAM::Policy
    Properties: 
      PolicyName: "aws-health-check-policy"
      PolicyDocument: 
        Version: "2012-10-17"
        Statement: 
          - 
            Effect: Allow
            Action: 
              - "health:DescribeEvents"
            Resource:
              - "*" 
          - 
            Effect: Allow
            Action: 
              - "sns:Publish"
            Resource: 
              - !Ref AWSHealthAlertTopic
      Roles: 
        - !Ref AWSHealthCheckRole
  AWSHealthCheckFunction:
    Type: AWS::Lambda::Function
    Properties:
      Code:
        ZipFile: !Sub |
          import json
          import boto3
          import datetime
          import dateutil.tz
          import os
          health = boto3.client('health')
          sns = boto3.client('sns')
          def json_serial(obj):
              return obj.isoformat()
          def lambda_handler(event, context):
              aws_health_events = list(health.describe_events(
                      filter={
                          'eventTypeCategories':[
                              'issue'
                          ],
                          'eventStatusCodes':[
                              'open'
                          ]
                      }
                  )['events'])
                  
              open_issue_list = []
                  
              for aws_health_event in aws_health_events:
                  start_time = aws_health_event['startTime']
                  
                  current_check_time = datetime.datetime.now(dateutil.tz.tzlocal())
                  pre_check_time = current_check_time - datetime.timedelta(minutes=int(os.environ['CHECK_CYCLE']))
                  if pre_check_time < start_time <= current_check_time:
                      open_issue_list.append(aws_health_event)
              
              if len(open_issue_list) == 0:
                  return 'AWS Health Check is OK.'
              else:
                  sns.publish(
                      TopicArn=os.environ['SNS_TOPIC_ARN'],
                      Subject='AWS Health check Alert!',
                      Message=json.dumps(open_issue_list, default=json_serial)
                  )
                  return 'New issue detected.'
      Description: "Lambda Function for AWS Health Check"
      FunctionName: AWS_Health_Check
      Handler: index.lambda_handler
      MemorySize: 128
      Role: !GetAtt AWSHealthCheckRole.Arn
      Runtime: python3.7
      Timeout: 30
      Environment:
        Variables:
          TZ: Asia/Tokyo
          SNS_TOPIC_ARN: !Ref AWSHealthAlertTopic
          CHECK_CYCLE: !Ref CheckCycle
  AWSHealthCheckFunctionLogGroup:
    Type: AWS::Logs::LogGroup
    Properties:
      LogGroupName: "/aws/lambda/AWS_Health_Check"
      RetentionInDays: 7

  AWSHealthCheckEventRule:
    Type: AWS::Events::Rule
    Properties:
      Description: "time scheduled event for aws health check"
      Name: "AWS_Health_Check-event"
      ScheduleExpression: {"Fn::Join":  [ "" , ["rate(", !Ref CheckCycle, " minutes)"]]}
      State: ENABLED
      Targets:
        - Arn: !GetAtt AWSHealthCheckFunction.Arn
          Id: "AWS_Health_Check-event"
  AWSHealthCheckEventPermission:
    Type: AWS::Lambda::Permission
    Properties:
      Action: lambda:InvokeFunction
      FunctionName: !Ref AWSHealthCheckFunction
      Principal: "events.amazonaws.com"
      SourceArn: !GetAtt AWSHealthCheckEventRule.Arn

  AWSHealthAlertTopic:
    Type: AWS::SNS::Topic
    Properties: 
      DisplayName: 'aws-health-alert-topic'
      TopicName: 'aws-health-alert-topic'
  AWSHealthAlertTopicSubscription:
    Type: AWS::SNS::Subscription
    Properties: 
      Endpoint: !Ref NotificationEmailAddress
      Protocol: email
      Region: !Ref "AWS::Region"
      TopicArn: !Ref AWSHealthAlertTopic
```

####5.その他
・AWS HealthのAPIはバージニア北部リージョンからしか実行できないため、CFnスタック作成もバージニア北部で実行要です
・社内NWでSlackが使えないためメール通知にしていますが、ここはSlack通知にしたい...
・発生の検知だけでなくcloseの検知もできるようにしようと思っています（検知状況のテキストファイルをS3に配置して、毎回差分を確認するイメージ）