毎朝Lambdaで加工した画像をSlackに飛ばす(コスト0)

-　アドベントカレンダー4日目を担当します、体脂肪率8%マンです。
-　今回は、全無料でAWS上で画像を加工して毎日決まった時間にSlackに飛ばすシステムを構築したので共有します。
-　このシステムは毎日動いていますが、1ヵ月でかかる料金は0円です。

## 目次
1. どんなシステムを作ったか
1. 背景
1. つまづいたポイント
1. ソースコード
1. Slack通知とInstagram投稿リスト
1. おわりに

## どんなシステムを作ったか

### 何するシステム？
まず、毎朝7時に、EventBridgeがLambdaを起動して、Lambdaがローカルに保存している画像を加工します。具体的には、画像に対してLambda起動時の日付と任意のIndexを描画します。このとき、任意のIndexはDynamodbから取得を行い、日毎にインクリメントする形をとっています（つまり、12/1に１２５である場合、12/2は126のIndex番号を付与することが可能である）。画像を加工後、Slackに飛ばして処理は完了となります。

### 使用技術と役割
アーキテクチャ内に含まれるもの
-  EventBridge：スケジューラー
-  Lambda：画像加工およびSlack通知
-  DynamoDB：画像に付与するIndexの管理
-  Slack：通知先

ーーー

システムを実現する上で必要なもの（アーキ外）
-  Docker：LambdaのLayerに使うパッケージを作成
-  MEIRYO.TTC：画像加工時のフォント

### 構成

![スクリーンショット 2023-11-30 17.56.15.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/754596/6f13c024-e3d8-366b-07e1-f4b3079c64cd.png)

## 背景

:::note warn
背景長いので、技術のみに興味がある方は、読み飛ばしてください。
:::

読者の方は、日付と番号が書かれた画像を何に使っているか謎だと思います。
結論から言うと、英語日記の投稿のためにその画像を使っています。
というのも、私は毎日英語で日記を書いてフォロワー1人のみの鍵付きのInstagramに投稿しています。その1人はネイティブの友人（アメリカ出身＋日本語もペラペラ＋ベルリッツで働いている＋親友の奥さん）です。その方に、英語で書いた日記を添削してもらってるのですが、テキトーな画像を貼って日記を投稿すると、Instagramは基本自分のプロフィールから投稿リストをみると画像だけが見えるようになっているので、いつ書いた日記なのかが、添削者からも投稿者からも一目でわからないという課題がありました。日記の文頭に日付を書いているものの、1つ1つ開いて確認するのは手間です。また毎日写真を選定するのもすごくだるいです。毎日撮りませんし。テキトーにキャベツとか床とかを撮って、日記と関係ない写真を使って日記を投稿したりもしていました。笑

また、Indexを付与する理由としては、この記事を投稿した段階で900日以上日記の投稿を継続しているので、どのくらいの時期からボキャブラリーが増えていったとか、どれくらい続いているかを常に追いたいという成長の番号としてIndexを付与したかったので、わざわさDBまで用意してシステムを構成しました。

これを構築してから、毎日の投稿時の画像選定とIndexの計算の手間がなくなりましたし、添削時や振り替えったときにも、すぐに目的の日記を見つけることができるようになりました。

添削者であるネイティブの友人からは以前の、日記とは全く関係ない写真と共に投稿されてるのが面白かったんだけどな〜、と言われたのですが、まあそこは複数写真を貼り付けて投稿もできるので、なにか面白い写真が撮れた時には、トップに見える写真を日付+Index入りのものにして2枚目以降に付与すれば良いよねって感じにしています。

以上が背景になります。中には、こんなシステム作らなくてもいいやろって方もいると思うのですが、まあ目的をもって技術に触れられるってことで許してください。しかも毎月0円で動いてるのでコスト的にも負担はありません。

本当は投稿まで自動化できたらいいのですが、InstagramのAPIはビジネスアカウントじゃないと投稿機能を提供してないので、対処できてません。。泣 まあビジネスアカウント無料で作れるらしいですが。笑

## つまづいたポイント
ここでは、構築にあたって、つまづいたポイントをいくつか紹介します。
基本的にAWSを触ったことがある人は上記のシステムを作ること自体は簡単にできると思うので、あえて最初に構成だけ置いておきました。他方で、このシステムを構築する上で、ハマりそうかな？と思ったことを共有するので、同じようなシステムを構築してみたいと思っている方の一助になれば幸いです。

以下、3点について話します。

1. LayerにDockerで作成したパッケージを設定
1. Slackチャンネルへの通知
1. フォントはMEIRYOを使う

### LayerにDockerで作成したパッケージを設定

:::note warn
pythonのvenvとか使ってもできますので、Dockerなんだそれって方はそちらでやってみてください。
:::

　まず、LambdaでAWSが提供してくれる以外のライブラリなどを使いたい場合は、Layerの設定をする必要があります。で、今回私は、画像加工のために、pillowという画像をいじれるPythonのライブラリを使おうと思ったんですが、それがLambdaで提供されてないので、pillowを使えるようにLayerを設定してあげる必要がありました。（ライブラリはOpenCVとかでもokです）
以下のリンクにARNがまとまっているのでお使いのランタイムからpillowを選択してマネージメントコンソールに指定してあげるだけで、pillowライブラリをlambdaで使用できるようになります。
 https://github.com/keithrozario/Klayers#list-of-arns

しかし、ちょっと罠があって、これだけでlambdaを実行すると以下のエラーがでました。
```terminal:Lambda実行時のerror
"cannot import name 'DEFAULT_CIPHERS' from 'urllib3.util.ssl_'"
 ```
どうやらurllib3というライブラリが入っていないと使えないみたいで、今度はこのurllib3をLayerに設定してあげる必要がでてきました。じゃあ、先ほどのARNがまとまっているURLのdeploymentsからurllib3を選べば良いだけかと思いきや、python10でurllibのARNが見当たりません。。。。そんなわけで自前で用意してあげます。

 ##### Dockerでpillowライブラリをダウンロードするためにurllib3の入ったパッケージを用意する

まずは、以下のDockerfileを用意します。requestsはslackに画像をpostするために必要です。
```Dockerfile:Dockerfile
FROM python:3.10

WORKDIR /app

RUN python3 -m pip install requests "urllib3<2" -t /app
```

次に、Dockerfileが存在するディレクトリで以下のコマンドを実行していきます。
```terminal
docker build -t python310-requests-test2 .
```
→ ビルド（対象のDockerfileからイメージの作成を行う　-t は作成するイメージにつける名前）

```terminal
docker run --name new-temp-container -d python310-requests-test2 tail -f /dev/null
```
→スタート（イメージが作成できたので以下のコマンドでコンテナを実行する）

Docker Desktopを開いてterminalをひらく
https://www.docker.com/products/docker-desktop/
→ python -Vでpythonがインストールされているかを確認
→ pip liistでどのパッケージが存在するかを確認（urllib3とrequestsが入っていることを確認する）


次に、コンテナで作成したパッケージをローカルにコピーします。Dockerコンテナが起動している状態でローカルPCで以下のコマンドをたたきます。
```terminal
docker cp new-temp-container:/app/python ./
```
ローカルにファイルを持ってきたら圧縮を行う。
```terminal
zip -r9 layer.zip python
```

で、AWSマネージメントコンソールからLamndaのLayersに上記のパッケージをアップロードしてあげれば、urllib3がLambdaで使用できるようになり、ライブラリの依存関係のエラーは解消できるはずです。エラーが出ずにLambdaを実行できればOKです。

### Slackチャンネルへの通知

次は、Lambdaで加工した画像をLambdaのローカルから指定したSlackチャンネルに飛ばすことになるのですが、私はお恥ずかしながらSlackにポストするためのURLがどの部分を指すのかを探すのに、ものすごく時間をかけてしまったので、それを紹介したいと思います。

まず、Slackに通知するためにはAuth Tokenと、チャンネルを識別するためのChannelIDが必要になります。

- Tokenを発行する方法
これに関しては、以下の記事が非常に参考になったので、こちらをみながら設定してみてください！

https://qiita.com/kobayashi_ryo/items/a194e620b49edad27364


- ChanneIDを取得する方法
実際はめちゃくちゃ簡単でした。slackのチャンネルを右クリックすると、チャンネルのリンクを取得できるのですが、そのままコピペして使うのではなく、URLの一番後ろの部分がCannelIDとなります。（私はURL全部をチャンネルIDだと錯覚していることに気が付きませんでした。泣）
例：https://XXXXXX.slack.com/YYYYYYYY/チャンネルID(←ここ！)

ちなみにコードはこんな感じです。
```python:slackに画像を飛ばす.py
    url = "https://slack.com/api/files.upload"
    data = {
        "token": 取得したAuthToken,
        "channels": 取得したチャンネルID,
        "title": "タイトル",
        "initial_comment": "メッセージに付与するテキスト"
    }
    files = {'file': open(output_path, 'rb')}
    requests.post(url, data=data, files=files)
```


### フォントはMEIRYOを使う

はい、ここまでくれば正直この項はやらなくてもいいのですが、pillowのデフォルトのフォントだと日本語が使えなかったりフォントサイズを変えられないという問題がありました。私は日本語は使う必要はなかったのですが、フォントを大きくしたかったので、テキトーにメイリオを選びました。デフォルトのフォントだと自由が聞かないことにだけ気づいてもらえれば大丈夫です。
ちなみにメイリオはここにあります。

https://github.com/gocreating/fonts/blob/master/MEIRYOB.TTC

ソースコードをLambdaにアップロードする際に、加工する画像と一緒に圧縮してアップロードしますが、その際にフォントも一緒に圧縮してアップロードしましょう。
また、Lambdaにアップロードする容量が多いとLambdaから直接ソースコードをいじれなくなるので、そこも知っておいた方がよいです。（ソースコードを改変したい場合は、一々アップロードしてあげる必要があるという意味です。）



## ソースコード
```python:lambda_function.py
import os
from PIL import Image, ImageDraw, ImageFont
import boto3
import requests
from datetime import datetime
from dateutil import tz
from boto3.dynamodb.conditions import Key


dynamodb = boto3.resource('dynamodb')
table = dynamodb.Table('Diary_increment')

SLACK_POST_URL = 'チャンネルID'
SLACK_TOKEN = 'Token'


# Indexの更新(Dynamodb)
def update_diary_number():
    try:
        item_key = {'id': 1}
        response = table.get_item(Key=item_key)
        item = response.get('Item', None)
        if item:
            # number_of_diaryの値を取得して1加算
            new_number_of_diary = item.get('number_of_diary', 0) + 1
            # DBの値を更新しつつ、returnするIndex番号を変数に入れておく
            update_response = table.update_item(
                Key=item_key,
                UpdateExpression='SET number_of_diary = :val',
                ExpressionAttributeValues={
                    ':val': new_number_of_diary
                },
                ReturnValues='UPDATED_NEW'
            )
            print("更新されたアイテム:", update_response)
            return update_response['Attributes']['number_of_diary']
        else:
            print("指定されたキーのアイテムが見つかりませんでした。")
    except Exception as e:
        #DBとの通信が発生するのでerrorをキャッチできるようにする
        print("エラーが発生しました:", e)

# 画像を過去する
def add_text_to_image(image_path, data_text, current_dir):
    # 画像を読み込む
    with Image.open(image_path) as image:
        draw = ImageDraw.Draw(image)
        fontfile_name = f'{current_dir}/MEIRYO.TTC'
        # フォントとサイズを選択
        font = ImageFont.truetype(fontfile_name, 100) 
        # テキストのサイズを取得
        text_width, text_height = draw.textsize(data_text, font)
        # 画像の中心を計算
        width, height = image.size
        x = (width - text_width) / 2
        y = (height - text_height) / 2
        # テキストを画像に挿入
        draw.text((x, y), data_text, font=font, fill="white")
        # 透明度情報がある場合、削除する
        if image.mode in ('RGBA', 'LA') or (image.mode == 'P' and 'transparency' in image.info):
            image = image.convert('RGB')
        # 変更を保存
        output_path = f"/tmp/{data_text}.png"
        image.save(output_path)
        return output_path


def lambda_handler(event, context):
    # ラムダのディレクトリを取得し、画像のパスを更新します。
    # __file__は実行中のスクリプトのパスを表す。dirnameにパスを渡すと親ディレクトリを返すので、当該スクリプト名を抜いたパス名がcurrent_dirに格納。
    current_dir = os.path.dirname(os.path.abspath(__file__))
    # joinは、3つの変数を結合してパスとする。
    image_path = os.path.join(current_dir, './', '画像.png')
    # 日時を取得する。
    # フォーマット
    date_format='%Y-%m-%d'
    # タイムゾーン
    time_zone = tz.gettz('Asia/Tokyo')
    # 日付
    date = datetime.now(tz=time_zone)
    # 格納
    date_result = date.strftime(date_format)
    #DynamoDBでdiaryの日数をインクリメント
    diary_number = update_diary_number()
    #日付と日記の番号をk都合
    finaly_text = f'{date_result}_diary-{diary_number}'

    # 画像にテキストを追加
    output_path = add_text_to_image(image_path, finaly_text, current_dir)

    # Slack通知
    url = "https://slack.com/api/files.upload"
    data = {
        "token": SLACK_TOKEN,
        "channels": SLACK_POST_URL,
        "title": "タイトル",
        "initial_comment": "メッセージ"
    }
    files = {'file': open(output_path, 'rb')}
    response = requests.post(url, data=data, files=files)

    # 応答を確認、ログ出力
    print(response.json())
    return {
        'statusCode': 200,
        'body': f"uploaded to Slack.{response.json()}"
    }

```
## Slack通知とInstagram投稿リスト
### Slack通知
![Slack通知.jpeg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/754596/27a1a858-8639-fb0b-0e31-44c421b91255.jpeg)

### Instagram投稿リスト
![Instagram投稿リスト.jpeg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/754596/4297e9b4-bb98-e4ea-ebb6-376716c99712.jpeg)

KDDIに入社してから2週間くらいで日記投稿を始めたのですが、見返したら2021/4/17から続けてました。（自分がんばってる。。

## おわりに
- ふう、Qiita久しぶりに書きました。
- インスタの投稿、Indexが4桁だと確実に見切れますね。文字の配置を変えなきゃいけなさそうです^_^
- それでは次は、12/9にKDDI本体の方のアドベントカレンダーでお会いしましょう。