AWS費用監視ツール(後編:Lambda活用)

# 1. はじめに
前回の記事では、AWS マネジメントコンソールと、AWS CLIによる費用確認方法を紹介しました。今回は、プログラミングによるREST API操作と、Lambdaへのデプロイについて解説します。

- AWS費用監視ツール(前編:AWS マネジメントコンソール&AWS CLI) 
- AWS費用監視ツール(後編:Lambda活用)　**← 本記事**

本編は、次の2つの章立てになっています:

- 2章「プログラム(Python)による確認」
    - AWS CLIに代わってプログラムからREST APIを操作します。ここではプログラミング言語としてPythonを使いましたが、(SDKがサポートされている)好きなプログラミング言語を使用できます(下記参照)。

https://aws.amazon.com/jp/getting-started/tools-sdks/

- 3章「Lambdaへのデプロイ (AWS SAMフレームワーク)」
    - 2章で作成したPythonのロジックをベースにLambdaに移植します。この章では、サーバレスフレームワークのAWS SAMを使います。「ステップ・バイ・ステップ」という観点では「(1)AWS マネジメントコンソールを使ってLambdaなどリソースを手動構築する」「(2)CloudFormationを利用することで構築操作を自動化(テンプレート化)する」「(3)AWS SAMを使う」と説明手順を分解することも可能ですが、今回は1〜2を省略、いきなり3の解説になります^^;。ご了承下さいm(_ _)m

# 2. プログラム(Python)による確認
<div align="center">
  <a href="https://github.com/t-tkm/aws-billing-tool-post/blob/imgs/imgs/img8.png?raw=true">
    <img alt="img8" src="https://github.com/t-tkm/aws-billing-tool-post/blob/imgs/imgs/img8.png?raw=true">
  </a>
</div>

## 2-1. Python実行環境
Pythonが使える環境を準備します。やり方はネット上に沢山解説がありますので、それらを参照して下さい。私はMiniconda(Pythonのディストリビューションの一つ)を使いました。詳細は「付録: Python実行環境の準備(Miniconda)」を参考にしてください。

## 2-2. Pythonコード
次に、コードを記載していきます。AWSのREST APIを利用するために「AWS SDK for Python (Boto3)」というSDKを使います(SDKは、プログラミング毎に準備されるツールです)。

https://docs.aws.amazon.com/ja_jp/pythonsdk/

Pythonでは、「boto3」と呼ばれるモジュールを使います。boto3の使い方については、APIリファレンスを活用します。

https://boto3.amazonaws.com/v1/documentation/api/latest/guide/index.html

また、今回Microsoft Teamsへ結果をプッシュ配信するため、「requests」と呼ばれるPythonのモジュールも利用します。以上、「boto3」と「requests」を、お使いのPython実行環境に合せインストールしてください。
付録のMinicondaを使っている場合は、(pipコマンドでなく)condaコマンドでライブラリをインストールします。

```
(billing-3.8) % conda install boto3 requests
```

AWS費用出力スクリプトを準備しましょう。

```python:app_shell.py
import os
import json
import boto3
import requests
from datetime import datetime, timedelta, date
from typing import Union

USE_TEAMS_POST="no"
#USE_TEAMS_POST="yes"

if USE_TEAMS_POST == "yes":
#    TEAMS_WEBHOOK_URL =  "https://<YOUR DOMAIN>>.webhook.office.com/webhookb2/XXXXXXXXX"
    TEAMS_WEBHOOK_URL = os.environ['TEAMS_WEBHOOK_URL']

#集計期間自動設定(今月初め〜本日)
def get_date_range() -> Union[str,str]:
    start_date = date.today().replace(day=1).isoformat()
    end_date = date.today().isoformat()
    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
(start_date, end_date) = get_date_range()

#集計期間手動設定(不要ならコメントアウト)
(start_date, end_date) = ("2021-11-01", "2021-12-01")

#REST API(get_cost_and_usage)のパラメータ設定
MY_PERIOD={
    'Start': start_date,
    'End': end_date
}

MY_GRANULARITY='MONTHLY'

MY_FILTER={
    'Not': {
        'Dimensions': {
            'Key': "RECORD_TYPE",
            'Values': ["Credit"]
        }
    }
}

MY_METRIC='AmortizedCost'

MY_GROUP_BY= {
    'Type': 'DIMENSION',
    'Key': 'SERVICE'
}

def get_total_cost(inCredit:bool, client) -> str:
    if inCredit:
        response = client.get_cost_and_usage(
            TimePeriod=MY_PERIOD,
            Granularity=MY_GRANULARITY,
            Metrics=[MY_METRIC]
        )
    else:
        response = client.get_cost_and_usage(
            TimePeriod=MY_PERIOD,
            Granularity=MY_GRANULARITY,
            Filter=MY_FILTER,
            Metrics=[MY_METRIC]
        )

    return response['ResultsByTime'][0]['Total'][MY_METRIC]['Amount']
    
def get_service_costs(inCredit:bool, client) -> list:
    if inCredit:
        response = client.get_cost_and_usage(
            TimePeriod=MY_PERIOD,
            Granularity=MY_GRANULARITY,
            Metrics=[MY_METRIC],
            GroupBy=[MY_GROUP_BY]
        )
    else:
        response = client.get_cost_and_usage(
            TimePeriod=MY_PERIOD,
            Granularity=MY_GRANULARITY,
            Filter=MY_FILTER,
            Metrics=[MY_METRIC],
            GroupBy=[MY_GROUP_BY]
        )

    billings = []

    for item in response['ResultsByTime'][0]['Groups']:
        billings.append({
            'service_name': item['Keys'][0],
            'billing': item['Metrics'][MY_METRIC]['Amount']
        })
    return billings

def get_services_msg(service_billings: list) -> dict:
    servicess = []
    for item in service_billings:
        service_name = item['service_name']
        billing = round(float(item['billing']), 2)
        if billing == 0.0: continue
        servicess.append(f'- {service_name}: {billing:.2f}')
    return servicess

def post_teams_webhook(title: str, services: str) -> None:
    payload = {
        "title": title,
        "text": services
    }

    try:
        response = requests.post(TEAMS_WEBHOOK_URL, data=json.dumps(payload))
    except requests.exceptions.RequestException as e:
        print(e)
    else:
        print("Teams Webhook Response(Status Code): " + str(response.status_code))

if __name__ == '__main__':
    client = boto3.client('ce', region_name='us-east-1')

    start_day = (datetime.strptime(start_date, '%Y-%m-%d')).strftime('%m/%d')
    end_yesterday = (datetime.strptime(end_date, '%Y-%m-%d') - timedelta(days=1)).strftime('%m/%d')

    # クレジット適用後費用
    print("------------------------------------------------------")
    total_cost_after_credit = round(float(get_total_cost(True, client)), 2)
    title = f'{start_day} - {end_yesterday}のクレジット適用後費用は{total_cost_after_credit:.2f}(USD)です.'
    print(title)
    services_cost_after_credit = get_services_msg(get_service_costs(True, client))
    print('\n'.join(services_cost_after_credit))
    if USE_TEAMS_POST == "yes":
        post_teams_webhook(title, '\n\n'.join(services_cost_after_credit))

    # クレジット適用前費用
    print("------------------------------------------------------")
    total_cost_before_credit = round(float(get_total_cost(False, client)), 2)
    title = f'{start_day} - {end_yesterday}のクレジット適用前費用は{total_cost_before_credit:.2f}(USD)です.'
    print(title)
    services_cost_before_credit = get_services_msg(get_service_costs(False, client))
    print('\n'.join(services_cost_before_credit))
    print("------------------------------------------------------")
    if USE_TEAMS_POST == "yes":
        post_teams_webhook(title, '\n\n'.join(services_cost_before_credit))
```

## 2-3. 実行&結果確認
では、実際に試してみます。

```
# AWSプロファイルを設定
export AWS_PROFILE=billing-user

# 実行
% python app_shell.py
------------------------------------------------------
11/01～11/30のクレジット適用後費用は、209.13 (USD)です。
- AWS Config: 31.23
- AWS Cost Explorer: 2.42
(中略)
- Tax: 19.02
------------------------------------------------------
11/01～11/30のクレジット適用前費用は、440.21 (USD)です。
- AWS Config: 31.23
- AWS Cost Explorer: 5.41
(中略)
- Tax: 19.02
------------------------------------------------------
```

如何でしょうか？うまく表示できましたか？プログラム(Python)による確認は以上になります。

# 3. Lambdaへのデプロイ (AWS SAMフレームワーク)
<div align="center">
  <a href="https://github.com/t-tkm/aws-billing-tool-post/blob/imgs/imgs/img9.png?raw=true">
    <img alt="img9" src="https://github.com/t-tkm/aws-billing-tool-post/blob/imgs/imgs/img9.png?raw=true">
  </a>
</div>

それでは最後に、AWS SAMを使った「LambdaからMicrosoft Teamsへ費用の通知システム」を構築します。AWS SAM(**S**erverless **A**pplication **M**odel)は、AWSでのサーバレスアプリケーション開発を効率的にするためのフレームワークです。CloudFormationに比べて、より簡略化した定義(テンプレート)が使えますし、(今回は手を出してませんが)CI/CDへの組込みも容易になります。詳細は、下記ドキュメントを参照して下さい。

https://docs.aws.amazon.com/ja_jp/serverless-application-model/latest/developerguide/what-is-sam.html

## 3-1. AWS SAMプロジェクト
SAMのプロジェクトを作って、カスタマイズしていきます。

```
sam init --runtime python3.8 --name <<好きなプロジェクト名>>
```

```
.
├── README.md
├── __init__.py
├── aws_billing_app
│   ├── __init__.py
│   ├── app.py
│   ├── model
         |-(省略)
│   └── requirements.txt
├── buildAnddeploy.sh       <=== ビルド&デプロイスクリプト
├── conftest.py
├── events
│   └── event.json
├── packaged.yaml
├── template.yaml
└── tests
     |-(省略) 
```

## 3-2. SAMテンプレート

```diff_yaml:template.yaml
AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
- Description: 
-   sample
-   Sample SAM Template for sample
+ Description: Notify AWS billing

Globals:
  Function:
-    Timeout: 3
+    Timeout: 30

+ Parameters:
+   TeamsWebhookUrl:
+     Type: String
+     Default: default-value

Resources:
+   BillingIamRole:
+       Type: AWS::IAM::Role
+       Properties:
+         AssumeRolePolicyDocument:
+           Version: "2012-10-17"
+           Statement:
+             - Effect: Allow
+               Principal:
+                 Service: lambda.amazonaws.com
+               Action: "sts:AssumeRole"
+         Policies:
+           - PolicyName: "BillingNotificationToTeamsLambdaPolicy"
+             PolicyDocument:
+               Version: "2012-10-17"
+               Statement:
+                 - Effect: Allow
+                   Action:
+                     - "logs:CreateLogGroup"
+                     - "logs:CreateLogStream"
+                     - "logs:PutLogEvents"
+                     - "ce:GetCostAndUsage"
+                     - "sns:*"
+                   Resource: "*"
  
-   HelloWorldFunction:
+   BillingNotificationFunction:
      Type: AWS::Serverless::Function
      Properties:
-       CodeUri: hello_world_function
-       Handler: hello_world/app.lambda_handler
+       CodeUri: aws_billing_app/
+       Handler: app.lambda_handler
        Runtime: python3.8
        Architectures:
          - x86_64        
+       Environment:
+         Variables:
+           TEAMS_WEBHOOK_URL: !Ref TeamsWebhookUrl
+       Role: !GetAtt BillingIamRole.Arn       
        Events:
-         HelloWorld:
-           Type: CloudWatchEvent # More info about CloudWatchEvent Event Source: https://github.com/awslabs/serverless-application-model/blob/master/versions/2016-10-31.md#cloudwatchevent
+         NotifyTeams:
+           Type: Schedule
            Properties:
-             Pattern:
-               source:
-                 - aws.ec2
-               detail-type:
-                 - EC2 Instance State-change Notification
+             Schedule: cron(0 0 * * ? *)

Outputs:
- HelloWorldFunction:
-   Description: "Hello World Lambda Function ARN"
-   Value: !GetAtt HelloWorldFunction.Arn
- HelloWorldFunctionIamRole:
-   Description: "Implicit IAM Role created for Hello World function"
-   Value: !GetAtt HelloWorldFunctionRole.Arn
+ BillingNotificationFunction:
+   Description: "Billing Notification Lambda Function ARN"
+   Value: !GetAtt BillingNotificationFunction.Arn
+ BillingNotificationFunctionIamRole:
+   Description: "Implicit IAM Role created for Billing Notification function"
+   Value: !GetAtt BillingIamRole.Arn
```

## 3-3. プログラム(Lambdaハンドラー)
2-2節にある「app_shell.py」を修正します。下記のように「```if __name__ == '__main__':```」のコードブロックを削除し、代わりに「```def lambda_handler(event, context) -> None:```」のコードブロック(関数)を追加すればok！

```python:app_shell.py
(中略)
if __name__ == '__main__':
    client = boto3.client('ce', region_name='us-east-1')
    ・
    ・
    ・
(中略)
```
↑を削除、↓を追加。

```python:app.py
(中略)
def lambda_handler(event, context) -> None:
    client = boto3.client('ce', region_name='us-east-1')

    start_day = (datetime.strptime(start_date, '%Y-%m-%d')).strftime('%m/%d')
    end_yesterday = (datetime.strptime(end_date, '%Y-%m-%d') - timedelta(days=1)).strftime('%m/%d')

    # クレジット適用後費用
    total_cost_after_credit = round(float(get_total_cost(True, client)), 2)
    title = f'{start_day} - {end_yesterday}のクレジット適用後費用は{total_cost_after_credit:.2f}(USD)です.'
    services_cost_after_credit = get_services_msg(get_service_costs(True, client))
    post_teams_webhook(title, '\n\n'.join(services_cost_after_credit))

    # クレジット適用前費用
    total_cost_before_credit = round(float(get_total_cost(False, client)), 2)
    title = f'{start_day} - {end_yesterday}のクレジット適用前費用は{total_cost_before_credit:.2f}(USD)です.'
    services_cost_before_credit = get_services_msg(get_service_costs(False, client))
    post_teams_webhook(title, '\n\n'.join(services_cost_before_credit))
(中略)
```

細かいですが、Lambdaの方は(app_shell.pyにある)下記も不要です。

```diff_python:app.py
- USE_TEAMS_POST="no"
- #USE_TEAMS_POST="yes"
- 
- if USE_TEAMS_POST == "yes":
- #    TEAMS_WEBHOOK_URL =  "https://<YOUR DOMAIN>>.webhook.office.com/webhookb2/XXXXXXXXX"

- #特定の期間に上書きする場合
- start_date = "2021-11-01"
- end_date = "2021-11-30"
```

## 3-4. デプロイ
AWS SAMを用いたデプロイ用にスクリプとを準備します。通知先となるMicrosoft TeamsのURLは、下記を参考に取得して下さい。

https://docs.microsoft.com/ja-jp/microsoftteams/platform/webhooks-and-connectors/how-to/add-incoming-webhook

```:buildAnddeploy.sh
#!/bin/sh
set -eu

#aws-root
AWS_PROFILE=billing-user
S3_BACKET=<your backet name>
TEAMS_WEBHOOK_URL=<Teams Webhook URL>

echo "Start sam build command."
sam build

echo "Start sam package command."
sam package \
    --output-template-file packaged.yaml \
    --s3-bucket $S3_BACKET

echo "Start sam deploy command."
sam deploy \
    --template-file packaged.yaml \
    --stack-name NotifyBillingToTeams \
    --capabilities CAPABILITY_IAM \
    --parameter-overrides TeamsWebhookUrl=$TEAMS_WEBHOOK_URL
```
スクリプトを実行します。

```
(billing-3.8) % ./buildAnddeploy.sh 
Start sam build command.
(中略)
Build Succeeded
Built Artifacts  : .aws-sam/build
Built Template   : .aws-sam/build/template.yaml
・
(中略)
・
Successfully created/updated stack - NotifyBillingToTeams in None
```

以上でビルド成果物がS3へアップロードされ、CloudFormation経由でLambdaなどリソースが作成されました。

## 3-5. 実行&結果
最後に、Lambdaの[テスト]で、Teamsへ通知されるか確認します。

<div align="center">
  <a href="https://github.com/t-tkm/aws-billing-tool-post/blob/imgs/imgs/img15.png?raw=true">
    <img alt="img15" src="https://github.com/t-tkm/aws-billing-tool-post/blob/imgs/imgs/img15.png?raw=true">
  </a>
</div>

無事、Microsoft Teamsへ通知されたでしょうか？以上で検証は完了です。

## 3-6. 削除

削除は、下記コマンドで行います。以前はdeleteがサポートされておらず、CloudFormationのスタックを手動で削除していましたが、このdeleteオプションのサポートにより、sam packageでアップロードしたS3の成果物の消し忘れなどが無くなりました。

```
(billing-3.8) % sam delete --stack-name NotifyBillingToTeams
    Are you sure you want to delete the stack NotifyBillingToTeams in the region ap-northeast-1 ? [y/N]: y
	Do you want to delete the template file XXXXXXXXXXXXXXX.template in S3? [y/N]: y
(中略)
Deleted successfully
```

CloudFormationのスタック、及びS3にアップロードされた成果物が削除されていることを確認します。

# 4. まとめ
以上、AWS費用の可視化を中心に、管理ポータル、AWS CLI、Lambdaを使った通知ツール作成(サーバレスアプリケーション構築)を説明しました。AWSクラウドを操作するUIは複数ありますが、最終的には全てのクラウドリソースがREST APIを通して操作されているという感触が掴めたでしょうか？

最後に筆者の経験上、クラウドのGUI(管理ポータル)は変化・成長が激しく、1年前に準備した手順書(スクリーンショット)がガラッと代わっていて、メンテナンスが大変といった事がありました。AWS CLIやREST APIは、GUIと比較すると変更は緩やかです。そういう観点では、操作手順書は極力CLIベースにするといったチャレンジも、今後必要かなと感じております。その時、本記事が、GUI、CLI、REST APIの関係理解のお役に立てると幸いです。

---
* Amazon Web Services、および、その他のAWS 商標は、米国およびその他の諸国におけるAmazon.com, Inc.またはその関連会社の商標です。
* Microsoft 365、Microsoft Teamsは、米国Microsoft Corporationおよびその関連会社の米国およびその他の国における登録商標または商標です。
* その他、本資料に記述してある会社名、製品名は、各社の登録商品または商標です。

# 付録: Python実行環境の準備(Miniconda)
Pythonの実行環境準備ですが、今回は(私が使い慣れている)Anaconda(正確にはGUIなどを抜いているMiniconda)を活用しました。Miniconda環境のインストールは、下記をご覧下さい。

https://docs.conda.io/en/latest/miniconda.html

無事Minicondaが使えるようになったら、検証用の仮想環境を作成します。

```
# minicondaインストール初期状態。仮想環境の確認。baseのみ。
(base) % conda info -e
# conda environments:
#
base                  *  /Users/<user-name>/opt/anaconda3
(中略)

# 仮想環境として、名前「billing-3.8」で作成。pythonのバージョンは(後述AWS SAMと合せ)3.8とする。
(base) % conda create -n billing-3.8 python=3.8
Collecting package metadata (current_repodata.json): done
Solving environment: done
(中略)

# 新規に「billing-3.8」が作成されていることを確認。
(base) % conda info -e  
# conda environments:
#
base                  *  /Users/<user-name>/opt/anaconda3
billing-3.8              /Users/<user-name>/opt/anaconda3/envs/billing-3.8
(中略)

# 仮想環境「billing-3.8」をアクティベート
(base) % conda activate billing-3.8

# プロンプトが「base」から「billing-3.8」に変更されている事がわかる。
# 初期状態で、この仮想環境にインストールされているライブラリ表示。
(billing-3.8) % conda list
# packages in environment at /Users/<user-name>/opt/anaconda3/envs/billing-3.8:
#
# Name                    Version                   Build  Channel
ca-certificates           2021.10.26           hecd8cb5_2  
certifi                   2021.10.8        py38hecd8cb5_0  
libcxx                    12.0.0               h2f01273_0  
libffi                    3.3                  hb1e8313_2  
ncurses                   6.3                  hca72f7f_2  
openssl                   1.1.1l               h9ed2024_0  
pip                       21.2.4           py38hecd8cb5_0  
python                    3.8.12               h88f2d9e_0  
readline                  8.1                  h9ed2024_0  
setuptools                58.0.4           py38hecd8cb5_0  
sqlite                    3.36.0               hce871da_0  
tk                        8.6.11               h7bc2e8c_0  
wheel                     0.37.0             pyhd3eb1b0_1  
xz                        5.2.5                h1de35cc_0  
zlib                      1.2.11               h1de35cc_3  

# pythonバージョン確認
(billing-3.8) % python -V
Python 3.8.12
```