【AWS】Amazon S3 Object Lambdaを検証！


## はじめに

この記事では「この前リリースされた機能って実際に動かすとどんな感じなんだろう」とか「もしかしたら内容次第では使えるかも？？」などAWSサービスの中でも特定の機能にフォーカスして検証していく記事です。主な内容としては実践したときのメモを中心に書きます。（忘れやすいことなど）
誤りなどがあれば書き直していく予定です。

今回はAmazon S3（以下、S3）が機能として提供しているAmazon S3 Object Lambdaを検証してみます。

参考：[Amazon S3 Object Lambda を使用して、取得時に画像に動的にウォーターマークを付ける](https://aws.amazon.com/jp/getting-started/hands-on/amazon-s3-object-lambda-to-dynamically-watermark-images/)

## Amazon S3 Object Lambdaとは

簡単に説明するとデータを取り出す時に取り出したデータを加工できる機能です。

> S3 Object Lambda を使用すると、S3 GET、HEAD および LIST リクエストに独自のコードを追加して、データがアプリケーションに返されるときにそのデータを変更および処理できます。

> カスタムコードを使用して、S3 GET リクエストによって返されるデータを変更し、行のフィルタリング、画像の動的なサイズ変更、機密データの編集などを行うことができるようになりました。

> また、S3 Object Lambda を使用して、バケット内のオブジェクトのカスタムビューを作成する S3 LIST リクエストの出力や、オブジェクト名やサイズなどのオブジェクトメタデータを変更する S3 HEAD リクエストの出力を変更することができます。

> AWS Lambda 関数により、コードは AWS によって完全に管理されているインフラストラクチャで実行されるため、データの派生コピーを作成して保存したり、高価なプロキシを実行したりする必要はなく、アプリケーションに変更を加える必要もありません。

[参考](https://aws.amazon.com/jp/s3/features/object-lambda/)

これができると何が嬉しいかというと上記の説明にあるとおりデータを取り出す時に`画像の動的なサイズ変更、機密データの編集`ができるので複数種類のデータを保持することなく、単一のデータで複数の表現できるようになります。

たとえば、画像の動的なサイズ変更というとレスポンシブデザインの要件に応える時に必要になったりします。

## 実際に使ってみよう

下記の[手順](https://aws.amazon.com/jp/getting-started/hands-on/amazon-s3-object-lambda-to-dynamically-watermark-images/)を参考に進めていきます。

### Amazon S3 バケットを作成する

[S3のマネジメントコンソール](https://s3.console.aws.amazon.com/s3/home?region=ap-northeast-1#)を開いてバケットを作成します。

バケット名は`object-s3-lambda-アカウントID`とします。

### 画像をアップロード

手順にあるAWSのロゴをダウンロードします。

作成したバケットにダウンロードしたAWSのロゴ画像をアップロードします。Uploadをクリックします。

![Screenshot 2023-11-10 at 22.09.20.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/527543/58ccf687-1b2b-4c9f-0bca-559a880414f3.png)

画像をアップロードしたら`Upload`をクリックします。
![Screenshot 2023-11-10 at 22.12.21.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/527543/3da95dfe-4e73-ba70-0bed-9b847b379de0.png)

### S3 アクセスポイントを作成する

左メニューにある`Access Points`をクリックします。

![Screenshot 2023-11-10 at 22.14.05.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/527543/4f9a3f9f-e103-ad4b-4d18-3989efc97a6d.png)

`Create Access point`をクリックします。

![Screenshot 2023-11-10 at 22.15.28.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/527543/47c5065a-8d51-243a-00a7-4f49d66914ec.png)

アクセスポイント名は`s3-obeject-lambda`
Bucket nameは先ほど作成したバケット名を選択
Network originは`インターネット`を選択します。

Create Access Pointを実行します。

### Lambda 関数を作成する

画像を変更するLambda関数を作成します。このLambdaはS3へのGETリクエストを実行した際に呼び出されます。

構築のためにCloudShellを呼び出します。
ターミナルボタンをクリックします。

![Screenshot 2023-11-10 at 22.26.05.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/527543/7344bdb3-2d44-d86c-5011-b9b38ee82ba5.png)

[参考手順](https://aws.amazon.com/jp/getting-started/hands-on/amazon-s3-object-lambda-to-dynamically-watermark-images/)の4.2にある`4.2 – Lambda 関数をデプロイするように CloudShell を準備する`からコードをコピーします。

以下に本記事執筆時点のコードを記載

```bash
# Install the required libraries to build new python
sudo yum install gcc openssl-devel bzip2-devel libffi-devel -y
# Install Pyenv
curl https://pyenv.run | bash
echo 'export PYENV_ROOT="$HOME/.pyenv"' >> ~/.bash_profile
echo 'export PATH="$PYENV_ROOT/bin:$PATH"' >> ~/.bash_profile
echo 'eval "$(pyenv init -)"' >> ~/.bash_profile
source ~/.bash_profile

# Install Python version 3.9
pyenv install 3.9.13
pyenv global 3.9.13

# Build the pillow Lambda layer
mkdir python
cd python
pip install pillow -t .
cd ..
zip -r9 pillow.zip python/
aws lambda publish-layer-version \
    --layer-name Pillow \
    --description "Python Image Library" \
    --license-info "HPND" \
    --zip-file fileb://pillow.zip \
    --compatible-runtimes python3.9
```

CloudShellに貼り付けて実行します。手順には10分〜15分かかるとあるのでしばらく待ちましょう。

終わりましたら下記のコマンドを実行します。

```bash
wget https://m.media-amazon.com/images/G/01/mobile-apps/dex/alexa/branding/Amazon_Typefaces_Complete_Font_Set_Mar2020.zip
```

ウォーターマークが付いたテキストを画像に書き込むのに使用する TrueType フォントを抽出するため、下記のコマンドを実行します。unzipコマンドなので解凍作業です。

```bash
unzip -oj Amazon_Typefaces_Complete_Font_Set_Mar2020.zip "Amazon_Typefaces_Complete_Font_Set_Mar2020/Ember/AmazonEmber_Rg.ttf"
```

### Lambdaのコードを作成

下記のcatを実行して`lambda.py`を作成します。
なお、公式サイトに掲載されているコードは動作しなかったため、以下の修正したコードを利用しています。

```bash
cat << EOF > lambda.py
import boto3
import json
import os
import logging
from io import BytesIO
from PIL import Image, ImageDraw, ImageFont
from urllib import request
from urllib.parse import urlparse, parse_qs, unquote
from urllib.error import HTTPError
from typing import Optional

logger = logging.getLogger('S3-img-processing')
logger.addHandler(logging.StreamHandler())
logger.setLevel(getattr(logging, os.getenv('LOG_LEVEL', 'INFO')))
FILE_EXT = {
    'JPEG': ['.jpg', '.jpeg'],
    'PNG': ['.png'],
    'TIFF': ['.tif']
}
OPACITY = 64  # 0 = transparent and 255 = full solid


def get_img_encoding(file_ext: str) -> Optional[str]:
    result = None
    for key, value in FILE_EXT.items():
        if file_ext in value:
            result = key
            break
    return result


def add_watermark(img: Image, text: str) -> Image:
    font = ImageFont.truetype("AmazonEmber_Rg.ttf", 82)
    txt = Image.new('RGBA', img.size, (255, 255, 255, 0))
    if img.mode != 'RGBA':
        image = img.convert('RGBA')
    else:
        image = img

    d = ImageDraw.Draw(txt)
    # Positioning Text
    width, height = image.size
    text_width = d.textlength(text, font)
    text_height = d.textlength(text, font)
    
    x = width / 2 - text_width / 2
    y = height / 2 - text_height / 2
    # Applying Text
    d.text((x, y), text, fill=(255, 255, 255, OPACITY), font=font)
    # Combining Original Image with Text and Saving
    watermarked = Image.alpha_composite(image, txt)
    return watermarked


def handler(event, context) -> dict:
    logger.debug(json.dumps(event))
    object_context = event["getObjectContext"]
    # Get the presigned URL to fetch the requested original object
    # from S3
    s3_url = object_context["inputS3Url"]
    # Extract the route and request token from the input context
    request_route = object_context["outputRoute"]
    request_token = object_context["outputToken"]
    parsed_url = urlparse(event['userRequest']['url'])
    object_key = parsed_url.path
    logger.info(f'Object to retrieve: {object_key}')
    parsed_qs = parse_qs(parsed_url.query)
    for k, v in parsed_qs.items():
        parsed_qs[k][0] = unquote(v[0])

    filename = os.path.splitext(os.path.basename(object_key))
    # Get the original S3 object using the presigned URL
    req = request.Request(s3_url)
    try:
        response = request.urlopen(req)
    except HTTPError as e:
        logger.info(f'Error downloading the object. Error code: {e.code}')
        logger.exception(e.read())
        return {'status_code': e.code}

    if encoding := get_img_encoding(filename[1].lower()):
        logger.info(f'Compatible Image format found! Processing image: {"".join(filename)}')
        img = Image.open(response)
        logger.debug(f'Image format: {img.format}')
        logger.debug(f'Image mode: {img.mode}')
        logger.debug(f'Image Width: {img.width}')
        logger.debug(f'Image Height: {img.height}')

        img_result = add_watermark(img, parsed_qs.get('X-Amz-watermark', ['Watermark'])[0])
        img_bytes = BytesIO()

        if img.mode != 'RGBA':
            # Watermark added an Alpha channel that is not compatible with JPEG. We need to convert to RGB to save
            img_result = img_result.convert('RGB')
            img_result.save(img_bytes, format='JPEG')
        else:
            # Will use the original image format (PNG, GIF, TIFF, etc.)
            img_result.save(img_bytes, encoding)
        img_bytes.seek(0)
        transformed_object = img_bytes.read()

    else:
        logger.info(f'File format not compatible. Bypass file: {"".join(filename)}')
        transformed_object = response.read()

    # Write object back to S3 Object Lambda
    s3 = boto3.client('s3')
    # The WriteGetObjectResponse API sends the transformed data
    if os.getenv('AWS_EXECUTION_ENV'):
        s3.write_get_object_response(
            Body=transformed_object,
            RequestRoute=request_route,
            RequestToken=request_token)
    else:
        # Running in a local environment. Saving the file locally
        with open(f'myImage{filename[1]}', 'wb') as f:
            logger.debug(f'Writing file: myImage{filename[1]} to the local filesystem')
            f.write(transformed_object)

    # Exit the Lambda function: return the status code
    return {'status_code': 200}
EOF

```

コードを圧縮します。

```bash
zip -r9 lambda.zip lambda.py AmazonEmber_Rg.ttf
```

IAMロールを作成します。

```bash
aws iam create-role --role-name ol-lambda-images --assume-role-policy-document '{"Version": "2012-10-17","Statement": [{"Effect": "Allow", "Principal": {"Service": "lambda.amazonaws.com"}, "Action": "sts:AssumeRole"}]}'
```

IAMポリシーをロールにアタッチします。

```bash
aws iam attach-role-policy --role-name ol-lambda-images --policy-arn arn:aws:iam::aws:policy/service-role/AmazonS3ObjectLambdaExecutionRolePolicy

export OL_LAMBDA_ROLE=$(aws iam get-role --role-name ol-lambda-images | jq -r .Role.Arn)

export LAMBDA_LAYER=$(aws lambda list-layers --query 'Layers[?contains(LayerName, `Pillow`) == `true`].LatestMatchingVersion.LayerVersionArn' | jq -r .[])
```

Lambda関数を作成してアップロードします。

```bash
aws lambda create-function --function-name ol_image_processing \
 --zip-file fileb://lambda.zip --handler lambda.handler --runtime python3.9 \
 --role $OL_LAMBDA_ROLE \
 --layers $LAMBDA_LAYER \
 --memory-size 1024

```

### S3 Object Lambda アクセスポイントを作成する

ここでようやく今回使う機能が登場！

S3のメニューからObject Lambda Access Pointsをクリックします。

![Screenshot 2023-11-10 at 22.44.34.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/527543/43171abd-0d4d-d9bd-5cad-c28ed5279cae.png)

`Create Object Lambda Access Point`をクリックします。

### Create Object Lambda Access Pointの設定

Object Lambda Access Point nameを`ol-amazon-s3-images-guide`とします。
リージョンは画像をアップロードしたS3バケットと同じリージョンを指定します。
`Browse S3`（S3を参照）をクリックし、先ほど作成したアクセスポイントを指定します。

変化設定で`GET Object`にチェックを入れます。

![Screenshot 2023-11-10 at 22.50.40.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/527543/378c28d7-9b8b-72c0-dc60-b52da40cadf4.png)

Lambda関数の選択欄では`ol_image_processing`を選択します。

![Screenshot 2023-11-10 at 22.51.51.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/527543/735102d3-b7d5-4123-e47c-c08d88ffbf94.png)

最後に`Create Object Lambda Access Point`をクリックします。
これで環境構築は以上です。

## （テスト）画像をダウンロードする

それでは早速、画像を開いてみましょう。

画像にチェックを入れて、`Open`をクリックします。

![Screenshot 2023-11-10 at 23.30.15.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/527543/8e6879b9-3027-baef-9448-e7565ec35eb2.png)

※以下、加工された画像

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/527543/b982c8a3-af35-bfec-d5a6-3b1b9a4e8489.png)

うっすらですが、`Watermark`という文字がAWSのロゴマークに写っているのがわかるでしょうか。
ちなみに元の画像は以下

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/527543/e9acf03c-8edc-3a61-c297-d3d10eab1072.png)

## 片付け

参考手順の片付け手順に従って片付けを行いましょう。

## まとめ

今回はS3のダウンロードをトリガーにして画像を自動で加工し、加工した画像をダウンロードできるように環境を構築しました。
S3からファイルをダウンロードするとLambdaが実行されます。実行の様子はCloudWatch Logsのロググループから確認できます。

※ログを抜粋

```text
Object to retrieve: /2.3%20aws%20logo.f00a88b928cdc48ba417e90c2c1eab9d961899d1.png
[INFO]	2023-11-10T14:29:37.232Z	41e2f357-2ec3-4faa-a9cc-3297cf1ce9c3	Object to retrieve: /2.3%20aws%20logo.f00a88b928cdc48ba417e90c2c1eab9d961899d1.png
[INFO]	2023-11-10T14:29:37.309Z	41e2f357-2ec3-4faa-a9cc-3297cf1ce9c3	Compatible Image format found! Processing image: 2.3%20aws%20logo.f00a88b928cdc48ba417e90c2c1eab9d961899d1.png
Compatible Image format found! Processing image: 2.3%20aws%20logo.f00a88b928cdc48ba417e90c2c1eab9d961899d1.png
END RequestId: 41e2f357-2ec3-4faa-a9cc-3297cf1ce9c3
```

作成された画像は商品のサンプルの画像にも使えるような見た目で少しだけ面白いと感じました。
どんな文字列でも挿入できるので使い方次第ではいろんなことができそうです。

## 今回の注意点

記事を執筆時点で掲載されているLambdaのソースコードは動作しなかったため、うまくいかなかった箇所を特定して修正しました。

動作しなかった箇所について説明します。

コード内にある`ImageDraw`のtextsizeメソッドは`no attribute`になってしまうのでtextlengthに変更します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/527543/18988ad1-6e5e-b3a8-a66e-fb7a1e6a47db.png)


変更前

```python
text_width, text_height = d.textsize(text, font)
```

変更後

```python
text_width = d.textlength(text, font)
text_height = d.textlength(text, font)
```

## 最近のアップデート

今回はご紹介した機能はAmazon Athenaと統合されているようなので分析にも一役買いそうです。

[Amazon S3 Object Lambda と Amazon Athena が統合](https://aws.amazon.com/jp/about-aws/whats-new/2023/10/amazon-s3-object-lambda-integrates-amazon-athena/)

## おわり