온디맨드 리사이징 구현기(feat. CloudFront, Lambda@Edge)
Programming

온디맨드 리사이징 구현기(feat. CloudFront, Lambda@Edge)

 

상황

데이원 프로젝트는 매일 운동 인증샷을 남기고 캘린더로 확인할 수 있는 애플리케이션입니다. 이때 사용자가 원본이미지를 업로드하면, 캘린더에서 보여줄 썸네일 이미지(39px*39px)와 개별 이미지(390px*390px) 이미지가 필요했습니다. 

 

데이원 프로젝트 사용자 플로우

 

 

처음에는 단순히 원본, 썸네일, 개별 이미지를 모두 S3 버킷에 저장하려고 했습니다. 그러나 만약 이미지 정책이 바뀌어 썸네일 이미지가 39px*39px이 아닌 50px*50px로 바뀐다면, S3 버킷에 저장된 썸네일 이미지는 모두 바뀐 정책에 따라 마이그레이션해야 합니다. 이미지 정책은 언제든 변할 수 있기에 우리는 업로드 시 필요한 이미지를 모두 저장하는 것이 아닌, 사용자 요청이 들어올 때마다 리사이징해서 내려주는 방법을 선택했습니다.

 

사용자 요청이 들어올 때마다 이미지를 리사이징하는 방식을 온디맨드 리사이징이라고 합니다. 동작 방식은 다음과 같습니다.

온디맨드 리사이징 플로우

 

 

 

 

AWS CloudFront와 Lambda@Edge로 온디맨드 리사이징 구현하기

1. S3 퍼블릭 버킷 생성

1) 먼저 이미지를 저장할 S3 버킷을 생성합니다.

 

 

 

2) 생성한 버킷의 권한을 퍼블릭으로 설정합니다.

{
    "Version": "2012-10-17",
    "Id": "Policy1708824202108",
    "Statement": [
        {
            "Sid": "Stmt1708824193201",
            "Effect": "Allow",
            "Principal": "*",
            "Action": "s3:*",
            "Resource": "arn:aws:s3:::yeongun-image-bucket/*" // 본인의 S3 arn으로 수정
        }
    ]
}

 

 

 

3) 생성한 버킷의 액세스가 퍼블릭으로 설정된 것을 확인할 수 있습니다.

 

2. CloudFront 생성 및 캐시 정책 수정

1) S3 버킷을 생성했다면 그 다음으로 CloudFront를 생성합니다. 이때 Origin domain에는 방금 생성한 S3를 연결합니다.

 

 

2) 다음으로 캐싱 정책을 정해야 합니다. 이미지 리사이징을 위해선 기본적으로 w(=weight), h(=height) 헤더를 받아야하기에, Create cache policy를 클릭해 커스텀 캐싱 정책을 만들어야 합니다.

 

 

3) 캐싱 정책에 이름을 넣고, 쿼리 문자열에 w, h를 추가합니다. 캐싱된 이미지는 기본적으로 변경될 일이 없기에 최소 TTL, 최대 TTL, 기본 TTL 모두 최대인 31536000로 설정했습니다.

 

 

4) CloudFront 생성 화면으로 다시 돌아와 방금 생성한 캐싱 정책을 선택합니다.

 

 

5) 마지막으로 배포 생성 버튼을 눌러 CloudFront를 생성합니다.

 

3. IAM 정책 및 역할 

1) IAM 콘솔로 들어가 정책을 생성을 클릭합니다.

 

 

2) 권한을 입력합니다. 정책 편집기에서 시각적 대신 JSON을 선택합니다. 그 후 아래 내용을 입력합니다.

 

 

3) 정책 이름은 ResizingImagePolicy으로 정한 뒤, 정책에 정의된 권한 목록을 한번 더 확인합니다.

 

 

4) 다음으로 IAM Role을 생성합니다.

 

 

5) 신뢰할 수 있는 엔터티 유형에는 AWS 서비스를, 사용 사례에는 Lambda를 설정합니다.

 

 

6) 권한 추가에서는 방금 생성한 ResizingImagePolicy을 선택합니다.

 

 

7) 역할 이름은 ResizingImageRole로 정한뒤, 설정한 권한이 제대로 추가되었는지 한번 더 확인합니다.

 

 

8) 방금 생성한 ResizingImageRole을 클릭합니다.

 

 

9) 2번째 탭인 신뢰관계를 클릭한 뒤, 아래와 같이 lambda Edge를 추가합니다.

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Principal": {
                "Service": [
                    "lambda.amazonaws.com",
                    "edgelambda.amazonaws.com"
                ]
            },
            "Action": "sts:AssumeRole"
        }
    ]
}

 

 

 

4. Lambda 생성

이제 람다를 생성할 차례입니다. 람다는 AWS 콘솔이 아닌 로컬에서 serverless framework을 통해 배포할 예정입니다. 이제 로컬에서 터미널을 열어 줍니다.

서버리스를 설치하기 위해선 먼저 Node.js와 npm이 설치되어있어야 합니다.

brew install node
npm -v

 

 

npm이 제대로 설치된 것을 확인했다면, 이제 serverless를 설치해줍니다.

npm install -g serverless

 

serverless 폴더 생성

그 다음 프로젝트 폴더를 생성합니다.

serverless

 

serverless project의 템플릿은 AWS - Node.js - Starter를 선택해줍니다.

 

 

프로젝트 폴더 명을 적어줍니다. 전 serverless-lambda-edge-image-resize로 폴더명을 넣었습니다.

 

 

그 다음 serverless framework에 로그인할 것인지 묻는데, n을 입력합니다.

 

 

마지막으로 바로 생성한 프로젝트를 배포할 것인지 묻는데, 이것도 n을 입력합니다.

 

 

서버리스 프로젝트가 정상적으로 생성된 것을 확인합니다.

 

 

서버리스 프로젝트를 생성하면 아래 4개의 파일이 만들어집니다.

  • index.js : 람다에서 수행될 비즈니스 로직이 작성될 파일
  • serverless.yml : 람다 배포 시 필요한 셋팅 파일

 

 

node 모듈 관리를 위해 npm init을 실행합니다. package name부터 시작해 여러 설정을 입력할 수 있는데, 전부 디폴트 값을 사용해도 되기에 엔터를 눌러줍니다. 마지막에 package.json 파일을 확인한 뒤 yes를 입력합니다.

npm init

 

 

다시 폴더를 확인해보면 package.json 파일이 생성된 것을 확인할 수 있습니다.

 

 

 

5. 간단한 콘솔 출력 Lambda 배포

람다가 제대로 동작하는지 확인하기 위해 간단히 console.log()를 남기는 람다를 배포해봅시다.

index.js를 아래와 같이 수정합니다.

export const imageResize = async (event, context) => {
    console.log("serverless-lambda-edge-image-resize 실행!");
    console.log(JSON.stringify(event));
    const res = event.Records[0].cf.response;

    return res;
};

 

 

package.json에 "type": "module"을 추가합니다.

{
  "name": "serverless-lambda-edge-image-resize",
  "version": "1.0.0",
  "type": "module", // 추가
  "description": "",
  "main": "index.js",
  "scripts": {
	"test": "echo \"Error: no test specified\" && exit 1"
  },
  "author": "",
  "license": "ISC"
}

 

 

플러그인 설치

serverless-lambda-edge-pre-existing-cloudfront 플러그인을 설치합니다.

npm install --save-dev serverless-lambda-edge-pre-existing-cloudfront

 

 

플러그인 설치 후 package.json에 보면 devDependencies가 추가된 것을 확인할 수 있습니다.

>> cat package.json
{
  "name": "serverless-lambda-edge-image-resize",
  "version": "1.0.0",
  "type": "module",
  "description": "",
  "main": "index.js",
  "scripts": {
	"test": "echo \"Error: no test specified\" && exit 1"
  },
  "author": "",
  "license": "ISC",
  "devDependencies": {
	"serverless-lambda-edge-pre-existing-cloudfront": "^1.2.0"
  }
}

 

 

배포 전 마지막으로 serverless.yml 파일도 수정합니다.

service: serverless-lambda-edge-image-resize
frameworkVersion: '3'
plugins:
  - serverless-lambda-edge-pre-existing-cloudfront

provider:
  name: aws
  runtime: nodejs20.x
  region: us-east-1
  iam:
    role: 'arn:aws:iam::982881663579:role/ResizingImageRole' // 방금 생성한 IAM role arn 입력

functions:
  imageResize:
    name: 'serverless-lambda-edge-image-resize-yeongun'
    handler: index.imageResize
    events:
      - preExistingCloudFront:
          distributionId: ESM5EZ6YH37CY // 본인의 CloudFront Id 입력
          eventType: origin-response
          pathPattern: '*'
          includeBody: false
  • provider.runtime : nodejs20.x
  • provider.region : us-east-1 (Lambda@Edge는 오직 버지니아북부(us-east-1) 리전만 가능합니다.)
  • provider.iam.role : 방금 생성한 IAM Role의 arn을 입력합니다.
  • functions.imageResize.handler : index.js의 imageResize 함수를 바라보도록 입력합니다.
  • functions.imageResize.events.preExistingCloudFront.distributionId : CloudFront의 id를 입력합니다.

 

AWS configure 설정

저는 개인 AWS를 포함한 여러 계정을 사용하고 있으므로 --profile 옵션을 통해 AWS configure 설정을 등록하겠습니다.

aws configure --profile testUser1

 

 

그 다음 severless 터미널에서 방금 생성한 프로필로 환경변수를 설정합니다.

export AWS_DEFAULT_PROFILE=testUser1

 

 

AWS profile이 제대로 바뀌었는지 확인하기 위해 S3 버킷 리스트를 한번 출력해봅시다.

>> aws s3 ls --profile testUser1
2024-03-01 12:25:41 yeongun-image-bucket

 

바뀐 계정은 다른 터미널에선 적용되지 않고, 현재 열린 터미널에서만 적용됩니다.

 

 

서버리스 배포

이제 간단한 로그를 출력하는 람다를 배포해봅시다.

serverless deploy

 

us-east-1 리전에서 람다를 확인하면 방금 생성한 람다를 확인할 수 있습니다.

 

 

2번째 탭인 테스트 탭을 클릭해 간단한 이벤트를 날려봅시다.

템플릿을 CloudFront Modify QueryString으로 합니다.

 

 

테스트 버튼을 클릭한 뒤, 세부정보를 열면 함수가 제대로 실행된 것을 확인할 수 있습니다.

 

6. 이미지 리사이징 Lambda 배포

간단한 람다 테스트가 끝났습니다. 이제 이미지 리사이징 람다를 배포해봅시다.

index.js를 아래와 같이 수정합니다.

'use strict';

import Sharp from "sharp"
import { S3Client, GetObjectCommand } from "@aws-sdk/client-s3"
const S3 = new S3Client({
    region: 'ap-northeast-2'
});

const getQuerystring = (querystring, key) => {
    return new URLSearchParams("?" + querystring).get(key);
}


export const imageResize = async (event, context) => {
    console.log("imageResize 실행!!");

    const { request, response } = event.Records[0].cf;

    const querystring = request.querystring;
    if(!querystring){
        console.log("querystring is empty!! return origin");
        return response;
    }

    const uri = decodeURIComponent(request.uri);

    const extension = uri.match(/(.*)\.(.*)/)[2].toLowerCase();
    console.log("extension", extension);

    if(extension === 'gif'){
        console.log("extension is gif!! return origin");
        return response;
    }

    const width = Number(getQuerystring(querystring, "w")) || null;
    const height = Number(getQuerystring(querystring, "h")) || null;
    const fit = getQuerystring(querystring, "f");
    const quality = Number(getQuerystring(querystring, "q")) || null;
    console.log({
        width,
        height,
        fit,
        quality
    });

    const s3BucketDomainName = request.origin.s3.domainName;
    let s3BucketName = s3BucketDomainName.replace(".s3.ap-northeast-2.amazonaws.com", "");
    s3BucketName = s3BucketName.replace(".s3.amazonaws.com", "");
    console.log("s3BucketName", s3BucketName);
    const s3Path = uri.substring(1);

    let s3Object = null;
    try{
        s3Object = await S3.send(new GetObjectCommand({
            Bucket: s3BucketName,
            Key: s3Path
        }));
        console.log("S3 GetObject Success");
    }catch (err){
        console.log("S3 GetObject Fail!! \n" +
            "Bucket: " + s3BucketName + ", Path: " + s3Path + "\n" +
            "err: " + err);
        return err;
    }

    const s3Uint8ArrayData = await s3Object.Body.transformToByteArray();

    let resizedImage = null;
    try{
        resizedImage = await Sharp(s3Uint8ArrayData)
            .resize({
                width: width,
                height: height,
                fit: fit
            })
            .toFormat(extension, {
                quality: quality
            })
            .toBuffer();
        console.log("Sharp Resize Success");
    }catch (err) {
        console.log("Sharp Resize Fail!! \n" +
            "Bucket: " + s3BucketName + ", Path: " + s3Path + "\n" +
            "err: " + err);
        return err;
    }

    const resizedImageByteLength = Buffer.byteLength(resizedImage, 'base64');
    console.log('resizedImageByteLength:', resizedImageByteLength);

    if (resizedImageByteLength >= 1048576) {
        console.log("resizedImageByteLength >= 1048576!! return origin");
        return response;
    }

    response.status = 200;
    response.body = resizedImage.toString('base64');
    response.bodyEncoding = 'base64';
    console.log("imageResize 종료!!");
    return response;
};

 

 

그 다음 아래 명령어를 차례로 입력합니다.

npm uninstall sharp
npm install --cpu=x64 --os=linux sharp

 

 

람다 함수를 다시 배포해봅니다.

serverless deploy

 

 

AWS 콘솔에서 버전을 보면 버전이 하나 올라간 것을 확인할 수 있습니다.

 

 

7. 리사이징 테스트

람다가 제대로 동작하는지 테스트해봅시다. S3 버킷에 profile_image.jpeg 파일을 올린 뒤, CloudFront가 생성한 이미지 url을 웹 브라우저에 입력합니다.

 

 

그 다음 url 끝에 ?w=100&h=100과 같이 리사이징하려는 width, height를 입력하면 리사이징이 제대로 적용된 것을 확인할 수 있습니다. 개발자도구를 통해 아직 CloudFront에 캐싱되지 않아 원본 S3 버킷에서 이미지를 가져왔고, 람다 연산이 동작한 것을 볼 수 있습니다.

 

 

새로고침하면 CloudFront는 이제 캐싱된 이미지만을 내려줍니다.

 

 

정리

이번 글에서는 CloudFront와 Lambda@Edge를 통해 온디맨드 리사이징을 구현하는 방법을 알아보았습니다. 애플리케이션을 운영하다 보면 다양한 크기의 이미지를 서빙할 필요성이 생기게 됩니다. 이때 온디맨드 리사이징을 사용하면 나중에 이미지 정책이 바뀌어도 기존 이미지를 수정하지 않아도 되는 장점이 있습니다.

 

 

 

참고 자료