S3の署名付きURLを短縮URLでアクセス可能にするプロキシサービスをサーバーレスに構築する
はじめに
S3の署名付きURLをご存知でしょうか?
署名を付けたURLにアクセスすることで、プライベートなS3オブジェクトに対してダウンロードやアップロードを許可することが可能なしくみです。
ただ、この署名部分って結構長いんです。
この署名付きURLをどのように短縮できるのか、というのが今回のテーマです。
アーキテクチャ
[s3 presignedurl shortener]でググってみると、AWSのcompute blogにいい感じの記事が見つかりました。
このアーキテクチャは素晴らしくて、S3のリダイレクト機能を上手く利用してなんとサーバーレスで短縮URLの仕組みを実現しています。
通常、短縮URLといえばリダイレクトで実現することが多いと思いますので、上記のブログも是非見ていただきたいです。
私は都合によりリダイレクトは利用できなかったため、この構成は見送りました。
リダイレクトをせずに署名付きURLを短縮したい。できればサーバーレスで。ということで今回のアーキテクチャはこちらです。
CloudFrontをS3の前段に配置、Lambda@EdgeでCloudFrontからS3へのリクエストをインターセプトしてリクエストを加工することで、CloudFront - S3間の通信は署名付きURLによる通信としています。ユーザーは短縮URLでCloudFrontにアクセスするだけです。
Lambda@Egdeを利用するアイデアはid:kikumotoさんから頂きました!ありがとうございます。
構築手順
CloudFormationのテンプレートを用意しました。
AWSコンソールにログインしていれば、こちらのボタンからStackの作成が可能です。
また、こちらのテンプレートを使うと、DynamoDBへ短縮URLを含んだItemを登録するLambdaが生成されるため、動作確認が簡単に行えます。
(このLambdaは構成図には含まれていません)
一応テンプレートをテキストでも貼っておきます。
AWSTemplateFormatVersion: "2010-09-09" Description: Serverless Presinged URL Proxy. Must be deploy to us-east-1. Outputs: S3BucketName: Description: "Amazon S3 bucket name." Value: !Ref S3Bucket CloudFrontURL: Description: CloudFront Distribution DomainName. Value: !Sub "https://${CloudFrontDistrib.DomainName}/" DynamoDBTableName: Description: DynamoDB TableName. Value: !Ref DynamoDBTable Resources: S3Bucket: Type: "AWS::S3::Bucket" DeletionPolicy: Delete DependsOn: LambdaInvokePermission Properties: BucketName: !Ref "AWS::NoValue" NotificationConfiguration: LambdaConfigurations: - Event: "s3:ObjectCreated:*" Function: !GetAtt LambdaGenerateS3PresingedURL.Arn DynamoDBTable: Type: "AWS::DynamoDB::Table" Properties: AttributeDefinitions: - AttributeName: ShortURL AttributeType: S KeySchema: - AttributeName: ShortURL KeyType: HASH ProvisionedThroughput: ReadCapacityUnits: "5" WriteCapacityUnits: "5" LambdaExecRole: Type: "AWS::IAM::Role" Properties: Policies: - PolicyName: LambdaExecRolePresignedURLProxy PolicyDocument: Version: "2012-10-17" Statement: - Effect: Allow Action: "logs:*" Resource: "arn:aws:logs:*:*:*" - Effect: Allow Action: [ "s3:GetObject", "s3:PutObject", "s3:DeleteObject" ] Resource: !Sub "arn:aws:s3:::*" - Effect: Allow Action: [ "dynamodb:GetItem", "dynamodb:PutItem" ] Resource: !GetAtt DynamoDBTable.Arn AssumeRolePolicyDocument: Version: "2012-10-17" Statement: - Sid: "" Effect: Allow Principal: Service: [ "lambda.amazonaws.com", "edgelambda.amazonaws.com"] Action: "sts:AssumeRole" CloudFrontDistrib: Type: "AWS::CloudFront::Distribution" Properties: DistributionConfig: Origins: - DomainName: !GetAtt S3Bucket.DomainName Id: S3Origin S3OriginConfig: OriginAccessIdentity: "" Comment: CloudFront distribution used as a front end to the server-less Presinged URL Proxy. Enabled: true DefaultCacheBehavior: AllowedMethods: [ HEAD, DELETE, POST, GET, OPTIONS, PUT, PATCH ] CachedMethods: [ HEAD, GET ] Compress: true DefaultTTL: 60 TargetOriginId: "S3Origin" ViewerProtocolPolicy: "redirect-to-https" ForwardedValues: QueryString: 'false' LambdaFunctionAssociations: - EventType: origin-request LambdaFunctionARN: !Ref LambdaProxyDeployVersion LambdaProxy: Type: "AWS::Lambda::Function" Properties: Handler: index.handler MemorySize: 512 Role: !GetAtt LambdaExecRole.Arn Runtime: nodejs6.10 Timeout: 3 Code: ZipFile: !Sub - | 'use strict'; const aws = require('aws-sdk'); const docClient = new aws.DynamoDB.DocumentClient({region: '${AWS::Region}'}); exports.handler = (event, context, callback) => { const request = event.Records[0].cf.request; const httpVersion = request.httpVersion; const uri = request.uri; var params = { TableName: '${DynamoDBTableName}', Key: { 'ShortURL': uri.slice(1) } }; docClient.get(params, function(err, data) { const isEmpty = function(obj) { for(var key in obj) { if(obj.hasOwnProperty(key)) { return false; } } return true; }; if (err || isEmpty(data)) { const response = { status: '404', statusDescription: 'Not Found.', httpVersion: httpVersion, body: "404 Not Found.", }; callback(null, response); return; } const util = require('util'); /* Rewrite uri */ request.uri = '/' + data.Item.S3Key; /* Added Query String */ request.querystring = data.Item.S3PrisignedQuery; callback(null, request); }); }; - { DynamoDBTableName: !Ref DynamoDBTable } LambdaProxyDeployVersion: Type: "AWS::Lambda::Version" Properties: FunctionName: !Ref LambdaProxy LambdaGenerateS3PresingedURL: Type: "AWS::Lambda::Function" Properties: Handler: index.handler MemorySize: 512 Role: !GetAtt LambdaExecRole.Arn Runtime: nodejs6.10 Timeout: 30 Code: ZipFile: !Sub - | 'use strict'; const aws = require('aws-sdk'); const endpoint = new aws.Endpoint("s3.amazonaws.com"); const s3 = new aws.S3({ apiVersion: '2006-03-01', signatureVersion: 'v4', endpoint: endpoint }); const docClient = new aws.DynamoDB.DocumentClient({region: '${AWS::Region}'}); const url = require('url'); exports.handler = (event, context, callback) => { const bucket = event.Records[0].s3.bucket.name; const key = decodeURIComponent(event.Records[0].s3.object.key.replace(/\+/g, ' ')); const expireSec = 60 * 10; const presingedURL = s3.getSignedUrl('getObject', { Bucket: bucket, Key: key, Expires: expireSec }); console.log('Generate PresingedURL.'); console.log(presingedURL); const presignedQuery = url.parse(presingedURL).query; const params = { TableName: '${DynamoDBTableName}', Item: { 'ShortURL': Math.random().toString(36).slice(-8), 'S3Key': key, 'S3PrisignedQuery': presignedQuery } }; docClient.put(params, function(err, data) { if (err) { console.error("Unable to add item. Error JSON:", JSON.stringify(err, null, 2)); } else { console.log("Added item:", JSON.stringify(data, null, 2)); } }); }; - { DynamoDBTableName: !Ref DynamoDBTable } LambdaInvokePermission: Type: "AWS::Lambda::Permission" Properties: FunctionName: !GetAtt LambdaGenerateS3PresingedURL.Arn Action: "lambda:InvokeFunction" Principal: s3.amazonaws.com SourceAccount: !Ref AWS::AccountId
動作確認
上記テンプレートを使用した事を前提として動作確認を行っていきます。
Cloud FormationのOutputタブを確認
出力されている項目を控えておきましょう。
- CloudFrontURL
- DynamoDBTableName
- S3BucketName
S3バケットにオブジェクトをアップロード
S3BucketNameで表示されたバケットにオブジェクトをアップロードします。
今回はhello worldと書かれただけのhtmlをアップロードしました。
$ echo "hello world" > hello.html $ aws s3 cp hello.html s3://presingedurlproxy2-s3bucket-s98otf4r11ov upload: ./hello.html to s3://presingedurlproxy2-s3bucket-s98otf4r11ov/hello.html
DynamoDBの登録内容を確認する
DynamoDBTableNameで表示されたテーブルを確認します。
S3 PUTをトリガーに起動されたLambdaにより、新しいItemが登録されていることが確認できます。
$ aws dynamodb scan --table-name PresingedURLProxy2-DynamoDBTable-6PNHTTGS8MNM { "Items": [ { "S3Key": { "S": "hello.html" }, "ShortURL": { "S": "pee6zuxr" }, "S3PrisignedQuery": { "S": "X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=ASIAJTONTYX635YGUGFQ%2F20180110%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20180110T081138Z&X-Amz-Expires=600&X-Amz-Security-Token=FQoDYXdzEOn%2F%2F%2F%2F%2F%2F%2F%2F%2F%2FwEaDIm2aaU%2BCSkXPOo8IyKWAim%2B6fln3qiscBisqXqbF09Ofxn62mNkovBdkxw1riLMxKM%2BumxIwUq19QppQNrmnSyhKBjfvpM5FnLKB441Pc0bmzcuaykjfKUHmiekTtqxKj03gbM%2B8BgwAdaXJbS%2BgeDU%2BB9sAwl2w9j%2BzsqtYSuNF8MtPwmner2eV0jxineURCNIODeklgk%2FyRUKz4U%2BssIbk4%2Bf6qdyNcFBkHV8JO5wsXfduSEjxUPBrqTFt%2FlFFK8QoE01x1b9rl8jZ%2BCfsWMj%2FN6c8boY1QBwQspg4gTd9wC31ROvRc1QCq9B7hU0ie78DPS%2BkGBt%2B5m2Xbt0oAlpgnMr8sPRvKN2wXA2mvY1jYvUeiFoxmMjG0d2ngob7xVrkKCwKImI19IF&X-Amz-Signature=218d4973fc2502ae942f06002ee17f1a582ddf711265da583858ec1ff5b86e67&X-Amz-SignedHeaders=host" } } ], "Count": 1, "ScannedCount": 1, "ConsumedCapacity": null }
ShortURLを控えましょう。
CloudFrontに短縮URLでアクセスする
それでは短縮URLにアクセスしてみます。
ドメインにはCloudFrontURLを使ってください。
$ curl -vvv -X GET https://d14gehme3xkgip.cloudfront.net/pee6zuxr * Trying 13.32.50.11... * Connected to d14gehme3xkgip.cloudfront.net (13.32.50.11) port 443 (#0) * TLS 1.2 connection using TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256 * Server certificate: *.cloudfront.net * Server certificate: Symantec Class 3 Secure Server CA - G4 * Server certificate: VeriSign Class 3 Public Primary Certification Authority - G5 > GET /pee6zuxr HTTP/1.1 > Host: d14gehme3xkgip.cloudfront.net > User-Agent: curl/7.43.0 > Accept: */* > < HTTP/1.1 200 OK < Content-Type: text/html < Content-Length: 12 < Connection: keep-alive < Date: Wed, 10 Jan 2018 08:21:28 GMT < Last-Modified: Wed, 10 Jan 2018 08:11:37 GMT < ETag: "6f5902ac237024bdd0c176cb93063dc4" < Accept-Ranges: bytes < Server: AmazonS3 < X-Cache: Miss from cloudfront < Via: 1.1 9063af643f5f74dbc0e44494f142a87f.cloudfront.net (CloudFront) < X-Amz-Cf-Id: VYFNZjPSCdntReqTnIGdx6jnD76Tz2lFrMConknNiTnVveRp4xKVRg== < hello world * Connection #0 to host d14gehme3xkgip.cloudfront.net left intact
成功です。
それではS3オブジェクトのパスであるhello.htmlでアクセスするとどうなるか。該当するShortURLがDynamoDBに登録されていないため404になるはずです。
$ curl -vvv -X GET https://d14gehme3xkgip.cloudfront.net/hello.html * Trying 54.192.233.250... * Connected to d14gehme3xkgip.cloudfront.net (54.192.233.250) port 443 (#0) * TLS 1.2 connection using TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256 * Server certificate: *.cloudfront.net * Server certificate: Symantec Class 3 Secure Server CA - G4 * Server certificate: VeriSign Class 3 Public Primary Certification Authority - G5 > GET /hello.html HTTP/1.1 > Host: d14gehme3xkgip.cloudfront.net > User-Agent: curl/7.43.0 > Accept: */* > < HTTP/1.1 404 Not Found < Content-Length: 14 < Connection: keep-alive < Server: CloudFront < Date: Wed, 10 Jan 2018 08:23:09 GMT < X-Cache: Error from cloudfront < Via: 1.1 2fe788985cac89c0ef661ff7cd8edf63.cloudfront.net (CloudFront) < X-Amz-Cf-Id: tyQof7QnAQy9p5XzryvT0F6jyEFdoBx2dzYoor3H0PmA-bKvSin_AQ== < * Connection #0 to host d14gehme3xkgip.cloudfront.net left intact 404 Not Found.
404になりましたね。
なお、今回は試していませんが同じ要領でS3へPUTすることも可能です。
興味がある方はぜひ試してみてください。
解説
今回の構成についての解説です。
署名計算 + DynamoDBへのItem登録処理
S3 Putをトリガーとして起動したLambdaの処理について少しだけ解説です。
まずは、短縮URLに相当する文字列の生成と署名の計算を行います。
そして、DynamoDBへ短縮URL、署名、S3オブジェクトのパスを保存して終了です。
やっていることはこれだけなのですが、署名計算時に一点注意点があります。
署名計算時のホスト名は必ず、バケット名.s3.amazonaws.comとして計算をしてください。
Cloud FrontがS3 Originにアクセスする時にホスト名のリライトをしてくれるのですが、この時に使われるホスト名の形式が上記となるようです。
この形式以外のホスト名を使うと、署名が合わなかったり(署名計算にホスト名を使うため)と色々とハマるポイントが生まれます。
検証時、ap-northeast-1のエンドポイントを使ってしまった為、随分とハマりました。。。
署名付きURLプロキシ
CloudFront + Lambda@Edge + DynamoDB + S3 の構成について。
CloudFront + Lambda@Edge
CloudFrontのオリジンがS3Originとして構成されていると、CloudFrontによりHost名のリライト(cloud frontからs3への変換)が自動的に行われます。
まずはここで第一のリクエストの加工が発生します。
CloudFrontでは、リクエストに該当するキャッシュが無かった場合のみLambda@Edgeがトリガーされます。これはオリジンリクエストで動くLambda@Edgeの仕様です。
トリガーされたLambda@Edgeは、短縮URLをキーにDynamoDBからアイテムを取得します。
該当するアイテムが無ければ404のレスポンスを返しますが、アイテムが取得できた場合はS3へ渡すリクエストを更に加工していきます。
加工するポイントは2つです。
まず、リクエストのURIは短縮URLのままなので、S3オブジェクトへのパスへと変換します。
最後に、リクエストのクエリストリングに署名情報を突っ込みます。
これでCloudFrontからS3へのリクエストは、署名付きURLによるリクエストに置き換わりました。
実際に運用される場合は、DynamoDBのTTLや署名の期限なども考慮した方が良いと思います。
さいごに
Lambda@Edgeの上手い使い方がわからなかったのですが、今回はなかなか面白い構成ができたと思っています。
他にもS3にベーシック認証掛けたりもできるようなので、アーキテクチャの選定時にはLambda@Edgeを忘れないでおこうと思います。