본문 바로가기

AWS

[AWS Lambda] S3 Image Resize with URL Parameter (Cloudfront 요금 30% 감면)

기존에 사용하던 이미지 파일들이 너무 커서 리사이징을 해야 하는 이슈가 발생했는데

파일이 너무 많고 쓰는지 안 쓰는지 알수 없는 상황이었다.

 

그렇다고 개발팀에선 이미지 파일 리사이징 후 업로드 하는 부분에 대해서 대응할 시간은 없고 인프라쪽은 빠르게 조치를 해야하다보니

람다 function을 이용해서 처리할 방법을 고민하게 되었다.

 

찾아보다보니 lambda@edge 기능을 이용하여 S3 -> Cloudfront로 보낼때 origin response 부분에 Lambda@edge를 채워 놓고 거기서 리사이징을 한 뒤 Cloudfront로 보내면 URL 파라미터를 포함하여 캐시가 된다는 내용이 있었다.

 

그렇게 되면 개발팀에서도 굳이 별도의 기능을 구현하지 않고 URL Parameter를 사용하여 처리하면 되기 떄문에 사용이 쉽고, 인프라 상황에서도 사이즈 별로 캐시를 처리할 필요가 없기 때문에 꽤 괜찮은 기능이라 생각하여 작업하게 되었다.

 

1. S3와 Cloudfront 생성

- 사용에 필요한 S3를 생성하고 Cloudfront를 생성한 뒤 원본을 생성한 S3로 설정한다.

 

2. Cloudfront 동작에 queryString을 받을 수 있게 한다.

[캐시 정책]

[원본 요청 정책]

 

생성한 정책을 캐시와 원본 정책에 연결한다.

 

3. 람다에서 실행할 IAM Role 생성

- IAM 생성시 적정한 Role을 적용해야 한다. 나는 일단 생성하기로 했다.

// ResizingImageRole
{
    "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": "*"
        }
    ]
}

 

- 예제를 위해서는 AWSLambdaExecute Role 을 추가하였지만, 더 명확한 권한을 위해선 정확한 Resource를 지정하여 진행하자.

- 신뢰 관계를 추가하기 위해 lambda에서 실행하도록 추가한다. 단 이 기능은 Lambda@edge에서 실행되는 기능이므로 Lambda뿐만이 아닌 Lambda@edge도 추가되어야 한다.

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

 

4. Lambda 생성 준비

- 이제 람다를 생성한다. 라이브러리가 필요없다면 당연히 직접 코딩을 해도 무관하겠지만, 우린 이미지 리사이징 Library를 사용해야 하므로 sharp 라는 Library를 추가하여 작업을 진행해야 한다.

npm i -g serverless

# 원하는 파일 생성 경로
cd ~/path/to/

# 프로젝트 폴더 생성
serverless
# template -> AWS Node.js - Starter
# Project Name -> Want Name
cd /path/to/{Project Name}
npm init

index.js

//node 16 : CommonJS 방식
exports.imageResize = async function (event, context) { ~ };

//node 18 : ES6 방식
export const imageResize = async (event, context) => { ~ };

// 환경을 14에서 싫행했고 실제 테스트에선 아래와 같이 진행함.
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

//package.json
{
  "name": "serverless-lambda-edge-image-resize",
  "version": "1.0.0",
  "type": "module",
  "description": "<!-- title: 'AWS Simple HTTP Endpoint example in NodeJS' description: 'This template demonstrates how to make a simple HTTP API with Node.js running on AWS Lambda and API Gateway using the Serverless Framework.' layout: Doc framework: v3 platform: AWS language: nodeJS authorLink: 'https://github.com/serverless' authorName: 'Serverless, inc.' authorAvatar: 'https://avatars1.githubusercontent.com/u/13742415?s=200&v=4' -->",
  "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"
  }
}

이 코드가 실행되면, cloudwatch에서 로그를 확인할 수 있다.

- 추가 라이브러리 설치

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

serverless.yml

#serverless.yml

service: {service_name}
frameworkVersion: '3'
plugins:
  - serverless-lambda-edge-pre-existing-cloudfront

provider:
  name: aws
  runtime: nodejs14.x
  region: us-east-1 #lambda@edge는 북미서버에만 등록 가능하다.
  iam:
    role: 'arn:aws:iam:{account}:ResizingImageRole'

functions:
  imageResize:
    name: '{lambda_name}'
    handler: index.imageResize #index.js 파일의 imageResize 함수를 바라본다
    events:
      - preExistingCloudFront:
          distributionId: {CF_Distribute} #s3-lambda-edge-image-resize의 cloudfront id값 입력
          eventType: origin-response
          pathPattern: '*'
          includeBody: false

실 배포

User@localhost {folder} % serverless deploy     

Deploying {Service_name} to stage dev (us-east-1)
arn:aws:lambda:us-east-1:{account}:function:{Lambda_name}:1 (Event: origin-response, pathPattern: *) is associating to {CF_ID} CloudFront Distribution. waiting for deployed status.

✔ Service deployed to stack {Service_name} (101s)

functions:
  imageResize: {Lambda_name} (12 MB)

Need a faster logging experience than CloudWatch? Try our Dev Mode in Console: run "serverless dev"

https://us-east-1.console.aws.amazon.com/lambda/home?region=us-east-1#/functions 페이지로 가보면 us-east-1에 람다가 생성되었다.

최초 생성되었으면 버전은 1일 것이다. 버전을 눌러 1인지 확인하고 버전을 클릭하여 트리거에 Cloudfront가 연결되었는지 확인한다.

 

 

잘 붙어 있으면 트리거에서 내가 배포하고 싶은 Cloudfront Distribution ID와 맞는지 대조해봐야 한다. 맞으면 이미지가 잘 나오는지 테스트를 한다.

https://{cf_url}/{image_url}

일단 이미지가 나오는 건 알겠는데 로그는 어디서 확인하는지 봐야한다.

람다 테스트에서 진행한 것은 /aws/lambda/{function_name} 에 저장되며 그곳에서 확인이 가능하지만, Cloudfront에서 실행된 경우 ap-northeast-2에 저장된다는 희한한 사실이었다. 여기서 로그가 안나와서 엄청 해멨다...

/aws/lambda/us-east-1.{function_name} 로그 그룹의 버전별로 값이 확인된다.

 

코드가 정상적으로 출력되었다면 /aws/lambda/us-east-1.{function_name} 로그에 event가 출력된 로그가 나오는데 그 로그를 카피해다가 OriginResponseEvent.json파일로 만든다.

 

- 람다 테스트 환경 구축

#serverless invoke local --function 함수명 --path event.json파일경로

$ serverless invoke local --function imageResize --path OriginResponseEvent.json

** 라이브러리 설치

- 여기서 하루는 날렸지 싶다. sharp 라이브러리가 설치되어야 하는데, cpu 아키텍쳐에 따라 설치해야 하는 플랫폼이 다르다. 람다에서는 x64 arch linux platform을 이용해야 했다.

- 아래 적은 참고 글에선 windows에서 실행하는 환경이기에 win32로 했고 linux는 linux를 썻지만, 지금 테스트 환경은 macbook m1 pro이므로 둘다 적합하지 않아서 prestart 부분에선 아예 적지 않았다. 적지 않는게 맞지 않나 싶다.

 

- package.json 수정

  "scripts": {
    "prestart": "npm uninstall sharp && npm install sharp",
    "start": "serverless invoke local --function imageResize --path OriginResponseEvent.json",
    "predeploy": "npm uninstall sharp && npm install sharp --platform=linux --arch=x64",
    "deploy": "serverless deploy"
  }

index-local.js 생성

//index-local.js
'use strict';


import { imageResize } from "./index.js";
import eventJson from "./OriginResponseEvent.json" assert{ type: "json"}; //json을 가져오려면 assert를 붙여주어야한다.
import fs from "fs";


export const imageResizeToFile = async (event, context) => {
    console.log("imageResizeToFile start!!");
    event = eventJson;


    console.log("===============================");
    const response = await imageResize(event, context);
    console.log("===============================");


    const buf = new Buffer(response.body, 'base64');
    fs.writeFile('output.jpg', buf ,function(err, result) {
        if(err){console.log('error', err);}
    });

    console.log("imageResizeToFile end!!");
};

 

index.js 수정

//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 실행!!");

    //===================================================================
    //event에서 요청데이터 가져오기
    const { request, response } = event.Records[0].cf;

    //쿼리스트링 가져오기
    //쿼리스트링이 존재하지 않다면 리사이즈 처리할 필요 없으니 원본 반환
    const querystring = request.querystring;
    if(!querystring){
        console.log("querystring is empty!! return origin");
        return response;
    }

    //uri 가져오기
    const uri = decodeURIComponent(request.uri);
    //파일명이 영어인 경우 : "/samples/chun.jpeg"
    //파일명이 한글인 경우 : "/samples/%E1%84%86%E1%85%A9%E1%84%85%E1%85%A1%E1%86%AB.png"

    //파일 확장자 가져오기
    const extension = uri.match(/(.*)\.(.*)/)[2].toLowerCase();
    console.log("extension", extension);

    //gif는 sharp라이브러리 자체 이슈 존재. 리사이징하지 않고 바로 원본 반환
    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
    });


    //s3 데이터 가져오기
    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);
    //===================================================================


    //S3에서 이미지 가져오기
    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;
    }


    //람다엣지에서 응답을 만드는 경우, 응답할 수 있는 body에 크기제한이 있다.
    //base64 인코딩 텍스트로 반환하는 경우 1.3MB(1,048,576 byte)까지 가능.
    const resizedImageByteLength = Buffer.byteLength(resizedImage, 'base64');
    console.log('resizedImageByteLength:', resizedImageByteLength);

    //리사이징한 이미지 크기가 1MB 이상인 경우 원본 반환
    if (resizedImageByteLength >= 1048576) {
        console.log("resizedImageByteLength >= 1048576!! return origin");
        return response;
    }


    //리사이징한 이미지 응답할 수 있도록 response 수정
    response.status = 200;
    response.body = resizedImage.toString('base64');
    response.bodyEncoding = 'base64';
    console.log("imageResize 종료!!");
    return response;
};
# aws s3 sdk 설치
npm install @aws-sdk/client-s3
npm install run-func
serverless deploy

Cloudfront 배포가 끝나면 이제 테스트해볼 수 있다.

w=100, h=100, q=10을 주었더니, 100x100이미지에 퀄리티가 10%인 파일이 나왔다. 작은건 둘째치고 퀄리티가 낮은 파일이 출력되었다. 이제 q값을 좀 만 더 높여서 적정한 이미지로 뽑아서 쓸 수 있을거 같다.

 

로컬에서 테스트를 하려면 npm run start 하면 된다. 실행이 끝나면 output.jpg로 이미지가 저장되고 그것으로 확인해보면 된다.

 

코드가 바뀌어 업데이트 할때가 되면 npm run deploy로 배포한다.

 

 

참조 : https://lotuus.tistory.com/165

 

 

S3, CloudFront, Lambda@Edge를 이용한 이미지 리사이즈(6) - 리사이징 로직 작성 및 테스트

목차 이 게시글은 시리즈물입니다! 아래 목차를 먼저 확인해주세요 1. Lambda@Edge란? 2. S3, CloudFront 셋팅 3. CloudFront 쿼리스트링 캐시 셋팅 4. IAM 역할 생성 5. Lambda@Edge 배포 셋팅 및 로그 확인 6. 리사

lotuus.tistory.com

 

적용시 장점

1. 사이즈가 작아져 이미지 딜리버리가 빨라진다.

2. 사이즈가 작아져 네트웍 비용도 감소한다.

적용시 단점

1. 라이브러리를 잘 못 사용하면 이미지가 나오지 않는다.

'AWS' 카테고리의 다른 글

테라폼 맨땅에서 부터 적용하기 2  (0) 2023.09.11
테라폼 맨땅에서 부터 적용하기 1  (0) 2023.09.11
ngrinder 서버 세팅  (0) 2022.04.12
ELK 서버 세팅하기  (0) 2020.09.01
ubuntu apache2 letsencrypt 적용방법  (0) 2019.10.18