AWS Budgets 自動調整予算を CloudFormation で設定する

# はじめに

昨年まで AWS Budgets の自動調整予算は CloudFormation で未対応と記載がされていたのでテンプレートで作成したあとにCLIで一括更新するという非常に手間なことをやっていたのですが、いつの間にか設定できるようになっていたので複数アカウントへ一括で適用するためのテンプレートを作りました。

https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-properties-budgets-budget-autoadjustdata.html

## 前提

マルチアカウント構成の AWS Budgets の設定を CloudFormation StackSets を利用して構築します。

## 事前準備

- Chatbot と Slack との連携済みであること
- Slack 通知先のチャネルが作成済みであること
    - 予算(予測)通知用チャネル
    - 予算(実績)通知用チャネル
- 構築は us-east-1 で行う(変更可)
- 適用するアカウントのエイリアス名がMappingsへ設定してあること
    - 通知された予算がどのアカウントなのか分かりやすくするためにMappingsでアカウント毎にエイリアス名を設定しています

## テンプレート

毎月、毎日の予算を自動調整予算で設定します。
`CostTypes` はお好みで変更してください。

```yaml
AWSTemplateFormatVersion: 2010-09-09
Description: AWS Budgets
Parameters:
  Prefix:
    Type: String
    Default: "example"
  Env:
    Type: String
    Default: "prod"
    AllowedValues:
      - prod
  ChatbotSlackWorkspaceId:
    Type: String
    Description: Chatbot Workspace ID
    Default: "XXXXXXXXX"
    AllowedValues:
      - "XXXXXXXXX"
  ChatbotSlackChannelForecasted:
    Type: String
    Description: Slack Channel ID Forecasted
    Default: "XXXXXXXXX"
    AllowedValues:
      - "XXXXXXXXX"
  ChatbotSlackChannelActual:
    Type: String
    Description: Slack Channel ID Actual
    Default: "XXXXXXXXX"
    AllowedValues:
      - "XXXXXXXXX"

Conditions:
  IsUSE1: !Equals [ !Ref "AWS::Region", "us-east-1" ]

Mappings:
  Budget:
    "888888888888":
      AccountAliasName: account-a
    "999999999999":
      AccountAliasName: account-b

Resources:
  # --------------------------------------------------
  # Budgets 予測
  # --------------------------------------------------
  BudgetsAlarmForecastedTopic:
    Type: AWS::SNS::Topic
    Condition: IsUSE1
    Properties:
      DisplayName: !Sub "${Prefix} [${Env}] AWS Budgets Alarm Forecasted"
      TopicName: !Sub "${Prefix}-${Env}-awsbudgets-alarm-forecasted-topic"

  BudgetsAlarmForecastedTopicPolicy:
    Type: AWS::SNS::TopicPolicy
    Condition: IsUSE1
    Properties:
      PolicyDocument:
        Statement:
          - Effect: "Allow"
            Principal:
              Service: "budgets.amazonaws.com"
            Action: "sns:Publish"
            Resource:
              - !Ref BudgetsAlarmForecastedTopic
          - Sid: "__default_statement_ID"
            Effect: "Allow"
            Principal:
              AWS: "*"
            Action:
              - "sns:GetTopicAttributes"
              - "sns:SetTopicAttributes"
              - "sns:AddPermission"
              - "sns:RemovePermission"
              - "sns:DeleteTopic"
              - "sns:Subscribe"
              - "sns:ListSubscriptionsByTopic"
              - "sns:Publish"
              - "sns:Receive"
            Resource:
              - !Ref BudgetsAlarmForecastedTopic
            Condition:
              StringEquals:
                AWS:SourceOwner: !Ref "AWS::AccountId"
      Topics:
        - !Ref BudgetsAlarmForecastedTopic

  BudgetsAlarmForecastedTopicSubscription:
    Type: AWS::SNS::Subscription
    Condition: IsUSE1
    Properties:
      Endpoint: https://global.sns-api.chatbot.amazonaws.com
      Protocol: https
      RawMessageDelivery: false
      Region: !Ref AWS::Region
      TopicArn: !Ref BudgetsAlarmForecastedTopic

  BudgetsAlarmForecastedChatbot:
    Type: AWS::Chatbot::SlackChannelConfiguration
    Condition: IsUSE1
    Properties:
      ConfigurationName: !Sub "${Prefix}-${Env}-awsbudgets-forecasted"
      IamRoleArn: !GetAtt ChatbotRole.Arn
      LoggingLevel: ERROR
      SlackChannelId: !Ref ChatbotSlackChannelForecasted
      SlackWorkspaceId: !Ref ChatbotSlackWorkspaceId
      SnsTopicArns:
        - !Ref BudgetsAlarmForecastedTopic

  # --------------------------------------------------
  # Budgets 実績
  # --------------------------------------------------
  BudgetsAlarmActualTopic:
    Type: AWS::SNS::Topic
    Condition: IsUSE1
    Properties:
      DisplayName: !Sub "${Prefix} [${Env}] AWS Budgets Alarm Actual"
      TopicName: !Sub "${Prefix}-${Env}-awsbudgets-alarm-actual-topic"

  BudgetsAlarmActualTopicPolicy:
    Type: AWS::SNS::TopicPolicy
    Condition: IsUSE1
    Properties:
      PolicyDocument:
        Statement:
          - Effect: "Allow"
            Principal:
              Service: "budgets.amazonaws.com"
            Action: "sns:Publish"
            Resource:
              - !Ref BudgetsAlarmActualTopic
          - Sid: "__default_statement_ID"
            Effect: "Allow"
            Principal:
              AWS: "*"
            Action:
              - "sns:GetTopicAttributes"
              - "sns:SetTopicAttributes"
              - "sns:AddPermission"
              - "sns:RemovePermission"
              - "sns:DeleteTopic"
              - "sns:Subscribe"
              - "sns:ListSubscriptionsByTopic"
              - "sns:Publish"
              - "sns:Receive"
            Resource:
              - !Ref BudgetsAlarmActualTopic
            Condition:
              StringEquals:
                AWS:SourceOwner: !Ref "AWS::AccountId"
      Topics:
        - !Ref BudgetsAlarmActualTopic

  BudgetsAlarmActualTopicSubscription:
    Type: AWS::SNS::Subscription
    Condition: IsUSE1
    Properties:
      Endpoint: https://global.sns-api.chatbot.amazonaws.com
      Protocol: https
      RawMessageDelivery: false
      Region: !Ref AWS::Region
      TopicArn: !Ref BudgetsAlarmActualTopic

  BudgetsAlarmActualChatbot:
    Type: AWS::Chatbot::SlackChannelConfiguration
    Condition: IsUSE1
    Properties:
      ConfigurationName: !Sub "${Prefix}-${Env}-awsbudgets-actual"
      IamRoleArn: !GetAtt ChatbotRole.Arn
      LoggingLevel: ERROR
      SlackChannelId: !Ref ChatbotSlackChannelActual
      SlackWorkspaceId: !Ref ChatbotSlackWorkspaceId
      SnsTopicArns:
        - !Ref BudgetsAlarmActualTopic

  # --------------------------------------------------
  # Chatbot関連
  # --------------------------------------------------
  ChatbotRole:
    Type: AWS::IAM::Role
    Condition: IsUSE1
    Properties:
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: "Allow"
            Action: "sts:AssumeRole"
            Principal:
              Service: "chatbot.amazonaws.com"
      Policies:
        - PolicyName: inline
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              - Action:
                  - "cloudwatch:Describe*"
                  - "cloudwatch:Get*"
                  - "cloudwatch:List*"
                Effect: "Allow"
                Resource: "*"
      RoleName: !Sub "${Prefix}-${Env}-awsbudgets-chatbot-role"

  AWSBudgetsForecastedChatbotLogGroup:
    Type: AWS::Logs::LogGroup
    Condition: IsUSE1
    DeletionPolicy: Delete
    UpdateReplacePolicy: Delete
    Properties:
      LogGroupName: !Sub "/aws/chatbot/${Prefix}-${Env}-awsbudgets-forecasted"
      RetentionInDays: 90

  AWSBudgetsActualChatbotLogGroup:
    Type: AWS::Logs::LogGroup
    Condition: IsUSE1
    DeletionPolicy: Delete
    UpdateReplacePolicy: Delete
    Properties:
      LogGroupName: !Sub "/aws/chatbot/${Prefix}-${Env}-awsbudgets-actual"
      RetentionInDays: 90

  # --------------------------------------------------
  # Budgets 毎月
  # --------------------------------------------------
  BudgetMonthly:
    Type: AWS::Budgets::Budget
    Condition: IsUSE1
    Properties:
      Budget:
        AutoAdjustData:
          AutoAdjustType: HISTORICAL
          HistoricalOptions:
            BudgetAdjustmentPeriod: 6
        BudgetName: !Sub
          - "Monthly Budget ${AccountAliasName}"
          - AccountAliasName: !FindInMap [Budget, !Ref AWS::AccountId, AccountAliasName]
        BudgetType: COST
        CostTypes:
          IncludeCredit: false
          IncludeDiscount: true
          IncludeOtherSubscription: true
          IncludeRecurring: true
          IncludeRefund: false
          IncludeSubscription: true
          IncludeSupport: true
          IncludeTax: true
          IncludeUpfront: false
          UseAmortized: false
          UseBlended: false
        TimeUnit: MONTHLY
      NotificationsWithSubscribers:
        # --------------------------------------------------
        # 予測
        # --------------------------------------------------
        - Notification:
            ComparisonOperator: GREATER_THAN
            NotificationType: FORECASTED
            Threshold: 120
            ThresholdType: PERCENTAGE
          Subscribers:
            - Address: !Ref BudgetsAlarmForecastedTopic
              SubscriptionType: SNS
        # --------------------------------------------------
        # 実績
        # --------------------------------------------------
        - Notification:
            ComparisonOperator: GREATER_THAN
            NotificationType: ACTUAL
            Threshold: 120
            ThresholdType: PERCENTAGE
          Subscribers:
            - Address: !Ref BudgetsAlarmActualTopic
              SubscriptionType: SNS

  # --------------------------------------------------
  # Budgets 毎日
  # --------------------------------------------------
  BudgetDaily:
    Type: AWS::Budgets::Budget
    Condition: IsUSE1
    Properties:
      Budget:
        AutoAdjustData:
          AutoAdjustType: HISTORICAL
          HistoricalOptions:
            BudgetAdjustmentPeriod: 60
        BudgetName: !Sub
          - "Daily Budgets ${AccountAliasName}"
          - AccountAliasName: !FindInMap [Budget, !Ref AWS::AccountId, AccountAliasName]
        BudgetType: COST
        CostTypes:
          IncludeCredit: false
          IncludeDiscount: true
          IncludeOtherSubscription: true
          IncludeRecurring: true
          IncludeRefund: false
          IncludeSubscription: true
          IncludeSupport: true
          IncludeTax: false
          IncludeUpfront: false
          UseAmortized: false
          UseBlended: false
        TimeUnit: DAILY
      NotificationsWithSubscribers:
        - Notification:
            ComparisonOperator: GREATER_THAN
            NotificationType: ACTUAL
            Threshold: 120
            ThresholdType: PERCENTAGE
          Subscribers:
            - Address: !Ref BudgetsAlarmActualTopic
              SubscriptionType: SNS
```

## 自動調整予算の設定値について

`TimeUnit` 毎に上限値が存在します。
`BudgetAdjustmentPeriod` の値をお好みの適切な値に調整してください。

| TimeUnit | 最大値 |
| --- | --- |
| DAILY | 60 |
| MONTHLY | 12 |
| QUARTERLY | 4 |
| ANNUALLY | 1 |

```yaml
AutoAdjustData:
    AutoAdjustType: HISTORICAL
    HistoricalOptions:
        BudgetAdjustmentPeriod: 60
```