Lambda コンテナイメージを使ってSlackのグラフ貼り付けBOTを作る

この記事は[Wano Group Advent Calendar 2023](https://qiita.com/advent-calendar/2023/wano-group)の23日目の記事となります。

## はじめに

プロダクトに関わる指標を確認する際、開発部門が作成した社内ページ・BIツール・各種アナリティクスサービスを用いると思います。

ですが、チャットツール(Slack等)で指標を確認できると、より手軽で利便性も上がるかなと思ったのでAWSサービスを用いて作ってみました。

## 構成

構成は以下のようになります。

![lambda with container.drawio (1).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/635079/afa82d46-96cc-f7c4-cb90-1b914350eff3.png)

1. AWS Lambda用のコンテナイメージをECRで用意
1. AWS LambdaでAthenaからデータ取得->グラフ作成→Slackへ通知
1. 定期的な実行をEventBridge Schedulerで作成

活用するデータはS3に格納されていて、Athenaでクエリが実行できる環境が整っている事を前提とします。

### Slack App 設定

まずは[Slack api](https://api.slack.com/apps)からアプリを作ります。
作成過程は省きますが、アプリの**OAuth & Permissions**のBot User OAuth Tokenの値をメモしておき、Scopeは`chat:write,files:write,incoming-webhook`を付与しておきます。

通知したいチャンネルにアプリを追加しておきます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/635079/a71428c2-3363-321d-6633-cddc0c72f2b5.png)

また、メモしたBot User OAuth Tokenの値をAWS Secrets Managerに登録しておきます。

![20231112.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/635079/6a811473-24c8-8b08-b319-e54691acfefd.png)



### AWS Lambda用プログラム作成

ローカル環境で実行をテストするために[python-lambda-local](https://pypi.org/project/python-lambda-local/)を使います。

```bash
$ pip install python-lambda-local
```

Lambdaで実行する流れの詳細は

1. Athenaで実行するクエリを読み込む
1. クエリを実行しデータを取得する
1. データをもとにMatplotlibでグラフを作成する
1. requestsライブラリでSlackへグラフ付きの通知を飛ばす

のような形です。


Lambda ローカル開発環境は以下のような構成になっています。
```
└── lambda_with_container
       ├── event.json
       ├── lambda_function.py
       ├── lib
       ├── query.sql
       └── requirements.txt
```

#### event.json
python-lambda-local 実行時の入力内容が入っています。
実際にLambdaで実行する際の入力と同じです。

```json
{
    "slack_channel_name" : "#general", -- 投稿するSlackチャンネル名
    "bucket" : "bucket_name",          -- Athenaへ実行するsqlファイルのバケット
    "key" : "/query/query.sql",        -- Athenaへ実行するsqlファイルのキー
    "x_column" : "request_date",       -- 取得したデータのX軸カラム名
    "y_column" : "sent_bytes",         -- 取得したデータのY軸カラム名
    "hue_column" : "elb_name"          -- 取得したデータの要素名
}
```

#### lib

python-lambda-localを用いて実行をテストする際に用いるライブラリデータが入っています。
matplotlibを使いたい場合は

```bash
$ pip install matplotlib -t ./lib
```

のようにします。

#### requirements.txt

実行するのに必要なライブラリを記載しています。

```
pandas==2.1.1 
matplotlib==3.8.0 
boto3==1.28.68
requests==2.31.0
awswrangler==3.4.1
seaborn==0.13.0
```

`python-lambda-local`用の環境(`lib`)を作る際は、これを用いて

```bash
$ pip install -r requirements.txt -t ./lib
```

のようにして作ります。

#### query.sql
Athenaへ実行するためのクエリを書いています。
```sql
SELECT
    DATE(SUBSTR(request_timestamp,1,10)) as request_date,
    elb_name,
    SUM(sent_bytes) as sent_bytes
FROM "sampledb"."elb_logs"
GROUP BY 1,2
```

実際の動作時は、ローカルへ置かず`event.json`に記載した**S3 URIの場所にファイルをアップロードしておきます**。
自動でアップロードする方法としては、Githubでコード管理していることを前提として、特定ブランチPUSH時に
1. Github ActionsでS3へアップロード
1. CodePipelineの設定を行い、CodeBuildでS3へアップロード

等で行います。


#### lambda_function.py
lambdaで実際に動かすコードです。

```py
import json
import logging
import os

import awswrangler as wr
import boto3
import matplotlib.pyplot as plt
import requests
import seaborn as sns
from botocore.exceptions import ClientError
from matplotlib import rcParams

sns.set()
rcParams['font.family'] = 'Noto Sans CJK JP'

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

def get_secret():
    secret_name = "SLACK_BOT_USER_OAUTH_TOKEN"
    region_name = "ap-northeast-1"

    # Create a Secrets Manager client
    session = boto3.session.Session()
    client = session.client(
        service_name='secretsmanager',
        region_name=region_name
    )

    try:
        get_secret_value_response = client.get_secret_value(
            SecretId=secret_name
        )
    except ClientError as e:
        # For a list of exceptions thrown, see
        # https://docs.aws.amazon.com/secretsmanager/latest/apireference/API_GetSecretValue.html
        raise e

    # Decrypts secret using the associated KMS key.
    secret = json.loads(get_secret_value_response['SecretString'])
    
    # Your code goes here.
    return secret['SLACK_BOT_USER_OAUTH_TOKEN']


def download_query_from_s3(bucket, key, local_file_path, session):
    try:
        wr.s3.download(
            path=f"s3://{bucket}{key}", 
            local_file=local_file_path,
            boto3_session=session
        )
    except ClientError as e:
        logger.error(f"Error downloading file from S3: {e}")
        raise

def post_image_to_slack(slack_token, channel, image_path):
    headers = {"Authorization": f"Bearer {slack_token}"}
    payload = {"channels": channel}
    with open(image_path, "rb") as image_file:
        image_data = image_file.read()
    files = {"file": (image_path, image_data, "image/png")}
    try:
        response = requests.post("https://slack.com/api/files.upload", headers=headers, data=payload, files=files)
        response.raise_for_status()
    except requests.exceptions.RequestException as e:
        logger.error(f"Error posting to Slack: {e}")
        raise

def lambda_handler(event, context):
    slack_token = get_secret()
    slack_channel = event['slack_channel_name']
    local_save_image_path = '/tmp/result.png'
    session = boto3.Session()

    try:
        download_query_from_s3(event['bucket'], event['key'], '/tmp/query.sql', session)

        with open('/tmp/query.sql', 'r') as file:
            sql = file.read()

        df = wr.athena.read_sql_query(sql=sql, database='*', boto3_session=session, ctas_approach=False)

        plt.figure(figsize=(15, 6))
        if event['hue_column'] != "":
            sns.lineplot(df, x=event['x_column'], y=event['y_column'], hue=event['hue_column'])
        else:
            sns.lineplot(df, x=event['x_column'], y=event['y_column'])
        plt.savefig(local_save_image_path)
        plt.close()

        post_image_to_slack(slack_token, slack_channel, local_save_image_path)

    except Exception as e:
        logger.error(f"Error in lambda execution: {e}")
        raise

    return 'Result.png has been saved and posted to Slack.'
```

python-lambda-localで実行してみます。

```
$ python-lambda-local -l lib/ -f lambda_handler -t 100 lambda_function.py event.json
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/635079/2442f827-da6a-da67-e895-040a4ae4fd1f.png)

無事実行されました。
ローカルでのテストにはaws cliにて用いるcredentialsの情報を`~/.aws/credentials`に置いておく必要があり、そのcredentialsのロールが実行するための権限を満たす必要が有ります。



### ECRへイメージをデプロイ

ローカルでの実行が成功したら、そのままDockerイメージを作成しECRへデプロイします。

[AWS LambdaではLayerをつかってライブラリを追加することもできます](https://docs.aws.amazon.com/ja_jp/lambda/latest/dg/gettingstarted-limits.html)が、今回必要なライブラリ群は**Layerのサイズ上限「解凍後250MB以内」に抑えることができない**ので、コンテナイメージを使っています。

コンテナイメージであればイメージサイズ10GBまで対応しているので、問題無くライブラリを使えます。

基本的に流れは

https://docs.aws.amazon.com/ja_jp/lambda/latest/dg/python-image.html#python-image-instructions

上記リンクに則っています。


#### Dockerfile作成



```Dockerfile:Dockerfile
FROM public.ecr.aws/lambda/python:3.11

# システム更新と必要なパッケージのインストール
RUN yum update && yum install -y wget unzip

# ファイルコピー
COPY requirements.txt ${LAMBDA_TASK_ROOT}
COPY lambda_function.py ${LAMBDA_TASK_ROOT}

# ライブラリをrequirements.txtからインストール
RUN pip install --no-cache-dir -r requirements.txt

# Matplotlib用に日本語フォントをインストール→ダウンロード
RUN wget https://noto-website-2.storage.googleapis.com/pkgs/NotoSansCJKjp-hinted.zip && \
    unzip NotoSansCJKjp-hinted.zip -d NotoSansCJKjp && \
    mkdir -p /usr/share/fonts/ && \
    mv NotoSansCJKjp /usr/share/fonts/NotoSansCJKjp && \
    chmod 644 /usr/share/fonts/NotoSansCJKjp/* && \
    rm NotoSansCJKjp-hinted.zip
    
# Lambda 関数ハンドラを指定
CMD ["lambda_function.lambda_handler"]
```

ローカル環境での実行では、設定によりますが問題無く日本語をMatplotlibで使えます。
ただし、Lambda with Containerを使う場合、コンテナに日本語フォントをダウンロードしておく必要があります。

#### テスト&デプロイ

イメージを作成します。

```bash
$ docker build --platform linux/amd64 -t graph-notification:test .
```


ECRへリポジトリを作成します

```bash
$ aws ecr get-login-password --region ap-northeast-1 | docker login --username AWS --password-stdin 111122223333.dkr.ecr.ap-northeast-1.amazonaws.com
Login Succeeded
$ aws ecr create-repository --repository-name graph-notification --region ap-northeast-1 --image-scanning-configuration scanOnPush=true --image-tag-mutability MUTABLE
{
    "repository": {
        "repositoryArn": "arn:aws:ecr:ap-northeast-1:xxxxxxxxxxxx:repository/graph-notification",
        "registryId": "xxxxxxxxxxxx",
        "repositoryName": "graph-notification",
        "repositoryUri": "xxxxxxxxxxxx.dkr.ecr.ap-northeast-1.amazonaws.com/graph-notification",
        "createdAt": "2023-11-12T13:16:05+09:00",
        "imageTagMutability": "MUTABLE",
        "imageScanningConfiguration": {
            "scanOnPush": true
        },
        "encryptionConfiguration": {
            "encryptionType": "AES256"
        }
    }
}
```

レスポンスの`repositoryUri`をコピーして、先ほど作成したイメージを置き換えます

```bash
$ docker tag graph-notification:test <repositoryUri>:latest
```

ECRリポジトリにイメージをデプロイします。


```bash
$ docker push <repositoryUri>:latest
```
できました。

![202311121.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/635079/6cc8888c-bdfe-9d02-3b0f-d63f0ec4fb85.png)


### Lambda with Container 関数作成


#### IAMロールの作成

Lambda with Container に割り当てるIAMロールを作っておきます。
今回は簡単にコンソールから
```
- AmazonAthenaFullAccess
- AmazonS3FullAccess
- secretsmanager:GetSecretValueのActionが許可された独自ポリシー
```

のポリシーを追加しました。実際に運用する場合は最小権限の原則の元ポリシーをアタッチしたほうが良いです。


#### 関数作成&実行

ECRリポジトリにデプロイしたイメージを用いて関数を作成します。

```bash
$ aws lambda create-function \
  --function-name graph-notification \
  --region ap-northeast-1 \
  --package-type Image \
  --code ImageUri=<repositoryUri>:latest \
  --role arn:aws:iam::xxxxxxxxxxxx:role/lambda-ex \ # 先ほど作成したIAMロールのARN
  --timeout 60 \
  --memory-size 1024 \
  --ephemeral-storage Size=1024

{
    "FunctionName": "graph-notification",
    "FunctionArn": "arn:aws:lambda:ap-northeast-1:xxxxxxxxxxxx:function:graph-notification",
    "Role": "arn:aws:iam::xxxxxxxxxxxx:role/tesrrole",
    "CodeSize": 0,
    "Description": "",
    "Timeout": 60,
    "MemorySize": 1024,
    "LastModified": "2023-11-12T04:38:00.836+0000",
    "CodeSha256": "",
    "Version": "$LATEST",
    "TracingConfig": {
        "Mode": "PassThrough"
    },
    "RevisionId": "xxxxxxxxxxxx",
    "State": "Pending",
    "StateReason": "The function is being created.",
    "StateReasonCode": "Creating",
    "PackageType": "Image",
    "Architectures": [
        "x86_64"
    ],
    "EphemeralStorage": {
        "Size": 1024
    }
}
```


関数を呼び出します。
AWS CLI v2では、呼び出し時のjsonをbase64にエンコードする必要があります。

https://dev.classmethod.jp/articles/aws-cli-v2-blob-default-base64/


```
$ aws lambda invoke --function-name graph-notification --payload "$(cat event.json | base64)" response.json
{
    "StatusCode": 200,
    "ExecutedVersion": "$LATEST"
}
```

「スッコココ」とくればOKです！

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/635079/0123cfc2-fece-146d-9baa-8144fd095f6f.png)



### EventBridge Schedulerで定期実行

EventBridge Schedulerを使って、毎日実行させる場合は、作成したLambda関数に対して`event.json`の中身を渡してあげればよいです。

EventBridge->スケジュール->スケジュールを作成で任意の時間設定にして

ターゲットをAWS Lambda Invoke
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/635079/485b15f9-d6a0-eeb4-d1b5-bfaf160b686b.png)

Lambda関数を選択し、ペイロードに`event.json`の内容を張り付け。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/635079/d79d4257-bc2f-fa8d-81c4-3e48321289ff.png)

これで定期的に結果を張り付けてくれるようになります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/635079/a5df2746-171a-c612-5775-925149d30ed3.png)


## さいごに

重要な指標についてこの方法でSlackへ投稿することで、全員の指標に対する意識を高めることができるかなと思い、作ってみました。

出力結果などを凝りたい場合、Lambdaでは結果に応じて描画を振り分けることが難しくなってくると思いますし、BIツールが適しているので、そちらで行うのが良いと思います。
あくまで重要な指標についてだけ行うのが、運用面でも良いと思っています。

仮に複数のクエリが対象となる場合は、[AWS Step Functions Distributed Map](https://dev.classmethod.jp/articles/distributed-map-which-can-perform-large-scale-parallel-processing-with-aws-step-functions/)を用いてそれぞれの`event.json`を作り、並列に実行させるのが良さそうです。

閲覧された方の参考になれば幸いです。

### 余談

- [matplotlibの日本語化](#Dockerfile作成)については、[japanize-matplotlib](https://pypi.org/project/japanize-matplotlib/)を使うという手もあります。
- BIツールの[Tableau](https://www.tableau.com/ja-jp)では、[バージョン2021.3からSlackへ通知を行うことができます](https://help.tableau.com/current/pro/desktop/ja-jp/slack_tableau.htm)が、Tableauへ登録しているメールアドレス宛にDMで届くので、今回行ったものより少し柔軟性に欠けます。

### 人材募集

現在、Wanoグループでは人材募集をしています。興味のある方は下記を参照してください。
Wano | [Wano Group JOBS](https://group.wano.co.jp/jobs/)
TuneCore Japan | [TuneCore Japan JOBS](https://www.tunecore.co.jp/jobs)