AWS
S3
CloudFront
lambda
APIGateway

【AWS】CloudFront+Lambda+APIGatewayでS3の画像をクエリパラメータに応じてリサイズする

More than 1 year has passed since last update.


概要

https://example.cloudfront.net/sample.png?w=200

上記のようにCloudFront経由でS3の画像を取得する際にwクエリーに値に応じて画像をリサイズするようなシステムを作ります。

主に下記記事を参考にさせていただきましたが、いくつか詰まったところがありましたのでそこを中心に自分用にまとめなおしています。

API Gatewayでサーバレスな画像リサイズAPIを作る - Qiita

また、今回もともとCloudFrontを使用していたのと事情もあってCloudFront前提で作成していますが、ゼロから最適化された画像を取得するシステムを構築するのであればFastlyなど別のCDNを使用した方がいいかもしれません。


やりたいこと


  • ページ高速化のためS3に保存している画像をCloudFront経由で最適化されたサイズで取得する

  • 一度リサイズされた画像はCloudFrontにキャッシュされている状態にし、なるべく何度もLambdaが走らないようにする

  • 取得画像のレスポンスにはCache-Controlヘッダをつけブラウザキャッシュも行われるようにする


やったこと


1. Lambdaでリサイズ用のfunctionを作成する

まず、関数の新規作成を行います。

設計図(BluePrint)にサンプルとなる関数がいくつかあるのでそれを元に作成します。

今回はs3-get-objectというS3からオブジェクトを取得するNode.jsの関数を元に作成しましたが、imagemagickを使用するimage-processing-serviceという関数もリサイズの処理が参考になりました。

ロールはS3のオブジェクトの読み取り権限があるロールを設定します。

特定のバケットにのみアクセスさせたい場合はそのようなポリシーを持ったロールにする必要があります。

最終的に下記の関数を作成しました。

'use strict';

console.log('Loading function');

const aws = require('aws-sdk');
const s3 = new aws.S3({ apiVersion: '2006-03-01' });
const im = require('imagemagick');
const fs = require('fs');

exports.handler = (event, context, callback) => {
// bucketはs3のバケットを静的に指定。keyはリクエストされたfilenameから取ってくる
const bucket = 'your_bucket_name';
const key = event.pathParameters.filename;
const params = {
Bucket: bucket,
Key: key,
};

console.log(event);

// s3からオブジェクトを取得する
s3.getObject(params, (err, data) => {
if (err) {
console.log(err);
const message = `${key}をS3から取得するのに失敗しました。`;
console.log(message);
callback(message);
} else {
// contentTypeと拡張子を取得
const contentType = data.ContentType;
const extension = contentType.split('/').pop();

// 一時的にS3から取得した画像を置く仮パスを定義
const tmpFile = `/tmp/inputFile.${extension}`;
const buffer = new Buffer(data.Body, 'base64');

// 仮パスに画像を置いて画像のサイズ情報を取得する
fs.writeFileSync(tmpFile, buffer);
const originBuffer = new Buffer(fs.readFileSync(tmpFile)).toString('base64');

im.identify(tmpFile, (err, output) => {
fs.unlinkSync(tmpFile);
if (err) {
console.log('Identify operation failed:', err);
callback(err);
} else {
console.log('Identify operation completed successfully');
const originWidth = output.width;

// 返すレスポンスを定義
const response = {
statusCode: 200,
isBase64Encoded: true,
headers: {
'Content-Type': contentType,
'Cache-Control': 'max-age=864000',
},
};

// wクエリーが取得できなければオリジンのサイズをリサイズサイズとする
// 強制的にいくつかの大きさまでリサイズしたければここで静的に定義してもいい
let eventWidth = originWidth;
if (event.queryStringParameters != null) {
eventWidth = event.queryStringParameters.w;
}

// 取得した画像サイズがリサイズサイズよりも大きければリサイズする
if (originWidth > eventWidth) {
im.resize({
srcData: data.Body,
format: extension,
width: eventWidth,
quality: 0.6,
progressive: true,
}, function(err, stdout, stderr) {
if (err) {
console.log(err);
const message = `${key}のリサイズに失敗しました。`;
console.log(message);
context.done(message, err);
} else {
// リサイズした画像のbase64形式をレスポンスボディとして追加して返す
response['body'] = new Buffer(stdout, 'binary').toString('base64');
callback(null, response);
}
});
} else {
// 元画像のbase64形式をレスポンスボディとして追加して返す
response['body'] = originBuffer
callback(null, response);
}
}
});
}
});
};


ImageMagickで画像の元のサイズを取得し、それより大きければリサイズするようにする

全ての画像について一律でリサイズしようとすると、もともとリサイズしたかったサイズよりも小さい画像もリサイズしてしまうことになります。

ページ高速化のためにリサイズしているのにそれでは本末転倒なので、元のサイズを取得するためにImageMagickのidentifyメソッドを使用しています。

もし一律でリサイズしてもよければこの辺の処理は不要になります。


APIGatewayでLambdaプロキシ統合を使用することでresponseオブジェクトを返すことができる

APIGatewayの設定についてはまた後述するのですが、Cache-ControlヘッダをつけるためにLambdaでレスポンスオブジェクトなどを返したかったのですが、なかなかこの方法がわかりませんでした。

最終的に下記公式ドキュメントでresponseオブジェクトを返す方法がわかりました。

今回bodyはbase64形式なので、isBase64Encodedプロパティはtrueにする必要があります。

API Gateway で統合レスポンスを設定する - Amazon API Gateway


メモリ、タイムアウトを下の方の基本設定で設定する

タイムアウトの初期値はたしか3秒となかなか短かったので自分は10秒に設定しています。

メモリはこれによって料金も変わってくるので様子を見て最適な値を設定します。自分のは128MBで十分そうでした。

なお処理ログについてはページ上部の方のモニタリングや、CloudWatchなどで確認します。


テストにはAPI Gateway AWS Proxyというイベントテンプレートを使用する

このイベントテンプレートのqueryStringParametersにwが、pathParametersにfilenameが入ってくることになります。

任意の値を入れてテストします。


2. APIGatewayでLambdaに繋げる用のAPIを作成する

APIの作成を行います。

新しいAPIで、エンドポイントはエッジ最適化です。


  1. アクションからリソースの作成をクリックし、 リソース名にfilenameリソースパスに{filename+}API Gateway CORS を有効にするにチェックを入れ 、作成する

  2. ANYメソッドは不要なので、アクションからメソッドの削除を行う

  3. アクションからメソッドの作成をクリックし、GETメソッドを 統合タイプにLambda関数プロキシ 、Lambdaリージョン、Lambda関数に自分が作成したものを入れ、作成する。

  4. メソッドリクエストの URLクエリ文字列パラメータにクエリ文字列の追加を行い、名前をw とする

  5. メソッドリクエストのHTTPリクエストヘッダーにヘッダーの追加を行い、 AcceptとContent-Type をそれぞれ追加する


  6. 設定のバイナリメディアタイプにimage/*を追加 する

  7. アクションからAPIのデプロイを行い、適当なステージ名を入力してデプロイする

デプロイしたステージのURLの呼び出しというところに、APIGateway用のURLが表示されます。

これをCloudFrontのオリジンとするのでコピーしておきます。


3. CloudFrontでAPIGatewayをオリジンとしたビヘイビアを作成する

まだディストリビューションを作成していなければwebで画像が保存してあるS3をオリジンとして作成します。


  1. OriginsからCreate Originを選択し、作成したAPIGatewayのURLを貼り付けます

  2. Origin Protocol PolicyをHTTPS Only、 Origin Custom HeadersにAccept: image/jpeg,image/pngContent-Type: image/jpeg,image/png を入れて作成します。

  3. BehaviorsからCreate Behaviorを選択し、 パスパターンを*.pngQuery String Forwarding and CachingをForward all, cached based on whitelistQuery String Whitelistにw とし作成します。

  4. 3と同じでパスパターンを*.jpgとしたBehaviorも作成します。


Behaviorをデフォルトのものと分けることでpng、jpgのみLambdaでのリサイズを行うようにする

パスパターンによってCloudFrontからリクエストが向かうOriginを分けることができます。

自分のプロジェクトでは画像以外にもcssやjs、mp4などもS3に置いてあるので、CloudFrontから通る全てのリクエストがLambdaリサイズに行ってしまうと困ります。

なので、パスパターンによってjpgとpngのみLambdaリサイズに行くようにしています。

なお、パスパターンは正規表現ほどの柔軟性は持っていないのでjpgとpngで分けてBehaviorを作成しています。

また、このディレクトリ配下のjpgとpngは直接S3に取りに行きたいという場合もあるかと思うのですが、そのような場合はsample/*.jpgなどとするとsampleディレクトリ配下のサブディレクトリを含むjpgのみ分けることができます。

さらに言うと.jpegや.PNGといった画像もありえますが、この辺はS3に保存する段階でフィルターできるとよいと思います。


Query String Forwarding and Cachingの設定について

CloudFrontのデフォルトのQuery String Forwarding and Cachingの設定はNone(Improves Cashing)です。

これはCloudFrontでの取得URLについたクエリを無視してキャッシュするというものです。

つまり、Noneのままだと、下記は全て同じリクエストと認識されてキャッシュされます。

https://example.cloudfront.net/sample.png

https://example.cloudfront.net/sample.png?w=200

https://example.cloudfront.net/sample.png?w=1000

サイズ200の画像はサイズ200で、サイズ1000の画像はサイズ1000でキャッシュしてほしいので、

設定をForward all, cached based on whitelistとし、wクエリーのみ区別してキャッシュするようにします。


最後に費用について

以上でリサイズAPIが完成しました!

なお、費用についてはS3、CloudFront、APIGateway、Lambdaでかかってくることになりますが、S3とCloudFrontは導入以前と特に変わりません。

導入当初こそLambdaの呼び出しカウントも数100いきましたが、ブラウザキャッシュやCloudFrontのキャッシュもあるので、その後は日に100未満程度の呼び出しカウントにおさまっています。

上記ぐらいの規模感でAPIGatewayとLambdaで月に数ドルという感じになりそうです。