개요
본 포스팅을 들어가기에 앞서 먼저 이미지 리사이징이 왜 필요한지 생각해볼 필요가 있습니다.
B2C 서비스, 특히 온라인 쇼핑몰을 접속해보면 굉장히 많은 상품 사진들을 볼 수 있습니다.
저는 국내 브랜드를 좋아하니 탑텐몰에서 아우터 항목을 한번 살펴보겠습니다.
상품들을 한 눈에 보여주니 마음에 드는 옷을 바로바로 고를 수 있겠네요.
대부분의 쇼핑몰들은 브라우저 메인 화면부터 세부 카테고리에 접속하기까지 소비자들의 이목을 끌기 위해 공통적으로 제공하는 것이 있습니다.
바로 썸네일(Thumbnail)인데요.
위 사진처럼 굳이 상품을 하나하나 클릭하지 않더라도 어떤 상품들이 있는지, 상품의 색상은 무엇인지 등의 정보를 소비자에게 노출시키기 위해 썸네일을 활용하고 있습니다.
여러 사진들 중에서 한 가지를 골라 썸네일의 크기를 한번 확인해보겠습니다.
아래 사진의 크기는 284 x 374.88입니다.
상품을 클릭한 후 상세 페이지에 들어가니 사진의 크기가 384 x 512로 커진 것을 볼 수 있습니다.
그럼 컨텐츠 관리자는 상품 이미지를 어떻게 관리해야 할까요?
접속 엔드포인트가 PC가 아닌 모바일, 패드 등의 장비라면 이보다 사이즈가 더 작을 것이고 상품의 이미지 저장을 위해 아마 S3 서비스를 사용할 테니
각 카테고리별로 상품을 하나씩 업로드 한다면 아마 이런 식으로 파일이 저장되겠네요.
만약 1000개의 상품을 가지고 있는 쇼핑몰이라고 가정해보겠습니다.
각각 100x100, 300x300, 500x500 사이즈의 사진이 필요한 경우 원본 이외에 1000장 x 3 = 3000장의 이미지에 대해 리사이징 처리하고
이를 S3에 모두 저장해야 하며, 새로운 사이즈가 필요하다면 그때마다 다시 리사이징 작업을 수행해줘야 합니다.
이렇게 사이즈별로 이미지를 모두 관리한다면 스토리지 비용 뿐 아니라 이미지를 관리하기가 너무 복잡할 것 같네요.
이러한 이슈를 해결하고 싶다면 필요시 실시간으로 이미지를 리사이징하여 클라이언트에게 제공하면 됩니다.
AWS CloudFront 서비스는 AWS의 대표적인 CDN 서비스로 원본 컨텐츠를 캐싱하여 사용자에게 더욱 빠른 응답을 제공할 수 있습니다.
CloudFront의 여러 기능 중에서 Lambda@Edge 기능을 활용하면 실시간 이미지 리사이징이 가능합니다.
Lambda@Edge란?
엣지 로케이션에서 Lambda 함수를 실행시키기 위해 CloudFront에서 제공하는 기능
이 Lambda@Edge 기능을 이해하기 위해 먼저 CloudFront에서 발생하는 4가지 이벤트에 대해 살펴보겠습니다.
• Viewer Request
Client에게 요청을 받는 시점에 함수가 실행됩니다.
AWS에서 제공하는 Viewer Request의 예제 일부분을 보면 Client의 IP, Host(CloudFront)의 정보, http method 등의 정보를 확인할 수 있습니다.
{
"Records": [
{
"cf": {
"config": {
"distributionDomainName": "d111111abcdef8.cloudfront.net",
"distributionId": "EDFDVBD6EXAMPLE",
"eventType": "viewer-request",
"requestId": "4TyzHTaYWb1GX1qTfsHhEqV6HUDd_BzoBZnwfnvQc_1oF26ClkoUSEQ=="
},
"request": {
"clientIp": "203.0.113.178",
"headers": {
"host": [
{
"key": "Host",
"value": "d111111abcdef8.cloudfront.net"
}
],
"user-agent": [
{
"key": "User-Agent",
"value": "curl/7.66.0"
}
],
"accept": [
{
"key": "accept",
"value": "*/*"
}
]
},
"method": "GET",
"querystring": "",
"uri": "/"
}
}
}
]
}
• Origin Request
Origin(S3)에 요청을 전달할 때 함수가 실행됩니다.
CloudFront와 Client에 대한 정보 이외에도 Origin에 대한 정보가 포함되어 있습니다.
각 헤더들에 대한 자세한 정보는 Lambda@Edge 이벤트 구조 를 확인하시면 됩니다.
{
"Records": [
{
"cf": {
"config": {
"distributionDomainName": "d111111abcdef8.cloudfront.net",
"distributionId": "EDFDVBD6EXAMPLE",
"eventType": "origin-request",
"requestId": "4TyzHTaYWb1GX1qTfsHhEqV6HUDd_BzoBZnwfnvQc_1oF26ClkoUSEQ=="
},
"request": {
"clientIp": "203.0.113.178",
"headers": {
"x-forwarded-for": [
{
"key": "X-Forwarded-For",
"value": "203.0.113.178"
}
],
"user-agent": [
{
"key": "User-Agent",
"value": "Amazon CloudFront"
}
],
"via": [
{
"key": "Via",
"value": "2.0 2afae0d44e2540f472c0635ab62c232b.cloudfront.net (CloudFront)"
}
],
"host": [
{
"key": "Host",
"value": "example.org"
}
],
"cache-control": [
{
"key": "Cache-Control",
"value": "no-cache, cf-no-cache"
}
]
},
"method": "GET",
"origin": {
"custom": {
"customHeaders": {},
"domainName": "example.org",
"keepaliveTimeout": 5,
"path": "",
"port": 443,
"protocol": "https",
"readTimeout": 30,
"sslProtocols": [
"TLSv1",
"TLSv1.1",
"TLSv1.2"
]
}
},
"querystring": "",
"uri": "/"
}
}
}
]
}
• Origin Response
Origin으로부터 응답을 수신한 후 실행되는 함수입니다.
{
"Records": [
{
"cf": {
"config": {
"distributionDomainName": "d111111abcdef8.cloudfront.net",
"distributionId": "EDFDVBD6EXAMPLE",
"eventType": "origin-response",
"requestId": "4TyzHTaYWb1GX1qTfsHhEqV6HUDd_BzoBZnwfnvQc_1oF26ClkoUSEQ=="
},
"request": {
"clientIp": "203.0.113.178",
"headers": {
"x-forwarded-for": [
{
"key": "X-Forwarded-For",
"value": "203.0.113.178"
}
],
"user-agent": [
{
"key": "User-Agent",
"value": "Amazon CloudFront"
}
],
"via": [
{
"key": "Via",
"value": "2.0 8f22423015641505b8c857a37450d6c0.cloudfront.net (CloudFront)"
}
],
"host": [
{
"key": "Host",
"value": "example.org"
}
],
"cache-control": [
{
"key": "Cache-Control",
"value": "no-cache, cf-no-cache"
}
]
},
"method": "GET",
"origin": {
"custom": {
"customHeaders": {},
"domainName": "example.org",
"keepaliveTimeout": 5,
"path": "",
"port": 443,
"protocol": "https",
"readTimeout": 30,
"sslProtocols": [
"TLSv1",
"TLSv1.1",
"TLSv1.2"
]
}
},
"querystring": "",
"uri": "/"
},
"response": {
"headers": {
"access-control-allow-credentials": [
{
"key": "Access-Control-Allow-Credentials",
"value": "true"
}
],
"access-control-allow-origin": [
{
"key": "Access-Control-Allow-Origin",
"value": "*"
}
],
"date": [
{
"key": "Date",
"value": "Mon, 13 Jan 2020 20:12:38 GMT"
}
],
"referrer-policy": [
{
"key": "Referrer-Policy",
"value": "no-referrer-when-downgrade"
}
],
"server": [
{
"key": "Server",
"value": "ExampleCustomOriginServer"
}
],
"x-content-type-options": [
{
"key": "X-Content-Type-Options",
"value": "nosniff"
}
],
"x-frame-options": [
{
"key": "X-Frame-Options",
"value": "DENY"
}
],
"x-xss-protection": [
{
"key": "X-XSS-Protection",
"value": "1; mode=block"
}
],
"content-type": [
{
"key": "Content-Type",
"value": "text/html; charset=utf-8"
}
],
"content-length": [
{
"key": "Content-Length",
"value": "9593"
}
]
},
"status": "200",
"statusDescription": "OK"
}
}
}
]
}
• Viewer Response
CloudFront가 Client에게 반환하기 전 실행되는 함수입니다.
{
"Records": [
{
"cf": {
"config": {
"distributionDomainName": "d111111abcdef8.cloudfront.net",
"distributionId": "EDFDVBD6EXAMPLE",
"eventType": "viewer-response",
"requestId": "4TyzHTaYWb1GX1qTfsHhEqV6HUDd_BzoBZnwfnvQc_1oF26ClkoUSEQ=="
},
"request": {
"clientIp": "203.0.113.178",
"headers": {
"host": [
{
"key": "Host",
"value": "d111111abcdef8.cloudfront.net"
}
],
"user-agent": [
{
"key": "User-Agent",
"value": "curl/7.66.0"
}
],
"accept": [
{
"key": "accept",
"value": "*/*"
}
]
},
"method": "GET",
"querystring": "",
"uri": "/"
},
"response": {
"headers": {
"access-control-allow-credentials": [
{
"key": "Access-Control-Allow-Credentials",
"value": "true"
}
],
"access-control-allow-origin": [
{
"key": "Access-Control-Allow-Origin",
"value": "*"
}
],
"date": [
{
"key": "Date",
"value": "Mon, 13 Jan 2020 20:14:56 GMT"
}
],
"referrer-policy": [
{
"key": "Referrer-Policy",
"value": "no-referrer-when-downgrade"
}
],
"server": [
{
"key": "Server",
"value": "ExampleCustomOriginServer"
}
],
"x-content-type-options": [
{
"key": "X-Content-Type-Options",
"value": "nosniff"
}
],
"x-frame-options": [
{
"key": "X-Frame-Options",
"value": "DENY"
}
],
"x-xss-protection": [
{
"key": "X-XSS-Protection",
"value": "1; mode=block"
}
],
"age": [
{
"key": "Age",
"value": "2402"
}
],
"content-type": [
{
"key": "Content-Type",
"value": "text/html; charset=utf-8"
}
],
"content-length": [
{
"key": "Content-Length",
"value": "9593"
}
]
},
"status": "200",
"statusDescription": "OK"
}
}
}
]
}
여기서 주의깊게 봐야 할 이벤트는 Origin Response 이벤트입니다.
S3로부터 응답을 수신한 후 함수가 실행되는데 아직 CloudFront에서 캐싱하기 전의 상태로, 이 과정에서 Lambda 함수를 사용하여 이미지를 리사이징 처리하여 CloudFront에 캐싱하게 됩니다.
이러한 기능이 바로 Lambda@Edge입니다.
구현
본 실습은 CloudFront와 Lambda@Edge를 활용하여 실시간으로 리사이징이 이루어지는지 확인하기 위함으로.
각 서비스별 구체적인 제약사항이나 Cost Reduction에 대한 부분은 다루지 않았다는 점 참고 바랍니다.
정책 생성
먼저 Lambda 함수를 CloudFront에서 실행하기 위한 IAM 권한을 설정합니다.
[IAM]-[정책]-[정책 생성]을 클릭합니다.
서비스를 하나하나 클릭하여 액세스 레벨을 체크하는 것보다 코드를 통해 한 번에 지정하는 것이 더 수월하기 때문에
[JSON]을 클릭한 후 아래 코드를 추가합니다.
{
"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": "*"
}
]
}
태그의 경우 선택 사항으로써 정책에 대한 간단한 설명을 추가합니다.
[요약] 부분을 확인하면 조금 전 입력한 JSON 코드를 통해 서비스에 대한 액세스 레벨이 추가된 것을 확인할 수 있습니다.
정책에 대한 이름을 지정하고 [정책 생성]을 클릭합니다.
역할 생성
조금 전 생성한 정책을 할당할 서비스를 설정하는 과정입니다.
[IAM]-[역할]-[역할 만들기]에서 Lambda를 클릭합니다.
[권한 정책 연결]에서 조금 전 생성했던 ImageResizing 권한을 선택합니다.
태그 또한 역할에 대한 간단한 설명으로 작성해줍니다.
역할 이름을 지정한 후 [역할 만들기]를 통해 역할을 생성합니다.
신뢰관계 수정
위 두 과정을 통해 정책과 역할을 생성했습니다.
정책은 '어떤 서비스에 대해 어느 정도의 Access Level을 부여할지' 를 설정하는 것과 같고,
역할은 '해당 정책을 부여할 사용자(서비스)를 누구로 할지' 와 같다고 생각하시면 됩니다.
흔히 IAM 정책을 '모자를 씌운다' 라고 표현하는데, 이 헬맷을 착용했을 때만 해당 권한을 사용할 수 있기 때문입니다.
역할을 사용하기 위해서는 이 역할을 위임할 사용자를 정해야 하는데 이때 '신뢰 관계' 를 지정하게 됩니다.
신뢰 관계를 맺은 사용자는 임시 토큰을 발급받게 되고, 역할에 연결된 권한들을 사용할 수 있습니다.
앞서 생성한 ImageResizing_Role 역할을 확인해보면 [신뢰 관계] 부분에 lambda가 설정된 것을 확인할 수 있습니다.
실습을 진행하기 위해선 lambda 이외에 lambda@edge를 사용하기 때문에 해당 신뢰 관계에 lambda@edge에 대한 부분을 추가해주어야 합니다.
[신뢰 관계 편집]을 클릭하여 .Statement.Principal.Service 부분에 edgelambda.amazonaws.com를 추가합니다.
(에러가 발생한다면 쉼표와 대괄호 등의 기입에 주의합니다.)
CloudFront 배포 생성
CloudFront 배포를 생성하기 전 먼저 S3 버킷에 이미지 파일이 업로드 돼있어야 합니다.
자세한 방법은 S3 버킷 생성, 파일 업로드 포스팅을 참고 바랍니다.
[CloudFront]-[배포 생성]을 클릭합니다.
(여기서부터 집중)
S3 Origin의 원본 이미지를 가져와 캐싱하기 때문에 먼저 [원본 도메인]에서 이미지가 업로드 돼있는 S3 버킷을 선택해줍니다.
다음은 OAI에 대한 설정입니다.
OAI(Origin Access ID)란?
OAI란, 이름 그대로 Origin에 Access가 가능한 ID 값을 의미한다.
S3에 객체를 업로드하면 각각의 객체에 액세스할 수 있는 URL이 할당됩니다.
(e.g. https://[버킷 명].s3.[리전].amazonaws.com/[객체 명])
이 URL을 그대로 사용할 수도 있지만 버킷 명이 노출되면 원본 컨텐츠에 직접 액세스할 수 있기 때문에 보안적으로 이슈가 발생할 수 있습니다.
그렇기 때문에 OAI란 식별자 값을 할당하여 이 OAI 값이 적용돼있지 않은 대상은 S3로 접근이 불가능하게 설정해주어야 안전합니다.
다시 CloudFront 서비스로 돌아가서 [CloudFront]-[보안]-[원본 액세스 ID]를 클릭합니다.
임의의 이름을 설정한 후에 [생성] 버튼을 눌러 식별자를 생성해줍니다.
OAI를 생성한 후 [CloudFront]-[배포 생성]을 클릭하여 [S3 버킷 액세스]-[예, OAI 사용]을 클릭합니다.
생성했던 OAI 값으로 설정하고 [버킷 정책]에서 [예, 버킷 정책 업데이트]를 수행하면 자동으로 S3 버킷 정책에 해당 OAI 값이 설정되게 됩니다.
다음으로 리사이징 처리를 어떻게 수행할 것인지 생각해봐야 합니다.
Client가 원본 이미지가 아닌 300x300 이미지를 조회한다고 가정하면 도메인 뒤에 파라미터 값으로 width=300와 height=300과 같은 쿼리 스트링이 필요합니다.
대충 example.com/1.jpg?width=300&height=300 이런 형식이 될 것입니다.
우리는 파일명 뒤에 ?s=100x100 과 같은 식으로 파라미터 값을 전달하여 이미지를 리사이징 시켜보도록 하겠습니다.
물론 width와 height를 모두 [지정된 쿼리 문자열 포함]에 추가시켜도 됩니다.
[캐시 키 및 원본 요청]-[Legacy cache settings]에서 [쿼리 문자열] 부분에 s 를 추가합니다.
[배포 생성]을 클릭하면 아래와 같이 CloudFront 도메인을 확인할 수 있습니다.
현재 S3에 Galaxy.jpg라는 파일을 업로드했으므로, CloudFront URL 뒤에 1.jpg 파일을 조회하여 원본 이미지를 먼저 확인합니다.
이제 리사이징 할 코드를 올리기 위한 Lambda 함수를 생성해보도록 하겠습니다.
앞서 말했듯, Lambda@Edge란 기능은 현재 버지니아 북부 리전(us-east-1)만 지원하고 있습니다.
리전을 버지니아 북부로 이동한 후 [Lambda]-[함수 생성]을 클릭합니다.
참고할 만한 자료가 대부분 Node.js로 작성되었으며, 본 실습 또한 Node.js로 진행하겠습니다.
[런타임]에서 Node.js 14.x 버전을 선택합니다.
아래 [권한] 부분에서 [기존 역할 사용] 부분에 앞서 생성했던 역할을 부여해줍니다.
함수가 생성되면 아래와 같이 코드를 업로드할 수 있는 화면이 표시된 것을 확인할 수 있습니다.
이제 해야할 것은 크게 아래 4가지입니다.
1. Cloud9 개발 환경 구성
2. Resizing을 위한 Sharp 라이브러리 설치
3. 코드 작성 후 zip 형태로 Lambda에 업로드
4. Lambda@Edge 배포
Sharp 라이브러리는 간단하게 npm install sharp 명령어로 가능합니다.
다만, Sharp 빌드 환경을 Lambda에서 진행해야 합니다. (Sharp는 외부 라이브러리이기 때문에 Cloud9이 필요)
먼저 버지니아 북부 리전으로 이동 후 [Cloud9]-[Create Environment]를 클릭합니다.
크게 변경할 사항은 없으며, Default VPC 이외의 VPC를 사용 중이라면 Network 설정 부분에서 VPC를 변경해줍니다.
생성이 완료되면 아래와 같이 코드 입력과 함께 터미널을 이용할 수 있는 화면이 표시됩니다.
좌측의 [aws]를 클릭합니다.
[aws]-[US East]-[Lambda] 아래 생성한 Lambda 함수를 클릭하여 [Download]를 클릭합니다.
[Download] 후 'Select a workspace~' 입력칸이 나타나면 demo를 입력해줍니다.
입력 후 index.js 파일이 정상적으로 import 되는지 확인합니다.
작성돼있던 코드를 모두 지우고 아래 코드를 입력해줍니다.
<your region name>과 <your bucket name>의 경우 각자의 리전과 버킷명을 입력합니다.
const querystring = require("querystring");
const AWS = require("aws-sdk");
const S3 = new AWS.S3({
region: "<your region name>"
});
const Sharp = require("sharp");
const BUCKET = "<your bucket name>";
exports.handler = async (event, context, callback) => {
const response = event.Records[0].cf.response;
const request = event.Records[0].cf.request;
const params = querystring.parse(request.querystring);
if (!params.s) { // example.com?s=100x100 의 형태가 아닐 경우 원본 이미지를 반환
callback(null, response);
return;
}
const uri = request.uri;
const imageSize = params.s.split("x");
const width = parseInt(imageSize[0]);
const height = parseInt(imageSize[1]);
const [, imageName, extension] = uri.match(/\/(.*)\.(.*)/);
const requiredFormat = extension == "jpg" ? "jpeg" : extension;// sharp에서는 jpg 대신 jpeg 확장자를 사용
const originalKey = imageName + "." + extension;
try {
const s3Object = await S3.getObject({
Bucket: BUCKET,
Key: originalKey
}).promise();
const resizedImage = await Sharp(s3Object.Body)
.resize(width, height)
.toFormat(requiredFormat)
.toBuffer();
response.status = 200;
//cache 에서 이미지를 찾지 못한 경우이기 때문에 404가 발생하지만 우리가 예상했던 동작이기 때문에 200 으로 반환
response.body = resizedImage.toString("base64");
response.bodyEncoding = "base64";
response.headers["content-type"] = [
{ key: "Content-Type", value: "image/" + requiredFormat }
];
return callback(null, response);
} catch (error) {
console.error(error);
return callback(error);
}
};
코드를 수정 후 터미널에서 아래 작업을 순서대로 진행합니다.
ec2-user:~/environment $ cd ImageResizing_Function/
ec2-user:~/environment/ImageResizing_Function $ npm init -yes
ec2-user:~/environment/ImageResizing_Function $ npm install sharp
패키지 설치가 완료되면 현재 Lambda 환경에 패키지 관련 디렉토리가 추가된 것을 확인할 수 있습니다.
이제 설치한 패키지들을 Lambda 환경에 업로드 할 순서입니다.
[Uploda Lambda]-[Directory]에서 아래 디렉토리를 선택하고 [Open]을 클릭합니다.
Directory 빌드 여부 항목에선 No를 클릭합니다.
##업로드 시 'Security Token ~ expired' 에러가 표시된다면
-> Terminal에서 aws configure 명령어 입력 후 계정정보를 입력 -> Cloud9 새로고침 후 재시도
업로드가 완료되면 [코드 소스] 부분이 아래와 같이 표시됩니다.
함수 실행에는 문제가 없으므로 다음 과정으로 넘어가면 됩니다.
우측의 [작업]-[Lambda@Edge 배포]를 클릭합니다.
만약 Lambda 함수를 버지니아 북부 리전에 생성하지 않았다면 해당 버튼은 비활성화 돼있으므로 리전을 반드시 확인합니다.
수정해줘야 할 부분은 [CloudFront 이벤트] 부분입니다.
Lambda 함수는 S3 Origin에서 원본 이미지가 반환될 때 실행되기 때문에 오리진 응답(Origin Response) 이벤트를 클릭해줍니다.
[배포]를 클릭하면 현재 Lambda 함수가 배포되고 있는 것을 확인할 수 있습니다.
이제 CloudFront 뒤에 파라미터 값을 붙여서 실시간으로 이미지가 리사이징되어 표시되는지 확인해보겠습니다.
URL/파일명.jpg?s=widthxheight와 같은 형식으로 확인이 가능합니다.
지정한 사이즈대로 이미지가 리사이징되어 표시되는 것을 확인할 수 있습니다.
마무리
이상으로 본 실습을 마치도록 하겠습니다.
S3 저장소 용량, CloudFront 트래픽 비용 등이 많이 발생하고 있다면 Lambda@Edge를 활용하여 실시간 리사이징을 구현하는 것도 좋은 방법 중의 하나가 될 것 같습니다.
P.S 부족하거나 틀린 부분은 언제든지 댓글로 남겨주시면 감사하겠습니다!
[참고]
https://docs.aws.amazon.com/ko_kr/AmazonCloudFront/latest/DeveloperGuide/lambda-event-structure.html
'AWS' 카테고리의 다른 글
[EKS] "Unhandled Error" err="couldn't get current server API group list: the server has asked for the client to provide credentials" 해결하기 (3) | 2024.10.04 |
---|---|
Session Manager를 통해 EC2 인스턴스 접속하기(feat. CentOS) (0) | 2022.09.14 |
Openswan을 활용한 AWS Site-to-Site VPN 구성하기 (0) | 2022.08.02 |
EC2에 Jenkins 구축하기 (0) | 2022.05.03 |
EC2에 harbor 설치하기 (0) | 2022.05.02 |