Fall in IT.

AWS S3, CloudFront, Lambda, Lambda@Edge를 활용한 이미지 리사이징 처리하기 본문

시스템구축

AWS S3, CloudFront, Lambda, Lambda@Edge를 활용한 이미지 리사이징 처리하기

D.Y 2023. 1. 1. 13:13

안녕하세요.

오늘은 AWS의 S3, CloudFront, Lambda, Lambda@Edge 기술을 활용하여 이미지 리사이징(작게..)하는 방법에 대해서 알아보도록 하겠습니다. (아래 내용에서는 이미지 리사이징을 위한 코드에 대한 설명은 하지 않고 AWS 서비스를 활용하는 방법을 중심으로 설명합니다.)

 

목적

다양한 사이즈의 이미지를 클라이언트가 사용할 수 있도록 한다.
(썸네일 이미지에 큰 사이지의 이미지를 사용할 필요는 없다. 상황에 따라 그에 맞는 이미지를 사용할 수 있도록 한다.)

 

요구사항

  • 하나의 이미지를 사용하여 다양한 사이즈의 이미지를 만들어낸다.
  • querystring을 사용하여 이미지 사이즈를 다양하게 요청이 가능하다.
    s 사이즈 설정 s=100x100 (width, height)
    q 비율 설정 1~100
    t 퀄리티 설정 fit, cover, contain, fill
    f 이미지 포맷 format (optional, webp*)
  • 모든 사이즈의 이미지를 저장해둘순 없기 때문에 필요한 시점에 만들어서 사용한다.
  • 필요한 시점에 만들어진 이미지는 캐싱처리를 하여 클라이언트에 제공한다.
  • AWS 기술을 사용하여 처리한다.

 

사용 기술

  • S3 - 원본 이미지 저장소
  • CloudFront - 콘텐츠 전송 네트워크(CDN)로 캐싱에 사용
  • Lambda - 이미지 리사이징 처리 함수
  • Lambda@Edge - CloudFront 이벤트에 Lambda 함수 연결 기능
  • IAM - Identity and Access Management의 약자로 AWS 리소스 접근 권한 부여 서비스
  • Cloud9 - 클라우드 IDE로 람다함수를 구현할때 사용

 

CloudFront 이벤트와 Lambda 함수 

cloudfront-events-that-trigger-lambda-functions

Lambda 함수를 사용하여 CloudFront의 요청 및 응답을 변경(조작?)이 가능하다. lambda 함수가 동작 가능한 시점은 아래와 같다.

  • CloudFront가 사용자의 요청을 수신한 후
  • CloudFront가 오리진 서버에 요청을 전달하기 전
  • CloudFront가 오리진 서버로부터 응답을 수신한 후
  • CloudFront가 사용자에게 응답을 전달하기 전

우리는 Origin response 즉, CloudFront가 오리진 서버로부터 응답을 수신한 후 이미지 리사이징 함수가 동작하도록 처리하고자 한다. 그리고 리사이징된 이미지는 오리진 서버에는 저장하지 않고 캐시 서버(CloudFront)에만 저장해놓으려한다.

리사이징 된 이미지가 CloudFront에 캐시되어 있다면 오리진 서버에 별도의 요청없이 유저에게 곧 바로 리사이징된 이미지가 전달될 것이다.

 

Image Reszing Architecture

image-resizing-architecture

이미지 리사이징 흐름은 아래와 같습니다.
(최초에 유저가 요청하는 이미지는 S3 버킷에 업로드되어 있음을 가정합니다)

  1. 유저는 이미지를 요청한다. (CloudFront로 제공되는 이미지)
    예를들면, www.test.com/mango.jpg?s=100x100 주소로 요청한다. 
  2. CloudFront(edge location)에 캐시된 이미지가 있을 경우(Cache hit) 유저에게 이미지를 리턴한다.
  3. CloudFront(edge location)에 캐시된 이미지가 없을 경우(Cache miss) 오리진 서버(S3)에 원본 이미지를 요청한다.
  4. CloudFront가 오리진 서버로부터 원본 이미지를 응답받으면 이미지 리사이징 Lambda 함수가 동작한다.
    (만약, 리사이징되지 않은 이미지를 요청하였다면 원본 이미지가 응답된다.)
  5. 리사이징 된 이미지는 캐시로 저장되고 유저에게 응답된다.

 

구축방법

단계

  1. IAM 정책 및 역할 생성
  2. S3 버킷 생성
  3. CloudFront 생성
  4. Lambda 함수 생성
  5. Cloud9 생성
  6. Lambda@Edge 연동 (CloudFront와 Lambda 함수 연동)

 

1. IAM 정책 및 역할 생성

IAM 정책은 AWS 리소스에 접근할 권한을 정의하는것이고, 역할은 정의한 정책을 참조하여 권한을 부여하는 방법이다.

Lambda@Edge와 연결할 람다함수의 정책을 먼저 만들어보자.

 

IAM 정책 생성 방법은, IAM에 접속해서 우측 상단에 정책 생성을 선택한다.

AWS Identity and Access Management (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": "*"
        }
    ]
}

IAM 정책을 만들었다면 이번엔 역할을 만들어보자. 역할 생성은 좌측에 액세스 관리 탭에서 역할을 선택 후 역할 만들기를 누른다.

엔티티 유형으로 AWS 서비스를 선택하고 Lambda 서비스를 선택하고 다음 버튼을 누른다.

두 번째 단계에서는 조금 전에 만들어두었던 정책을 검색하고 선택한다. 

마지막 단계에서는 사용할 역할 이름을 적고 역할을 생성합니다. 해당 역할은 람다 함수에서 연결하여 사용합니다.

마지막으로 역할을 사용하기 전에 역할의 신뢰 관계를 수정해야하기 때문에 생성한 역할을 선택하고, 아래 내용을 추가합니다.

 

2. S3 버킷 생성

S3 버킷을 생성하고 dev, release 폴더를 만들어줍니다. 

S3 버킷을 만들땐 보안을 위해서 S3 Path를 통해 직접 접근은 불가능하도록하고 CloudFront URL로만 접근 가능하도록 생성합니다.

즉, ACL은 활성화하고 모든 퍼블릭 엑세스를 차단합니다. 

그리고 dev 폴더 안에는 테스트할 이미지를 업로드하고 주소에 접속해보면 아래와 같이 접근이 거부된다.

 

3. CloudFront 생성

이제 S3버킷과 연동할 CloudFront를 생성합니다. 원본 엑세스 제어 설정(권장)을 선택한다.

 

HTTP로 접속할 경우 HTTPS로 Redirect 되도록 설정한다.

이후에 이미지 리사이징을 할때 사용할 쿼리스트링을 등록한다.

CloudFront가 생성되면 원본 탭에서 편집을 선택한다. 연결된 S3 버킷에 엑세스하기위한 정책을 등록하기위해 정책 복사를 누르고 S3 버킷 권한으로 이동한 후 버킷 정책을 수정한다.

수정된 버킷의 내용은 아래와 같다. 이제 CloudFront 주소로 S3버킷의 파일에 접근할 경우 잘 접근되는걸 확인할 수 있다.

 

4. Lambda 함수 생성

Lambda@Edge로 사용할 람다함수는 버지니아 북부(us-east-1)에 만들어진 함수로만 사용할 수 있기 때문에 버지니아 북부로 리전을 변경합니다.

함수의 런타임으로는 Node.js 14.x를 사용하고, IAM에서 이전에 만들어둔 역할을 설정합니다.

(람다 함수의 제한시간은 기본 3초인데 람다함수에 따라서 적절히 조정합니다.)

 

5. Cloud9 생성

생성한 람다함수를 연결하기 위해서 같은 리전인 버지니아 북부(us-east-1)에서 생성합니다.
(다른 리전에 생성하면 버지니아 북부에 만든 람다함수에 Download, Upload 할 수 없습니다.)


간단한 코드를 작성할것이기 때문에 t3.nano EC2 인스턴스를 사용합니다. 이외에 다른 설정은 변경하지 않고 생성합니다.

생성된 Cloud9 환경에 접속하고 만들어둔 람다함수를 연결(Download)합니다.

연결된 람다함수에 패키지를 설치하기 위해서 npm init 명령어를 사용하고 이미지 리사이징을 하기 위해 사용할 라이브러리인 sharp 패키지를 설치합니다. 다운로드 받은 폴더 경로로 이동한 후 실행합니다.

$ npm init -y
$ npm i sharp

그리고, 미리 작성한 이미지 리사이징 코드인 아래 코드를 입력합니다. (S3버킷의 리전정보와 버킷명은 실제 값을 넣어야합니다.)

'use strict';

const querystring = require('querystring');
const aws = require('aws-sdk');
const s3 = new aws.S3({
    region: 'ap-northeast-2',
    signatureVersion: 'v4'
});
const sharp = require('sharp');

// Image types that can be handled by Sharp
const supportImageTypes = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'tiff'];

exports.handler = async (event, context, callback) => {
    const { request, response } = event.Records[0].cf;

    console.log("request: ", request);
    console.log("response: ", response);

    const bucket = 'bucket-name';

    // check if image is present and not cached.
    if (response.status == 200) {
        const params = querystring.parse(request.querystring);
        // If none of the s, t, or q variables is present, just pass the request
        if (!params.s || !params.t || !params.q) {
            callback(null, response);
            return;
        }
        
        
        // not found extention
        var re = /(?:\.([^.]+))?$/;
        var ext = re.exec(request.uri)[1]
        if (!ext) {
            console.log("not found extention..")
            callback(null, response);
            return;            
        }

        // read the S3 key from the path variable.
        // assets/images/sample.jpeg
        let key = decodeURIComponent(request.uri).substring(1);
        let width, height, type, quality, requiredFormat;

        // s=100x100&t=crop&q=100(&f=webp)
        const sizeMatch = params.s.split('x');
        const typeMatch = params.t;
        const qualityMatch = params.q;
        const formatMatch = params.f;

        let originalFormat = ext.toLowerCase();

        if (!supportImageTypes.some((type) => { return type == originalFormat })) {
            console.log("not found extention types", supportImageTypes)
            responseUpdate(
                403,
                'Forbidden',
                'Unsupported image type',
                [{ key: 'Content-Type', value: 'text/plain' }],
            );
            callback(null, response);
        }

        width = parseInt(sizeMatch[0], 10);
        height = parseInt(sizeMatch[1], 10);
        type = typeMatch == 'crop' ? 'cover' : typeMatch;
        quality = parseInt(qualityMatch, 10)

        // correction for jpg required for 'Sharp'
        originalFormat = originalFormat == 'jpg' ? 'jpeg' : originalFormat;
        requiredFormat = formatMatch == 'webp' ? 'webp' : originalFormat;

        try {
            const s3Object = await s3.getObject({
                Bucket: bucket,
                Key: key
            }).promise();
            if (s3Object.ContentLength == 0) {
                responseUpdate(
                    404,
                    'Not Found',
                    'The image does not exist.',
                    [{ key: 'Content-Type', value: 'text/plain' }],
                );
                callback(null, response);
            }

            let metaData, resizedImage, byteLength = 0;

            if (requiredFormat != 'jpeg' && requiredFormat != 'webp' && requiredFormat != 'png' && requiredFormat != 'tiff') {
                requiredFormat = 'jpeg';
            }
            while (1) {
                resizedImage = await sharp(s3Object.Body).rotate();
                metaData = await resizedImage.metadata();

                if (metaData.width > width || metaData.height > height) {
                    resizedImage
                        .resize(width, height, { fit: type });
                }
                if (byteLength >= 1046528 || originalFormat != requiredFormat) {
                    resizedImage
                        .toFormat(requiredFormat, { quality: quality });
                }
                resizedImage = await resizedImage.toBuffer();

                byteLength = Buffer.byteLength(resizedImage, 'base64');
                if (byteLength >= 1046528) {
                    quality -= 10;
                }
                else {
                    break;
                }
            }

            responseUpdate(
                200,
                'OK',
                resizedImage.toString('base64'),
                [{ key: 'Content-Type', value: 'image/' + requiredFormat }],
                'base64'
            );
            response.headers['cache-control'] = [{ key: 'cache-control', value: 'max-age=31536000' }];
            return callback(null, response);
        }
        catch (err) {
            console.error('resize err : ', err);
            return callback(err);
        }
    }
    else {
        // allow the response to pass through
        callback(null, response);
    }

    function responseUpdate(status, statusDescription, body, contentHeader, bodyEncoding = undefined) {
        response.status = status;
        response.statusDescription = statusDescription;
        response.body = body;
        response.headers['content-type'] = contentHeader;
        if (bodyEncoding) {
            response.bodyEncoding = bodyEncoding;
        }
    }
};

작성된 코드를 람다함수에 업로드 합니다.

업로드 할때는 반드시 루트 디렉터리가 아닌 람다함수가 구현된 디렉터리를 선택합니다.

 

AWS에서 람다함수에 첫번째 버전이 배포되었음을 확인할 수 있습니다. 

 

6. Lambda@Edge 연동

이제 업로드 된 람다함수를 CloudFront와 연동해보자. 생성된 람다함수의 버전 1의 ARN을 복사해서 원본응답 영역에 입력한다.

CloudFront의 배포가 완료되면 CloudFront 주소를 통해서 이미지 리사이징이 가능하다.

예를들어, https://[CloudFrontURL]/dev/ramzi.png?s=50x50&q=100&t=cover

 

느낀점

  • Lambda@Edge로 연동된 람다함수의 로그를 확인하기 위해서는 함수가 실행된 위치에 가장 가까운 리전에서 로그를 확인해야했다. 다시말해, 한국에서 CloudFront 주소로 요청했을 경우 서울 리전의 로그(CloudWatch)를 확인해야한다.
    필자는 Lambda@Edge로 연동하기 위해서 람다함수를 버지니아 북부(us-east-1)로 만들었기 때문에 버지니아 북부 리전에서 로그를 확인했다가 로그가 찍히지 않아서 한참을 고민했다..
  • Cloud9을 사용할때 장점은 여러 버킷에 동일한 처리가 필요할 경우 하나의 코드만 작성해두고 버킷명 정도만 수정하면서 배포할 수 있는 점은 좋은것 같았다.
  • 꽤 간단한 처리임에도 불구하고 여러가지 설정이 필요했다. AWS의 정책과 역할을 설정하는부분에 대해서 정확한 이해가 필요하고 무엇보다 문서를 꼼꼼하게 읽는 습관을 들여야겠다.
  • (추가) 위의 람다함수에 작성된 코드는 이미지 리사이징이라기보단 이미지 썸네일 생성하는 코드에 가깝다. sharp 패키지를 사용해서 원본이미지보다 작은 이미지가 필요할때 사용할 수 있다.

 

참조

 

 

Comments