はじめに
某WebメディアをGoogleのPageSpeed Insightsにて解析したところ、「画像にCache-Controlが設定されてないぞ」と怒られました。メディア内の記事には大量の画像が使われていて、それらの画像はS3にアップロードされたものを直接参照しています。
CloudFront使えよと言われて片付けられそうな案件ですが、リリース時からそんな準備万端で始められるような事業ばかりでもないわけです。既存のシステムをCloudFrontで置き換えようと思うと、もちろん画像のURLも変更されます。それに伴いシステムにも改修は入りますし、記事内に埋め込まれた画像のURLを頑張って置換する必要があります。ちょっとそれ時間かかる、ということで暫定的にS3に置いたコンテンツにCache-Controlを付けてみました。
これ書いてるうちに結局CloudFrontに移行したけどまあいいや。
目次
S3に保存されてある画像にCache-Controlを付けるには、次の3つの方法があります。
- AWSコンソールから設定する
- 既にアップロード済みの画像情報を一括更新する
- ファイルアップロード時に自動的にcache-controlを付与する
今回は既にアップロード済みの画像は数千件単位であったため、2の方法で既存の画像を更新し、今後アップロードする画像のために3も設定しました。
1.AWSコンソールから設定する
ファイル数が少ない場合は、S3コンソールの[メタデータ]から設定できます。見たまんまなので難しい解説不要です。
2.既にアップロード済みの画像情報を一括更新する
さすがに数千にもなると手動で設定はできないので、コード書きました。ポイントとしては、copyObject
で同名ファイルにすることで画像を更新していることと、Metadata
/MetadataDirective
を設定しないと全く同じファイルとみなされてコピーに失敗することです。あとはPromise.allで数千件同時実行したらネットワークエラー出たので50件ずつに分割してる(50という数字は適当)。
'use strict';
const mime = require('mime-types');
const AWS = require('aws-sdk');
const s3 = new AWS.S3({apiVersion: '2006-03-01'});
// 再帰的に全ファイルを取得する
function findObjects() {
let result = [];
const _go = (marker, callback) => {
s3.listObjects({
Bucket: process.env.S3_IMAGE_BUCKET,
Delimiter: '/',
Marker: marker
}, (error, data) => {
if (error) return callback(error);
result = result.concat(data.Contents);
if (data.Contents.length >= 1000) return _go(data.NextMarker, callback);
callback(null, result);
});
};
return new Promise((resolve, reject) => {
_go(null, (error, objects) => {
if (error) return reject(error);
resolve(objects);
})
});
}
// cache-controlを全ファイルに付与
function updateMetadata(objects) {
const _go = (index, callback) => {
// 50件ずつリクエストを分割
const promises = objects.slice(index, index + 50).map((o, i) => {
return new Promise((resolve, reject) => {
s3.copyObject({
Bucket: process.env.S3_IMAGE_BUCKET,
CopySource: `${process.env.S3_IMAGE_BUCKET}/${o.Key}`,
Key: o.Key,
ContentType: mime.lookup(o.Key) || 'application/octet-stream',
CacheControl: 'max-age=31536000',
Metadata: {
updated: '1'
},
MetadataDirective: 'REPLACE'
}, (error, data) => {
if (error) return reject(error);
return resolve(null);
});
});
});
Promise.all(promises).then(results => {
if (index + results.length == objects.length) return callback(null);
_go(index + results.length, callback);
}).catch(callback);
};
return new Promise((resolve, reject) => {
_go(0, (error) => {
if (error) return reject(error);
resolve();
});
});
}
findObjects().then(objects => {
return updateMetadata(objects);
}).then(result => {
console.log("Complete!");
}).catch(error => {
console.error(error);
});
3.ファイルアップロード時に自動的にcache-controlを付与する
継続的に使うには、LambdaでS3へのUploadイベントを受けてMetaを書き換えるのが便利です。ここも先ほどとほぼ同じしくみで動かしていますが、注意しないとCopy後にもObjectCreated
のイベントが走るので無限ループに陥ります(Lambda破産ってあるのだろうか..)。幸いにも新規作成時がObjectCreated:Put
、コピー時がObjectCreated:Copy
とイベント名が分かれていたので、これで判定しています。
'use strict';
var mime = require('mime-types');
var aws = require('aws-sdk');
aws.config.update({
accessKeyId: 'xxxxxxxxxxxxxxxxxxxxxxxx',
secretAccessKey: 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'
});
var s3 = new aws.S3({apiVersion: '2006-03-01'});
// Lambda Handler
module.exports.handler = function(event, context) {
console.log(event.Records[0]);
var bucket = event.Records[0].s3.bucket.name;
var key = event.Records[0].s3.object.key;
var eventName = event.Records[0].eventName;
if (eventName !== 'ObjectCreated:Put' && eventName !== 'ObjectCreated:CompleteMultipartUpload') {
console.log('Object Not Created');
return context.done(null, 'Object Not Created');
}
s3.copyObject({
Bucket: bucket,
CopySource: bucket + '/' + key,
Key: key,
ContentType: mime.lookup(key) || 'application/octet-stream',
CacheControl: 'max-age=2592000',
Metadata: {
updated: '1'
},
MetadataDirective: 'REPLACE'
}, function(error, data) {
if (error) return context.done(error);
console.log("UPDATED!");
return context.done(null, 'COMPLETE!');
});
};
Lambdaに設定するevent sourceはこんな感じです。
まとめ
既にS3で運用してしまってる場合、(3)でLambdaを構築しつつ(コピペでも動くはず)、(2)で既存のものを置き換えるだけで作業は終わりなはずです。
そういえばLambdaがnode 4.x使えるようになったらしいから早く移行したいな。