1.この記事を書こうと思った背景

  • AWSの利用料金が想定以上に高くてびっくりしたことがあった
  • 無事できたので、この記事でどうやったか?またいくつか検知する方法があるなかでどういった選定基準を持ったか?書いていきたい

Image from Gyazo

※ サンプルコードはこちら

https://github.com/gkzz/serverless-aws-budget-alerts-to-slack

2.前提

AWSの利用料金が想定より高くなったら検知する方法は2022/02/24時点で3種類あり、この記事ではbudget alertsを使った方法を採用している。ただし、各サービスの仕様変更やアップデートなどにより今後は他2つの方法を採用した方がいいということも。

3.環境情報

  • Serverless Frameworkのバージョンは3.2
$ grep VERSION= /etc/os-release 
VERSION="20.04.3 LTS (Focal Fossa)"
$ sls --version
Framework Core: 3.2.0
Plugin: 6.0.0
SDK: 4.3.1
$ npm --version
6.14.16
$ nodejs --version
v10.19.0
# .env
# .envはserverless.ymlと同じディレクトリに配置

# AWS
REGION="***"

# slack incoming webhook
SLACK_WEBHOOK_URL="https://hooks.slack.com/services/***"
  • serverless.ymlを配置したディレクトリをルートディレクトリとみたときのディレクトリ構造
    • serverless.ymlにLambdaの実行ファイルのパスなどを書くので、treeコマンドの結果を貼っておく
    • treeコマンドの結果からnode_modulesディレクトリは見やすさを考慮して除外した
$ tree -L 3 -I node_modules
.
## Lambdaの実行環境でnpmパッケージを使うことが出来るようにLambda Layersを使っている
# ※ serverless.ymlでLambda Layersをどうやってdeployするのか?については後述
# https://docs.aws.amazon.com/lambda/latest/dg/configuration-layers.html
├── lambdaLayer
│   └── nodejs
│       ├── package.json
│       └── package-lock.json
├── LICENSE
├── package.json
├── package-lock.json
├── README.md
├── serverless.yml  ## serverless deployコマンドでdeployするリソースなどを定義
└── src
    └── slack-notify.js  ## Lambdaの実行ファイル

3 directories, 8 files


$ find ./ -type d -name node_modules | grep -Ev "./node_modules/.+"
./node_modules  ## Serverless Frameworkの実行環境であるローカルで使う
./lambdaLayer/nodejs/node_modules  ## Lambdaの実行環境で使う

4.AWSの利用料金が想定より高くなったら検知する3つの方法

冒頭でお話ししたとおり、AWSの利用料金が想定より高くなったら検知する方法は、今回採用するbudget alerts以外に2種類ある。billing alertsとAWS Cost Anomaly Detectionだ。

それではbudget alertsを採用した理由はなにか?また、残り2つのサービスの採用をどうして見送ったか?こちらについて書いていこう。

5.選定理由

budget alertsを使って検知することにした理由

  • アラートを飛ばすかどうか判断するための閾値はコストの実測値だけではなくその予測値も指定することが出来る

billing alertsの採用を見送った理由

  • アラートを飛ばすかどうか判断するための閾値はコストの実測値だけしか指定できない(予測値を指定することができない)
  • アカウント単位でしか設定できない

AWS Cost Anomaly Detectionの採用を見送った理由

以下のドキュメントや記事を踏まえてを選んだ

6.どうやるか?

6-1.budget alertsを使ったAWSの利用料金が想定より高くなったら検知する仕組み(全体像)

  • budget alertsでコスト予算と閾値(Budget thresholds)を設定
  • 閾値を超えたらAmazon SNSからメッセージが発行される
  • メッセージが発行されるとLambdaがSlackへincoming webhook urlを使ってメッセージを送信

Image from Gyazo

6-2.serverless.ymlの書き方ポイント

Serverless Frameworkは内部的にはCloudFormationのスタックを作成しているのでServerless Frameworkでの書きっぷりに困ったら、Cfnのドキュメントや技術ブログを読むとヒントがあるかも!?

たとえば、、。

Every stage you deploy to with serverless.yml using the aws provider is a single AWS CloudFormation stack.

出所: Serverless Framework - AWS Lambda Guide - AWS Infrastructure Resources

  • serverless.yml(providerブロックとfunctionsブロックの書き方など)
service: src
frameworkVersion: '3'
useDotenv: true

provider:
  name: aws
  region: ${env:REGION}
  runtime: nodejs14.x
  memorySize: 128
  timeout: 300

# Lambdaの実行環境でnpmパッケージを使うことが出来るようにpackage.jsonなどを転送
# 転送しているファイル
# $ ls lambdaLayer/nodejs/
# node_modules  package.json  package-lock.json
#
#
# https://docs.aws.amazon.com/lambda/latest/dg/configuration-layers.html
#
layers:
  layerName:
    path: lambdaLayer
    description: npm package layers

plugins:
  - serverless-dotenv-plugin

functions:
  slack-notify:
    # Lambdaのエントリーポイント
    handler: src/slack-notify.handler
    name: slack-notify
    events:
      - sns:
          arn: !Ref snsTopic
          topicName: snsTopic
    environment:
      TZ: Asia/Tokyo
      SLACK_WEBHOOK_URL: ${env:SLACK_WEBHOOK_URL}
    layers:
      # Refでlayersでデプロイしているnpmパッケージを参照するようにし、Lambdaの実行環境にpackage.jsonとpackage-lock.jsonを配置する
      - { Ref: LayerNameLambdaLayer }

resources:


    snsTopic:

    snsTopicPolicy:



  Outputs:

  • serverless.yml(resourcesブロックとOutputsブロックの書き方。AWS BudgetsとAmazon SNSなどの設定をする。)
service: src
frameworkVersion: '3'
useDotenv: true

provider:


layers:


plugins:


functions:


resources:
  Description: "Set billing/budget alerts for the AWS account."
  Resources:
    # GUIでは https://docs.aws.amazon.com/cost-management/latest/userguide/budgets-create.html#create-cost-budget
    dailyBudget:
      Type: "AWS::Budgets::Budget"
      Properties:
        # https://docs.aws.amazon.com/ja_jp/AWSCloudFormation/latest/UserGuide/aws-properties-budgets-budget-budgetdata.html
        Budget:
          BudgetName: Daily
          BudgetType: COST
          # 今回は日次で予算管理したいので。他に月次、四半期、年次も指定可能。
          # 月次はMONTHLY、 四半期はQUARTERLY、年次ANNUALLY
          TimeUnit: DAILY
          BudgetLimit:
            # "TimeUnit"で指定したスパンで管理したい予算金額。
            # なお、ここでのアラートの閾値は"Amount"のN%といった具合に決めていく
            Amount: 20
            Unit: USD
        # https://docs.aws.amazon.com/ja_jp/AWSCloudFormation/latest/UserGuide/aws-properties-budgets-budget-notification.html
        NotificationsWithSubscribers:
         # https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-properties-budgets-budget-notification.html
          - Notification:
              NotificationType: ACTUAL # FORECASTEDも指定可能
              ComparisonOperator: GREATER_THAN
              Threshold: 50 # "Amount"の50%に到達したら通知
              ThresholdType: PERCENTAGE
            Subscribers:
            - SubscriptionType: SNS
              Address: { Ref: snsTopic }
          - Notification:
              NotificationType: ACTUAL
              ComparisonOperator: GREATER_THAN # 他にEQUAL_TOとLESS_THANも指定可能
              Threshold: 90 # "Amount"の90%に到達したら通知
              ThresholdType: PERCENTAGE # 他にABSOLUTE_VALUE、絶対値も指定可能
            Subscribers:
            - SubscriptionType: SNS
              Address: { Ref: snsTopic }

    # "NotificationsWithSubscribers"で作成したアラートをAmazon SNSで受け取る
    snsTopic:
      Type: AWS::SNS::Topic
      Properties:
        DisplayName: aws-budget-alert-to-slack-topic
        TopicName: aws-budget-alert-to-slack-topic

    # "snsTopic"を定義するだけでは以下のエラーを引いてしまう。"snsTopicPolicy"も必要!
    # How to fix ERORR "Your budget must have permissions to send a notification to your topic " on AWS
    # https://stackoverflow.com/questions/70021257/how-to-fix-erorr-your-budget-must-have-permissions-to-send-a-notification-to-yo
    snsTopicPolicy:
      Type: AWS::SNS::TopicPolicy
      Properties:
        PolicyDocument:
          {
            "Version": "2008-10-17",
            "Id": "__default_policy_ID",
            "Statement": [
              {
                "Sid": "AWSBudgetsSNSPublishingPermissions",
                "Effect": "Allow",
                "Principal": {
                  "Service": "budgets.amazonaws.com"
                },
                "Action": "SNS:Publish",
                # Fn::Subは変数と文字列を結合するために使っている。ここではRegionとAccountId
                # https://dev.classmethod.jp/articles/cloud%E2%80%8Bformation-intrinsic-function-memorandum/
                # RegionとAccountIdはAWS CloudFormation によって事前定義されたパラメータ、Pseudo parameters(疑似パラメータ)であり、AWS::Regionなどと使う
                # https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/pseudo-parameter-reference.html
                "Resource": !Sub "arn:aws:sns:${AWS::Region}:${AWS::AccountId}:aws-budget-alerts-to-slack-topic"
            },
            {
              "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": !Sub "arn:aws:sns:${AWS::Region}:${AWS::AccountId}:aws-budget-alerts-to-slack-topic",
              "Condition": {
                "StringEquals": {
                  "AWS:SourceOwner": !Ref "AWS::AccountId"
                }
              }
            }
          ]
        }
        Topics:
          - { Ref: snsTopic }

  # sls deployが完了してから出力したいもの
  Outputs:
    DailyBudgetId:
      Value: 
        Ref: dailyBudget
    snsTopicArn:
      Description: "ARN of SNS Topic"
      Value:
        Ref: snsTopic

上で書いた、serverless.ymlでデプロイする"functions"(Lambda)の実体である、src/slack-notify.jsについては、書きっぷりはご自身のニーズによって変わってしまうので参考までに。

const request = require('request-promise-native');

const sendMessage = (message) => {
    return request({
        method: 'POST',
        url: process.env.SLACK_WEBHOOK_URL,
        body: message,
        json: true,
    })
        .then((body) => {
            if (body === 'ok') {
                return {};
            } else {
                throw new Error(body);
            }
        });
};

const processRecord = (record) => {
    console.log(`record: ${JSON.stringify(record)}`);
    return sendMessage({
        // @here AWS Budgets: Daily has exceeded your alert threshold
        text: '<!here> ' + record.Sns.Subject,
        attachments: [{
            fields: [{
                title: 'Type',
                value: record.Sns.Type,
                short: true,
            }, {
                title: 'Time',
                value: record.Sns.Timestamp,
                short: true,
            }, {
                title: 'MessageId',
                value: record.Sns.MessageId,
                short: false,
            }, {
                title: 'Message',
                value: record.Sns.Message,
                short: false,
            }],
        }],
    });
};

/*
example event:
{
    "EventSource": "aws:sns",
    "EventVersion": "1.0",
    "EventSubscriptionArn": "arn:aws:sns:<REGION>:<xxxxxxxxx>:src-dev-snsTopic-<xxxxxxxxx>:<xxxxxxxxx>",
    "Sns": {
        "Type": "Notification",
        "MessageId": "<xxxxxxxxx>",
        "TopicArn": "arn:aws:sns:<REGION>:<xxxxxxxxx>:src-dev-snsTopic-<xxxxxxxxx>",
        "Subject": "<SUBJECT>",
        "Message": "<MESSAGE>",
        "Timestamp": "2022-02-17T17:33:07.585Z",
        "SignatureVersion": "1",
        "Signature": "<xxxxxxxxx>",
        "SigningCertUrl": "<xxxxxxxxx>",
        "UnsubscribeUrl": "<xxxxxxxxx>",
        "MessageAttributes": {}
    }
}
*/
module.exports.handler = (event, context, callback) => {
    console.log(`event received: ${JSON.stringify(event)}`);
    Promise.all(event.Records.map(processRecord))
        .then(() => callback(null))
        .catch((err) => callback(err));
};

あとはdeployするだけ!!

$ sls deploy

デバッグしたいときは --verbose オプションを付ける
$ sls deploy --verbose

リソースを削除する場合

$ sls remove

参考