使用Amazon Cloudfront+Lambda@Edge生成缩略图

191218-head-lambda

国内的云存储厂商(如阿里云,七牛云等)提供了比较方便的图片处理方案,只需要添加不同的图片访问参数值,就可以实现图片的缩放。AmazonS3并没有提供现成的方案,但我们可以通过Cloudfront+Lambda@Edge实现图片的缩放功能。

Lambda@Edge

Lambda@Edge可以认为是运行在Cloudfront上的Lambda,不同的是Lambda@Edge只支持NodejsPython两种语言,而且Lambda@Edge仅可在 美国东部 (弗吉尼亚北部) us-east-1 配置。

我们可以在客户端访问Cloudfront的4个过程中配置Lambda:

  • 查看器请求(Viewer Request):在检查是否命中Cloudfront缓存前执行,就是客户端每个请求都会执行
  • 源请求(Origin Request):当Cloudfront缓存未命中,并且请求未到达源时执行
  • 源响应(Origin Response):当Cloudfront缓存未命中,并且得到源响应后执行
  • 查看器响应(Viewer Response):当Cloudfront命中缓存或者得到了源响应后执行,就是每个客户端请求都会执行

from https://docs.aws.amazon.com/zh_cn/lambda/latest/dg/lambda-edge.html

方案架构

lambda@edge

Lambda配置

我们在这里配置了2个Lambda函数:
1.配置于源请求,用于检查请求图片地址是否合法,判断是否需要修改请求。
2.配置于源响应,用于生成缩略图。

配置于源请求的Lambda函数其实是可以省略的,此处我们希望缩略图能够统一存储,所以此函数主要用于修改请求地址,例如客户端请求图片为/a/b/c_200x200.jpg,在Lambda函数处理后,请求至S3的地址会修改为/thumbnails/200x200/a/b/c.jpg,将所有缩略图存储在thumbnails文件夹下。

流程说明

1.客户端请求Cloudfront
2.Cloudfront上未命中缓存,请求S3之前执行Lambda函数,修改请求地址
3.获取S3响应。如果S3返回图片不存在,执行Lambda
4.Lambda下载原图并生成缩略图,转存至S3,将图片返回给Cloudfront
5.Cloudfront返回结果给客户端

Lambda函数

代码地址:https://github.com/iyichen/lambda-thumbnail

源请求函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
'use strict';

const variables = {
allowedDimension : [ {w:75,h:75}, {w:200,h:200}]
};

exports.handler = (event, context, callback) => {
const request = event.Records[0].cf.request;
// fetch the uri of original image
let fwdUri = request.uri;

let pathMatchFound = false;
for (let dimension of variables.allowedDimension) {
let size = "_" + dimension.w + "x" + dimension.h;
if (fwdUri.indexOf(size) > 0) {
pathMatchFound = true;
break;
}
}

if (!pathMatchFound) {
callback(null, request);
return;
}

let prefix, imageName, width, height, extension;

const match = fwdUri.match(/(.*)\/(.*)_(\d+)x(\d+)\.(.*)/);

try {
prefix = match[1];
imageName = match[2];
width = match[3];
height = match[4];
extension = match[5];
} catch (err) {
console.log("parse error. path:" + fwdUri);
callback(null, request);
return;
}

let matchFound = false;
for (let dimension of variables.allowedDimension) {
if(dimension.w == width && dimension.h == height){
width = dimension.w;
height = dimension.h;
matchFound = true;
break;
}
}

if(!matchFound){
callback(null, request);
return;
}

fwdUri = "/thumbnails/" + width + "x" + height + prefix + "/" + imageName + "." + extension;

// console.log(fwdUri);
// /thumbnails/200x200/images/image.jpg
request.uri = fwdUri;
callback(null, request);
};

源请求函数中,我们定义了缩略图的规范(只允许200x20075x75),避免生成一些无意义的缩略图,当符合请求规则后,将请求地址修改。如请求地址为/images/image_200x200.jpg,经过函数处理后,实际请求S3的地址为/thumbnails/200x200/images/image.jpg

源响应函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
'use strict';

const http = require('http');
const https = require('https');
const querystring = require('querystring');

const AWS = require('aws-sdk');
const S3 = new AWS.S3({
signatureVersion: 'v4',
});
const Sharp = require('sharp');

exports.handler = (event, context, callback) => {
let response = event.Records[0].cf.response;

// console.log("response:" + JSON.stringify(response));

if (response.status == 403) {

let request = event.Records[0].cf.request;

// console.log("request:" + JSON.stringify(request));

// Ex: uri /thumbnails/200x200/gamely/1/def1.png
let path = request.uri;

console.log("path not exist. path:" + path);

if (!path.startsWith("/thumbnails")){
callback(null, response);
return;
}

let bucket;

let host = request.origin.s3.domainName;
let bucketIndex = host.indexOf('.s3.amazonaws.com');
if (bucketIndex > 0) {
bucket = host.substring(0, bucketIndex);
// console.log("bucket:" + bucket);
} else {
callback(null, response);
return;
}

// Ex: path variable /thumbnails/200x200/gamely/1/def1.png
let key = path.substring(1); // thumbnails/200x200/gamely/1/def1.png

// parse the prefix, width, height and image name
let originalKey, match, width, height, requiredFormat, imageKey;
let startIndex;

match = key.match(/thumbnails\/(\d+)x(\d+)\/((.*)\.(.*))/);

if (match == null || match.length != 6) {
callback(null, response);
return;
}

try {

width = parseInt(match[1], 10);
height = parseInt(match[2], 10);

imageKey = match[3];

requiredFormat = match[5].toLowerCase();
requiredFormat = (requiredFormat == "jpg" ? "jpeg" : (requiredFormat == "gif" ? "png" : requiredFormat));

originalKey = imageKey;

// console.log("args:" + originalKey + ". " + width + ". " + height + ". " + requiredFormat + ". " + imageKey);
}
catch (err) {
console.log("parse error. path:" + path);
callback(null, response);
return;
}

S3.getObject({ Bucket: bucket, Key: originalKey }).promise()
.then(data => Sharp(data.Body)
.resize(width, height)
.toFormat(requiredFormat)
.toBuffer()
)
.then(buffer => {
S3.putObject({
Body: buffer,
Bucket: bucket,
ContentType: 'image/' + requiredFormat,
CacheControl: 'max-age=2592000',
Key: key,
StorageClass: 'STANDARD'
}).promise()
.catch(() => {
console.log("Exception while writing resized image to bucket. path:" + path);
callback(null, response);
return;
});

response.status = 200;
response.body = buffer.toString('base64');
response.bodyEncoding = 'base64';
response.headers['content-type'] = [{ key: 'Content-Type', value: 'image/' + requiredFormat }];
response.headers['last-modified'] = [{ key: 'Last-Modified', value: new Date().toUTCString() }];
callback(null, response);
})
.catch( err => {
console.log("Exception while reading source image. path:" + path);
callback(null, response);
return;
});
}
else {
callback(null, response);
}
};

在源响应函数分成以下几步:
1.通过源响应的返回判断图片是否存在
2.如果图片不存在,解析请求地址,获取原图地址和尺寸
3.下载原图,生成缩略图,将缩略图传至S3
4.返回缩略图给Cloudfront

其他

设置生命周期

可以对缩略图配置生命周期,到期自动删除,可以节省存储空间

源响应函数中response.status=403

在S3中,如果用户只有s3:GetObject权限,即使文件不存在,S3出于安全默认返回的也是403;如果需要返回404状态,需要赋予s3:ListBucket权限。

Lambda提示Sharp报错

按照官网说明,指定Lambda环境为nodejs10.x,修改安装命令:

1
2
rm -rf node_modules/sharp
npm install --arch=x64 --platform=linux --target=10.15.0 sharp

Sharp输出GIF格式报错

sharp不支持gif,示例代码中是取gif第一帧图片,缩放后图片虽然后缀为.gif,其实格式为png

参考

Resizing Images with Amazon CloudFront & Lambda@Edge
Lambda@Edge Design Best Practices
Lambda@Edge开发人员指南
Leveraging external data in Lambda@Edge
Lambda@Edge gotchas and tips
Building a Real-Time Image Optimizer Using Lambda@Edge and Amazon CloudFront