初めてのServerlessアプリ実装での 迷い&詰まり&学びポイント その２

# この記事について
Serverless初心者（WorkShopで触ったことはあるが、実際のアプリは作ったことがない人）がちょっとしたアプリの実装をやってみた際の迷い、詰まり、学びをつらつらと書きます。
Lambda多めかなと思って最初Lambdaのタイトルにしていましたが、Serverless全般の方が書きやすいなと思ったのでタイトルを変えました。
その１はこちら

https://qiita.com/gengen0719/items/9307253e41f4fc2dc27c

# 作ったもの
Googleカレンダーのその日の予定を確認し、お昼ご飯が食べられる時間をずんだもんが教えてくれるBot。
家族の予定をまとめたGoogleカレンダーを作って、ずんだもんBotと友達になるとお昼ご飯を食べられる時間を教えてくれるのだ。

構成図

![ずんだもんBot.drawio (2).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1126770/c25eee57-1e79-4aaf-a637-5d0d2971b30d.png)

前回は音声ファイルを作成してS3にアップするところまででしたが、そこからLINEに送信する処理と、友達追加をした際に送信先のLINEIDを取得する登録処理を作成していったん完成としました。2週間ほど動かしてみましたが機嫌よく動作してくれています。
まだTODOとかが残ってしまっていますが、一応GitHubにコードもあげておきました。

https://github.com/gengen0719/zunda-lunchtime-bot

# 迷い&詰まり&学びポイント

本題です。
少し運用してみての気づきもあるので前回実装箇所に関する記載もあります。

## S3のライフサイクルルールを設定して古いファイルを消す
このアプリでは音声ファイルをボイスメッセージという形で送信します。
~~ボイスメッセージは音声ファイル自体を受け手側に送るため、送信した後は音声ファイルをS3に置いておく必要はありません。いらないファイルは消しましょうということで、たしかライフサイクルルールというものがあったよねと思い、オブジェクトの現行バージョンが１日経ったら削除されるように設定しました。~~ 

こちらは誤りであることがわかりました。
LINEではユーザーが操作したタイミングでURLにアクセスしてダウンロードが行われ、一度ダウンロードが行われるとそれ以降はアクセスしなくなるという仕様のようでした。
そのためユーザーがアクセスするまではS3のファイルを保持する必要があります。
ライフサイクルルール自体は１日のままとして、有効期限は１日であることを示すメッセージを追加する実装にしました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1126770/256a95d6-517d-4cd0-92a0-2ee797e4a878.png)

設定後すぐには古いファイルが消えませんでしたが、しばらく待つと当日以外のファイルはうまく消えてくれました。

## S3の署名付きURLを使って公開時間を最小限にする
LINEのボイスメッセージ送信APIは以下のような形になっており、送信時にはLINEからアクセスできるURLが必要です。
```Python
    headers = {
        "Authorization": f"Bearer {LINE_ACCESS_TOKEN}",
        "Content-Type": "application/json"
    }

    payload = {
        "to": line_user_id,
        "messages": [{
            "type": "audio",
            "originalContentUrl": audio_url,
            "duration": audio_duration  
        }]
    }

    response = requests.post("https://api.line.me/v2/bot/message/push", headers=headers, json=payload)
```

~~ライフサイクルルールの設定で、S3のファイル自体が1日経てば消えてくれるのですが、S3のファイルにアクセスできる時間は送信完了までで十分です。
そのためLINEに渡す音声ファイルのURLを署名付きURLにして、LINEからS3のファイルにアクセスできる時間を5分までに絞る実装にしました。~~
こちらもユーザーが開くまではS3にファイルを置いておく必要があるということで、1日という有効期限を定め、その期限が経過したらアクセスできなくなるように署名付きURLを発行するように修正しました。

```python
def generate_presigned_url(bucket_name, object_key, expiration=300):
    """S3の署名付きURLを発行"""
    return s3.generate_presigned_url('get_object',
                                     Params={'Bucket': bucket_name, 'Key': object_key},
                                     ExpiresIn=expiration)
```

# S3のユーザー定義メタデータを利用する
LINEのボイスメッセージではmessageAPIに渡したdurationが音声ファイルの長さとして表示されます。

![IMG_6497.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1126770/43aaf132-2a5f-4488-a09b-67840e658776.jpeg)

今回のアプリでは音声ファイルを生成するmake-voiceと、LINEのmessageAPIに音声ファイルのURLを送信するbroadcastが別のLambdaに分かれています。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1126770/412aea97-ba6d-4e91-990f-c2fa7ccbf5f2.png)

音声ファイルの長さを測るには音声ファイルを実行環境に展開しフレームの長さを測る必要がありますが、broadcastの他の処理では音声ファイルを実行環境に展開する必要がないため、少し無駄な処理になってしまう感じがします。

またmake-voiceで作成したファイルの情報をDBなどに保持させることもできますが、やりたいことを実現するためには少々オーバーな処理のように思いました。

調べてみるとS3のオブジェクトにはユーザーが定義したメタデータを持たせることができるようだったので、今回はそこに音声ファイルの長さを持たせることにしました。

make-voiceでS3に音声ファイルをPUTする処理
```python
   # 音声ファイルを S3 バケットに保存する
   # 音声ファイルの長さをMeatadataで持たせる
    s3.put_object(
        Bucket= bucket,
        Body = wav,
        Key = key,
        Metadata = {
        'duration': str(audio_length)
        }
    )    
```

作成されたS3上のファイルのメタデータ

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1126770/e6f628d7-a1e1-452e-80a7-ed44fcf7d9a7.png)

broadcastでメタデータに含まれる音声ファイルの長さを取得する処理
```Python
def get_audio_duration(bucket_name, object_key):
    """Metaデータに含まれる音声ファイルの長さを取得"""
    response = s3.head_object(Bucket=bucket_name, Key=object_key)
    metadata = response.get('Metadata', {})
    return metadata.get('duration', '30000') # デフォルト値はメッセージが十分入る30秒とする
```


## ECRのリポジトリのライフサイクルルールの設定
make-voiceの処理はVOICEVOXを使った処理のため、[VOICEVOXをインストールしたコンテナイメージを作成し、そのイメージをLambdaで実行する実装](https://qiita.com/gengen0719/items/9307253e41f4fc2dc27c#%E3%82%B3%E3%83%B3%E3%83%86%E3%83%8A%E3%82%92%E5%88%A9%E7%94%A8%E3%81%97%E3%81%9Flambda)になっています。
Lambda関数で実行されるpyファイルもイメージの中に含まれるため、コードを変更した際にはコンテナイメージを更新する必要があります。

このアプリでは常に最新のイメージだけ残っていれば良く、ECRのPrivate registryはリポジトリに登録しているイメージの容量で課金されます。そのためECRのリポジトリにもライフサイクルルールを設定し、古いイメージが自動的に消されるようにしようと考えました。その際以下のように設定しましたが、うまく動いてくれませんでした。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1126770/a7385e48-ea3f-4ed2-95b2-9dc989477834.png)

調べてみると「イメージのステータス」の「すべて」というものにはタグなしのものは含まれないようでした。
雑な私はタグを付けずにイメージをプッシュしており、最新バージョンのイメージはlatest、それ以外のバージョンのイメージはタグなしという状態になっていたため、どのイメージも消えてくれないようでした。

そのため以下のようなルールを追加しました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1126770/d90d1443-2293-4e58-a6ed-2ec74e98f366.png)

しかしこれでもタグありのものから１つ、タグなしのものから１つを残すという設定のため、このように最新バージョンと一つ前のバージョンが残ることになるようでした。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1126770/40bc02bf-186b-4daa-bb01-a11b56daf0fb.png)

きちんとバージョンをつけてイメージを管理する方が良さそうだということを学びました。

# まだ残っている迷いポイント（結構ある）
- Lambdaをどこで分離するか？
registerとbroadcastは明確に分離しておいて良さそうかなと思ったが、make-textとmake-voiceは迷う。今の実装であればそれほど依存は強くないが、文章の中で一部ボイスを変えるとか、凝ったことをしだすと一緒にした方が良いかなとも思う。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1126770/a51c3efc-de11-49f4-a32a-01079705de92.png)

- Lambdaどうしの繋げ方
make-voiceからbroadcastへの繋がりはS3のAWS管理イベントを介して行っているが、EventBridgeのカスタムイベントやStep Functionsを使う方が美しい？
ただコスト的にはAWS管理イベントで済むものはその方が良いのかなとも思う。
https://aws.amazon.com/jp/eventbridge/pricing/
https://aws.amazon.com/jp/step-functions/pricing/

- Lambdaのテストがしづらい
今回は配信対象が自分と家族ぐらいなのでそれほど困ったわけではなく、考えるのを後回しにしてしまっていたが、処理のほとんどがAWSのリソースを触るのでローカルでのテストがしづらい。
AWS環境は使いつつ本番と別に開発用にデプロイしてそこで動かしながら開発するものなのだろうか？
MockにしてUnitTestを書くとかは違う気がしている。

- ソースコードの管理はどうするのが良いか？
GitHubには何をアップすれば良いか？とりあえずLambdaのコードとコンテナの箇所はDockerファイルを置いてみたがCloudFormationのファイルとかも置くものなのかもしれない。

とまだ迷いポイントがあるので、しっくりくる答えが見つかったらまた書こうと思います。

つづく。
かもしれないし次のものを作っていたら別のタイトルの記事にするかもしれないです。