Next.js 도커 이미지를 만들어서 CI/CD를 통해 NCloud 배포 (1)

기운찬곰·2023년 3월 12일
4

Next.js 이모저모

목록 보기
1/8
post-thumbnail

Overview

프로젝트를 시작하는 단계에서 먼저 작업할만한게 뭐가 좋을까 생각하다가 배포 자동화를 만드는게 좋겠다고 생각해서 진행하게 되었습니다. 이건 제가 자진해서 해보겠다고 했는데 왜냐면 재밌어보이니까요. ㅎㅎ

NCloud, 일명 Naver Cloud Platform(NCP)를 사용한 이유는 프로젝트 진행하면서 무료로 크레딧을 받았거든요. 20만원 정도를 받았기 때문에 처음에는 AWS를 사용하자고 말했지만 막상 NCP도 해보니까 그렇게 나쁜거 같지는 않았습니다. 아. 그리고 왜 도커 이미지를 만들어서 배포하고자 했냐면 GKE(Google Kubernetes Engine)도 사용해보고 싶었거든요. 근데 NCP를 쓰기로 했으니까 이와 비슷한 프로세스를 찾아봐야 했는데 마침 적당한게 있어서 도커 이미지로 말아서 올리는 방식을 택했습니다.


Next.js Docker 이미지 만들기

참고 : https://bum-developer.tistory.com/entry/Docker-Node-환경-만들기 (Dockerfile 설정하기)
참고 : https://github.com/vercel/next.js/blob/canary/examples/with-docker/Dockerfile

docker image로 만들기 위해 새 Dockerfile을 추가합니다. 마침 Next.js github에서 샘플로 Dockerfile을 만드는 법을 잘 알려주고 있었습니다. 이를 기반으로 참고해서 만들었습니다.

일단, 가장 기본이 되는 Base Image 정하기. node.js의 경우 node와 버전 명시. 그리고 alpine은 최소단위의 linux 버전을 의미합니다. 보통 이렇게 많이 쓰는 듯 하네요. 뒤에 AS base는 별칭을 지정했다가 나중에 사용하기 위해서입니다.

FROM node:18-alpine AS base

1단계. Get NPM packages with pnpm

이제 할 일은 npm 의존성을 설치하는 입니다. 근데 FROM base AS deps는 왜 또 하는거지? 라는 의문이 들었습니다.

# Install dependencies only when needed
FROM base AS deps

알고보니 생각보다 의미있는 작업이었습니다. 그리고 좀 낯선 작업… 도커를 좀 알아야 이해할 수 있는 작업이더군요.

  • multi-stage 빌드는 컨테이너 이미지를 만들면서 빌드 등에는 필요하지만 최종 컨테이너 이미지에는 필요 없는 환경을 제거할 수 있도록 단계를 나누어서 기반 이미지를 만드는 방법을 얘기한다.” - 참고
  • “빌드와 러닝 이미지를 나누는 Builder Pattern”, “Builder에서 빌드한 바이너리를 실행할 이미지로 전달해주기 위해선 COPY의 --from옵션을 통해 실행 이미지로 전달해줄 수 있습니다.” - 참고

✍️ 결론은 도커 이미지 사이즈를 줄이기 위한 작업이라고 보시면 될 거 같습니다.

그리고 나서 libc6-compat을 설치해주는데 해당 사이트에 번역한 걸 이해해보면 alpine 이미지가 musl libc 대신 glibc and friends 라는 걸 대신 사용하는데, libc 의존성을 참조하는 의존성 라이브러리에 대해 문제가 발생할 수 있다고 합니다. 얼핏 그런 라이브러리(암호화 라이브러리 같은)가 있었던거 같습니다.

대부분의 경우는 문제가 없기 때문에 이러한 추가는 그냥 사전에 예방하기 위한 선택이라고 보시면 되겠습니다.

# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
RUN apk add --no-cache libc6-compat

다음 단계는 작업 경로 설정. WORKDIR는 cd 명령이랑 동일한거 같고, app 폴더에다가 작업 프로젝트를 구성하겠다? 뭐 이런 의미인듯 합니다.

WORKDIR /app

다음으로는 프로젝트 파일을 복사해와야 합니다. 일반적으로 Dockerfile에서 파일을 복사하고 명령어를 수행할 때는 Layer System으로 구성되기 때문에 빈번하게 변경될 경우 마지막에 작성하는 것이 좋다고 합니다. - 참고

package.json, pnpm-lock.yaml은 그리 빈번하게 변경되지 않으니 처음 작성해주는 것이 좋습니다.

COPY package.json pnpm-lock.yaml ./

✅ Dockerfile은 명령어 한 줄, 한 줄이 Layer 형식으로 실행된다. 그래서 만약 소스코드인 src ./ 가 수정될 경우 이후 레이어만 다시 빌드를 하고 나머지 Layer는 재사용할 수 있다. 그렇게되면 이미지를 제작하는 시간을 단축하고, 효율성이 높아진다. 꿀팁이군.

그 후 RUN 명령어를 통해 package.json에 명시되어있는 라이브러리를 모두 설치 합니다. 저는 pnpm을 사용하기 때문에 pnpm 설치 후 사용합니다.

RUN npm install -g pnpm && pnpm install

2단계. Build the app with pnpm

라이브러리를 설치했으니 그 다음 단계로 build를 해야할 것 입니다.

# Rebuild the source code only when needed
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# This will do the trick, use the corresponding env file for each environment.
COPY .env.development .env.production
RUN yarn build

# RUN pnpm run build # pnpm 없다고 안됨. 하긴... 위에서 설치한 pnpm이랑은 별개의 이미지라서 그런듯. 
  • FROM base AS builder 를 하고 COPY —from=deps를 하는것은 위에서 빌드 이미지를 줄이기 위한 작업이라고 했습니다. 이렇게 빌드된 녀석들만 가져와서 사용하도록 해서 깔끔(?)한 상태를 만드는 것입니다.
  • COPY . . : 필요한 모든 파일을 복사
  • COPY .env.development .env.production : 이건 좀 트릭인거 같은데, 즉 현재 .env.development를 .env.production으로 만든다음에 yarn build를 통해 해당 설정된 환경변수를 사용하게 만드는 것입니다. yarn build 는 기본적으로 .env.production을 바라보니까요.
  • RUN yarn build : 최종 빌드

3단계. Production image, copy all the files and run next

최종 실행하기 위한 이미지를 만드는 단계입니다. 우리는 다 필요없고 이 이미지만 있으면 됩니다. 따라서 마찬가지로 FROM base AS runner로 구분해줍니다.

FROM base AS runner
WORKDIR /app

NODE_ENV를 production으로 환경 변수 설정

ENV NODE_ENV production

이건 약간 리눅스 그룹 id, user id 만드는 명령인거 같은데 자세히는 잘 모르겠습니다.

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

그리고 builder 된 곳에서 만들어진 /app/public을 해당 runner 이미지에 추가해주기 위해 복사

COPY --from=builder /app/public ./public

그리고 나서 아래 명령을 수행하는데 그 전에 알아야 할 사전 지식이 필요합니다.

# Automatically leverage output traces to reduce image size
# https://nextjs.org/docs/advanced-features/output-file-tracing
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static

참고 : https://nextjs.org/docs/advanced-features/output-file-tracing

Next.js 12부터는 .next/ 디렉토리의 출력 파일 추적을 활용하여 필요한 파일만 포함할 수 있습니다.

next build 중에, Next.js는 @vercel/nft를 사용하여 가져오기, 요구 사항 및 fs 사용량을 정적으로 분석하여 페이지가 로드될 수 있는 모든 파일을 결정합니다.next.js의 프로덕션 서버는 프로덕션에서 활용할 수 있는 .next/next-server.js.nft.json에서 필요한 파일과 출력을 추적합니다.

Next.js는 node_modules의 선택 파일을 포함하여 프로덕션 배포에 필요한 파일만 복사하는 독립 실행형 폴더를 자동으로 생성할 수 있습니다. 이 자동 복사를 활용하려면 next.config.js에서 자동 복사를 활성화하면 됩니다:

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

그러면 .next/standalone에 폴더가 생성되고 node_modules를 설치하지 않고도 자체적으로 배포할 수 있습니다. 또한 다음 시작 대신 사용할 수 있는 최소 server.js 파일도 출력됩니다.

🤔 그니까 결국에 standalone를 사용하는 이유는 이미지 사이즈를 줄일 수 있다는 거네. 어쨋건 좋은거니까 해줍니다.

리눅스 사용자 설정인가?

USER nextjs

3000 포트 사용 및 포트 3000 환경변수 설정

EXPOSE 3000
ENV PORT 3000

마지막으로 최종 실행. 위에 standalone로 만들어졌다면 server.js로 실행할 수 있습니다.

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

도커 이미지 생성하기

Dockerfile을 작성했다면 이를 기반으로 도커 빌드를 통해 이미지를 만들어볼 수 있습니다. 여기서 -t는 —tag 옵션을 의미합니다. 이미지 이름을 지정한다고 보면 될 거 같습니다. 그리고 . 은 현재 경로를 기반으로 만든다는 뜻.

docker build -t nextjs-docker .

근데 현재 Dockerfile 경로는 docker 폴더 아래 있습니다. 그래서 그런지 저렇게 실행하면 ‘failed to read dockerfile’ 이라는 오류가 나면서 실행이 안됩니다. Dockerfile을 밖으로 빼면 잘 됩니다.

찾아보니 -f를 통해 Dockerfile 경로를 강제로 지정해줄 수 있습니다.

docker build -t nextjs-docker -f ./docker/Dockerfile .

실행 과정

❯ docker build -t nextjs-docker .
[+] Building 16.0s (19/19) FINISHED                                                                                                       
 => [internal] load build definition from Dockerfile                                                                                 0.0s
 => => transferring dockerfile: 967B                                                                                                 0.0s
 => [internal] load .dockerignore                                                                                                    0.0s
 => => transferring context: 2B                                                                                                      0.0s
 => [internal] load metadata for docker.io/library/node:18-alpine                                                                    0.8s
 => [internal] load build context                                                                                                    1.3s
 => => transferring context: 3.14MB                                                                                                  1.2s
 => [base 1/1] FROM docker.io/library/node:18-alpine@sha256:f605fcd5254d0e398e04d93c7b11e2aec2a6e1aeb7da1f99bc40cd101dd8cde4         0.0s
 => CACHED [runner 1/6] WORKDIR /app                                                                                                 0.0s
 => CACHED [runner 2/6] RUN addgroup --system --gid 1001 nodejs                                                                      0.0s
 => CACHED [runner 3/6] RUN adduser --system --uid 1001 nextjs                                                                       0.0s
 => CACHED [deps 1/4] RUN apk add --no-cache libc6-compat                                                                            0.0s
 => CACHED [deps 2/4] WORKDIR /app                                                                                                   0.0s
 => CACHED [deps 3/4] COPY package.json pnpm-lock.yaml ./                                                                            0.0s
 => CACHED [deps 4/4] RUN npm install -g pnpm && pnpm install                                                                        0.0s
 => CACHED [builder 2/4] COPY --from=deps /app/node_modules ./node_modules                                                           0.0s
 => [builder 3/4] COPY . .                                                                                                           2.9s
 => [builder 4/4] RUN yarn build                                                                                                    10.2s
 => [runner 4/6] COPY --from=builder /app/public ./public                                                                            0.0s 
 => [runner 5/6] COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./                                                  0.1s 
 => [runner 6/6] COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static                                          0.0s 
 => exporting to image                                                                                                               0.1s 
 => => exporting layers                                                                                                              0.1s 
 => => writing image sha256:a76d375f14c08f8bde5c5ad7803c1903c6a93aecbbbd49bc898386d3630869a0                                         0.0s 
 => => naming to docker.io/library/nextjs-docker

최종적으로 nextjs-docker라는 이미지가 잘 만들어졌습니다.

이제 만들어진 도커를 실행해볼까요? 잘됩니다. VSCode에서 docker extension을 설치하면 docker container 바로 확인할 수 있더군요. 참 좋아졌습니다.

❯ docker run -p 3000:3000 nextjs-docker
info  - Loaded env from /app/.env.production
Listening on port 3000 url: http://e94bfe211f46:3000


도커 컴포즈 연동하기

도커 컴포즈는 여러개의 도커 컨테이너를 띄우기 위해 사용되는 간단한 오케스트레이션 도구라고 보면 됩니다.

next.js 에서 작성된 가이드 코드 입니다. 무슨 의미인지 간단히 살펴보겠습니다. 그전에 현재 프로젝트 경로와 docker 파일 위치를 참고해서 적절한 경로를 지정해줘야 합니다.

version: '3'

services:
  next-app:
    container_name: next-app
		build:
      context: ../
      dockerfile: docker/prod.Dockerfile
      args:
        ENV_VARIABLE: ${ENV_VARIABLE}
        NEXT_PUBLIC_ENV_VARIABLE: ${NEXT_PUBLIC_ENV_VARIABLE}
    restart: always
    ports:
      - 3000:3000
    networks:
      - my_network

  # Add more containers below (nginx, postgres, etc.)

# Define a network, which allows containers to communicate
# with each other, by using their container name as a hostname
networks:
  my_network:
    external: true
  • 우리는 next-app 이라는 컨테이너를 만들 예정
  • build.context 의미는 현재 프로젝트 위치를 가리켜야 합니다. 따라서 .. 을 통해 상위 경로를 나타내줍니다.
  • build.dockerfile는 prod.Dockerfile 을 실행 파일로 삼는다는 의미입니다.
  • build.args : 빌드 시에만 사용 가능한 환경변수를 설정. 여기서 설정하면 Dockefile 등에서 사용 가능하다? 흠… 일단 저는 패스하기로 합니다.
  • restart: always. 컨테이너가 멈추면 항상 다시 시작한다는 명령. 수동으로 중지된 경우 재시작되거나 컨테이너 자체가 수동으로 재시작될 때만 재시작 된다고 합니다.
  • networks external : true로 세팅되면, network가 Compose 외부에서 생성되었음을 지정 합니다.

보니까 문서에 실행 전에 network를 생성하는 명령이 있습니다. 그니까 기본 네트워크 대신 새로 생성한 네트워크를 사용하겠다 뭐 이런거 같습니다. 역시 좀 어렵군요.

# Create a network, which allows containers to communicate
# with each other, by using their container name as a hostname
docker network create my_network

# docker network list 확인
docker network ls

만약 생성을 안하면 아래와 같은 에러가 뜬다.

Use 'docker scan' to run Snyk tests against images to find vulnerabilities and learn how to fix them
network next-app-network declared as external, but could not be found

그리고 최종적으로 도커 컴포즈 실행해보기. up -d는 백그라운드로 실행하겠다 이런 뜻입니다. 실행시 현재 경로에 따라 docker-compose.prod.yml은 맞춰주길 바랍니다. 실행 결과 성공적으로 되었습니다.

❯ docker-compose -f docker-compose.prod.yml up -d
[+] Running 1/1
 ⠿ Container next-app  Started

참고 : https://github.com/vercel/next.js/tree/canary/examples/with-docker-multi-env

배포환경에 따라 development, staging, production 구분해서 만드는 방법에 관한 내용입니다. 좀 더 발전시킬 수 있겠군요. 하지만 일단 필요없으니 여기까지 하도록 하겠습니다.


마치면서

생각보다 재밌으면서도 삽질을 많이 했던 작업이었던거 같습니다. 경로문제, 환경변수 문제, 빌드 문제 등... 여러가지 문제가 있었던거 같네요.

다음 시간에는 이렇게 만든 도커 이미지를 NCP를 통해 올려보고 다시 타겟 서버에서 받아서 구동시켜보는 작업을 github actions를 통해 자동화 해보도록 하겠습니다.


참고 자료

profile
배움을 좋아합니다. 새로운 것을 좋아합니다.

1개의 댓글

comment-user-thumbnail
2024년 2월 7일

NCP에 Next.js 프로젝트를 배포하려고 찾아보는 중이였는데 큰 도움이 되었습니다 :)

답글 달기