NestJS API Service deploy with AWS Lambda and Serverless Framework
NestJS 를 AWS Lambda 로 배포하기
  • Infra

시작하기에 앞서

스터디에서 혹은 개인, 팀을 이뤄서 프로젝트를 하는 경우가 종종 있습니다. 그럴 때 고민되는 게 API 서버를 어디에 어떻게 올릴 것인가에 대한 문제이죠.
테스트, 개인용 프로젝트인데 AWS에서 저렴하게 EC2 하나를 생성하더라도 한 달에 최소 몇 만원 정도는 비용이 발생합니다.

이 비용조차 아까워지기 마련이죠. 그래서 더 저렴한 방법은 없을까 고민해 보았어요.

Why Lambda?

API 서버를 띄우기 위해 AWS EC2를 받았다고 가정해 봅시다.

image

메모리가 2기가 정도는 되어야 하기에 t4g.small사양으로 할당받았다고 가정해 보면,
한 달 사용료는 0.0208 * 24 * 30 = $14.976 가 발생합니다. 대략 2만원 정도 발생합니다.

하루에 1~2번 호출될 서버에 2만원도 과하다고 생각할 수 있어요.

그럼, AWS Lambda 가격도 살펴보겠습니다.

image lambda 는 무기한 프리티어를 제공합니다.
즉, 월별 100만건 요청까지는 무료이고, 그 이후로는 100건당 $0.20 가 발생합니다.
($0.20 per 1 million requests thereafter, or $0.0000002 per request)

이는 사이드프로젝트에서 사용하기에 충분한 요청량으로 보이고 또한 월 100만건 요청이 들어오고, 추가 요청까지 들어온다면 충분히 요금을 더 낼 의사가 있습니다. (사이드프로젝트의 성공일까요)

요청이 1건이던 100건이던 상관없이 항상 서버 비용을 내야하는 EC2와 비교해서, 실행 횟수에 따라 요금을 책정하는 Lambda는 사이드프로젝트에서 사용하기 더 적절해보입니다.
물론 Lambda의 요금 책정은 단순히 요청 횟수 뿐 아니라, 호출에 대한 실행시간도 비용이 영향을 미칩니다.

실행 시간은 요청이 들어오고, 리턴이 나가는 시간까지의 비용입니다. 아래에서 더 자세히 설명합니다.

image

월별 10,000건의 호출이 호출당 2초가 걸린다고 가정해보고 계산해보아도 월 0원이 발생하는걸 볼 수 있습니다.

(참고) AWS Pricing

AWS Lambda Service

그럼 위에서 설명한 Lambda는 뭐하는 서비스이길래 EC2보다 더 저렴하게 사용할 수 있을까요?

Lambda Service에 대해 AWS 공식 홈페이지에는 아래와 같이 설명하고 있습니다.

Lambda는 서버를 프로비저닝하거나 관리하지 않고도 코드를 실행할 수 있게 해주는 컴퓨팅 서비스입니다. 
Lambda는 고가용성 컴퓨팅 인프라에서 코드를 실행하고 서버와 운영 체제 유지 관리, 용량 프로비저닝 및 자동 조정, 코드 및 보안 패치 배포, 로깅 등 모든 컴퓨팅 리소스 관리를 수행합니다. 
Lambda를 사용하면 거의 모든 유형의 애플리케이션 또는 백엔드 서비스에 대한 코드를 실행할 수 있습니다.

즉, serverless로 함수를 실행할 수 있도록 해주는 AWS 서비스입니다.
(cloud에 많은 serverless라고하는 서비스들은 실제로 서버가 없다는 뜻은 아닙니다. 사용자가 서버를 관리하지 않아도 된다는 의미지요)

여기서 중요한건 함수 를 실행하는 것입니다.

exports.handler =  async function(event, context) {
  console.log("EVENT: \n" + JSON.stringify(event, null, 2))
  return context.logStreamName
}

handler 함수를 통해 event를 받아 어떠한 작업을 한 후 return값을 돌려줄 수 있습니다.

하지만, 우리가 실행하고싶은건 단순히 함수 1개가 아니라 서버였습니다. (endpoint가 무수히 많은 api 서버 1대 말이죠.)
그래서 예전에는 실제로 한개의 웹/앱 서비스를 구성하기위해 무수히 많은 lambda 함수를 사용하기도 했었습니다.

본 글에서는 이 방법이 아닌 Serverless npm 모듈을 사용해서 함수가 아닌 NestJS 프로젝트를 Lambda 한개의 서비스로 담아 배포하는 방법에 대해 설명하려 합니다.

(참고) AWS Lambda

그럼 Lambda가 무조건 좋은걸까?

결론부터 말씀드리면 꼭 그렇지는 않습니다.
이런 Lambda역시 단점이 존재합니다. 바로 Cold Start의 문제입니다.

Lambda 의 실행순서를 보면 다음과 같습니다.

image

Lambda 함수 호출되면 ECR 혹은 S3에서 소스코드를 읽어와서 실행을 준비합니다. 그리고 설정된 서버구성(RAM 등)을 읽어와 서버를 구성합니다. 마지막으로 Hander코드를 통해 함수가 실행된 후 결과를 리턴하죠.

즉, 소스코드를 읽어와 서버를 구성하고 실행하는데까지 어느정도의 시간이 필요합니다. 이렇게 실행되는것을 Cold Start 라고 하고, 이 과정으로 인해 Latency 가 발생합니다.

Lambda는 함수 호출이 끝난 다음에는 해당 서버를 즉시 내리지 않고 잠시 기다립니다. 서버가 완전히 내려가기 전에, 해당 함수가 다시한번 더 호출되면 이번엔 소스코드를 읽어오는 과정이 생략되고 서버를 재사용하여 함수가 즉시 실행될 수 있습니다. 그리고 이렇게 실행되는걸 Warm Start라고 부릅니다.
이 때에는 서버구성하는 과정이 빠지기 때문에 Cold Start보다 더 빠르고, Latency 가 발생하지 않습니다.

image (출처: AWS)

이처럼 자주 사용되는 함수는 Warm Start로 빠르게 응답을 주는 반면, 가끔 호출되는 함수의 경우 Cold Start로 실행되어 첫 호출에는 결과받는데 시간이 조금 걸립니다 (1초 이상 걸릴 수 있습니다)

그리고 Warm Start로 게속 실행되는 함수 (주기적으로 호출되어서)라고 하더라도 lambda에 새로운 코드가 배포되면 Cold Start로 동작합니다.
그 외에도 동시성에 대한 호출 역시 Cold Start로 실행될 수 있는데, 아래 링크의 AWS 공식문서를 참고 바랍니다.

참고

NestJS, Serverless, AWS Lambda

NestJS

NestJS는 TypeScript 언어를 사용해 API 서버를 손쉽게 구성할 수 있는 Framework입니다.

Serverless

Serverless 는 어떤 프로젝트를 serverless 로 실행할 수 있도록 구성해주는 프레임워크 입니다. (여기서는 NestJS 를 사용하지만 express와 같은 다른 프로젝트도 모두 사용가능합니다.)
이 npm 모듈을 사용해서 NestJS를 serverless로 실행할 수 있는 구성을 하고, Lambda로 배포할 수 있습니다.

(참고)

환경 구성

NestJS Application 프로젝트 생성

nest cli 명령어를 통해 프로젝트를 생성해줍니다.
nest new aws-sample

만약 nest 명령어가 없다면 nest cli 를 먼저 설치합니다.
npm install -g @nestjs/cli

nest start 롤 통해 생성된 서버를 실행할 수 있고, curl http://localhost:3000으로 잘 실행되는지 확인합니다.

AWS account 설정

image

코드 배포를 위해 AWS 사용자를 생성합니다. AWS의 IAM 서비스로 들어가 새로운 사용자를 생성하고, keyId, secretId 를 잘 저장합니다.

image

사용자를 추가하고, 필요한 권한을 추가합니다.
(본 글에서 Serverless를 통해 AWS Lambda 서비스로 배포하기 위해서는, S3, Lambda, API Gateway 등의 권한이 필요합니다)

(참고) create user on AWS IAM

NestJS Application 배포를 위한 serverless 설정

image

마지막으로 NestJS 프로젝트에 AWS Lambda로 배포하기위해 Serverless Framework 를 세팅합니다.

우선 serverless를 설치합니다.

$npm i -g serverless

이후 설치된 serverless 는 sls 라는 명령어를 통해 실행이 가능합니다.

구성을 위해 몇가지 파일을 추가합니다.

  1. serverless 가 배포될 때 실행할 함수, 배포할 타겟을 지정
    serverless.yaml
service: sample-project

plugins:
  - serverless-plugin-typescript
  - serverless-plugin-warmup
  - serverless-offline

provider:
  name: aws
  region: ap-northeast-2
  stage: dev
  runtime: nodejs16.x
    ecr:
      images:
        appimage:
          path: ./

functions:
  main:
    handler: src/lambda.handler
    events:
      - http:
         method: any
         path: /{any+}

서울로 배포하기위해 region 설정을 ap-northeast-2를 사용합니다.
stage 설정을 통해 env를 분리해서 사용할 수 있습니다.

  1. 일부 오류처리를 위한 tsconfig 설정 추가
    tsconfig.json
"tsBuildInfoFile": ".tsbuildinfo",
"esModuleInterop": false

(아래 자세한 오류 내용 설명)

  1. labmda로 요청들어오는 event를 nestjs 컨트롤러로 연결하기 위한 설정
    src/lambda.ts
import { Handler, Context } from 'aws-lambda';
import { Server } from 'http';
import { createServer, proxy } from 'aws-serverless-express';
import { eventContext } from 'aws-serverless-express/middleware';

import { NestFactory } from '@nestjs/core';
import { ExpressAdapter } from '@nestjs/platform-express';
import { AppModule } from './app.module';

// eslint-disable-next-line @typescript-eslint/no-var-requires
const express = require('express');

const binaryMimeTypes: string[] = [];

let cachedServer: Server;

async function bootstrapServer(): Promise<Server> {
  if (!cachedServer) {
    const expressApp = express();
    const nestApp = await NestFactory.create(
      AppModule,
      new ExpressAdapter(expressApp),
    );
    nestApp.use(eventContext());
    await nestApp.init();
    cachedServer = createServer(expressApp, undefined, binaryMimeTypes);
  }
  return cachedServer;
}

export const handler: Handler = async (event: any, context: Context) => {
  cachedServer = await bootstrapServer();
  return proxy(cachedServer, event, context, 'PROMISE').promise;
};

오류 처리

Running "serverless" from node_modules
Compiling with Typescript...
Using local tsconfig.json - tsconfig.json
{
  file: undefined,
  start: undefined,
  length: undefined,
  messageText: "Option '--incremental' can only be specified using tsconfig, emitting to single file or when option '--tsBuildInfoFile' is specified.",
  category: 1,
  code: 5074,
  reportsUnnecessary: undefined,
  reportsDeprecated: undefined
}
Environment: darwin, node 16.13.2, framework 3.25.1 (local) 3.25.1v (global), plugin 6.2.2, SDK 4.3.2
Docs:        docs.serverless.com
Support:     forum.serverless.com
Bugs:        github.com/serverless/serverless/issues

Error:
TypeError: Cannot read properties of undefined (reading 'getLineAndCharacterOfPosition')

tsconfig.json에 "tsBuildInfoFile": ".tsbuildinfo", 설정을 통해 해결합니다.

ANY /dev/test (λ: main)
✖ Unhandled exception in handler 'main'.
✖ TypeError: (0 , express_1.default) is not a function
      at bootstrapServer (/Users/byun/Desktop/program/github/time-manager/api/.build/src/lambda.js:14:50)
      at handler (/Users/byun/Desktop/program/github/time-manager/api/.build/src/lambda.js:23:26)
      at InProcessRunner.run (file:///Users/byun/Desktop/program/github/time-manager/api/node_modules/serverless-offline/src/lambda/handler-runner/in-process-runner/InProcessRunner.js:87:20)
      at async MessagePort.<anonymous> (file:///Users/byun/Desktop/program/github/time-manager/api/node_modules/serverless-offline/src/lambda/handler-runner/worker-thread-runner/workerThreadHelper.js:24:14)(0 , express_1.default) is not a function

tsconfig.json에 "esModuleInterop": false 설정을 통해 해결합니다.

AWS profile 설정

serverless 배포를 위해 위 AWS에서 만든 profile key정보를 설정합니다.

명령어를 통해 프로필을 설정할 수 있습니다.

sls config credentials --provider aws --key {keyId} --secret ${secretId}

실행하고나면 ~/.aws 경로에 credentials 파일이 생성됩니다. 열어보면 default 가 생겨있을거에요.

ex)

[default]
aws_access_key_id=...
aws_secret_access_key=...

만약 이미 파일이 있어서 위 명령어 실행이 안된다면, -o 옵션을 붙여주면 됩니다. (override option)

Deploying to AWS

마지막으로 sls deploy 명령어를 통해 배포할 수 있습니다.

image

AWS Lambda로 가보면 잘 배포된걸 확인할 수 있습니다.

전체가 동작하는 과정은 아래 그림과 같습니다.

image

참고