AWSの予算超過時の通知設定をIaCで手軽に実現してみる

# 概要
- AWSで予算が超過しそうな時にアラートをあげてくれるよう設定
- CloudFormationを使って（半）自動で構築
- 本記事では以下2パターン用意
  - ①メール通知のみ
  - ②メール通知＆稼働中のEC2インスタンス停止

## 1. テンプレートファイルをローカルに準備
以下①か②どちらかをローカルPCに保存しておく
- ①メール通知のみ
```yaml:alarm-budget.yaml
AWSTemplateFormatVersion: '2010-09-09'
Parameters:
  myAlarmCostUSD:
    Type: Number
    Default: 1 # 基準を1USDとする
    Description: Alarm Cost.USD.
  myEmail:
    Type: String
    AllowedPattern: >-
      [a-zA-Z0-9]+[-._a-zA-Z0-9]*[a-zA-Z0-9]+.@[a-zA-Z0-9]+[-._a-zA-Z0-9]*[a-zA-Z0-9]+\.([a-zA-Z0-9]+[-._a-zA-Z0-9]*[a-zA-Z0-9]+\.)*([a-zA-Z0-9]+[-._a-zA-Z0-9]*[a-zA-Z0-9]+)
Resources:
  # Budgetの設定
  myBudget:
    Type: AWS::Budgets::Budget
    Properties:
      Budget:
        BudgetLimit:
          Amount: !Ref myAlarmCostUSD
          Unit: USD
        TimeUnit: MONTHLY
        BudgetType: COST
      NotificationsWithSubscribers:
      # 予測コストの通知設定
        - Notification: 
            NotificationType: FORECASTED
            ComparisonOperator: GREATER_THAN
            Threshold: 0.1 # 基準の0.1%（0.001 USD）
          Subscribers:
            - SubscriptionType: EMAIL
              Address: !Ref myEmail
        - Notification:
            NotificationType: FORECASTED
            ComparisonOperator: GREATER_THAN
            Threshold: 50 # 基準の50%（0.5 USD）
          Subscribers:
            - SubscriptionType: EMAIL
              Address: !Ref myEmail
        - Notification:
            NotificationType: FORECASTED
            ComparisonOperator: GREATER_THAN
            Threshold: 100 # 基準の100%（1 USD）
          Subscribers:
            - SubscriptionType: EMAIL
              Address: !Ref myEmail
      # 実コストの通知設定 
        - Notification:
            NotificationType: ACTUAL
            ComparisonOperator: GREATER_THAN
            Threshold: 0.1 # 基準の0.1%（0.001 USD）
          Subscribers:
            - SubscriptionType: EMAIL
              Address: !Ref myEmail
        - Notification:
            NotificationType: ACTUAL
            ComparisonOperator: GREATER_THAN
            Threshold: 50 # 基準の50%（0.5 USD）
          Subscribers:
            - SubscriptionType: EMAIL
              Address: !Ref myEmail
        - Notification:
            NotificationType: ACTUAL
            ComparisonOperator: GREATER_THAN
            Threshold: 100 # 基準の100%（1 USD）
          Subscribers:
            - SubscriptionType: EMAIL
              Address: !Ref myEmail
Outputs:
  BudgetId:
    Value: !Ref myBudget
```

- ②メール通知＆稼働中の全EC2インスタンスを停止（※終了ではない）
```yaml:alarm-and-stopInstances-budget.yaml
AWSTemplateFormatVersion: '2010-09-09'
Parameters:
  myAlarmCostUSD:
    Type: Number
    Default: 1 # 基準を1USDとする
    Description: Alarm Cost.USD.
  myEmail:
    Type: String
    AllowedPattern: >-
      [a-zA-Z0-9]+[-._a-zA-Z0-9]*[a-zA-Z0-9]+.@[a-zA-Z0-9]+[-._a-zA-Z0-9]*[a-zA-Z0-9]+\.([a-zA-Z0-9]+[-._a-zA-Z0-9]*[a-zA-Z0-9]+\.)*([a-zA-Z0-9]+[-._a-zA-Z0-9]*[a-zA-Z0-9]+)

# Lambda関数を呼び出すSNSの設定
Resources:
  BudgetNotificationTopic:
    Type: AWS::SNS::Topic
    Properties:
      DisplayName: "Budget Notification Topic"
      Subscription:
        - Endpoint: !GetAtt BudgetActionLambda.Arn
          Protocol: lambda

  # インスタンスを停止するLambda関数の設定
  BudgetActionLambda:
    Type: AWS::Lambda::Function
    Properties:
      Handler: index.handler
      Role: !GetAtt LambdaExecutionRole.Arn
      Runtime: python3.8
      Timeout: 10
      Code:
        ZipFile: |
          import json
          import boto3
          
          def handler(event, context):
              ec2 = boto3.client('ec2')
              
              # 実行中のインスタンス一覧を取得
              filters = [{'Name': 'instance-state-name', 'Values': ['running']}]
              
              response = ec2.describe_instances(Filters=filters)
              instance_ids = []
              
              for reservation in response['Reservations']:
                  for instance in reservation['Instances']:
                      if instance['State']['Name'] == 'running':
                          instance_ids.append(instance['InstanceId'])
                          
              # 実行中のインスタンスがあれば停止
              if instance_ids:
                  stop_response = ec2.stop_instances(InstanceIds=instance_ids)
                  
              return {
                  'statusCode': 200,
                  'body': json.dumps(f'Stopped instances: {instance_ids}')
              }
  # 必要な各種権限をLambda関数に付与
  LambdaExecutionRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Effect: Allow
            Principal:
              Service: lambda.amazonaws.com
            Action: sts:AssumeRole
      Policies:
        - PolicyName: LambdaEC2Policy
          PolicyDocument:
            Version: '2012-10-17'
            Statement:
              - Effect: Allow
                Action:
                  - ec2:DescribeInstances
                  - ec2:StopInstances
                  - logs:CreateLogGroup
                  - logs:CreateLogStream
                  - logs:PutLogEvents
                Resource: "*"

  # Budgetの設定
  MyBudget:
    Type: "AWS::Budgets::Budget"
    Properties:
      Budget:
        BudgetName: "MyBudget"
        BudgetLimit:
          Amount: !Ref myAlarmCostUSD
          Unit: USD
        TimeUnit: MONTHLY
        BudgetType: COST
      NotificationsWithSubscribers:
      # 予測コストの設定
        - Notification:
            NotificationType: FORECASTED
            ComparisonOperator: GREATER_THAN
            Threshold: 0.1 # 基準の0.1%（0.001 USD）
          Subscribers:
            - SubscriptionType: EMAIL
              Address: !Ref myEmail
        - Notification:
            NotificationType: FORECASTED
            ComparisonOperator: GREATER_THAN
            Threshold: 50 # 基準の50%（0.5 USD）
          Subscribers:
            - SubscriptionType: EMAIL
              Address: !Ref myEmail
        - Notification:
            NotificationType: FORECASTED
            ComparisonOperator: GREATER_THAN
            Threshold: 100 # 基準の100%（1 USD）
          Subscribers:
            - SubscriptionType: EMAIL
              Address: !Ref myEmail
      # 実コストの設定
        - Notification:
            NotificationType: ACTUAL
            ComparisonOperator: GREATER_THAN
            Threshold: 0.1 # 基準の0.1%（0.001 USD）
          Subscribers:
            - SubscriptionType: EMAIL
              Address: !Ref myEmail
            - SubscriptionType: SNS
              Address: !Ref BudgetNotificationTopic
        - Notification:
            NotificationType: ACTUAL
            ComparisonOperator: GREATER_THAN
            Threshold: 50 # 基準の50%（0.5 USD）
          Subscribers:
            - SubscriptionType: EMAIL
              Address: !Ref myEmail
            - SubscriptionType: SNS
              Address: !Ref BudgetNotificationTopic
        - Notification:
            NotificationType: ACTUAL
            ComparisonOperator: GREATER_THAN
            Threshold: 100 # 基準の100%（1 USD）
          Subscribers:
            - SubscriptionType: EMAIL
              Address: !Ref myEmail
            - SubscriptionType: SNS
              Address: !Ref BudgetNotificationTopic

Outputs:
  BudgetId:
    Value: !Ref MyBudget
```
:::note
①と②のどちらを使うかは各自の用途に合わせる
- 例：
    - メール通知は欲しいがインスタンス停止までは要らない⇒①
    - 通知も欲しいし、インスタンスも自動で止めてほしい⇒②
:::

## 2. CloudFormationの設定
1\. AWSマネジメントコンソールにログイン
2\. 左上の検索バーで「CloudFormation」と検索し、CloudFormationの画面を開く
3\. 「スタックの作成」→「新しいリソースを使用(標準)」押下
![howto_setup_1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3851141/87a9d971-e6f9-7275-d891-6a44730240fc.png)

4\. **スタックの作成**で以下の通り設定  
①既存のテンプレートを選択  
②テンプレートファイルのアップロード  
③「ファイルの選択」でローカルに保存したyamlファイルを選択  
④「次へ」押下  
![howto_setup_2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3851141/27dbdc6b-0a96-eb53-346c-bb5858805396.png)


5\. **スタックの詳細を指定**で以下の通り設定  
①```スタック名```は自由に設定  
②```myEmail```に通知を受け取りたいメールアドレスを指定  
③「次へ」押下  
![howto_setup_3.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3851141/8b800ff5-b64b-60eb-7d75-9496fda40c50.png)

:::note info
```myAlarmCostUSD```の値に対して何割超過したかがアラートをあげる条件になります。テンプレではデフォルト$1 USDにしていますが、1ドルじゃ小さすぎる場合はここを変えてください。
- 例：```myAlarmCostUSD```を「100」にした場合
    - 1ドル、50ドル、100ドル到達タイミングでそれぞれ通知
:::

6\. **スタックオプションの設定**はどこも触らず「次へ」押下
![howto_setup_4.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3851141/b8205ddf-edce-3ce5-cdde-6c5049353465.png)


7\. **確認して作成**で設定を確認し、「送信」押下
![howto_setup_5.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3851141/b13aa1df-45b9-cf5b-6c66-ff5c245df755.png)
![howto_setup_6.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3851141/f4f0c049-e126-6897-4ba9-0ec9f65a3fad.png)

:::note info
②のテンプレを使用する場合はこのタイミングでIAMリソース作成の許可を求められますのでチェックを入れてください。
![howto_setup_6a.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3851141/41f67c2f-17f5-46f4-283b-a6a2ce56522b.png)
:::

8\. スタックが「<font color="green">**CREATE_COMPLETE**</font>」になるまでしばらく待機
![howto_setup_7.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3851141/232af1ea-7c8a-e072-fed3-0662601cd0ce.png)

## 3. 設定内容の確認

1\. 作成済みのスタックの**①リソース**タブを押下し**②物理ID**のリンクを押下
![howto_setup_8.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3851141/7a2dcbce-f3f3-7f8e-850d-34538c3e8338.png)


2\. Budgetの画面に飛ぶので「アラート」タブを押下し、作成された通知設定の内容を確認
![howto_setup_9.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3851141/e19b18db-5039-050f-6f3c-27506baf7aa5.png)

## 番外編. ②を使用した際のLambda関数の動作確認
説明を省きましたが、②のテンプレではEC2インスタンスを停止するためのLambda関数を定義しています。
その関数がちゃんと動くかテストする手順を紹介します。

:::note warn
- リージョン内にEC2インスタンスが1個も存在しない場合は、実施前にテストで停止させる用のEC2インスタンスを作成しておいてください（設定はテキトーでOK）
- リージョン内の全EC2インスタンスが停止するので、止めたくないものがある場合はリージョンを変える等で対応してください
（※終了ではなく停止させるだけなので、インスタンスが消えることはありません。）
:::

1\. CloudFormationの「リソース」タブから作成されたLambdaの画面に飛ぶ
![howto_setup_10.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3851141/b9b30b70-674e-94a8-5baf-12b278fab1cc.png)

2\. 「テスト」タブ押下
![howto_setup_11.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3851141/98fec1a4-3953-62cb-d329-bb60ee51db0f.png)

3\. 「イベントJSON」に以下をコピペ
![howto_setup_12.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3851141/33b1ba24-7df3-76a9-6834-685deaf2e6f4.png)
```json:lambda_test.json
{
  "version": "0",
  "id": "abcdefg-hijklmn",
  "detail-type": "AWS Budgets Notification",
  "source": "aws.budgets",
  "account": "123456789012",
  "time": "2021-12-06T18:40:00Z",
  "region": "ap-northeast-1",
  "resources": [],
  "detail": {
    "budgetName": "MyBudget",
    "budgetLimit": {
      "amount": 100.0,
      "unit": "USD"
    },
    "costFilters": {},
    "costTypes": {
      "includeTax": true,
      "includeSubscription": true,
      "useBlended": false,
      "includeRefund": true,
      "includeCredit": true,
      "includeUpfront": true,
      "includeRecurring": true,
      "includeOtherSubscription": true,
      "includeSupport": true,
      "includeDiscount": true,
      "useAmortized": false
    },
    "timeUnit": "MONTHLY",
    "timePeriod": {
      "start": "2021-12-01T00:00:00Z",
      "end": "2021-12-31T23:59:59Z"
    },
    "notification": {
      "notificationType": "ACTUAL",
      "comparisonOperator": "GREATER_THAN",
      "threshold": 100.0,
      "thresholdType": "PERCENTAGE",
      "notificationState": "ALARM"
    },
    "calculatedSpend": {
      "actualSpend": {
        "amount": 120.0,
        "unit": "USD"
      },
      "forecastedSpend": null
    },
    "budgetAction": null
  }
}

```

4\. 実際にテストしてみる
- 1\. テスト前のインスタンス
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3851141/379a876e-654e-00c9-cc1e-849cd3aa659e.png)
- 2\. JSON貼り付けて「テスト」押下
![howto_setup_13.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3851141/a5fc7bec-6b48-3d52-c52b-7b54937aeb98.png)

- 3\. ↓実行中
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3851141/9411364e-7709-75fb-d583-1555773de584.png)
↓成功したら緑になる
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3851141/e7ce9178-5356-c534-231c-bb0a3ff87ee4.png)

- 4\. インスタンスも止まってました
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3851141/1ae42c40-50f8-3260-e819-18e0923bae24.png)

## 今後の展望
- Slackとかにも通知を送れるようにしたい
- EC2インスタンス停止以外にも予算超過時のアクションのバリエーションを増やしたい
- GCPやOCIでもIaCの対応ができるようにTerraformも勉強したい