Deploy Nextjs Standalone with Lambda & Express

오형근·2024년 3월 10일
1

Study

목록 보기
7/10
post-thumbnail

Serverless 프레임워크 혹은 OpenNext(SST)를 활용해서 Next.js 애플리케이션을 배포하고자 했으나, Nextjs의 Standalone 기능을 최대한 활용하기 위해 하는 시도.

기존에 next.js 애플리케이션을 도커라이징하여 Lambda@edge에 배포하려고 했지만, Lambda@edge는 도커 기반 런타임을 지원하지 않아서 시도하게 되었다.

Next.js의 Standalone 옵션은 next의 빌드 아티팩트를 하나의 독립적인 모듈로 만들어주어 마이그레이션 등에 유용하게 사용된다. 마치 도커 이미지를 여기저기 들고 다니면서 배포하는 형식과 비슷하다. 이때 필요한 라이브러리까지 모두 번들에 포함시켜준다.

이번 기회에 standalone 번들이 어떠한 방식으로 동작하고 실행할 수 있는지 제대로 다루어 앞으로 이를 십분 활용하고자 한다.

Standalone의 구조

실제 Next.js 애플리케이션을 도커라이징 할 때에도 standalone 옵션을 적용해 이를 실행하는 것을 기반으로 하기에, 먼저 기존에 사용하던 Next.js 도커파일부터 살펴보자.

FROM node:18-alpine AS base

RUN npm i -g pnpm

FROM base AS dependencies

WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN pnpm install

FROM base AS build

WORKDIR /app
COPY . .
COPY --from=dependencies /app/node_modules ./node_modules
RUN pnpm build
RUN pnpm prune --prod

FROM base AS runner
WORKDIR /app

ENV NODE_ENV=production

RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs

COPY --from=build /app/public ./public
COPY --from=build --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=build --chown=nextjs:nodejs /app/.next/static ./.next/static

USER nextjs

EXPOSE 3000

ENV PORT 3000

CMD ["node", "server.js"]

위 코드에서 주목할 부분은 이곳이다.

COPY --from=build /app/public ./public
COPY --from=build --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=build --chown=nextjs:nodejs /app/.next/static ./.next/static

CMD ["node", "server.js"]

빌드된 결과물 중 ./.next/standalone./.next/static 의 내용과 /public 의 내용이 빌드 아티팩트가 되는 것을 확인할 수 있다.

이후 node server.js 라는 명령어를 실행하는 것을 보아. ./next/standalone 내에 있는 server.js 파일이 진입점이 됨을 알 수 있다.

Stanalone의 파일 구성을 살펴보자.

standalone directory structure

각 구성들의 존재 이유를 살펴보자.

.next ⇒ 실제 CSR에 사용되는 js 코드 번들과 SSR을 위한 serverless function들이 존재한다.

node_modules ⇒ 의존성을 가지는 패키지들이 담겨 있다. 실제 압축하여 아티팩트화 하는 경우 가장 용량 문제가 많이 발생하는 곳으로, Lambda의 경우 Layer를 이용해서 처리하는 것이 바람직하다.

.env ⇒ 필요한 환경변수들이 담겨있다.

package.json ⇒ 필요한 패키지들이 정의되어있다. 중요한 점은, standalone 파일과 무관하게 실제 package.json을 그대로 복사하여 가져오기 때문에 dependency, devDependency 부분의 경우 실질적으로 이 폴더와는 무관하다.

server.js ⇒ .next 에 있는 실제 구동 코드를 기반으로 서버를 실행하는 파일. 내부에 next.config 를 비롯한 환경 설정들이 담겨 있다. 내부를 살펴보면 코드를 어떻게 실행하는지 알 수 있다.

오늘 주요하게 살펴볼 것은 server.js!!!!!

Server.js 뜯어보기

server.js 는 실제 .next의 실행파일들을 기반으로 애플리케이션을 구동시켜주는 진입점이 된다.

const path = require('path');

const dir = path.join(__dirname);

process.env.NODE_ENV = 'production';
process.chdir(__dirname);

const currentPort = parseInt(process.env.PORT, 10) || 3000;
const hostname = process.env.HOSTNAME || '0.0.0.0';

let keepAliveTimeout = parseInt(process.env.KEEP_ALIVE_TIMEOUT, 10);
let server;
const nextConfig = {
 // ... next 관련 configuration 이 담겨 있다. 기존의 config를 기반으로 한다.
};

process.env.__NEXT_PRIVATE_STANDALONE_CONFIG = JSON.stringify(nextConfig);

// startServer를 next 패키지 안에서 꺼낸다.

require('next');
const { startServer } = require('next/dist/server/lib/start-server');

if (Number.isNaN(keepAliveTimeout) || !Number.isFinite(keepAliveTimeout) || keepAliveTimeout < 0) {
  keepAliveTimeout = undefined;
}

// 아래 부분이 우리가 주목해야하는 부분

startServer({
  dir,
  isDev: false,
  config: nextConfig,
  hostname,
  port: currentPort,
  allowRetry: false,
  keepAliveTimeout,
}).catch((err) => {
  console.error(err);
  process.exit(1);
});

위 코드에서 주요하게 살펴보아야하는 곳은 아래 두 곳이다.

...

const { startServer } = require('next/dist/server/lib/start-server');

startServer({
  dir,
  isDev: false,
  config: nextConfig,
  hostname,
  port: currentPort,
  allowRetry: false,
  keepAliveTimeout,
}).catch((err) => {
  console.error(err);
  process.exit(1);
});

...

위 코드를 보면 next/server 패키지 내에 있는 start-server 라는 메서드를 사용함을 알 수 있다.

즉, 우리도 standalone 파일과 저 메서드만 있다면 언제든 함수를 실행할 수 있음을 알 수 있다(물론 관련 설정들도 필요하다).

근데 나는 startServer를 하고 싶은 것이 아니고, 관련한 실행 방법을 조금 더 파헤쳐서 이를 Lambda에서 실행할 수 있도록 만들고자 하는 것이 목적이다.

이때, 나는 동일한 node.js 기반의 nest.js를 Lambda에 배포하는 코드를 눈여겨서 보았다.

Nest.js 는 빌드 시 코드를 express 혹은 fastify 기반으로 트랜스파일링하여 이를 실행하게 되고, Next.js 또한 custom server 기능을 활용하면 express와의 통합이 가능하다. 이 점에 주목하여 나는 Next.js를 express에 올려서 실행할 수 있는 방법을 생각하고자 했다.

아래는 Nest.js를 Serverless 프레임워크를 사용해서 배포하는 경우 구동되는 코드이다.

import { NestFactory } from '@nestjs/core';
import serverlessExpress from '@vendia/serverless-express';
import { Callback, Context, Handler } from 'aws-lambda';

import { AppModule } from './app.module';

let server: Handler;

async function bootstrap(): Promise<Handler> {
  const app = await NestFactory.create(AppModule);
  await app.init();

  const expressApp = app.getHttpAdapter().getInstance();
  return serverlessExpress({ app: expressApp });
}

export const handler: Handler = async (
  event: any,
  context: Context,
  callback: Callback,
) => {
  server = server ?? (await bootstrap());
  return server(event, context, callback);
};

위 코드를 통해 우리는 단순 express 애플리케이션만 있다면 이를 Lambda로 배포할 수 있음을 알았다.

또한 아래의 코드를 살펴보자. 아래의 코드는 Next.js 애플리케이션을 래핑하여 express 에 올려서 사용할 때 쓰이는 코드이다.

// 출처 : **https://velog.io/@juhwannn/Next.js-Custom-Server-%EC%84%A4%EC%A0%95-Express**

require('dotenv').config();

const NODE_ENV = process.env.NODE_ENV;
const port = 3000;
const dev = NODE_ENV !== 'staging' && NODE_ENV !== 'production';

const nextJsEnabled = true;

const express = require('express');

const Next = require('next');
const nextJs = Next({dev});
const nextJsRequestHandler = nextJs.getRequestHandler();

(async () => {
    try {
        if (nextJsEnabled) await nextJs.prepare();
      
        const expressServer = express();
      
        expressServer.set('trust proxy', true);
        expressServer.use('/api', require('./serverSides/routes/api'));

        if (nextJsEnabled) expressServer.get('*', (req, res) => {
            return nextJsRequestHandler(req, res);
        });

        expressServer.listen(port, (err) => {
            if (err)
                throw err;

            console.info(`http://localhost:${port}`);
        });
    } catch (ex) {
        console.error(ex.stack);
        process.exit(1);
    }
})();

위의 코드처럼 즉시 실행 함수가 아니더라도 서버를 실행하는 방법은 많겠지만, 우리가 주목해야하는 것은 바로 getRequestHandler 에 있다. 해당 핸들러가 실질적인 Next.js 애플리케이션의 진입점이며, standalone 코드 번들에서도 결국에 저 핸들러를 찾아내기만 하면 이를 express 에 연결하여 Lambda에 배포할 수 있다는 이야기가 된다.

그리고 나는 이를 찾기 위해 next 패키지를 확인했고, startServer 가 존재하던 파일에 getRequestHandler가 함께 존재하고 있음을 확인했다.

start-server package

위 코드를 살펴보면 startServer와 동일한 파일에 getRequestHandler 라는 함수가 있음을 알 수 있었고, 나는 이 함수가 Next.js custom server 설정 시에 사용되는 getRequestHandler와 동일함을 알 수 있었다.

그러나 그 형태가 조금 달랐는데, 내부적으로 initialize라는 함수 타입을 반환하고 있었고, 이 initializer는 아래와 같이 배열을 반환했다.

initialize package

이 반환 값 중 첫 번째 값인 WorkerRequestHandler를 꺼내주어야 한다.

RequestHanlder vs UpgradeHandler

둘 다 요청을 다루는 핸들러이지만, 그 사용처가 다르다.

전자는 기본적인 HTTP 기반 요청들을 다룰 때 사용된다.

후자의 경우 WebSocket과 같이 Connection: Upgrade 속성을 가진 요청들을 받아들이기 위해 존재한다. Upgrade Request에 대해 더 알고 싶다면 아래 글을 참고하자.

Next.js API route to upgrade request into web socket (ws package)

웹소켓에 대해 알아보자

Connect RequestHandler with Express & Lambda

이제 핸들러를 꺼내왔고, 나머지는 기존에 Next.js Custom Server를 설정하는 것과 비슷하게 동작한다.

해당 핸들러를 express에 등록하여 express가 요청을 받아서 Next.js 핸들러로 전달해주는 프록시의 역할을 해줄 수 있도록 설정한다.

이제 중요한 것은 Lambda가 해당 코드를 실행할 수 있도록 만들어주는 것인데, 이를 위해서는 serverlessExpress 라는 함수가 필요하다.

이는 @codegenie/serverless-express 라는 라이브러리에서 지원하고 있으니, 해당 라이브러리가 standalone 내에 반드시 명시되고 설치되어있어야한다. express 도 마찬가지다!!! 개인적으로 이러한 필수 라이브러리들을 Lambda layer에 추가하여 사용하는 것을 추천한다.

@codegenie/serverless-express 라이브러리의 경우 반드시 해당 라이브러리 default에서 configure로 접근하여 올바른 함수를 꺼내서 사용해주어야한다. 그렇지 않으면 serverlessExpress is not a function 이라는 에러와 함께 동작이 멈추게 된다.

혹은 source-map-support라는 라이브러리를 사용하여 타입 에러를 방지할 수도 있다. 공식 문서에서는 해당 방법을 사용하고 있다.

Github - CodeGenieApp/serverless-express

Protect cold start by caching server instance

아마 이전까지의 과정들을 진행하고 나면 배포가 무리 없이 될 것이다. 만일 Lambda를 실행하는 데에 소요 가능한 최소 시간이 3초로 기본 설정되어이있는 경우 이를 여유있게 늘려주어야 동작한다.

이 말은 그만큼 처음 bootstrap에 많은 시간을 쏟는다는 이야기인데, 매 요청마다 이렇게 서버가 구동되기까지 기다릴 수는 없다.

따라서 아래 글을 참고해서 cold start를 방지하자.

Optimization to import cold start latency

Result

이제 당신은 standalone 을 뜯어보면서 번들을 실행하고 배포하는 일련의 과정을 파악했다.

이를 활용해 굳이 도커 이미지가 아니더라도 Next.js 애플리케이션을 모듈화하여 들고 다닐 수 있게 되었다. 이를 이용해 Lambda@edge에 배포하는 등의 다양한 동작들을 시도해보자.

현재 나는 이렇게 배포된 Lambda를 API Gateway를 프록시로 연결해두었는데, 이후 Cloudfront를 앞단에 추가하거나 Lambda@edge로 전환하여 배포할 예정이다.

참고용으로 하단에 설정들을 제외한 코드를 첨부한다.

require('next');
const { getRequestHandlers } = require('next/dist/server/lib/start-server');
const express = require('express');
const serverlessExpress = require('@vendia/serverless-express').configure;

if (Number.isNaN(keepAliveTimeout) || !Number.isFinite(keepAliveTimeout) || keepAliveTimeout < 0) {
  keepAliveTimeout = undefined;
}

const bootstrap = async (event, context, callback) => {
  const nextServerHandler = await getRequestHandlers({
    dir,
    isDev: false,
    config: nextConfig,
    hostname,
    port: currentPort,
    allowRetry: false,
    keepAliveTimeout,
  });
  const expressServer = express();

  expressServer.set('trust proxy', true);

  expressServer.get('*', (req, res) => {
    return nextServerHandler[0](req, res);
  });

  server = serverlessExpress({ app: expressServer });

  return server(event, context, callback);
};

exports.handler = (event, context, callback) => {
  console.log(server);
  if (server) return server(event, context, callback);
  return bootstrap(event, context, callback);
};

0개의 댓글