안녕하세요. 인프랩 프런트엔드 개발자 고슈입니다.

랠릿은 인프랩에서 인프런과 함께 개발하고 있는 채용 서비스입니다. 랠릿 B2C는 Next.js 기반으로 Docker image 파일을 배포하여 서비스를 운영하고 있는데요. 랠릿의 기존 배포용 Docker image 의 사이즈는 꽤 무거웠습니다. (MAC OS 기준 약 4.67GB)

이번에 Next.js 에서 지원하는 standalone 옵션을 적용시킨 후, Docker image 사이즈를 최소화하고 배포 시간을 줄이는데 성공했습니다. 그 경험에 대해 가볍게 공유합니다.

랠릿(Rallit) 은 ‘인프런이 학습으로 사람들을 성장시키는 서비스를 하고 있으니까, 그 성장한 사람들이 능력을 펼칠 수 있는 환경을 조성해 주는 것이 우리 서비스 완성아닐까?’ 라는 생각에서 출발한 서비스입니다. 채용 뿐만 아니라 자신의 커리어를 관리할 수 있는 공간을 지향하고 있습니다.
많은 관심 부탁드립니다! 🙌

1. Standalone

1-1. Standalone 의 의미

Standalone 은 ‘독립형’ 또는 ‘독립적인 것’ 이라는 뜻을 가지고 있습니다. Next.js 에서는 웹 어플리케이션을 실행하는데 필요한 최소한의 코드만 추출하겠다는 의미로 사용됩니다. 공식 문서를 확인하면, 해당 옵션을 추가했을 때 빌드 결과물로 /standalone 폴더를 얻을 수 있습니다.

module.exports = {
  output: 'standalone',
};

이 옵션은 배포 환경에서 웹앱을 실행할 때 필요없는 코드는 빌드 결과물에서 제외시킵니다.

1-2. 기대 결과

Docker image 의 사이즈를 줄이고, 배포 시간을 단축시킬 수 있습니다.

랠릿은 현재 Jenkins CI/CD 파이프라인을 이용해 Amazon ECS Cluster 에 Docker image 를 배포하고 있습니다. CD 파이프라인에서는 생성된 Docker image 를 빌드한 뒤, ECR(Amazon Elastic Container Registry)에 push 합니다. 따라서 Docker image 의 사이즈가 작아지면, ECR 에 push & pull 하는 시간이 감소되어 배포 시간이 단축됩니다.

Amazon Elastic Container Registry(ECR) 는 AWS 에서 제공하는 Docker Container Registry 서비스입니다. Docker Container Registry 는 Docker Hub 와 같은 역할을 수행합니다. 즉, 배포를 위한 Docker image 를 저장하고 관리하는 서비스입니다.

2. 로컬에서 배포용 Docker 실행

로컬에서 배포용 Docker 이미지를 실행하면, standalone 이 잘 적용되었는지 Jenkins 와 ECR 을 거치지 않고도 쉽게 확인할 수 있습니다. Dockerfile 이 존재한다면, 아래와 같은 코드로 쉽게 배포용 Docker 를 띄울 수 있습니다.
참고로, next build 를 실행하여 빌드 결과물을 생성한 이후에 다음을 진행해야합니다.

Docker build . --target {특정 빌드 스테이지} -t {이미지_이름:태그}
Docker run -p {호스트 포트}:{컨테이너 포트} {이미지_이름:태그}

3. Standalone 적용

3-1. 기본

Next.js의 공식 문서에서는 다음과 같이 옵션을 적용시킬 수 있다고 말합니다.

module.exports = {
  output: 'standalone',
};

이 옵션을 추가하면, next build 시 다음과 같은 폴더를 확인할 수 있습니다.

standalone 빌드 결과물

⚠️ 모노레포 + Next.js v12 인 경우
모노레포 내 Next.js v12 에서 위의 옵션만을 추가한 경우엔 다음과 같은 빌드 결과물을 얻습니다.

standalone monorepo + v12 빌드 결과물

빌드하고자 하는 root 경로와 실제 root 경로가 명확하게 일치하지 않아, 원하는 패키지의 하위 라이브러리를 받아오지 못합니다. 따라서 요구하는 라이브러리를 찾을 수 없다는 Not Found 에러를 뱉으면서 빌드가 멈춥니다. 🫠

이는 아래의 옵션으로 해결 가능합니다. 공식 문서

module.exports = {
  experimental: {
    // this includes files from the monorepo base two directories up
    outputFileTracingRoot: path.join(__dirname, '../../'),
  },
};

해당 옵션을 추가하면 하위 패키지를 받아올 수 있습니다.

outputFileTracingRoot 옵션 추가

해당 옵션은 Next.js v12 에서 유효합니다. v13 에서는 해당 옵션 없이도 모노레포에서 잘 동작합니다.

3-2. 실행

standalone 으로 걸러낸 서버의 실행은 다음과 같이 할 수 있습니다. next start와 유사한 기능입니다.

node standalone/server.js

4. 빌드하기 전에 미리 로드해야 할 라이브러리들이 있다면

server.js를 실행할 때 미리 로드해야 할 모듈이 있을 수 있습니다. 랠릿에서는 데이터독의 tracer 인 dd-trace, pino-logger, 그리고 next-logger을 사전에 불러오는 작업을 진행해야 했습니다.

사전에 로드해야할 모듈을 정리한 파일

이는 Dockerfile 에서 따로 설정해줍니다. server.js 를 실행하기 전에 Node.js 에서 -r 옵션을 통해 preload.js 를 실행하도록 설정합니다.
dd-trace 에 대한 추가 내용은 6-3에서 설명합니다.

COPY --chown=node:node packages/b2c/next-logger.config.js ./packages/b2c
COPY --chown=node:node packages/b2c/scripts/preload.js ./packages/b2c

ENV LOG_LEVEL debug
ENV DD_LOGS_INJECTION true
...
ENV NODE_OPTIONS "-r /workspace/packages/b2c/preload.js"
ENTRYPOINT ["node", "packages/b2c/server.js"]

[참고] Dockerfile 의 COPY

COPY --chown=node:node packages/b2c/next-logger.config.js ./packages/b2c
위 명령어는 앞으로 설명할 많은 Docker 파일 코드에서 자주 등장하니, 간단하게 소개하겠습니다.

  • COPY
    파일이나 디렉토리를 현재 빌드 컨텍스트에서 Docker 이미지 내부로 복사합니다.
  • --chown=node:node:
    이 옵션은 복사된 파일 또는 디렉토리의 소유자 및 그룹을 설정합니다.
    여기서는 node 사용자와 node 그룹을 소유자로 설정합니다.
  • packages/b2c/next-logger.config.js
    현재 Docker 빌드 컨텍스트에서 source 파일 경로입니다.(출발지)
  • ./packages/b2c
    복사될 대상의 Docker image 내부 경로입니다. (도착지)

즉, 현재 빌드 컨텍스트의 packages/b2c/next-logger.config.js 파일을 Docker 이미지 내의 ./packages/b2c 디렉토리로 복사하고, 그 파일의 소유자와 그룹을 node 로 설정합니다.

사전 로드 관련된 코드 작성은 팀 동료인 루카스가 많은 도움을 주셨습니다. 감사합니다! 🙏

5. /static, /public 폴더 직접 복사

공식 문서에서는 /static 폴더와 /public 폴더는 standalone 내에 존재하지 않는다고 합니다. 아래의 내용과 같이 /static 폴더와 /public 폴더는 CDN 을 이용하여 관리하기를 권장하기 때문입니다.

static, public 폴더는 직접 복사가 필요하다.

static 및 public 경로를 별도의 오브젝트 스토리지 또는 CDN 으로 서빙하지 않고, Next.js 서버에서 서빙하는 경우라면 직접 복사해야합니다.

COPY --chown=node:node packages/b2c/.next/standalone ./
COPY --chown=node:node packages/b2c/public ./packages/b2c/public
COPY --chown=node:node packages/b2c/.next/static ./packages/b2c/.next/static

6. 환경 변수 관리

6-1. 사라진 환경 변수

정상적으로 standalone 을 적용했다면, 환경 변수가 잘 적용이 되어야 합니다. 하지만 랠릿에서는 환경 변수가 적용되지 않았습니다.

프런트 내부에서 사용하는 fetcher 에서 API response 을 정상적으로 받아오지 못하는 문제가 있었습니다. Invalid Url 에러로, endpoint 의 hostname 이 사라진 상태여서 아래의 코드를 확인했습니다.
fetcher 은 환경 변수를 통해 endpoint 를 가져오고 있었습니다.

standalone codegen

fetcher 은 Next.js 뿐만 아니라 CRA 환경에서도 사용하고 있는 값이기 때문에 REACT_APP prefix 를 붙여 사용하고 있습니다.

하지만 Docker 내에 환경변수가 세팅되지 못해 endpoint 를 제대로 가져올 수 없는 상황이었습니다.

6-2. 환경 변수를 강제로 Docker 에 복사 가능할까?

결론만 말씀드리자면 아니오.입니다. 환경 변수가 없어서 생기는 문제를 해결하기 위해, 두 가지의 방식을 실험했습니다.

  • 환경 변수 파일을 Dockerfile 에 복사
    랠릿은 env-cmd 를 이용해 환경 변수를 우회하여 사용하고 있습니다. 동일한 방식으로 Dockerfile 에서 COPY 하고, ENTRYPOINT 에 env-cmd 를 추가했습니다.

     RUN npm install -g env-cmd
     COPY --chown=node:node packages/b2c/env/.env.development ./packages/b2c/env
     ENTRYPOINT ["env-cmd", "-f", "/workspace/packages/b2c/env/.env.development", "node", "packages/b2c/server.js"]

    하지만 경로를 못 받아오는 문제가 있었습니다. env 파일 자체를 Docker 에서 찾지 못하고, Dockerfile 의 ENV 명령어로 넣어준 값만 적용이 됩니다.

  • Dockerfile 에서 환경 변수를 세팅 (비권장)
    위에서 불러오지 못했던 코드젠 환경변수를 동일하게 추가해주었습니다.

     ENV REACT_APP_CODEGEN_API_HOST http://sample.api.rallit.com

    정상적으로 fetcher 을 사용할 수 있었습니다. 하지만 이 방식을 사용한다면, 모든 환경변수를 Dockerfile 에 직접 추가해야합니다. 해결은 가능하나, 정상적인 방식은 아니라 판단하고 근본적인 문제를 해결하기로 했습니다.

6-3. 근본적인 문제의 해결책은?

정상적으로 했다면, build 를 했을 때 무조건 .env .env.production/standalone 안에 들어갑니다.

env 파일이 standalone 안에 들어가야 함

하지만 랠릿에서는 .env 파일이 /standalone 안에 들어가지 않았습니다. 그 이유는 .env 파일이 root 경로가 아닌 /env 안에 있었기 때문입니다.

env 파일 위치

/env 폴더 안에 있을 경우 build 할 때 env 파일을 찾지 못합니다. 그래서 /standalone 폴더 내에 환경 변수가 존재하지 않았습니다.

왜 랠릿은 /env 폴더 안에 환경 변수를 관리하나요?

랠릿은 일부 env 파일을 git 에서 관리하고 있습니다.

로컬에서 사용할 때는 .env.local, 개발 환경에서는 .env.development, 운영 환경에서는 .env.production 이렇게 사용하고 있는데요. 공식 문서에 따르면 build 시 무조건 .env.env.production 만 빌드됩니다. 즉, 빌드 시 .env.development값이 적용되지 않습니다.

또한 .env.local 파일은 최우선 순위로 overwrite 되는 환경변수입니다. 개발 환경에서 실행할 때 .env.development 대신 .env.local 이 실행되는 문제가 있습니다.

이러한 문제들을 우회하기 위해 env-cmd 를 사용하였습니다. 자세한 내용은 공식 문서를 참고 바랍니다.

제목 빌드 실행 우선 순위
.env 🟢 🟢 3️⃣
.env.local 🟢 1️⃣
.env.development 🟢 2️⃣
.env.production 🟢 🟢 2️⃣

기존의 방식인 /env 경로를 유지하면서 해당 문제를 해결하기로 하였습니다. root 경로에 .env 파일만 있으면 복사가 가능하기 때문입니다.
이를 위해 root 경로에 .env 파일을 생성하는 쉘 스크립트를 작성했습니다. 그리고 배포 환경에 따라 복사할 파일을 다르게 설정하였습니다.

# B2C standalone build script
# standalone 에 환경 변수를 세팅하기 위해서 /env 폴더 내의 환경 변수를 최상위로 추출하는 스크립트
# development 인 경우: /env/.env.development -> .env COPY
# production 인 경우: /env/.env.production -> .env COPY

#!/bin/bash

ORIGINAL_DIRECTORY="./env"
NEW_DIRECTORY="./"
NEW_FILE=".env"

clean_env() {
  rm -rf .env
  echo "🚀 .env 삭제를 완료했습니다."
}

copy_env() {
  local original_file="$ORIGINAL_DIRECTORY/$1"
  if [ -e "$original_file" ]; then
    cat "$original_file" > "$NEW_DIRECTORY/$NEW_FILE"
    echo "$original_file$NEW_FILE 로 복사했습니다."
    echo "🚀 $2 환경의 .env 복사를 완료했습니다."
  else
    echo "$original_file 파일을 찾을 수 없습니다."
    exit 1
  fi
}

case "$1" in
  prod)
    copy_env ".env.production" "prod"
    ;;
  dev)
    copy_env ".env.development" "dev"
    ;;
  clean)
    clean_env
    ;;
  *)
    echo "Usage: sh set-env.sh [prod|dev|clean]"
    exit 1
    ;;
esac

exit 0
{
  ...
  "scripts": {
    ...
    "build:env": "sh ./set-env.sh",
    "build:development": "pnpm build:env dev && next build && pnpm build:env clean",
    "build:production": "pnpm build:env prod && next build && pnpm build:env clean"
  }
}

[참고] Docker 내 node_modules 제거하기

standalone 의 장점은 어플리케이션을 실행하는데 필요한 최소한의 파일만을 독립 폴더로 생성한다는 점입니다. 즉, node_modules 를 image 에 전부 갖고 있지 않더라도 실행할 수 있다는 건데요. 랠릿의 Dockerfile 에서는 dd-trace 에서 root의 node_modules 가 제거되면 next.js를 찾을 수 없어 강제로 root의 node_modules 복사해서 사용했습니다.

해당 문제는 Next.js v13 이 dd-trace 를 지원하지 않게 되면서, 해당 라이브러리를 사용하지 않고 node_modules 를 제거하는 방향으로 작업을 마무리했습니다.

수정 (2023.11)
해당 오류는 dd-trace 만 import 했을 때 dd-trace 내부의 의존성인 init.js 를 찾을 수 없어 발생하는 문제입니다.
standalone은 프로덕트에서 사용하지 않는 의존성은 복사하지 않습니다.
따라서 이 문제를 해결하기 위해 dd-trace/init 을 import 합니다.

_document.tsx 파일에 import dd-trace/init 로 수정하면 dd-trace 의존성 내부 init.js 파일까지 정상적으로 standalone 결과물에 포함됩니다.
이를 통해 root 의 node_modules 제거한 상태로 dd-trace 의 APM 을 이용할 수 있게 되었습니다.
보다 자세한 내용은 추후에 블로그를 올릴 예정입니다.

팀 동료인 융디가 자세히 디깅해주셨습니다. 감사합니다! 🙏

7. 차이 비교

7-1. No registry stage 비교 in MAC (node_modules 제거 전: 1.48GB)

4.37GB -> 625MB (약 3.75GB 감소)

no registry stage 비교 in MAC

7-2. Registry stage 비교 in MAC (pnpm 제거 전/후)

다른 인프랩 서비스 패키지들 모두 공통으로 Registry stage 를 사용하고 있습니다. Registry stage 에는 기본적으로 pnpm 이 설치되어있습니다. 하지만 이번에 standalone 을 적용시키면서 pnpm 을 더이상 사용할 필요가 없어졌습니다. 해당 패키지 매니저를 제거한 레지스트리를 불러왔을 때의 차이입니다.

920MB -> 635MB (약 285MB 감소)

registry stage 비교 in MAC

7-3. Registry stage 비교 in Linux (ECR) (pnpm 제거 전/후)

실제로 Docker image 를 ECR 에 올려 사용하기 때문에, ECR 에 올라간 Docker image 의 용량을 비교해보았습니다.

689MB → 404MB (약 285MB 감소)

registry stage 비교 in Linux

7-4. 최종 비교

ECR 에 올라간 Docker image 는 압축된 용량이므로 크기 차이가 있습니다.

stage before after (standalone + no pnpm)
MAC 4.67 GB 625 MB
Linux + ECR 2.46 GB 404 MB

8. 결과

배포 시간이 기존 3분 38초에서 1분 7초로 약 320%의 성능 개선 (2분 30초 단축) 을 할 수 있었습니다.

deploy 시간 단축

9. 마치며

Docker 스터디를 함께한 랠릿 프런트엔드 팀의 라비와 록, standalone 옵션에 대한 전반적인 방향을 제시해준 루카스, 그리고 인프라 환경에 대해 많은 조언과 아이디어를 준 제이크 덕분에 이번 작업을 무사히 마무리할 수 있었습니다.

그 외에 작성에 도움을 준 모든 팀원분들께 감사합니다. 🙇‍️