AWS請求額管理の手間を減らす!Slack通知の仕組みを構築する方法

Infraチームの中尾です。
2023年2月で入社してからちょうど1年が経ちましたが、相当密度の濃い1年を経験させていただきました。 入社してまもなくWallet StationのAWS移行や踏み台環境の移行をメインの業務として担当したので、短期間でAWSやAzureなどの知識が相当増えたと思います。 今後もインフラチームとしてプロダクトの安定稼働やトイルの削減を実施していきます。
今回のテーマはAWSの請求額を自動的にSlackに通知する仕組みを構築する方法についてです。

目的

  • 別チームにAWSのコストを見たいと言われた際、毎月コンソールから見てレポートを取得するのが面倒くさいので、定期的にSlackチャンネルに通知するようにしたい
  • インフラチーム以外の日頃のコスト意識を少しでも高めたい

前提

  • Cost Explorerを変更かつ各リソースを作成できる権限を持つユーザーが払い出されていること
  • Docker実行環境があること

構成図

指定した日時になるとEventBridgeがLamdaを起動し、LamdaがAWSのCostExplorerからその月にかかったコストを取得し、Slackに通知するという仕組みを構築しています。

完成イメージ

「Billing警察」をクリックするとAWS請求画面に飛ぶこともできます。

作成手順

①CostExplorerの有効化

AWS Cost Explorerとは、AWSのリソースの使用状況と、コストを可視化して分析できるサービスです。 このCost ExplorerにLamda関数からアクセスし、AWSの利用金額を取得します。
 1. AWSのコンソール画面を開き、[AWS Cost Explorer]-[設定]を選択し、各チェックボックスにチェックをいれて[設定の保存]をクリック

②IAMの設定

lambdaがCost Explorerから請求データを取得する権限を持つIAMポリシー/ロールを設定します。
 1. IAMの管理画面より[ポリシー]-[ポリシーを作成]をクリック
 2. Jsonタブを選択し、以下の内容を張り付けます
 3. 適当なリソース名でポリシーを作成をクリック

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "VisualEditor0",
            "Effect": "Allow",
            "Action": [
                "logs:CreateLogStream",
                "ce:GetCostAndUsage",
                "logs:CreateLogGroup",
                "logs:PutLogEvents"
            ],
            "Resource": "*"
        }
    ]
}

 4. IAMの管理画面より[ロール]- [ロール]を作成をクリック
 5. カスタム信頼ポリシーを選択し、以下を張り付けます

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Principal": {
                "Service": "lambda.amazonaws.com"
            },
            "Action": "sts:AssumeRole"
        }
    ]
}

 6. 先ほど作成したポリシーと以下のマネージドポリシーを選択します

  • AWSLambdaBasicExecutionRole
  • CloudWatchReadOnlyAccess
  • AmazonSSMFullAccess

 7. 適当なリソース名でロールを作成をクリック

③請求額取得スクリプト実行用lambdaを作成

Cost Explorerから請求額を取得するスクリプトを実行するLambda関数を作成します。
 1. Lambdaの管理画面より[関数]- [関数の作成]をクリック
 2. 以下の設定値を入力します

 3. 上記の設定値を入力後、[関数の作成]をクリック

④Lambdaレイヤーを作成

LambdaレイヤーとはLambda関数で使用できるライブラリとその他の依存関係をパッケージ化し、Lambda関数間で共有可能にする機能です。
AWSコストはドルで請求され、日本円に変換してslackに通知する為、Pandasを使ってコスト取得時の為替レートを取得したいですが、LambdaデフォルトライブラリではPandasが提供されていないので、Lambdaレイヤーにパッケージ化したファイルをアップロードする必要があります。
 1. Bash実行環境にて以下のコマンドを実行し、レイヤーにアップロードするファイルを作成します。※事前にDockerデーモンが起動している環境で実行する必要があります

mkdir -p python
cd python
echo pandas >> requirements.txt
echo pandas_datareader >> requirements.txt
docker run -it --rm -v $(pwd):/var/task amazon/aws-sam-cli-build-image-python3.8:latest pip install -r requirements.txt -t . #LambdaはAmazon Linuxなので、Macでpip installしても動かない場合がある為
zip -r layer.zip python #zipファイル名は特に指定なし

 2. Lambdaの管理画面より[レイヤー]- [レイヤーの作成]をクリック
 3. 以下の設定値を入力します

  • 名前:任意のレイヤー名
  • アップロード:上記で作成したzipファイル
  • 互換性のあるアーキテクチャ :x86_64
  • 互換性のあるランタイム:Python 3.8

 4. 上記の設定値を入力後、[作成]をクリック

 5. Lambdaの管理画面より[関数]- [手順③で作成した関数名]をクリック
 6. レイヤー設定から[レイヤーの追加]をクリック
 7. [カスタムレイヤー]-[上記で作成したレイヤー名]を選択し、バージョン1を指定し、[追加]をクリック

⑤パラメータストアにSlackチャンネルのWebhookURLを設定

Systems Manager Parameter Store(パラメータストア)とはAWS Systems Manager のサービスの一つで、データベースへの接続文字列のような機密情報を一元管理するサービスです。
スクリプト内で使用する環境変数に通知するslackチャンネルのWebhookURLを設定するので、暗号化して変数として登録しておき、パラメータストアから参照した値を復号化して使用します。
事前にこちらのサイトを参考にWebhookURLを用意します。
 1. パラメータストアの管理画面より[パラメータの作成]をクリック
 2. 以下の設定値を入力します

  • 名前:SLACK_URL
  • 利用枠:標準
  • タイプ :安全な文字列
  • KMS キーソース:現在のアカウント
  • 値:事前に取得したwebhookURL

 3. 上記の設定値を入力後、[パラメータを作成]をクリック

環境変数の設定

Lambda内で使用できる環境変数を設定します。
 1. Lambdaの管理画面より[関数]- [手順③で作成した関数名]をクリック
 2. [設定]-[環境変数]-[編集]をクリック

 3. 以下の変数と値を入力します

  • AWS_ACCOUNT_NAME:任意の名前
  • SLACK_CHANNEL:任意のチャンネル名

 4. 上記の設定値を入力後、[保存]をクリック

⑦請求額取得スクリプト作成

スクリプトをLambda上に作成します。
 1. Lambdaの管理画面より[関数]- [手順③で作成した関数名]をクリック
 2. [コード]に以下を張り付けて[Deploy]をクリック※142行目のauthor_linkの値は任意のCost Explorer画面のURL に置き換えてください

import boto3
import json
import logging
import os
import sys
import math

from base64 import b64decode
from urllib.request import Request, urlopen
from urllib.error import URLError, HTTPError

from datetime import datetime, date, timedelta
from dateutil.relativedelta import relativedelta
from decimal import *
from pandas_datareader.data import get_quote_yahoo

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

ce = boto3.client('ce')

# 実行時の為替レート取得
result = get_quote_yahoo('JPY=X')
ary_result = result["price"].values
price = ary_result[0]
price = math.floor(price)

# パラメータストアからwebhookurlを取得する
def get_ssm_params(*keys, region='ap-northeast-1'):
    result = {}
    ssm = boto3.client('ssm', region)
    response = ssm.get_parameters(
        Names=keys,
        WithDecryption=True,
    )

    for p in response['Parameters']:
        result[p['Name']] = p['Value']

    return result
    
parameters = get_ssm_params('SLACK_URL')
SLACK_CHANNEL     = os.environ['SLACK_CHANNEL']
SLACK_WEBHOOK_URL = parameters['SLACK_URL']
AWS_ACCOUNT_NAME  = os.environ['AWS_ACCOUNT_NAME']

# 対象月にかかったAWSの合計金額の算出
def get_total_billing(client) -> dict:
    #コスト集計範囲の取得
    (start_date, end_date) = get_total_cost_date_range()

    # https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/ce.html#CostExplorer.Client.get_cost_and_usage
    #コスト集計範囲に対する合計コストの取得
    response = ce.get_cost_and_usage(
        TimePeriod={
            'Start': start_date,
            'End': end_date
        },
        Granularity='MONTHLY',
        Metrics=[
            'AmortizedCost'
        ]
    )
    #取得したデータの返却
    return {
        'start': response['ResultsByTime'][0]['TimePeriod']['Start'],
        'end': response['ResultsByTime'][0]['TimePeriod']['End'],
        'billing': response['ResultsByTime'][0]['Total']['AmortizedCost']['Amount'],
    }
    
# 対象月のコスト算出対象の初日と当日の日付を取得する
def get_total_cost_date_range() -> (str, str):

    # Costを算出する期間を設定する
    start_date = get_begin_of_month()
    end_date = get_today()

    #「start_date」と「end_date」が同じ場合「start_date」は先月の月初の値を取得する。
    if start_date == end_date:
        end_of_month = datetime.strptime(start_date, '%Y-%m-%d') + timedelta(days=-1)
        begin_of_month = end_of_month.replace(day=1)
        return begin_of_month.date().isoformat(), end_date
    return start_date, end_date

def get_begin_of_month() -> str:
    return date.today().replace(day=1).isoformat()


def get_prev_day(prev: int) -> str:
    return (date.today() - timedelta(days=prev)).isoformat()


def get_today() -> str:
    return date.today().isoformat()    

def lambda_handler(event, context):
    logger.info("Event: %s", str(event))

    (start_date, end_date) = get_total_cost_date_range()

    fields = []
    total  = Decimal(0)

    ce_res = ce.get_cost_and_usage(
        TimePeriod = {
            'Start': str(start_date),
            'End'  : str(end_date)
        },
        Granularity = 'MONTHLY',
        Metrics     = ['UnblendedCost'],
        GroupBy     = [{
            'Type': 'DIMENSION',  #タグの値別で内訳を出す場合はTypeにTAG、Keyにタグ名を入れる
            'Key' : 'SERVICE'  #アカウント別で内訳を出す場合はKeyにLINKED_ACCOUNTを入れる
        }]
    )
    logger.info("CostAndUsage: %s", str(ce_res))
    
    rbt = ce_res['ResultsByTime']
    for groups in rbt:
        for group in groups['Groups']:
            
            value = round(Decimal(group["Metrics"]["UnblendedCost"]["Amount"]) * price, 0)
            # 情報量が多いため、2円以下は割愛
            if value >= 2:
                print(value)
                fields.append({
                    "title": group["Keys"][0],
                    "value": ":yen: " + str("{:,}".format(value)) + "円",
                    "short": True
                })
                total += Decimal(group["Metrics"]["UnblendedCost"]["Amount"])
            else:
                logger.info("Event: %s is under 1 yen", group["Keys"][0])
    
    # Slack通知内容の作成
    slack_message = {
        "attachments": [
            {
                'fallback'   : "Required plain-text summary of the attachment.",
                'color'      : "#36a64f",
                'author_name': ":male-police-officer: :moneybag: Billing 警察",
                'author_link': "【表示したいAWSアカウントのCostExploler画面URL】",
                'text'       : "<!here>【サービス別】",
                'fields'     : fields,
                'footer'     : "Powered by on %s Lambda" % (str(AWS_ACCOUNT_NAME)),
                'footer_icon': "https://platform.slack-edge.com/img/default_application_icon.png",
                'pretext'    : "* %s~%s の [ %s ] のAWS利用料 は :money_with_wings: %s 円です ※本日の為替レート[%s 円/1ドル]で計算しています*" % (str(start_date), str(end_date), str(AWS_ACCOUNT_NAME), str("{:,}".format(round(total * price, 0))), str(price)),
                'channel'    : SLACK_CHANNEL
            }
        ]
    }
    
    # Slackへの通知
    req = Request(SLACK_WEBHOOK_URL, json.dumps(slack_message).encode('utf-8'))
    try:
        response = urlopen(req)
        response.read()
        logger.info("Message posted to %s", slack_message['attachments'][0]['channel'])
    except HTTPError as e:
        logger.error("Request failed: %d %s", e.code, e.reason)
    except URLError as e:
        logger.error("Server connection failed: %s", e.reason)

⑧スケジュールからLambdaを定期起動するEventBridgeを作成

Amazon EventBridgeとは、AWSで発生する様々なイベントやSaaSから発生するイベントを使用して、さらに様々なAWSサービスとつなげるサービスです。
月の初めにslackに請求額をSlackに通知したいので、EventBridgeのスケジュールからLambdaを呼び出します。
 1. Lambdaの管理画面より[関数]- [手順③で作成した関数名]をクリック
 2. [トリガーを追加]を選択し、以下の設定値を入力します

  • ソースの選択:EventBridge
  • ルール:新規ルールを作成
  • ルール名:任意のルール名
  • ルールタイプ:スケジュール式
  • スケジュール式:cron(0 0 0 1 * ? *)※cronの書き方はこちらの公式ドキュメントを参照

 3. 上記の設定値を入力後、[追加]をクリック

これで毎月決められた日次にSlackにAWSの請求額が日本円に変換され通知されます。

今後の課題

今回は先月のコストを取得して通知しましたが、前月との差分を取得してどれくらいコストに変化があったか表示できればもっと便利だなと感じているので、今後実装したいと思います。

まとめ

AWSの先月の請求額を定期的にSlackへ通知する仕組みを構築しました。
こちらを月初めに通知することで開発する上で不要なコストがかかっていないか等を考えるきっかけになればよいと思います。
インフラチームとしてコスト削減策を実施しているので、この通知に表示されている請求額を減らしていくことをモチベーションに頑張ります。

参考

https://fresopiya.com/2022/05/30/cost-slack/
https://qiita.com/turupon/items/4e9af748b5d43bddcf8d