お世話になります、株式会社エイトハンドレッド・テクノロジー本部の細江と申します。
数年前から PPAP 対策が提唱されるようになり、専用の有料サービスなども提供され始めていますが、皆さまはどうされていますでしょうか?
「AWS サービスを組み合わせて簡易的なソリューションを構築できたら便利だなぁ」と考えていたところ、下記 AWS ブログを見つけました。
AWS のサーバーレスと Amazon S3 署名付き URL、クライアントサイド JavaScript で大きなサイズの複数ファイルの一括アップロード・ダウンロード機能を実現する方法
今回は、こちらの AWS ブログのソリューションを CloudFormation で実装する YAML テンプレートを、本投稿を含む合計3つの記事(Cognito, S3 編, API Gateway 編)でご紹介します。
構成図
ソリューションの概要につきましては 当該ブログ記事 をご確認ください。
YAML テンプレート
【参考】 AWS CloudFormation テンプレートの構造分析 > YAML
450行以上あるため、アコーディオンブロックにしています。
展開してご確認ください
AWSTemplateFormatVersion: 2010-09-09 Parameters: SharedName: Type: String Default: hatena-file-sharing Resources: UserPool: Type: AWS::Cognito::UserPool Properties: UserPoolName: !Sub ${SharedName}-userpool UsernameAttributes: - email AccountRecoverySetting: RecoveryMechanisms: - Name: verified_email Priority: 1 AdminCreateUserConfig: AllowAdminCreateUserOnly: true AutoVerifiedAttributes: - email UserAttributeUpdateSettings: AttributesRequireVerificationBeforeUpdate: - email Schema: - Name: email Required: true Mutable: false UserPoolDomain: Type: AWS::Cognito::UserPoolDomain Properties: Domain: !Ref SharedName UserPoolId: !Ref UserPool ResourceServer: Type: AWS::Cognito::UserPoolResourceServer Properties: Identifier: !Sub ${SharedName}-rs-id Name: !Sub ${SharedName}-rs UserPoolId: !Ref UserPool AppClient: Type: AWS::Cognito::UserPoolClient Properties: ClientName: !Sub ${SharedName}-appclient UserPoolId: !Ref UserPool AllowedOAuthFlowsUserPoolClient: true CallbackURLs: - !Sub 'https://${RestApi}.execute-api.${AWS::Region}.amazonaws.com/prod/web/download.html' - !Sub 'https://${RestApi}.execute-api.${AWS::Region}.amazonaws.com/prod/web/entrance.html' - !Sub 'https://${RestApi}.execute-api.${AWS::Region}.amazonaws.com/prod/web/upload.html' LogoutURLs: - !Sub 'https://${RestApi}.execute-api.${AWS::Region}.amazonaws.com/prod/web/entrance.html' SupportedIdentityProviders: - COGNITO AllowedOAuthFlows: - code AllowedOAuthScopes: - openid AppClientHostedUI: Type: AWS::Cognito::UserPoolUICustomizationAttachment Properties: ClientId: !Ref AppClient UserPoolId: !Ref UserPool S3Bucket: Type: AWS::S3::Bucket DeletionPolicy: Delete Properties: BucketName: !Ref SharedName AccessControl: Private PublicAccessBlockConfiguration: BlockPublicAcls: true BlockPublicPolicy: true IgnorePublicAcls: true RestrictPublicBuckets: true WebsiteConfiguration: IndexDocument: index.html LifecycleConfiguration: Rules: - Id: DeleteDownloads Status: Enabled Prefix: "download/" ExpirationInDays: 7 - Id: DeleteUploads Status: Enabled Prefix: "upload/" ExpirationInDays: 7 - Id: AbortIncompleteMultipartUpload Status: Enabled AbortIncompleteMultipartUpload: DaysAfterInitiation: 1 CorsConfiguration: CorsRules: - AllowedHeaders: - '*' AllowedMethods: - GET - POST AllowedOrigins: - !Sub 'https://${RestApi}.execute-api.${AWS::Region}.amazonaws.com' MaxAge: 3000 S3BucketPolicy: Type: AWS::S3::BucketPolicy Properties: Bucket: !Ref S3Bucket PolicyDocument: Version: 2012-10-17 Statement: - Effect: Allow Action: - s3:GetObject Resource: !Sub '${S3Bucket.Arn}/*' Principal: Service: 'apigateway.amazonaws.com' Condition: ArnLike: 'aws:SourceArn': !Sub 'arn:aws:execute-api:${AWS::Region}:${AWS::AccountId}:${RestApi}/*/GET/' LambdaS3Download: Type: AWS::Lambda::Function Properties: FunctionName: !Sub ${SharedName}-s3download-function Role: !Sub ${LambdaFunctionRole.Arn} Runtime: python3.10 Handler: index.handler Environment: Variables: DURATION_SECONDS: 3600 S3_BUCKET_NAME: !Ref SharedName S3_PREFIX_NAME: 'download/' Code: ZipFile: | import json import datetime import botocore import boto3 import os S3_BUCKET_NAME = os.environ["S3_BUCKET_NAME"] S3_PREFIX_NAME = os.environ["S3_PREFIX_NAME"] DURATION_SECONDS = int(os.environ["DURATION_SECONDS"]) # S3クライアント s3_client = boto3.client("s3") def handler(event, context): # 戻り値の初期化 return_obj = dict() return_obj["body"] = dict() # バケット名の設定 return_obj["body"]["bucket"] = S3_BUCKET_NAME # フォルダー名の設定 return_obj["body"]["prefix"] = S3_PREFIX_NAME # ファイル (オブジェクト) 一覧の初期化 return_obj["body"]["contents"] = [] # ファイル一覧情報の取得 response = s3_client.list_objects_v2(Bucket=S3_BUCKET_NAME, Prefix=S3_PREFIX_NAME) for content in response["Contents"]: # ファイル情報の初期化 object = dict() # ファイルサイズの取得 size = content["Size"] if(size == 0): # ファイルサイズが 0 の場合、その後の処理をスキップ continue # ファイル名の取得と戻り値への設定 key = content["Key"] object["name"] = key.replace(S3_PREFIX_NAME, "").replace("/", "") # ファイルサイズの戻り値への設定 object["size"] = "{:,} Bytes".format(size) # ファイル更新日時の取得と戻り値への設定 # 日本のタイムゾーン (JST) tz_jst = datetime.timezone(datetime.timedelta(hours=9)) # 取得日時をJSTに変換 dt_jst = content['LastModified'].astimezone(tz_jst) object["lastModified"] = dt_jst.strftime('%Y/%m/%d %H:%M:%S') # 署名付き URL の取得と戻り値への設定 object["presignedUrl"] = s3_client.generate_presigned_url( ClientMethod = "get_object", Params = {"Bucket" : S3_BUCKET_NAME, "Key" : key}, ExpiresIn = DURATION_SECONDS, HttpMethod = "GET" ) # 取得した各情報の戻り値への設定 return_obj["body"]["contents"].append(object) return_obj["statusCode"] = 200 return_obj["body"] = json.dumps(return_obj["body"]) return return_obj LambdaS3DownloadPermission: Type: AWS::Lambda::Permission Properties: FunctionName: !Ref LambdaS3Download Action: 'lambda:InvokeFunction' Principal: 'apigateway.amazonaws.com' SourceArn: !Sub 'arn:aws:execute-api:${AWS::Region}:${AWS::AccountId}:${RestApi}/*/GET/api/download' LambdaS3Upload: Type: AWS::Lambda::Function Properties: FunctionName: !Sub ${SharedName}-s3upload-function Role: !Sub ${LambdaFunctionRole.Arn} Runtime: python3.10 Handler: index.handler Environment: Variables: DURATION_SECONDS: 3600 S3_BUCKET_NAME: !Ref SharedName S3_PREFIX_NAME: 'upload/' Code: ZipFile: | import json import boto3 from botocore.client import Config import os S3_BUCKET_NAME = os.environ["S3_BUCKET_NAME"] S3_PREFIX_NAME = os.environ["S3_PREFIX_NAME"] DURATION_SECONDS = int(os.environ["DURATION_SECONDS"]) s3_client = boto3.client("s3", config=Config(signature_version="s3v4")) def handler(event, context): # 戻り値の初期化 return_obj = dict() return_obj["body"] = dict() # バケット名の設定 return_obj["body"]["bucket"] = S3_BUCKET_NAME # フォルダー名の設定 return_obj["body"]["prefix"] = S3_PREFIX_NAME target_info = s3_client.generate_presigned_post(S3_BUCKET_NAME, S3_PREFIX_NAME + "${filename}", Fields=None, Conditions=None, ExpiresIn=DURATION_SECONDS) # 取得した各情報の戻り値への設定 return_obj["body"]["contents"] = target_info return_obj["statusCode"] = 200 return_obj["body"] = json.dumps(return_obj["body"]) return return_obj LambdaS3UploadPermission: Type: AWS::Lambda::Permission Properties: FunctionName: !Ref LambdaS3Upload Action: 'lambda:InvokeFunction' Principal: 'apigateway.amazonaws.com' SourceArn: !Sub 'arn:aws:execute-api:${AWS::Region}:${AWS::AccountId}:${RestApi}/*/GET/api/upload' LambdaFunctionRole: Type: AWS::IAM::Role Properties: RoleName: !Sub ${SharedName}-lambda-role AssumeRolePolicyDocument: Version: 2012-10-17 Statement: - Effect: Allow Principal: Service: - lambda.amazonaws.com Action: - sts:AssumeRole Policies: - PolicyName: !Sub ${SharedName}-lambda-policy PolicyDocument: Version: 2012-10-17 Statement: - Effect: Allow Action: - logs:CreateLogGroup Resource: !Sub 'arn:aws:logs:${AWS::Region}:${AWS::AccountId}:*' - Effect: Allow Action: - logs:CreateLogStream - logs:PutLogEvents Resource: !Sub 'arn:aws:logs:${AWS::Region}:${AWS::AccountId}:log-group:/aws/lambda/*' - Effect: Allow Action: - s3:ListBucket Resource: !Sub ${S3Bucket.Arn} - Effect: Allow Action: - s3:GetObject - s3:PutObject Resource: !Sub '${S3Bucket.Arn}/*' RestApi: Type: AWS::ApiGateway::RestApi Properties: Name: !Sub ${SharedName}-apigw ApiResourceApi: Type: AWS::ApiGateway::Resource Properties: RestApiId: !Ref RestApi ParentId: !Sub ${RestApi.RootResourceId} PathPart: 'api' ApiResourceApiDownload: Type: AWS::ApiGateway::Resource Properties: RestApiId: !Ref RestApi ParentId: !Ref ApiResourceApi PathPart: 'download' ApiResourceApiDownloadMethod: Type: AWS::ApiGateway::Method Properties: RestApiId: !Ref RestApi ResourceId: !Ref ApiResourceApiDownload HttpMethod: GET AuthorizationType: COGNITO_USER_POOLS AuthorizerId: !Ref ApiResourceApiAuthorizer MethodResponses: - StatusCode: 200 ResponseModels: application/json: Empty Integration: IntegrationHttpMethod: POST Type: AWS_PROXY Uri: !Sub 'arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${LambdaS3Download.Arn}/invocations' ApiResourceApiUpload: Type: AWS::ApiGateway::Resource Properties: RestApiId: !Ref RestApi ParentId: !Ref ApiResourceApi PathPart: 'upload' ApiResourceApiUploadMethod: Type: AWS::ApiGateway::Method Properties: RestApiId: !Ref RestApi ResourceId: !Ref ApiResourceApiUpload HttpMethod: GET AuthorizationType: COGNITO_USER_POOLS AuthorizerId: !Ref ApiResourceApiAuthorizer MethodResponses: - StatusCode: 200 ResponseModels: application/json: Empty Integration: IntegrationHttpMethod: POST Type: AWS_PROXY Uri: !Sub 'arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${LambdaS3Upload.Arn}/invocations' ApiResourceApiAuthorizer: Type: AWS::ApiGateway::Authorizer Properties: Name: !Sub ${SharedName}-apigw-authorizer RestApiId: !Ref RestApi Type: COGNITO_USER_POOLS IdentitySource: method.request.header.Authorization ProviderARNs: - !Sub 'arn:aws:cognito-idp:${AWS::Region}:${AWS::AccountId}:userpool/${UserPool}' ApiResourceWeb: Type: AWS::ApiGateway::Resource Properties: RestApiId: !Ref RestApi ParentId: !Sub ${RestApi.RootResourceId} PathPart: 'web' ApiResourceWebProxy: Type: AWS::ApiGateway::Resource Properties: RestApiId: !Ref RestApi ParentId: !Ref ApiResourceWeb PathPart: '{proxy+}' ApiResourceWebMethod: Type: AWS::ApiGateway::Method Properties: RestApiId: !Ref RestApi ResourceId: !Ref ApiResourceWebProxy HttpMethod: GET AuthorizationType: NONE RequestParameters: method.request.path.proxy: true MethodResponses: - StatusCode: 200 ResponseParameters: method.response.header.Content-Length: true method.response.header.Content-Type: true method.response.header.Timestamp: true ResponseModels: application/json: Empty - StatusCode: 400 - StatusCode: 500 Integration: IntegrationHttpMethod: GET Type: AWS Uri: !Sub 'arn:aws:apigateway:${AWS::Region}:s3:path/${SharedName}/contents/{proxy}' Credentials: !Sub ${ApiResourceWebRole.Arn} CacheKeyParameters: - 'method.request.path.proxy' RequestParameters: integration.request.path.proxy: 'method.request.path.proxy' IntegrationResponses: - StatusCode: 200 ResponseParameters: method.response.header.Content-Length: 'integration.response.header.Content-Length' method.response.header.Content-Type: 'integration.response.header.Content-Type' method.response.header.Timestamp: 'integration.response.header.Date' ResponseTemplates: application/json: '' - StatusCode: 400 SelectionPattern: '4\d{2}' - StatusCode: 500 SelectionPattern: '5\d{2}' ApiResourceWebRole: Type: AWS::IAM::Role Properties: RoleName: !Sub ${SharedName}-apigw-role AssumeRolePolicyDocument: Version: 2012-10-17 Statement: - Effect: Allow Principal: Service: - apigateway.amazonaws.com Action: - sts:AssumeRole ManagedPolicyArns: - 'arn:aws:iam::aws:policy/service-role/AmazonAPIGatewayPushToCloudWatchLogs' Policies: - PolicyName: !Sub ${SharedName}-apigw-policy PolicyDocument: Version: 2012-10-17 Statement: - Effect: Allow Action: - s3:ListBucket Resource: !Sub ${S3Bucket.Arn} - Effect: Allow Action: - s3:GetObject - s3:PutObject Resource: !Sub '${S3Bucket.Arn}/*' ApiResourceDeployment: Type: AWS::ApiGateway::Deployment DependsOn: - ApiResourceApiDownloadMethod - ApiResourceApiUploadMethod - ApiResourceWebMethod Properties: RestApiId: !Ref RestApi StageName: 'prod'
Parameters
Parameters: SharedName: Type: String Default: hatena-file-sharing
各リソースに使用する共通の名称として SharedName
というパラメータを宣言。デフォルトは hatena-file-sharing としています。
Resources
本記事では Lambda についてご説明しています。Cognito, S3 と API Gateway リソースについては、それぞれリンク先の記事をご確認ください。
LambdaS3Download
LambdaS3Download: Type: AWS::Lambda::Function Properties: FunctionName: !Sub ${SharedName}-s3download-function Role: !Sub ${LambdaFunctionRole.Arn} Runtime: python3.10 Handler: index.handler Environment: Variables: DURATION_SECONDS: 3600 S3_BUCKET_NAME: !Ref SharedName S3_PREFIX_NAME: 'download/' Code: ZipFile: |
S3 ファイルダウンロード用の署名付き URL を生成する Lambda 関数を作成しています。 ZipFile: |
以下の Python コードは省略してあるので、確認されたい場合は以下を展開してください。
ダウンロード用 Lambda - Python コード
import json import datetime import botocore import boto3 import os S3_BUCKET_NAME = os.environ["S3_BUCKET_NAME"] S3_PREFIX_NAME = os.environ["S3_PREFIX_NAME"] DURATION_SECONDS = int(os.environ["DURATION_SECONDS"]) # S3クライアント s3_client = boto3.client("s3") def handler(event, context): # 戻り値の初期化 return_obj = dict() return_obj["body"] = dict() # バケット名の設定 return_obj["body"]["bucket"] = S3_BUCKET_NAME # フォルダー名の設定 return_obj["body"]["prefix"] = S3_PREFIX_NAME # ファイル (オブジェクト) 一覧の初期化 return_obj["body"]["contents"] = [] # ファイル一覧情報の取得 response = s3_client.list_objects_v2(Bucket=S3_BUCKET_NAME, Prefix=S3_PREFIX_NAME) for content in response["Contents"]: # ファイル情報の初期化 object = dict() # ファイルサイズの取得 size = content["Size"] if(size == 0): # ファイルサイズが 0 の場合、その後の処理をスキップ continue # ファイル名の取得と戻り値への設定 key = content["Key"] object["name"] = key.replace(S3_PREFIX_NAME, "").replace("/", "") # ファイルサイズの戻り値への設定 object["size"] = "{:,} Bytes".format(size) # ファイル更新日時の取得と戻り値への設定 # 日本のタイムゾーン (JST) tz_jst = datetime.timezone(datetime.timedelta(hours=9)) # 取得日時をJSTに変換 dt_jst = content['LastModified'].astimezone(tz_jst) object["lastModified"] = dt_jst.strftime('%Y/%m/%d %H:%M:%S') # 署名付き URL の取得と戻り値への設定 object["presignedUrl"] = s3_client.generate_presigned_url( ClientMethod = "get_object", Params = {"Bucket" : S3_BUCKET_NAME, "Key" : key}, ExpiresIn = DURATION_SECONDS, HttpMethod = "GET" ) # 取得した各情報の戻り値への設定 return_obj["body"]["contents"].append(object) return_obj["statusCode"] = 200 return_obj["body"] = json.dumps(return_obj["body"]) return return_obj
今回は 既存の Python コード の流用なので、そのまま貼り付けていますが、ご自身でコーディングされる場合、この長さのソースコードを YAML テンプレート内に直接記述するのは、あまりオススメしません。
FunctionName
関数名。 !Sub
によって SharedName パラメータを挿入しています。
【参考】 AWS CloudFormation > Fn::Sub
Role
関数を実行する IAM ロール。同じく !Sub
によって、本テンプレートで作成している LambdaFunctionRole の ARN を指定しています。
Runtime
参照先の AWS ブログ に従って、ランタイムは python3.10
にしています。
Handler
ソースコードをインラインで記述する場合、ファイル名が index
になるため、それに合わせてハンドラも index.handler
としています1。
Environment > Variables
環境変数の設定。S3 バケット名は !Ref
によって SharedName パラメータを挿入し、その他は参照先の AWS ブログ と同じです。
Code > ZipFile
Lambda 関数のソースコード。末尾のバーティカルバー |
は、それ以降の複数行(=ソースコード)がひとつの ZIP ファイルになることを示しています。
LambdaS3DownloadPermission
LambdaS3DownloadPermission: Type: AWS::Lambda::Permission Properties: FunctionName: !Ref LambdaS3Download Action: 'lambda:InvokeFunction' Principal: 'apigateway.amazonaws.com' SourceArn: !Sub 'arn:aws:execute-api:${AWS::Region}:${AWS::AccountId}:${RestApi}/*/GET/api/download'
API Gateway サービスに対して Lambda 関数の実行を許可しています。
FunctionName
!Ref
によって、上記作成している LambdaS3Download を指定しています。
Action
API Gateway サービスに対して許可する操作。Lambda 関数の実行を許可しています。
Principal
上記 Action 操作を許可する AWS サービス。API Gateway を指定しています。
SourceArn
上記 Principal の ARN。 !Sub
によって、本テンプレートで作成している RestApi の ARN を作成しています。
【参考】
AWS CloudFormation > 擬似パラメータ参照 > AWS::Region
AWS CloudFormation > 擬似パラメータ参照 > AWS::AccountId
LambdaS3Upload
LambdaS3Upload: Type: AWS::Lambda::Function Properties: FunctionName: !Sub ${SharedName}-s3upload-function Role: !Sub ${LambdaFunctionRole.Arn} Runtime: python3.10 Handler: index.handler Environment: Variables: DURATION_SECONDS: 3600 S3_BUCKET_NAME: !Ref SharedName S3_PREFIX_NAME: 'upload/' Code: ZipFile: |
S3 ファイルアップロード用の署名付き URL を生成する Lambda 関数を作成しています。 ZipFile: |
以下の Python コードは省略してあるので、確認されたい場合は以下を展開してください。
アップロード用 Lambda - Python コード
import json import boto3 from botocore.client import Config import os S3_BUCKET_NAME = os.environ["S3_BUCKET_NAME"] S3_PREFIX_NAME = os.environ["S3_PREFIX_NAME"] DURATION_SECONDS = int(os.environ["DURATION_SECONDS"]) s3_client = boto3.client("s3", config=Config(signature_version="s3v4")) def handler(event, context): # 戻り値の初期化 return_obj = dict() return_obj["body"] = dict() # バケット名の設定 return_obj["body"]["bucket"] = S3_BUCKET_NAME # フォルダー名の設定 return_obj["body"]["prefix"] = S3_PREFIX_NAME target_info = s3_client.generate_presigned_post(S3_BUCKET_NAME, S3_PREFIX_NAME + "${filename}", Fields=None, Conditions=None, ExpiresIn=DURATION_SECONDS) # 取得した各情報の戻り値への設定 return_obj["body"]["contents"] = target_info return_obj["statusCode"] = 200 return_obj["body"] = json.dumps(return_obj["body"]) return return_obj
LambdaS3UploadPermission
LambdaS3UploadPermission: Type: AWS::Lambda::Permission Properties: FunctionName: !Ref LambdaS3Upload Action: 'lambda:InvokeFunction' Principal: 'apigateway.amazonaws.com' SourceArn: !Sub 'arn:aws:execute-api:${AWS::Region}:${AWS::AccountId}:${RestApi}/*/GET/api/upload'
本テンプレートで作成している RestApi に対して、上記作成している LambdaS3Upload の実行を許可しています。
LambdaFunctionRole
LambdaFunctionRole: Type: AWS::IAM::Role Properties: RoleName: !Sub ${SharedName}-lambda-role AssumeRolePolicyDocument: Version: 2012-10-17 Statement: - Effect: Allow Principal: Service: - lambda.amazonaws.com Action: - sts:AssumeRole Policies: - PolicyName: !Sub ${SharedName}-lambda-policy PolicyDocument: Version: 2012-10-17 Statement: - Effect: Allow Action: - logs:CreateLogGroup Resource: !Sub 'arn:aws:logs:${AWS::Region}:${AWS::AccountId}:*' - Effect: Allow Action: - logs:CreateLogStream - logs:PutLogEvents Resource: !Sub 'arn:aws:logs:${AWS::Region}:${AWS::AccountId}:log-group:/aws/lambda/*' - Effect: Allow Action: - s3:ListBucket Resource: !Sub ${S3Bucket.Arn} - Effect: Allow Action: - s3:GetObject - s3:PutObject Resource: !Sub '${S3Bucket.Arn}/*'
RoleName
IAM ロール名。 !Sub
によって SharedName パラメータを挿入しています。
AssumeRolePolicyDocument
信頼関係(信頼されたエンティティ)の編集。Lambda サービス lambda.amazonaws.com
から本 IAM ロールを引き受けられるアクション sts:AssumeRole
を記述しています。
Policies
インラインポリシーの作成。参照先の AWS ブログ で IAM ポリシーサンプルとして JSON 表記されているものをそのまま YAML で表現しています。
本投稿でご紹介する内容は以上になります。続きは下記の記事をご確認ください。
簡易ファイル共有サイトを CloudFormation で実装してみました(API Gateway 編)
-
インラインで記述している Python コードの関数名も参照先 AWS ブログの
lambda_handler
からhandler
に変更しています。↩