NestJS 기반 웹 서버 serverless로 배포
2020년 07월 01일

AWS Lambda

위 아키텍쳐의 배포 속도 때문에 "이것도 람다에다 올려버릴까"라는 말만 반복하다 본격 작업에 들어가게 되었습니다.

serverless를 활용하여 간단한 크롤링 서비스 AWS Lambda에 배포

위와 같이 serverless를 활용한 경험이 있으므로 이번에도 기왕이면 serverless를 통해 배포하기로 결정했습니다.

기존 서버 시작은 main.ts에서 했는데, handler 시작점을 추가로 잡기 위해 기존 NestFactory 부분을 모듈화하고 serverless.ts 파일을 추가했습니다. 일반 tsc 빌드 후에는 main.js로, serverless에서는 serverless 내의 handler 를 통해 서버를 실행하게 됩니다.

// main.ts async function bootstrap() { const expressApp = express(); const app = await createApp(expressApp); await app.listen(3000); } bootstrap();
// serverless.ts let cachedServer: Server; async function bootstrap(): Promise<Server> { const expressApp = express(); const app = await createApp(expressApp); await app.init(); return awsServerlessExpress.createServer(expressApp); } export const handler: APIGatewayProxyHandler = async (event, context) => { if (!cachedServer) { const server = await bootstrap(); cachedServer = server; return awsServerlessExpress.proxy(server, event, context, "PROMISE") .promise; } else { return awsServerlessExpress.proxy(cachedServer, event, context, "PROMISE") .promise; } };

문제는 tsconfig-paths

환경 셋업 후 local invoke를 해보면 실패합니다. 왜냐하면 @src 등 custom path를 인식 못하기 때문입니다.

기존 같은 경우는 ts-node -r tsconfig-paths/register src/main.ts 등 ts 컴파일 시 직접 등록할 수 있는 방식이었는데, 사용하고 있는 serverless-plugin-typescript로는 한계가 있었습니다.

백엔드에도 webpack을 적용

리서치 해보니 이 이슈는 webpack 적용으로 해결할 수 있다고 합니다. 그러하기 위해선 기존의 serverless-plugin-typescript 대신 serverless-webpack으로 빌드 방식을 교체 후 webpack 설정 내에 ts-loader로 typescript를 빌드하는 방식으로 대폭 수정이 필요합니다. 그 후 alias 옵션을 맞춰서 추가해주면 됩니다.

// webpack.config.js module.exports = { ... resolve: { extensions: [".tsx", ".ts", ".js"], alias: { "@src": path.resolve(__dirname, "src/") } } }

추가 이슈 1 : TypeORM - require.context()

typeorm은 entity, migraiton 등을 glob 패턴 혹은 클래스 나열 방식으로 받아들이는데 당연히 glob 패턴으로 써왔다. 그러나 이 방식은 webpack으로 번들링하는 순간 무용지물이 됩니다.

우선 TypeORM에 webpack 관련 FAQ가 있었으나 이 케이스에서는 적용에 실패했습니다.

typeorm/typeorm

또는 클래스, 마이그레이션을 전부 import하여 array에 나열하거나, 이 파일들은 번들링에서 제외시키는 방법이 있지만 둘 다 마음에 들지 않았습니다.

리서치 결과 require.context()라는 webpack에 있는 동적 모듈 로드로 적용했습니다. 그 때문에 build는 이제부터 반드시 webpack으로 해야만 합니다.

// 동적 로드 const entityContexts = (require as any).context( "./entities", true, /\.entity.ts$/ ); const entities = entityContexts .keys() .map((modulePath) => entityContexts(modulePath)) .reduce( (result, entityModule) => result.concat(Object.keys(entityModule).map((key) => entityModule[key])), [] ); // 수동 나열 import { User } from "./entities/user.entity"; import { Deck } from "./entities/deck.entity"; import { Post } from "./entities/post.entity"; const entities = [User, Deck, Post];

node_modules 인식 불가

local invoke, sls offline 등에서는 작동하더라도, 실제로 sls deploy로 배포해보니 동작하지 않습니다. 이는 packaging 과정에서 node_modules에 해당하는 모듈들이 zip에 패키징 되지 않았기 때문이며, #1 방식처럼 옵션을 추가하면 zip 파일 내에 node_modules가 첨부됨을 알 수 있습니다.

그러나 정작 실행해보니 뜬금없이 TypeORM에서 mysql을 못찾는다는 에러가 떴습니다. 이는 TypeORM 내 에서 mysql을 동적으로 require하기 때문에 serverless-webpack단에서의 스캐닝 과정에서 생략된 것이 원인입니다. 이러한 module 등은 직접 include해야 합니다.

# 1 #custom: # webpack: # includeModules: true # 2 custom: webpack: includeModules: forceInclude: - mysql <- typeorm에서 내부적으로 mysql이 조건부 require ( dynamic) 하게 된듯, 직접 해줘야함

layer를 통한 최적화

이리하여 우선 배포는 성공했으나 파일 사이즈가 너무 큽니다. 이는 배포 속도의 저하를 가져옵니다. 리서치 해본 결과 AWS Lambda에는 layers라는 기능을 제공한다고 합니다. 작년 초에 소개된 기능으로 비교적 최근입니다. serverless 단에서 layers 기능을 제공하긴 하지만 부차적인 처리( package.json 체크, production 빌드 ) 등을 해주는 serverless-layers를 쓰면 더 좋습니다.

serverless-layers는 원하는 path를 잡아 ( package.json ) 변동이 있을 시에만 layer를 업데이트합니 다.

agutoli/serverless-layers

이렇게 node_modules를 layer로 분리하여 배포 소요 시간 20~30초, 용량은 약 100kb로 정리했습니다.

Serverless ≠ MSA

처음에는 MSA도 생각은 해보았지만 이 인력으로 한다는게 불가능하고, 위와 같이 Lambda를 활용하면서도 모놀리식 서버리스 구조도 많은 것으로 보아 이렇게 진행하기로 했습니다. 전통적인 구조에서 EC2, ELB만 Lambda로 대체된 구조입니다.

AWS SAM을 이용한 모놀리식 서버리스 어플리케이션 운영하기

github action을 통한 배포 자동화

cli를 통한 배포가 성공했으니 github action에 넣어 배포 자동화를 할 차례입니다.

우선은 development branch에 push시에만 serverless로 배포하도록 구획을 잡았습니다.

삽질 : enterprise login ?

development branch에 푸시 후 github action 의 한 장면.

에러메시지의 url을 따라가보면 이런 페이지가 나옵니다.

Serverless Dashboard - CI/CD

우선 Serverless Dashboard(enterprise)는 AWS, github을 연동하여 cli 및 gui 기반 배포, 배포 후 통계 등 좋은 기능들이 많습니다. 단순한 라이브러리 인줄 알았는데 하나의 플랫폼 급이었습니다.

그러나 단순히 workflow 단에서의 자동화만 하고 싶은데, 이런 식이면 enterprise 사용을 강제하는건가 하는 느낌까지 받았습니다. 옆에 billing이란 단어를 봤을때 특히 더.

관련 글을 찾아보면 yml에 org 필드 등을 지워야 enterprise로 인식 안된다는 글이 대부분인데 이 케이스는 해당되지 않았습니다. 어떻게 해도 같은 메시지였고, 오히려 org 필드를 추가하니 에러메시지가 달라졌습니다.(해결x) - 여기서 enterprise로의 인식 자체에 대한 문제임은 확신하게 되었습니다.

분명히 env에 키값을 넣으면 sls login을 하지 않아도 동작해야 되는데...

로컬 브라우저에서 직접 로그인 과정까지 거쳐도 결과는 동일했습니다. 여기서 100% 로그인 이슈는 아닌 것 같다는 느낌이 들었고, 에러 스택을 계속 찾아 들어가보았습니다.

serverless/enterprise-plugin

// lib/variables.js:20 in serverless/enterprise-plugin if (!ctx.sls.enterpriseEnabled) throwAuthError(ctx.sls);

serverless 실행 후 enterprise라 판단하여 enterprise-plugin으로 접근했는데, 여기서 enterprise가 활성화되어 있지 않아서 throwAuthError를 내버린 상황이었다. 그러니 처음에 로그인이 안되는 이슈로 생각했던 것이다.

결론 - SERVERLESS_ACCESS_KEY 환경 변수를 제외 : 이 값은 enterprise에서 사용하는 값이고 그렇게 때문에 저 값이 있으면 enterprise로 인식되어 생긴 문제였습니다.

이슈 : lambda layer 서울 리전 미지원

걸리는 점이 있다면 lambda의 layers 기능은 ap-northeast-2(seoul) 에서는 지원하지 않아 우선 도쿄 리전에 배포했다는 점입니다. 이러면 분명 네크워크 상의 손해가 있을텐데, layer 기능이 서울 리전에서 제공하는 것을 기다리거나, layer만 도쿄에 배포하고 메인 함수는 서울 리전에 배포하는 등이 가능한지 추가적인 리서치가 필요합니다.

로컬 환경

처음에는 serverless-offline 라이브러리를 활용해 로컬 환경을 갖추려고 했습니다. serverless에서 local invoke라는 기능을 제공하기는 합니다. 그러나 1회성 실행이 아닌 웹서버를 테스트하기에는 적절하지 않았습니다. serverless-offline 같은 경우는 aws 기준 API Gateway와 Lambda를 시뮬레이션한 서버를 실행시켜 테스트하기 용이하게 되어있습니다.

로컬 환경에서는 무엇보다 수정한 코드를 바로 확인할 수 있는 환경이 중요한데, 문제는 serverless-offline의 watch 기능이 cli 상에서는 동작하는 듯 보이나 실제로 반영되지 않는 문제가 있었습니다.

Serverless-Webpack watching for changes not working · Issue #931 · dherault/serverless-offline

찾아보니 useChildProcesses 옵션을 true로 주면 된다고 하는데 로컬 프론트와 연계하여 테스트를 해보니, 두 번 call되거나 재시작이 너무 느린 이슈 등 불안정했습니다.

그리하여 로컬 환경에서는 배포 환경과 달리 webpack을 직접 빌드 후 HotModuleReplacementPlugin을 사용하는 것으로 결론지었습니다.

#local webpack --watch #development serverless deploy --stage development #production serverless deploy --stage production