1
1

More than 1 year has passed since last update.

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

Posted at

構成図

config-notify.png

概要

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

事前準備

SlackのWebhook URLの取得

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

SlackのWebhook URLをSSM Parameter Storeに格納

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

スクリーンショット 2021-09-25 23.47.54.png

SAMテンプレート

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

ステートマシン定義

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に記録する。

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に記録したアイテムを削除する。

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に通知する(定期実行)。

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から削除する。

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)

デプロイ方法

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

DynamoDBのアイテム。

スクリーンショット 2021-09-25 23.26.57.png

StepFunction。

スクリーンショット 2021-09-25 23.30.12.png

Slackへの通知内容。

スクリーンショット 2021-09-25 23.19.36.png

使用しての感想

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

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

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1