🦎

AWSの利用料を毎日Slackに通知するシステムをコマンドのみで構築する

2024/05/06に公開

使っていないAWSのリソースを削除し忘れを防ぐために、Slackで毎日通知するシステムを構築しました。
このシステムは、AWS CloudFormationを用いてテンプレート化することで、他のチームメンバーにも簡単に共有し再利用できる環境を提供します。

やってみた結果

CloudFormationを使ってインフラをコード化することで、インフラの管理が容易になりました。
コードベースで共有することで、誰もが同じ環境を再現可能にすることが大きなメリットです。
また、将来的にはSlackから直接、使用していないStackを削除できるように拡張することで、さらに管理の効率を上げることが可能だと感じました。

インフラ構成

事前準備

以下のツールが必要です。これらは、スクリプトの実行や環境構築の過程で利用します。

ツール 説明
peco コマンドラインでのインタラクティブフィルタリングツール。
awscli AWSサービスをコマンドラインから操作するためのツール。
jq JSONデータを処理するためのコマンドラインツール。
rain AWS CloudFormationのための開発用ツール。

実際に動かしてみる

以下の手順でシステムをセットアップし、動作させます。

  1. SlackのWebhook URLを取得し、設定ファイルに記入します。
  2. リポジトリをクローンします。
    git clone git@github.com:yoppyDev/iac-templates.git
    
  3. 環境変数が記載された.envファイルが必要に応じて修正します。
  4. シェルスクリプトを実行して環境をデプロイします。
    sh utils.sh
    

コードの説明

aws-billing-to-slack-notification.yaml
aws-billing-to-slack-notification.yaml
# 「aws:cloudformation:stack-name」タグが付与されたリソースのコストを取得し、Slackに通知する
AWSTemplateFormatVersion: '2010-09-09'
Description: Get AWS billing information and send it to Slack

Parameters:
  SlackWebhookUrl:
    Type: String
    Description: Slack Incoming Webhook URL

Resources:
  LambdaExecutionRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: "Allow"
            Principal:
              Service: "lambda.amazonaws.com"
            Action: "sts:AssumeRole"
      Policies:
        - PolicyName: "lambda-billing-policy"
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              - Effect: "Allow"
                Action:
                  - "cloudwatch:GetMetricStatistics"
                  - "cloudwatch:ListMetrics"
                  - "ce:GetCostAndUsage"
                  - "ce:GetCostForecast"
                  - "cloudformation:ListStacks"
                Resource: "*"
              - Effect: "Allow"
                Action:
                  - "logs:CreateLogGroup"
                  - "logs:CreateLogStream"
                  - "logs:PutLogEvents"
                Resource: "arn:aws:logs:*:*:*"
        - PolicyName: "EventBridgeLambdaInvocationPolicy"
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              - Effect: "Allow"
                Action:
                  - "lambda:InvokeFunction"
                Resource: "*"

  LambdaFunction:
    Type: AWS::Lambda::Function
    Properties:
      Handler: "index.handler"
      Role: !GetAtt LambdaExecutionRole.Arn
      Code:
        ZipFile: |
          import json
          import os
          import requests
          import datetime
          import boto3
          import logging
          import calendar

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

          def get_all_stack_names():
              client = boto3.client('cloudformation', region_name='ap-northeast-1')
              paginator = client.get_paginator('list_stacks')
              stack_names = []
              for page in paginator.paginate(StackStatusFilter=['CREATE_COMPLETE', 'UPDATE_COMPLETE']):
                  for stack in page['StackSummaries']:
                      stack_names.append(stack['StackName'])
              return stack_names

          def get_cost_for_stack(stack_name, start_date, end_date):
              client = boto3.client('ce', region_name='ap-northeast-1')
              response = client.get_cost_and_usage(
                  TimePeriod={
                      'Start': start_date.strftime('%Y-%m-%d'),
                      'End': end_date.strftime('%Y-%m-%d')
                  },
                  Granularity='MONTHLY',
                  Filter={
                      'Tags': {
                          'Key': 'aws:cloudformation:stack-name',
                          'Values': [stack_name]
                      }
                  },
                  Metrics=["UnblendedCost"]
              )
              results_by_time = response.get('ResultsByTime')
              if results_by_time:
                  total_cost = sum(float(day['Total']['UnblendedCost']['Amount']) for day in results_by_time)
              else:
                  total_cost = 0
              return total_cost

          def handler(event, context):
              today = datetime.datetime.today()
              start_of_month = datetime.datetime(today.year, today.month, 1)
              end_of_month = datetime.datetime(today.year, today.month, calendar.monthrange(today.year, today.month)[1])

              ce_client = boto3.client('ce', region_name='ap-northeast-1')
              forecast_response = ce_client.get_cost_forecast(
                  TimePeriod={
                      'Start': today.strftime('%Y-%m-%d'),
                      'End': end_of_month.strftime('%Y-%m-%d')
                  },
                  Metric='UNBLENDED_COST',
                  Granularity='MONTHLY'
              )

              client = boto3.client('cloudwatch', region_name='us-east-1')
              get_metric_statistics = client.get_metric_statistics(
                  Namespace='AWS/Billing',
                  MetricName='EstimatedCharges',
                  Dimensions=[
                      {
                          'Name': 'Currency',
                          'Value': 'USD'
                      }
                  ],
                  StartTime=today - datetime.timedelta(days=1),
                  EndTime=today,
                  Period=86400,
                  Statistics=['Maximum']
              )

              current_cost = get_metric_statistics['Datapoints'][0]['Maximum']
              forecast_cost = float(forecast_response['Total']['Amount'])

              stack_names = get_all_stack_names()
              messages = [f"当月の現費用:${current_cost:.2f}", f"当月の予測費用:${forecast_cost:.2f}\n"]

              for stack_name in stack_names:
                  cost = get_cost_for_stack(stack_name, start_of_month, end_of_month)
                  messages.append(f"{stack_name}: ${cost:.2f}")

              headers = {'Content-Type': 'application/json'}
              response = requests.post(os.environ.get('SLACK_WEBHOOK_URL'), data=json.dumps({'text': "\n".join(messages)}), headers=headers)
              return {
                  'statusCode': response.status_code,
                  'body': response.text
              }
      Runtime: "python3.7"
      Environment:
        Variables:
          SLACK_WEBHOOK_URL: !Ref SlackWebhookUrl
      Timeout: 30

  LambdaPermission:
    Type: AWS::Lambda::Permission
    Properties:
      Action: "lambda:InvokeFunction"
      FunctionName: !GetAtt LambdaFunction.Arn
      Principal: "events.amazonaws.com"
      SourceArn: !GetAtt EventBridgeRule.Arn

  EventBridgeRule:
    Type: AWS::Events::Rule
    Properties:
      ScheduleExpression: "cron(0 21 * * ? *)" # 6 AM JST
      Targets:
        - Arn: !GetAtt LambdaFunction.Arn
          Id: "TriggerLambdaDaily"
          Input: "{}"

Outputs:
  LambdaFunctionArn:
    Value: !GetAtt LambdaFunction.Arn
    Description: "ARN of the Lambda Function"

Parameters

  • SlackWebhookUrl: SlackのIncoming Webhook URLを指定するパラメータです。これにより、Lambda関数がコスト情報をSlackチャンネルに送信できます。

Resources

LambdaExecutionRole

  • 概要: Lambda関数がAWSリソースにアクセスするために必要な権限を持つIAMロールです。

  • ポリシー:

    • lambda-billing-policy: AWSコストデータを取得し、CloudWatchからメトリクス情報を取得するためのポリシー。

      • cloudwatch:GetMetricStatisticsおよびcloudwatch:ListMetrics: CloudWatchからメトリクス情報を取得するための権限。
      • ce:GetCostAndUsageおよびce:GetCostForecast: AWS Cost Explorerを使用してコストデータおよび予測を取得するための権限。
      • cloudformation:ListStacks: スタックのリストを取得するための権限。
      • ログ関連のアクション: Lambda実行ログをCloudWatch Logsに記録するための権限。
    • EventBridgeLambdaInvocationPolicy: EventBridgeからLambda関数を呼び出すためのポリシー。

      • lambda:InvokeFunction: 任意のLambda関数を実行するための権限。

LambdaFunction

  • 概要: 実際にAWSのコスト情報を取得し、Slackに通知するLambda関数です。
  • 実装詳細:
    • get_all_stack_names: CloudFormationからすべてのスタック名を取得します。
    • get_cost_for_stack: 指定されたスタックの指定期間内のコストをAWS Cost Explorerから取得します。
    • handler: 現在の月のコストと予測コストを定期的に取得し、それらをSlackに通知します。
  • 使用されるAWSサービス: Boto3ライブラリを使用し、CloudFormation、AWS Cost Explorer、CloudWatchを操作します。
  • 環境変数: SLACK_WEBHOOK_URLを利用し、通知を送信するSlackのWebhook URLを設定します。

LambdaPermission

  • 概要: Lambda関数をEventBridgeから呼び出す権限を付与します。
  • 設定:
    • Action: "lambda:InvokeFunction"。EventBridgeからLambda関数を実行するための権限。
    • Principal: "events.amazonaws.com"。EventBridgeが実行するためのプリンシパル。
    • SourceArn: EventBridgeのルールのARNを指定します。

EventBridgeRule

  • 概要: AWS EventBridgeを使用し、Lambda関数を毎日UTCの21時(日本時間で翌日の6時)に自動的にトリガーします。
  • 設定: スケジュール式はcron(0 21 * * ? *)に設定し、Lambda関数を定期実行します。

おわりに

このプロジェクトを通じて、インフラをコードとして管理する重要性を改めて実感しました。
これからも、インフラ管理の自動化と最適化を進め、運用の効率化を図っていきたいと思います。

今回作ったリポジトリは以下です。ぜひご参照ください。
https://github.com/yoppyDev/iac-templates

Discussion