テックメモ

技術的に気になったことをメモしていきます

S3の署名付きURLを短縮URLでアクセス可能にするプロキシサービスをサーバーレスに構築する

はじめに

S3の署名付きURLをご存知でしょうか?
署名を付けたURLにアクセスすることで、プライベートなS3オブジェクトに対してダウンロードやアップロードを許可することが可能なしくみです。

ただ、この署名部分って結構長いんです。
この署名付きURLをどのように短縮できるのか、というのが今回のテーマです。

アーキテクチャ

[s3 presignedurl shortener]でググってみると、AWSのcompute blogにいい感じの記事が見つかりました。

aws.amazon.com

このアーキテクチャは素晴らしくて、S3のリダイレクト機能を上手く利用してなんとサーバーレスで短縮URLの仕組みを実現しています。
通常、短縮URLといえばリダイレクトで実現することが多いと思いますので、上記のブログも是非見ていただきたいです。

私は都合によりリダイレクトは利用できなかったため、この構成は見送りました。
リダイレクトをせずに署名付きURLを短縮したい。できればサーバーレスで。ということで今回のアーキテクチャはこちらです。

f:id:ginyon:20180110142128p:plain

CloudFrontをS3の前段に配置、Lambda@EdgeでCloudFrontからS3へのリクエストをインターセプトしてリクエストを加工することで、CloudFront - S3間の通信は署名付きURLによる通信としています。ユーザーは短縮URLでCloudFrontにアクセスするだけです。

Lambda@Egdeを利用するアイデアid:kikumotoさんから頂きました!ありがとうございます。

構築手順

CloudFormationのテンプレートを用意しました。
AWSコンソールにログインしていれば、こちらのボタンからStackの作成が可能です。

Create 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タブを確認

f:id:ginyon:20180110210534p:plain 出力されている項目を控えておきましょう。

  • 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にアクセスする時にホスト名のリライトをしてくれるのですが、この時に使われるホスト名の形式が上記となるようです。

docs.aws.amazon.com

この形式以外のホスト名を使うと、署名が合わなかったり(署名計算にホスト名を使うため)と色々とハマるポイントが生まれます。
検証時、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を忘れないでおこうと思います。

EC2-Classicな環境のハマりどころ(EC2編)

はじめに

EC2-Classicをご存知でしょうか。
まだVPCがデフォルトでは無かった時代に作成したAWSアカウントで利用可能な環境で、EC2-Classic環境上で構築したELBのセキュリティグループは基本フルオープンだったりします。

Classicを冠しているその名からも解る通り、今となっては古い環境ですので名前だけは知っているが実際に触れた事の無い方も多いのではないかと思います。

本題

とはいえ、今もどこかで元気に稼働しているEC2-Classicな環境もあるわけで、当然EC2のSystem Rebootなどのメンテナンス通知なども届くわけですね。
そんな時VPC Defaultな感覚でrebootやstop->startをすると少しハマるかもしれない、というのが今回のお話です。

docs.aws.amazon.com

上記ドキュメントの、インスタンスの停止と起動を見てみましょう。

インスタンスがEC2-Classicで実行されている場合、インスタンスは新しいプライベートIPv4アドレスを受け取ります。

そうなんです、プライベートIPが変わるんですね。
VPCの場合は停止していてもIPアドレスが保持されますが、EC2-Classicの場合は保持されません。

次、

つまり、プライベート IPv4 アドレスに関連付けられていた Elastic IP アドレスは、インスタンスとの関連付けが解除されます。

そう、パブリックIPも解除されてしまうんですね。EIP付けてるから安心だろう、というのは甘いということですね。これは意外とハマるのではないでしょうか。

なお、これはEC2の停止と起動を行った場合のお話で、再起動の場合は問題ありません。

System Rebootの対応として、EC2-Classicな環境でEC2のstop->startをすることがあれば思い出したいポイントですね。

ACMでDNSによるドメイン検証が可能になりました(自動更新が個人的に楽になった)

はじめに

AWSACMに関する以下のリリースを発表しました。

https://aws.amazon.com/jp/about-aws/whats-new/2017/11/aws-certificate-manager-easier-certificate-validation-using-dnsaws.amazon.com

今まで、ACMドメイン検証をする場合は、メール受信による検証だけだったのですが、これにDNSレコードの登録(CNAMEレコード)による検証が加わりました。

正直、今まではこれだけのためにメール受信環境を構築することもあり、TerraformとかCloud Formation等でテンプレ化をしていても面倒だったのでかなり嬉しいリリースです。

DNSレコード検証の自動更新プロセスについて詳細な内容にアップデートをしました (1/22)

何が嬉しいか

メールの受信環境を構築しなくて良いのは楽ですね。

また、DNSレコード検証専用の自動更新プロセスも誕生しました。

実は私はこちらの自動更新プロセスの方が嬉しいです。
メール検証の場合、証明書の自動更新の際に以下のプロセスが発生していました。

  1. ACMから対象ドメインの対してHTTPSの接続要求による検証を行う
  2. HTTPSの接続が確認できなかった場合はドメインオーナーに対して確認用Eメールを送付する

ACMからの接続による検証が通ると勝手に更新されていて、ACM最高だわ!となるのですが、そもそもSGなどで接続を制限している環境で運用をしていると、更新時にメール検証の一手間が発生してしまい、あー。ってなっていました。

では、DNSレコード検証ではどのようなプロセスとなるのか。

DNS を使用したドメインの所有権の検証 - AWS Certificate Manager

証明書は使用中で CNAME レコードが残っている状態であれば、証明書は ACM によって自動的に更新されます。

とあります。つまり以下の2点をクリアできれば自動的に更新されます。

  1. CNAMEレコードによる検証を行う
  2. 証明書が使用中であるか検証を行う

また「証明書が使用中」であることは以下のいずれかで確認可能です。

  • コンソールで確認したときに「使用中?」が「はい」になっている
  • describe-certificate コマンド InUseBy に何かしらのARNが存在する

上記で検証が失敗した場合はAWS Accountに対して確認用Eメールが送付されます。

Eメール認証で取得した証明書からドメイン認証へ乗り換える方法

これはよくある質問に答えが載っていました。

既存の証明書を E メールでの検証から DNS での検証に変更できますか?
できません。しかし、無料の証明書を新たに ACM からリクエストして、この新しいものに対して DNS での検証を選択できます。

つまり、Eメール認証で取得したドメイン名の証明書をDNS認証で新しく取得して、各AWSリソースとの紐づけを順次切り替えていけば良いです。

ACMで自動更新ができない件について調べてみた

はじめに

先日クラスメソッドさんのこちらのブログでAWS ACMで自動更新に失敗するケースがあることを知りました。

dev.classmethod.jp

自動更新に失敗することがあると聞いた事はありつつも、詳しい条件は知らなかった為なるほどなぁーとつらつらと眺めていると衝撃的な一文を目にしました。

纏めると次の条件に当てはまる証明書は自動更新が実施されません。 ワイルドカード証明書を利用している

まじか!それは面倒くさいよ!と思い調査をしたところ、AWSのドキュメントがアップデートされており少し状況が変わっている事が判明しました。

ACMで自動更新されない条件(2017/7/3版)

サクッと紹介します。

まず、ACMで自動更新を有効にする条件を見ていきましょう。 参照したドキュメントは以下になります。

docs.aws.amazon.com

証明書を自動的に更新するには、以下の条件が満たされている必要があるとあります。

  • ACM によって証明書の各ドメインHTTPS 接続を確立できる必要があります。
  • 各接続では、返される証明書が ACM が更新している証明書と一致する必要があります。

さらに更新の可能性を高める為には次の操作を行うようにとあります。

  • AWS リソースで証明書を使用すること
  • インターネットからの HTTPS リクエストを許可するようにリソースを設定します
  • ドメイン名を ACM 証明書をホストするリソースにルーティングするように DNS を設定すること

ここまではクラスメソッドさんのブログにある通りだと思います。

ACMワイルドカード証明書を自動更新させる条件(2017/7/3版)

次にACMドメイン検証の仕組みを確認しましょう。 参照したドキュメントは以下になります。

docs.aws.amazon.com

おそらく、ここに関するドキュメントがアップデートされたのだと思います。 自動ドメイン検証の仕組みには以下の記載があります。

ACM は、ワイルドカードドメイン名 (*.example.com など) を親ドメインと同じく処理します。次の表の例を参照してください。

肝心の表を参照してみると・・・・

証明書のドメイン ACM 自動検証に使用するドメイン
example.com example.com / www.example.com
www.example.com www.example.com / example.com
*.example.com example.com / www.example.com

なんとワイルドカードドメインにも自動検証に使用するドメインが記載されています!

念のためAWSサポートの方にも確認したところ、ワイルドカードドメイン証明書の場合は表の通りapexまたはwwwドメインに対して検証を行うとの事でした。
(ただしワイルドカードのみの証明書の場合はRFC 6125に違反する為、不正な証明書とみなされるので注意!!)

つまり、ワイルドカード証明書の場合は以下3点の条件を満たしていれば自動更新される事になります。

  1. 証明書がELBやCloudFrontに設定されている
  2. インターネットからそのリソースに対してアクセスができる
  3. www.もしくはapexドメインでそのリソースにアクセスできるようにDNSでルーティングされている

ELBだと固定費がかかるので、なるべく安く自動更新する為にはS3+CloudFrontのような構成で自動検証をクリアするのが良いかもしれませんね。

CloudFrontでACMを利用する場合はus-east-1で証明書を作成する必要がありました。そのため、東京リージョンで取得したACMの証明書は上記の構成で自動検証をクリアすることができませんでした。
us-east-1以外のリージョンで自動更新を通過させるためには、現状はELBに証明書をセットするしか手がなさそうです。