AWS Lambda@Edge + CloudFront でサーバレス画像リサイズサーバ構築

AWS Lambda@Edge と CloudFront の組み合わせを使って画像リサイズを動的に行える環境を構築するメモ。

## 前提

- ServerlessFramework 利用
- リサイズは Sharp を利用
- 画像オリジンサーバは S3 を利用
- webp に対応している UA は webp 変換
- エンドポイント URL: https://example.com/sample/hoge.jpg?size=100x50
- S3 オリジンに保存されているパス: example-bucket/sample/hoge.jpg
- リサイズファイル保持パス: example-bucket/100x50/jpg/sample/hoge.jpg

## 手順

### ServerlessFramework

```
$ mkdir sample
$ cd sample
$ npm init
$ npm install -g serverless
$ npm install --save serverless-plugin-embedded-env-in-code
```

環境変数を埋め込むために `serverless-plugin-embedded-env-in-code` というプラグイン作ったので、これを利用します。

- https://www.npmjs.com/package/serverless-plugin-embedded-env-in-code

ServerlessFramework の環境を用意します。

```
$ sls create -t aws-nodejs --name cloudfront-resize
```

#### serverless.yml

ここで Lambda の設定と権限を作ります。

```yaml
service: cloudfront-edge
package:
  individually: true
  exclude:
    - node_modules/**
    - lambda_modules/**
provider:
  name: aws
  runtime: nodejs8.10
  region: us-east-1
  memorySize: 128
  timeout: 5
  role: LambdaEdgeRole
  logRetentionInDays: 30
  stage: ${opt:stage, 'development'}
  profile: ${self:custom.profiles.${self:provider.stage}}

plugins:
  - serverless-plugin-embedded-env-in-code

custom:
  profiles:
    development: profile-name
    production: profile-name
  otherfile:
    environment:
      development: ${file(./conf/development.yml)}
      production: ${file(./conf/production.yml)}

functions:
  storage:
    handler: storage.redirect
  resize:
    handler: resize.perform
    embedded:
      files:
        - resize.js
        - resize-func.js
      variables:
        File_Original_Url: ${self:custom.otherfile.environment.${self:provider.stage}.File_Original_Url}
        Cache_S3_Bucket: ${self:custom.otherfile.environment.${self:provider.stage}.Cache_S3_Bucket}
    timeout: 20
    memorySize: 512
    package:
      include:
        - node_modules/**

resources:
  Resources:
    LambdaEdgeRole:
      Type: AWS::IAM::Role
      Properties:
        AssumeRolePolicyDocument:
          Statement:
            - Effect: Allow
              Principal:
                Service:
                  - lambda.amazonaws.com
                  - edgelambda.amazonaws.com
              Action:
                - sts:AssumeRole
        Policies:
          - PolicyName: ${opt:stage}-serverless-lambdaedge
            PolicyDocument:
              Version: '2012-10-17'
              Statement:
                - Effect: Allow
                  Action:
                    - logs:CreateLogGroup
                    - logs:CreateLogStream
                    - logs:PutLogEvents
                    - logs:DescribeLogStreams
                  Resource: 'arn:aws:logs:*:*:*'
                - Effect: "Allow"
                  Action:
                    - "s3:PutObject"
                    - "s3:GetObject"
                    - "s3:PutObjectAcl"
                  Resource:
                    - "arn:aws:s3:::example-bucket-name/*"
```

#### conf/development.yml

コードに埋め込みたい環境変数はこちらで管理します。

```yaml
File_Original_Url: https://s3.amazonaws.com/example-bucket-name
Cache_S3_Bucket: example-bucket-name
```

### Lambda@Edge

画像のリサイズには sharp ライブラリを利用します。また S3 からのファイルダウンロードは `request-promise` を利用します。S3 SDK の `getObject` を使っても良いのですが、S3 以外のオリジンサーバを利用することを想定して通常の HTTP リクエストにしています。

```
$ npm install --save sharp request-promise
```

#### storage.js

まず CloudFront へのリクエスト時に URL をパースしてあげます。ここで行っているのは大きく2点。

- `?size=100x50` のクエリストリングをパースして、定義済みのサイズに一番近いものに正規化する
- webp 対応のブラウザの場合は webp に変換するように URL を修正

```javascript
'use strict'

const querystring = require('querystring')

const variables = {
  allowedDimension: [
    { w: 128, h: 128 },
    { w: 256, h: 256 },
    { w: 512, h: 512 },
    { w: 1024, h: 1024 },
    { w: 2048, h: 2048 },
    { w: 3072, h: 3072 },
    { w: 4096, h: 4096 }
  ],
  defaultDimension: { w: 128, h: 128 },
  variance: 20
}

const getExt = url => {
  const match = url.match(/\.((gif|jpg|jpeg|png)+)/)
  if (match) {
    return match[1]
  }
  return ''
}

const balanceSize = size => {
  let [width, height] = size.split('x')
  let matchFound = false
  let variancePercent = (variables.variance / 100)
  for (let dimension of variables.allowedDimension) {
    let minWidth = dimension.w - (dimension.w * variancePercent)
    let maxWidth = dimension.w + (dimension.w * variancePercent)
    if (width >= minWidth && width <= maxWidth) {
      width = dimension.w
      if (height) {
        height = dimension.h
      }
      matchFound = true
      break
    }
  }
  if (!matchFound) {
    width = variables.defaultDimension.w
    height = variables.defaultDimension.h
  }
  return [width, height]
}

const normalizedExt = (ext, headers) => {
  const accept = headers['accept'] ? headers['accept'][0].value : ''
  if (accept.includes('webp')) {
    url.push('webp')
  } else {
    url.push(ext)
  }
}

module.exports.redirect = (event, context, callback) => {
  const request = event.Records[0].cf.request
  const ext = getExt(request.uri)
  const params = querystring.parse(request.querystring)
  const originalUri = request.uri
  let url = []
  if (params.size) {
    const [width, height] = balanceSize(params.size)
    url.push(width + 'x' + height)
    url.push(normalizedExt(ext, headers))
  } else {
    url.push('original')
    url.push(ext)
  }
  url.push(originalUri)

  const rewriteUri = '/' + url.join('/')
  request.uri = rewriteUri
  callback(null, request)
}
```

#### resize.js

storage.js で正規化されたリクエスト URL を受け取って実際のレスポンスを返却します。すでに S3 にリサイズファイルのキャッシュがある場合はそれをそのまま返します。なければ S3 からファイルを取得して Sharp でリサイズ後に S3 に保存します。

```javascript
'use strict'
const AWS = require('aws-sdk')
const S3 = new AWS.S3({
  signatureVersion: 'v4'
})
const Sharp = require('sharp')
const request = require('request-promise')

const download = (url) => {
  return request({
    url: url,
    encoding: null
  })
}

const resize = (body, format, width, height) => {
  return Sharp(body)
    .resize(width, height)
    .toFormat(format)
    .toBuffer()
    .then(buffer => {
      return buffer
    })
}

const save = (body, format, key) => {
  return S3.putObject({
    Body: body,
    Bucket: process.env.Cache_S3_Bucket,
    ContentType: 'image/' + format,
    CacheControl: 'max-age=31536000',
    Key: key,
    StorageClass: 'STANDARD',
    ACL: 'public-read'
  }).promise()
}

const setResponse = response => {
  response.status = 200
  response.body = body.toString('base64')
  response.bodyEncoding = 'base64'
  response.headers['content-type'] = [{ key: 'Content-Type', value: 'image/' + format }]
  response.headers['cache-control'] = [{ key: 'Cache-Control', value: 'max-age=31536000' }]
  return response
}

module.exports.perform = (event, context, callback) => {
  let response = event.Records[0].cf.response
  const request = event.Records[0].cf.request
  if (response.status === '404') {
    const match = request.uri.match(/^\/(.+?)\/(.+?)\/(.+)$/)
    const size = match[1]
    const format = match[2]
    const key = match[3]
    const originalUrl = `${process.env.File_Original_Url}${key}`

    if (size === 'original' || format === 'gif') {
      download(originalUrl).then(body => {
        save(body, format, request.uri).then(() => {
          response = setResponse(response)
          callback(null, response)
        })
      })
    } else {
      resizeFunc.download(originalUrl).then(body => {
        let [width, height] = size.split('x')
        width = width !== 'undefined' ? width : undefined
        height = height !== 'undefined' ? height : undefined
        resize(body, format, width, height).then(body => {
          save(body, format, request.uri).then(() => {
            response = setResponse(response)
            callback(null, response)
          })
        })
      })
    }
  } else {
    callback(null, response)
  }
}
```

## デプロイ

Lambda@Edge は npm パッケージをバンドリしてアップロードする必要があります。Sharp ライブラリはネイティブライブラリなので Mac などでインストールしたパッケージは Lambda では利用できません。そこで Docker を使って Sharp だけ置き換えるようにします。

### Sharp を Lambda で使う

```
$ mkdir lambda_modules
$ cd lambda_modules
$ npm init
$ npm install --save sharp request-promise serverless-plugin-embedded-env-in-code
$ rm -rf node_modules/sharp
$ docker run -v "$PWD":/var/task lambci/lambda:build-nodejs8.10 npm install
$ rm -rf ../node_modules && mv ./node_modules ../
```

### ServerlessFramework デプロイ

```
$ sls deploy -v --stage development --aws-profile profile-name
```

これでようやく Lambda@Edge のデプロイが完了しました。なんか長いですね。あと一息です。

### CloudFront の設定

CloudFront のコンソールから「Behaviors」を開き作成します。

- Path Pattern: `/sample/*`
- Lambda Function Associations:
    - Origin Response: resize.js
    - Viewer Request: storage.js

![AWS_CloudFront_Management_Console.png](https://qiita-image-store.s3.amazonaws.com/0/2152/99cd2365-dd68-ce4a-9deb-dc750a9223c9.png)

Lambda Function ARN はバージョンも含めた完全なものを指定する必要があります。

これですべての設定は完了です。おつかれさまでした。