AWS CDKで毎日の料金をSlackに通知する機能を実装してみた

August 06, 2023

やりたいこと

AWSの利用料は予算管理をしていても、予算の指定したパーセンテージに達するか、請求が確定するまでデフォルトでは通知ができません。 消し忘れのリソースについては1日でも早く気が付きたいです。 そのためには毎日コスト状況を確認する事が大事です。

ということで、毎日Slackに利用料を通知する仕組みを作ってみようと思います。 この手の話は沢山の方が記事にされているので、そこまで新規性はないですが、やったことメモ的な感じで残しておきます。

また、せっかくなのでCDKを使ってLambdaまで構築してみようと思います。 CDKはTypescript、Lambdaはpythonで作成してますが、個人的な趣味なので、気になる方は書き換えてください!

環境

構築環境はこちらです。

  • WSL2上のUbuntu22.04
  • node : v18.12.1
  • CDK : 2.54.0 (TypeScript)
  • AWS東京リージョン
  • Lambda:Python3.9

構築

Slackチャンネル設定

SlackでWebHookを受けられるように設定を行います。

通知したいチャンネルを作成し、チャンネルの設定から、「アプリを追加する」を選択し、「Appディレクトリを表示」をクリックすると、ブラウザにSlackのアプリ設定画面が開きます。

アプリを追加する
Appディレクトリを表示
アプリのビルド

Create AppからNameSpace等を設定し、Webhookの許可を行います。

アプリの作成
スクラッチで作成
各種設定

「Add New Webhook to Workspace」をクリックすると、チャンネル用のURLが表示されますので、コピーしておきます。

Webhook有効化
URL発行

CDKプロジェクト作成

基本的な構築手順はAWSの公式入門手順等を参考に、initします。

スタック名は今回、AwsCostalertSlackappStack という名前で作成しています。

Lambda作成

「lambda」というディレクトリに「app.py」を作成していきます。

# lambda/app.py

# encoding: utf-8
import json
import datetime
import requests
import boto3
import os
import logging

TODAY = datetime.datetime.utcnow()
FIRST_DAY_OF_THE_MONTH = TODAY - datetime.timedelta(days=TODAY.day - 1)
START_DATE = FIRST_DAY_OF_THE_MONTH.strftime('%Y/%m/%d').replace('/', '-')
END_DATE = TODAY.strftime('%Y/%m/%d').replace('/', '-')

SLACK_POST_URL = os.environ['SLACK_POST_URL']
SLACK_CHANNEL = os.environ['SLACK_CHANNEL']

logger = logging.getLogger()
logger.setLevel(logging.INFO)

client = boto3.client('ce')
sts = boto3.client('sts')
id_info = sts.get_caller_identity()

def get_total_cost():
    response = client.get_cost_and_usage(
        TimePeriod={
            'Start': START_DATE,
            'End': END_DATE
        },
        Granularity='MONTHLY',
        Metrics=[
            'UnblendedCost',
        ],
    )

    total_cost = response["ResultsByTime"][0]["Total"]["UnblendedCost"]["Amount"]
    return total_cost

def handler(event, context):
    text = "ID:{} の {}までのAWS合計料金 : ${}".format(id_info['Account'], END_DATE, get_total_cost())
    content = {"text": text}

    slack_message = {
        'channel': SLACK_CHANNEL,
        "attachments": [content],
    }

    try:
        requests.post(SLACK_POST_URL, data=json.dumps(slack_message))
    except requests.exceptions.RequestException as e:
        logger.error("Request failed: %s", e)

少しだけコードの解説をすると、cost-usageレポートの開始日と終了日を変数で定義し、それをもとにtotal_cost関数を実行しています。 Slackの設定はスタックで設定しているパラメータを参照するようになっています。

Lambda Layer作成

requestsモジュールを使うために、Lambda Layerを作ります。 boto3も必要があればインストールします。

mkdir lambda_layer  && cd lambda_layer

mkdir python
pip install -t python requests boto3

CDKスタック作成

CDKのスタックを更新します。

import * as cdk from 'aws-cdk-lib';
import { Construct } from 'constructs';
import * as lambda from 'aws-cdk-lib/aws-lambda';
import * as events from "aws-cdk-lib/aws-events";
import * as targets from 'aws-cdk-lib/aws-events-targets';
import * as iam from 'aws-cdk-lib/aws-iam';

export class AwsCostalertSlackappStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    // lambda-layer
    const layer = new lambda.LayerVersion(this, 'MyLayer', {
      code: lambda.Code.fromAsset("lambda_layer"),
      compatibleRuntimes: [lambda.Runtime.PYTHON_3_9],
    });
    
    // lambda
    const sampleLambda = new lambda.Function(this, 'NptifyPriceHandler', {
      runtime: lambda.Runtime.PYTHON_3_9,    // execution environment
      code: lambda.Code.fromAsset('lambda'),  // code loaded from "lambda" directory
      handler: 'app.handler',                // file is "hello", function is "handler"
      environment: {
        TZ: 'Asia/Tokyo',
        SLACK_POST_URL: 'コピーしたURL',
        SLACK_CHANNEL: '送信先チャンネル',
      },
      layers: [layer],
      initialPolicy: [new iam.PolicyStatement({
        actions: ['ce:GetCostAndUsage'],
        resources: ['*'],
      })],
    });

    // EventBridge
    new events.Rule(this, "sampleRule", {
      // JST で毎日 AM9:10 に定期実行
      // 参考 https://docs.aws.amazon.com/ja_jp/AmazonCloudWatch/latest/events/ScheduledEvents.html#CronExpressions
      schedule: events.Schedule.cron({minute: "10", hour: "0"}),
      targets: [new targets.LambdaFunction(sampleLambda, {retryAttempts: 3})],
  });
  }
}

先ほどのLambda_LayerとLambda、スケジュール実行するためのEventbridgeを定義しています。 細かいパラメータの説明は今回省かせていただきます。

lambdaコンストラクトを利用するため、以下コマンドでインストールします。

npm install aws-cdk-lib/aws-lambda

CDKデプロイ

アカウント内で構築するリージョンで初回実行の方はbootstrapが必要になります。

cdk bootstrap

作成したスタックをデプロイしてみましょう。

cdk deploy

正常に終了後、マネジメントコンソールでCloudFormationを確認してみましょう。 スタックが作成されているはずです。 後は実行されるまで待ちます。

実行確認

設定時刻にSlackを見てみると、、うまく実行されてますね! 料金はちょっと特殊な表示になってますが、あまり利用していないアカウントなのでそもそも利用料がないです。(面白味がないですね)

実行確認

改善

このままでも問題ないですが、よりセキュアに定義してみましょう。 どこかというと、スタック内でLambdaを定義する部分のパラメータをParameterStoreから参照出来るようにします。

      environment: {
        TZ: 'Asia/Tokyo',
        SLACK_POST_URL: 'コピーしたURL',
        SLACK_CHANNEL: '送信先チャンネル',
      },

ParameterStore自体は手動で作成します。 ParameterStoreをコードで作成してしまうと、結局クレデンシャルが残るのであまり好きではありませんし、適切ではないと思います。

また、せっかくなのでSecretsManagerも利用してURLの取得はそちらから行いたいと思います。 何のためかと言われると、普段コードを書く仕事をしていないので、この機会にチャレンジしてみようという、ただそれだけです!

ParameterStore作成

ParamaterStoreの作成は特に変わったことをするわけではないので、作成手順は割愛します。 型はString型で登録しています。SecureStringでも良いですが、その場合は、メソッドが変わるのでご注意ください。

Stack内でParameterStoreの値を参照する

先ほど作成したパラメータストアをスタックから参照します。

import { StringParameter } from 'aws-cdk-lib/aws-ssm';

export class AwsCostalertSlackappStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);
    // Parameter
    const slackChannel = StringParameter.valueForStringParameter(this, 'ParameterStore key');

コードとしては1行足すだけです。簡単ですね。 今回は string 型で登録しているので、valueForStringParameterメソッドを利用します。

SecretsManager作成

URLはSecretsManagerに登録してみます。 こちらも手順は省きますが、とりあえず登録してみました。

Lambda関数内でSecretsManagerの値を取得する

SecretsManagerに登録した値はスタックから参照すると、SecureStringとなってスタック内で型変換等が必要になるので、Lambdaが参照するようにしてみます。 Lambdaが起動するたびにSecretsManagerへのアクセスが発生するので、気になる方はParamaterStoreでcdk deployの時に取得する方法で十分かと思います。

SecretsManagerに値を登録すると、参考のコードが出てくるので、それをもとにします。 また、LambdaがSecretsManagerに読み込みが出来るようIAMロールに権限が必要ですので、適宜付けてください。

from botocore.exceptions import ClientError

def get_secret():

    secret_name = "SecretsManagerのシークレット名"
    region_name = "ap-northeast-1"

    # Create a Secrets Manager client
    session = boto3.session.Session()
    client = session.client(
        service_name='secretsmanager',
        region_name=region_name
    )

    try:
        get_secret_value_response = client.get_secret_value(
            SecretId=secret_name
        )
    except ClientError as e:
        # For a list of exceptions thrown, see
        # https://docs.aws.amazon.com/secretsmanager/latest/apireference/API_GetSecretValue.html
        raise e

    # Decrypts secret using the associated KMS key.
    secret = get_secret_value_response['SecretString']
    return secret

最後の2行だけ足しています。ここではSecretStringを返しているので、hondlerの方でastを使って辞書型に変換します。

import ast
def handler(event, context):
  secret = ast.literal_eval(get_secret())
  SLACK_POST_URL = secret['SecretsManagerのキー']

再デプロイ

再度デプロイして実行されるのを待ちます。 待つのが嫌な人は近い時間に設定してデプロイしましょう。

実行確認

問題なく実行されました! これでパラメータは別としたちょっとセキュア?なスタックを作成することが出来ました。

再実行確認

最後に

コスト管理はAWSを利用する上でIAMと同じくらい大事なので、出来れば最初に設定しておきたいですね。 Slackだけでなく、LINEに通知する記事も出ていたりするので、好みに合った通知方法を設定しておくことをおすすめします。 こまめに見るし、リソースは削除しているから大丈夫という方も、朝起きてびっくり!という事態を防ぐためにも、ぜひ導入しておきましょう!

最後まで呼んでいただいてありがとうございました!

コードはこちらにおいています。

https://github.com/nnydtmg/aws-costalert-slackapp


Profile picture

Written by hr0hr57 who lives and works in Tokyo tech company. You should follow me on Twitter