서비스 프로모션 페이지에 APNG 이미지가 삽입되어 있는데, 최적화된 WebP로 변경하더라도 이미지 크기가 2MB로 커서 LCP(Largest Contentful Paint)가 최대 7초까지 발생했습니다.
이 문제를 해결하기 위해 Lambda@Edge를 활용한 실시간 이미지 리사이징 을 도입해 성능을 개선한 과정과 트러블슈팅 경험을 정리했습니다.
CloudFront Functions와 Lambda@Edge 중 어떤 것을 사용할지 고민될 수 있습니다.
CloudFront Functions vs Lambda@Edge를 참고하면,
함수 만들기에 앞서 함수에 적용할 정책과 역할을 생성해야합니다.
IAM 콘솔 > 정책 > 정책생성
s3:GetObject 권한 필요초기 셋업
npm init -y
npm install sharp처음엔 간단히 끝날 줄 알았지만, 실제로는 트러블의 연속이었습니다.
cloudfront에서 람다 엣지를 활용하기 위한 레퍼런스를 찾아보면 node버전이 매우 낮고(e.g. node8 / node10) 현재 람다에서 지원하는 런타임 중 node16이 제일 낮다는 것을 감안했을때 아득했습니다.
하지만 해내야죠?
1차 시도
node16으로 런타임 구성 후 블로그글을 참고하여 함수를 만들었습니다.
sharp라이브러리를 install하려고 하자 node version을 18이상으로 올리라는 에러메세지를 만났습니다.
2차 시도
node 18로 런타임 구성을 변경한 후 맞닥뜨린 오류
{
"errorType": "Error",
"errorMessage": "Could not load the "sharp" module using the linux-x64 runtime\nPossible solutions:\n- Ensure optional dependencies can be installed:\n npm install --include=optional sharp\n yarn add sharp --ignore-engines\n- Ensure your package manager supports multi-platform installation:\n See https://sharp.pixelplumbing.com/install#cross-platform\n- Add platform-specific dependencies:\n npm install --os=linux --cpu=x64 sharp\n- Consult the installation documentation:\n See https://sharp.pixelplumbing.com/install",
}해결 방법 → sharp설치시 cross 환경 대응 가능하도록 설치
npm install --arch=x64 --platform=linux --target=18x sharp 12024-03-22T14:39:10.928Z undefined ERROR Uncaught Exception {
2 "errorType": "ReferenceError",
3 "errorMessage": "require is not defined in ES module scope, you can use import instead",
4 "stack": [
5 "ReferenceError: require is not defined in ES module scope, you can use import instead",
6 " at file:///var/task/index.mjs:3:21",
7 " at ModuleJob.run (node:internal/modules/esm/module_job:195:25)",
8 " at async ModuleLoader.import (node:internal/modules/esm/loader:336:24)",
9 " at async _tryAwaitImport (file:///var/runtime/index.mjs:1008:16)",
10 " at async _tryRequire (file:///var/runtime/index.mjs:1057:86)",
11 " at async _loadUserApp (file:///var/runtime/index.mjs:1081:16)",
12 " at async UserFunction.js.module.exports.load (file:///var/runtime/index.mjs:1119:21)",
13 " at async start (file:///var/runtime/index.mjs:1282:23)",
14 " at async file:///var/runtime/index.mjs:1288:1"
15 ]
16}
17node18로 올렸다면… 맞닥뜨리게될 esm 관련 에러 (node18부터는 기본 ESM 환경이기에)
esm 모듈시스템 문법에 맞춰 index.js의 코드를 재작성해야했습니다.
aws-sdk v3 + ESM 호환 필요sharp@0.32.6으로 다운그레이드 후 성공⇒ sharp버전을 0.32.6으로 낮췄습니다.
⇒ 람다 함수 테스트는 성공
*로컬에서 작성한 람다 함수를 .zip으로 압축한 뒤 업로드하여 테스트 하는 방법
Lambda > Test 탭의 기본 제공 템플릿 중 cloudfront-modify-querystring 선택 후 Test
테스트 버튼 클릭시 위와 같이 결과값이 나오며 하이퍼 링크된 logs를 누르면 cloudwatch로 이동해 세세한 로그를 확인할 수 있습니다.
5차 시도 - AccessDenied(403)
AWS S3에서 객체를 가져오려 할 때 권한 문제가 있거나 경로 설정이 잘못되어 접근을 못할 때 나는 에러입니다.
IAM role / policy & S3 Bucket policy를 살펴봤지만 이 문제는 아니였습니다.
아래 같이 찍힌 에러 로그 중 404 NoSuchKey 항목을 보고 객체를 접근하는 경로가 문제가 되고 있음을 파악해 함수 내부를 뜯어보기 시작했습니다.
handler함수 내부에
const getObjectParams = {
Bucket: BUCKET,
Key: decodeURI("bus/image/" + imageName + "." + extension),
};Key프로퍼티의 value가 기존에는 ‘/’였으나 해당 버킷의 구조가 /bus/image/이미지들.svg 이런식임을 감안해 수정해주었습니다.
⇒ NoSuchKey 로그를 보고 S3 Key 경로 수정 필요
Sharp - "Input file is missing”
query params는 잘 읽으나.. Input file is missing이라는 에러 로그가 확인되었고…
getObject().Body는 ReadableStream 반환하기에Sharp에서 바로 처리 불가 → Buffer로 변환 필요하다라는 점을 알게되었습니다.
🔗 관련 stackoverflow
data의 body값으로 온 ReadableStream객체를 Buffer객체로 변환하는
1const streamToBuffer = async (stream) => {
2 const chunks = [];
3 for await (const chunk of stream) {
4 chunks.push(chunk);
5 }
6 return Buffer.concat(chunks);
7};
8아래 함수를 추가하여 return된 Buffer를 Sharp함수에 전달하여 이미지를 로드합니다.
Sharp에서 이미지 크기를 조정하고 포맷 변경 후 다시 Buffer객체로 변환한 다음, 변환된 이미지의 크기가 1MB를 초과하지 않으면 처리된 이미지를 base64인코딩하여 HTTP 응답에 포함시키고, 변환된 이미지의 MIME타입을 명시합니다. 최종 응답 객체는 callback을 통해 반환합니다.
1"use strict";
2import querystring from "querystring";
3import Sharp from "sharp";
4
5import { S3Client, GetObjectCommand } from "@aws-sdk/client-s3";
6
7const S3 = new S3Client({ region: "ap-northeast-2" });
8
9const BUCKET = "버킷명";
10
11export const handler = async (event, context, callback) => {
12 const { request, response } = event.Records[0].cf;
13 // Parameters are w, h, f, q and indicate width, height, format and quality.
14 const params = querystring.parse(request.querystring);
15
16 if (!params.w && !params.h) {
17 return callback(null, response);
18 }
19
20 const { uri } = request;
21 const [, imageName, extension] = uri.match(/\/?(.*)\.(.*)/);
22
23
24 if (extension === "gif" && !params.f) {
25 return callback(null, response);
26 }
27
28 // Init variables
29 let width;
30 let height;
31 let format;
32 let quality;
33 let s3Object;
34 let resizedImage;
35
36 width = parseInt(params.w, 10) ? parseInt(params.w, 10) : null;
37 height = parseInt(params.h, 10) ? parseInt(params.h, 10) : null;
38
39 if (parseInt(params.q, 10)) {
40 quality = parseInt(params.q, 10);
41 }
42
43 format = params.f ? params.f : extension;
44 format = format === "jpg" ? "jpeg" : format;
45
46 // For AWS CloudWatch.
47 console.log(`parmas: ${JSON.stringify(params)}`); // Cannot convert object to primitive value.
48 console.log(`name: ${imageName}.${extension}`); // Favicon error, if name is `favicon.ico`.
49
50 try {
51 s3Object = await S3.GetObjectCommand({
52 Bucket: BUCKET,
53 Key: decodeURI(imageName + "." + extension),
54 }).promise();
55 } catch (error) {
56 console.log("S3.GetObjectCommand: ", error);
57 return callback(error);
58 }
59
60 try {
61 resizedImage = await Sharp(s3Object.Body)
62 .resize(width, height)
63 .toFormat(format, {
64 quality,
65 })
66 .toBuffer();
67 } catch (error) {
68 console.log("Sharp: ", error);
69 return callback(error);
70 }
71
72 const resizedImageByteLength = Buffer.byteLength(resizedImage, "base64");
73 console.log("byteLength: ", resizedImageByteLength);
74
75 if (resizedImageByteLength >= 1 * 1024 * 1024) {
76 return callback(null, response);
77 }
78
79 response.status = 200;
80 response.body = resizedImage.toString("base64");
81 response.bodyEncoding = "base64";
82 response.headers["content-type"] = [
83 {
84 key: "Content-Type",
85 value: `image/${format}`,
86 },
87 ];
88 return callback(null, response);
89};
901"use strict";
2import querystring from "querystring";
3import Sharp from "sharp";
4import { S3Client, GetObjectCommand } from "@aws-sdk/client-s3";
5
6const S3 = new S3Client({ region: "ap-northeast-2" });
7const BUCKET = "버킷명";
8
9const streamToBuffer = async (stream) => {
10 const chunks = [];
11 for await (const chunk of stream) {
12 chunks.push(chunk);
13 }
14 return Buffer.concat(chunks);
15};
16
17export const handler = async (event, context, callback) => {
18 const { request, response } = event.Records[0].cf;
19 const params = querystring.parse(request.querystring);
20
21 if (!params.w && !params.h) {
22 return callback(null, response);
23 }
24
25 const { uri } = request;
26 const [, imageName, extension] = uri.match(/\/?(.*)\.(.*)/);
27
28 if (extension === "gif" && !params.f) {
29 return callback(null, response);
30 }
31
32 let width = parseInt(params.w, 10) || null;
33 let height = parseInt(params.h, 10) || null;
34 let quality = parseInt(params.q, 10) || 80; // NaN이면 기본값으로 80을 설정
35 let format = params.f ? params.f : extension;
36 format = format === "jpg" ? "jpeg" : format;
37
38 console.log(`params: ${JSON.stringify(params)}`);
39 console.log(`name: ${imageName}.${extension}`);
40
41 try {
42 const getObjectParams = {
43 Bucket: BUCKET,
44 Key: decodeURI("bus/image/" + imageName + "." + extension),
45 };
46 const command = new GetObjectCommand(getObjectParams);
47 const data = await S3.send(command);
48
49 const bodyBuffer = await streamToBuffer(data.Body);
50
51 let resizedImage = await Sharp(bodyBuffer)
52 .resize(width, height)
53 .toFormat(format, { quality })
54 .toBuffer();
55
56 const resizedImageByteLength = Buffer.byteLength(resizedImage, "base64");
57 console.log("byteLength: ", resizedImageByteLength);
58
59 if (resizedImageByteLength >= 1 * 1024 * 1024) {
60 return callback(null, response);
61 }
62
63 response.status = 200;
64 response.body = resizedImage.toString("base64");
65 response.bodyEncoding = "base64";
66 response.headers["content-type"] = [
67 { key: "Content-Type", value: `image/${format}` },
68 ];
69
70 return callback(null, response);
71 } catch (error) {
72 console.error("Error: ", error);
73 return callback(error);
74 }
75};
76기쁩니다,,,
Origin Request 이벤트에 연결.zip으로 압축한 후 업로드cloudfront-modify-querystring 템플릿 선택 후 Test 진행Github Repo 코드 보러가기!
https://github.com/reeseo3o/image-resizing-with-lambda-edge