【Amazon S3 Object Lambda】AWS公式のハンズオンやってみた

はじめに

今回は、AWS公式が提供しているハンズオンのひとつをやっていこうと思います。

やっていくのは「Amazon S3 Object Lambda を使用して、取得時に画像に動的にウォーターマークを付ける」です。

やろうと思ったきっかけ

今回、AWSのハンズオンをやりたいと思ったきっかけとしては以下のようなものがあります。

  • AWSのサービスを実際に触ってみたかった
  • AWSのハンズオンはどんなものなのか知りたかった
  • AWSの資格勉強をしているのでその一環として
  • 単純に面白そうだと思った(これが一番大きい)

それでは、早速始めていきましょう!
AWSアカウントはある前提で進めていきます。

実際にやっていく

1. IAMユーザーを作成する

元ページでは省略されている工程ですが、せっかくなのでIAMユーザーの作成からやっていきます。

AWSコンソールにログインし、IAM > アクセス管理 > ユーザーのページからユーザー作成を押下します。

スクリーンショット 2024-12-02 12.55.03.png
image.png

ユーザー名は s3-object-lambda-to-dynamically-watermark-imagesとします。

スクリーンショット 2024-12-02 13.03.37.png

許可の設定に進み、「ポリシーの作成」を押下します。

スクリーンショット 2024-12-02 13.12.48.png

以下のポリシーを付与します。

{
	"Version": "2012-10-17",
	"Statement": [
		{
			"Sid": "AllowS3BasicOperations",
			"Effect": "Allow",
			"Action": [
				"s3:*",
				"s3-object-lambda:*",
				"lambda:*",
				"cloudshell:*",
				"iam:CreateRole",
				"iam:GetRole",
				"iam:PutRolePolicy",
				"iam:PassRole",
				"iam:DeleteRole",
		        "iam:AttachRolePolicy",
				"sts:AssumeRole"
			],
			"Resource": "*"
		}
	]
}
image.png

名前は s3-object-lambda-to-dynamically-watermark-imagesとします。

そして、先ほどのユーザーに作成したポリシーを付与します。(名前で検索かけると楽です)

image.png

確認して、ユーザーを作成します。

また、コンソールでのログインを有効化します。
ユーザー→s3-object-lambda-to-dynamically-watermark-images→セキュリティ認証情報→コンソールサインインで「コンソールアクセスを有効にする」を押下して、表示された「コンソールサインイン URL」、「ユーザー名」、「コンソールパスワード」をコピーして置いておきます。

スクリーンショット 2024-12-02 13.33.19.png
スクリーンショット 2024-12-02 13.33.47.png

以降は、このコンソールサインインURLからログインした、ユーザー(s3-object-lambda-to-dynamically-watermark-images)のコンソールで作業を行なっていきます。

image.png

2. Amazon S3 バケットを作成する

コンソールからS3を選択し、「バケットを作成」ボタンを押下します。

image.png

バケット名はs3-object-lambda-to-dynamically-watermark-images-202412とします。

他は変更せず、バケットを作成します。

3: オブジェクトをアップロードする

以下の画像をダウンロードします。(AWS公式が提供している画像です)

S3→s3-object-lambda-to-dynamically-watermark-images-202412から、アップロードボタンを押下しアップロードします。

image.png

オブジェクトの欄に画像があればこのステップでの作業は完了です!

image.png

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

S3→アクセスポイントから「アクセスポイントの作成」ボタンを押下します。

image.png

アクセスポイント名はaccess-point-s3とし、バケット名は「S3の参照」から先ほど作成したバケットを選択します。

また、ネットワークオリジンはインターネットを選択してください。
ほかは設定を変えず、作成ボタンを押します。

5. Lambda関数を作成する

このステップでは、S3へのGETリクエストがS3ObjectLambdaアクセスポイントを通じて行われたときに呼び出されるLambda関数を作成します。

すこし複雑ですが、頑張ってやっていきましょう!

5-1 CloudShellターミナルの起動

まず、CloudShellを起動します。
コンソールの一番上にあるペインの以下画像の右端にあるボタンを押すことで起動します。

image.png

以下のような画面になればOKです。

image.png

5-2 CloudShellの環境セットアップ

以下のコードをそのままコピペ&実行します。
警告がでますが、そのまま貼り付けを選択します。

# 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

5-3 Lambda関数を構築する

Lambda関数によって使用されるフォントをダウンロードします。

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

ダウンロードしたフォントのzipファイルから、使用するファイルを抽出します。

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

S3 Object Lambdaリクエストを処理するために使用されるPythonのコードが入ったファイルを作成します。
少し長いですが、同じようにコピペして実行してください。

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, text_height = d.textsize(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

次に、Pythonのコードとフォントファイルを含むlambda.zipファイルを作成します。

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

Lambda関数にアタッチするIAMロールを作成します。

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ポリシーを作成したIAM ロールにアタッチします。
1行ずつ実行し、エラーを吐いてないかはしっかり見ておくことを推奨します。

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関数を作成してアップロードします。

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

以下のような文言が表示されれば正常に作成が完了しています!

[cloudshell-user@ip-10-134-28-58 ~]$ 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
{
    "FunctionName": "ol_image_processing",
    "FunctionArn": "arn:aws:lambda:ap-northeast-1:*************:function:ol_image_processing",
    "Runtime": "python3.9",
    "Role": "arn:aws:iam::*************:role/ol-lambda-images",
    "Handler": "lambda.handler",
    "CodeSize": 52282,
    "Description": "",
    "Timeout": 3,
    "MemorySize": 1024,
    "LastModified": "2024-12-01T09:25:10.218+0000",
    "Version": "$LATEST",
    "TracingConfig": {
        "Mode": "PassThrough"
    },
    "RevisionId": "e90a13c0-1dbd-4c73-a1cb-f59b4d644b5c",
    "Layers": [],
    "State": "Pending",
    "StateReason": "The function is being created.",
    "StateReasonCode": "Creating",
    "PackageType": "Zip",
    "Architectures": [
        "x86_64"
    ],
    "EphemeralStorage": {
        "Size": 512
    },
    "SnapStart": {
        "ApplyOn": "None",
        "OptimizationStatus": "Off"
    },
    "RuntimeVersionConfig": {
        "RuntimeVersionArn": "arn:aws:lambda:ap-northeast-1::runtime:**************************"
    },
    "LoggingConfig": {
        "LogFormat": "Text",
        "LogGroup": "/aws/lambda/ol_image_processing"
    }
}

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

ここからはコンソールでの操作になります。

s3→Object Lambda アクセスポイントから、「Object Lambda アクセスポイントの作成」ボタンを押下します。

スクリーンショット 2024-12-02 14.34.39.png

アクセスポイント名はol-amazon-s3-images-guideとし、
サポートするアクセスポイントは「s3の参照」から先ほど作成したアクセスポイントを選択します。

また、S3 APIはGetObjectを選択しておきます。

そして、「Lambda 関数の呼び出し」から先ほど作成したLambda関数、ol_image_processingを選択します。

ほかは変更せず、作成ボタンを押下し戻ります。

7.S3 Object Lambdaアクセスポイントからウォーターマーク付きの画像を閲覧する

いよいよ最後のステップです。

s3 → Object Lambda アクセスポイントから先ほど作成したol-amazon-s3-images-guideを開きます。

スクリーンショット 2024-12-02 14.41.24.png

アップロードしていた画像をチェックし、「開く」を押すと…

image.png

ウォーターマーク付きの画像が表示されました!

しかし、実は1回目の実行ではエラーが出ていました。

スクリーンショット 2024-12-02 14.45.27.png

それが私だけなのか、必ず再現するかはわからないのですが、もしこのエラーが出る時は、以下の方法を試してみてください。

エラー解決: LambdaRuntimeError

Lambdaのエラーを見に行くと、以下のような記載が…

[ERROR] Runtime.ImportModuleError: Unable to import module 'lambda': No module named 'PIL' Traceback (most recent call last):

どうやら、コード内で使用しているPIL(pillow)ライブラリがうまく使えないようです。

解決策として、LambdaのLayers機能を使用します。

コンソールでLambda → 関数 → ol_image_processingと進み、以下の画面下部にある「レイヤーの追加」をクリックします。

スクリーンショット 2024-12-02 14.49.58.png
スクリーンショット 2024-12-02 14.51.21.png

「ARN を指定」を選択し、arn:aws:lambda:ap-northeast-1:770693421928:layer:Klayers-p39-pillow:1と入力して「追加」を押します。

スクリーンショット 2024-12-02 14.52.37.png

これが正常に保存された後、再度S3 Object Lambdaアクセスポイントから画像を閲覧するとエラーが解消されウォーターマーク付きの画像が表示されました。

8. リソースの削除

最後に、意図せぬ料金の発生などを避けるためリソースの削除を行います。

  • S3バケット
  • アクセスポイント
  • S3 Object Lambdaアクセスポイント
  • Lambda関数
  • IAMロール
  • IAMポリシー
  • IAMユーザー

作成したすべてのリソースを削除しておきましょう!

さいごに

最後までお読みいただきありがとうございました!

ハンズオンで実際にAWSのリソースを触ることによって、サービスへの理解が深まりました。
今後も積極的に触っていきたいと思います!

普段はQiitaにて技術記事の投稿を行なっていますので、こちらもよろしくお願いします!

hikagami – Qiita