AWS CloudFront + Lambda@Edge를 이용한 이미지 리사이징
배경
서비스를 운영할 때 이미지를 썸네일, 배너, 배경 등 다양한 용도와 사이즈로 이용하게 된다. 이때 원본 크기대로 이미지를 불러오면 이미지 로딩 시간이 늘어나 좋지 않은 UX를 제공하게 되고, 다양한 크기의 이미지를 각각 저장해서 사용하게 스토리지 용량을 낭비하게 된다.
이 때 도입할 수 있는 방법이 이미지를 요청할 때 원하는 크기, 포맷으로 이미지를 실시간(on the fly)으로 리사이징 하여 받는 방법이다.
이번 포스팅 에서는 AWS CloudFront + Lambda@Edge를 활용해 on-the-fly 이미지 리사이징 처리를 만들어 보자.
CloudFront & Lambda@edge
Lambda@Edge는 CloudFront의 동작을 조작하는 Lambda 함수를 실행할 수 있는 서비스이다. Lambda@Edge로 구성되는 Lambda 함수는 ua-east-1에만 배포 가능하며, ua-east-1에 배포된 Lambda 함수가 CloudFront의 edge location에 복제되어 배포 & 실행되는 구조다.
Lambda@Edge가 트리거 되는 CloudFront의 이벤트는 아래 4가지이다. 참고: AWS 문서
- origin-request: CloudFront에서 오리진으로 요청을 전달할 때만 실행. 요청한 객체가 CloudFront 캐시에 있으면 함수가 실행되지 않음
- origin-response: CloudFront가 오리진으로부터 응답을 받은 후 응답에 객체를 캐시하기 전에 실행
- viewer-request: CloudFront가 최종 사용자로부터 요청을 수신하면 요청된 객체가 CloudFront 캐시에 있는지 확인하기 전에 실행
- viewer-response: 요청된 파일을 뷰어에 반환하기 전에 실행. 파일이 이미 CloudFront 캐시에 있는지 여부에 관계없이 함수가 실행됨
만약, 캐싱에 관계없이 모든 요청에 함수를 실행하고 싶다면, viewer-request
나 viewer-response
를 사용해야 한다. origin-request
와 origin-response
는 요청된 객체가 엣지 로케이션에 캐싱되어 있지 않아 CloudFront가 오리진에 요청을 전달하는 경우에만 실행되기 때문이다.
함수가 오리진의 응답에 영향을 주는 방식으로 요청을 변경한다면 origin-request
를 사용해야 하며, 함수가 캐싱의 기준으로 사용중인 값을 변경(ex. 서비스 언어)해야 한다면 viewer-request
이벤트를 사용한다.
이미지 리사이징 함수는 오리진 응답의 형태에는 영향이 없고, 함수의 결과가 캐싱되어야 최적화를 이룰 수 있으니 origin-response
이벤트를 사용한다.
on-the-fly 이미지 리사이징 동작 과정
- 클라이언트가 이미지 url의 쿼리 스트링을 옵션으로 하여 이미지를 요청한다. ex)
https://example.com/images/thumbnail.png?w=200&f=webp
- 캐싱되지 않은 이미지라면, CloudFront가 S3로 이미지를 요청하고 응답을 받는데, 이 때 리사이징 람다 함수가 실행되어 리사이징된 이미지가 CloudFront에 캐싱`되고 클라이언트에 전달된다.
- 이미 캐싱된 이미지라면, 리사이징 처리를 다시 실행하지 않고 캐싱된 이미지가 클라이언트에 전달된다.
구현하기
이 포스팅에서는 이미지 오브젝트가 저장되어 있는 오리진 S3 버킷과, IAM 정책 및 역할 생성을 제외한 AWS 리소스 생성은 serverless.yml
에서 설정해 배포해 보겠다.
IAM 정책 및 역할 생성
먼저, Lambda를 CloudFront에 배포하고 연결하기 위한 IAM 권한을 설정한다.
정책 생성
AWS 콘솔의 IAM > 정책 > ‘정책 생성’을 선택하고, 아래와 같이 정책을 생성한다.
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "VisualEditor0",
"Effect": "Allow",
"Action": [
"iam:CreateServiceLinkedRole",
"lambda:GetFunction",
"lambda:EnableReplication*",
"cloudfront:UpdateDistribution",
"s3:GetObject",
"logs:CreateLogGroup",
"logs:CreateLogStream",
"logs:PutLogEvents",
"logs:DescribeLogStreams"
],
"Resource": "*"
}
]
}
역할 생성
AWS 콘솔의 IAM > 역할 > ‘역할 만들기’을 선택하고, 아래 옵션을 선택한 후 위에서 만들어 준 정책을 연결한다.
- 신뢰할 수 있는 엔터티 유형 : AWS 서비스
- 사용 사례: Lambda
역할의 신뢰 관계 수정
위에서 생성한 역할의 신뢰 관계를 아래와 같이 편집해 준다.
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "Statement1",
"Effect": "Allow",
"Principal": {
"Service": ["edgelambda.amazonaws.com", "lambda.amazonaws.com"]
},
"Action": "sts:AssumeRole"
}
]
}
오리진 S3 버킷 생성
이제 이미지 오브젝트들이 저장될 S3 이미지 버킷을 생성한다.
버킷의 리전은 상관없으며, 퍼블릭 액세서 차단 설정을 한다. 이후 스텝에서 CloudFront에서만 접근할 수 있도록 권한 설정을 진행할 것이다.
버킷이 만들어졌다면, 불필요한 오류가 발생하지 않도록 루트 경로에 favicon.ico
를 업로드 하고,
/images
경로에 고화질의 thumbnail.png
이미지를 업로드 한다.
이미지 리사이징 함수 작성
npm init
을 실행하고,
src/hander.js
에 이미지 리사이징 핸들러를 작성한다.
이미지 리사이징 조건은 url 쿼리 스트링 w, h, q, f
로 받아 sharp로 실행한다. nextjs의 이미지 최적화도 이 sharp 패키지를 이용한다.
Lambda@Edge 사용 시 주의점 중 하나가 이벤트 유형에 따라 response 크기나 람다 펑션의 타임아웃 시간이 제한되는데,
origin-response
의 응답 크기 제한은 1MB 이므로, 리사이징 결과가 1MB보다 크면 origin response를 반환해 준다.
"use strict";
const AWS = require("aws-sdk");
const querystring = require("querystring");
const Sharp = require("sharp");
const S3 = new AWS.S3({ region: [생성한 S3 버킷 리전] });
const BUCKET = [생성한 S3 버킷 이름];
const supportImageTypes = ["jpg", "jpeg", "png", "gif", "webp", "svg", "tiff"];
module.exports.handler = async (event, context, callback) => {
const { request, response } = event.Records[0].cf;
const params = querystring.parse(request.querystring);
// width or height variable is required
if (!params.w && !params.h) {
return callback(null, response);
}
// extract image from uri
const { uri } = request;
const [, imageName, extension] = uri.match(/\/?(.*)\.(.*)/);
if (!supportImageTypes.some((type) => type === extension)) {
updateResponse({
status: 400,
statusDescription: "Bad Request",
contentHeader: [{ key: "Content-Type", value: "text/plain" }],
body: "Unsupported image type",
});
return callback(null, response);
}
let width;
let height;
let format;
let quality;
let s3Object;
let resizedImage;
// Init sizes.
width = parseInt(params.w, 10) ? parseInt(params.w, 10) : null;
height = parseInt(params.h, 10) ? parseInt(params.h, 10) : null;
// Init quality.
if (parseInt(params.q, 10)) {
quality = parseInt(params.q, 10);
}
// Init format.
format = params.f ? params.f : extension;
format = format === "jpg" ? "jpeg" : format;
// For AWS CloudWatch.
console.log(`parmas: ${JSON.stringify(params)}`); // Cannot convert object to primitive value.
console.log(`name: ${imageName}.${extension}`); // Favicon error, if name is `favicon.ico`.
try {
s3Object = await S3.getObject({
Bucket: BUCKET,
Key: decodeURI(imageName + "." + extension),
}).promise();
} catch (error) {
console.log("S3.getObject: ", error);
return callback(error);
}
if (s3Object.ContentLength === 0) {
updateResponse({
status: 404,
statusDescription: "Not Found",
contentHeader: [{ key: "Content-Type", value: "text/plain" }],
body: "The image does not exist.",
});
return callback(null, response);
}
try {
resizedImage = await Sharp(s3Object.Body)
.resize(width, height)
.toFormat(format, {
quality,
})
.toBuffer();
} catch (error) {
console.log("Sharp: ", error);
return callback(error);
}
const resizedImageByteLength = Buffer.byteLength(resizedImage, "base64");
console.log("byteLength: ", resizedImageByteLength);
// `response.body`가 변경된 경우 1MB까지만 허용됨.
if (resizedImageByteLength >= 1 * 1024 * 1024) {
return callback(null, response);
}
updateResponse({
status: 200,
statusDescription: "OK",
contentHeader: [{ key: "Content-Type", value: `image/${format}` }],
body: resizedImage.toString("base64"),
bodyEncoding: "base64",
});
return callback(null, response);
function updateResponse(newResponse) {
response.status = newResponse.status;
response.statusDescription = newResponse.statusDescription;
response.headers["content-type"] = newResponse.contentHeader;
response.body = newResponse.body;
if (newResponse.bodyEncoding) {
response.bodyEncoding = newResponse.bodyEncoding;
}
if (newResponse.cacheControl) {
response.headers["cache-control"] = newResponse.cacheControl;
}
}
};
배포 환경 설정 (serverless.yml)
이제 루트 디렉토리에 serverless.yml
을 작성해 보자.
CloudFront Lambda@Edge를 연결하기 위해 @silvermine/serverless-plugin-cloudfront-lambda-edge 설치가 필요하다.
plugins:
- "@silvermine/serverless-plugin-cloudfront-lambda-edge"
useDotenv: true
package:
excludeDevDependencies: true
patterns:
- src/**
- node_modules/**
- "!node_modules/aws-sdk/**"
custom:
bucketName: [생성한 S3 버킷 이름] # 아래에서 버킷 이름이 여러번 쓰이기 때문에 custom 섹션에 상수로 선언
service: image-resizer-ex
frameworkVersion: "3"
provider:
name: aws
runtime: nodejs14.x
versionFunctions: true # CloudFront와 Lambda@edge는 버전으로 연결되기 때문에 필수적으로 설정해줘야 함
functions:
imageResize:
name: image-resizer-ex
handler: src/handler.handler
role: !Sub arn:aws:iam::${AWS::AccountId}:role/[생성한 IAM 역할 이름]
memorySize: 256
timeout: 30 # `origin-response`의 타임아웃 제한 30을 추가. 기본은 5초
lambdaAtEdge:
distribution: ImageResizeDistribution # 아래 resources에 선언해준 CloudFront의 Distribution Id와 같아야 함
eventType: "origin-response"
pathPattern: "images*"
resources:
Resources:
ImageResizeDistribution: # CloudFront의 Distribution Id
Type: AWS::CloudFront::Distribution
Properties:
DistributionConfig:
Origins:
- DomainName: ${self:custom.bucketName}.s3.amazonaws.com
Id: S3Origin
S3OriginConfig:
OriginAccessIdentity: ""
OriginAccessControlId: !GetAtt CloudFrontOriginAccessControl.Id
Enabled: "true"
CacheBehaviors:
- PathPattern: "images*"
TargetOriginId: S3Origin
ViewerProtocolPolicy: "allow-all"
ForwardedValues:
QueryString: true
QueryStringCacheKeys: # 이미지 리사이징 옵션을 캐시 키로 설정
- "h"
- "w"
- "q"
- "f"
DefaultCacheBehavior:
TargetOriginId: S3Origin
ViewerProtocolPolicy: "allow-all"
ForwardedValues:
QueryString: true
AllowedMethods: [HEAD, GET]
CachedMethods: [HEAD, GET]
HttpVersion: "http2"
OriginBucketPolicy: # CloudFront에서만 S3에 접근이 가능하도록 권한 업데이트
Type: AWS::S3::BucketPolicy
Properties:
Bucket: ${self:custom.bucketName}
PolicyDocument:
Statement:
- Action: s3:GetObject
Sid: "AllowCloudFrontServicePrincipalReadOnly"
Effect: Allow
Resource: !Sub arn:aws:s3:::${self:custom.bucketName}/*
Principal:
Service: cloudfront.amazonaws.com
Condition:
StringEquals:
AWS:SourceArn: !Sub arn:aws:cloudfront::${AWS::AccountId}:distribution/${ImageResizeDistribution}
CloudFrontOriginAccessControl:
Type: AWS::CloudFront::OriginAccessControl
Properties:
OriginAccessControlConfig:
Description: Default Origin Access Control
Name: !Ref AWS::StackName
OriginAccessControlOriginType: s3
SigningBehavior: always
SigningProtocol: sigv4
이제 serverless deploy
명령어 한줄이면 이미지 리사이징 함수가 배포된다. 배포된 클라우드 프론트의 도메인으로 테스트를 해 보면,
첫 요청과 다음 요청의 캐시 처리 여부도 확인할 수 있다.
참고
추가적으로, 혹시 CloudWatch로 오류 트래킹을 한다면, 꼭 region이 오류가 실행된 region이 맞는지 확인해야 한다. 😂 버지니아 북부에 두고 오류 로그 찾는다고 삽질을..
👩🏻💻 배우는 것을 즐기는 프론트엔드 개발자 입니다
부족한 블로그에 방문해 주셔서 감사합니다 🙇🏻♀️
in the process of becoming the best version of myself