AWS CloudFront + Lambda@Edge를 이용한 이미지 리사이징

Written on October 23, 2022

배경

서비스를 운영할 때 이미지를 썸네일, 배너, 배경 등 다양한 용도와 사이즈로 이용하게 된다. 이때 원본 크기대로 이미지를 불러오면 이미지 로딩 시간이 늘어나 좋지 않은 UX를 제공하게 되고, 다양한 크기의 이미지를 각각 저장해서 사용하게 스토리지 용량을 낭비하게 된다.
이 때 도입할 수 있는 방법이 이미지를 요청할 때 원하는 크기, 포맷으로 이미지를 실시간(on the fly)으로 리사이징 하여 받는 방법이다.
이번 포스팅 에서는 AWS CloudFront + Lambda@Edge를 활용해 on-the-fly 이미지 리사이징 처리를 만들어 보자.

CloudFront & Lambda@edge

Lambda@Edge는 CloudFront의 동작을 조작하는 Lambda 함수를 실행할 수 있는 서비스이다. Lambda@Edge로 구성되는 Lambda 함수는 ua-east-1에만 배포 가능하며, ua-east-1에 배포된 Lambda 함수가 CloudFront의 edge location에 복제되어 배포 & 실행되는 구조다.

cloudfront cache

Lambda@Edge가 트리거 되는 CloudFront의 이벤트는 아래 4가지이다. 참고: AWS 문서

  • origin-request: CloudFront에서 오리진으로 요청을 전달할 때만 실행. 요청한 객체가 CloudFront 캐시에 있으면 함수가 실행되지 않음
  • origin-response: CloudFront가 오리진으로부터 응답을 받은 후 응답에 객체를 캐시하기 전에 실행
  • viewer-request: CloudFront가 최종 사용자로부터 요청을 수신하면 요청된 객체가 CloudFront 캐시에 있는지 확인하기 전에 실행
  • viewer-response: 요청된 파일을 뷰어에 반환하기 전에 실행. 파일이 이미 CloudFront 캐시에 있는지 여부에 관계없이 함수가 실행됨

만약, 캐싱에 관계없이 모든 요청에 함수를 실행하고 싶다면, viewer-requestviewer-response를 사용해야 한다. origin-requestorigin-response는 요청된 객체가 엣지 로케이션에 캐싱되어 있지 않아 CloudFront가 오리진에 요청을 전달하는 경우에만 실행되기 때문이다.
함수가 오리진의 응답에 영향을 주는 방식으로 요청을 변경한다면 origin-request를 사용해야 하며, 함수가 캐싱의 기준으로 사용중인 값을 변경(ex. 서비스 언어)해야 한다면 viewer-request 이벤트를 사용한다.
이미지 리사이징 함수는 오리진 응답의 형태에는 영향이 없고, 함수의 결과가 캐싱되어야 최적화를 이룰 수 있으니 origin-response 이벤트를 사용한다.

on-the-fly 이미지 리사이징 동작 과정

lambda edge image resize flow
  • 클라이언트가 이미지 url의 쿼리 스트링을 옵션으로 하여 이미지를 요청한다. ex)https://example.com/images/thumbnail.png?w=200&f=webp
  • 캐싱되지 않은 이미지라면, CloudFront가 S3로 이미지를 요청하고 응답을 받는데, 이 때 리사이징 람다 함수가 실행되어 리사이징된 이미지가 CloudFront에 캐싱`되고 클라이언트에 전달된다.
  • 이미 캐싱된 이미지라면, 리사이징 처리를 다시 실행하지 않고 캐싱된 이미지가 클라이언트에 전달된다.

구현하기

이 포스팅에서는 이미지 오브젝트가 저장되어 있는 오리진 S3 버킷과, IAM 정책 및 역할 생성을 제외한 AWS 리소스 생성은 serverless.yml에서 설정해 배포해 보겠다.

IAM 정책 및 역할 생성

먼저, Lambda를 CloudFront에 배포하고 연결하기 위한 IAM 권한을 설정한다.

정책 생성

AWS 콘솔의 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": "*"
    }
  ]
}

역할 생성

AWS 콘솔의 IAM > 역할 > ‘역할 만들기’을 선택하고, 아래 옵션을 선택한 후 위에서 만들어 준 정책을 연결한다.

  • 신뢰할 수 있는 엔터티 유형 : AWS 서비스
  • 사용 사례: Lambda

역할의 신뢰 관계 수정

위에서 생성한 역할의 신뢰 관계를 아래와 같이 편집해 준다.

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

오리진 S3 버킷 생성

이제 이미지 오브젝트들이 저장될 S3 이미지 버킷을 생성한다.
버킷의 리전은 상관없으며, 퍼블릭 액세서 차단 설정을 한다. 이후 스텝에서 CloudFront에서만 접근할 수 있도록 권한 설정을 진행할 것이다.
버킷이 만들어졌다면, 불필요한 오류가 발생하지 않도록 루트 경로에 favicon.ico를 업로드 하고,
/images 경로에 고화질의 thumbnail.png 이미지를 업로드 한다.

이미지 리사이징 함수 작성

npm init을 실행하고,
src/hander.js에 이미지 리사이징 핸들러를 작성한다.

이미지 리사이징 조건은 url 쿼리 스트링 w, h, q, f로 받아 sharp로 실행한다. nextjs의 이미지 최적화도 이 sharp 패키지를 이용한다.
Lambda@Edge 사용 시 주의점 중 하나가 이벤트 유형에 따라 response 크기나 람다 펑션의 타임아웃 시간이 제한되는데,
origin-response의 응답 크기 제한은 1MB 이므로, 리사이징 결과가 1MB보다 크면 origin response를 반환해 준다.

"use strict";

const AWS = require("aws-sdk");
const querystring = require("querystring");
const Sharp = require("sharp");

const S3 = new AWS.S3({ region: [생성한 S3 버킷 리전] });
const BUCKET = [생성한 S3 버킷 이름];

const supportImageTypes = ["jpg", "jpeg", "png", "gif", "webp", "svg", "tiff"];

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

  // width or height variable is required
  if (!params.w && !params.h) {
    return callback(null, response);
  }

  // extract image from uri
  const { uri } = request;
  const [, imageName, extension] = uri.match(/\/?(.*)\.(.*)/);

  if (!supportImageTypes.some((type) => type === extension)) {
    updateResponse({
      status: 400,
      statusDescription: "Bad Request",
      contentHeader: [{ key: "Content-Type", value: "text/plain" }],
      body: "Unsupported image type",
    });
    return callback(null, response);
  }

  let width;
  let height;
  let format;
  let quality;
  let s3Object;
  let resizedImage;

  // Init sizes.
  width = parseInt(params.w, 10) ? parseInt(params.w, 10) : null;
  height = parseInt(params.h, 10) ? parseInt(params.h, 10) : null;

  // Init quality.
  if (parseInt(params.q, 10)) {
    quality = parseInt(params.q, 10);
  }

  // Init format.
  format = params.f ? params.f : extension;
  format = format === "jpg" ? "jpeg" : format;

  // For AWS CloudWatch.
  console.log(`parmas: ${JSON.stringify(params)}`); // Cannot convert object to primitive value.
  console.log(`name: ${imageName}.${extension}`); // Favicon error, if name is `favicon.ico`.

  try {
    s3Object = await S3.getObject({
      Bucket: BUCKET,
      Key: decodeURI(imageName + "." + extension),
    }).promise();
  } catch (error) {
    console.log("S3.getObject: ", error);
    return callback(error);
  }

  if (s3Object.ContentLength === 0) {
    updateResponse({
      status: 404,
      statusDescription: "Not Found",
      contentHeader: [{ key: "Content-Type", value: "text/plain" }],
      body: "The image does not exist.",
    });
    return callback(null, response);
  }

  try {
    resizedImage = await Sharp(s3Object.Body)
      .resize(width, height)
      .toFormat(format, {
        quality,
      })
      .toBuffer();
  } catch (error) {
    console.log("Sharp: ", error);
    return callback(error);
  }

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

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

  updateResponse({
    status: 200,
    statusDescription: "OK",
    contentHeader: [{ key: "Content-Type", value: `image/${format}` }],
    body: resizedImage.toString("base64"),
    bodyEncoding: "base64",
  });

  return callback(null, response);

  function updateResponse(newResponse) {
    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;
    }
    if (newResponse.cacheControl) {
      response.headers["cache-control"] = newResponse.cacheControl;
    }
  }
};

배포 환경 설정 (serverless.yml)

이제 루트 디렉토리에 serverless.yml을 작성해 보자.
CloudFront Lambda@Edge를 연결하기 위해 @silvermine/serverless-plugin-cloudfront-lambda-edge 설치가 필요하다.

plugins:
  - "@silvermine/serverless-plugin-cloudfront-lambda-edge"

useDotenv: true

package:
  excludeDevDependencies: true
  patterns:
    - src/**
    - node_modules/**
    - "!node_modules/aws-sdk/**"

custom:
  bucketName: [생성한 S3 버킷 이름] # 아래에서 버킷 이름이 여러번 쓰이기 때문에 custom 섹션에 상수로 선언

service: image-resizer-ex

frameworkVersion: "3"

provider:
  name: aws
  runtime: nodejs14.x
  versionFunctions: true # CloudFront와 Lambda@edge는 버전으로 연결되기 때문에 필수적으로 설정해줘야 함

functions:
  imageResize:
    name: image-resizer-ex
    handler: src/handler.handler
    role: !Sub arn:aws:iam::${AWS::AccountId}:role/[생성한 IAM 역할 이름]
    memorySize: 256
    timeout: 30 # `origin-response`의 타임아웃 제한 30을 추가. 기본은 5초
    lambdaAtEdge:
      distribution: ImageResizeDistribution # 아래 resources에 선언해준 CloudFront의 Distribution Id와 같아야 함
      eventType: "origin-response"
      pathPattern: "images*"

resources:
  Resources:
    ImageResizeDistribution: # CloudFront의 Distribution Id
      Type: AWS::CloudFront::Distribution
      Properties:
        DistributionConfig:
          Origins:
            - DomainName: ${self:custom.bucketName}.s3.amazonaws.com
              Id: S3Origin
              S3OriginConfig:
                OriginAccessIdentity: ""
              OriginAccessControlId: !GetAtt CloudFrontOriginAccessControl.Id
          Enabled: "true"
          CacheBehaviors:
            - PathPattern: "images*"
              TargetOriginId: S3Origin
              ViewerProtocolPolicy: "allow-all"
              ForwardedValues:
                QueryString: true
                QueryStringCacheKeys: # 이미지 리사이징 옵션을 캐시 키로 설정
                  - "h"
                  - "w"
                  - "q"
                  - "f"
          DefaultCacheBehavior:
            TargetOriginId: S3Origin
            ViewerProtocolPolicy: "allow-all"
            ForwardedValues:
              QueryString: true
            AllowedMethods: [HEAD, GET]
            CachedMethods: [HEAD, GET]
          HttpVersion: "http2"
    OriginBucketPolicy: # CloudFront에서만 S3에 접근이 가능하도록 권한 업데이트
      Type: AWS::S3::BucketPolicy
      Properties:
        Bucket: ${self:custom.bucketName}
        PolicyDocument:
          Statement:
            - Action: s3:GetObject
              Sid: "AllowCloudFrontServicePrincipalReadOnly"
              Effect: Allow
              Resource: !Sub arn:aws:s3:::${self:custom.bucketName}/*
              Principal:
                Service: cloudfront.amazonaws.com
              Condition:
                StringEquals:
                  AWS:SourceArn: !Sub arn:aws:cloudfront::${AWS::AccountId}:distribution/${ImageResizeDistribution}
    CloudFrontOriginAccessControl:
      Type: AWS::CloudFront::OriginAccessControl
      Properties:
        OriginAccessControlConfig:
          Description: Default Origin Access Control
          Name: !Ref AWS::StackName
          OriginAccessControlOriginType: s3
          SigningBehavior: always
          SigningProtocol: sigv4

이제 serverless deploy 명령어 한줄이면 이미지 리사이징 함수가 배포된다. 배포된 클라우드 프론트의 도메인으로 테스트를 해 보면,
첫 요청과 다음 요청의 캐시 처리 여부도 확인할 수 있다.

참고

추가적으로, 혹시 CloudWatch로 오류 트래킹을 한다면, 꼭 region이 오류가 실행된 region이 맞는지 확인해야 한다. 😂 버지니아 북부에 두고 오류 로그 찾는다고 삽질을..

👩🏻‍💻 배우는 것을 즐기는 프론트엔드 개발자 입니다
부족한 블로그에 방문해 주셔서 감사합니다 🙇🏻‍♀️

in the process of becoming the best version of myself