AWSリソースの削除漏れを日次通知するSAMテンプレートを作成した

# 構成図

![config-notify.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/344711/564924a7-f39b-f81e-40c4-96222a1b7c10.png)


# 概要

Configでサポートされているリソースに限り、削除漏れのリソースを通知します。
リソースが作成されたらDynamoDBにアイテムが保存され、削除したらアイテムも削除されます。
指定された通知日時までに削除されなかったリソースのみ、Slackに通知される仕組みです。
通知間隔は1日で設定していますが、EventBridgeのスケジュールルールで起動しているので変更可能。

# 事前準備

## SlackのWebhook URLの取得

こちらの記事が分かりやすかったです。

https://qiita.com/suo-takefumi/items/738793e2e426f52d7d42

## SlackのWebhook URLをSSM Parameter Storeに格納

コンソールからでいいので作成しておきます。
パラメータ名をSAMでデプロイする時に渡します。

![スクリーンショット 2021-09-25 23.47.54.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/344711/2e29a702-1749-bf36-bc3c-442c059c5aad.png)


# SAMテンプレート

```yaml:template.yml
AWSTemplateFormatVersion: 2010-09-09
Transform: AWS::Serverless-2016-10-31
Description: resources-notification

Parameters:
  ParameterName:
    Type: String
    Description: SSM parameter name whose value is the Slack Web Hook URL.
  TableName:
    Type: String
    Description: DynamoDB table name.

Globals:
  Function:
    Runtime: python3.9
    Handler: index.lambda_handler
    Environment:
        Variables:
          TABLE_NAME: !Ref TableName

Resources:
  NewResourcesStore:
    Type: AWS::Serverless::SimpleTable
    Properties:
      TableName: !Ref TableName
      PrimaryKey:
        Name: resourceId
        Type: String
      ProvisionedThroughput:
        ReadCapacityUnits: 5
        WriteCapacityUnits: 5

  NewResourceRecordFunction:
    Type: AWS::Serverless::Function
    Properties:
      FunctionName: new-resource-record-dynamodb-v2
      CodeUri: functions/new-resource-record-dynamodb
      Policies:
        - Version: 2012-10-17
          Statement:
            - Effect: Allow
              Action: dynamodb:PutItem
              Resource: !Sub arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/${TableName}
      Events:
        ResourceDescoverd:
          Type: EventBridgeRule
          Properties:
            Pattern:
              source:
                - aws.config
              detail-type:
                - 'Config Configuration Item Change'
              detail:
                messageType:
                  - ConfigurationItemChangeNotification
                configurationItem:
                  configurationItemStatus:
                    - ResourceDiscovered

  NewResourceDeleteFunction:
    Type: AWS::Serverless::Function
    Properties:
      FunctionName: new-resource-delete-dynamodb-v2
      CodeUri: functions/new-resource-delete-dynamodb
      Policies:
        - Version: 2012-10-17
          Statement:
            - Effect: Allow
              Action: dynamodb:DeleteItem
              Resource: !Sub arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/${TableName}
      Events:
        ResourceDeleted:
          Type: EventBridgeRule
          Properties:
            Pattern:
              source:
                - aws.config
              detail-type:
                - 'Config Configuration Item Change'
              detail:
                messageType:
                  - ConfigurationItemChangeNotification
                configurationItem:
                  configurationItemStatus:
                    - ResourceDeleted

  ResourceNotificationStateMachine:
    Type: AWS::Serverless::StateMachine
    Properties:
      DefinitionUri: statemachine/resources_notification.asl.json
      DefinitionSubstitutions:
        NewResourceNotificationFuncationArn: !GetAtt NewResourceNotificaitonFunction.Arn
        ScanResourcesDeleteFunctionArn: !GetAtt ScanResourcesDeleteFunction.Arn
      Events:
        Schedule:
          Type: Schedule
          Properties:
            Description: Schedule to run the resource notification state machine every
            Schedule: "cron(0 15 * * ? *)"
      Policies:
        - LambdaInvokePolicy:
            FunctionName: !Ref NewResourceNotificaitonFunction
        - LambdaInvokePolicy:
            FunctionName: !Ref ScanResourcesDeleteFunction

  NewResourceNotificaitonFunction:
    Type: AWS::Serverless::Function
    Properties:
      FunctionName: new-resources-notification-v2
      CodeUri: functions/new-resources-notification
      Environment:
          Variables:
            SLACK_WEBHOOK_URL: !Ref ParameterName
      Policies:
        - Version: 2012-10-17
          Statement:
            - Effect: Allow
              Action: dynamodb:Scan
              Resource:
                - !Sub arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/${TableName}
                - !Sub arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/${TableName}/index/*
        - Version: 2012-10-17
          Statement:
            - Effect: Allow
              Action: ssm:GetParameter
              Resource:
                - !Sub arn:aws:ssm:${AWS::Region}:${AWS::AccountId}:parameter/${ParameterName}

  ScanResourcesDeleteFunction:
    Type: AWS::Serverless::Function
    Properties:
      FunctionName: scan-resources-delete-dynamodb-v2
      CodeUri: functions/scan-resources-delete-dynamodb
      Policies:
        - Version: 2012-10-17
          Statement:
            - Effect: Allow
              Action: dynamodb:BatchWriteItem
              Resource:
                - !Sub arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/${TableName}

Outputs:
  ResourceNotificationStateMachineArn:
    Value: !Ref ResourceNotificationStateMachine
  NewResourceRecordFunctionName:
    Value: !Ref NewResourceRecordFunction
  NewResourceDeleteFunctionName:
    Value: !Ref NewResourceDeleteFunction
  NewResourceNotificaitonFunctionName:
    Value: !Ref NewResourceNotificaitonFunction
  ScanResourcesDeleteFunctionName:
    Value: !Ref ScanResourcesDeleteFunction

```
# ステートマシン定義

```json:statemachine/resources_notification.asl.json
{
    "Comment": "resource notification(config)",
    "StartAt": "Notification",
    "States": {
        "Notification": {
            "Type": "Task",
            "Resource": "arn:aws:states:::lambda:invoke",
            "Parameters": {
                "Payload.$": "$",
                "FunctionName": "${NewResourceNotificationFuncationArn}"
            },
            "Retry": [
                {
                    "ErrorEquals": [
                        "Lambda.ServiceException",
                        "Lambda.AWSLambdaException",
                        "Lambda.SdkClientException"
                    ],
                    "IntervalSeconds": 2,
                    "MaxAttempts": 6,
                    "BackoffRate": 2
                }
            ],
            "Next": "Choice"
        },
        "Choice": {
            "Type": "Choice",
            "Choices": [
                {
                    "Variable": "$.Payload",
                    "IsNull": true,
                    "Next": "Pass"
                }
            ],
            "Default": "DynamoDB Delete"
        },
        "Pass": {
            "Type": "Pass",
            "End": true
        },
        "DynamoDB Delete": {
            "Type": "Task",
            "Resource": "arn:aws:states:::lambda:invoke",
            "OutputPath": "$.Payload",
            "Parameters": {
                "Payload.$": "$",
                "FunctionName": "${ScanResourcesDeleteFunctionArn}"
            },
            "Retry": [
                {
                    "ErrorEquals": [
                        "Lambda.ServiceException",
                        "Lambda.AWSLambdaException",
                        "Lambda.SdkClientException"
                    ],
                    "IntervalSeconds": 2,
                    "MaxAttempts": 6,
                    "BackoffRate": 2
                }
            ],
            "End": true
        }
    }
}
```

# コード

リソース作成時にDynamoDBに記録する。

```python:functions/new-resource-record-dynamodb/index.py
import os
import logging
import datetime
import boto3
from botocore.exceptions import ClientError

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

client = boto3.client('dynamodb')
table_name = os.environ.get('TABLE_NAME')


def lambda_handler(event, context):

    logger.info(event)

    configurationItem = event['detail']['configurationItem']

    arn = event['resources'][0]
    capture_time = configurationItem['configurationItemCaptureTime']
    resource_type = configurationItem['resourceType']
    resource_id = configurationItem['resourceId']

    if 'resourceName' in configurationItem.keys():
        resource_name = configurationItem['resourceName']
    else:
        resource_name = '-'

    # capture_timeをdatetime.datetime型に変換
    fmt_time = datetime.datetime.strptime(
        capture_time,
        '%Y-%m-%dT%H:%M:%S.%f%z'
    )
    # JST時間
    jst_time = fmt_time + datetime.timedelta(hours=9)
    # 不要部分を削除して文字列型に変換
    day_time = jst_time.strftime('%Y-%m-%d %H:%M:%S.%f')
    day, time = day_time.split()

    logger.info(f'リソース {arn} が作成されたため、DynamoDB {table_name}に追加します')

    try:
        response = client.put_item(
            TableName=table_name,
            Item={
                'resourceId': {'S': resource_id},
                'resourceType': {'S': resource_type},
                'resourceName': {'S': resource_name},
                'arn': {'S': arn},
                'day': {'S': day},
                'time': {'S': time}
            }
        )
        logger.info('DynamoDBに追加しました')
        logger.info(response)
    except ClientError as e:
        logger.error(e)

```

リソース削除時にDynamoDBに記録したアイテムを削除する。

```python:functions/new-resource-delete-dynamodb/index.py
import os
import boto3
import logging
from botocore.exceptions import ClientError

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

client = boto3.client('dynamodb')
table_name = os.environ.get('TABLE_NAME')

def lambda_handler(event, context):

    logger.info(event)

    resource_id = event['detail']['configurationItem']['resourceId']
    arn = event['resources'][0]

    logger.info(f'リソース {arn} が削除されたため、DynamoDB {table_name}からも削除を開始します')

    try:
        response = client.delete_item(
            TableName=table_name,
            Key={
                'resourceId': {
                    'S': resource_id
                }
            },
            ConditionExpression='resourceId = :id',
            ExpressionAttributeValues={
                ':id': {
                    'S': resource_id
                }
            }
        )
        logger.info('DynamoDBから削除しました')
        logger.info(response)
    except client.exceptions.ConditionalCheckFailedException as e:
        logger.error(
            f'resourceId: {resource_id}　がDynamoDB {table_name}に存在しません')
        logger.error(e)
    except ClientError as e:
        logger.error('その他のエラーが発生しました')
        logger.error(e)

```
DynamoDBに保存されているアイテムを全てSlackに通知する（定期実行）。

```python:functions/new-resources-notification/index.py
import json
import os
import logging
import re
import urllib.request
import boto3
from botocore.exceptions import ClientError

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

dynamodb = boto3.client('dynamodb')
table_name = os.environ.get('TABLE_NAME')
ssm = boto3.client('ssm')
ssm_parameter_name = os.environ.get('SLACK_WEBHOOK_URL')


def lambda_handler(event, context):

    item_list = scan(table_name)
    if not item_list:
        return

    # Scanしたアイテムのリスト.次のLambdaで削除
    delete_list = []
    for item in item_list:
        delete_list.append({'resourceId': item['resourceId']['S']})

    message = create_message(item_list)

    response = ssm.get_parameter(
        Name=ssm_parameter_name,
        WithDecryption=True
    )
    web_hook_url = response['Parameter']['Value']

    post_slack(web_hook_url, message)

    # 削除するLambdaに渡す
    return delete_list


def scan(table_name):
    try:
        response = dynamodb.scan(
            TableName=table_name,
            Select='ALL_ATTRIBUTES'
        )
        logger.info(json.dumps(response))
        if response['Count'] == 0:
            return
        return response['Items']
    except ClientError as e:
        logger.error(e)
        return


def create_message(item_list):
    message_list = []
    for item in item_list:
        day = item['day']['S']
        resource_type = item['resourceType']['S']
        pre_arn = item['arn']['S']
        arn = re.sub('^.+:\d{12}:', '', pre_arn)

        message_list.append(f'・ *{day}*   *{resource_type}* \n{arn}\n')
    sort_message_list = sorted(message_list)
    sort_message_list.insert(0, ':bulb: *削除されていないリソース* :bulb:\n\n')
    message = '\n'.join(sort_message_list)
    return message


def post_slack(web_hook_url, message):
    send_data = {
        "username": "aws-new-resource-notify",
        "text": message
    }

    send_text = 'payload=' + json.dumps(send_data)

    request = urllib.request.Request(
        web_hook_url,
        data=send_text.encode('utf-8'),
        method='POST'
    )

    with urllib.request.urlopen(request) as response:
        response_body = response.read().decode('utf-8')

    logger.info(response_body)

```

Slackに通知したアイテムをDynamoDBから削除する。

```python:functions/scan-resources-delete-dynamodb/index.py
import os
import logging
import boto3

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

table_name = os.environ.get('TABLE_NAME')
dynamodb = boto3.resource('dynamodb')
table = dynamodb.Table(table_name)


def lambda_handler(event, context):
    logger.info(event)
    # DynamoDBに該当のアイテムが存在しない状態で削除してもエラーにならない
    try:
        with table.batch_writer() as batch:
            for key in event['Payload']:
                logger.info(f'DynamoDBテーブル {table_name} に保存された resourceId:{key["resourceId"]} の削除を開始します')
                batch.delete_item(Key=key)
                logger.info(f'resourceId:{key["resourceId"]} の削除が完了しました')
    except Exception as e:
        logger.exception(e)
```

# デプロイ方法

https://github.com/kazu638/aws-sam/tree/main/resources-notification

# リソースや通知の内容など

DynamoDBのアイテム。

![スクリーンショット 2021-09-25 23.26.57.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/344711/9dbde3ff-fe64-825a-8b51-db34cb027ab4.png)

StepFunction。

![スクリーンショット 2021-09-25 23.30.12.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/344711/4d475c90-9fe5-241b-8416-0478b0e4661e.png)

Slackへの通知内容。

![スクリーンショット 2021-09-25 23.19.36.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/344711/c5bfefd5-ceef-0443-b883-e10a99ed9808.png)

# 使用しての感想

削除漏れの把握以外で良かったのは、作成したつもりのなかったリソースも把握できたことです。
AWSでリソースを作成すると、そのリソースに必要なリソースなどが自動作成されることが多いと思います。
そのリソースも通知してくれるので、このリソースを作成するとこんなリソースも作成されるのかと勉強になりました。

ただ通知がConfigに依存しているため、Configがサポートしているリソースに限るのが残念な点です。
2週間ほど使用していますが、料金は無料の範囲内で使用できています（大量リソースの作成・削除は行っていない、かつ私用アカウントでの場合）。