もふもふ技術部

IT技術系mofmofメディア

AWS SAMを使ってS3通知→Lambda(Python Pillow)で画像加工する環境をつくる

S3への画像アップロードをトリガーに、それを加工するようなLambdaを1コマンドでつくれるやつです!

  • 通知を設定する方法
  • Lambdaで外部ライブラリを使う方法
  • SAMでLambdaをデプロイする方法

あたりの情報がひとつにまとまった記事が見つけられなかったので書きます。

ファイル準備

ディレクトリ作成

mkdir sam-lambda-s3

ファイルとか作成

touch template.yaml
mkdir function python
touch function/function.py function/requirements.txt

template.yml(CloudFormationのやつ)

概ね公式の内容通りですが、一部調整してます。
CloudFormation を使用して、既存の S3 バケットで Lambda 用の Amazon S3 通知設定を作成する方法を教えてください。 https://aws.amazon.com/jp/premiumsupport/knowledge-center/cloudformation-s3-notification-lambda/

先に内容の概要を紹介

  • S3NotificationLambdaFunction

    • S3からの通知を受け取るLambda
    • 処理自体はfunction/function.py
  • LibraryLayer

    • S3NotificationLambdaFunctionで使うライブラリを置いておくLambdaLayer
    • python/ 以下がデプロイされるように設定している
  • LambdaInvokePermission

    • S3NotificationLambdaFunctionのPermission
  • LambdaIAMRole

    • S3NotificationLambdaFunctionのIAMロール
  • CustomResourceLambdaFunction

    • S3通知を設定するLambda
  • LambdaTrigger

    • S3通知の設定

template.yaml

AWSTemplateFormatVersion: 2010-09-09
Transform: 'AWS::Serverless-2016-10-31'
Description: S3から通知を受けるLambdaを作成する
Parameters:
  NotificationBucket:
    Type: String
    Description: 通知を設定するS3バケット名
  S3NotificationLambdaFunctionName:
    Type: String
    Description: S3からの通知を受ける関数名
  Prefix:
    Type: String
    Description: 通知対象のファイルのPrefix(フォルダ等)

Resources:
  S3NotificationLambdaFunction:
    Type: 'AWS::Serverless::Function'
    Properties:
      FunctionName: !Ref S3NotificationLambdaFunctionName
      CodeUri: function/
      Handler: function.lambda_handler
      Role: !GetAtt LambdaIAMRole.Arn
      Runtime: python3.9
      Timeout: 5
      Layers:
        - !Ref LibraryLayer

  LibraryLayer:
    Type: "AWS::Serverless::LayerVersion"
    Properties:
      LayerName: PythonLibraryLayer
      ContentUri: python/
      CompatibleRuntimes:
        - python3.9
      RetentionPolicy: Delete

  LambdaInvokePermission:
    Type: 'AWS::Lambda::Permission'
    Properties:
      FunctionName: !GetAtt S3NotificationLambdaFunction.Arn
      Action: 'lambda:InvokeFunction'
      Principal: s3.amazonaws.com
      SourceAccount: !Ref 'AWS::AccountId'
      SourceArn: !Sub 'arn:aws:s3:::${NotificationBucket}'

  LambdaIAMRole:
    Type: 'AWS::IAM::Role'
    Properties:
      AssumeRolePolicyDocument:
        Version: 2012-10-17
        Statement:
          - Effect: Allow
            Principal:
              Service:
                - lambda.amazonaws.com
            Action:
              - 'sts:AssumeRole'
      Path: /
      ManagedPolicyArns:
        - 'arn:aws:iam::aws:policy/AmazonS3FullAccess'
        - 'arn:aws:iam::aws:policy/AmazonRekognitionFullAccess'
      Policies:
        - PolicyName: root
          PolicyDocument:
            Version: 2012-10-17
            Statement:
              - Effect: Allow
                Action:
                  - 's3:GetBucketNotification'
                  - 's3:PutBucketNotification'
                Resource: !Sub 'arn:aws:s3:::${NotificationBucket}'
              - Effect: Allow
                Action:
                  - 'logs:CreateLogGroup'
                  - 'logs:CreateLogStream'
                  - 'logs:PutLogEvents'
                Resource: 'arn:aws:logs:*:*:*'

  CustomResourceLambdaFunction:
    Type: 'AWS::Lambda::Function'
    Properties:
      Handler: index.lambda_handler
      Role: !GetAtt LambdaIAMRole.Arn
      Code:
        ZipFile: |

            from __future__ import print_function
            import json
            import boto3
            import cfnresponse

            SUCCESS = "SUCCESS"
            FAILED = "FAILED"

            print('Loading function')
            s3 = boto3.resource('s3')

            def lambda_handler(event, context):
                print("Received event: " + json.dumps(event, indent=2))
                responseData={}
                try:
                    if event['RequestType'] == 'Delete':
                        print("Request Type:",event['RequestType'])
                        Bucket=event['ResourceProperties']['Bucket']
                        delete_notification(Bucket)
                        print("Sending response to custom resource after Delete")
                    elif event['RequestType'] == 'Create' or event['RequestType'] == 'Update':
                        print("Request Type:",event['RequestType'])
                        LambdaArn=event['ResourceProperties']['LambdaArn']
                        Bucket=event['ResourceProperties']['Bucket']
                        Prefix=event['ResourceProperties']['Prefix']
                        add_notification(LambdaArn, Bucket, Prefix)
                        responseData={'Bucket':Bucket}
                        print("Sending response to custom resource")
                    responseStatus = 'SUCCESS'
                except Exception as e:
                    print('Failed to process:', e)
                    responseStatus = 'FAILED'
                    responseData = {'Failure': 'Something bad happened.'}
                cfnresponse.send(event, context, responseStatus, responseData)

            def add_notification(LambdaArn, Bucket, Prefix):
                bucket_notification = s3.BucketNotification(Bucket)
                response = bucket_notification.put(
                  NotificationConfiguration={
                    'LambdaFunctionConfigurations': [
                      {
                          'LambdaFunctionArn': LambdaArn,
                          'Events': [
                              's3:ObjectCreated:*'
                          ],
                          'Filter': {'Key': {'FilterRules': [
                            {'Name': 'Prefix', 'Value': Prefix}
                          ]}}
                      }
                    ]
                  }
                )
                print("Put request completed....")

            def delete_notification(Bucket):
                bucket_notification = s3.BucketNotification(Bucket)
                response = bucket_notification.put(
                    NotificationConfiguration={}
                )
                print("Delete request completed....")
      Runtime: python3.9
      Timeout: 50

  LambdaTrigger:
    Type: 'Custom::LambdaTrigger'
    DependsOn: LambdaInvokePermission
    Properties:
      ServiceToken: !GetAtt CustomResourceLambdaFunction.Arn
      LambdaArn: !GetAtt S3NotificationLambdaFunction.Arn
      Bucket: !Ref NotificationBucket
      Prefix: !Ref Prefix

画像処理にPillowを使います

requirements.txt

Pillow

インストール

pip3 install -r function/requirements.txt -t python

function.pyを編集

import json
import boto3
import os
from PIL import Image

WIDTH_INDEX = 0
HEIGHT_INDEX = 1

def lambda_handler(event, context):
    s3 = boto3.client('s3')
    created_images = event["Records"]

    for image in created_images:
        created_image_path = image["s3"]["object"]["key"]
        bucket_name = image["s3"]["bucket"]["name"]
        temp_file_path = u'/tmp/' + os.path.basename(created_image_path)

        print(f'target_image is {created_image_path}')

        s3.download_file(Bucket=bucket_name, Key=created_image_path, Filename=temp_file_path)
        target_image = Image.open(temp_file_path)

        image_width = target_image.size[WIDTH_INDEX]
        image_height = target_image.size[HEIGHT_INDEX]

        is_width_shorter = image_width < image_height

        upper_coordinate = 0
        left_coordinate = 0
        lower_coordinate = 0
        right_coordinate = 0

        if is_width_shorter:
            left_coordinate = 0
            right_coordinate = image_width
            upper_coordinate = image_height / 2 - image_width / 2
            lower_coordinate = image_height / 2 + image_width / 2
        else:
            upper_coordinate = 0
            lower_coordinate = image_height
            left_coordinate = image_width / 2 + image_height / 2
            right_coordinate = image_width / 2 - image_height / 2

        cropped_image = target_image.crop((left_coordinate, upper_coordinate, right_coordinate, lower_coordinate))
        cropped_file_temp_path = u'/tmp/cropped-' + os.path.basename(created_image_path)
        cropped_image.save(cropped_file_temp_path)

        s3.upload_file(Filename=cropped_file_temp_path, Bucket=bucket_name, Key=f'cropped/{created_image_path}')

    return {
        'statusCode': 200,
        'body': json.dumps('Success!')
    }

awsコンソールからs3のバケットを作成する(すでに自由に使えるバケットがあるならスキップでOK)

ビルドとデプロイ

sam build
sam deploy --guided

以下にダイアログの例を置いておきます。 NotificationBucketは作成したバケット名を指定する必要がありますが、それ以外はよしなに進めればOKです。

$ sam deploy --guided

Configuring SAM deploy
======================

        Looking for config file [samconfig.toml] :  Found
        Reading default arguments  :  Success

        Setting default arguments for 'sam deploy'
        =========================================
        Stack Name [sam-lambda-s3]: デフォルトでOK
        AWS Region [ap-northeast-1]: デフォルトでOK
        Parameter NotificationBucket []: <作成したバケット名を入力>
        Parameter S3NotificationLambdaFunctionName []: <Lambdaの関数名を自由に入力>
        Parameter Prefix []: original/ (このフォルダ以下に画像が追加されると通知が行われ、Lambdaが実行されます。特に好みがなければoriginalと入れておきましょう。)
        #Shows you resources changes to be deployed and require a 'Y' to initiate deploy
        Confirm changes before deploy [y/N]:
        #SAM needs permission to be able to create roles to connect to the resources in your template
        Allow SAM CLI IAM role creation [Y/n]:
        #Preserves the state of previously provisioned resources when an operation fails
        Disable rollback [y/N]:
        Save arguments to configuration file [Y/n]:
        SAM configuration file [samconfig.toml]:
        SAM configuration environment [default]:

デプロイが完了したら、S3に画像上げて→Lambdaが動き→加工された画像が/croppedに入ってるのを確認してみましょう。

以上です。