CloudFormation (S3＋サムネイル画像)

## 概要
S3に画像登録したら、サムネイル画像を作成、CloudFront(CDN)で配信する環境を作成してみる。

※ S3バケットの「 `origin` 」フォルダに画像(jpg/png)をアップロードしたら、
　「 `thumb` 」フォルダに80x80px以内にリサイズしたサムネイル画像を作成する。

## 前提条件
* macOS
* Docker Toolbox ( Docker for mac )

## ファイル構成
下記のようなファイルを作成します。

```:ファイル構成
opt
　├ dis       <- サムネイル画像生成コード(Node.js)のパッケージ(zip)の格納フォルダ
　│　├ dist/thumbnail-function.v***.zip
　│    ... 
　│　
　├ docker
　│　├ aws-cli
　│　│  ├ cmd
　│　│  │ ├ code.sh
　│　│  │ └ deploy.sh
　│　│  └ Dockerfile
　│　│
　│　└ lambda
　│　   ├ cmd
　│　   │ └ thumbnail.sh
　│　   └ Dockerfile
　│
　├ src
　│  ├ thumbnail-function
　│  │  └ index.js
　│  └ template.yml
　│
　└ docker-compose.yml
```

## aws-cliコンテナの構築ファイル
### Dockerfile作成 (aws-cli)
aws-cliコマンドが使えるコンテナを構築する。

```Dockerfile:./docker/aws-cli/Dockerfile
FROM python:3.6

ARG pip_installer="https://bootstrap.pypa.io/get-pip.py"
ARG awscli_version="1.16.168"

# install aws-cli
RUN pip install awscli==${awscli_version}

# install sam
RUN pip install --user --upgrade aws-sam-cli
ENV PATH $PATH:/root/.local/bin

# install command.
RUN apt-get update && apt-get install -y less vim

# copy aws command.
COPY ./docker/aws-cli/cmd/code.sh /root/code.sh
COPY ./docker/aws-cli/cmd/lambda.sh /root/lambda.sh

# copy source code.
COPY ./src /src

WORKDIR /root
```

### コマンド作成 (code.sh)
lambda関数に登録するプログラムをアップするS3バケットを作成し、アップロードするshellスクリプト

```shell:./docker/aws-cli/cmd/code.sh
#!/bin/bash

source /root/.bashrc

if [ -z "${CODE_BUCKET}" ]; then
    echo -e "環境変数「CODE_BUCKET」が定義されていません\n"
    exit 1
fi
if [ -z "$CODE_VERSION" ]; then
    echo -e "環境変数「CODE_VERSION」が定義されていません\n"
    exit 1
fi

create() {
    aws s3 mb s3://${CODE_BUCKET}
}
upload() {
    aws s3 cp /dist/thumbnail-function.${CODE_VERSION}.zip s3://${CODE_BUCKET}/
}
clear() {
    rm -f /dist/thumbnail-function.${CODE_VERSION}.zip
}

# 引数(オプション)
while getopts ":-:" opt; do
    case "$opt" in
        -)
            case "${OPTARG}" in
                create)
                    create
                    upload
                    clear
                    exit 0 ;;
                upload)
                    upload
                    clear
                    exit 0 ;;
                *) ;;
            esac ;;
    esac
done

echo -e "Usage: code.sh [--create] [--upload]\n"
exit 1
```

### コマンド作成 (deploy.sh)
CloudFormationを実行するshellスクリプト

```shell:./docker/aws-cli/cmd/deploy.sh
#!/bin/bash

source /root/.bashrc

if [ -z "${STACK_NAME}" ]; then
    echo "環境変数「STACK_NAME」が定義されていません\n"
    exit 1
fi
if [ -z "${IMAGE_BUCKET}" ]; then
    echo "環境変数「IMAGE_BUCKET」が定義されていません\n"
    exit 1
fi
if [ -z "${CODE_BUCKET}" ]; then
    echo -e "環境変数「CODE_BUCKET」が定義されていません\n"
    exit 1
fi
if [ -z "$CODE_VERSION" ]; then
    echo -e "環境変数「CODE_VERSION」が定義されていません\n"
    exit 1
fi

YAML="template.yml"
SRC="/src/${YAML}"
FILE="/root/${YAML}"

if [ -e "${FILE}" ]; then
    rm -f ${FILE}
fi

cp ${SRC} ${FILE}
sed -i "s/<image-bucket>/${IMAGE_BUCKET}/g" ${FILE}
sed -i "s/<code-bucket>/${CODE_BUCKET}/g" ${FILE}
sed -i "s/<code-version>/${CODE_VERSION}/g" ${FILE}

aws cloudformation deploy \
    --template-file ${FILE} \
    --stack-name "${STACK_NAME}" \
    --capabilities CAPABILITY_NAMED_IAM CAPABILITY_IAM
```

## lambdaコンテナの構築ファイル
### Dockerfile作成 (lambda)
lambda関数をコンパイル＆パッケージングするコンテナを構築する。

```Dockerfile:./docker/lambda/Dockerfile
FROM amazonlinux

WORKDIR /tmp

# install the dependencies.
RUN yum -y install gcc-c++ && yum -y install findutils tar zip gzip && \
    touch ~/.bashrc && chmod +x ~/.bashrc && \
    curl -o- https://raw.githubusercontent.com/creationix/nvm/v0.33.5/install.sh | bash && \
    source ~/.bashrc && nvm install 8.10

# copy aws command.
COPY ./docker/lambda/cmd/thumbnail.sh /root/thumbnail.sh

# copy source code.
RUN mkdir -p /src && chmod -x /src
COPY ./src /src

WORKDIR /root
```

### コマンド作成 (thumbnail.sh)
`npm install`を実行し、アプリケーションをzip圧縮するスクリプト

```shell:./docker/lambda/cmd/thumbnail.sh
#!/bin/bash

source /root/.bashrc

if [ -z "$CODE_VERSION" ]; then
    echo -e "環境変数「CODE_VERSION」が定義されていません\n"
    exit 1
fi

compile() {
    mkdir -p /tmp/thumbnail-function/
    cd /tmp/thumbnail-function/
    cp /src/thumbnail-function/index.js .
    npm init -f -y;
    npm install async gm --save;
    npm install --only=prod
}
package() {
    mkdir -p /dist
    cd /tmp/thumbnail-function/
    file="/dist/thumbnail-function.${CODE_VERSION}.zip"
    zip -FS -q -r "${file}" *
    echo "export: ${file}"
}

while getopts ":-:" opt; do
    case "$opt" in
        -)
            case "${OPTARG}" in
                package)
                    compile
                    package
                    exit 0 ;;
                *) ;;
            esac ;;
    esac
done

echo -e "Usage: thumbnail.sh --package\n"
exit 1
```

## lambda関数の作成
lambda関数で実行するNode.jsのスクリプトを作成する

```javascript:./src/thumbnail-function/index.js
/**
 * AWSドキュメント - Amazon S3 で AWS Lambda を使用する
 * https://docs.aws.amazon.com/ja_jp/lambda/latest/dg/with-s3-example.html
 */
'use strict';
console.log('Loading function');

// dependencies
var async = require('async');
var AWS = require('aws-sdk');
var gm = require('gm')
    .subClass({ imageMagick: true }); // Enable ImageMagick integration.
var util = require('util');

// constants
var MAX_WIDTH  = 80;
var MAX_HEIGHT = 80;

// get reference to S3 client
var s3 = new AWS.S3();

exports.handler = function(event, context, callback) {
    // Read options from the event.
    console.log("Reading options from event:\n", util.inspect(event, {depth: 5}));
    var srcBucket = event.Records[0].s3.bucket.name;
    // Object key may have spaces or unicode non-ASCII characters.
    var srcKey    =
        decodeURIComponent(event.Records[0].s3.object.key.replace(/\+/g, " "));
    var dstBucket = srcBucket + "/thumb";
    var dstKey    = srcKey.substr(7);

    // Sanity check: validate that source and destination are different buckets.
    if (srcBucket == dstBucket) {
        callback("Source and destination buckets are the same.");
        return;
    }

    // Infer the image type.
    var typeMatch = srcKey.match(/\.([^.]*)$/);
    if (!typeMatch) {
        callback("Could not determine the image type.");
        return;
    }
    var imageType = typeMatch[1];
    if (imageType != "jpg" && imageType != "png") {
        callback('Unsupported image type: ${imageType}');
        return;
    }

    // Download the image from S3, transform, and upload to a different S3 bucket.
    async.waterfall([
            function download(next) {
                // Download the image from S3 into a buffer.
                s3.getObject({
                        Bucket: srcBucket,
                        Key: srcKey
                    },
                    next);
            },
            function transform(response, next) {
                gm(response.Body).size(function(err, size) {
                    // Infer the scaling factor to avoid stretching the image unnaturally.
                    var scalingFactor = Math.min(
                        MAX_WIDTH / size.width,
                        MAX_HEIGHT / size.height
                    );
                    var width  = scalingFactor * size.width;
                    var height = scalingFactor * size.height;

                    // Transform the image buffer in memory.
                    this.resize(width, height)
                        .toBuffer(imageType, function(err, buffer) {
                            if (err) {
                                next(err);
                            } else {
                                next(null, response.ContentType, buffer);
                            }
                        });
                });
            },
            function upload(contentType, data, next) {
                // Stream the transformed image to a different S3 bucket.
                s3.putObject({
                        Bucket: dstBucket,
                        Key: dstKey,
                        Body: data,
                        ContentType: contentType
                    },
                    next);
            }
        ], function (err) {
            if (err) {
                console.error(
                    'Unable to resize ' + srcBucket + '/' + srcKey +
                    ' and upload to ' + dstBucket + '/' + dstKey +
                    ' due to an error: ' + err
                );
            } else {
                console.log(
                    'Successfully resized ' + srcBucket + '/' + srcKey +
                    ' and uploaded to ' + dstBucket + '/' + dstKey
                );
            }

            callback(null, "message");
        }
    );
};
```

## docker-compose.yml作成
下記の内容で作成します。

```yaml:docker-compose.yml
version: '3'
services:
  aws-cli:
    container_name: 'aws-cli'
    image: aws-s3/aws-cli
    build:
      context: ./
      dockerfile: ./docker/aws-cli/Dockerfile
    tty:true
    environment:
      AWS_ACCESS_KEY_ID: ${AWS_ACCESS_KEY_ID}
      AWS_SECRET_ACCESS_KEY: ${AWS_SECRET_ACCESS_KEY}
      AWS_DEFAULT_REGION: ap-northeast-1
      AWS_DEFAULT_OUTPUT: json

  lambda:
    container_name: 'lambda'
    image: local/lambda
    build:
      context: ./
      dockerfile: docker/lambda/Dockerfile
    volumes:
      - ./dist:/dist
```

## template.yml作成
CloudFormationの設定ファイルを作成します。
※ 「`<image-bucket>`,`<code-version>`,`<code-version>`」は、shellスクリプト内で置換される

```yaml:./src/template.yml
AWSTemplateFormatVersion: 2010-09-09
Description: "create image thumnail."
Resources:

  # Lambda関数にセットするRole(役割)を定義する
  LambdaExecutionRole:
    Type: "AWS::IAM::Role"
    Properties:
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          Effect: "Allow"
          Principal:
            Service:
              - "lambda.amazonaws.com"
          Action:
            - "sts:AssumeRole"
      Path: "/lambda-role/"
      ManagedPolicyArns:
        - "arn:aws:iam::aws:policy/AmazonS3ReadOnlyAccess"
        - "arn:aws:iam::aws:policy/CloudWatchLogsFullAccess"

  # サムネイル画像を作るLambda関数を定義する
  ThumbLambdaFunction:
    Type: "AWS::Lambda::Function"
    Properties:
      Code:
        S3Bucket: "<code-bucket>"
        S3Key: "thumbnail-function.<code-version>.zip"
      Handler: "index.handler"
      Runtime: "nodejs8.10"
      MemorySize: "192"
      Timeout: "25"
      Role: !GetAtt LambdaExecutionRole.Arn

  # S3BucketからLambda関数を実行する許可を与える
  LambdaInvokePermission:
    Type: AWS::Lambda::Permission
    Properties:
      FunctionName: !GetAtt
        - ThumbLambdaFunction
        - Arn
      Action: 'lambda:InvokeFunction'
      Principal: s3.amazonaws.com
      SourceAccount: !Ref 'AWS::AccountId'
      SourceArn: arn:aws:s3:::<image-bucket>

  # S3Bucket作成
  ImageBucket:
    Type: "AWS::S3::Bucket"
    DeletionPolicy: "Retain"
    Properties:
      AccessControl: "PublicRead"
      BucketName: !Sub <image-bucket>
      NotificationConfiguration:
        LambdaConfigurations:
          - Event: "s3:ObjectCreated:*"
            Filter:
              S3Key:
                Rules:
                  - Name: "prefix"
                    Value: "origin/"
                  - Name: "suffix"
                    Value: ".png"
            Function: !GetAtt
              - ThumbLambdaFunction
              - Arn
          - Event: "s3:ObjectCreated:*"
            Filter:
              S3Key:
                Rules:
                  - Name: "prefix"
                    Value: "origin/"
                  - Name: "suffix"
                    Value: ".jpg"
            Function: !GetAtt
              - ThumbLambdaFunction
              - Arn

  # S3Bucketのポリシーを定義する
  ImageBucketPolicy:
    Type: "AWS::S3::BucketPolicy"
    Properties:
      Bucket: !Sub ${ImageBucket}
      PolicyDocument:
        Statement:
          - Action:
              - s3:GetObject
            Effect: "Allow"
            Principal: "*"
            Resource: !Sub arn:aws:s3:::${ImageBucket}/*
          - Action:
              - s3:PutObject
            Effect: "Allow"
            Principal:
              AWS: !GetAtt LambdaExecutionRole.Arn
            Resource: !Sub arn:aws:s3:::${ImageBucket}/*
          - Action:
              - s3:GetObject
            Effect: "Allow"
            Principal:
              AWS: !GetAtt LambdaExecutionRole.Arn
            Resource: !Sub arn:aws:s3:::${ImageBucket}/*

  # CloudFront
  MyDistribution:
    Type: AWS::CloudFront::Distribution
    Properties:
      DistributionConfig:
        Origins:
          - DomainName: <image-bucket>.s3.amazonaws.com
            Id: myS3Origin
            S3OriginConfig: {}
        Enabled: 'true'
        Comment: distribution for content delivery
        DefaultRootObject: index.html
        DefaultCacheBehavior:
          TargetOriginId: myS3Origin
          ForwardedValues:
            QueryString: 'true'
            Cookies:
              Forward: 'none'
          ViewerProtocolPolicy: allow-all
          MinTTL: '100'
          SmoothStreaming: 'false'
          Compress: 'true'
        PriceClass: PriceClass_All
        ViewerCertificate:
          CloudFrontDefaultCertificate: 'true'

Outputs:
  ImageBucket:
    Value: !Ref ImageBucket
    Export:
      Name: !Sub "${AWS::StackName}-ImageBucket"

  MyDistribution:
    Value: !Ref MyDistribution
    Export:
      Name: !Sub "${AWS::StackName}-MyDistribution"
```

## サーバ構築
下記コマンドにてdockerコンテナのイメージを構築します。

```bash:ターミナル
$ docker-compose build
```

## 環境設定
下記のように環境変数を定義します。
※ ここで定義した環境変数は、自動的にdockerコンテナに取り込まれます

```bash:ターミナル
$ export AWS_ACCESS_KEY_ID='xxxxxxxxxxxxx'
$ export AWS_SECRET_ACCESS_KEY='xxxxxxxxxxxxxxxxxx'
$ export STACK_NAME='sample-s3-thumb'
$ export CODE_BUCKET='sample-s3-thumb-func'
$ export IMAGE_BUCKET='sample-s3-thumb'
$ export CODE_VERSION='v1'
```

## デプロイ
CloudFormationでStackを登録してAWSの各リソースを自動生成する。

```bash:ターミナル
$ export CODE_VERSION='v1'
  docker-compose build && \
  docker-compose run --rm lambda /root/thumbnail.sh --package && \
  docker-compose run --rm aws-cli /root/code.sh --create && \
  docker-compose run --rm aws-cli /root/deploy.sh
```

## 動作確認

```
# S3 - オリジナル画像
https://******.s3-ap-northeast-1.amazonaws.com/origin/sample.png

# S3 - サムネイル画像 (80x80px以内)
https://******.s3-ap-northeast-1.amazonaws.com/thumb/sample.png

# CloudFront - オリジナル画像
https://**********.cloudfront.net/origin/sample.png

# CloudFront - サムネイル画像 (80x80px以内)
https://**********.cloudfront.net/thumb/sample.png
```

## メンテナンス
### lamda関数の更新
JavaScriptを変更して、CloudFormationでStackを更新し、lambdaのリソースを再構築する。

```bash:ターミナル
# ↓ 最新バージョン＋１を指定する
$ export CODE_VERSION='v2' && \
  docker-compose build && \
  docker-compose run --rm lambda /root/thumbnail.sh --package && \
  docker-compose run --rm aws-cli /root/code.sh --upload && \
  docker-compose run --rm aws-cli /root/deploy.sh
```

## 参考サイト

* [AWSドキュメント - Amazon S3 で AWS Lambda を使用する](https://docs.aws.amazon.com/ja_jp/lambda/latest/dg/with-s3-example.html)
* [S3の画像アップロードをトリガーにサムネイル画像を自動生成する](https://beyondjapan.com/blog/2017/04/lambda-image-auto-resize)
* [S3にcreateObjectをトリガーにLambdaを起動するCloudformationテンプレート](https://dev.classmethod.jp/cloud/cloudformation-s3-put-event/)
* [LambdaでS3にアップロードされが画像をサムネイルにしてみた](https://mediaash.com/2015/11/12/lambda-s3thumbnail/)
* [CloudFrontでマルチオリジンとCache Behavior設定してみた](https://dev.classmethod.jp/cloud/aws/cloudfront-multioriginbehavior/)

以上