CloudFront Lambda@Edge로 on-demand image resize 기능 구현하기

yo·2021년 6월 10일
3

목차

  1. 배경

    • 이미지 리사이징 필요성
    • 이미지 썸네일 미리 만들기 vs on-demand image resizing
  2. 구현

    • 서비스 소개(CloudFront, Lambda@Edge)
    • 구현
  3. 성능 개선 지표

  4. 삽질 흔적

  5. 참고 자료

배경(문제 정의)

저 포함 주니어 개발자 3명(프1, 백2)이 개발팀이 없던 스타트업에 손잡고 들어가
우여곡절 끝에 약 반년에 거쳐 자체 쇼핑몰을 구현했습니다.
다들 경험이 없다보니 원본 이미지를 리사이징 해서 로딩해야 한다는 사실 자체를 모른채 개발해 런칭까지 했습니다.

한 두달 운영하며 상품 데이터가 늘다 보니, 느린 사진 로딩 속도가 문제도 대두되었습니다.
어드민(운영팀)이 올리는 상품 사진, 고객들이 올리는 리뷰 사진들을 전혀 압축하지 않은채 날 것으로 썼던 탓이죠. (말하면서도 부끄럽네요)

로딩 속도를 빠르게 하기 위한 첫 시도로 CloudFront를 적용했더니 사진 로딩 속도는 더욱 느려졌습니다.
(지금도 원인을 모르겠네요. s3에서 직접 가져다 쓸 때 보다 속도가 더 느려졌습니다. CDN 캐싱은 잘 작동했습니다.)

여튼, 어떤 사진은 로딩하는데 3초 이상 걸리는 지경까지 왔고,
그제서야 리서칭을 본격적으로 해서 결론적으로 두 가지 선택지를 추려냈습니다.

하나는 이미지가 s3에 업로드 되는 순간 lambda를 trigger시켜 썸네일을 생성시키는 방법,
다른 하나는 원본만 저장하고, on-demand로 이미지를 리사이즈 하는 방법.

저는 두 번째 방법을 선택했습니다.
첫번째 방법은 원본 + small, midium, large 최소한 4장의 사진을 같이 저장해야 하기 때문에
장기적으로 봤을 때 비용이 만만치 않을 것 같았구요.
게다가 UI가 바껴 새로운 사이즈의 이미지가 필요해지면 그때마다 이전 사이즈는 무가치해지고,
새로운 사이즈의 이미지를 만드는 수고를 해야 하기 때문이죠.

두 번째 방법은 이 모든 단점을 커버할 수 있기에 on-demand를 선택하게 되었습니다.

구현

CloudFront

CloudFront는 CDN서비스입니다.
aws 공식문서 설명: html, .css, .js 및 이미지 파일과 같은 정적 및 동적 웹 콘텐츠를 사용자에게 더 빨리 배포하도록 지원하는 웹 서비스입니다.

아래 사진처럼 Client가 직접 S3에 접근하지 않고 중간에 CloudFront를 거칩니다.
CloudFront는 S3 데이터를 캐싱하여 더 빠른 데이터 제공을 돕습니다.
심지어 전 세계에 CloudFront 엣지 로케이션이 존재하여, 정적 파일의 global serving을 돕습니다.

Lambda@edge

Lambda@Edge는 CloudFront 기능 중 하나로, Client와 CloudFront와 Origin(S3)사이에서 작동하는 함수입니다.
아래 사진처럼 총 4가지 위치에서 작동할 수 있습니다.
Client request -> CloudFront
CloudFront request -> Origin(S3)
Origin Response(S3) -> CloudFront
CloudFront Response -> Client

이 기능을 활용해 "웹사이트 보안 및 프라이버시", "엣지의 동적 웹 애플리케이션", "SEO", "A/B테스트", " 실시간 이미지 변화" 등을 구현할 수 있습니다.

실제 구현

대략적인 플로우는 이렇습니다.
사진 url뒤에 쿼리스트링으로 리사이즈에 관한 데이터를 보냅니다.
람다에서 이를 받아 리사이즈 한 후 origin의 response를 가공하여 CloudFront에게 보냅니다.

예를 들어 www.cloudfront_domain.net/some_image?w=244 로 요청이 들어오면 some_image의 사이즈를 244x244로 가공하여 리턴합니다.
cloudfront가 쿼리스트링을 기반으로 하여 캐싱하도록 설정했기 때문에, 최초 1회만 lambda가 실행되고 캐시가 살아있는 동안엔 해당 uri로 오는 요청을 모두 CloudFront에서 처리합니다.

저보다 훨씬 설명력이 좋으신 분이 실제 구현 방법을 적어 놓으신 기술블로그 가 있어서 공유합니다.
파이썬으로 구현된 어떤 고수분의 코드 자료도 첨부합니다. 많은 도움을 받았습니다.

위의 자료를 참고해 개발단계에서 만들었던 코드를 공유합니다.
일단 작동하게끔만 만든 코드라 퀄리티가 처참합니다.
전반적인 흐름만 전달하기 위해 공유합니다.
이걸 그대로 가져다 쓰시는 건 비추합니다!

from PIL import Image, ImageOps
from urllib import parse
import boto3
import base64
import io


s3_bucket_name = "my_bucket_name"  
s3_client = boto3.client("s3")


def get_s3_object(s3_object_key):
    """get s3 object using key """
    try:
        return s3_client.get_object(Bucket=s3_bucket_name, Key=parse.unquote(s3_object_key))
    except Exception as e:
        print("get_s3_object Exception", e)


def process_qs(qs):
       """
    쿼리스트링을 가공해서 리사이즈 정보를 얻어낸다.
    디폴트는 244x244이고, 총 세 네 종류의 사이즈가 존재한다.
    w=o는 원본 사진을 볼 때 사용하는 쿼리스트링.
    """
    try:
        if qs == "w=42":
            size = (42, 42)
        elif qs == "w=244":
            size = (244, 244)
        elif qs == "w=448":
            size = (448, 448)
        elif qs == "w=o":
            return None
        else:
            size = (244, 244)
        return size
    except:
        return None


def resize_image(original_image, size):
    """이미지를 리사이즈한다."""
    try:

        fixed_image = ImageOps.exif_transpose(original_image)  # 이미지 메타데이터 때문에 사진이 rotate하는 문제를 해결하기 위한 작업
        fixed_image.thumbnail(size, Image.LANCZOS)
        bytes_io = io.BytesIO()
        fixed_image.save(bytes_io, format=original_image.format, optimize=True, quality=90)

        original_image.close()

        result_size = bytes_io.tell()
        result_data = bytes_io.getvalue()
        result = base64.standard_b64encode(result_data).decode()
        bytes_io.close()
        res = {'resized_image': result,
               'resized_image_size': result_size}
        return res
    except Exception as e:
        print(e)
        return None


def lambda_handler(event, context):
    request = event["Records"][0]["cf"]["request"]
    response = event["Records"][0]["cf"]["response"]

    # 200_OK가 아니면 사진 가공 없이 origin의 response를 그대로 return
    if int(response['status']) != 200:
        print("response status is not 200!!!!!", response['status'])
        return response

    qs = request['querystring']
    uri = request['uri']
    s3_object_key = uri[1:]

    # 사진 객체를 s3에서 가져온다.
    s3_response = get_s3_object(s3_object_key)
    # 사진 객체 가져오는 것을 실패하면 response를 그대로 리턴한다.
    if not s3_response:
        return response
    s3_object_type = s3_response["ContentType"]

    # 우리 서비스에서 취급하는 사진 타입이 아니라면 response를 그대로 리턴한다.
    if s3_object_type not in ["image/jpeg", "image/png", "image/jpg"]:
        return response

    # 리사이즈해야 할 사이즈를 구한다.
    size = process_qs(qs)
    if not size:
        return response

    original_image = Image.open(s3_response["Body"])
    result = resize_image(original_image, size)  # 리사이즈 실행

    if not result:
        return response

    resized_image = result['resized_image']
    resized_image_size = result['resized_image_size']

    # lambda@edge의 response는 1MB를 넘길 수 없다. 넘는다면 s3에 저장 후 301 response한다.
    if resized_image_size > 1024 * 1024:
        s3_object_key_split = s3_object_key.split("/")
        s3_object_key_split[-1] = "resized" + qs + s3_object_key_split[-1]
        converted_object_key = "/".join(s3_object_key_split)

        try:
            s3_client.put_object(
                Bucket=s3_bucket_name,
                Key=parse.unquote(converted_object_key),
                ContentType=s3_object_type,
                Body=resized_image,
            )
        except Exception as e:
            print(e)

        response["status"] = 301
        response["statusDescription"] = "Moved Permanently"
        response["body"] = ""
        response["headers"]["location"] = [
            {"key": "Location", "value": f"/{converted_object_key}"}
        ]
        return response

    # 리사이즈한 사진의 크기가 1mb를 넘지 않는 case
    response["status"] = 200
    response["statusDescription"] = "OK"
    response["body"] = resized_image
    response["bodyEncoding"] = "base64"
    response["headers"]["content-type"] = [
        {"key": "Content-Type", "value": s3_object_type}
    ]

    return response

3. 성능 개선 지표

리사이징 하기 전 600x600사진의 로딩 속도는 608ms입니다.

244x244으로 리사이징한 후 로딩 속도는 24ms으로 개선되었습니다.

4. 삽질 흔적

1. Lambda@edge에서 Pillow사용하기

이거 때문에 반나절을 날렸습니다. Lambda@Edge는 Layer사용이 불가능하기 때문에 모든 dependencies를 집파일로 만들어 올려야 합니다.
문제는 무슨 이유에선지 pillow가 작동을 안한다는 거........
스택오버플로우에도 필로우 안된다고 울부짖는 글 투성이네요.
결국 저는 이 방법을 참고해서 해결했습니다.
레이어를 만든건 아니고 pillow를 재설치 한 후 그 파일을 그대로 옮겨와서 집파일에 함께 압축해줬습니다.


2. pillow로 리사이즈 한 결과 사진이 돌아가는 문제

전혀 예상 못한 문제였는데, 이 문제 해결 과정을 글로 남겼습니다.
https://velog.io/@kpl5672/python-pillow-resize-thumbnail-%EC%82%AC%EC%9A%A9-%EC%8B%9C-%EC%9D%B4%EB%AF%B8%EC%A7%80-rotate%EB%90%98%EB%8A%94-%EB%AC%B8%EC%A0%9C-%ED%95%B4%EA%B2%B0

5. 참고 자료

사진 출처: https://blog.wonizz.tk/2020/04/21/lambda-edge-guide/

profile
Never stop asking why

1개의 댓글

comment-user-thumbnail
2023년 12월 13일

안녕하세요! 글 잘 읽었습니다

저도 한번 해보려고 하는데
1. Lambda@edge에서 Pillow사용하기

부분이 저도 막혀서 공유주신 링크를 들어가보니 게시글이 옮겨진 상태라서 조회가 어렵습니다 ㅠㅠㅠ

혹시 어떻게 해결하셨는지 자세히 여쭤봐도 괜찮으실까요?

답글 달기