AWS Lambda@Edge와 이미 존재하는 CloudFront distributions를 이용한 실시간 이미지 리사이징

YJun·2023년 1월 2일
3

AWS

목록 보기
1/1

시작

회사에서 백오피스와 그에 연관된 API를 만드는 작업을 진행하고 있었다. 상품등록과 관련된 업무였다. 기존의 상품등록 방식에서는 작업자가 같은 이미지를 3종류의 사이즈로 만들어서 등록해야 했었다. 이에 대한 개선을 고민하던 중, CloudFront와 Lambda@Edge를 이용해서 이미지 리사이즈 처리를 하는 방법을 알게 되었다.

두 참고글을 찬찬히 살펴보며 다음의 가설을 세워봤다.

  • 작업자가 원본이미지만 업로드한다.
  • 프론트영역에서 해당 이미지를 ?w=720과 같은 쿼리값을 붙여서 호출한다.
  • 리사이즈된 이미지가 로드되어 로딩성능이 개선될 것이다.
  • 또한 작업자가 원본만 올리면 되기때문에 생산성이 증가할 것이다.
  • 처음 호출시에는 느리겠지만, 캐싱된 이후부터는 빠를테니 특별하게 문제생길일은 없을것이다.
  • 이와같은 플로우를 잘 설계해두면 여러곳에서 사용하기 용이할것이다. (예를들어 상품상세이미지를 나열할때 모바일은 w=720, 데스크탑은 w=860으로 리사이즈 후 로드하도록 구현한다면 별도의 이미지작업은 불필요)

실시간 이미지 리사이징 기능을 완성한다면 두가지 장점을 얻을 수 있을것 같다.

  1. 생산성 증가
  2. 로딩속도 향상

위의 두 글을 참고하여 설계한 시스템 구조는 다음과 같다.

  1. 이미지를 CloudFront에 요청한다.
  2. CloudFront에서 요청된 이미지를 S3에서 찾는다.
  3. S3가 이미지를 CloudFront에 반환한다.
  4. CloudFront가 사용자에게 넘기기전에 Lambda로 리사이즈를 요청한다.
  5. Lambda함수가 리사이즈가 완료된 이미지를 CloudFront로 반환한다.
  6. CloudFront가 리사이즈된 이미지를 캐싱한 후, 사용자에게 반환한다.
  7. 사용자가 같은 요청을 수행한다.
  8. 이미 캐싱된 이미지가 있으므로 바로 사용자에게 반환한다.

참고글을 참고하며 구현을 해보려다 보니 문제가 있었다. 최근에 Serverless Framework를 알게되어 활용을 하고 싶었는데, 참고글1에는 관련된 내용이 있었지만, 참고글2에는 없었다. 또한 참고글1은 CloudFront설정까지도 Serverless로 구성하는 형태였다. 이미 회사에는 CloudFront설정이 되어있었기 때문에, 이미 존재하는 CloudFront와 Lambda@Edge를 연결하는 방식이 필요하였다. 위의 두 참고글만 가지고는 원하는 형태로 구현할 수 없었다.

참고글1에서의 Serverless의 구성파일을 보면 @silvermine/serverless-plugin-cloudfront-lambda-edge 플러그인을 사용한다. 플러그인 깃허브에 들어가서 내가 원하는 고민을 해결한 사람이 있는지 찾아보았다.

역시나 관련 이슈가 이미 있었다. 관련이슈 이슈 내용을 요약하자면, 해당 플러그인의 사용의도와 다른 방식이라 지원하지 않는다고 한다. 추후 버전에서 가능성을 염두하긴 했는데 당장 쓸모는 없을듯 하다.

구글에서 좀더 검색해봤다. serverless exsiting cloudfront lambda edge 로 검색하니 결과 페이지를 발견할 수 있었다.

A Serverless Framework plugin which associates Lambda@Edge against pre-existing CloudFront distributions.

내가 원하던것이다.

원하던 시스템을 구현하기 위한 준비작업은 끝이났다. 이제 구현할 차례이다.

구현을 시작하기에 앞서..

이 포스트와 관련된 기술스택

  • AWS CloudFront
  • AWS Lambda@Edge
  • AWS Lambda
  • AWS S3
  • AWS IAM
  • Severless Freamwork
  • Sharp

이 포스트에서 설명하는 것

  • Severless를 이용해서 Lambda@Edge와 Cloudfront 연결하는 방법 및 관련 코드
  • 위 작업하기 위한 IAM 권한 설정 방법
  • 위 작업을 하기 위한 프로젝트 구성 및 관련 라이브러리 설정방법

이 포스트에서 설명하지 않는 것

  • Severless가 무엇인지, 왜 쓰는지
  • Lambda가 무엇인지
  • Lambda@Edge가 무엇인지, 왜 쓰는지
  • CloudFront가 무엇인지, 왜 쓰는지
  • S3와 CloudFront 연동방법

0. 준비작업

우선 serverless framework를 사용하기 위한 프로젝트를 생성해준다. 적당한 위치에 적당한 이름으로 폴더생성하고 yarn init 혹은 npm init으로 초기 셋업을 진행해준다. 진행시에 나오는 내용은 그냥 모두 엔터쳐서 넘겨도 상관없다. 진행후에 package.json이 생성되면 성공!

{
  "name": "ImgResizer",
  "version": "1.0.0",
  "main": "index.js",
  "license": "MIT"
}

라이브러리 설치

프로젝트에서 사용할 라이브러리들을 미리 설치해주자.

  • @aws-sdk/client-s3: S3의 Object를 불러와야 하므로 필요하다. sdk v2가아닌 sdk v3의 특정 라이브러리만 설치하여 저장공간을 아끼자.
  • querystring : get query를 분석하기 위해 필요하다.
  • sharp : 성능좋은 javascript 이미지처리 라이브러리이다. 리사이징시 사용하므로 설치해주자.
  • serverless-lambda-edge-pre-existing-cloudfront : serverless 플러그인이다. DEV로 설치해주자.
yarn add @aws-sdk/client-s3 querystring sharp
yarn add serverless-lambda-edge-pre-existing-cloudfront --dev

serverless.yml 생성

프로젝트 루트 경로에 serverless.yml 파일을 생성해주자.

Typescript를 쓰고 싶은데..

회사의 모든 JS프로젝트는 Typescript를 사용하고 있다. 그것에 익숙하다보니 자바스크립트는 불편하다. 그래서 참고글이나 AWS 문서는 Javascript 기반이었지만, Typescript 기반으로 변경하였다.

우선 Typescript를 사용하기 위해 관련 작업을 진행한다.

yarn add typescript --dev
npx tsc --init

이 작업을 진행하면 tsconfig.json 파일이 생성되었을 것이다.
특별하게 큰 프로젝트가 아니니까 생성된 초기파일은 그대로 냅두자.
이렇게 하면 준비작업은 끝!

1. 코드 작성

이제 코드를 작성해보자.
루트 경로에 적당한 이름으로 ts 파일을 생성해준다. 이미지 리사이징과 관련된 기능이니까 resizer.ts로 명했다.

앞서 설치한 라이브러리들을 import해준다.

import Sharp from "sharp";
import { GetObjectCommand, S3Client } from "@aws-sdk/client-s3";
import querystring from "querystring";

ES6 문법을 따라서 require를 쓰지 않고 import 형식으로 진행하였다.
문제가 하나 발생했다. sharp 라이브러리를 import 했더니 오류구문이 잡혔다.

'Sharp' is declared but its value is never read. ts (6133)
  
Could not find a declaration file for module 'sharp'. 
'/ImgResizer/node_modules/sharp/lib/index.js' 
implicitly has an 'any' type. Try 'npm i --save-dev
@types/sharp if it exists or add a new declaration (.d.ts) 
file containing declare module 'sharp'; ts (7016)

typescript를 사용하는 프로젝트이다보니 이러한 문제가 보인것. 친절한 설명대로 @types/sharp를 설치해주자.

yarn add @types/sharp --dev

설치후엔 문제없이 보이는것을 확인할 수 있다.

이제 메인함수를 하나 선언해준다. 이름은 어떤걸로 하든 무관하다.
그냥 편하게 main함수를 하나 정의했다.

export async function main(event: any, context: any, callback: any) {

}

main의 인자로 event, context, callback을 받는 이유는 이와 같은 구성이 일반적인 람다함수의 인자구성이기 때문이다 (참고). Cloudfront에서 발생한 어떤 이벤트가 Lambda함수를 호출하는데, 이때 이러한 인자들을 받게 되어있다.

event인자로 넘어오는 데이터 구조에 대해서는 여기를 참고하면 된다.

resizer.ts의 전체 코드는 다음과 같다.
하단 코드는 개발진행시 만났던 버그, 로직오류 등을 해결한 최종코드이다.
로그를 남길 필요가 없다면 console.log는 모두 지우고 배포하자.

import Sharp from "sharp";
import { GetObjectCommand, S3Client } from "@aws-sdk/client-s3";
import querystring from "querystring";

const BUCKET_NAME = "버킷이름";

type ImageFormat = "jpeg" | "png" | "webp" | "tiff" | "gif" | "svg" | "jpg";
const SUPPORT_IMAGE_TYPES: ImageFormat[] = ["jpg", "jpeg", "png", "gif", "webp", "svg", "tiff"];

function getNewResponse(
  originResponse: any,
  newResponse: {
    status: number;
    statusDescription: string;
    contentHeader: { key: string; value: string }[];
    body: string;
    bodyEncoding?: string;
  }
) {
  const response = { ...originResponse };

  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;
  }

  return response;
}

async function streamToBuffer(stream: any): Promise<Buffer> {
  return await new Promise((resolve, reject) => {
    const chunks: Uint8Array[] = [];
    stream.on("data", (chunk: any) => chunks.push(chunk));
    stream.on("error", reject);
    stream.on("end", () => resolve(Buffer.concat(chunks)));
  });
}

export async function main(event: any, context: any, callback: any) {
  const { request, response: originalResponse } = event.Records[0].cf;

  /**
   * If there is no querystring, return the original response.
   */
  if (request.querystring === "") {
    console.log("no querystring return origin");
    return callback(null, originalResponse);
  }

  /**
   * queryString은 w, h, f, q로 구성되어 있다.
   */
  const params: {
    w?: string; // width
    h?: string; // height
    f?: ImageFormat; // format
    q?: string; // quality, 이미지 품질
  } = querystring.parse(request.querystring);
  const { uri } = request;
  const [, imageName, originFormat] = uri.match(/\/?(.*)\.(.*)/);

  /**
   * If the image type is not supported, return the original response.
   */
  if (!SUPPORT_IMAGE_TYPES.includes(originFormat)) {
    const newResponse = getNewResponse(originalResponse, {
      status: 400,
      statusDescription: "Bad Request",
      contentHeader: [{ key: "Content-Type", value: "text/plain" }],
      body: "Unsupported image type",
    });
    return callback(null, newResponse);
  }

  /**
   * Init Params
   */
  let toWidth = (params.w && parseInt(params.w, 10)) || undefined;
  let toHeight = (params.h && parseInt(params.h, 10)) || undefined;
  let toFormat: ImageFormat | undefined = (params.f && params.f) || undefined;
  let toQuality = (params.q && parseInt(params.q, 10)) || undefined;

  // For AWS CloudWatch.
  console.log(`parmas >>`, params, toWidth, toHeight, toFormat, toQuality);

  /**
   * Get Origin Image
   */
  const command = new GetObjectCommand({
    Bucket: BUCKET_NAME,
    Key: decodeURI(imageName + "." + originFormat),
  });

  const client = new S3Client({ region: "사용하는 S3 리전" });
  const originObject = await client.send(command);
  const originObjectBody = originObject.Body;
  const originObjectBuffer: Buffer = await streamToBuffer(originObjectBody);

  /**
   * Handle Image
   */
  let resizedImage: Sharp.Sharp;
  try {
    const origin = await Sharp(originObjectBuffer);

    /**
     * Image Resize
     */
    if (toWidth && toHeight) {
      resizedImage = await origin.resize({ width: toWidth, height: toHeight });
    } else if (toWidth) {
      resizedImage = await origin.resize({ width: toWidth });
    } else if (toHeight) {
      resizedImage = await origin.resize({ height: toHeight });
    } else {
      resizedImage = await origin;
    }

    /**
     * Image Format Change
     */
    if (toFormat) {
      resizedImage = await resizedImage.toFormat(toFormat);
    }

    /**
     * Image Quality Change
     */
    if (toQuality) {
      const format = toFormat || originFormat;
      switch (format) {
        case "jpg":
        case "jpeg":
          resizedImage = await resizedImage.jpeg({ quality: toQuality });
          break;
        case "png":
          resizedImage = await resizedImage.png({ quality: toQuality });
          break;
        case "webp":
          resizedImage = await resizedImage.webp({ quality: toQuality });
          break;
        case "tiff":
          resizedImage = await resizedImage.tiff({ quality: toQuality });
          break;
      }
    }
  } catch (error) {
    console.log("Sharp: ", error);
    return callback(error);
  }

  const resizedImageBuffer = await resizedImage.toBuffer();
  const resizedImageByteLength = Buffer.byteLength(resizedImageBuffer, "base64");

  // `response.body`가 변경된 경우 1MB까지만 허용됨.
  if (resizedImageByteLength >= 1 * 1024 * 1024) {
    return callback(null, originalResponse);
  }

  const format = toFormat || originFormat;
  const newResponse = getNewResponse(originalResponse, {
    status: 200,
    statusDescription: "OK",
    contentHeader: [{ key: "Content-Type", value: `image/${format}` }],
    body: resizedImageBuffer.toString("base64"),
    bodyEncoding: "base64",
  });

  return callback(null, newResponse);
}
  • 전체적인 동작은 요청이 들어오면 s3에서 원본객체를 호출하여 변환한 뒤 리턴하는 구조이다.
  • 확장자가 이미지인 경우에만 처리가 가능하도록 구성했다.
  • 입력 쿼리값에 따라서 변환하도록 구성했다.
  • 이미지 리사이징 말고 이미지 확장자 변환이나 품질 변환도 가능하도록 구성했다.

2. 배포준비

이제 리사이즈를 위한 코드를 작성했으므로, Serverless Framework를 이용하여 배포할 수 있도록 환경을 미리 구성할 차례이다.

CloudFront 설정

먼저, 기존에 존재하는 CloudFront에 Lambda@Edge를 연결할 것이다.
[CloudFront] > [배포] > [설정할 대상 ID] > [동작] 으로 이동한다.

우선 테스트를 위해서 테스트 경로를 생성해준다.
(테스트 이후엔 실제로 필요한 경로로 수정해주면 된다)

  • 경로패턴은 /test*로 설정해준다. 해당 패턴의 의미는 test로 시작하는 경로에 있는 모든 객체를 대상으로 삼는다는 뜻이다.

  • 원본 및 원본 그룹은 생성하거나 이미 사용중인 S3 버킷을 선택해주면 된다.

  • 나머지 옵션은 그대로 둔다.

  • 캐시 키 및 원본 요청에서 Legacy cache settings를 선택한 뒤 위 이미지 처럼 값을 셋팅해준다.

  • 이때 객체 캐싱값도 커스텀으로 셋팅해준다. 이미 생성되어있는 CloudFront 설정에 따라서 특별하게 캐싱을 설정할 필요가 없을수도 있지만, 안되어있을수도 있기 때문에 설정해준다.

  • 참고로 86400초면 하루동안 캐시된 이미지가 유지된다는 의미이다.

    위와 같이 설정한 후 생성버튼을 눌러서 동작을 생성해주자.

IAM 설정

Lambda에서 S3의 기능을 사용하기 위해선 권한을 부여해줘야 한다.
우선 정책부터 생성해야 한다.

[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": "*"
    }
  ]
}

정책 이름을 적당히 설정해준 뒤 정책생성을 해준다.
이제 역할을 생성해보자.

[IAM] -> [역할] 로 이동하여 [역할 만들기]를 눌러준다.

  • 위와같은 화면이 나오면 신뢰할 수 있는 엔터티 유형은 [AWS 서비스] 로 선택, 사용 사례는 [Lambda]로 선택한 뒤 다음을 누른다.

  • 다음 페이지에서 앞서 생성했던 정책을 선택한 뒤 다음을 누른다.

  • 이름 지정, 검토 및 생성 페이지에서는 역할 이름과 설명을 적당히 설정한 뒤 역할생성을 눌러준다.

    그 후 생성된 역할 페이지로 이동한 뒤, 신뢰 관계를 설정해준다.

    기존 내용을 제거 후 다음 내용으로 대치한다.

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

    이 역할의 ARN은 serverless 세팅때 사용된다.

    Serverless.yml 셋업

    앞서서 severless.yml 파일을 생성해뒀지만, 내용은 넣지 않았다. 구성한 iam과 cloudfront 동작을 기반으로 serverless.yml을 셋업해보자.

  plugins:
    - serverless-lambda-edge-pre-existing-cloudfront

  frameworkVersion: "3"

  useDotenv: true

  package:
    excludeDevDependencies: true

  service: {생성할 서비스 명} # 생성되는 서비스명, cloudformation에서 볼수있다. 리전은 us-east-1에 생성된다.

  provider:
    name: aws
    runtime: nodejs14.x

  functions:
    imageResize:
      name: {생성할 람다함수 이름} # Lambda함수 이름
      handler: resizer.main 
      role: !Sub {앞서 생성한 iam 역할의 ARN} # "!Sub"는 Fn::Sub의 짧은 구문이며 CloudFormation의 문법이다. 
      memorySize: 128
      timeout: 30 # `origin-response`의 타임아웃 제한을 30초 변경. 기본은 5초
      events:
        - preExistingCloudFront:
          # ---- Mandatory Properties -----
            distributionId: {CloudFront 배포 ID} # CloudFront distribution ID you want to associate
            eventType: origin-response # Choose event to trigger your Lambda function, which are `viewer-request`, `origin-request`, `origin-response` or `viewer-response`
            pathPattern: '/test*' # Specifying the CloudFront behavior
            includeBody: false # Whether including body or not within request
          # ---- Optional Property -----
            stage: dev # Specify the stage at which you want this CloudFront distribution to be updated

여기까지 진행하면 배포할 모든 준비가 끝났다.

3. 배포

프로젝트 root 경로에서 다음 명령어를 입력한다.
serverless deploy 혹은 sls deploy

특별한 문제가 없다면 별다른 오류 없이 배포가 완료될 것이다.
us-east-1(버지니아 북부) 리전의 [CloudFormation]을 확인하면 serverless.yml에 정의한 service 이름으로 생성된 스택을 확인할 수 있다.

또한 같은 리전의 [Lambda] > 함수를 확인하면 생성한 함수를 확인할 수 있다.

[CloudFront] > [배포] > [배포ID] > [동작]에서 아까 테스트를 위해서 생성한 동작을 선택한 뒤 동작편집을 누르면 하단 함수연결 부분이 다음과 같이 변경되어 있을 것이다.

원본 응답영역에 생성한 람다가 연결되었다.

이제 실제로 테스트를 진행해보자.

4. 테스트

S3로 이동하여 root 경로에 test폴더를 생성한 후, 테스트할 이미지를 넣어보자.
00.jpg란 이름으로 이미지를 넣어본 후, cloudfront 배포 주소를 이용해서 /test/00.jpg로 들어가봤다.

503에러가 나를 반긴다.

5. 디버깅

Typescript Bundler 미설치 오류

503에러는 서버에러, 아예 서버쪽에 문제가 생겼다는 의미이다. 여기에서 서버는 람다이므로 람다함수 구성에 문제가 발생한듯하다.

[Cloudwatch] > [로그그룹]으로 이동하여 관련 로그가 있는지 살펴보자.
(로그그룹에 관련 로그가 없다면, 원래 사용중이던 리전으로 변경해보자)
로그를 확인하니 다음과 같은 에러가 발생하였다.

  undefined	ERROR	Uncaught Exception 	{
    "errorType": "Runtime.ImportModuleError",
    "errorMessage": "Error: Cannot find module 'resizer'\nRequire stack:\n- /var/runtime/UserFunction.js\n- /var/runtime/Runtime.js\n- /var/runtime/index.js",
    "stack": [
        "Runtime.ImportModuleError: Error: Cannot find module 'resizer'",
        "Require stack:",
        "- /var/runtime/UserFunction.js",
        "- /var/runtime/Runtime.js",
        "- /var/runtime/index.js",
        "    at _loadUserApp (/var/runtime/UserFunction.js:225:13)",
        "    at Object.module.exports.load (/var/runtime/UserFunction.js:300:17)",
        "    at Object.<anonymous> (/var/runtime/index.js:43:34)",
        "    at Module._compile (internal/modules/cjs/loader.js:1085:14)",
        "    at Object.Module._extensions..js (internal/modules/cjs/loader.js:1114:10)",
        "    at Module.load (internal/modules/cjs/loader.js:950:32)",
        "    at Function.Module._load (internal/modules/cjs/loader.js:790:12)",
        "    at Function.executeUserEntryPoint [as runMain] (internal/modules/run_main.js:75:12)",
        "    at internal/main/run_main_module.js:17:47"
    ]
}

resizer라는 모듈을 찾을 수 없다고 한다.

이 문제는 typescript를 사용하기 때문에 발생하는 문제이다. 타입스크립트는 번들러로 자바스크립트 문법으로 변환시켜줘야 하는데, 관련된 동작을 설정해주지 않았기 때문에 모듈을 찾을 수 없다는 에러가 나왔다.

여기를 눌러서 관련 셋업법을 찾았다.

typescript를 람다에서 사용하기 위해서는 esbuild 번들러를 플러그인으로 사용해줘야 한다.

yarn add esbuild serverless-esbuild --dev

두 라이브러리를 추가해주고, serverless.yml내용을 일부 업데이트 하자.

plugins:
- serverless-esbuild # 추가
- serverless-lambda-edge-pre-existing-cloudfront

배포전에 함수가 실행되는지만 미리 테스트 해보려면 다음 명령어를 이용한다.

serverless invoke local --function {함수명} 

함수명은 yml파일의 functions: 바로 다음줄에 정의해놓은 이름이다. name: 다음에 나오는 이름 아니다. 이 프로젝트에서는 imageResize 이다.

로컬에서 실행 명령어를 동작하면 모듈에러는 나지 않는다.
하지만 다른 에러가 발생한다.

Sharp 모듈 오류

Something went wrong installing the "sharp" module

해당 에러는 sharp 라이브러리의 동작환경에 대한 문제인데, 다음 코드를 추가하여 해결할 수 있다.

custom:
  esbuild:
    external:
      - sharp
    packagerOptions:
      scripts:
        - rm -rf node_modules/sharp
        - npm install --platform=linux --arch=x64 sharp

serverless.yml의 package:와 service: 사이에 입력해주자. (최종코드는 하단에 있음)
이 코드를 입력해도 로컬동작 테스트는 에러가 나지만, sls deploy를 통해 배포후 람다환경에서 동작시키면 에러가 발생하지 않는다.

Sharp 관련 에러는 짧게 해결법을 적었지만, 해당 버그해결이 가장 오래걸렸다. 구글링과 github issue등 여러정보를 취합하여 해결하였다. sharp는 람다랑 호환성이 안좋나보다..

6. 다시 테스트

에러를 수정한 후 다시 배포를 한뒤, 이미지 주소를 재입력해본다.

처음 접속시에 꽤나 오랜응답시간이 걸린다(파일용량은 36kb이다)
그리고 헤더에는 miss from cloudfront라는 캐시되지 않았다는 의미가 적혀있다.
새로고침 해보면

캐시되어 매우 빠른 응답을 받을수있는걸 볼 수 있다.

위 테스트는 아무런 리사이즈 동작이 없었을때의 테스트이다. 아무런 동작이 없는데도 처음 접속시엔 꽤나 오래걸리는걸 알 수 있었다.

쿼리값이 없더라도 항상 람다함수를 통할 수 밖에 없다. 첫 접속시에 람다도 초기화되어 실행되는 시간이 있으므로, 첫 접속시의 지연시간은 람다의 coldstart 시간이다. coldstart 시간을 줄이고 싶다면 메모리량을 늘려주면 된다. 하지만 비용이 추가되므로 상황에 맞도록 조정하면 될듯하다.

또한, 캐싱되기 전의 최초접속시엔 변환시간이든 coldstart시간이든 어떠한 지연이 존재하지만, 캐싱된 이후부터는 그러한 지연시간이 없기에 프로덕션 환경에서도 극히 일부만 느림을 느낄것이기에 크게 문제되진 않을것 같다.

7. 최종 테스트

몇가지 코드를 수정한 후 최종적으로 리사이즈 테스트까지 진행해 보았다.

/00.jpg?w=100
/00.jpg?w=500&h=100
/00.jpg?q=10
/00.jpg?f=png
...

모두 잘 변환되었고, 동일한 경로로 재접속시에도 캐싱되어있음을 확인할 수 있었다.

마치며

아직 프로덕션 환경에서 본격적으로 사용하지는 않았다. 하지만 개발환경에서 사용할때는 충분히 빠르고 리사이즈도 잘되는것을 확인할 수 있었다. 아직 gif나 다른 형식(tiff, webp..)에 대한 테스트는 충분히 진행하지 못했지만, 문제가 발생하면 그때 해결하면 될것 같다.

이번 프로젝트를 진행하면서 serverless framework를 처음 사용해봤다. 말로만 들었지 실제로 사용해보기는 처음인데 생각보다 편하고 설정도 쉽다는걸 느꼈다. 이를 이용해서 람다함수를 다양하게 활용할 수도 있을것 같다. 회사내에 사용하는 기술스택중 하나로 자리잡도록 필요한곳마다 열심히 써봐야 겠다.

마지막으로 이 포스트가 누군가에게 도움이 되길 바라며, 긴 글을 마치겠다.

최종코드

resizer.ts

 import Sharp from "sharp";
 import { GetObjectCommand, S3Client } from "@aws-sdk/client-s3";
 import querystring from "querystring";

 const BUCKET_NAME = "버킷이름";

 type ImageFormat = "jpeg" | "png" | "webp" | "tiff" | "gif" | "svg" | "jpg";
 const SUPPORT_IMAGE_TYPES: ImageFormat[] = ["jpg", "jpeg", "png", "gif", "webp", "svg", "tiff"];

 function getNewResponse(
   originResponse: any,
   newResponse: {
     status: number;
     statusDescription: string;
     contentHeader: { key: string; value: string }[];
     body: string;
     bodyEncoding?: string;
   }
 ) {
   const response = { ...originResponse };

   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;
   }

   return response;
 }

 async function streamToBuffer(stream: any): Promise<Buffer> {
   return await new Promise((resolve, reject) => {
     const chunks: Uint8Array[] = [];
     stream.on("data", (chunk: any) => chunks.push(chunk));
     stream.on("error", reject);
     stream.on("end", () => resolve(Buffer.concat(chunks)));
   });
 }

 export async function main(event: any, context: any, callback: any) {
   const { request, response: originalResponse } = event.Records[0].cf;

   /**
    * If there is no querystring, return the original response.
    */
   if (request.querystring === "") {
     console.log("no querystring return origin");
     return callback(null, originalResponse);
   }

   /**
    * queryString은 w, h, f, q로 구성되어 있다.
    */
   const params: {
     w?: string; // width
     h?: string; // height
     f?: ImageFormat; // format
     q?: string; // quality, 이미지 품질
   } = querystring.parse(request.querystring);
   const { uri } = request;
   const [, imageName, originFormat] = uri.match(/\/?(.*)\.(.*)/);

   /**
    * If the image type is not supported, return the original response.
    */
   if (!SUPPORT_IMAGE_TYPES.includes(originFormat)) {
     const newResponse = getNewResponse(originalResponse, {
       status: 400,
       statusDescription: "Bad Request",
       contentHeader: [{ key: "Content-Type", value: "text/plain" }],
       body: "Unsupported image type",
     });
     return callback(null, newResponse);
   }

   /**
    * Init Params
    */
   let toWidth = (params.w && parseInt(params.w, 10)) || undefined;
   let toHeight = (params.h && parseInt(params.h, 10)) || undefined;
   let toFormat: ImageFormat | undefined = (params.f && params.f) || undefined;
   let toQuality = (params.q && parseInt(params.q, 10)) || undefined;

   // For AWS CloudWatch.
   console.log(`parmas >>`, params, toWidth, toHeight, toFormat, toQuality);

   /**
    * Get Origin Image
    */
   const command = new GetObjectCommand({
     Bucket: BUCKET_NAME,
     Key: decodeURI(imageName + "." + originFormat),
   });

   const client = new S3Client({ region: "사용하는 S3 리전" });
   const originObject = await client.send(command);
   const originObjectBody = originObject.Body;
   const originObjectBuffer: Buffer = await streamToBuffer(originObjectBody);

   /**
    * Handle Image
    */
   let resizedImage: Sharp.Sharp;
   try {
     const origin = await Sharp(originObjectBuffer);

     /**
      * Image Resize
      */
     if (toWidth && toHeight) {
       resizedImage = await origin.resize({ width: toWidth, height: toHeight });
     } else if (toWidth) {
       resizedImage = await origin.resize({ width: toWidth });
     } else if (toHeight) {
       resizedImage = await origin.resize({ height: toHeight });
     } else {
       resizedImage = await origin;
     }

     /**
      * Image Format Change
      */
     if (toFormat) {
       resizedImage = await resizedImage.toFormat(toFormat);
     }

     /**
      * Image Quality Change
      */
     if (toQuality) {
       const format = toFormat || originFormat;
       switch (format) {
         case "jpg":
         case "jpeg":
           resizedImage = await resizedImage.jpeg({ quality: toQuality });
           break;
         case "png":
           resizedImage = await resizedImage.png({ quality: toQuality });
           break;
         case "webp":
           resizedImage = await resizedImage.webp({ quality: toQuality });
           break;
         case "tiff":
           resizedImage = await resizedImage.tiff({ quality: toQuality });
           break;
       }
     }
   } catch (error) {
     console.log("Sharp: ", error);
     return callback(error);
   }

   const resizedImageBuffer = await resizedImage.toBuffer();
   const resizedImageByteLength = Buffer.byteLength(resizedImageBuffer, "base64");

   // `response.body`가 변경된 경우 1MB까지만 허용됨.
   if (resizedImageByteLength >= 1 * 1024 * 1024) {
     return callback(null, originalResponse);
   }

   const format = toFormat || originFormat;
   const newResponse = getNewResponse(originalResponse, {
     status: 200,
     statusDescription: "OK",
     contentHeader: [{ key: "Content-Type", value: `image/${format}` }],
     body: resizedImageBuffer.toString("base64"),
     bodyEncoding: "base64",
   });

   return callback(null, newResponse);
 }

serverless.yml

  plugins:
    - serverless-esbuild
    - serverless-lambda-edge-pre-existing-cloudfront

  frameworkVersion: "3"

  useDotenv: true

  package:
    excludeDevDependencies: true
  
  custom:
    esbuild:
      external:
        - sharp
      packagerOptions:
        scripts:
          - rm -rf node_modules/sharp
          - npm install --platform=linux --arch=x64 sharp


  service: {생성할 서비스 명} # 생성되는 서비스명, cloudformation에서 볼수있다. 리전은 us-east-1에 생성된다.

  provider:
    name: aws
    runtime: nodejs14.x

  functions:
    imageResize:
      name: {생성할 람다함수 이름} # Lambda함수 이름
      handler: resizer.main 
      role: !Sub {앞서 생성한 iam 역할의 ARN} # "!Sub"는 Fn::Sub의 짧은 구문이며 CloudFormation의 문법이다. 
      memorySize: 128
      timeout: 30 # `origin-response`의 타임아웃 제한을 30초 변경. 기본은 5초
      events:
        - preExistingCloudFront:
          # ---- Mandatory Properties -----
            distributionId: {CloudFront 배포 ID} # CloudFront distribution ID you want to associate
            eventType: origin-response # Choose event to trigger your Lambda function, which are `viewer-request`, `origin-request`, `origin-response` or `viewer-response`
            pathPattern: '/test*' # Specifying the CloudFront behavior
            includeBody: false # Whether including body or not within request
          # ---- Optional Property -----
            stage: dev # Specify the stage at which you want this CloudFront distribution to be updated

package.json

  {
    "name": "",
    "version": "1.0.0",
    "license": "MIT",
    "dependencies": {
      "@aws-sdk/client-s3": "^3.231.0",
      "querystring": "^0.2.1",
      "sharp": "^0.31.3"
    },
    "devDependencies": {
      "@types/sharp": "^0.31.0",
      "esbuild": "^0.16.10",
      "serverless-esbuild": "^1.34.0",
      "serverless-lambda-edge-pre-existing-cloudfront": "^1.2.0",
      "typescript": "^4.9.4"
    }
  }

기타

CloudFront events that can trigger a Lambda@Edge function AWS 문서

  • Viewer Request(1) : CloudFront가 최종 사용자의 요청을 수신할 때(최종 사용자 요청)
  • Origin Request(2) : CloudFront가 오리진에 요청을 전달하기 전(오리진 요청)
  • Origin Response(3) : CloudFront가 오리진의 응답을 수신할 때(오리진 응답)
  • Viewer Response(4) : CloudFront가 최종 사용자에게 응답을 반환하기 전(최종 사용자 응답)

기타

  • CloudFront에서의 Origin은 보통 연결된 S3버킷을 말한다.
  • CloudFront 캐시에 이미 데이터가 있다면 함수가 실행되지 않는다.
  • Viewer Response는 캐시여부에 관계없이 예외사항을 제외하고는 무조건 동작한다.

관련문서

profile
뭐라도 해야지

0개의 댓글