Search

Lambda@Edge로 서버 비용 다이어트하기

들어가며

8월 2일날 열린 인프콘 2024에 당첨되진 않았지만 운 좋게 티켓을 양도 받아서 갈 수 있었습니다.
여러 세션들 중 인프랩 CTO이신 동욱님(aka. 향로)의 인프런 아키텍처 2024~2025라는 세션을 들었고
세션 내용은 인프런의 아키텍처가 어떻게 변해왔고 어떻게 변해갈 것인지를 소개하는 내용이었습니다.
최근 1년간 인프런은 글로벌 플랫폼을 목표로 비용절감과 레거시를 청산하며 새로운 기술스택으로의 이동으로써 기반을 다지고 있다고 하셨는데
비용절감의 사례 중 하나로 인프런에서의 강의 썸네일 이미지를 리사이징을 통해 절감한 사례를 소개해주셨습니다.
인프런의 홈 화면 (출처: https://www.inflearn.com/)
대량의 유저가 주로 랜딩하게 되는 인프런의 홈 화면은 많은 강의 썸네일 이미지가 노출되고 있는걸 확인할 수 있습니다.
매일 50만명이 사이트에 접속한다고 하고 각 이미지가 1MB라고 한다면 저 부분에서만 한달이면 15TB의 이미지 전송량이 발생하게 됩니다.
AWS Cloudfront 데이터 전송 요금
AWS Cloudfront HTTP/HTTPS 요청 요금
전송량을 AWS Cloudfront 비용으로 단순하게 계산해본다면
데이터 전송 요금으로 1구간(처음 10TB)인 GB 당 $0.12 에 10TB를 계산하면 $1,200, 2구간(다음 40TB)인 GB당 $0.1 에 5TB를 계산하면 $500가 발생하게 되어 총 $1,700가 발생합니다.
HTTPS 요청이라고 가정하면 10,000 요청당 $0.012 에 15,000,000 요청을 계산하면 $18가 발생하게 됩니다.
CDN을 통한 이미지 요청은 두 요금 둘 다 부과되기 때문에 한 달마다 총 $1,718 한화로 약 240만원이 부과됩니다.
인프런의 홈 화면 (출처: https://www.inflearn.com/)
인프런의 강의 상세 화면 (출처: https://www.inflearn.com/)
인프런은 이렇게 부과되는 비용을 줄이기 위해 큰 용량의 원본 이미지를 그대로 사용하는 것이 아니라
이미지를 사용하는 곳마다 필요한 사이즈로 리사이징해서 사용하는 방식으로 불 필요한 용량을 줄였습니다.
이제부터 이게 어떤 방식인지 알아보며 직접 적용해보겠습니다.

CDN의 이해

CDN은 Content Delivery Network의 약자로 웹 컨텐츠를 사용자에게 더 빠르게 전달하기 위해 설계된 시스템을 의미합니다.
CDN은 전 세계에 분산된 여러 서버들(노드)로 구성되어 있으며 이 서버들은 웹사이트의 정적 컨텐츠를 저장하고 사용자에게 가장 가까운 서버에서 이 컨텐츠를 제공합니다.
이렇게 하면 사용자와 서버 간의 물리적 거리가 줄어들어 로딩 속도가 빨라지고 서버의 부하를 분산시킬 수 있다는 장점이 있습니다.
Amazon CloudFront는 AWS에서 제공하는 CDN 서비스로 AWS의 글로벌 인프라를 기반으로 하여 전 세계에 위치한 다양한 엣지 로케이션에서 컨텐츠를 제공하는 서비스입니다.
CloudFront는 컨텐츠를 캐시하고 사용자에게 가장 가까운 엣지 로케이션에서 빠르게 제공함으로써 웹사이트나 어플리케이션의 성능을 향상시키고 대역폭을 절약하며 서버 부하를 줄입니다.

Lambda@Edge의 이해

Lambda@Edge는 AWS의 서버리스 컴퓨팅 서비스인 Lambda의 확장 기능으로 AWS의 전세계 컨텐츠 전송 네트워크인 CloudFront의 엣지 로케이션에서 코드를 실행하여 사용자와 가까운 위치에서 데이터를 처리할 수 있게 해줍니다.
Lambda@Edge는 4가지의 이벤트 트리거를 통해 함수를 실행할 수 있게 해줍니다.
Viewer Request
사용자가 CloudFront URL에 요청을 보내기 전에 함수가 실행됩니다.
요청을 사전 처리하거나 사용자 인증을 추가하는 등의 작업을 할 수 있습니다.
Viewer Response
CloudFront가 컨텐츠를 클라이언트에게 응답하기 전에 함수가 실행됩니다.
응답을 수정하거나 추가적인 보안 헤더를 삽입하거나 캐시 제어 정책을 동적으로 제어할 수 있습니다.
Origin Request
CloudFront가 요청을 Origin 서버로 전달하기 전에 함수가 실행됩니다.
요청을 수정하거나 Origin 서버에 전달하기 전에 조건에 따라 요청을 조정할 수 있습니다.
Origin Response
CloudFront가 Origin 서버로부터 응답을 받은 후 클라이언트에게 전달하기 전에 함수가 실행됩니다.
Origin 응답을 수정하거나 캐싱 전략을 조정할 수 있습니다.

그래서 어떤 방식인데?

1.
유저는 CloudFront의 URL을 통해 Origin 서버에 있는 컨텐츠를 요청합니다.
2.
CloudFront는 요청한 컨텐츠가 캐싱되어 있으면 바로 응답하고 캐싱되어 있지 않다면 Origin 서버에 해당 컨텐츠를 요청합니다.
3.
Origin 서버가 요청을 받고 해당 컨텐츠를 찾아서 응답합니다.
4.
Origin Response 이벤트를 트리거로 Lambda@Edge가 실행되며 리사이징된 이미지로 가로채서 응답합니다.
5.
CloudFront는 Origin 서버의 응답을 가로챈 Lambda@Edge로부터 받은 리사이징된 이미지를 캐싱해둡니다.
6.
유저가 요청했던 컨텐츠를 받습니다.

구축해보기

S3 버킷 생성

먼저 Origin 으로 사용할 S3 버킷을 하나 생성합니다.
S3 버킷은 CloudFront 혹은 권한을 갖고 있는 서버를 통해서만 접근되어야 하기 때문에 모든 퍼블릭 엑세스를 차단해줍니다.

CloudFront 생성

생성한 S3 버킷을 Origin Domain으로 하여 CloudFront도 생성해줍니다.
원본 엑세스 제어 설정을 통해 CloudFront로만 S3 버킷에 접근할 수 있게 설정해줍니다.
캐시 정책엔 쿼리 파라미터를 추가하여 해당 문자열들도 캐시 키로 사용될 수 있게 설정해줍니다.

S3 버킷 정책 수정

만들어뒀던 버킷의 정책을 수정하여 CloudFront의 접근을 허용해줍니다.

Lambda 함수 생성

Lambda@Edge는 서울 리젼에서 생성할 수 없고 버지니아 북부 리젼에만 지원되기 때문에 생성을 위해선 리젼을 변경해주셔야합니다.
적당한 함수 이름과 익숙한 개발 언어를 선택해줍니다.
아키텍처는 반드시 x86_64를 선택해야 하는데 Lambda@Edge에선 arm64를 지원해주지 않기 때문입니다.
Lambda 생성 후에도 런타임 설정 편집에서 개발 언어와 아키텍처는 변경할 수 있습니다.
또한 사용할 파일 이름과 메서드 이름을 변경하고 싶다면 핸들러 정보를 변경하면 됩니다.

함수 코드 작성

코드를 작성하기 전에 먼저 Lambda@Edge 함수로 들어오는 event가 어떤 형식인지 확인해야합니다.
자세한 내용은 AWS Docs에서 확인할 수 있으며 Origin Response 이벤트에 대한 json 구조는 아래와 같습니다.
{ "Records": [ { "cf": { "config": { "distributionDomainName": "d111111abcdef8.cloudfront.net", "distributionId": "EDFDVBD6EXAMPLE", "eventType": "origin-response", "requestId": "4TyzHTaYWb1GX1qTfsHhEqV6HUDd_BzoBZnwfnvQc_1oF26ClkoUSEQ==" }, "request": { "clientIp": "203.0.113.178", "headers": { "x-forwarded-for": [ { "key": "X-Forwarded-For", "value": "203.0.113.178" } ], "user-agent": [ { "key": "User-Agent", "value": "Amazon CloudFront" } ], "via": [ { "key": "Via", "value": "2.0 8f22423015641505b8c857a37450d6c0.cloudfront.net (CloudFront)" } ], "host": [ { "key": "Host", "value": "example.org" } ], "cache-control": [ { "key": "Cache-Control", "value": "no-cache" } ] }, "method": "GET", "origin": { "custom": { "customHeaders": {}, "domainName": "example.org", "keepaliveTimeout": 5, "path": "", "port": 443, "protocol": "https", "readTimeout": 30, "sslProtocols": [ "TLSv1", "TLSv1.1", "TLSv1.2" ] } }, "querystring": "", "uri": "/" }, "response": { "headers": { "access-control-allow-credentials": [ { "key": "Access-Control-Allow-Credentials", "value": "true" } ], "access-control-allow-origin": [ { "key": "Access-Control-Allow-Origin", "value": "*" } ], "date": [ { "key": "Date", "value": "Mon, 13 Jan 2020 20:12:38 GMT" } ], "referrer-policy": [ { "key": "Referrer-Policy", "value": "no-referrer-when-downgrade" } ], "server": [ { "key": "Server", "value": "ExampleCustomOriginServer" } ], "x-content-type-options": [ { "key": "X-Content-Type-Options", "value": "nosniff" } ], "x-frame-options": [ { "key": "X-Frame-Options", "value": "DENY" } ], "x-xss-protection": [ { "key": "X-XSS-Protection", "value": "1; mode=block" } ], "content-type": [ { "key": "Content-Type", "value": "text/html; charset=utf-8" } ], "content-length": [ { "key": "Content-Length", "value": "9593" } ] }, "status": "200", "statusDescription": "OK" } } } ] }
JSON
복사
위 이벤트 객체 구조를 토대로 이미지를 리사이징하는 함수 코드를 작성하겠습니다.
import boto3 import urllib.parse import io import base64 import mimetypes from PIL import Image import pillow_avif s3 = boto3.client('s3') mimetypes.add_type('image/avif', '.avif') def handler(event, context): config = event['Records'][0]['cf']['config'] request = event['Records'][0]['cf']['request'] response = event['Records'][0]['cf']['response'] # 정상 response가 오지 않는 경우 early return if int(response.get('status')) != 200: return response # S3 Object 가져오기 if not (s3_object := get_s3_object(config, request)): return response original_image = Image.open(s3_object["Body"]) # 쿼리 파라미터 추출 query = request['querystring'] image_spec = get_image_spec(original_image, query) # 리사이징 converted_image = convert_image(original_image, image_spec) # 응답 생성 content_type, _ = mimetypes.guess_type("dummy." + image_spec['image_format']) content_length = str(len(converted_image.get('data'))) response["status"] = "200" response["statusDescription"] = "OK" response["body"] = converted_image.get('data') response["bodyEncoding"] = "base64" response["headers"]["content-type"] = [{ "key": "Content-Type", "value": content_type }] response["headers"]["content-length"] = [{ "key": "Content-Length", "value": content_length }] return response def get_s3_object(config, request): bucket = 'bucket' key = request['uri'][1:] try: return s3.get_object(Bucket=bucket, Key=key) except Exception as e: print(e) print('Error getting object {} from bucket {}. Make sure they exist and your bucket is in the same region as this function.'.format(key, bucket)) raise e def get_image_spec(image, query): params = urllib.parse.parse_qs(query) width = int(params.get('w', [0])[0]) if 'w' in params else None height = int(params.get('h', [0])[0]) if 'h' in params else None image_format = params.get('f', [image.format])[0] return { 'width': width, 'height': height, 'image_format': image_format } def is_empty_image_spec(spec): return (spec['width'] is None or spec['width'] == 0) or (spec['height'] is None or spec['height'] == 0) def convert_image(image, spec): try: output = io.BytesIO() resized_image = image if is_empty_image_spec(spec): resized_image.save(output, format=spec['image_format']) else: resized_image = image.resize((spec['width'], spec['height']), Image.LANCZOS) redrawn_image = resized_image.convert('RGB') redrawn_image.save(output, format=spec['image_format']) converted_image = { 'width': resized_image.width, 'height': resized_image.height, 'format': spec['image_format'], 'data': base64.standard_b64encode(output.getvalue()).decode() } image.close() output.close() return converted_image except Exception as e: print(e)
Python
복사
코드를 간략히 설명하면 조회하려는 대상 이미지의 S3 Object를 가져온 후 원하는 사이즈와 포맷대로 변환 후 응답하는 코드입니다.

Lambda@Edge 배포

새 버전 발행을 통해 배포한 Lambda 함수의 버전을 만들어줍니다.
그 후 Lambda@Edge 배포를 눌러서 배포할 CloudFront를 선택합니다.
그리고 어떠한 동작에서 함수가 작동할지 캐시 동작을 설정하는데 S3 버킷에서 디렉토리 구조에 따라 나눠서 적용할 수 있습니다.
마지막으로 이벤트는 오리진 응답을 선택하여 캐시가 논히트 했을 때 리사이징 함수가 작동하여 원본 이미지를 리사이징해서 응답하도록 해줍니다.

이슈1. PIL 라이브러리

사용하던 Lambda의 CPU 아키텍처는 x86_64 였고 Python의 버전은 3.12 였습니다.
하지만 제 PC는 맥북 M1칩이었기 때문에 CPU 아키텍처가 arm64 였고 Python은 3.10 이었습니다.
Python 에서 Image를 처리해주는 라이브러리인 PIL 라이브러리를 사용하려고 기존에 하던 방식처럼 Lambda Layer를 추가하려하였지만 Lambda@Edge에선 Layer를 지원해주지 않았습니다.
결국 코드 파일과 라이브러리를 직접 zip 으로 압축해서 배포했지만 이번엔 아래와 같은 ImportError를 만나게 됩니다.
ImportError: cannot import name '_imaging' from 'PIL'
Python
복사
검색을 통해 라이브러리를 설치한 환경이 서로 달라서 에러가 발생한거라는 힌트를 얻게 되었고
Docker로 x86_64 아키텍처의 linux 이미지를 컨테이너로 띄워 Python3.12 버전의 가상 환경을 만든 후 라이브러리를 설치하였습니다.
그렇게 얻어낸 동일한 환경의 라이브러리로 함께 압축하여 배포해 해결하였습니다.

이슈2. Lambda Error

구축하며 가장 불편했던건 Lambda@Edge는 CloudWatch에서 해당 Lambda에 대한 로그 그룹으로 로그를 확인할 수 없다는 것이었습니다.
로그를 확인하려면 CloudFront의 로그를 활성화 해야 하는데 비활성화로 작업하느라 에러 코드로만 에러를 유추해야했었습니다.
제가 겪었던 것은 아래와 같습니다.
1.
response의 header의 value로 문자 타입이 아닌 타입을 넣어서 502 에러가 발생한 문제
2.
큰 사이즈의 이미지를 리사이징하느라 Lambda 함수의 메모리가 초과하여 503 에러가 발생한 문제
이 문서에 Lambda@Edge의 500, 502, 503 에러코드에 대한 설명이 잘 나타나있어서 수월하게 이슈에 대응할 수 있었습니다.
이 문서에선 Lambda@Edge를 사용할 때 이벤트 유형에 따라 어느정도의 할당량을 사용할 수 있는지 표로 정리되어 있습니다.

검증해보기

원본 이미지는 200x200에 1.12KB png 이미지였습니다.
w=20, h=10, f=avif 의 쿼리 파라미터와 함께 재요청하여 리사이징했습니다.
그 결과 1.12KB 에서 0.92KB로 17.8% 감소하였고 사진 크기나 포맷 또한 조건대로 변환되었습니다.
AVIF나 WebP 같은 이미지 포맷들이 특정 브라우저를 지원하지 않는다는 등의 제약은 있지만 운영하는 서비스에서의 제약이 없다면 압축률이 좋은 이미지 포맷으로 변경하여 더욱 더 비용을 줄일 수 있는 것을 확인하였습니다.
그럼 이제 위 실험을 통해 확인한 17.8%라는 감소율을 가지고 아주 단순하게 비용이 얼마나 줄어들지 계산해보겠습니다.
맨 처음 가정했던 한달 15TB의 이미지 전송량에 17.8%에 해당하는 2.67TB를 아낀 셈이고
데이터 전송 요금으로 GB당 $0.1에 해당하는 $267 한화로 약 37만원을 아낀 셈입니다.
(일단 Lambda@Edge 실행 비용은 제외하겠습니다..)
숫자로만 보면 미미한 비용 절감처럼 보이지만 서비스가 성장하고 유저가 늘어나며 트래픽이 증가함에 따라 함께 늘어나는 비용이기 때문에 장기적으로 본다면 큰 비용 절감 방법이 될 수 있을거라 생각합니다.

적용 계획 세워보기

현재 회사에서 서비스 중인 고초대졸닷컴의 웹사이트를 훑어보며 이미지 리사이징을 적용할 수 있는 곳들을 살펴보겠습니다.

메인 화면

기업 정보 화면

기업 디테일 화면

공고 디테일 화면

사용자들이 가장 먼저 랜딩하는 메인 화면부터 시작해서 여러 화면에 기업 로고 이미지들이 다양한 사이즈로 사용되는 것을 확인할 수 있었습니다.
27.55KB의 예시 이미지로 사용하고 있는 다양한 사이즈별 리사이징을 해본 결과 아래와 같은 감소율을 보였습니다.
248 * 170
27.55KB → 18.55KB (32.6% 감소)
98 * 98
27.55KB → 17.86KB (35.1% 감소)
54 * 54
27.55KB → 17.42KB (36.7% 감소)
38 * 38
27.55KB → 17.38KB (36.9% 감소)
18 * 18
27.55KB → 17.21KB (37.5% 감소)