2021年9月16日

AWS Organization下アカウント課金情報をSlackに投稿する

ce-thumbnail 
こんにちは!
「技術記事書いて!」と急に無茶振りされたシステム開発部エンジニアのSと申します。
何を書こうか迷いましたが、最近社内のAWS管理を行っているのでそのノウハウをご紹介しようと思います!
 
突然ですが、Organizationを用いて複数AWSアカウントを一括請求管理していると各アカウント毎の請求情報をいちいち確認するのめんどくさいですよね?ニッチな悩みかもしれないですが、私はめんどくさいです。
そこで、複数AWSアカウントの請求情報をまとめてSlackに投稿するサーバーレスアプリケーションをサクッとデプロイして楽をしようと思います!

前提

  • ServerlessFrameworkを使います。
  • CostExplorerのAPIを1実行毎に叩くので、最低でも月々30円程の課金が発生します。
  • Organizationで管理している複数アカウントの課金情報を取ってくるので課金管理アカウントで実行します。
  • SlackとのWebhook連携は完了していて Incoming Webhook は既に払い出されているものとします。
  • ServerlessFrameworkとSlackに関しての詳しい説明はしません。

各種ツール / バージョン

  • Ubuntu / 20.04.2 LTS
  • ServerlessFramework / 2.28.7
  • Python / 3.8.7

フォルダ構造

.
├── handler.py
└── serverless.yml

handler.py

CostExplore::GetCostAndUsage

まずはCostExplorerのget_cost_and_usageAPIを実行して課金情報をまとめて取得します。前日の課金情報はまだ集計が終わってない事があるので、今日から数えて2日前から昨日までのデータを日毎、アカウント毎に集約して取得します。

ce_args = {
    'TimePeriod': {
        'Start': str(today - timedelta(days=2)),
        'End': str(today),
    },
    'Granularity': 'DAILY',
    'Metrics': ['UnblendedCost'],
    'GroupBy': [{
        'Type': 'DIMENSION',
        'Key': 'LINKED_ACCOUNT',
    }]
}
ce_client = boto3.client('ce')
ce_response = ce_client.get_cost_and_usage(**ce_args)
APIを実行すると以下のようなデータが返ってきますので、この中から各アカウント情報と課金情報を抽出します。

{
  "DimensionValueAttributes": [
    {
      "Attributes": {
        "description": "ACCOUNT_A" # アカウント名
      },
      "Value": "123456789012" # アカウントID
    },
    {
      "Attributes": {
        "description": "ACCOUNT_B"
      },
      "Value": "123456789013"
    },
  .....
  ],
  .....
  "ResultsByTime": [
    { # 2021-08-28 のアカウント毎の課金情報
      "Estimated": true,
      "Groups": [
        {
          "Keys": [
            "123456789012"
          ],
          "Metrics": {
            "UnblendedCost": {
              "Amount": "6.6658078778",
              "Unit": "USD"
            }
          }
        },
        {
          "Keys": [
            "123456789013"
          ],
          "Metrics": {
            "UnblendedCost": {
              "Amount": "0.164979503",
              "Unit": "USD"
            }
          }
        },
        .....
      ],
      "TimePeriod": {
        "End": "2021-08-29",
        "Start": "2021-08-28"
      },
      .....
    },
    { # 2021-08-29のアカウント毎の課金情報
      "Estimated": true,
      "Groups": [
        {
          "Keys": [
            "123456789012"
          ],
          "Metrics": {
            "UnblendedCost": {
              "Amount": "6.6658078778",
              "Unit": "USD"
            }
          }
        },
        {
          "Keys": [
            "123456789013"
          ],
          "Metrics": {
            "UnblendedCost": {
              "Amount": "0.164979503",
              "Unit": "USD"
            }
          }
        },
        .....
      ],
      "TimePeriod": {
        "End": "2021-08-30",
        "Start": "2021-08-29"
      },
      .....
    },
  ]
}

CostExplorerへのリンク作成

Slackに課金情報を投稿するだけでは詳細を確認したい時にいちいちマネジメントコンソール開いて確認するのがこれまた面倒なので、
1アカウント内でサービス毎の課金額を、過去1週間分のグラフで表示させるマネジメントコンソールのCostExplorerへのリンクを作成しちゃいます。
もちろん、閲覧するには課金管理アカウントに課金情報を見れる権限をもったIAMユーザーでログインする必要がありますのでここはお好みで……
def create_ce_url(account_id):
    # 1週間前から昨日までの課金情報を表示
    start = today - timedelta(days=7)
    end = today - timedelta(days=1)

    base_url = 'https://console.aws.amazon.com/cost-management/home?#/custom'
    filter_option = [{
        'dimension': 'LinkedAccount',
        'values': [account_id],
        'include': True,
        'children': None
    }]
    filter = json.dumps(filter_option, separators=(',', ':')).encode('utf-8')
    # 以下のクエリパラメータは実際にマネジメントコンソールのCostExploreを操作した
    # 結果付与されるクエリパラメータを参考にしてください。
    query_params = {
        'groupBy': 'Service',
        'hasBlended': 'false',
        'hasAmortized': 'false',
        'excludeDiscounts': 'true',
        'excludeTaggedResources': 'false',
        'excludeCategorizedResources': 'false',
        'excludeForecast': 'false',
        'timeRangeOption': 'Last7Days',
        'granularity': 'Daily',
        'reportName': ' ',
        'reportType': 'CostUsage',
        'isTemplate': 'true',
        'filter': filter,
        'chartStyle': 'Stack',
        'forecastTimeRangeOption': 'None',
        'usageAs': 'usageQuantity',
        'startDate': str(start),
        'endDate': str(end)
    }
 あとはSlackへ送信するためのデータを作成してそれをSlackに送信すればプログラムは完成です!
slack_attachments = []
for groups in ce_response['ResultsByTime']:
    target_date = groups['TimePeriod']['Start']
    total = 0
    slack_fields = []
    for group in groups['Groups']:
        cost = float(group["Metrics"]["UnblendedCost"]["Amount"])
        account_id = group["Keys"][0]
        ce_url = create_ce_url(account_id)
        # slack mrkdwnのlinkフォーマットでアカウント名を表示
        slack_fields.append({
            "title": accounts_dict[account_id],
            "value": "<{0}|{1:.2f}USD>".format(ce_url, cost),
            "short": True
        })
        # 日毎の総課金額
        total += cost

    slack_attachments.append({
        'color': "#36a64f",
        'fields': slack_fields,
         # 全AWSアカウントを合算した金額を表示
        'pretext': "{0}のトータルAWS料金は約{1:.2f}USDです".format(target_date, total)
    })
send_slack(slack_attachments)

def send_slack(attachments):
    messages = {"attachments": attachments}
    req = Request(SLACK_WEBHOOK_URL, json.dumps(messages).encode('utf-8'))
    try:
        urlopen(req)
    except HTTPError as e:
        print("Request failed: %d %s" % (e.code, e.reason))
    except URLError as e:
        print("Connection failed: %s" % (e.reason)) 

完成版ソースコード

import json
import os
from datetime import date, timedelta
from urllib.parse import urlencode, quote
from urllib.request import Request, urlopen
from urllib.error import URLError, HTTPError

import boto3

SLACK_WEBHOOK_URL = os.environ['SLACK_WEBHOOK_URL']

today = date.today()


def lambda_handler(event, context):
    ce_args = {
        'TimePeriod': {
            'Start': str(today - timedelta(days=2)),
            'End': str(today),
        },
        'Granularity': 'DAILY',
        'Metrics': ['UnblendedCost'],
        'GroupBy': [{
            'Type': 'DIMENSION',
            'Key': 'LINKED_ACCOUNT',
        }]
    }
    ce_client = boto3.client('ce')
    ce_response = ce_client.get_cost_and_usage(**ce_args)

    accounts_dict = {}
    for a in ce_response['DimensionValueAttributes']:
        accounts_dict[a['Value']] = a['Attributes']['description']

    slack_attachments = []
    for groups in ce_response['ResultsByTime']:
        target_date = groups['TimePeriod']['Start']
        total = 0
        slack_fields = []
        for group in groups['Groups']:
            cost = float(group["Metrics"]["UnblendedCost"]["Amount"])
            account_id = group["Keys"][0]
            ce_url = create_ce_url(account_id)
            # slack mrkdwnのlinkフォーマットでアカウント名を表示する。
            slack_fields.append({
                "title": accounts_dict[account_id],
                "value": "<{0}|{1:.2f}USD>".format(ce_url, cost),
                "short": True
            })
            total += cost

        slack_attachments.append({
            'color': "#36a64f",
            'fields': slack_fields,
            'pretext': "{0}のトータルAWS料金は約{1:.2f}USDです".format(target_date, total)
        })
    send_slack(slack_attachments)


def create_ce_url(account_id):
    start = today - timedelta(days=7)
    end = today - timedelta(days=1)

    base_url = 'https://console.aws.amazon.com/cost-management/home?#/custom'
    filter_option = [{
        'dimension': 'LinkedAccount',
        'values': [account_id],
        'include': True,
        'children': None
    }]
    filter = json.dumps(filter_option, separators=(',', ':')).encode('utf-8')

    query_params = {
        'groupBy': 'Service',
        'hasBlended': 'false',
        'hasAmortized': 'false',
        'excludeDiscounts': 'true',
        'excludeTaggedResources': 'false',
        'excludeCategorizedResources': 'false',
        'excludeForecast': 'false',
        'timeRangeOption': 'Last7Days',
        'granularity': 'Daily',
        'reportName': ' ',
        'reportType': 'CostUsage',
        'isTemplate': 'true',
        'filter': filter,
        'chartStyle': 'Stack',
        'forecastTimeRangeOption': 'None',
        'usageAs': 'usageQuantity',
        'startDate': str(start),
        'endDate': str(end)
    }
    return base_url + '?' + urlencode(query_params, safe=':,', quote_via=quote)


def send_slack(attachments):
    messages = {"attachments": attachments}
    req = Request(SLACK_WEBHOOK_URL, json.dumps(messages).encode('utf-8'))
    try:
        urlopen(req)
    except HTTPError as e:
        print("Request failed: %d %s" % (e.code, e.reason))
    except URLError as e:
        print("Connection failed: %s" % (e.reason))

デプロイ

最後に、本アプリケーションをデプロイするために、serverless.ymlを作成してデプロイします!
 

serverless.yml

service: aws-billing-bot
provider:
  name: aws
  region: ap-northeast-1
  runtime: python3.8
  stage: ${opt:stage, "prod"}
 # 最低限必要なポリシーをlambdaに割り当てます。
  iam:
    role:
      statements:
        - Effect: "Allow"
          Action:
            - "ce:GetCostAndUsageWithResources"
            - "ce:GetCostAndUsage"
          Resource: "*"
  environment: 
    SLACK_WEBHOOK_URL: "slackのwebhookURL"

package:
  include:
    - 'handler.py'

functions:
  main:
    handler: handler.lambda_handler
    events:
      # AM10:00に実行する設定を行っています
      # 適宜cron式は変更してください。
      - schedule: cron(0 1 * * ? *)

 デプロイコマンド

$ serverless deploy

完成!

アプリケーションサンプル
 
これで大体の課金額を確認したいときにマネジメントコンソールからCostExplorerを見る必要がなくなりました。あんまり手間をかけずに作成したものにしては結構助かってます。
ただ、AWSのサービスは日々進化しているので、頑張って作ったAWS管理機能がある日とつぜん陳腐化するということがよくあります……
なので、まずはAWSのサービスを利用することでノーコードで課題を解決できないか検討し、今回のようなLambdaを書く必要がある場合でもそこまで力を入れず作るくらいが楽をするのにちょうどいいのかなと思います。


最近の投稿

  • お誕生日紹介
    2021/10/11

    お誕生日紹介

    こんにちは! 最近では気温も落ち着き、過ごしやすい季節になってまいりました。 ワクチンの接種も広まってきており、筆者も接種日が迫っており少しビクビクとしておりま…

  • AWS Organization下アカウント課金情報をSlackに投稿する
    2021/9/16

    AWS Organization下アカウント課金情報をSlackに投稿する

      こんにちは! 「技術記事書いて!」と急に無茶振りされたシステム開発部エンジニアのSと申します。 何を書こうか迷いましたが、最近社内のAWS管理を行っているの…

  • 音楽と集中力
    2021/8/6

    音楽と集中力

    こんにちは!暑い日々が続きますが、皆様いかがお過ごしでしょうか? リモートワークが続いている方も多いと思いますので、今回は音楽と集中力の関係についてご紹介させて…

  • 第1回テクニカルトレーニングを受けて
    2021/7/5

    第1回テクニカルトレーニングを受けて

    当ブログをご覧いただきありがとうございます! 今回の内容は、先月実施された第1回テクニカルトレーニングを受講したときの内容を書きたいと思います。 プログラムを組…

  • 6月特有の不調とセルフケア
    2021/6/4

    6月特有の不調とセルフケア

    こんにちは! 梅雨になり、天気が崩れやすい季節となりましたがみなさまいかがお過ごしでしょうか。 新型コロナウイルス感染症の影響もあり、リモートワークとなり自宅作…

月別アーカイブ