NestJS + AWS SAM 으로 백엔드 배포하기

jiffydev·2022년 8월 23일
1

배경

기존 백엔드 코드를 Next.js에서 NestJS로 분리하고 AWS SAM으로 배포하는 업무를 맡게 되었습니다.

이 과정에서 NestJS와 SAM 각각에 대해서는 참고할 수 있는 자료가 어느정도 있었지만, 이 둘을 함께 사용한 케이스는 거의 없었습니다.

그로 인해 발생한 에러를 수많은 시행착오 끝에 해결하고 빌드와 배포에 성공하였기에, 이 조합으로 serverless application을 배포할 누군가에게는 도움이 되었으면 하는 마음에 이번 포스트의 주제로 선정하였습니다.

NestJS 세팅

nest/cli만 설치해주면 즉시 새로운 NestJS 프로젝트를 시작할 수 있습니다.

$ npm i -g @nestjs/cli
$ nest new nestjs-sam # project name

프로젝트를 생성하면 아래처럼 NestJS에서 미리 만들어둔 구조의 boilerplate로 생성됩니다.

.
├── README.md
├── nest-cli.json
├── package-lock.json
├── package.json
├── src
│   ├── app.controller.spec.ts
│   ├── app.controller.ts
│   ├── app.module.ts
│   ├── app.service.ts
│   └── main.ts
├── test
│   ├── app.e2e-spec.ts
│   └── jest-e2e.json
├── tsconfig.build.json
└── tsconfig.json

AWS SAM 설정

공식문서에는 Serverless Framework로 된 예제만 있고 SAM 예제가 없었기 때문에 둘이 많이 다른 것으로 착각했지만, 오히려 초기 세팅이 거의 비슷하여 공식문서에서도 충분히 도움을 받을 수 있습니다. (처음에 이것을 몰라 시간을 많이 낭비했습니다.🥲)

Prerequisites

  • AWS 계정과, 리소스를 관리할 수 있는 IAM role
  • Docker (필수는 아니지만 로컬에서 테스트하기 위해 필요)
  • AWS credentials (설정하는 방법)
  • SAM CLI (설치 방법: macOS Windows)

SAM CLI를 설치했다면 배포 시 AWS 리소스 설정을 위해 루트 디렉터리에 template.yaml 파일을 생성해 아래와 같이 입력합니다.

AWSTemplateFormatVersion: '2010-09-09'
Transform: 'AWS::Serverless-2016-10-31'
Description: A starter AWS Lambda function.

Resources:
  ExampleFunction:
    Type: 'AWS::Serverless::Function'
    Properties:
      Handler: dist/lambda.handler
      Runtime: nodejs14.x
      Description: A starter AWS Lambda function.
      MemorySize: 128
      Timeout: 10
      Events:
        Api:
          Type: Api
          Properties:
            Path: /
            Method: GET

Outputs:
  WebEndpoint:
    Description: "API Gateway endpoint URL for Prod stage"
    Value: !Sub "https://${ServerlessRestApi}.execute-api.${AWS::Region}.amazonaws.com/Prod/"

이렇게만 설정해도 배포할 때 SAM이 알아서 Lambda와 API Gateway와의 연결 등의 작업을 대신해 줍니다. 물론 좀 더 세밀한 설정(커스텀 도메인 추가나 Authorizer 설정 등)을 위해 직접 template.yaml 에 추가해 줄 수도 있습니다.

그리고 Lambda로 서버를 돌릴 때는 handler 함수를 통해 전달되도록 할 예정이기 때문에 lambda.ts 라는 파일을 main.ts 와 같은 경로에 추가해 주도록 하겠습니다. 이 때 필요한 패키지를 다음과 같이 설치합니다.

$ npm i @vendia/serverless-express aws-lambda
$ npm i -D @types/aws-lambda

그리고 serverless-express 패키지가 원활하게 동작할 수 있도록 ts-config.json"esModuleInterop": true 를 추가해 줍니다.

이제 lambda.tsmain.ts 를 작성합니다.

// lambda.ts
import { bootstrap } from "./main";
import serverlessExpress from "@vendia/serverless-express";
import { Callback, Context, Handler } from "aws-lambda";

let server: Handler;

export async function handler(event: any, context: Context, callback: Callback) {
    const app = await bootstrap();
    await app.init();
    const expressApp = app.getHttpAdapter().getInstance();

    server = server ?? serverlessExpress({ app: expressApp });
    return server(event, context, callback);
}
// main.ts
import { NestFactory } from "@nestjs/core";
import { AppModule } from "./app.module";

export async function bootstrap() {
    const app = await NestFactory.create(AppModule);
    return app;
}

async function run() {
    const nestApp = await bootstrap();
    await nestApp.listen(3000);
}

run();

공식문서의 예제와 다른 점은 run() 함수가 추가되었다는 점입니다.

이 함수가 없으면 개발할 때도 SAM CLI로 서버를 띄워서 작업해야 하는데, 아무래도 로컬에서 직접 띄워서 하는 것보다는 시간도 걸리고 번거롭다는 느낌이 들어 추가하게 되었습니다.

여기까지 왔으니 우선 로컬에서 잘 실행되는지 테스트해보겠습니다.

npm run start:dev
# 다른 터미널에서
curl http://localhost:3000/

이제 빌드와 배포를 하면 되는데, 그 전에 빌드 시 생성되는 .aws-sam 을 인식하지 못하도록 .gitignore 에 추가해 줍니다.

SAM 빌드하고 테스트하기

SAM에서는 로컬에서 실제로 Lambda를 돌리는 것처럼 테스트할 수 있는 도구를 제공해 줍니다.

이를 위해서는 SAM build 과정이 필요한데, 이 빌드 과정부터가 난관이었습니다.

우선 위에서 작성한 코드를 토대로 NestJS 코드를 npm run build 로 빌드한 후 sam build 를 실행하면 빌드 자체에는 문제가 없어 보입니다. 그러나 테스트를 위해 sam local start-api 를 실행하고 curl로 request를 보내면 다음과 같은 이상한 에러가 나오는 것을 확인할 수 있습니다.

가장 핵심이 되는 부분은 에러 메시지의 Error: Cannot find module 'lambda' 인데, SAM build에서 생성된 .aws-sam 폴더를 보면 위에서 생성한 NestJS 앱의 모든 데이터가 transpile 없이 들어가 있는 것을 볼 수 있습니다.

이는 template.yaml 에서 설정한 handler의 경로와도 다를뿐더러, SAM은 .ts파일을 읽을 수 없기 때문에 경로를 수정하더라도 여전히 에러가 발생하게 됩니다. 이를 해결하기 위한 방법을 몇가지 소개해 보고자 합니다.

1. webpack

NestJS 공식문서의 serverless로 배포하기에서도 webpack 관련 내용을 찾을 수 있습니다. 그런데 SAM에서는 적용이 안 되는 부분이 있어, 🐶고생한 끝에 빌드에 성공할 수 있었습니다.

@nestjs/cli가 있다면 이미 dependency로 webpack이 설치되어 있기 때문에 따로 설치할 패키지는 없습니다.

우선 webpack.config.js 를 root 디렉터리에 생성하여 아래와 같이 작성합니다.

const path = require('path');

module.exports = (options, webpack) => {
  const lazyImports = [
    '@nestjs/microservices/microservices-module',
    '@nestjs/websockets/socket-module',
  ];

  return {
    entry: './src/main',
    target: 'node',
    ...options,
    externals: [],
    module: {
      rules: [
        {
          test: /\.ts?$/,
          use: {
            loader: 'ts-loader',
            options: { transpileOnly: true },
          },
          exclude: /node_modules/,
        },
      ],
    },
    output: {
      path: path.resolve(__dirname, 'dist'),
      libraryTarget: 'commonjs2',
    },
    plugins: [
      ...options.plugins,
      new webpack.IgnorePlugin({
        checkResource(resource) {
          if (lazyImports.includes(resource)) {
            try {
              require.resolve(resource);
            } catch (err) {
              return true;
            }
          }
          return false;
        },
      }),
    ],
  };
};

webpack을 사용하게 되면 template.yaml 에서 설정한 handler 경로에서 문제가 발생합니다. 왜냐하면 webpack으로 코드를 번들링 하게 되면 main.js 파일 하나로 나오게 되는데, 저희는 lambda.ts 에서 handler를 설정했기 때문입니다. 이에 따라 main.js 에서는 handler를 import 할 수 없다는 에러가 발생했습니다. (template.yaml 에서 handler 경로를 수정하더라도 발생)

따라서 위에서 나눴던 main.tslambda.ts 를 합치는 방식으로 해결하게 되었습니다.

// main.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import serverlessExpress from '@vendia/serverless-express';
import { Callback, Context, Handler } from 'aws-lambda';

export async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  return app;
}

let server: Handler;

export async function handler(
  event: any,
  context: Context,
  callback: Callback,
) {
  const app = await bootstrap();
  await app.init();
  const expressApp = app.getHttpAdapter().getInstance();

  server = server ?? serverlessExpress({ app: expressApp });
  return server(event, context, callback);
}

async function run() {
  const nestApp = await bootstrap();
  await nestApp.listen(3000);
}

if (!['PRODUCTION', 'STAGING'].includes(process.env.ENV)) {
  run();
}

맨 아래의 if 문은, 로컬에서 SAM으로 테스트할 경우 환경변수로 막아주지 않으면 handler(), run() 모두 실행되기 때문에 추가하게 되었습니다.

위의 변경 사항과 더불어, SAM local에서는 template에 있는 환경변수만 적용되기 때문에(출처) template.yaml 에 이와 관련된 내용을 추가합니다.

...(생략)
Resources:
  ExampleFunction:
    Type: 'AWS::Serverless::Function'
    Properties:
      CodeUri: ./dist
      Handler: main.handler
      Runtime: nodejs14.x
      Description: A starter AWS Lambda function.
      MemorySize: 128
      Timeout: 10
      Environment:
        Variables:
          ENV: STAGING
      Events:
        Api:
          Type: Api
          Properties:
            Path: /
            Method: GET
...(생략)

이것으로 빌드와 테스트 준비는 끝났습니다. 아래의 커맨드로 NestJS와 SAM 모두 빌드해줍니다.

$ nest build --webpack
$ sam build

그리고 sam local start-api 커맨드를 실행하면 로컬에서 lambda가 돌아가는 것처럼 테스트할 수 있습니다. (Docker 필요)

⚠️ webpack을 사용할 경우 로컬에서 SAM 없이 서버를 돌릴 때 반드시 --webpack 옵션을 넣어주어야 합니다. 그러지 않으면 webpack으로 빌드한 main.jsdist에서 사라지게 됩니다.
이를 방지하려면 package.json 에서 {"start:dev": "nest start --watch"}로 되어 있는 것을 {"start:dev": "nest start --webpack --watch"}로 수정하면 됩니다.

2. makefile

SAM에서는 makefile을 통한 빌드 방식도 제공하고 있습니다. webpack을 사용할 수 없는 경우 채택하는 방법인데, 빌드 속도가 느리다는 점과 node_modules도 같이 가져와야 하므로 배포 시 매우 무거워진다는 단점이 있습니다.

특히 압축 해제 시 250MB 이상일 경우 배포 자체가 안되고 다른 방법을 사용해야 하므로 주의가 필요합니다. 다만 이 문제에 대해서는 밑에서 다룰 layer 사용으로 해결이 가능합니다.

makefile을 사용하는 경우 webpack과 다르게 기존의 lambda.ts 를 수정할 필요는 없습니다.

우선은 template.yaml 파일에 빌드 방식을 추가해 줍니다.

...생략
Resources:
  ExampleFunction:
    Type: 'AWS::Serverless::Function'
    Metadata:
      BuildMethod: makefile
    Properties:
      Handler: dist/lambda.handler
      Runtime: nodejs14.x
      Description: A starter AWS Lambda function.
      MemorySize: 128
      Timeout: 10
      Events:
        Api:
          Type: Api
          Properties:
            Path: /
            Method: GET
...생략

BuildMethod로 makefile을 사용한다고 지정했으니 어떤 커맨드를 사용할지 Makefile을 통해 설정합니다.

.PHONY: build-lambda-common build-ApiFunction

build-ExampleFunction:
	$(MAKE) HANDLER=src/main.ts build-lambda-common

build-lambda-common:
	npm install
	rm -rf dist
	nest build
	cp -r dist "$(ARTIFACTS_DIR)/"
	cp -r node_modules "$(ARTIFACTS_DIR)/"

여기서 ARTIFACTS_DIR 은 빌드되는 아티팩트(여기서는 Lambda)의 디렉터리를 뜻합니다. .aws-sam/build 에서 보이는 디렉터리들이 모두 ARTIFACTS_DIR 입니다.

이것으로 빌드와 테스트 준비는 끝났습니다. 아래의 커맨드로 NestJS와 SAM 모두 빌드해줍니다.

$ npm run build
$ sam build

그리고 sam local start-api 커맨드를 실행하면 로컬에서 lambda가 돌아가는 것처럼 테스트할 수 있습니다. (Docker 필요)

3. SAM에서 제공하는 esbuild (베타버전)

이 방법은 베타버전이기 때문에 아직 production에 적용하기는 어려울 것으로 보여 시도하지는 않았습니다.

다만 공식문서에 상세한 설명이 나와 있기 때문에 이를 참고하면서 작성할 수 있으리라 생각합니다.

덤: makefile로 빌드 시 node_modules 분리하기

makefile을 통해 빌드하게 되면 .aws-sam 에는 node_modules도 함께 들어가게 됩니다. 이렇게 되면 배포할 용량이 커지므로 프로젝트가 확장될수록 배포에 오랜 시간이 걸리게 됩니다.

그렇기 때문에 Lambda에서는 이미 Lambda layer(계층)를 통해 필요한 패키지와 의존성을 분리할 수 있는 기능을 제공하고 있습니다.

SAM에서도 이를 사용할 수 있으며, 다음과 같은 과정을 거치게 됩니다.

우선 template.yaml 에서 layer를 생성할 수 있도록 세팅해 줍니다.

# Resources 아래 추가
Resources:
  ExampleFunction:
    Type: 'AWS::Serverless::Function'
    Metadata:
      BuildMethod: makefile
    Properties:
      Handler: dist/lambda.handler
      Runtime: nodejs14.x
      Description: A starter AWS Lambda function.
      MemorySize: 128
      Timeout: 10
      Events:
        Api:
          Type: Api
          Properties:
            Path: /
            Method: GET
      Layers:
        - !Ref RuntimeDependenciesLayer

	RuntimeDependenciesLayer:
    Type: AWS::Serverless::LayerVersion
    Metadata:
      BuildMethod: makefile
    Properties:
      Description: Runtime dependencies for Lambdas
      ContentUri: ./
      CompatibleRuntimes:
        - nodejs14.x
      RetentionPolicy: Retain

다음으로 Makefile에도 layer를 생성하는 커맨드를 추가합니다.

.PHONY: build-lambda-common build-ApiFunction build-RuntimeDependenciesLayer

build-ExampleFunction:
	$(MAKE) HANDLER=src/main.ts build-lambda-common

build-lambda-common:
	npm install
	rm -rf dist
	nest build
	cp -r dist "$(ARTIFACTS_DIR)/"

build-RuntimeDependenciesLayer:
	mkdir -p "$(ARTIFACTS_DIR)/nodejs"
	cp package.json package-lock.json "$(ARTIFACTS_DIR)/nodejs/"
	npm install --production --prefix "$(ARTIFACTS_DIR)/nodejs/"
	rm "$(ARTIFACTS_DIR)/nodejs/package.json"

위에서 build-lambda-common 에 있었던 cp -r node_modules "$(ARTIFACTS_DIR)/" 는 삭제하고 아래에 별도로 layer를 빌드하는 커맨드를 추가하였습니다.

추가한 커맨드의 내용은 아래와 같습니다.

  1. 생성된 아티팩트(여기서는 RuntimeDependenciesLayer) 디렉터리 하위에 nodejs 디렉터리를 생성한다
  2. package.json, package-lock.json 파일을 위에서 생성한 nodejs 아래로 복사한다
  3. 패키지를 nodejs 아래에 설치하되 DevDependency는 제외한다
  4. package.json을 삭제한다

이렇게 빌드한 후 배포하게 되면 layer에 dependency가 분리된 상태로 배포되어 Lambda의 코드 용량을 확연히 줄일 수 있습니다.

배포하기

배포 자체는 sam deploy 한 줄로도 실행 가능하고, 공식문서의 튜토리얼에도 잘 나와 있는 부분이라 상세하게 설명하지는 않겠습니다.

다만 배포할 때 --guided 옵션을 주고 배포하면 여러 질문이 나오게 되는데, 그 중 Save arguments to configuration file ****에 Yes를 입력하면 배포 후에 samconfig.toml 파일이 생성됩니다. 여기에는 배포 시에 설정한 내용들이 담겨있어 이후에 배포할 때는 --guided 옵션 없이 배포하면 SAM이 해당 파일을 인식해 자동으로 배포하게 됩니다.

또한 CI/CD를 위해 변경 사항에 대한 확인 과정을 없애고 samconfig.toml 에 있는 내용대로 배포되도록 Confirm changes before deploy 에는 No를 입력합니다. 그 외 배포시 줄 수 있는 옵션은 공식문서에서 확인할 수 있습니다.

마치며

처음에는 기존 코드를 옮기기만 하면 되겠다고 생각해 가벼운 마음으로 임했지만, NestJS와 SAM으로 배포하는 경우 참고할 수 있는 자료가 부족하여 맨땅에 헤딩하듯 진행하게 되었습니다.

하지만 이번 일을 통해 Typescript, NestJS 같은 새로운 언어/프레임워크와 더불어 SAM, Cloudformation을 비롯한 AWS infrastructure까지도 폭넓게 공부할 수 있어서, 개발자로서 한 층 성장할 수 있는 계기가 되었습니다.

참고자료

Documentation | NestJS - A progressive Node.js framework

Adam Barker

nestjs packaging problem

Custom domain 설정이 필요한 경우, 아래 자료를 참조하면 됩니다. (일본어이나 template.yaml만 확인하면 됨)

AWS SAMテンプレートでAPI Gatewayに独自ドメインを使う - Qiita

profile
잘 & 열심히 살고싶은 개발자

0개의 댓글