Cloud9 で SAM を利用し AWS サービス毎の請求額を毎日 Slack に通知する

Cloud9 を利用して AWS のサービス毎の料金を毎日 Slack に通知する仕組みを作成しました。
2023.04.19

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

コーヒーが好きな emi です。

AWSサービス毎の請求額を毎日 Slack に通知するため、以下のブログ AWSサービス毎の請求額を毎日Slackに通知してみた を見ながら設定しようとしたのですが、手元の Windows 11 端末に AWS CLI、AWS SAM CLI、Python などの開発環境を整えるのが面倒…!!と思いました。
そこで、AWS Cloud9 を使って手軽に一時的な開発環境を構築し、AWS Serverless Application Model (SAM) でサーバレス通知システムを構築しました。

AWS Serverless Application Model (SAM) とは

AWS SAM は、サーバーレスアプリケーション構築用のオープンソースフレームワークです。関数、API、データベース、イベントソースマッピングなどを定義し、CloudFormation テンプレート(YAML)を使用して簡単に構築できます。

AWS Cloud9 とは

AWS Cloud9 は、ブラウザのみでコードを記述、実行、デバッグできるクラウドベースの統合開発環境 (IDE) です。
内部的には EC2 インスタンスに開発環境が搭載されたものです。

構成

「毎日、指定時刻になると Lambda を起動し、Lambda が請求額を取得& Slack に Post する」というサーバーレスな構成を作成します。
デプロイのため Cloud9 で SAM を使います。

Cloud9 は AWS CLI や AWS SAM のために一時的に利用するだけなので、環境作成後は削除します。

事前準備

以下ブログの「事前準備」部分を実施してください。

  • AWS 請求の設定
  • Slack の設定
    • Webhook URLをメモしておく

やってみる

Cloud9 環境の構築

デフォルト VPC に Amazon Linux 2 ベースの Cloud9 環境を作成します。

AWS マネジメントコンソールで Cloud9 コンソールに移動し、[Create environment] をクリックします。

Create environment の画面に遷移します。

  • Name:sam-environment
  • Description:sam-environment

のみ入力し、その他の設定はすべてデフォルトのまま進め、[Create] をクリックます。
Name は任意の名前で構いません。

1~2 分程度待ち、緑のバーで「Successfully created ~~」と表示されたら OK です。 [Open] をクリックし、Cloud9 環境を開きます。

少し待つと画像のような画面が開きます。
画面下部のターミナルでコマンドを入力して操作していきます。

aws --version コマンドで AWS CLI のバージョンを確認します。

  • 実行結果
    kitani.emi:~/environment $ aws --version
    aws-cli/1.19.112 Python/2.7.18 Linux/4.14.311-233.529.amzn2.x86_64 botocore/1.20.112
    kitani.emi:~/environment $

sam --version コマンドで SAM CLI のバージョンを確認します。

  • 実行結果
    kitani.emi:~/environment $ sam --version
    SAM CLI, version 1.57.0
    kitani.emi:~/environment $

python --version コマンドで Python のバージョンを確認します。

  • 実行結果
    kitani.emi:~/environment $ python --version
    Python 3.7.16
    kitani.emi:~/environment $

Python のバージョンアップ

Python 3.8 は Amazon Linux 2 のデフォルトリポジトリに含まれています。

Q: Amazon Linux Extras リポジトリからソフトウェアパッケージをインストールするにはどうすればよいですか?
A: 利用できるパッケージの一覧は、Amazon Linux 2 のシェルで amazon-linux-extras コマンドを使って表示できます。Extras のパッケージは "sudo amazon-linux-extras install" コマンドを使ってインストールできます。

次のコマンドを実行してPython 3.8 をインストールします。

  • 実行コマンド
    sudo amazon-linux-extras install python3.8
実行結果(クリックで展開)
kitani.emi:~/environment $ sudo amazon-linux-extras install python3.8
Installing python38
Loaded plugins: extras_suggestions, langpacks, priorities, update-motd
Cleaning repos: amzn2-core amzn2extra-docker amzn2extra-epel amzn2extra-lamp-mariadb10.2-php7.2 amzn2extra-python3.8 epel hashicorp
33 metadata files removed
11 sqlite files removed
0 metadata files removed
Loaded plugins: extras_suggestions, langpacks, priorities, update-motd
amzn2-core                                                                                                                  | 3.7 kB  00:00:00     
amzn2extra-docker                                                                                                           | 3.0 kB  00:00:00     
amzn2extra-epel                                                                                                             | 3.0 kB  00:00:00     
amzn2extra-lamp-mariadb10.2-php7.2                                                                                          | 3.0 kB  00:00:00     
amzn2extra-python3.8                                                                                                        | 3.0 kB  00:00:00     
epel/x86_64/metalink                                                                                                        | 5.7 kB  00:00:00     
epel                                                                                                                        | 4.7 kB  00:00:00     
hashicorp                                                                                                                   | 1.4 kB  00:00:00     
(1/15): amzn2-core/2/x86_64/group_gz                                                                                        | 2.5 kB  00:00:00     
(2/15): amzn2-core/2/x86_64/updateinfo                                                                                      | 586 kB  00:00:00     
(3/15): amzn2extra-epel/2/x86_64/primary_db                                                                                 | 1.8 kB  00:00:00     
(4/15): amzn2extra-lamp-mariadb10.2-php7.2/2/x86_64/updateinfo                                                              |   76 B  00:00:00     
(5/15): amzn2extra-lamp-mariadb10.2-php7.2/2/x86_64/primary_db                                                              | 506 kB  00:00:00     
(6/15): amzn2extra-python3.8/2/x86_64/updateinfo                                                                            |   76 B  00:00:00     
(7/15): amzn2extra-python3.8/2/x86_64/primary_db                                                                            |  67 kB  00:00:00     
(8/15): amzn2extra-docker/2/x86_64/updateinfo                                                                               | 9.1 kB  00:00:00     
(9/15): amzn2extra-docker/2/x86_64/primary_db                                                                               | 107 kB  00:00:00     
(10/15): amzn2extra-epel/2/x86_64/updateinfo                                                                                |   76 B  00:00:00     
(11/15): epel/x86_64/group_gz                                                                                               |  99 kB  00:00:00     
(12/15): epel/x86_64/updateinfo                                                                                             | 1.0 MB  00:00:00     
(13/15): hashicorp/x86_64/primary                                                                                           | 156 kB  00:00:00     
(14/15): epel/x86_64/primary_db                                                                                             | 7.0 MB  00:00:00     
(15/15): amzn2-core/2/x86_64/primary_db                                                                                     |  71 MB  00:00:01     
hashicorp                                                                                                                                1122/1122
244 packages excluded due to repository priority protections
Resolving Dependencies
--> Running transaction check
---> Package python38.x86_64 0:3.8.16-1.amzn2.0.2 will be installed
--> Processing Dependency: python38-libs(x86-64) = 3.8.16-1.amzn2.0.2 for package: python38-3.8.16-1.amzn2.0.2.x86_64
--> Processing Dependency: python38-setuptools for package: python38-3.8.16-1.amzn2.0.2.x86_64
--> Processing Dependency: python38-pip for package: python38-3.8.16-1.amzn2.0.2.x86_64
--> Processing Dependency: libpython3.8.so.1.0()(64bit) for package: python38-3.8.16-1.amzn2.0.2.x86_64
--> Running transaction check
---> Package python38-libs.x86_64 0:3.8.16-1.amzn2.0.2 will be installed
---> Package python38-pip.noarch 0:21.0.1-4.amzn2.0.1 will be installed
---> Package python38-setuptools.noarch 0:38.4.0-4.amzn2.0.1 will be installed
--> Finished Dependency Resolution

Dependencies Resolved

===================================================================================================================================================
 Package                               Arch                     Version                               Repository                              Size
===================================================================================================================================================
Installing:
 python38                              x86_64                   3.8.16-1.amzn2.0.2                    amzn2extra-python3.8                    70 k
Installing for dependencies:
 python38-libs                         x86_64                   3.8.16-1.amzn2.0.2                    amzn2extra-python3.8                    10 M
 python38-pip                          noarch                   21.0.1-4.amzn2.0.1                    amzn2extra-python3.8                   2.1 M
 python38-setuptools                   noarch                   38.4.0-4.amzn2.0.1                    amzn2extra-python3.8                   619 k

Transaction Summary
===================================================================================================================================================
Install  1 Package (+3 Dependent packages)

Total download size: 13 M
Installed size: 54 M
Is this ok [y/d/N]: y
Downloading packages:
(1/4): python38-3.8.16-1.amzn2.0.2.x86_64.rpm                                                                               |  70 kB  00:00:00     
(2/4): python38-pip-21.0.1-4.amzn2.0.1.noarch.rpm                                                                           | 2.1 MB  00:00:00     
(3/4): python38-setuptools-38.4.0-4.amzn2.0.1.noarch.rpm                                                                    | 619 kB  00:00:00     
(4/4): python38-libs-3.8.16-1.amzn2.0.2.x86_64.rpm                                                                          |  10 MB  00:00:00     
---------------------------------------------------------------------------------------------------------------------------------------------------
Total                                                                                                               28 MB/s |  13 MB  00:00:00     
Running transaction check
Running transaction test
Transaction test succeeded
Running transaction
  Installing : python38-setuptools-38.4.0-4.amzn2.0.1.noarch                                                                                   1/4 
  Installing : python38-pip-21.0.1-4.amzn2.0.1.noarch                                                                                          2/4 
  Installing : python38-3.8.16-1.amzn2.0.2.x86_64                                                                                              3/4 
  Installing : python38-libs-3.8.16-1.amzn2.0.2.x86_64                                                                                         4/4 
  Verifying  : python38-libs-3.8.16-1.amzn2.0.2.x86_64                                                                                         1/4 
  Verifying  : python38-3.8.16-1.amzn2.0.2.x86_64                                                                                              2/4 
  Verifying  : python38-setuptools-38.4.0-4.amzn2.0.1.noarch                                                                                   3/4 
  Verifying  : python38-pip-21.0.1-4.amzn2.0.1.noarch                                                                                          4/4 

Installed:
  python38.x86_64 0:3.8.16-1.amzn2.0.2                                                                                                             

Dependency Installed:
  python38-libs.x86_64 0:3.8.16-1.amzn2.0.2      python38-pip.noarch 0:21.0.1-4.amzn2.0.1      python38-setuptools.noarch 0:38.4.0-4.amzn2.0.1     

Complete!
  0  ansible2                        available    \
        [ =2.4.2  =2.4.6  =2.8  =stable ]
  2  httpd_modules                   available    [ =1.0  =stable ]
  3  memcached1.5                    available    \
        [ =1.5.1  =1.5.16  =1.5.17 ]
  6  postgresql10                    available    [ =10  =stable ]
  9  R3.4                            available    [ =3.4.3  =stable ]
 10  rust1                           available    \
        [ =1.22.1  =1.26.0  =1.26.1  =1.27.2  =1.31.0  =1.38.0
          =stable ]
 17 *lamp-mariadb10.2-php7.2=latest  enabled      \
        [ =10.2.10_7.2.0  =10.2.10_7.2.4  =10.2.10_7.2.5
          =10.2.10_7.2.8  =10.2.10_7.2.11  =10.2.10_7.2.13
          =10.2.10_7.2.14  =10.2.10_7.2.16  =10.2.10_7.2.17
          =10.2.10_7.2.19  =10.2.10_7.2.22  =10.2.10_7.2.23
          =10.2.10_7.2.24  =stable ]
 18  libreoffice                     available    \
        [ =5.0.6.2_15  =5.3.6.1  =stable ]
 19  gimp                            available    [ =2.8.22 ]
 20  docker=latest                   enabled      \
        [ =17.12.1  =18.03.1  =18.06.1  =18.09.9  =stable ]
 21  mate-desktop1.x                 available    \
        [ =1.19.0  =1.20.0  =stable ]
 22  GraphicsMagick1.3               available    \
        [ =1.3.29  =1.3.32  =1.3.34  =stable ]
 23  tomcat8.5                       available    \
        [ =8.5.31  =8.5.32  =8.5.38  =8.5.40  =8.5.42  =8.5.50
          =stable ]
 24  epel=latest                     enabled      [ =7.11  =stable ]
 25  testing                         available    [ =1.0  =stable ]
 26  ecs                             available    [ =stable ]
 27  corretto8                       available    \
        [ =1.8.0_192  =1.8.0_202  =1.8.0_212  =1.8.0_222  =1.8.0_232
          =1.8.0_242  =stable ]
 29  golang1.11                      available    \
        [ =1.11.3  =1.11.11  =1.11.13  =stable ]
 30  squid4                          available    [ =4  =stable ]
 32  lustre2.10                      available    \
        [ =2.10.5  =2.10.8  =stable ]
 33  java-openjdk11                  available    [ =11  =stable ]
 34  lynis                           available    [ =stable ]
 36  BCC                             available    [ =0.x  =stable ]
 37  mono                            available    [ =5.x  =stable ]
 38  nginx1                          available    [ =stable ]
 40  mock                            available    [ =stable ]
 41  postgresql11                    available    [ =11  =stable ]
 43  livepatch                       available    [ =stable ]
 44  python3.8=latest                enabled      [ =stable ]
 45  haproxy2                        available    [ =stable ]
 46  collectd                        available    [ =stable ]
 47  aws-nitro-enclaves-cli          available    [ =stable ]
 48  R4                              available    [ =stable ]
 49  kernel-5.4                      available    [ =stable ]
 50  selinux-ng                      available    [ =stable ]
  _  php8.0                          available    [ =stable ]
 52  tomcat9                         available    [ =stable ]
 53  unbound1.13                     available    [ =stable ]
  _  mariadb10.5                     available    [ =stable ]
 55  kernel-5.10                     available    [ =stable ]
 56  redis6                          available    [ =stable ]
 57  ruby3.0                         available    [ =stable ]
 58  postgresql12                    available    [ =stable ]
 59  postgresql13                    available    [ =stable ]
 60  mock2                           available    [ =stable ]
 61  dnsmasq2.85                     available    [ =stable ]
 62  kernel-5.15                     available    [ =stable ]
 63  postgresql14                    available    [ =stable ]
 64  firefox                         available    [ =stable ]
 65  lustre                          available    [ =stable ]
  _  php8.1                          available    [ =stable ]
 67  awscli1                         available    [ =stable ]
  _  php8.2                          available    [ =stable ]
 69  dnsmasq                         available    [ =stable ]
 70  unbound1.17                     available    [ =stable ]
 71  golang1.19                      available    [ =stable ]
* Extra topic has reached end of support.
kitani.emi:~/environment $

python3.8 --version コマンドで Python のバージョンを確認します。

  • 実行結果
    kitani.emi:~/environment $ python3.8 --version
    Python 3.8.16
    kitani.emi:~/environment $

sam init

sam init コマンドで、プロジェクトのフォルダを作成します。

  • 実行コマンド
    sam init --runtime python3.8 --name AWS_Billing

以下のように対話形式で進みます。

kitani.emi:~/environment $ sam init --runtime python3.8 --name AWS_Billing
Which template source would you like to use?
        1 - AWS Quick Start Templates
        2 - Custom Template Location
Choice:

1 を入力して Enter を押下します。

Choice: 1

Choose an AWS Quick Start application template
        1 - Hello World Example
        2 - Infrastructure event management
        3 - Multi-step workflow
        4 - Lambda EFS example
Template:

1 を入力して Enter を押下します。

Template: 1

Based on your selections, the only Package type available is Zip.
We will proceed to selecting the Package type as Zip.

Based on your selections, the only dependency manager available is pip.
We will proceed copying the template using pip.

Would you like to enable X-Ray tracing on the function(s) in your application?  [y/N]:

アプリケーションの関数の X-Ray トレースを有効にしたいですか?と聞かれています。
N を入力して Enter を押下します。

Would you like to enable X-Ray tracing on the function(s) in your application?  [y/N]: N

Cloning from https://github.com/aws/aws-sam-cli-app-templates (process may take a moment)

    -----------------------
    Generating application:
    -----------------------
    Name: AWS_Billing
    Runtime: python3.8
    Architectures: x86_64
    Dependency Manager: pip
    Application Template: hello-world
    Output Directory: .
    
    Next steps can be found in the README file at ./AWS_Billing/README.md
        

    Commands you can use next
    =========================
    [*] Create pipeline: cd AWS_Billing && sam pipeline init --bootstrap
    [*] Validate SAM template: sam validate
    [*] Test Function in the Cloud: sam sync --stack-name {stack-name} --watch
    

SAM CLI update available (1.80.0); (1.57.0 installed)
To download: https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/serverless-sam-cli-install.html
kitani.emi:~/environment $

ls -l を実行すると、AWS_Billing というディレクトリができているのが確認できます。

  • 実行結果
    kitani.emi:~/environment $ ls -l
    total 4
    drwxrwxr-x 5 ec2-user ec2-user 127 Apr 18 11:13 AWS_Billing
    -rw-r--r-- 1 ec2-user ec2-user 569 Apr 11 13:25 README.md
    kitani.emi:~/environment $

Cloud9 の画面左側を見ると、AWS_Billing ディレクトリの中身が確認できます。
以下のような構成になっています。

.
├── AWS_Billing
│   ├── events
│   │   └── event.json
│   ├── hello_world
│   │   ├── app.py
│   │   ├── __init__.py
│   │   └── requirements.txt
│   ├── __init__.py
│   ├── README.md
│   ├── template.yaml
│   └── tests
│       ├── __init__.py
│       ├── integration
│       │   ├── __init__.py
│       │   └── test_api_gateway.py
│       ├── requirements.txt
│       └── unit
│           ├── __init__.py
│           └── test_handler.py
└── README.md

template ファイルを編集(template.yaml)

AWS_Billing ディレクトリ配下の template.yaml を編集します。
Cloud9 の画面左側で template.yaml を選択すると、ターミナル上部で編集することができます。

template.yaml 編集前(クリックで展開)

AWS_Billing/template.yaml

AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: >
  AWS_Billing

  Sample SAM Template for AWS_Billing

# More info about Globals: https://github.com/awslabs/serverless-application-model/blob/master/docs/globals.rst
Globals:
  Function:
    Timeout: 3

Resources:
  HelloWorldFunction:
    Type: AWS::Serverless::Function # More info about Function Resource: https://github.com/awslabs/serverless-application-model/blob/master/versions/2016-10-31.md#awsserverlessfunction
    Properties:
      CodeUri: hello_world/
      Handler: app.lambda_handler
      Runtime: python3.8
      Architectures:
        - x86_64
      Events:
        HelloWorld:
          Type: Api # More info about API Event Source: https://github.com/awslabs/serverless-application-model/blob/master/versions/2016-10-31.md#api
          Properties:
            Path: /hello
            Method: get

Outputs:
  # ServerlessRestApi is an implicit API created out of Events key under Serverless::Function
  # Find out more about other implicit resources you can reference within SAM
  # https://github.com/awslabs/serverless-application-model/blob/master/docs/internals/generated_resources.rst#api
  HelloWorldApi:
    Description: "API Gateway endpoint URL for Prod stage for Hello World function"
    Value: !Sub "https://${ServerlessRestApi}.execute-api.${AWS::Region}.amazonaws.com/Prod/hello/"
  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

編集前の内容をすべて削除し、templateファイル を参考に以下の内容で上書きし保存します。

AWS_Billing/template.yaml

AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: Notify Slack every day of AWS billing

Globals:
  Function:
    Timeout: 10

Parameters:
  SlackWebhookUrl:
    Type: String
    Default: hoge

Resources:
  # https://docs.aws.amazon.com/ja_jp/AWSCloudFormation/latest/UserGuide/aws-resource-iam-role.html
  # https://docs.aws.amazon.com/ja_jp/step-functions/latest/dg/tutorial-lambda-state-machine-cloudformation.html
  BillingIamRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: Allow
            Principal:
              Service: lambda.amazonaws.com
            Action: "sts:AssumeRole"
      Policies:
        - PolicyName: "NotifySlackToBillingLambdaPolicy"
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              - Effect: Allow
                Action:
                  - "logs:CreateLogGroup"
                  - "logs:CreateLogStream"
                  - "logs:PutLogEvents"
                  - "ce:GetCostAndUsage"
                Resource: "*"

  HelloWorldFunction:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: hello_world/
      Handler: app.lambda_handler
      Runtime: python3.8
          # python3.6→python3.8 に変更
      Environment:
        Variables:
          # このURLはコミット&公開したくないため、デプロイ時にコマンドで設定する
          SLACK_WEBHOOK_URL: !Ref SlackWebhookUrl
      Role: !GetAtt BillingIamRole.Arn
      Events:
        NotifySlack:
          Type: Schedule
          Properties:
            Schedule: cron(0 0 * * ? *) # 日本時間AM9時に毎日通知する

Outputs:
  HelloWorldFunction:
    Description: "Hello World Lambda Function ARN"
    Value: !GetAtt HelloWorldFunction.Arn

  HelloWorldFunctionIamRole:
    Description: "Implicit IAM Role created for Hello World function"
    Value: !GetAtt BillingIamRole.Arn

Lambda 関数の中身を編集(app.py)

AWS_Billing ディレクトリ配下の app.py を編集し、Lambda 関数の中身を記述します。

app.py 編集前(クリックで展開)

AWS_Billing/hello_world/app.py

import json

# import requests


def lambda_handler(event, context):
    """Sample pure Lambda function

    Parameters
    ----------
    event: dict, required
        API Gateway Lambda Proxy Input Format

        Event doc: https://docs.aws.amazon.com/apigateway/latest/developerguide/set-up-lambda-proxy-integrations.html#api-gateway-simple-proxy-for-lambda-input-format

    context: object, required
        Lambda Context runtime methods and attributes

        Context doc: https://docs.aws.amazon.com/lambda/latest/dg/python-context-object.html

    Returns
    ------
    API Gateway Lambda Proxy Output Format: dict

        Return doc: https://docs.aws.amazon.com/apigateway/latest/developerguide/set-up-lambda-proxy-integrations.html
    """

    # try:
    #     ip = requests.get("http://checkip.amazonaws.com/")
    # except requests.RequestException as e:
    #     # Send some context about this error to Lambda Logs
    #     print(e)

    #     raise e

    return {
        "statusCode": 200,
        "body": json.dumps({
            "message": "hello world",
            # "location": ip.text.replace("\n", "")
        }),
    }

編集前の内容をすべて削除し、Lambda関数の作成 を参考に以下の内容で上書きし保存します。

AWS_Billing/hello_world/app.py

import os
import boto3
import json
import requests
from datetime import datetime, timedelta, date


SLACK_WEBHOOK_URL = os.environ['SLACK_WEBHOOK_URL']


def lambda_handler(event, context) -> None:
    client = boto3.client('ce', region_name='us-east-1')

    # 合計とサービス毎の請求額を取得する
    total_billing = get_total_billing(client)
    service_billings = get_service_billings(client)

    # Slack用のメッセージを作成して投げる
    (title, detail) = get_message(total_billing, service_billings)
    post_slack(title, detail)


def get_total_billing(client) -> dict:
    (start_date, end_date) = get_total_cost_date_range()

    # https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/ce.html#CostExplorer.Client.get_cost_and_usage
    response = client.get_cost_and_usage(
        TimePeriod={
            'Start': start_date,
            'End': end_date
        },
        Granularity='MONTHLY',
        Metrics=[
            'AmortizedCost'
        ]
    )
    return {
        'start': response['ResultsByTime'][0]['TimePeriod']['Start'],
        'end': response['ResultsByTime'][0]['TimePeriod']['End'],
        'billing': response['ResultsByTime'][0]['Total']['AmortizedCost']['Amount'],
    }


def get_service_billings(client) -> list:
    (start_date, end_date) = get_total_cost_date_range()

    # https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/ce.html#CostExplorer.Client.get_cost_and_usage
    response = client.get_cost_and_usage(
        TimePeriod={
            'Start': start_date,
            'End': end_date
        },
        Granularity='MONTHLY',
        Metrics=[
            'AmortizedCost'
        ],
        GroupBy=[
            {
                'Type': 'DIMENSION',
                'Key': 'SERVICE'
            }
        ]
    )

    billings = []

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


def get_message(total_billing: dict, service_billings: list) -> (str, str):
    start = datetime.strptime(total_billing['start'], '%Y-%m-%d').strftime('%m/%d')

    # Endの日付は結果に含まないため、表示上は前日にしておく
    end_today = datetime.strptime(total_billing['end'], '%Y-%m-%d')
    end_yesterday = (end_today - timedelta(days=1)).strftime('%m/%d')

    total = round(float(total_billing['billing']), 2)

    title = f'{start}~{end_yesterday}の請求額は、{total:.2f} USDです。'

    details = []
    for item in service_billings:
        service_name = item['service_name']
        billing = round(float(item['billing']), 2)

        if billing == 0.0:
            # 請求無し(0.0 USD)の場合は、内訳を表示しない
            continue
        details.append(f' ・{service_name}: {billing:.2f} USD')

    return title, '\n'.join(details)


def post_slack(title: str, detail: str) -> None:
    # https://api.slack.com/incoming-webhooks
    # https://api.slack.com/docs/message-formatting
    # https://api.slack.com/docs/messages/builder
    payload = {
        'attachments': [
            {
                'color': '#36a64f',
                'pretext': title,
                'text': detail
            }
        ]
    }

    # http://requests-docs-ja.readthedocs.io/en/latest/user/quickstart/
    try:
        response = requests.post(SLACK_WEBHOOK_URL, data=json.dumps(payload))
    except requests.exceptions.RequestException as e:
        print(e)
    else:
        print(response.status_code)


def get_total_cost_date_range() -> (str, str):
    start_date = get_begin_of_month()
    end_date = get_today()

    # get_cost_and_usage()のstartとendに同じ日付は指定不可のため、
    # 「今日が1日」なら、「先月1日から今月1日(今日)」までの範囲にする
    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


def get_begin_of_month() -> str:
    return date.today().replace(day=1).isoformat()


def get_prev_day(prev: int) -> str:
    return (date.today() - timedelta(days=prev)).isoformat()


def get_today() -> str:
    return date.today().isoformat()

S3 バケットの作成

コード等を格納するための S3 バケットを作成します。
s3://xxxxxxxxxx 部分に作成する S3 バケット名を入れて実行してください。
今回は sam-bucket-emikitani という名前で作成します。

  • 実行コマンド
    aws s3 mb s3://sam-bucket-emikitani

  • 実行結果

    kitani.emi:~/environment $ aws s3 mb s3://sam-bucket-emikitani
    make_bucket: sam-bucket-emikitani
    kitani.emi:~/environment $

sam build

cd AWS_Billing/ コマンドでAWS_Billing ディレクトリに移動します。

  • 実行結果
    kitani.emi:~/environment $ cd AWS_Billing/
    kitani.emi:~/environment/AWS_Billing $

sam build コマンドでビルドします。

  • 実行結果
    kitani.emi:~/environment/AWS_Billing $ sam build
    Building codeuri: /home/ec2-user/environment/AWS_Billing/hello_world runtime: python3.8 metadata: {} architecture: x86_64 functions: HelloWorldFunction
    Running PythonPipBuilder:ResolveDependencies
    Running PythonPipBuilder:CopySource
    
    Build Succeeded
    
    Built Artifacts  : .aws-sam/build
    Built Template   : .aws-sam/build/template.yaml
    
    Commands you can use next
    =========================
    [*] Validate SAM template: sam validate
    [*] Invoke Function: sam local invoke
    [*] Test Function in the Cloud: sam sync --stack-name {stack-name} --watch
    [*] Deploy: sam deploy --guided
            
    kitani.emi:~/environment/AWS_Billing $

sam package

続いて以下のコマンドを実行し、コード一式を S3 バケットにアップロードします。

  • 実行コマンド
    sam package \
        --output-template-file packaged.yaml \
        --s3-bucket sam-bucket-emikitani

  • 実行結果

    kitani.emi:~/environment/AWS_Billing $ sam package \
    >     --output-template-file packaged.yaml \
    >     --s3-bucket sam-bucket-emikitani
    Uploading to 82dfd5a6da2b821fb0c5d84f4d08ad16  617632 / 617632  (100.00%)
    
    Successfully packaged artifacts and wrote output template to file packaged.yaml.
    Execute the following command to deploy the packaged template
    sam deploy --template-file /home/ec2-user/environment/AWS_Billing/packaged.yaml --stack-name <YOUR STACK NAME>
    
    kitani.emi:~/environment/AWS_Billing $

sam deploy

最後にデプロイします。template.yaml の環境変数をオーバーライドし、ここで Slack の Webhook URL を設定します。

  • 実行コマンド
    sam deploy \
        --template-file packaged.yaml \
        --stack-name NotifyBillingToSlack \
        --capabilities CAPABILITY_IAM \
        --parameter-overrides SlackWebhookUrl=https://hooks.slack.com/services/xxxxxxxxxxxxx

    SlackWebhookUrl=https://hooks.slack.com/services/xxxxxxxxxxxxx にはコピーしておいた Slack の Webhook URL を入れてください。

sam deploy 実行結果(クリックで展開)
kitani.emi:~/environment/AWS_Billing $ sam deploy \
>     --template-file packaged.yaml \
>     --stack-name NotifyBillingToSlack \
>     --capabilities CAPABILITY_IAM \
>     --parameter-overrides SlackWebhookUrl=https://hooks.slack.com/services/xxxxxxxxxxxxx

        Deploying with following values
        ===============================
        Stack name                   : NotifyBillingToSlack
        Region                       : None
        Confirm changeset            : False
        Disable rollback             : False
        Deployment s3 bucket         : None
        Capabilities                 : ["CAPABILITY_IAM"]
        Parameter overrides          : {"SlackWebhookUrl": "https://hooks.slack.com/services/xxxxxxxxxxxxx"}
        Signing Profiles             : {}

Initiating deployment
=====================

Waiting for changeset to be created..
CloudFormation stack changeset
---------------------------------------------------------------------------------------------------------------------
Operation                     LogicalResourceId             ResourceType                  Replacement                 
---------------------------------------------------------------------------------------------------------------------
+ Add                         BillingIamRole                AWS::IAM::Role                N/A                         
+ Add                         HelloWorldFunctionNotifySla   AWS::Lambda::Permission       N/A                         
                              ckPermission                                                                            
+ Add                         HelloWorldFunctionNotifySla   AWS::Events::Rule             N/A                         
                              ck                                                                                      
+ Add                         HelloWorldFunction            AWS::Lambda::Function         N/A                         
---------------------------------------------------------------------------------------------------------------------

Changeset created successfully. arn:aws:cloudformation:ap-northeast-1:123456789012:changeSet/samcli-deploy1681817652/11a848f8-0228-41e0-b39e-xxxxxxxxxxxx


2023-04-18 11:34:23 - Waiting for stack create/update to complete

CloudFormation events from stack operations (refresh every 0.5 seconds)
---------------------------------------------------------------------------------------------------------------------
ResourceStatus                ResourceType                  LogicalResourceId             ResourceStatusReason        
---------------------------------------------------------------------------------------------------------------------
CREATE_IN_PROGRESS            AWS::IAM::Role                BillingIamRole                -                           
CREATE_IN_PROGRESS            AWS::IAM::Role                BillingIamRole                Resource creation Initiated 
CREATE_COMPLETE               AWS::IAM::Role                BillingIamRole                -                           
CREATE_IN_PROGRESS            AWS::Lambda::Function         HelloWorldFunction            -                           
CREATE_IN_PROGRESS            AWS::Lambda::Function         HelloWorldFunction            Resource creation Initiated 
CREATE_COMPLETE               AWS::Lambda::Function         HelloWorldFunction            -                           
CREATE_IN_PROGRESS            AWS::Events::Rule             HelloWorldFunctionNotifySla   -                           
                                                            ck                                                        
CREATE_IN_PROGRESS            AWS::Events::Rule             HelloWorldFunctionNotifySla   Resource creation Initiated 
                                                            ck                                                        
CREATE_COMPLETE               AWS::Events::Rule             HelloWorldFunctionNotifySla   -                           
                                                            ck                                                        
CREATE_IN_PROGRESS            AWS::Lambda::Permission       HelloWorldFunctionNotifySla   -                           
                                                            ckPermission                                              
CREATE_IN_PROGRESS            AWS::Lambda::Permission       HelloWorldFunctionNotifySla   Resource creation Initiated 
                                                            ckPermission                                              
CREATE_COMPLETE               AWS::Lambda::Permission       HelloWorldFunctionNotifySla   -                           
                                                            ckPermission                                              
CREATE_COMPLETE               AWS::CloudFormation::Stack    NotifyBillingToSlack          -                           
---------------------------------------------------------------------------------------------------------------------
CloudFormation outputs from deployed stack
-----------------------------------------------------------------------------------------------------------------------
Outputs                                                                                                               
-----------------------------------------------------------------------------------------------------------------------
Key                 HelloWorldFunctionIamRole                                                                         
Description         Implicit IAM Role created for Hello World function                                                
Value               arn:aws:iam::123456789012:role/NotifyBillingToSlack-BillingIamRole-xxxxxxxxxxxx                   

Key                 HelloWorldFunction                                                                                
Description         Hello World Lambda Function ARN                                                                   
Value               arn:aws:lambda:ap-northeast-1:123456789012:function:NotifyBillingToSlack-                         
HelloWorldFunction-xxxxxxxxxxx                                                                                       
-----------------------------------------------------------------------------------------------------------------------

Successfully created/updated stack - NotifyBillingToSlack in None

kitani.emi:~/environment/AWS_Billing $

裏では CloudFormation スタックが作成されているので、スタックの展開が完了するまで時間がかかります。
Cloud9 のターミナル上でスタックの進行状況が確認できますし、CloudFormation コンソールからもスタックの進行状況を確認することができます。

スタックの展開完了後 Lambda コンソールを確認すると、 Lambda 関数が作成されています。

動作確認

Lambda コンソールから手動でテストしてみます。
テストタブを開き、[テスト] をクリックします。

関数の実行が成功します。

Slack にも通知されました。

Cost Explorer の表示と見比べて、料金が大体一致しているのがわかります。

AM 9:00 にも時間通り通知されました。

Cloud9 環境の削除

SAM の実行に使用した Cloud9 環境を削除します。
Cloud9 コンソールに移動し、作成した Cloud9 環境を選択して [Delete] をクリックします。

テキストフィールドに Delete と入力し、[Delete] をクリックします。

終わりに

手元に開発環境がなくても、Cloud9 を利用してサーバーレス環境の構築ができました。
どなたかのお役に立てば幸いです。

参考