アンリミロゴ

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を書く必要がある場合でもそこまで力を入れず作るくらいが楽をするのにちょうどいいのかなと思います。


音楽と集中力
お誕生日紹介
採用・ご応募に関するご相談
当社は経験あるシステムエンジニアやプログラマーの方はもちろん、未経験の方でも、社内研修などで一から育成を行います。
興味がある方はお気軽にお問い合わせください。

お問い合わせはこちら

ビジネスパートナー・協業に関するご相談
当社では「共存共栄」をポリシーとして、IT業界とゲーム業界のご案件や人材の情報を交換して頂ける、企業様を募集しております。
以下の「お問い合わせはこちら」よりご連絡ください。

ビジネスパートナー・協業の
お問い合わせはこちら