ちょっと話題の記事

Cost Optimization HubのレコメンデーションをEventBridge Scheduler+Lambdaで定期通知してみた

Cost Optimization Hubのダッシュボードを定期的にチェックしなくても、レコメンデーションをSlackで受け取れるようにしました
2024.05.27

こんにちは!AWS事業本部のおつまみです。

みなさん、コスト削減のためのレコメンデーション(推奨事項)を定期的に通知してほしいなと思ったことはありませんか?私はあります。

コスト削減は多くのAWS利用ユーザーにとって重要な課題の1つです。
そこでAWSには、その課題解決をサポートするTrusted Adviser,Compute Optimizerなどの様々なサービスがあります。その中の1つにCost Optimization Hubがあります。

Cost Optimization Hubは、コスト最適化に関する推奨事項(レコメンデーション)を一元的に管理できる非常に優れたサービスです。
しかし、2024/5/27時点ではダッシュボード機能のみの提供となっています。
よって、普段からこのダッシュボードを定期的にチェックしない方にとっては、レコメンデーションを見逃してしまう可能性があります。

そこで、今回はCost Optimization HubのレコメンデーションをEventBridge Scheduler+Lambdaで定期通知する方法をご紹介します!

構成図

今回実装した仕組みです。

EventBridge Schedulerにて指定した間隔でLambdaを呼び出し、Cost Optimization Hubのレコメンデーションを取得します。取得後は、SNS経由でSlack通知するようにします。

構成はこちらのブログを参考にしました。

やってみた

前提条件

今回はCloudFormationを用いるため、AWSコンソールにログインできるAWSアカウントを用いるだけで構築可能です。 事前に通知先となる、Slackアカウントを用意しておいてください。

サービスの有効化

以下のサービスを事前に有効化しておく必要があります。

  • Cost Optimization Hub
  • Compute Optimizer

公式ドキュメントを参考に、事前に有効化しましょう。

マルチアカウント管理をしている場合は組織設定もこの時点で行うようにしておきましょう。

なおどちらのサービスもレコメンデーションが表示されるまでに24時間以上時間がかかるので、すぐにレコメンデーションが出ないことを念頭に置いておきましょう。

SlackのWebhook URL取得

SlackのWebhook URLを取得する必要があります。
下記を参考に、Slack Appを作成し、Webhook URLを取得しましょう。

CloudFormationテンプレートのデプロイ

以下のCloudFormationテンプレートを用いて、バージニア北部リージョン(us-east-1)でスタックをデプロイします。

バージニア北部リージョン(us-east-1)でデプロイする必要がある理由は、Cost Optimization Hub がグローバルサービスのためです。

CloudFormationテンプレート

AWSTemplateFormatVersion: '2010-09-09'
Description: 'Cost Optimization Hub Notification'

Parameters:
  SlackWebHookURL:
    Type: String
    Description: 'Slack WebHook URL'
  SlackChannelName:
    Type: String
    Description: 'Slack Channel Name'
  ScheduleExpression:
    Type: String
    Default: cron(0 0 * * ? *)
    Description: 'Notification Schedule' 

Resources:
  # IAM Role for EventBridge Scheduler
  EventBridgeCostOptimizationHubNotificationRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: EventBridge_CostOptimizationHub_Notification_Role
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Effect: Allow
            Principal:
              Service: scheduler.amazonaws.com
            Action: sts:AssumeRole
      ManagedPolicyArns:
        - arn:aws:iam::aws:policy/service-role/AWSLambdaRole

  # IAM Role for Lambda
  LambdaCostOptimizationHubNotificationRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: Lambda_CostOptimizationHub_Notification_Role
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Effect: Allow
            Principal:
              Service: lambda.amazonaws.com
            Action: sts:AssumeRole
      Policies:
        - PolicyName: my_inline_policy
          PolicyDocument:
            Version: '2012-10-17'
            Statement:
              - Action: sns:Publish
                Effect: Allow
                Resource: '*'
      ManagedPolicyArns:
        - arn:aws:iam::aws:policy/CostOptimizationHubReadOnlyAccess

  # SNS Topic
  CostOptimizationHubNotificationTopic:
    Type: AWS::SNS::Topic
    Properties:
      TopicName: CostOptimizationHub-notification-topic

  SNSTopicPolicy:
    Type: AWS::SNS::TopicPolicy
    Properties:
      Topics:
        - !Ref CostOptimizationHubNotificationTopic
      PolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Effect: Allow
            Principal:
              Service: lambda.amazonaws.com
            Action: sns:Publish
            Resource: !Ref CostOptimizationHubNotificationTopic
  
  # Lambda Function (Cost Optimization Hub Information Retrieval)
  LambdaCostOptimizationHub:
    Type: AWS::Lambda::Function
    Properties:
      FunctionName: cost-optimization-hub-notification
      Role: !GetAtt LambdaCostOptimizationHubNotificationRole.Arn
      Handler: index.lambda_handler
      Runtime: python3.9
      Timeout: 60
      Code:
        ZipFile: |
          import boto3
          import json
          import os

          AccountID = os.environ['AccountId']
          SNSTopicArn = os.environ['SNSTopicArn']

          def lambda_handler(event, context):
              # Cost Optimization Hubから推奨事項を取得
              # クライアント作成
              client = boto3.client('cost-optimization-hub')
              
              # リクエスト発信
              list_response = client.list_recommendations(
                  filter={
                  accountIds': [AccountID],
                  'resourceTypes': ['Ec2Instance']
                  },
                  maxResults=10
              )

              # メッセージ生成に必要な情報の抽出
              message = []
              message.append("Cost Optimization HubのEC2に関するコスト最適化の推奨事項を通知")

              # recommendationIdを使用して詳細を取得
              ec2_recommendations_found = False
              for recommendation in list_response['items']:
                  recommendation_id = recommendation['recommendationId']
                  get_response = client.get_recommendation(
                      recommendationId=recommendation_id
                  )
                  recommendation = get_response['recommendation']
                  if recommendation['resourceType'] == "Ec2Instance":
                      message.append("EC2インスタンス")
                      message.append(f" アカウントID: {AccountID} 推定削減額: {recommendation['estimatedMonthlySavings']}")
              
              if not ec2_recommendations_found:
                  message.append("推奨事項なし")

              # メッセージをSNSに送信
              # 通知を送信するSNSトピックのARNを指定
              sns_client = boto3.client('sns')
              sns_topic_arn = SNSTopicArn
              sns_client.publish(
                  TopicArn=sns_topic_arn,
                  Message=json.dumps(message, indent=3, ensure_ascii=False)
              )   
      Environment:
        Variables:
          SNSTopicArn: !Ref CostOptimizationHubNotificationTopic
          AccountId: !Ref AWS::AccountId

  # Lambda Function (Slack Notification)
  LambdaSlack:
    Type: AWS::Lambda::Function
    Properties:
      FunctionName: cost-optimization-hub-to-slack
      Role: !GetAtt LambdaCostOptimizationHubNotificationRole.Arn
      Handler: index.lambda_handler
      Runtime: python3.9
      Code:
        ZipFile: |
          import json
          import os
          import urllib.request

          def lambda_handler(event, context):
              # SNSメッセージからCost Optimization Hubの情報を取得
              message = event['Records'][0]['Sns']['Message']

              # Slackへの通知処理を記述
              hook_url = os.environ['HookURL']
              channel_name = os.environ['ChannelName']
              payload = {
                  'channel': channel_name,
                  'text': message
              }
              data = json.dumps(payload).encode('utf-8')
              req = urllib.request.Request(hook_url, data=data, method='POST')
              with urllib.request.urlopen(req) as res:
                  res.read()
      Environment:
        Variables:
          HookURL: !Ref SlackWebHookURL
          ChannelName: !Ref SlackChannelName

  LambdaSlackPermission:
    Type: AWS::Lambda::Permission
    Properties:
      Action: lambda:InvokeFunction
      FunctionName: !Ref LambdaSlack
      Principal: sns.amazonaws.com
      SourceArn: !Ref CostOptimizationHubNotificationTopic

  # EventBridge Scheduler
  CostOptimizationHubNotificationSchedule:
    Type: AWS::Scheduler::Schedule
    Properties:
      Name: cost-optimization-hub-notification-schedule
      Description: Cost Optimization Hub Notification
      FlexibleTimeWindow:
        Mode: "OFF"
      ScheduleExpression: !Ref ScheduleExpression
      ScheduleExpressionTimezone: Asia/Tokyo
      Target:
        Arn: !GetAtt LambdaCostOptimizationHub.Arn
        RoleArn: !GetAtt EventBridgeCostOptimizationHubNotificationRole.Arn

  # SNS Subscription
  SlackSubscription:
    Type: AWS::SNS::Subscription
    Properties:
      TopicArn: !Ref CostOptimizationHubNotificationTopic
      Protocol: lambda
      Endpoint: !GetAtt LambdaSlack.Arn

スタックの詳細画面では、スタック名及びパラメータを指定する必要があります。

  • スタック名:任意の名前
  • パラメータ
    • SlackWebHookURL:事前に取得したSlackのWebhook URL
    • SlackChannelName:通知するSlackチャンネル名(Webhook URLを取得したときに指定したSlackチャンネル名を指定)
    • ScheduleExpression:通知したい時間をcron式で指定(デフォルトはcron(0 0 * * ? *)としており、日本時間の午前9時 (AM9:00) に通知するスケジュールにしています)

参考:cron 式のリファレンス - Amazon EventBridge

パラメータを指定後、スタックをデプロイします。

通知テスト

最後に、デプロイしたシステムがきちんと動くかテストします。

Lambdaコンソールにアクセスし、デプロイしたLambda関数であるcost-optimization-hub-notificationを選択後、テストタブを選択し"テスト"ボタンを押します。

その後、通知先として設定したSlackに、以下のような通知が届く事を確認できればOKです!

現在自分のアカウントはレコメンデーションがなかったため、「推奨事項なし」が表示されています。 ある場合はこのような文言で推奨事項が表示されるようにLambdaを記述しています。

[
   "Cost Optimization HubのEC2に関するコスト最適化の推奨事項を通知",
   "EC2インスタンス",
   アカウントID: {AccountID}, 推定削減額: {recommendation['estimatedMonthlySavings']}
]

※こちらの文言はBoto3 APIで値を取得しているため、カスタマイズ可能です。
参考:Cost Optimization Hub - Boto3 1.34.113 documentation

あとは指定時刻(今回の場合はAM9:00)まで待機し、同じようなメッセージが届けばSchedulerの動作もOKです!

さいごに

今回はCost Optimization HubのレコメンデーションをEventBridge Scheduler+Lambdaで定期通知する方法をご紹介しました。

これにより、コスト削減の機会を見逃すことなく、効率的にAWSのコストを最適化することができるようになりましたね!

「でも、推奨されたアクションの実行方法がわからない・・・」

という方は、弊社でコスト最適化のご支援も行なっているので、ぜひご活用下さい!(無料相談もやっています!)

最後までお読みいただきありがとうございました!
どなたかのお役に立てれば幸いです。

以上、おつまみ(@AWS11077)でした!

参考

Cost Optimization Hub - AWS コスト管理

Cost Optimization Hub - Boto3 1.34.113 documentation

新しいコスト最適化ハブは、推奨アクションを一元化してコストを節約します | Amazon Web Services ブログ