テックメモ

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

ALB Ingress Controller で SSL Redirect を設定する方法

はじめに

ALB Ingress Controllerで作成されたALBでどのようにSSL Redirectを設定するのかを調べたのでメモ。 github.com

設定方法

以下の公式ドキュメントがそのままずばりだった。 kubernetes-sigs.github.io

ドキュメントを参照すれば十分ではあるのだけど、感想や注意点をいくつかメモしておく。

annotations について

ALBでHTTPSのリスナーを有効にする為には alb.ingress.kubernetes.io/certificate-arn が必要となる。また、HTTPからHTTPSへのリダイレクトとなるので alb.ingress.kubernetes.io/listen-ports はHTTPとHTTPSどちらもリッスンする必要がある。
リダイレクトを行うためのALBのアクションとして、以下のような定義も必要となる。(ssl-redirectはアクション名で任意の名付けが可能)
alb.ingress.kubernetes.io/actions.ssl-redirect: '{"Type": "redirect", "RedirectConfig": { "Protocol": "HTTPS", "Port": "443", "StatusCode": "HTTP_301"}}'

spec.rules.http.paths の書き方について

ALBとしてSSL Redirectを実現するためにはHTTPとHTTPSでそれぞれ異なるリスナールールを追加する必要がある。しかし以下はALB Ingress Controllerのドキュメントから引用したSSL Redirectの定義例なのだが、httpとhttpsのように個別に定義している訳では無かった。

apiVersion: extensions/v1beta1
kind: Ingress
metadata:
  namespace: default
  name: ingress
  annotations:
    kubernetes.io/ingress.class: alb
    alb.ingress.kubernetes.io/certificate-arn: arn:aws:acm:us-west-2:xxxx:certificate/xxxxxx
    alb.ingress.kubernetes.io/listen-ports: '[{"HTTP": 80}, {"HTTPS":443}]'
    alb.ingress.kubernetes.io/actions.ssl-redirect: '{"Type": "redirect", "RedirectConfig": { "Protocol": "HTTPS", "Port": "443", "StatusCode": "HTTP_301"}}'
spec:
  rules:
    - http:
        paths:
         - path: /*
           backend:
             serviceName: ssl-redirect
             servicePort: use-annotation
         - path: /users/*
           backend:
             serviceName: user-service
             servicePort: 80
         - path: /*
           backend:
             serviceName: default-service
             servicePort: 80

ALB Ingress Controllerでは以下のロジックで処理することにより、ALBのHTTPとHTTPSのリスナールールを定義しているようだ。

  • デフォルトではspecで指定された全てのリスナールールをALBの全てのリスナーに適用する
  • リダイレクトルールが存在する場合、全てのリスナーでリダイレクトループになるかを判定し、リダイレクトループになる場合はルールを無視する

結構トリッキーだなというのが感想だが、これでALB Ingress ControllerでSSL Redirectを行う方法が分かった。

RDSの証明書更新が無停止で出来ると聞いたので試した

はじめに

RDSのTLS証明書の更新が無停止で出来ると聞いたので、調べた結果のメモです。
ソースはこちらのドキュメントです。 aws.amazon.com

--no-certificate-rotation-restart オプションについて以下のように言及がなされています。

I don’t use SSL/TLS, can I rotate the certificate without restarting my database?
If you do not want to restart your database, you can use a new CLI option for the modify-db-instance CLI command (--no-certificate-rotation-restart) specifically to rotate and stage the new certificates on the database host to avoid a restart. However, new certificates will be picked up by the database only when a planned or unplanned database restart happens.

結果

結論からいうと、TLS証明書の更新が不要な場合に限り無停止でメンテナンスを行う事が可能でした。
以下、検証結果を記します。

検証結果

まずは更新前のRDSの証明書を確認します。確認したところ、有効期限が2020/3/5の状態でした。

openssl s_client -connect ${RDS_ENDPOINT}:${RDS_PORT} -starttls mysql | openssl x509 -noout -enddate

depth=1 C = US, ST = Washington, L = Seattle, O = "Amazon Web Services, Inc.", OU = Amazon RDS, CN = Amazon RDS ap-northeast-1 CA
depth=0 CN = xxxxx.ap-northeast-1.rds.amazonaws.com, OU = RDS, O = Amazon.com, L = Seattle, ST = Washington, C = US
verify return:1
notAfter=Mar  5 22:03:06 2020 GMT

次に、このRDSに対して以下のようにヘルスチェックを行うスクリプトを作成しました。

#!/bin/bash
while true;
do
  d=$(date)
  if mysql -e 'show databases' > /dev/null 2>&1 ; then
    echo "${d}: connect success"
  else
    echo "${d}: connect failed"
  fi
  sleep 1
done

上記のスクリプトを実行した状態で、以下のように変更を実施しました。

aws rds modify-db-instance --db-instance-identifier ${RDS_IDENTIFIER} --no-certificate-rotation-restart --ca-certificate-identifier rds-ca-2019

# レスポンスから抜粋
"PendingModifiedValues": {
   "CACertificateIdentifier": "rds-ca-2019"
},

PendingModifiedValuesがCACertificateIdentifierだけでしたので、即時反映をしてみます。

aws rds modify-db-instance --db-instance-identifier ${RDS_IDENTIFIER} --no-certificate-rotation-restart --ca-certificate-identifier rds-ca-2019 --apply-immediately

無事に完了し、このRDSインスタンスaws rds describe-pending-maintenance-actions の対象から除外されたことも確認できました。もちろんこの間にmysqlのヘルスチェックは失敗しませんでした。

最後に証明書の状態を確認してみたところ、ドキュメントの通り証明書は更新されていませんでした。

openssl s_client -connect ${RDS_ENDPOINT}:${RDS_PORT} -starttls mysql | openssl x509 -noout -enddate

depth=1 C = US, ST = Washington, L = Seattle, O = "Amazon Web Services, Inc.", OU = Amazon RDS, CN = Amazon RDS ap-northeast-1 CA
depth=0 CN = xxxxx.ap-northeast-1.rds.amazonaws.com, OU = RDS, O = Amazon.com, L = Seattle, ST = Washington, C = US
verify return:1
notAfter=Mar  5 22:03:06 2020 GMT

証明書を更新する方法

CLIのリファレンスを確認したところ --no-certificate-rotation-restart を指定した場合でも、RDSインスタンスを再起動すれば証明書が更新されるようなので試してみました。 docs.aws.amazon.com

さきほどの検証に用いたRDSインスタンスを再起動します。

aws rds reboot-db-instance --db-instance-identifier ${RDS_IDENTIFIER}

再起動後、TLS証明書の状態を確認した結果がこちらです。無事にTLS証明書が更新されていますね。

openssl s_client -connect ${RDS_ENDPOINT}:${RDS_PORT} -starttls mysql | openssl x509 -noout -enddate

depth=1 C = US, ST = Washington, L = Seattle, O = "Amazon Web Services, Inc.", OU = Amazon RDS, CN = Amazon RDS ap-northeast-1 2019 CA
depth=0 CN = xxxxx.ap-northeast-1.rds.amazonaws.com, OU = RDS, O = Amazon.com, L = Seattle, ST = Washington, C = US
verify return:1
notAfter=Aug 22 17:08:50 2024 GMT

terraform planで意図せず差分が検出される件と向き合ってみた

はじめに

良く terraformを利用するのですが、定義変更がない状態でplanを実行した際差分が検出される(ことがある)事象に悩まされていました。遭遇頻度がそこまで高くなかったために、しばらく放置していたのですがterraformもCI/CDをまわしたい欲に駆られてしっかりと向き合ってみました。

どんな事象が起きるのか

たとえば、Datadogと連携するためのiAM Roleを以下のように定義してみます。

resource "aws_iam_role" "dd" {
    name = "dd"
    description = "dd"
    assume_role_policy = <<EOF
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "",
      "Effect": "Allow",
      "Principal": {
        "AWS": "123451234512"
      },
      "Action": "sts:AssumeRole"
    }
  ]
}
EOF
}

おもむろにapplyします。

$ terraform apply
<snip>
Apply complete! Resources: 1 added, 0 changed, 0 destroyed.

無事にIAM Roleが作成されました。それでは、定義を変更せずにplanを実行してみます。

$ terraform plan
<snip>
  ~ aws_iam_role.dd
      assume_role_policy: "{\"Version\":\"2012-10-17\",\"Statement\":[{\"Sid\":\"\",\"Effect\":\"Allow\",\"Principal\":{\"AWS\":\"arn:aws:iam::123451234512:root\"},\"Action\":\"sts:AssumeRole\"}]}" => "{\n  \"Version\": \"2012-10-17\",\n  \"Statement\": [\n    {\n      \"Sid\": \"\",\n      \"Effect\": \"Allow\",\n      \"Principal\": {\n        \"AWS\": \"123451234512\"\n      },\n      \"Action\": \"sts:AssumeRole\"\n    }\n  ]\n}\n"

定義の変更が無いにも関わらず、差分が検出されてしまいます。

向き合い方

それではこの差分と向き合ってみましょう。今回はterraform-landscapeを利用してplanの可読性を高めています。

github.com

使い方は簡単でplanの結果をパイプでlandscapeに渡してあげるだけです。それでは実行してみます。

$ terraform plan | landscape
<snip>
~ aws_iam_role.dd
    assume_role_policy:   "Statement": [
                               {
                                 "Action": "sts:AssumeRole",
                                 "Effect": "Allow",
                                 "Principal": {
                          -        "AWS": "arn:aws:iam::123451234512:root"
                          +        "AWS": "123451234512"
                                 },
                                 "Sid": ""
                               }
                             ],
                             "Version": "2012-10-17"

Principalの値に差分が見られますね。123451234512として定義しましたが、tfstateファイルではarn:aws:iam::123451234512:rootと記録されているようです。

一方planで表示された設定は表記方法が異なるだけで、設定としては問題がなさそうです。そのためこのままapplyしても問題はなさそう。ですがこれでは都度差分を確認することとなり、精神的にも消耗しますし、最悪planをしっかりと確認しない文化が作られる可能性があります。

このようなケースで差分として検出されないために、定義ファイルを少しだけ修正してみます。

resource "aws_iam_role" "dd" {
    name = "dd"
    description = "dd"
    assume_role_policy = <<EOF
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "",
      "Effect": "Allow",
      "Principal": {
        "AWS": "arn:aws:iam::123451234512:root"
      },
      "Action": "sts:AssumeRole"
    }
  ]
}
EOF
}
`

Principalの定義をtfstateファイルに合わせ修正しています。この状態でplanを実行して差分が検出されないことを確認してみます。

$ terraform plan
Refreshing Terraform state in-memory prior to plan...
The refreshed state will be used to calculate this plan, but will not be
persisted to local or remote state storage.

aws_iam_role.dd: Refreshing state... (ID: dd)

------------------------------------------------------------------------

No changes. Infrastructure is up-to-date.

This means that Terraform did not detect any differences between your
configuration and real physical resources that exist. As a result, no
actions need to be performed.

無事に差分が検出されなくなりました。

terreformでは定義ファイルの通りにtfstateファイルへと記録されないこともあるようですね。そのため、定義ファイルで変更が無い場合でもplanの際に差分として検出されることがあります。このような差分はplanのノイズになるので排除したいところです。

この辺りはplanにヒントが多く表示されていると思われますので、やはりplanは大事です。また、planの差分としてJSONが表示されるとかなり視認性が低くツラかったのですが、terraform-landscapeの力を借りることでかなり視認性が高くなりました。

この調子で意図しない差分はどんどん排除したいと思います。

Golang dep ensureがハングした時にやったこと

はじめに

たまにGolangを使います。最近は依存関係のマネージメントツールは Glide ではなく dep を使っているのですが dep ensure が延々と終わらない状況となり、一応の解決を迎えたので備忘録です。

起こったこと

以下のようなコードを書き dep ensure をしたところ、10分以上処理が終わらない状況になりました。

package main

import (
    "fmt"
    _ "github.com/aws/aws-sdk-go"
)

func main() {
    fmt.Println("hello world")
}

depのバージョンはこちらです。

$ dep version
dep:
 version     : v0.4.1-161-g8b166f24
 build date  : 2018-05-02
 git hash    : 8b166f24
 go version  : go1.10
 go compiler : gc
 platform    : darwin/amd64
 features    : ImportDuringSolve=false

対応

まずは詳細情報を得るため dep ensure -v を実行してみたところ、以下の表示でハングしていました。

$ dep ensure -v
Root project is "xxxx/xxxx"
 1 transitively valid internal packages
 1 external packages imported from 1 projects
(0)   ✓ select (root)

この状態でどのようなプロセスが起動されているのか調査した結果がこちらです。

$ pstree

 |       \-+= 94723 ginyon dep ensure -v
 |         \-+= 94805 ginyon git ls-remote ssh://git@github.com/aws/aws-sdk-go
 |           \--- 94806 ginyon /usr/bin/ssh git@github.com git-upload-pack '/aws/aws-sdk-go'

試しに git ls-remote ssh://git@github.com/aws/aws-sdk-go を叩いたところホストキーの受け入れを確認されました。

$ git ls-remote ssh://git@github.com/aws/aws-sdk-go
The authenticity of host 'github.com (192.30.255.112)' can't be established.
RSA key fingerprint is SHA256:nThbg6kXUpJWGl7E1IGOCspRomTxdCARLviKw6E5SY8.
Are you sure you want to continue connecting (yes/no)?

yes としてホストキー受け入れ後、再度 dep ensure -v を実行した結果がこちらです。

$ dep ensure -v
Root project is "xxxx/xxxx"
 1 transitively valid internal packages
 1 external packages imported from 1 projects
(0)   ✓ select (root)
(1) ? attempt github.com/aws/aws-sdk-go with 1 pkgs; 410 versions to try
(1)     try github.com/aws/aws-sdk-go@v1.13.54
(1) ✓ select github.com/aws/aws-sdk-go@v1.13.54 w/1 pkgs
  ✓ found solution with 1 packages from 1 projects

Solver wall times by segment:
     b-source-exists: 5.001233802s
         b-list-pkgs: 3.256008604s
              b-gmal: 3.198661178s
     b-list-versions: 2.016181548s
             satisfy:  13.335691ms
         select-atom:  11.367302ms
            new-atom:     82.554µs
         select-root:     61.103µs
               other:     18.659µs
  b-deduce-proj-root:      2.296µs

  TOTAL: 13.496952737s

(1/1) Wrote github.com/aws/aws-sdk-go@v1.13.54

無事に導入できました。というメモです。

ElasticBeantalkで作成されるリソース間の参照方法

はじめに

今年はブログたくさん書きたいなぁと思っていたらもう4月でした。
これで今年2本目のブログです。やばい。

直近でAWSのElasticBeanstalkで使える ebextensions について調べたのでメモしておきます。

ebextensionsのこと

そもそもみなさんはElasticBeanstalkを使ったことはありますか? docs.aws.amazon.com

たまにPaaSと表現されたりもしますが、僕はAWSリソースのプロビジョニングとアプリケーションのデプロイを統合して面倒を見てくれる、それはそれは素敵で業の深いサービスだと思っています。
ElasticBeanstalkの酸いも甘いも経験された方、一杯飲みに行きましょう。

さて、肝心の ebextensions について。

これはアプリケーションのソースコード内に .ebextensions というフォルダを作り、そこへ xxx.config という感じで命名した設定ファイルを放り込むことで、環境設定、パッケージの導入、CloudFormation記法を使ってのリソース作成、などが出来る控えめに言って黒魔法な存在です。
個人的にはあまり黒魔法すぎない程度(パッケージの導入やロケールの設定)に使うと良いと思っているのですが、バランス良く使うのはとても難しいです。

そんな ebextensions の、リソース作成にフォーカスした話題です。

本題

ElasticBeanstalkで作成されるリソース間の参照方法をまとめたいと思います。
まずElasticBeanstalkのリソースには、以下の2つのタイプが存在する事を意識しておくと良いです。

  • ElasticBeanstalkによって作成される標準リソース
  • ebextensionsResources プロパティによって作成されるカスタムリソース

標準リソースはこちらのドキュメントを参考にしてください。 docs.aws.amazon.com

カスタムリソースはこちらのドキュメントを参考にしてください。 docs.aws.amazon.com

さて、もちろんAWSリソースを作成する際には依存関係のあるリソース定義も必要になります。
この時は、標準リソースであっても、カスタムリソースであってもリソース情報を参照する術が必要となります。

上述したドキュメントには両者の答えとなる素晴らしいサンプルが記述されています。
そちらを抜粋したのが下記となります。

Resources:

  hooktopic:
    Type: AWS::SNS::Topic
    Properties:
      Subscription:
        - Endpoint: "my-email@example.com"
          Protocol: email

  lifecyclehook:
    Type: AWS::AutoScaling::LifecycleHook
    Properties:
      AutoScalingGroupName: { "Ref" : "AWSEBAutoScalingGroup" }
      LifecycleTransition: autoscaling:EC2_INSTANCE_TERMINATING
      NotificationTargetARN: { "Ref" : "hooktopic" }
      RoleARN: { "Fn::GetAtt" : [ "hookrole", "Arn"] }

これはLifeCyclehookとSNS Topicの作成例です。

着眼していただきたいのは、カスタムリソースの lifecyclehook 作成部分になります。
ここには、標準リソースの参照方法と、カスタムリソースの参照方法のエッセンスが詰まっています。

標準リソースの参照方法

カスタムリソースの lifecyclehook を作成する為に、ElasticBeanstalkが作成する標準リソースの AutoScalingName が必要としています。

ここの部分です。

      AutoScalingGroupName: { "Ref" : "AWSEBAutoScalingGroup" }

通常 AutoScalingName はElasticBeanstalkにより作成されるため不定の値となるので、これを標準リソース AWSEBAutoScalingGroup から Ref 関数で参照するようにしています。

つまり、標準リソースからは Ref 関数を用いることでリソース情報を参照できます。

カスタムリソースの参照方法

またまたカスタムリソースの lifecyclehook を作成する部分を見ていくと、今度はカスタムリソースの hooktopic を必要としている記述があります。

ここの部分です。

      NotificationTargetARN: { "Ref" : "hooktopic" }

これは直前で定義されている以下 hooktopic のARNを Ref で参照しています。

  hooktopic:
    Type: AWS::SNS::Topic
    Properties:
      Subscription:
        - Endpoint: "my-email@example.com"
          Protocol: email

また、データを参照する方法は Ref 以外にもあります。

以下のように Fn::GetAtt を使って各リソースのアトリビュートも取得可能です。(この辺りはCloudFormationの世界ですね)

      RoleARN: { "Fn::GetAtt" : [ "hookrole", "Arn"] }

つまり、カスタムリソースからは RefFn::GetAtt でリソース情報が参照できます。

WAFを適用するスニペット

最後に、ググってもヒットしなかったのでElasticBeanstalkにWAFを適用できるスニペットを貼っておきます。

Resources:
  webACLAssociation:
    Type: AWS::WAFRegional::WebACLAssociation
    Properties:
      ResourceArn: { "Ref": "AWSEBV2LoadBalancer" }
      WebACLId: { "Ref": "wafACLId" }

AWSEBV2LoadBalancerは標準リソースからの参照です。
wafACLIdは別途パラメーターに定義するなどしてご利用ください。

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をすることがあれば思い出したいポイントですね。