エンジニア

2021.11.11

AWS Organizations ゲストアカウント毎の請求額をSlackに通知したい!

AWS Organizations ゲストアカウント毎の請求額をSlackに通知したい!

こんにちは。ハンズラボのモチです。
今回もニッチな情報をお伝えさせて頂きます。
同じことで悩んでいる誰か一人でもお役に立てたら光栄です。

やりたいこと

今月は予想よりデータ通信量が多かった!なんてこと、よくありますよね。

請求額が大幅に増えていることに月末に気付くと手遅れなので、月中に前月比を通知する仕組みを作ろうと思ったのがきっかけです。
既にサービス提供中の環境には触れず、マスターアカウント側でリスクを減らしつつ一括で実現できる方法を考えました。

<補足>
弊社がAWS Organizations(以下、Organizations)を導入した当時はAWS Control Towerが東京リージョンに対応していなかったり、OUに属さないアカウントが多数あったことから、OrganizationsのままAWS LandingZoneの思想を取り入れる方針としています。
(AWS LandingZoneは、マルチアカウントのベストプラクティスのことです。)
AWSサポートにOragnaizationsのゲスト毎の請求の一括通知のベストプラクティスがあるかをお聞きしてみましたが、現状はないとのことでした。そもそも要件がニッチなのと、コンソール上の機能は充実していますもんね。。

私自身がプログラミング初心者のため、Pythonのコードは参考程度に見て頂けると幸いです。

構成

マスターアカウントでは実行したくなかったので、ツール用のアカウントを作成し、クロスアカウントで実行することにしました。

① 指定日時になったらLambdaを実行

②③ AWS OrganizationsのマスターアカウントにAssumeRoleして一時認証情報を取得
 (マスターアカウント側のRoleで、許可するサービスを制限)

④ OU配下のアカウント一覧を取得

⑤ アカウント毎にAWS CostExplorerの請求情報を取得
 (今回は、月初から前日までと先月同期間の請求額を比較)

⑥ 請求額をSlackに通知する

サンプルコード

Serverless Frameworkを使っています。

アカウントA(ツール実行用)

serverless.yml

service: cost_notification
frameworkVersion: '>=1.53.0 <3.0.0'

plugins:
  - serverless-pseudo-parameters

custom:
  stsrole: arn:aws:iam::XXXXXXXXXXXX:role/xxx-Role  #マスターアカウント側のSTS用Role

provider:
  name: aws
  runtime: python3.7
  region: ap-northeast-1
  profile: ${opt:profile, ''}
  environment:
    TZ: Asia/Tokyo
    STAGE: ${self:provider.stage}
  logRetentionInDays: 7
  iamRoleStatements:
    - Effect: 'Allow'
      Action:
        - logs:CreateLogGroup
        - logs:CreateLogStream
        - logs:PutLogEvents
      Resource:
        - '*'
    - Effect: Allow
      Action:
        - ssm:GetParameter
      Resource:
        - arn:aws:ssm:${self:provider.region}:#{AWS::AccountId}:parameter/*
   #STS用Role(図②)
    - Effect: Allow
      Action:
        - sts:AssumeRole
      Resource:
        - ${self:custom.stsrole}

functions:
  cost_notification:
    handler: functions/cost_notification.handler
    environment:
      SLACK_WEBHOOK: ${ssm:/costnotification/slack-webhook}
      PARENT_ID: ${ssm:/costnotification/parenr-id}
      STS_ROLE: ${self:custom.stsrole}
    events:
     - schedule: cron(00 01 15 * ? *) #毎月15日10時

cost_notification.py 
※ あくまでも参考程度に見て頂けると幸いです。(重要なので二回書きました)

import os
import boto3
import json
import urllib
from urllib import parse, request
from datetime import datetime, timedelta, date
from dateutil.relativedelta import relativedelta
import logging

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

WEB_HOOK_URL = os.environ['SLACK_WEBHOOK']

# 今月
thismonth_firstdate = (date(date.today().year, date.today().month, 1)).isoformat()
today = (date.today()).isoformat()
yesterday = (date.today() - timedelta(days=1)).isoformat()

# 先月
# 先月
lastmonth_firstdate = ((date(date.today().year, date.today().month, 1)) + relativedelta(months=-1)).isoformat()
lastmonth_samedate_before = (date.today() - timedelta(days=1) - relativedelta(months=1)).isoformat()
lastmonth_samedate = (date.today() - relativedelta(months=1)).isoformat()



# OU配下のアカウントリスト取得
def get_accountlist(parentId, orgclient):
    try:
        accountslist = orgclient.list_accounts_for_parent(
            ParentId = parentId,
            )
        
        return(accountslist)
            
    except Exception as e:
        logger.exception('Error: %s', e)
        raise


# 特定アカウントの請求額取得
def get_cost(firstdate, enddate, accountid, ceclient):

    try:
        cost = ceclient.get_cost_and_usage(
    	TimePeriod = {
                'Start': firstdate,
                'End'  : enddate
            },
            Granularity = 'MONTHLY',
            Metrics     = ['UnblendedCost'],
            GroupBy=[
                {
                'Type': 'DIMENSION',
                'Key': 'LINKED_ACCOUNT'
                },
            ],
            Filter={
                'Dimensions': {
                    'Key': 'LINKED_ACCOUNT',
                    'Values': [
                        accountid,
                    ],
                },
            },
        )
        
        array = []
        array = cost['ResultsByTime']
        UnblendedCost = round(float(array[0]['Groups'][0]['Metrics']['UnblendedCost']['Amount']), 1)

        return(UnblendedCost)

    except Exception as e:
        logger.exception('Error: %s', e)
        raise


# Slack通知 
def post_slack(accountid, accountname, lastmonth_UnblendedCost, UnblendedCost, costrate):
    
    send_data = {
        "username": '請求額のご案内' ,
        "icon_emoji": ":moneybag:"
    }
    
    mention = ""
    symbol = ""
    
    # 前月比増の時はメンションを付与する
    if costrate != 0:
        costrate = costrate * -1
        if costrate > 0:
            mention = '<!here>' 
            symbol = """+"""
    
    template = "{}\n {}から{}の請求額 {}USD\n{}から{}の請求額 {}USD (先月比{}{}%)"
    
    massage = template.format(
        mention,
        lastmonth_firstdate,
        lastmonth_samedate_before,
        str(lastmonth_UnblendedCost),
        thismonth_firstdate,
        yesterday,
        str(UnblendedCost),
        symbol,
        str(costrate)
        )

    send_data["attachments"] = [{
        'fields': [
            {
                'title': 'アカウント:' + accountid + '(' + accountname + ')',
                'value': massage,
                'short': False,
            }
        ],
        'color': 'danger',
    }]

    send_text = "payload=" + json.dumps(send_data)

    request = urllib.request.Request(
        WEB_HOOK_URL,
        data=send_text.encode("utf-8"), 
        method="POST"
    )
    urllib.request.urlopen(request)
    

def handler(event, context):
    
    # 一時認証情報の取得(図③)
    acct_b = boto3.client("sts").assume_role(
        RoleArn=os.environ['STS_ROLE'],
        RoleSessionName="cross_acct_lambda"
    )
    
    ACCESS_KEY = acct_b['Credentials']['AccessKeyId']
    SECRET_KEY = acct_b['Credentials']['SecretAccessKey']
    SESSION_TOKEN = acct_b['Credentials']['SessionToken']
    
    ceclient = boto3.client('ce',
        aws_access_key_id=ACCESS_KEY,
        aws_secret_access_key=SECRET_KEY,
        aws_session_token=SESSION_TOKEN,)
        
    orgclient = boto3.client('organizations',
        aws_access_key_id=ACCESS_KEY,
        aws_secret_access_key=SECRET_KEY,
        aws_session_token=SESSION_TOKEN,)

    # OUのアカウントリスト取得(図④)
    parentId = 'xx-xxxx-xxxxxxx' 
  
    accounts_dict = {}
    accounts_dict = get_accountlist(parentId, orgclient)
    
    for account in accounts_dict.get("Accounts", {}):

        accountid = account.get("Id", "")
        accountname =  account.get("Name", "")

        # 請求額を取得(図⑤)
            
        # 今月(昨日まで)
        UnblendedCost = get_cost(thismonth_firstdate, today, accountid, ceclient)
        
        # 先月
        lastmonth_UnblendedCost = get_cost(lastmonth_firstdate, lastmonth_samedate, accountid, ceclient)

        # 先月比
        costrate = round(100 - float((UnblendedCost / lastmonth_UnblendedCost)*100),1)
        
        # Slackに通知(図⑥)
        post_slack(accountid, accountname, lastmonth_UnblendedCost, UnblendedCost, costrate)
     

ここで注意したいのが、get_cost_and_usageです。
CLIの公式ガイドに記載してありますが、endに指定した日は含まれないため、
昨日までを指定したい場合はendに今日の日付を指定します。

–time-period (structure)
Sets the start date and end date for retrieving Amazon Web Services costs. The start date is inclusive, but the end date is exclusive. For example, if start is 2017-01-01 and end is 2017-05-01 , then the cost and usage data is retrieved from 2017-01-01 up to and including 2017-04-30 but not including 2017-05-01 .

https://docs.aws.amazon.com/cli/latest/reference/ce/get-cost-and-usage.html

アカウントB(マスターアカウント)

serverless.yml

service: opti-shared-tool
frameworkVersion: '>=1.53.0 <3.0.0'

plugins:
  - serverless-pseudo-parameters

provider:
  name: aws
  runtime: python3.7
  region: ap-northeast-1
  profile: ${opt:profile, ''}
  stage: ce
  environment:
    TZ: Asia/Tokyo
    STAGE: ${self:provider.stage}
  logRetentionInDays: 7

resources:
  Resources:

   # STS用Role(図③)
    STSRoleforCostNotification:
      Type: AWS::IAM::Role
      Properties:
        RoleName: stsrole-for-costnotification
        AssumeRolePolicyDocument:
          Version: "2012-10-17"
          Statement:
            - Effect: Allow
              Principal:
                AWS: arn:aws:iam::XXXXXXXXXXXX:role/xxxxxxxx-lambdaRole #アカウントAのLambda用Role
              Action: sts:AssumeRole

    STSPolicyforCostNotification:
      Type: AWS::IAM::Policy
      Properties:
        PolicyName: stspolicy-for-costnotification
        PolicyDocument:
          Version: "2012-10-17"
          Statement:
            - Effect: Allow
              Action:
                - organizations:ListAccountsForParent
                - ce:GetCostAndUsage
              Resource:
                - "*"
        Roles:
          - Ref: STSRoleforCostNotification

実行されると、こんな感じでSlackに通知が届きます

※ アカウント名などは加工しています
※ テスト実行時の通知なので、日付が15日ではないですがご了承ください

前月比増の時は、スココココ(Slack通知音)を鳴らすようにしました

これで月末のやば!を防げますように。

一覧に戻る