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


最近の投稿

  • SESには闇がある?悪い噂や「やめとけ」と叩かれる理由について徹底解説!
    2022/5/10

    SESには闇がある?悪い噂や「やめとけ」と叩かれる理由について徹底解説!

    働き方改革が叫ばれ、転職や独立開業などが以前よりも活発に行われるようになり、IT業界で働きたいと考える人も少なくありません。 ただ、そんなIT業界でもSES(シ…

  • 実際どうなの?謎が多いSES企業の研修について徹底解説!
    2022/7/7

    実際どうなの?謎が多いSES企業の研修について徹底解説!

    これまでのブログでも、SES企業の嘘求人や離職率の高さについてはお話ししてきましたが、SES企業の悪いイメージと実情とのギャップはまだまだ拭いきれていません。 …

  • SES・客先常駐の転職タイミングは?噓求人の見分け方も紹介!
    2022/5/11

    SES・客先常駐の転職タイミングは?噓求人の見分け方も紹介!

    嘘求人は結構ある! SES企業に就職したけれど、求人に書いてあったことや面接時に言われたことと、実際の業務でやることが違った、なんて経験がある方もいるのではない…

  • 客先常駐のSESは離職率が高いって本当?その実態と離職率が高い会社の見分け方を解説!
    2022/5/11

    客先常駐のSESは離職率が高いって本当?その実態と離職率が高い会社の見分け方を解説!

    IT業界は、ほかの業界に比べて離職率が高いといわれています。 IT業界に転職を考えている方にとって、離職率の高低は気になるところでしょう。 そこで今回は、IT業…

  • SES未経験だけど転職は難しい?SESについて詳しく解説!
    2022/3/10

    SES未経験だけど転職は難しい?SESについて詳しく解説!

    SES未経験だけど転職は難しい?SESについて詳しく解説! 最近、転職先として注目を集めているSESですが、一体どのような職種なのでしょうか。未経験者歓迎や高額…

月別アーカイブ