이미지 리사이징으로 LCP 개선하기 - CloudFront + Lambda@edge

date
Mar 23, 2024
slug
Improving-lcp-using-cloudfront-and-lambda-edge
author
status
Public
tags
Optimizing Performance
summary
type
Post
thumbnail
shubham-dhage-WcebZ3ivkac-unsplash.jpg
category
updatedAt
Nov 11, 2024 01:58 PM
notion image

Lambda@Edge

서비스 프로모션 페이지에 apng가 들어갑니다. 이미지 크기가 2MB이기 때문에 webp로 변경하였지만 그럼에도 LCP가 7초까지 발생하여 Lambda@Edge로 이미지 리사이징을 통해 LCP를 개선합니다.
함수 만들기에 앞서 함수에 적용할 정책과 역할을 생성해야합니다.
 

정책 생성 : IAM 콘솔 > 정책 > 정책생성

notion image
notion image
notion image
 

역할 생성 : IAM 콘솔 > 역할 > 역할생성

notion image
notion image
notion image
  • 1번에서 만든 정책을 연결합니다.
notion image
notion image
  • 신뢰관계에 엣지람다도 추가해줍니다.
 

함수 생성 : Lambda 콘솔 > 함수 생성

notion image
notion image
  • 2번에서 만들었던 역할을 적용하고 함수를 생성합니다.
 

람다 함수 로컬에서 만들기

npm init -y npm install sharp
위 명령어와 함께 index.js로 만든 함수 적용! 이라면 쉬웠겠지만… 가혹한 트러블 슈팅을 해야했습니다.
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
 

3차 시도

2024-03-22T14:39:10.928Z undefined ERROR Uncaught Exception { "errorType": "ReferenceError", "errorMessage": "require is not defined in ES module scope, you can use import instead", "stack": [ "ReferenceError: require is not defined in ES module scope, you can use import instead", " at file:///var/task/index.mjs:3:21", " at ModuleJob.run (node:internal/modules/esm/module_job:195:25)", " at async ModuleLoader.import (node:internal/modules/esm/loader:336:24)", " at async _tryAwaitImport (file:///var/runtime/index.mjs:1008:16)", " at async _tryRequire (file:///var/runtime/index.mjs:1057:86)", " at async _loadUserApp (file:///var/runtime/index.mjs:1081:16)", " at async UserFunction.js.module.exports.load (file:///var/runtime/index.mjs:1119:21)", " at async start (file:///var/runtime/index.mjs:1282:23)", " at async file:///var/runtime/index.mjs:1288:1" ] }
node18로 올렸다면… 맞닥뜨리게될 esm 관련 에러
esm 모듈시스템 문법에 맞춰 index.js의 코드를 작성해야했습니다.
 

4차 시도

notion image
node 18부터는 aws sdk3을 써야한다는 사실 + esm 방식으로 함수를 재작성 했는데도 에러가 났습니다.
sharp버전을 0.32.6으로 낮췄습니다.
⇒ 람다 함수 테스트는 성공
 
*로컬에서 작성한 람다 함수를 .zip으로 압축한 뒤 업로드하여 테스트 하는 방법
Lambda > Test 탭의 기본 제공 템플릿 중 cloudfront-modify-querystring 선택 후 Test
notion image
notion image
테스트 버튼 클릭시 위와 같이 결과값이 나오며 하이퍼 링크된 logs를 누르면 cloudwatch로 이동해 세세한 로그를 확인할 수 있습니다.
notion image
notion image

5차 시도

AccessDenied…403
AWS S3에서 객체를 가져오려 할 때 권한 문제가 있거나 경로 설정이 잘못되어 접근을 못할 때 나는 에러입니다.
notion image
IAM role / policy & S3 Bucket policy 와리가리 했지만 이것은 문제가 아니였습니다.
아래 같이 찍힌 에러 로그 중 404 NoSuchKey 항목을 보고 객체를 접근하는 경로가 문제가 되고 있음을 파악해 함수 내부를 뜯어보기 시작했습니다.
notion image
handler함수 내부에
const getObjectParams = { Bucket: BUCKET, Key: decodeURI("bus/image/" + imageName + "." + extension), };
Key프로퍼티의 value가 기존에는 ‘/’였으나 해당 버킷의 구조가 /bus/image/이미지들.svg 이런식임을 감안해 수정해주었습니다.

마지막 에러

notion image
query params는 잘 읽으나.. Input file is missing이라는 에러 로그가 확인되었고…
관련해서 서치해본 결과 aws sdk2와 sdk3에서 getObject메서드가 반환하는 값의 형식이 string → ReadableStream 객체로 변경되어 비슷한 이슈가 생성되어있는것을 github repo를 통해 보게되었습니다.
data의 body값으로 온 ReadableStream객체를 Buffer객체로 변환하는
const streamToBuffer = async (stream) => { const chunks = []; for await (const chunk of stream) { chunks.push(chunk); } return Buffer.concat(chunks); };
아래 함수를 추가하여 return된 Buffer를 Sharp함수에 전달하여 이미지를 로드합니다.
Sharp에서 이미지 크기를 조정하고 포맷 변경 후 다시 Buffer객체로 변환한 다음, 변환된 이미지의 크기가 1MB를 초과하지 않으면 처리된 이미지를 base64인코딩하여 HTTP 응답에 포함시키고, 변환된 이미지의 MIME타입을 명시합니다. 최종 응답 객체는 callback을 통해 반환합니다.
기존 esm + sdk3만 적용했던 코드
"use strict"; import querystring from "querystring"; import Sharp from "sharp"; import { S3Client, GetObjectCommand } from "@aws-sdk/client-s3"; const S3 = new S3Client({ region: "ap-northeast-2" }); const BUCKET = "버킷명"; export const handler = async (event, context, callback) => { const { request, response } = event.Records[0].cf; // Parameters are w, h, f, q and indicate width, height, format and quality. const params = querystring.parse(request.querystring); if (!params.w && !params.h) { return callback(null, response); } const { uri } = request; const [, imageName, extension] = uri.match(/\/?(.*)\.(.*)/); if (extension === "gif" && !params.f) { return callback(null, response); } // Init variables let width; let height; let format; let quality; let s3Object; let resizedImage; width = parseInt(params.w, 10) ? parseInt(params.w, 10) : null; height = parseInt(params.h, 10) ? parseInt(params.h, 10) : null; if (parseInt(params.q, 10)) { quality = parseInt(params.q, 10); } 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.GetObjectCommand({ Bucket: BUCKET, Key: decodeURI(imageName + "." + extension), }).promise(); } catch (error) { console.log("S3.GetObjectCommand: ", error); return callback(error); } 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); if (resizedImageByteLength >= 1 * 1024 * 1024) { return callback(null, response); } response.status = 200; response.body = resizedImage.toString("base64"); response.bodyEncoding = "base64"; response.headers["content-type"] = [ { key: "Content-Type", value: `image/${format}`, }, ]; return callback(null, response); };
 

재 작성한 코드

"use strict"; import querystring from "querystring"; import Sharp from "sharp"; import { S3Client, GetObjectCommand } from "@aws-sdk/client-s3"; const S3 = new S3Client({ region: "ap-northeast-2" }); const BUCKET = "버킷명"; const streamToBuffer = async (stream) => { const chunks = []; for await (const chunk of stream) { chunks.push(chunk); } return Buffer.concat(chunks); }; export const handler = async (event, context, callback) => { const { request, response } = event.Records[0].cf; const params = querystring.parse(request.querystring); if (!params.w && !params.h) { return callback(null, response); } const { uri } = request; const [, imageName, extension] = uri.match(/\/?(.*)\.(.*)/); if (extension === "gif" && !params.f) { return callback(null, response); } let width = parseInt(params.w, 10) || null; let height = parseInt(params.h, 10) || null; let quality = parseInt(params.q, 10) || 80; // NaN이면 기본값으로 80을 설정 let format = params.f ? params.f : extension; format = format === "jpg" ? "jpeg" : format; console.log(`params: ${JSON.stringify(params)}`); console.log(`name: ${imageName}.${extension}`); try { const getObjectParams = { Bucket: BUCKET, Key: decodeURI("bus/image/" + imageName + "." + extension), }; const command = new GetObjectCommand(getObjectParams); const data = await S3.send(command); const bodyBuffer = await streamToBuffer(data.Body); let resizedImage = await Sharp(bodyBuffer) .resize(width, height) .toFormat(format, { quality }) .toBuffer(); const resizedImageByteLength = Buffer.byteLength(resizedImage, "base64"); console.log("byteLength: ", resizedImageByteLength); if (resizedImageByteLength >= 1 * 1024 * 1024) { return callback(null, response); } response.status = 200; response.body = resizedImage.toString("base64"); response.bodyEncoding = "base64"; response.headers["content-type"] = [ { key: "Content-Type", value: `image/${format}` }, ]; return callback(null, response); } catch (error) { console.error("Error: ", error); return callback(error); } };
람다 함수 생성 완료!
 

생성한 함수에 트리거 추가

notion image

생성한 람다 함수를 zip으로 압축해서 업로드 한 후 배포

notion image
notion image

테스트

notion image
 
Github Repo 참고하기