【CDK】Cost Anomaly DetectionがChatbotと統合されたのでCDKで実装してみた【アップデート】

## 背景
最近円安が進んでますね。
AWSの請求レートが上昇して心穏やかじゃない日々を過ごしています、という方に読んで欲しい

改めて確認したいAWSの予算設定
ついでに導入しておきたい**Cost Anomaly Detection(コスト異常検出)**

そんなCost Anomaly Detectionですが、3/14に以下のアップデートが発表されてSlackで通知を受け取ることが出来るようになりました！

折角なのでCDKで実装して動作を確認してみました。
※ついでにBudgetのslack通知も

>本日より、AWSチャットボットを介してSlackとAmazonChimeでAWSコスト異常検出アラート通知を受信できるようになります。AWS Chatbotとの統合により、SlackまたはAmazonChimeチャットチャネルを使用してコスト異常アラートサブスクリプションを簡単に構成できます。これにより、既存のチャットチャネル内で個々のAWS Cost Anomaly Detectionアラートを受信できるようになり、コラボレーションの向上とアラートのタイムリーな解決がサポートされます。

https://aws.amazon.com/jp/about-aws/whats-new/2022/03/aws-cost-anomaly-detection-integration-chatbot/

## AWS Cost Anomaly Detection(コスト異常検出)とは
AWS Cost Anomaly Detectionはコストエクスプローラーの機能の1つで、Machine Learningを使用してAWS使用料における異常な支出と根本原因を特定できるようになります。この機能を利用する事で、**個々のAWSサービス**の使用量などをモニタリングして**指定した閾値幅以上或いは以下を検知**した場合に、**SNSやEMailをターゲットに通知**させることが出来ます。

Cost Anomaly Detectionは、**過去のAWS利用履歴に基づいて通常の利用パターンが作られ、実際の利用料との差額が設定した閾値を超えた時にアラートが発報**されます。

<img width="640" alt="スクリーンショット 2022-03-23 21.51.00.png (13.6 kB)" src="https://img.esa.io/uploads/production/attachments/10655/2022/03/23/108350/e0a9b4af-724e-4a32-b005-3f5008c6553b.png">

また、実際の利用料と通常パターンとの差額が、設定した閾値を下回っても同様にアラートが鳴るという仕組みです。検出された異常値がコストに対してどの様なインパクトを与えるかは検出履歴から確認が出来ます。

<img width="639" alt="スクリーンショット 2022-03-23 21.53.02.png (13.8 kB)" src="https://img.esa.io/uploads/production/attachments/10655/2022/03/23/108350/b4c256b6-bfb2-4b7f-98c2-0d0f622eaeb9.png">

例として下図の場合ですと、通常利用が100前後だとして閾値を20と設定した場合、赤線を超えた箇所でアラートが鳴るイメージです。※Cost Anomaly DetectionのML内部がどうなっているのか分からないので飽くまでも自分の理解したイメージです。

<img width="673" alt="スクリーンショット_2022-03-23_22_01_29.png (25.8 kB)" src="https://img.esa.io/uploads/production/attachments/10655/2022/03/23/108350/efdd099f-c9a6-4b7f-aed7-16cefac24366.png">

### 選べる4つのモニタリングケース
Cost Anomaly Detectionでは4種類のモニタリング形式があり、それぞれ以下の特徴があり、それぞれのユースケースに合わせて選択できます。

- AWSサービス(**推奨**)
    - 使用する各サービスを個別に評価し、小さな異常を検出
    - **異常閾値は、サービス使用履歴パターンに基づいて自動的に調整**
 - 連結アカウント
     - 個々の連結アカウントの総支出を評価
     - 組織が連結アカウント別にチームを定義する場合に役立つ
 - コスト配分タグ
     - 個々のコストカテゴリ値の総支出を評価
     - 組織が Cost Categories を使用してチーム を定義する場合に役立つ
 - コストカテゴリ
     - 個々のタグのキーと値のペアの総支出を評価
     - 組織がコスト配分タグを使用してチームを定義する場合に役立つ

### 選べる3つのアラート頻度
アラートの頻度に関してもAWSサービス毎にすぐに通知するか日次or週次で纏めてメールで送るかを選択できます。

- 個々のアラート
    - 異常が検出された場合にすぐにアラートが表示
    - 対応ターゲット: SNSトピック
- 日別の概要
    - 異常が検出されると、アラートは日別の概要を通知
    - 対応ターゲット:  Email
- 週別の概要
    - 異常が検出されると、アラートは週別の概要を通知
    - 対応ターゲット:  Email

### 検出の評価
Cost Anomaly Detectionでは検出された異常値を評価してフィードバックを提供する事で異常検出システムの改善に役立てる事が出来ます。

- 検出履歴から対象の検出日を選択
<img width="860" alt="スクリーンショット 2022-03-23 22.30.24.png (73.4 kB)" src="https://img.esa.io/uploads/production/attachments/10655/2022/03/23/108350/d2b22c89-8542-4914-a357-b1f3d9fa5e2c.png">

- 画面右上の「評価を送信」を押下
<img width="1413" alt="スクリーンショット_2022-03-23_11_32_59.png (157.7 kB)" src="https://img.esa.io/uploads/production/attachments/10655/2022/03/23/108350/4eb33d96-38dd-4d41-8e5a-41352da2fc3c.png">

- 3つの項目から選択して「送信」を押下
<img width="595" alt="スクリーンショット_2022-03-23_22_32_33.png (53.3 kB)" src="https://img.esa.io/uploads/production/attachments/10655/2022/03/23/108350/41aaaa83-e684-447d-bdc7-74b1051aa5b6.png">


## 事前準備
### ChatbotとSlack連携
事前にchatbotがslackworkspaceにアクセスする権限を付与しておきましょう。

- AWS Chatbotコンソールより「チャットクライアント」を「Slack」にして「クライアントを設定」を押下する
 <img width="713" alt="スクリーンショット_2022-03-12_16_05_21.png (64.0 kB)" src="https://img.esa.io/uploads/production/attachments/10655/2022/03/12/108350/3380bf47-3651-4dfc-b391-03dd2152d3e5.png">

- アクセス権限をリクエスト画面が表示されたら対象のSlackワークスペースを確認して「許可する」を押下する
<img width="531" alt="スクリーンショット_2022-03-12_16_13_16.png (69.1 kB)" src="https://img.esa.io/uploads/production/attachments/10655/2022/03/12/108350/33ba529b-7d34-4018-a150-8cbd8a23b6ae.png">

## CDK
今回はサービス毎にスタックを分けて、リソースのスタック間参照を使って実装しています。

また、Cost Anomaly Detectionに加えてBudgetのSlack通知も併せて検証していきます。

### cdk.json
Contextにはそれぞれ以下を指定。
※amountはBudgetで使う予算額を入れてます。
slackWorkspaceIdとslackChannelIdは検証者の環境を適宜指定

>
    "prefix": "test",
    "slackWorkspaceId":"xxxxxxxxx",
    "slackChannelId":"xxxxxxxxx",
    "amount": 500

```cdk.json
{
  "app": "npx ts-node --prefer-ts-exts bin/src.ts",
  "watch": {
    "include": [
      "**"
    ],
    "exclude": [
      "README.md",
      "cdk*.json",
      "**/*.d.ts",
      "**/*.js",
      "tsconfig.json",
      "package*.json",
      "yarn.lock",
      "node_modules",
      "test"
    ]
  },
  "context": {
    "@aws-cdk/aws-apigateway:usagePlanKeyOrderInsensitiveId": true,
    "@aws-cdk/core:stackRelativeExports": true,
    "@aws-cdk/aws-rds:lowercaseDbIdentifier": true,
    "@aws-cdk/aws-lambda:recognizeVersionProps": true,
    "@aws-cdk/aws-cloudfront:defaultSecurityPolicyTLSv1.2_2021": true,
    "@aws-cdk-containers/ecs-service-extensions:enableDefaultLogDriver": true,
    "@aws-cdk/core:target-partitions": [
      "aws",
      "aws-cn"
    ],
    "prefix": "test",
    "slackWorkspaceId":"xxxxxxxxx",
    "slackChannelId":"xxxxxxxxx",
    "amount": 500
  }
}
```

### app
今回はStackをそれぞれで分けて、クロススタック参照でリソースの値を引用させてます。
※regionがus-east-1でないとCost Anomaly Detectionが正常にデプロイ出来なかったのでそれに合わせてChatbot以外は併せてます。

```bin/src.ts
#!/usr/bin/env node
import * as cdk from "aws-cdk-lib";
import { SnsStack } from "../lib/sns-stack";
import { ChatbotStack } from "../lib/chatbot-stack";
import { BudgetStack } from "../lib/budget-stack";
import { CostAnomalyStack } from "../lib/costanomaly-stack";

const app = new cdk.App();

const snsStack = new SnsStack(app, `sns-stack`,{
    env: {account: process.env.CDK_DEFAULT_ACCOUNT, region: "us-east-1"},
});
const chatbotStack = new ChatbotStack(app, `chatbot-stack`, snsStack,);
const budgetStack = new BudgetStack(app, `budget-stack`, snsStack,{
    env: {account: process.env.CDK_DEFAULT_ACCOUNT, region: "us-east-1"},
});
const costanomalyStack = new CostAnomalyStack(app, `costanomaly-stack`, snsStack,{
    env: {account: process.env.CDK_DEFAULT_ACCOUNT, region: "us-east-1"},
});

chatbotStack.addDependency(snsStack);
budgetStack.addDependency(snsStack);
budgetStack.addDependency(snsStack);
costanomalyStack.addDependency(snsStack);
```

### stack
#### sns-stack
```lib/sns-stack.ts
import * as cdk from "aws-cdk-lib";
import * as sns from "aws-cdk-lib/aws-sns";
import * as iam from 'aws-cdk-lib/aws-iam';

export interface SnsStackProps { 
  readonly snsTopic: sns.Topic
}

export class SnsStack extends cdk.Stack {
  public readonly snsTopic: sns.Topic

  // SNS Topic
  private createSnsTopic(name: string): sns.Topic {

    const snsTopic = new sns.Topic(this, `${name}`, {
      displayName: "alert Nottification",
      topicName: name,
    });
    snsTopic.addToResourcePolicy(new iam.PolicyStatement({
      sid: 'SNSPublishingPermissions',
      effect: iam.Effect.ALLOW,
      principals: [
        new iam.ServicePrincipal('costalerts.amazonaws.com'),
        new iam.ServicePrincipal('budgets.amazonaws.com'),
      ],
      actions: ['SNS:Publish'],
      resources: [snsTopic.topicArn],
    }));
    return snsTopic;
  }

  constructor(scope: cdk.App, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    const prefix = this.node.tryGetContext("prefix"); // Contextで指定したprefixを取得

    this.snsTopic = this.createSnsTopic(`${prefix}-sns-topic`);
  }
}
```

#### chatbot-stack
```lib/chatbot-stack.ts
import * as cdk from "aws-cdk-lib";
import * as sns from "aws-cdk-lib/aws-sns";
import type { SnsStackProps } from "./sns-stack"
import * as chatbot from "aws-cdk-lib/aws-chatbot";

export class ChatbotStack extends cdk.Stack {

  // Chatbot
  private createChatbot(name: string, sns: sns.Topic): chatbot.SlackChannelConfiguration {
    const slackWorkspaceId = this.node.tryGetContext("slackWorkspaceId"); // Contextで指定したslackworkspaceidを取得
    const slackChannelId = this.node.tryGetContext("slackChannelId"); // Contextで指定したslackchannelidを取得

    const slackchatbot = new chatbot.SlackChannelConfiguration(this, `${name}`, {
      slackChannelConfigurationName: name,
      slackWorkspaceId: slackWorkspaceId, // 事前にコンソール上でchatbotがslackworkspaceにアクセスする権限を与えていること
      slackChannelId: slackChannelId,
      loggingLevel: chatbot.LoggingLevel.INFO,
      notificationTopics: [sns],
    });
    return slackchatbot;
  }

  constructor(scope: cdk.App, id: string, SnsStack: SnsStackProps, props?: cdk.StackProps) {
    super(scope, id, props);

    const prefix = this.node.tryGetContext("prefix"); // Contextで指定したprefixを取得

    this.createChatbot(`${prefix}-chatbot`, SnsStack.snsTopic);
  }
}
```
#### budget-stack
```lib/budget-stack.ts
import * as cdk from "aws-cdk-lib";
import { aws_budgets as budgets } from "aws-cdk-lib";
import * as sns from "aws-cdk-lib/aws-sns";
import type { SnsStackProps } from "./sns-stack"

export class BudgetStack extends cdk.Stack {

  // Budget
  private createBudget(name: string, sns: sns.Topic): budgets.CfnBudget {
    const amount = this.node.tryGetContext("amount"); // Contextで指定したamountを取得
    
    const budget = new budgets.CfnBudget(this, `${name}`,{
      budget:{
        budgetName: `${name}`,
        budgetType: `COST`,
        timeUnit: `MONTHLY`,
        budgetLimit: {
          amount: amount, // Contextで予算上限を指定
          unit: "USD"
        },
        costTypes: {
          includeCredit: false,
          includeDiscount: true,
          includeOtherSubscription: true,
          includeRecurring: true,
          includeRefund: false,
          includeSubscription: true,
          includeSupport: true,
          includeTax: true,
          includeUpfront: true,
          useAmortized: true,
          useBlended: false,
        },
      },
      notificationsWithSubscribers: [{
        notification: {
          comparisonOperator: 'GREATER_THAN',
          notificationType: 'FORECASTED',
          threshold: 100,
          thresholdType: 'PERCENTAGE',
        },
        subscribers: [{
          address: `${sns.topicArn}`,
          subscriptionType: 'SNS',
        }],
      }],
    })
    return budget;
  }

  constructor(scope: cdk.App, id: string, SnsStack: SnsStackProps, props?: cdk.StackProps) {
    super(scope, id, props);

    const prefix = this.node.tryGetContext("prefix"); // Contextで指定したprefixを取得

    this.createBudget(`${prefix}-budget-monitoring`, SnsStack.snsTopic);
  }
}
```

#### costanomaly-stack
```lib/costanomaly-stack.ts
import * as cdk from "aws-cdk-lib";
import { aws_ce as ce } from "aws-cdk-lib";
import * as sns from "aws-cdk-lib/aws-sns";
import type { SnsStackProps } from "./sns-stack"

export interface CostAnomalyStackProps {
  readonly anomalymonitor: ce.CfnAnomalyMonitor
}

export class CostAnomalyStack extends cdk.Stack {
  public readonly anomalymonitor: ce.CfnAnomalyMonitor

  // Anomaly Monitor
  private createAnomalyMonitor(name: string): ce.CfnAnomalyMonitor {
    
    const anomalymonitor = new ce.CfnAnomalyMonitor(this, `${name}`,{
      monitorName: `${name}`,
      monitorType: 'DIMENSIONAL',
      monitorDimension: 'SERVICE',
    });
    return anomalymonitor;
  }

  // Anomaly Subscription
  private createAnomalySubscription(name: string, sns: sns.Topic, monitor: ce.CfnAnomalyMonitor): ce.CfnAnomalySubscription {
    
    const anomalysubscription = new ce.CfnAnomalySubscription(this, `${name}`,{
      frequency: 'IMMEDIATE',
      monitorArnList: [`${monitor.attrMonitorArn}`],
      subscribers: [{
        address: `${sns.topicArn}`,
        type: 'SNS',
        status: 'CONFIRMED',
      }],
      subscriptionName: `${name}`,
      threshold: 1, //　動作確認の為$1を指定
    });
    return anomalysubscription;
  }

  constructor(scope: cdk.App, id: string, SnsStack: SnsStackProps, props?: cdk.StackProps) {
    super(scope, id, props);

    const prefix = this.node.tryGetContext("prefix"); // Contextで指定したprefixを取得

    this.anomalymonitor = this.createAnomalyMonitor(`${prefix}-anomaly-monitor`);
    this.createAnomalySubscription(`${prefix}-anomaly-subscription`, SnsStack.snsTopic, this.anomalymonitor);
  }
}
```

## 動作確認
それぞれの通知結果

### Budget
<img width="619" alt="スクリーンショット_2022-03-15_20_23_06.png (32.4 kB)" src="https://img.esa.io/uploads/production/attachments/10655/2022/03/15/108350/300ddaba-9bbf-4b12-b090-b26713e138fd.png">

### コスト異常検出
アラート通知まで数日を要してやきもきしていましたが無事が届きました。

<img width="683" alt="スクリーンショット_2022-03-22_15_59_00.png (63.8 kB)" src="https://img.esa.io/uploads/production/attachments/10655/2022/03/22/108350/8dbbc3e1-edba-4160-9779-49bb5b83aa8f.png">

<span style="color: red; ">後で気付いたことですが、使用にあたって以下を認識しておく必要があります。</span>

>AWS コスト異常検出は、請求データが処理されてから 1 日に約 3 回実行されます。アラートの受け取りには若干の遅延が発生することがあります。その結果、アラートを受け取るまでに通知金額を超える追加コストが累積される可能性があります。

そもそもの請求データが揃わないといけないので、アラート通知が出るまで若干のタイムラグが発生するようです。

## おわりに
AWSBudgetは予め決めた予算額に対してAWS利用料が超えているか否かをモニタリングして閾値を超えたら通知するという機能ですが、例えば全体としては予算額に収まっているけれどある特定のAWSサービスが本来想定している額を大きく上回っているというケースで発覚するのが遅くなりがちです。

Cost Anomaly Detectionであれば、個々のAWSサービスの利用歴から異常値を検出出来るので検知を早めることができるというメリットがあります。

それぞれを併用する事でより効果的にコストをモニタリングが出来るようになるので是非活用していきたいですね。

## 参照
https://docs.aws.amazon.com/ja_jp/cost-management/latest/userguide/manage-ad.html
https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.aws_ce.CfnAnomalyMonitor.html
https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.aws_ce.CfnAnomalySubscription.html
https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.aws_budgets.CfnBudget.html