Ccmmutty logo
Commutty IT
0 pv15 min read

S3バケット間のファイル自動コピー機構をCloudFormationで作る

https://cdn.magicode.io/media/notebox/blob_A9JbfBk
仕事でAWSのCloudFormationを使わなければいけないことがたまにあるものの、正直あんまり慣れていないところでよく分かんない。
とは言え、分からないばっかりではしょうがないので多少は使いこなせるようにはしておきたい。
そのためには、色々簡単なシステムを自分で手を動かしながら構築して数をこなしていくのが一番なんじゃないかということで、複数のAWSのサービスを絡めた単純な機構を思い付き次第作っていくことにする。
今回はその第一弾。

作成するもの

機能の概要

S3バケット間で画像ファイルを自動コピーするシステムを作る。
  • バケットA内の「images」フォルダに画像をアップロード
  • ファイルがアップロードされたことを検知してファイルコピー処理Lambdaを起動
  • コピー処理LambdaはバケットB内の「images」フォルダに同じ画像を登録する
  • バケットA内の「images」直下以外にアップロードされたものはコピー対象外である

作成するリソース

サービス論理ID内容
S3::BucketBucketA画像アップロード元のバケット
S3::BucketBucketB画像コピー先のバケット
Lambda::FunctionCopyLambda画像コピー処理を行うスクリプト
IAM::RoleCopyLambdaRoleS3バケットやCloudWatchなどへアクセスする権限をLambdaに与える
Events::RuleEventBridgeRuleS3バケットへのファイルアップロードイベントを検知してLambdaを呼び出すルール
Lambda::PermissionLambdaPermissionEventBridgeルールからLambdaを呼び出すことを許可する
Lambda関数のソースはPythonで書いたコードをzipに圧縮し、S3上のlambda-sources-test1というバケットに置いておく。
lambda-sources-test1バケットはあらかじめ用意しておく想定なので今回の生成対象には含めない。

CloudFormationテンプレート

テンプレートはyaml形式かjson形式のいずれかで定義可能だが今回はyamlにする。

パラメータの定義

パラメータの定義は必須ではないが、!Refを使って同じテンプレート内から参照できるので複数箇所で同じ値を使いたい場合などに便利。
ここではLambdaのソースコードの格納フォルダと圧縮ファイル名、作成するS3バケット名(物理ID)を定義している。
Parameters:
  LambdaSourceBucket:
    Type: String
    Default: lambda-sources-test1
    Description: Lambda関数を格納するS3バケット名

  LambdaSourceKey:
    Type: String
    Default: copy_image.zip
    Description: Lambda関数のソースコードのS3キー(zipの配置場所)

  SourceBucketName:
    Type: String
    Default: bucket-a-20250421
    Description: アップロード元S3バケット名

  DestinationBucketName:
    Type: String
    Default: bucket-b-20250421
    Description: コピー先S3バケット名

S3関連の定義

S3バケットを2つ定義する。
重要なのはファイルアップロード元のBucketAの方で、アップロードのイベントを検知するためにEventBridgeの有効化の設定を追加する必要がある。
NotificationConfiguration(バケット通知設定)はいくつかプロパティを設定できるが、今回はEventBridgeConfigurationを設定する。
EventBridgeConfigurationEventBridgeEnabledというBooleanのプロパティがあり、これをtrueにしないと何も起きない。
BucketBの方は存在しているだけでいいのでとりあえず名前だけつけておく。
BucketA:
  Type: AWS::S3::Bucket
  Properties:
    BucketName: !Ref SourceBucketName
    NotificationConfiguration:
      EventBridgeConfiguration:
        EventBridgeEnabled: true # EventBridgeを有効化

BucketB:
  Type: AWS::S3::Bucket
  Properties:
    BucketName: !Ref DestinationBucketName

Lambda関連の定義

Lambdaでバケット内のオブジェクトを操作するので、そのための権限をLambda関数に与えるCopyLambdaRoleを定義する。
AssumeRolePolicyDocumentのところは、Lambdaサービスがsts:AssumeRoleを実行する(つまりロールを受け取る)ことを許可している。
Policiesには具体的な権限を個々に定義する。
Lambda関数はBacketAimages配下に対してオブジェクトの取得を行うのでs3:GetObjectを許可し、BacketBimages配下に対してオブジェクトの作成を行うのでs3:PutObjectを許可する。
この辺りが正しく設定されていないと権限エラーで所定の処理を実行できない。
また、ログ出力の権限も与えているのでLambda関数内でprint()で出力している内容がCloudWatchで確認できるようになる。
CopyLambdaRole:
  Type: AWS::IAM::Role
  Properties:
    RoleName: copy-lambda-execution-role
    AssumeRolePolicyDocument:
      Version: "2012-10-17"
      Statement:
        - Effect: Allow
          Principal:
            Service: lambda.amazonaws.com
          Action: sts:AssumeRole
    Policies:
      - PolicyName: lambda-s3-access-policy
        PolicyDocument:
          Version: "2012-10-17"
          Statement:
            - Effect: Allow
              Action:
                - s3:GetObject
              Resource: !Sub arn:aws:s3:::${SourceBucketName}/images/*
            - Effect: Allow
              Action:
                - s3:PutObject
              Resource: !Sub arn:aws:s3:::${DestinationBucketName}/images/*
            - Effect: Allow
              Action:
                - logs:CreateLogGroup
                - logs:CreateLogStream
                - logs:PutLogEvents
              Resource: arn:aws:logs:*:*:*
次はスクリプト本体のCopyLambdaの定義。
ソースコードを格納しているバケットの指定や権限の設定などを行う。
Roleの指定は必須であり、前述のCopyLambdaRoleARNを指定することでこのLambda関数にバケット操作の権限が与えられる。(権限は装備しなきゃ意味がないぞ!)
テンプレートからLambda関数へパラメータを渡したい時はEnvironmentに定義すればよく、ここではDEST_BUCKETというパラメータでコピー先バケットの物理IDを渡してLambda関数の中で使えるようにしている。
CopyLambda:
  Type: AWS::Lambda::Function
  Properties:
    FunctionName: copy-image-lambda
    Handler: copy_image.lambda_handler
    Runtime: python3.10
    Role: !GetAtt CopyLambdaRole.Arn
    Timeout: 30
    Code:
      S3Bucket: !Ref LambdaSourceBucket
      S3Key: !Ref LambdaSourceKey
    Environment:
      Variables:
        DEST_BUCKET: !Ref DestinationBucketName

EventBridge関連の定義

BucketAへのファイルアップロードイベントを検知してCopyLambdaに伝えるEventBridgeのルールを定義する。
ルールを設定する対象はS3なのでsourceaws.s3、アップロードイベント(ファイルオブジェクトの作成)を検知するのでdetail-typeObject Createddetailにはイベント検知対象のバケット名やオブジェクトのキーを指定する。
Targetsはイベントの発生を伝える先のLambda関数のARNを指定する。
EventBridgeRule:
  Type: AWS::Events::Rule
  Properties:
    Name: s3-image-upload-trigger-rule
    EventPattern:
      source:
        - aws.s3
      detail-type:
        - Object Created
      detail:
        bucket:
          name:
            - !Ref SourceBucketName
        object:
          key:
            - prefix: images/
    Targets:
      - Id: lambda-target
        Arn: !GetAtt CopyLambda.Arn
EventBridgeルールからLambda関数を呼び出せるようにするための権限も必要。
Lambda関数を呼び出したいのでAction: lambda:InvokeFunctionを設定する。
FunctionNameは対象Lambda関数の名前またはARNであるが、関数を呼び出すリソース(つまり今回はEventBridgeルール)を指定するSourceArnはARNのみなのでこの辺りの違いがややこしいと感じるところ。
LambdaPermission:
  Type: AWS::Lambda::Permission
  Properties:
    FunctionName: !Ref CopyLambda
    Action: lambda:InvokeFunction
    Principal: events.amazonaws.com
    SourceArn: !GetAtt EventBridgeRule.Arn

テンプレート全体

s3_image_copy_template.yamlというファイル名にしておく。
AWSTemplateFormatVersion: "2010-09-09"
Description: S3バケットAへの画像アップロードをトリガーに、Lambda関数でバケットBへ自動コピーする構成。

Parameters:
  LambdaSourceBucket:
    Type: String
    Default: lambda-sources-test1
    Description: Lambda関数を格納するS3バケット名

  LambdaSourceKey:
    Type: String
    Default: copy_image.zip
    Description: Lambda関数のソースコードのS3キー(zipの配置場所)

  SourceBucketName:
    Type: String
    Default: bucket-a-20250421
    Description: アップロード元S3バケット名

  DestinationBucketName:
    Type: String
    Default: bucket-b-20250421
    Description: コピー先S3バケット名

Resources:
  BucketA:
    Type: AWS::S3::Bucket
    Properties:
      BucketName: !Ref SourceBucketName
      NotificationConfiguration:
        EventBridgeConfiguration:
          EventBridgeEnabled: true # EventBridgeを有効化

  BucketB:
    Type: AWS::S3::Bucket
    Properties:
      BucketName: !Ref DestinationBucketName

  CopyLambdaRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: copy-lambda-execution-role
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: Allow
            Principal:
              Service: lambda.amazonaws.com
            Action: sts:AssumeRole
      Policies:
        - PolicyName: lambda-s3-access-policy
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              - Effect: Allow
                Action:
                  - s3:GetObject
                Resource: !Sub arn:aws:s3:::${SourceBucketName}/images/*
              - Effect: Allow
                Action:
                  - s3:PutObject
                Resource: !Sub arn:aws:s3:::${DestinationBucketName}/images/*
              - Effect: Allow
                Action:
                  - logs:CreateLogGroup
                  - logs:CreateLogStream
                  - logs:PutLogEvents
                Resource: arn:aws:logs:*:*:*

  CopyLambda:
    Type: AWS::Lambda::Function
    Properties:
      FunctionName: copy-image-lambda
      Handler: copy_image.lambda_handler
      Runtime: python3.10
      Role: !GetAtt CopyLambdaRole.Arn
      Timeout: 30
      Code:
        S3Bucket: !Ref LambdaSourceBucket
        S3Key: !Ref LambdaSourceKey
      Environment:
        Variables:
          DEST_BUCKET: !Ref DestinationBucketName

  EventBridgeRule:
    Type: AWS::Events::Rule
    Properties:
      Name: s3-image-upload-trigger-rule
      EventPattern:
        source:
          - aws.s3
        detail-type:
          - Object Created
        detail:
          bucket:
            name:
              - !Ref SourceBucketName
          object:
            key:
              - prefix: images/
      Targets:
        - Id: lambda-target
          Arn: !GetAtt CopyLambda.Arn

  LambdaPermission:
    Type: AWS::Lambda::Permission
    Properties:
      FunctionName: !Ref CopyLambda
      Action: lambda:InvokeFunction
      Principal: events.amazonaws.com
      SourceArn: !GetAtt EventBridgeRule.Arn

Lambdaスクリプトのソース

ファイルコピー処理のソースコードはPython3.10で記述している。
テンプレートのCopyLambdaEnvironmentで定義したパラメータはos.environ.get('変数名')のように受け取って使う。
lambda_handler()の引数として渡ってくるeventには、テンプレートのEventBridgeRuleEventPatternで定義した情報が含まれているので、そこからイベント発生元の情報などを参照することができる。
ファイルがコピー対象かどうかの判定はいい具合に書いておき、s3.copy_object()でコピー先のバケットを指定すればOK。
このスクリプトを圧縮したcopy_image.zipをS3のlambda-sources-test1にあらかじめ置いておけばLambdaソースの準備は完了。
copy_image.py
import boto3
import os
import urllib.parse

s3 = boto3.client('s3')
destination_bucket = os.environ.get('DEST_BUCKET')


def lambda_handler(event, context):
    try:
        detail = event['detail']
        source_bucket = detail['bucket']['name']
        key = urllib.parse.unquote_plus(detail['object']['key'])

        # 「images/」直下のファイルのみ許可(サブディレクトリは対象外)
        if not key.startswith('images/') or '/' in key.removeprefix('images/'):
            print(f'"{key}" is not located directly under the "images/". Skip copying.')
            return

        # jpeg, png, gif, webp 以外のファイルはスキップ
        if not key.endswith(('.jpg', '.jpeg', '.png', '.gif', '.webp')):
            print(f'"{key}" is unsupported file type. Skip copying.')
            return

        s3.copy_object(
            CopySource={'Bucket': source_bucket, 'Key': key},
            Bucket=destination_bucket,
            Key=key
        )

        print(f'Copied {key} from {source_bucket} to {destination_bucket}')
    except Exception as e:
        print(f'Error occurred: {e}')
        raise

デプロイ用のスクリプト

作成したs3_image_copy_template.yamlをデプロイしてスタックを作成する。
aws cloudformation deploy ~ のコマンドを直接打ってもいいが、毎回やるのは面倒なのでshell scriptなどで実行スクリプトを組んでおくと楽。
環境変数の定義やテンプレートの構文チェックなどもまとめて記述しておけるので便利。
deploy_cf.sh
#!/bin/bash

# 環境変数など
TEMPLATE_FILE='s3_image_copy_template.yaml'
REGION='ap-northeast-1'

# スタック名が指定されているか確認
if [ -z "$1" ]; then
  echo 'specify stack name: $0 <STACK_NAME>'
  exit 1
fi

STACK_NAME="$1"

# テンプレートのバリデーション
aws cloudformation validate-template \
  --template-body file://$TEMPLATE_FILE

if [ $? -ne 0 ]; then
  echo 'Template validation failed.'
  exit 1
fi

# デプロイ実行
aws cloudformation deploy \
  --template-file $TEMPLATE_FILE \
  --stack-name $STACK_NAME \
  --capabilities CAPABILITY_NAMED_IAM \
  --region $REGION

if [ $? -eq 0 ]; then
  echo 'Stack deployment successful!'
else
  echo '[ERROR] Stack deployment failed.'
  exit 1
fi
スタック名を付けてスクリプトを実行するとデプロイ処理が走る。
./deploy_cf.sh image-copy
デプロイ状況はAWSコンソールのCloudFormationの当該スタックのページで随時確認でき、エラーなどがあれば詳細が表示される。
エラーがあればCloudFormationのリファレンスと睨めっこしたりChatGPTなどに聞いたりしながら地道にテンプレートを修正していきましょう。
デプロイ完了して想定通りの動きになっていることを確認できたら完了🎉

参考

CloudFormationの公式リファレンス

Discussion

コメントにはログインが必要です。