【AWS】Lambda＋SNSを使ってエロ画像をアップロードする不届き者を密告する機能を実装してみる【後半】

叡智に富んだ読者の皆様、ご機嫌よう。

ダイエットのために自炊生活を始めたものの、ついつい食べ過ぎてしまい、気づけばプチ増量してしまったすぎちゃんです :pig: 

前回に引き続き、S3の[イベント通知機能](https://docs.aws.amazon.com/ja_jp/AmazonS3/latest/userguide/EventNotifications.html)を活用した「密告機能」について解説していきます。

今回は、上級者の皆様（？）向けに、「Lambdaを一旦経由し、SNSからメッセージを送る」といった、少し複雑な機能を実装していきます。

## 📕前回までのあらすじ

https://qiita.com/sugichaaaan/items/5b3a4c275e2f85e966ac

前回はS3のイベント通知機能を活用し、「アップロードされた画像に問題があれば即座に密告できるシステム」の構築を試みました。しかし、実装を進めるうちに思わぬ落とし穴にハマってしまうのでした:cry: 

**直面した問題**

1.  S3 → SNS に直接通知を飛ばすと、情報が貧弱すぎる！
→ どのファイルが問題なのか、誰がアップロードしたのか分からない…:sweat:

2. コンソールではS3オブジェクトのタグやメタデータが見えるのに、SNSの通知には含まれない！
→ 期待していた「不届き者の情報を即座にキャッチする機能」がまるで機能しない…

「このままではメールを読む私が発狂する…！」と感じた私は、
Lambdaを経由して通知内容をカスタマイズする作戦に打って出ることを決意しました！:muscle: 


## S3イベント→Lambda→SNSでメールの出しわけをしてみる

![lambda_sns_architect.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/401561/633b4bce-04ff-4600-a6cd-9948e94b534b.png)


次はLambdaを経由して読みやすいメッセージに整形した上でSNSからメッセージを送ってみましょう。

手順としては以下の通りになります。

1. Lambda実行ロールの作成
2. Lambda関数の設定
3. Lambda関数のロジック作成

### 1. Lambda実行ロールの作成

#### 1-1. IAMポリシーの作成

Lambda関数の中身を作成する前に**実行ロール**の設定を行う必要がございます。
今回はAWS管理ポリシーを使用せず、**最小権限の原則**に従い必要なポリシーのみを付与していきます。

https://docs.aws.amazon.com/ja_jp/wellarchitected/latest/security-pillar/sec_permissions_least_privileges.html

[ポリシー作成画面](https://us-east-1.console.aws.amazon.com/iam/home?region=us-east-1#/policies/create)に遷移します。

ポリシーエディタの**JSON**をタップすると以下のような画面が表示されます。

![スクリーンショット 2025-02-26 0.40.00.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/401561/c7dbb38e-c067-4b95-aa39-6406d773609a.png)

以下のJSONをコピペして、ポリシーエディタにペーストしましょう。

```json
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": [
                "s3:GetObject",
                "s3:PutObjectTagging"
            ],
            "Resource": [
                "arn:aws:s3:::pictures-local/*"
            ]
        },
        {
            "Effect": "Allow",
            "Action": "sns:Publish",
            "Resource": "arn:aws:sns:us-east-1:<アカウントID>:inform_erotic_art_uploaded"
        }
    ]
}
```


![IAM_policy_setting.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/401561/4ea10d8d-45bf-4fd2-9374-d932ca7bff59.png)

:::note info
**ポリシーの説明**
1. `s3:getObject`
    Resourceで指定されたS3バケット内にあるオブジェクトの情報を取得できるようにする
    
2. `s3:PutObjectTagging`
    指定したオブジェクトに任意のタグを付与できるようにする
2.  `sns:Publish` 
    Resourceで指定されたSNSトピックに向けてメッセージを発信できるようにする
:::

ポリシーの設定が終われば次のステップに進みます。

![IAM_policy_setting_2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/401561/1bd3bc7b-1972-4920-b483-7e7c935a544d.png)



ポリシー名には`EroticArtUploadNotifier`を設定しましょう。
ページ最下部にある「ポリシーの作成」をタップすると新しく**カスタマー管理のロール**が作成されます。

![スクリーンショット 2025-02-26 0.05.12.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/401561/1a185c31-854b-4098-9e22-0daffcf2f5f0.png)

#### 1-2. IAMロールの作成

次に実行ロールの本体を作成していきます。

![IAM_role_setting_step1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/401561/ea6b777d-dade-4494-861b-f1518037fe8f.png)

ステップ1では**信頼されたエンティティタイプ**、**ユースケース**の設定を行います。
それぞれ以下のように設定しましょう。

信頼されたエンティティタイプ：**AWSのサービス**
ユースケース：**Lambda**

上記のように設定が済んだら「次へ」をタップし、ステップ2に遷移します。

![IAM_role_setting_step2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/401561/691a6bc7-2b89-49b1-be39-b49d7779b30d.png)

ステップ2ではロールに紐付ける**許可ポリシー**を選択します。

許可ポリシーの下に[**許可の境界**](https://docs.aws.amazon.com/ja_jp/IAM/latest/UserGuide/access_policies_boundaries.html)を設定も表示されています、他のユーザーに委任する予定がないので今回は**許可の境界なしでロールを作成**のままで設定します。

「次へ」をタップし、ステップ3に遷移します。

![IAM_role_setting_step3.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/401561/501a93ad-3abc-4925-a051-b70518b78862.png)

ステップ3では**ロール名**および**説明**を設定します。

ロール名の入力欄に`EroticArtUploadNotificationLambdaRole`と入力します。
説明は任意項目なので特になくても大丈夫です :thumbsup: 

ロールの作成に成功すると、ロール一覧に新しく表示されていることが確認できます！

![IAM_role_setting_completed.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/401561/2c41f26f-be88-460a-b518-d3e5160716fb.png)


### 2. Lambda関数の作成

いよいよお待ちかねのLambda関数の作成です！
SNSでメッセージを送る前にLambdaを経由させるメリットは以下の通りです。

:::note info
- 読みやすいように整形したメッセージをSNSに伝達できる
- SNSを直接紐づけるよりも、はるかに柔軟な処理が実現できる
:::

#### 2-1. 基本的な情報の設定
[関数の作成画面](https://us-east-1.console.aws.amazon.com/lambda/home?region=us-east-1#/create/function?blueprint=s3-get-object-python&intent=blueprints)に遷移してください。

![Lambda_setting_step1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/401561/5c330760-4f33-4148-bfe0-1b55feb0a380.png)

一番上に３つの選択肢が表示されていると思いますが、
今回は**設計図の使用**を選択しましょう！ :smile: 

そうすると以下のコードが最初に設定されるので、Lambda関数の作成がかなり楽になります！

![スクリーンショット 2025-02-26 0.47.20.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/401561/0cf2cb5e-f148-4d8f-89e2-949a3333f8dc.png)


次に設計図名をドロップダウンメニューから選択します。
S3オブジェクトの情報を取得しSNSに伝達したいので、
**Get S3 object**を選択します。

実行ロールは「**既存のロールを使用する**」を選択肢、
既存のロールには`EroticArtUploadNotificationLambdaRole`を設定します。

#### 2-2. S3トリガーの設定
最後にS3トリガーの設定に移ります。
下にスクロールすると以下の画面が表示されます。

![S3_trigger_setting.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/401561/ac09b936-32bf-4266-9001-0a49b2af3970.png)

イベントタイプは以下のようにドロップダウンメニューで表示されます。
![スクリーンショット 2025-03-02 15.15.10.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/401561/62fd0959-68af-4329-9a23-5b4706d1ba42.png)

デフォルトで設定されている「全てのオブジェクト作成イベント」は❌ボタンで解除し、「**PUT**」のみを選択しましょう。

上記が全て完了したら画面最下部の「関数の作成」ボタンをタップしてください！
以下の画面が表示されるとLambda関数の作成に成功しています:smile_cat: 


![スクリーンショット 2025-03-02 16.25.01.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/401561/70a0e6ec-9c28-4e45-b7c0-5b9a48e5f9a9.png)


### 3. Lambda Layerの設定


Lambda関数からAPIリクエストを送信するにあたりrequestsライブラリが必要になります。

「`import requests`で呼び出せばいいじゃん？」と思う方もおられますが、残念ながらAWSではrequestsデフォルトでサポートしていないため、**Lambda Layer**で新しく設定する必要があります。

https://docs.aws.amazon.com/ja_jp/lambda/latest/dg/python-layers.html

![スクリーンショット 2025-03-02 16.27.37.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/401561/5e8a23f5-b16f-4012-8196-4d1f1e7e3923.png)


Lambda Layerの設定手順は以下の通りです。

1. ローカルでrequestsをダウンロードする
2. ダウンロードしたファイルをzip形式で圧縮する
3. zipファイルをアップロード
4. LambdaレイヤーのLambda関数への紐付け

:::note warn
予めローカル環境のPythonランタイムをAWSのランタイムと合わせておきましょう。
でないと上記の手順を完璧にこなしてもエラーが発生します。
[こちらのREADME](https://github.com/pyenv/pyenv)が一番信憑性高いです。
:::


#### 3-1. ローカルでrequestsをダウンロードする

まずコマンドプロンプトで、requestsライブラリをローカルにダウンロードしてください。

```
pip install requests -t python/
```

#### 3-2. ダウンロードしたファイルをzip形式で圧縮する

次に1でダウンロードした複数のファイルをzipで圧縮します。

```
zip -r layer.zip python/
```

#### 3-3. zipファイルのアップロード

[Lambdaレイヤー作成画面](https://us-east-1.console.aws.amazon.com/lambda/home?region=us-east-1#/create/layer)に遷移します。

![create_lambda_layer.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/401561/a3077001-ec21-48d1-b97b-e23afa3272cf.png)

名前は`request_layer`と設定し、ラジオボタンでは**zipファイルをアップロード**を選択してください。「ファイルを選択」をタップし2で作成した.zipファイルを選択すると、AWSでrequestライブラリを使えるようになります :smile: 

画面最下部の「作成」ボタンをタップすると、Layerの作成に成功すると以下のような詳細画面に遷移します。
![スクリーンショット 2025-03-02 16.05.19.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/401561/1c2e56c5-e988-4e08-a41c-53bdae72b319.png)

真ん中に表示されている**バージョンARN**はLambda関数との紐付けに使用するためコピーしておきましょう！
```
arn:aws:lambda:us-east-1:<アカウントID>:layer:requests_layer:<バージョン番号>
```

#### 3-4. LambdaレイヤーのLambda関数への紐付け

最後にLambdaレイヤーをLambda関数に紐づける手順について説明します。

Lambda関数の詳細画面に表示されている「コード」タブを選択し、
画面最下部までスクロールすると「レイヤー」のブロックが表示されます。

![スクリーンショット 2025-03-02 16.12.35.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/401561/7bf5a3cb-bd70-4604-9082-243840139b52.png)

ブロック右上に位置する「**レイヤーの追加**」ボタンをタップすると、以下のような画面が表示されます。

![スクリーンショット 2025-03-02 16.20.07.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/401561/a83191ae-a991-4ef5-8652-7597da9a0059.png)

右下の「追加」ボタンをタップするとLambdaの詳細画面に遷移します。
成功していれば赤枠で表示されたLayerの数が0から1に増えていることがわかります。

![スクリーンショット 2025-03-02 15.25.48.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/401561/b904558c-366e-4907-b50e-0c2fd3bf24c8.png)


### 4. Lambda関数のロジック作成

#### 4-1. エロ画像検出APIの起動

ロジックを作成する前に↓の記事でエロ画像判別APIを起動しておいてださい。

https://qiita.com/sugichaaaan/items/738867e4bc3a1a0077d9

慣れている人だと15分、初心者だと1時間ほどかかる見込みです。

#### 4-2. Lambda関数の中身を書いていく

```python
import json
import urllib.parse
import requests
import boto3

s3 = boto3.client('s3')
client = boto3.client('sns')


def lambda_handler(event, context):

    # ① eventデータよりS3バケット名、S3オブジェクトキーを取得する
    bucket = event['Records'][0]['s3']['bucket']['name']
    key = urllib.parse.unquote_plus(event['Records'][0]['s3']['object']['key'], encoding='utf-8')
    
    try:
        # ② S3オブジェクトの詳細情報を取得する
        response = s3.get_object(Bucket=bucket, Key=key) 

        # ③ オブジェクトURLを入力値とし、不適切度をNSFW_APIで算出する
        object_url = "https://"+ bucket +".s3.us-east-1.amazonaws.com/" + key
        nsfw_output = requests.get("http://<ECSタスクのパブリックIP>:5000/?url=" + object_url)

        if('score' in nsfw_output.json()): # エロ画像検出APIが正常に動作した時
            nsfw_score = nsfw_output.json()['score']

            # ④ 不適切度に基づくメッセージの出しわけ
            if(nsfw_score >= 0.8) :
                # ④-a: エロ画像判定喰らった時
                # S3オブジェクトにタグを付与する
                s3.put_object_tagging(
                    Bucket = bucket, 
                    Key = key,
                    Tagging = {
                    'TagSet': [
                        {
                            'Key': 'is_erotic',
                            'Value': '1'
                        }
                    ]
                })

                # 不適切画像をアップロードした人の情報を通知する
                params = {
                    'TopicArn': 'arn:aws:sns:us-east-1:<アカウントID>:inform_erotic_art_uploaded',
                    'Subject': '不適切画像がアップロードされました',
                    'Message': 'どこぞの不届きものがエロ画像をアップロードしたようです\n\n'+
                        '不届きも者のID: ' + response['Metadata']['user_id'] + '\n\n' +
                        '不届き者の名前: ' + response['Metadata']['user_name'] + '\n\n' + 
                        '画像のURL: ' + object_url + '\n\n' + 
                        '不適切度: ' + str(nsfw_score)
                }

            else: 
                # ④-b: 健全だと判断された場合
                params = {
                    'TopicArn': 'arn:aws:sns:us-east-1:<アカウントID>:inform_erotic_art_uploaded',
                    'Subject': '健全な画像がアップロードされました',
                    'Message': '画像が正常にアップロードされました\n\n'+
                        '画像のURL: ' + object_url
                }

            # ⑤SNSでメッセージを送信する
            client.publish(**params)

            return {
                'statusCode': 200,
                'body': json.dumps('Message has been successfully sent! ')
            }


        else: # エロ画像検出APIで何かしくじった時
            return {
                'statusCode': 500,
                'body': json.dumps('Oops!Something went wrong!')
            }
    except Exception as e:
        print(e)
        print('Error getting object {} from bucket {}. Make sure they exist and your bucket is in the same region as this function.'.format(key, bucket))
        raise e
```

お急ぎの方は上記のコードを説明を読み飛ばしてコピペするだけでも構いません:rabbit: 

上記のコードをざっくりと説明すると次の通りになります。

①イベントデータより、アップロード先のS3バケット名、アップロードされたオブジェクト名を取得する
②`s3.get_object`を用いてS3オブジェクトの詳細情報を取得する
③  オブジェクトURLを入力値とし、不適切度を[エロ画像検出API](https://qiita.com/sugichaaaan/items/738867e4bc3a1a0077d9)で算出する
④ 不適切度に基づき処理の出しわけを行う(※1)

(※1: 処理の出しわけ)
> - **不適切度が0.8以上の場合：**
    S3オブジェクトに`is_erotic`タグを付与する
    さらに**アップロードした本人のIDとユーザー名**、画像の不適切度をメールで通知する。
> - **不適切度が0.8未満の場合：**
    健全な画像がアップロードされた旨を通知する。

⑤EOF

それではちゃんと機能するか動作確認に移りましょう! :red_car: 


## 単体テスト（結合？）

最後に作成したLambda関数が正常に動作するかの確認です。
S3バケットには健全な画像、明らかなエロ画像をそれぞれ一枚以上アップロードしておきましょう :smiley_cat: 

イベントJSONは以下のように設定してください。

```event.json
{
  "Records": [
    {
      "eventVersion": "2.0",
      "eventSource": "aws:s3",
      "awsRegion": "us-east-1",
      "eventTime": "1970-01-01T00:00:00.000Z",
      "eventName": "ObjectCreated:Put",
      "userIdentity": {
        "principalId": "EXAMPLE"
      },
      "requestParameters": {
        "sourceIPAddress": "XX.XX.XX.XX" 
      },
      "responseElements": {
        "x-amz-request-id": "EXAMPLE123456789",
        "x-amz-id-2": "EXAMPLE123/5678abcdefghijklambdaisawesome/mnopqrstuvwxyzABCDEFGH"
      },
      "s3": {
        "s3SchemaVersion": "1.0",
        "configurationId": "testConfigRule",
        "bucket": {
          "name": "pictures-local",
          "ownerIdentity": {
            "principalId": "EXAMPLE"
          },
          "arn": "arn:aws:s3:::pictures-local"
        },
        "object": {
          "key": "boob_job.jpg",
          "size": 1024,
          "eTag": "0123456789abcdef0123456789abcdef",
          "sequencer": "0A1B2C3D4E5F678901"
        }
      }
    }
  ]
}

```

上記は、S3イベントが発火した際実際にLambdaに伝達されるデータのmockです。
実際のデータと形式を揃えることでLambdaの単体テストを行うことができます。

テストの時は`object`の中にある`key`を随時変更するようにしてください!


### :smile: 健全な画像をアップロードした時
![スクリーンショット 2025-03-02 17.21.23.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/401561/a0c7bc2d-3699-4911-bd38-d6f66846eef1.png)

### :smiling_imp: 不健全な画像をアップロードした時
![スクリーンショット 2025-03-02 17.21.59.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/401561/59b99dce-532f-47f0-a00c-ad693b1aedbf.png)




不適切だと判定されたS3オブジェクトのメタデータを通知内容に含めることで、リアルタイムで不届き者を密告することができます :smiling_imp: 

:::note info
Lambdaを経由することで、人間にとって理解しやすい文面に直した上でメールを送ってもらえる。
:::

## Laravelアプリケーションに繋ぎ込んだ上での結果

### :smile: 健全な画像をアップロードした場合
（「健全な画像がアップロードされました」というお題のメールが届くことを確認）
[エビデンス](https://github.com/sugizakikun/docker-laravel/issues/34#issuecomment-2693524045)

### :smiling_imp:不健全な画像をアップロードした場合
（「不適切画像がアップロードされました」というお題のメールが届くことを確認）
[エビデンス](https://github.com/sugizakikun/docker-laravel/issues/34#issuecomment-2693544721)


## まとめ

今回は S3のイベント通知 → Lambda → SNS 連携 を活用し、アップロードされた画像の情報を カスタマイズしたメール通知 として送信する仕組みを構築しました！:muscle: 

Lambdaを経由することで、SNSの通知に オブジェクトのタグやメタデータを含める ことが可能になり、 誰が・どんな画像をアップロードしたのかを明確に通知できるようになりました:cat: 

## 参考記事

### Lambdaからsnsを呼び出す
https://zenn.dev/nakam_aws/articles/6562e07cfae4fd

### Lambdaからs3オブジェクトにタグを付与する
https://sebenkyo.com/2021/01/30/post-1878/