AWS CDK で Lambda + CloudWatch Event の 日次 Slack 通知を作る

# はじめに
Lambda Function + CloudWatch Event な日次Slack通知を AWS CDK を使って作りました。
Python で作ってます。
CDKを触るにはちょうどいい規模でした。

# 作るもの
毎日9時にAWSの利用料金をSlack通知してくれるやつ。
![スクリーンショット 2021-05-18 14.01.09.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/112011/36b5a29d-f978-04f5-e274-788031e56ba7.png)




# 環境
```zsh
% sw_vers
ProductName:    Mac OS X
ProductVersion: 10.15.7
BuildVersion:   19H2
```

# 開始
## AWS CKD のインストール

```zsh:terminal
npm install -g aws-cdk
```
バージョン確認

```zsh:terminal
cdk --version
1.104.0 (build 44d3383)
```

※詳細は以下を参照

https://docs.aws.amazon.com/ja_jp/cdk/latest/guide/getting_started.html


## CDK プロジェクトの作成
プロジェクト用フォルダ作成

```zsh:terminal
mkdir mycdksample && cd mycdksample
```

CDK プロジェクト作成

```zsh:terminal
cdk init --language=python
```
フォルダ構成

```zsh:terminal
% tree -I ".DS_Store|.venv|.git|.gitignore"  -La 4 --dirsfirst
.
├── mycdksample
│   ├── __init__.py
│   └── mycdksample_stack.py
├── README.md
├── app.py
├── cdk.json
├── requirements.txt
├── setup.py
└── source.bat
```

Python の仮想環境を Activate

```zsh:terminal
python3 -m venv .venv && source .venv/bin/activate
```

先に必要なパッケージをインストール

```zsh:terminal
pip install aws_cdk.core aws_cdk.aws_lambda aws_cdk.aws_events aws_cdk.aws_events_targets requests
```

## Lambda Fucntion を作成

```zsh:terminal
mkdir lambda && vi lambda/app.py # コードは参考程度に
```
```python:lambda/app.py
# encoding: utf-8
import json
import datetime
import requests
import boto3
import os
import logging

TODAY = datetime.datetime.utcnow()
BEGINING_OF_THE_MONTH = TODAY - datetime.timedelta(days=TODAY.day - 1)
START_DATE = BEGINING_OF_THE_MONTH.strftime('%Y/%m/%d').replace('/', '-')
END_DATE = TODAY.strftime('%Y/%m/%d').replace('/', '-')

SLACK_POST_URL = os.environ['SLACK_POST_URL']
SLACK_CHANNEL = os.environ['SLACK_CHANNEL']

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

client = boto3.client('ce')

def get_total_cost():
    response = client.get_cost_and_usage(
        TimePeriod={
            'Start': START_DATE,
            'End': END_DATE
        },
        Granularity='MONTHLY',
        Metrics=[
            'UnblendedCost',
        ],
    )

    total_cost = response["ResultsByTime"][0]["Total"]["UnblendedCost"]["Amount"]
    return total_cost

def handler(event, context):
    text = "{}までのAWS合計料金 : ${}".format(END_DATE, get_total_cost())
    content = {"text": text}

    slack_message = {
        'channel': SLACK_CHANNEL,
        "attachments": [content],
    }

    try:
        requests.post(SLACK_POST_URL, data=json.dumps(slack_message))
    except requests.exceptions.RequestException as e:
        logger.error("Request failed: %s", e)
```

## Lambda Layer を作成
requests モジュールを使うので、 Lambda Layer を利用
Lambda Layer 用のディレクトリを作成

```zsh:terminal
mkdir lambda_layer
```

requirements.txt を作成

```zsh:terminal
pip freeze | grep requests > lambda_layer/requirements.txt
```

Makefile を作成

```zsh:terminal
vi Makefile
```
```makefile:Makefile
layer_build:
	docker run --rm \
		-v ${CURDIR}/lambda_layer:/var/task \
		lambci/lambda:build-python3.8 \
		pip install -r requirements.txt -t python/
```

Make

```zsh:terminal
make layer_build
```
lambda_layer 配下に python ディレクトリが作成されます

```zsh
% tree -I ".DS_Store|.venv|.git|.gitignore"  -La 3 --dirsfirst
.
├── lambda
│   └── app.py
├── lambda_layer
│   ├── python
│   │   ├── bin
│   │   ├── certifi
│   │   ├── certifi-2020.12.5.dist-info
│   │   ├── chardet
│   │   ├── chardet-4.0.0.dist-info
│   │   ├── idna
│   │   ├── idna-2.10.dist-info
│   │   ├── requests
│   │   ├── requests-2.25.1.dist-info
│   │   ├── urllib3
│   │   └── urllib3-1.26.4.dist-info
│   └── requirements.txt
(省略)
```

## Stack を作成
mycdksample/mycdksample_stack.py　を編集

```python:mycdksample/mycdksample_stack.py
from aws_cdk import core as cdk
# For consistency with other languages, `cdk` is the preferred import name for
# the CDK's core module.  The following line also imports it as `core` for use
# with examples from the CDK Developer's Guide, which are in the process of
# being updated to use `cdk`.  You may delete this import if you don't need it.
from aws_cdk import (
    core,
    aws_s3 as _s3,
    aws_lambda as _lambda,
    aws_events as _events,
    aws_events_targets as _event_targets,
    aws_iam as _iam
)
import os

class MycdksampleStack(cdk.Stack):
    def __init__(self, scope: cdk.Construct, construct_id: str, **kwargs) -> None:
        super().__init__(scope, construct_id, **kwargs)
        # S3 Bucket
        my_stack_bucket = _s3.Bucket(
            self, f"cdk-S3-{construct_id}",
            removal_policy=cdk.RemovalPolicy.DESTROY
        )
        # IAM Role
        my_function_role = _iam.Role(
            self, f"cdk-Role-{construct_id}",
            assumed_by=_iam.ServicePrincipal("lambda.amazonaws.com")
        )
        # Managed Policy
        my_function_role.add_managed_policy(
            _iam.ManagedPolicy.from_managed_policy_arn(
                self, f"cdk-Policy-{construct_id}",
                managed_policy_arn="arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
            )
        )
        # Add to Policy
        my_function_role.add_to_policy(
            _iam.PolicyStatement(
                effect=_iam.Effect.ALLOW,
                resources=[
                    "arn:aws:ce:us-east-1:{YOUR AWS ACCOUNT ID}:/GetCostAndUsage"
                ],
                actions=[
                    "ce:GetCostAndUsage",
                ]
            )
        )
        # Lambda Layer
        layer = _lambda.LayerVersion(
            self,
            f'cdk-Layer-{construct_id}',
            compatible_runtimes=[_lambda.Runtime.PYTHON_3_8],
            code=_lambda.AssetCode("lambda_layer"),
        )
        # Lambda Function
        my_function = _lambda.Function(
            self, f'cdk-Function-{construct_id}',
            runtime=_lambda.Runtime.PYTHON_3_8,
            code=_lambda.Code.asset('lambda'),
            handler='app.handler',
            environment={
                'TZ': "Asia/Tokyo",
                'SLACK_POST_URL': "{YOUR SLACK URL}",
                'SLACK_CHANNEL': "{YOUR CHANNEL}"
            },
            layers=[layer],
            timeout=cdk.Duration.seconds(60),
            role=my_function_role
        )
        # CloudWatch Event
        my_events = _events.Rule(
            self, "cdk-Event-{construct_id}",
            schedule=_events.Schedule.cron(
                minute='00',
                hour='00',
                day='*',
                month='*',
                # week_day='?', <- issue, don't add
                year='*'),
            enabled=True,
        )
        my_events.add_target(_event_targets.LambdaFunction(my_function))

def main():
    app = core.App()
    MycdksampleStack(
        app,
        "MycdksampleStack",
        env={
            "region": os.environ["CDK_DEFAULT_REGION"],
            "account": os.environ["CDK_DEFAULT_ACCOUNT"]
        }
    )
    app.synth()

if __name__ == "__main__":
    main()
```

Slack の URL と Channel はこちらから取得

https://slack.com/services/new/incoming-webhook

ServicePrincipal を探すのも苦労したので、こちらも記録

https://gist.github.com/shortjared/4c1e3fe52bdfa47522cfe5b41e5d6f22

## ローカルで動作確認
AWS CloudFormation template を作成

```zsh:terminal
cdk synth --no-staging > template.yaml
```

ローカルでテスト実行

```zsh:terminal
sam local invoke cdkFunctionMycdksampleStack{YOUR ID※} --no-event
```

以下の通知が来たら成功
![スクリーンショット 2021-05-18 13.52.06.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/112011/eed7a0e0-514e-10a4-40fb-9b9644072fde.png)


※cdkFunctionMycdksampleStack{YOUR ID※} は ```template.yaml``` を参照
SAM CLI のインストールは以下を参照

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

## デプロイ

bootstrap を実行

```zsh:terminal
cdk bootstrap
```

デプロイ

```zsh:terminal
cdk deploy
```

![スクリーンショット 2021-05-18 11.34.49.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/112011/63b5458b-cf2e-3802-b901-a6814c0280fa.png)


bootstrap, deploy は以下を参照

https://docs.aws.amazon.com/ja_jp/cdk/latest/guide/getting_started.html


## 後始末

```zsh:terminal
cdk destroy
```


# 最後に
やっぱりPythonが好き :snake: