서비스를 운영하다보면 이미지를 어떻게 처리할지 고민하게 된다. 보통은 S3 + CloudFront 조합으로 많이 구성하게 된다.
모든 이미지를 원본 이미지를 사용해야만 하는 서비스이면 괜찮겠지만 보통은 원본 사이즈가 필요없는 경우가 거의 대부분이다.
이미지 프로세싱을 구글에 검색하면 다양한 방법이 나오는데 요즘은 Lambda@Edge를 사용하여 프로세싱된 이미지를 보여주는 방식을 많이 사용한다.
Lambda로 이미지 프로세싱
S3 버킷에 이미지 업로드를 트리거로 람다 함수를 실행시켜 프로세싱된 이미지를 S3에 저장하는 방식이다.
이 방법의 장점은 아래와 같다.
- 기존 어플리케이션 코드의 수정 최소화
- S3 버킷에 오브젝트를 업로드할때만 실행되므로 비용 부담이 적음
하지만 직접 개발해보니 아래와 같은 문제점이 있었다.
- 같은 S3 버킷을 사용하면 재귀호출이 될 수 있음
- 썸네일용 버킷을 따로 만들거나, Metadata를 추가해야한다.
- 결국 용량이 커지므로 비용 부담이 될 수 있음(사진 개수 x 2)
- 이미지 크기를 동적으로 조절하기 힘듬
이 사실을 간과하고 같은 S3 버킷에 썸네일도 저장해야 하는 상황이었는데 이미지를 프로세싱하고 파일명에 기존파일명_thumbnail.png 이런식으로 저장해주었더니 기존파일명_thumbnail.png 이 버킷에 새로 업로드가 된걸 트리거로 또 람다함수가 계속 실행되어 결국 기존파일명_thumbnail_thumbnail_thumbnail...._thumbnail.png 까지 저장되어있어 매우 당황했던 기억이 있다.
물론 이미지 파일에 Metadata를 추가하여 저장하고 이미지 프로세싱 전에 Metadata에 썸네일이라는 정보가 있는지 없는지 체크해서 재귀호출을 막을 수 있었지만, 썸네일 이미지를 고정해야하고, 개발하기 까다롭다는 생각이 들어 차라리 Lambda@Edge를 사용하기로 결정했다.
Lambda@Edge로 이미지 프로세싱
Lambda@Edge를 사용하기 전에 CloudFront에 대해 알아야 하는데, CloudFront는 전 세계에 있는 엣지 로케이션을 활용해 사용자에게 짧은 지연 시간으로 콘텐츠를 전송해주는 서비스이다.
Lambda@Edge는 CloudFront를 통해 전달되는 콘텐츠를 다룰 수 있게 해주는 serverless 서비스이다. 엣지 로케이션에서 해당 함수를 실행하기 때문에 오리진 서버가 아니라 최종 사용자에게 가까운 위치에서 요청을 처리하므로 지연시간을 크게 단축할 수 있어 사용자 경험이 좋아진다.
Lambda@Edge를 사용하면
- 이미지 썸네일을 실시간으로 사이즈별로 캐싱할 수 있다.(On-The-Fly 방식)
나는 Typescript + Serverless로 코드를 작성하였다.(https://lotuus.tistory.com/165 블로그를 참고하였습니다.)
handler.ts
import { S3 } from '@aws-sdk/client-s3';
import sharp, { FormatEnum } from 'sharp';
function parseQueryStringValue(querystring: string, key: string): string {
return new URLSearchParams('?' + querystring).get(key);
}
export async function imageProcessingHandler(event: any, _context: any, callback) {
try {
const { request, response } = event.Records[0].cf;
const querystring = request.querystring;
if (!querystring) {
return callback(null, response);
}
const s3 = new S3();
const uri = decodeURIComponent(request.uri);
const bucket = request.origin.s3.domainName.replace('.s3.ap-northeast-2.amazonaws.com', '');
const supportFormats: unknown[] = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'tiff'];
const key = uri.substring(1);
const image = await s3.getObject({ Bucket: bucket, Key: key });
const originalFormat = key.match(/(.*)\.(.*)/)[2].toLowerCase();
const width = Number(parseQueryStringValue(querystring, 'w')) || null;
const height = Number(parseQueryStringValue(querystring, 'h')) || null;
const quality = Number(parseQueryStringValue(querystring, 'q')) || 100;
const format = supportFormats.find((f) => f === parseQueryStringValue(querystring, 'f')) || originalFormat;
if (
!image.Body ||
!supportFormats.includes(originalFormat) ||
!supportFormats.includes(format) ||
originalFormat === 'gif' ||
!width ||
!height
) {
console.log('존재하지 않는 이미지이거나 지원하지 않는 확장자 ', originalFormat, format);
return callback(null, response);
}
const body = await image.Body.transformToByteArray();
console.log({
key,
width,
height,
quality,
format,
});
let thumbnailImage = sharp(body);
const result = await thumbnailImage
.resize({
width: width > 600 ? 600 : width,
height: height > 600 ? 600 : height,
fit: 'cover',
})
.withMetadata()
.toFormat(format as keyof FormatEnum, { quality: quality }) // 타입 체크
.toBuffer();
if (Buffer.byteLength(result, 'base64') > 1048576) {
return callback(null, response);
}
response.status = 200;
response.body = result.toString('base64');
response.bodyEncoding = 'base64';
if (format !== originalFormat) {
response.headers['content-type'] = [{ key: 'Content-Type', value: 'image/' + format }];
}
return callback(null, response);
} catch (err) {
console.error(err);
return callback(err);
}
}
serverless.yml
service: image-processing-lambda
frameworkVersion: "3"
provider:
name: aws
runtime: nodejs18.x
stage: dev
region: us-east-1
architecture: x86_64
timeout: 30
memorySize: 256
iam:
role: '<iam arn>'
plugins:
- serverless-esbuild
- serverless-offline
- serverless-lambda-edge-pre-existing-cloudfront
package:
individually: true
custom:
esbuild:
bundle: true
minify: false
sourcemap: true
target: 'node16'
define: { 'require.resolve': undefined }
platform: 'node'
concurrency: 10
external: ['sharp']
packagerOptions:
scripts:
- rm -rf node_modules/sharp
- npm install --arch=x64 --platform=linux sharp
# - npm install sharp@0.32.0 # 로컬에서 실행
functions:
imageProcessingHandler:
name: image-processing-lambda-edge-${sls:stage}
handler: handler.imageProcessingHandler
events:
- preExistingCloudFront:
distributionId: ${file(./config.${opt:stage, 'dev'}.json):CF_DISTRIBUTION_ID}
eventType: origin-response
pathPattern: '*'
includeBody: false
stage: ${sls:stage}
- esbuild: tsc를 따로 사용하지 않아도 Typescript를 컴파일 해준다. 또한 Lambda@Edge로 배포할 수 있는 함수의 용량이 제한되어있기때문에 esbuild를 사용하면 더 좋다.
- sharp: sharp는 는 운영체제마다 다르게 설치해줘야하므로 코드 패키징할때 따로 설치해줘야 한다.(로컬에서 테스트할때랑, 실제 람다에 올라가서 운영할때 sharp를 다르게 설치해줘야함)
- serverless-lambda-edge-pre-existing-cloudfront 플러그인을 사용해서 이미 존재하는 CF에 Lambda@Edge함수를 배포해주었다.
- webp: png, jpg보다는 webp가 용량이 훨씬 적으므로 webp로 변환할 수 있도록 하였다.
- 실제로 스크립트로 테스트 해보니 사이즈가 큰 이미지일수록 드라마틱한 변환률을 보이는걸 확인할 수 있었다.
503 에러
개발 서버에서 간헐적으로 특정 이미지에서 503에러가 발생하며 이미지를 가져오지 못하는 이슈가 발생했다.
이것저것 찾아보니 Lambda@Edge 제한에 대한 문서를 찾아볼 수 있었다.
아무런 설정도 안한다면 기본 메모리는 128mb이다. 어쨌든 cf 이벤트값에서 원본이 넘어오는게 아니라서 s3에서 원본을 메모리에 불러온다음 프로세싱을 진행하게되는데 이 과정에서 일부 이미지들을 프로세싱하다가 메모리가 부족한 상황이 발생할 수 있다.
이 경우에는 메모리, 타임아웃 한도를 늘려서 다시 배포해봐야한다.(그래도 아주 큰 용량은 여전히 503에러가 발생하므로 처음부터 업로드 할 때 이미지 용량 제한을 하는것이 좋다.)
간헐적으로 에러가 발생했던 이유도 메모리와 관련된 이슈여서 그랬던걸로 추정된다. 확실히 메모리를 훨씬 늘려서 재배포하니 에러 빈도가 줄었다.
배포
작업하다보니 이미지 리사이징 작업 우선순위가 밀리기도 했고, 메모리 이슈를 어떻게 해결할지 논의해볼 시간도 없어 이 프로젝트는 자연스럽게 개발서버에만 적용된채로 남아있게 되었다. 그리고 무작정 메모리를 늘리기에는 비용 부담도 있어서 테스트를 통해 적절한 메모리 크기를 설정해주었어야 했는데 생각만 한 채로 실행하지 못한 점이 아쉬웠다. 또한 위의 공식문서에서 1초마다 request 제한이 걸려있는데 평소 모니터링이 제대로 되어있지 않아 어떻게 대응해야할지 생각하지도 못했다. 그래도 개발서버까지 적용해본 경험으로 다음에는 수월하게 적용할 수 있을것이다.
참고
https://lotuus.tistory.com/165
S3, CloudFront, Lambda@Edge를 이용한 이미지 리사이즈(6) - 리사이징 로직 작성 및 테스트
목차 이 게시글은 시리즈물입니다! 아래 목차를 먼저 확인해주세요 1. Lambda@Edge란? 2. S3, CloudFront 셋팅 3. CloudFront 쿼리스트링 캐시 셋팅 4. IAM 역할 생성 5. Lambda@Edge 배포 셋팅 및 로그 확인 6. 리사
lotuus.tistory.com
https://inventer.tistory.com/16
[이미지 리사이징] Lambda@Edge 502, 503 에러
어느덧 벌써 벡엔드 스린이로 활동한지 4주차가 되어가는 시점입니다.. 기본도 없이 시작한 백엔드가 많이 발전해나가는 모습을 보며 참으로 고생하고 있구나 싶습니다.. 오늘은 lambda@Edge로 이
inventer.tistory.com
'기타' 카테고리의 다른 글
뮤텍스와 세마포어 (0) | 2024.06.24 |
---|---|
Redis maxmemory와 eviction정책 (1) | 2024.06.19 |
계층형 모델 테이블 설계(인접 모델, MPTT) (0) | 2024.04.27 |
GraphQL Federation할때 null을 주의하자 (1) | 2024.03.12 |
SQLD 합격 후기 (0) | 2023.12.21 |