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
というプラグイン作ったので、これを利用します。
ServerlessFramework の環境を用意します。
$ sls create -t aws-nodejs --name cloudfront-resize
serverless.yml
ここで Lambda の設定と権限を作ります。
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
コードに埋め込みたい環境変数はこちらで管理します。
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 を修正
'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 に保存します。
'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
Lambda Function ARN はバージョンも含めた完全なものを指定する必要があります。
これですべての設定は完了です。おつかれさまでした。