또 맨날 주기적으로 올린다하고 그러지 못했네요...ㅠㅠ 게으른 저 반성합니다. NCP를 사용하시는 분이 많지는 않겠지만, 사용하신다면 도움이 될거 같아서 최근 프로젝트 CI/CD 구축했던 과정을 공유하려고 합니다. 앞으로는 꼭!! 주기적으로 작성할 예정입니다. 사실 블로깅하는 스터디도 하고 있어서 2주에 한 번씩을 포스팅할  예정입니다 잘 부탁드려요 :)


1. Docker 이미지 빌드

먼저 Next.js 프로젝트에 대한 Docker 이미지를 만들어야 합니다.

루트 디렉토리에 Dockerfile을 만들어 도커 이미지를 만드는 코드를 작성해 봅시다.

 

Dockerfile을 만들기 전에 이미지 빌드에 적용한 것들에 대해서 알아야합니다.

  1. multi-stage 빌드
  2. Next.js의 standalone

 

a. multi-stage 빌드

Multi-stage 빌드 방식을 이용해 도커 이미지를 만들 예정입니다. 해당 방식은 이미지를 만드는 과정에서는 필요하지만 최종 컨테이너 이미지에는 필요 없는 환경을 제거하여 이미지를 최적화하는 방식입니다.

이를 위해 stage를 나누어 기반 이미지를 생성합니다.

 

stage는 다음과 같습니다

  • Base stage (base):
    • npm install --production: 프로덕션에서만 필요한 의존성을 설치합니다. (예: react, next 등)
    • 이 스테이지에서는 프로덕션에서 실행에 필요한 최소한의 패키지만 설치합니다.
  • Dependencies stage (deps):
    • npm install: 개발 의존성까지 포함하여 모든 의존성을 설치합니다. 이 단계는 빌드에 필요한 패키지를 포함하는 것입니다.
    • 이 단계에서 storybook, eslint 등 개발 도구와 관련된 패키지들이 설치됩니다.
  • Build stage (builder):
    • npm run build: Next.js 애플리케이션을 빌드하는 단계입니다.
    • builder는 소스 파일 전체를 복사하고, 빌드를 통해 정적 자원을 생성합니다.
  • Runtime stage (runner):
    • 최종 런타임 이미지로, builder에서 빌드된 결과물과 프로덕션 의존성만 복사해 옵니다.
    • 이 단계에서는 개발 도구나 빌드에 필요한 패키지들이 포함되지 않으며, 실행에 필요한 것들만 포함최소한의 이미지를 생성합니다.

위 과정을 보면 개발 의존성과 프로덕션 의존성을 분리하는데요

두 개념과 왜 분리하는지를 설명하겠습니다.

 

 

1. 프로덕션 의존성 (Production Dependencies)

  • 프로덕션 의존성실제 애플리케이션이 배포되었을 때, 애플리케이션이 정상적으로 동작하는 데 필요한 라이브러리나 패키지입니다.
  • 이 의존성들은 애플리케이션이 실행될 때 필수적입니다.
  • 주로 애플리케이션의 기능 구현실행에 직접적으로 영향을 미치는 패키지입니다.

예시:

  • React, Next.js, Express 등은 프론트엔드/백엔드 애플리케이션의 핵심 라이브러리입니다. 이들은 애플리케이션이 실제로 배포되었을 때 동작하는 데 필요합니다.
  • axios와 같은 HTTP 요청 라이브러리도 프로덕션 의존성입니다.
npm install <패키지명>

# or

npm install <패키지명> --production

 

 

2. 개발 의존성 (Development Dependencies)

  • 개발 의존성은 애플리케이션이 개발 과정에서만 필요하고, 프로덕션 환경에서는 필요하지 않은 패키지입니다.
  • 주로 코드 품질 개선, 테스트, 빌드 도구 등이 여기에 포함됩니다.
  • 개발 중에만 사용되며, 애플리케이션이 배포될 때는 불필요한 패키지입니다.

예시:

  • eslint, prettier와 같은 코드 품질 검사 도구는 개발 시에만 필요하고, 애플리케이션 실행에는 필요하지 않습니다.
  • jest, storybook과 같은 테스트 도구도 개발 중에만 사용됩니다.
  • webpack과 같은 빌드 도구babel도 애플리케이션이 빌드될 때 필요하지만, 배포 후에는 필요하지 않습니다.
npm install <패키지명> --save-dev  # 개발 의존성으로 설치

 

 

왜 프로덕션과 개발 의존성을 구분할까?

  1. 최적화:
    • 개발 의존성까지 모두 배포하면, 애플리케이션의 크기가 커지고, 메모리 및 성능에 영향을 줄 수 있습니다.
    • 프로덕션에서는 실행에 필요한 의존성만 포함시키는 것이 좋습니다.
  2. 보안:
    • 개발 의존성에는 개발 도구테스트 도구가 포함되며, 이는 프로덕션에서 실행될 때 불필요한 취약성을 만들 수 있습니다.
    • 개발에만 필요한 라이브러리를 포함시키지 않으면, 보안 위험을 줄일 수 있습니다.
  3. 간결성:
    • 프로덕션 환경에서는 오직 애플리케이션이 동작하는 데 필요한 최소한의 코드만 포함시켜야 합니다.
    • 개발 과정에서 쓰는 도구는 프로덕션 환경에서는 불필요하므로 간결하고 최적화된 상태로 배포할 수 있습니다.

멀티 스테이지 빌드에서 --production 옵션을 사용하여 프로덕션 의존성만 설치하는 이유는

최종 컨테이너 이미지를 최적화하기 위함입니다.

 

예를 들어:

Dockerfile
코드 복사
# Base stage: Production dependencies
FROM node:18-alpine AS base
WORKDIR /app

# Install only production dependencies
COPY package*.json ./
RUN npm install --production

위와 같이 npm install --production 명령을 사용하면 devDependencies는 설치되지 않고, 프로덕션 의존성만 포함됩니다.

이렇게 하면 최종 컨테이너 이미지는 필수적인 패키지만을 포함하게 되어 이미지 크기와 성능 면에서 최적화됩니다.

 

 

b. Standalone

도커 이미지를 만드는 과정에서 Builder 스테이지에서 구성한 node_modules, package.json 등 필요한 파일을 복사해 와서 이미지에 넣습니다. 이 과정에서 Next.js의 standalone을 사용하면 node_modules의 선택 파일을 포함하여 프로덕션 배포에 필요한 파일만 복사하는 독립 실행형 폴더를 자동으로 생성할 수 있습니다.

 

즉 node_modules를 설치하지 않고도 자체적으로 배포가 가능합니다.

그래서 npm start가 어난 server.js 파일을 대신 사용합니다.

standalone을 설정하기 위해선 next.config.mjs에

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

를 추가하면 됩니다.

 

저는 우리가 구성한 프로젝트에 맞게 다음과 같이 설정했습니다.

/next.config.mjs

/** @type {import('next').NextConfig} */
const nextConfig = {
  output: 'standalone',
}

export default nextConfig

 

 

위의 개념들이 적용된 최종 코드는 다음과 같습니다.

# multi-stage 빌드 방식 사용
# 이미지를 만드는 과정에서는 필요하지만 최종 컨테이너 이미지에는 필요 없는 환경을 
# 제거하기 위해 단계를 나누어 기반 이미지를 생성하는 방법

FROM node:18-alpine AS base

# 작업 디렉토리를 설정 -> 도커 컨테이너 안에서 어떤 경로에서 실행할 것인지를 명시
WORKDIR /app

# 프로젝트의 의존성을 복사
COPY package*.json ./

# production 패키지만 base에 설치
RUN npm install --production

# 개발용 패키지를 설치하기 위해 단계 구분
FROM base AS deps

WORKDIR /app

# 패키지 설치
RUN npm install

# Build 스테이지
FROM deps AS builder

WORKDIR /app

# 현재 디렉토리를 /app 디렉토리에 복사
COPY . .

# 프리티어 오류 발생으로 빌드 전에 프리티어 규칙 적용
# 빌드된 파일들에는 프리티어가 적용되어 있지 않아서 미리 적용해 줌
# package.json에 script를 추가하고 "prettier": "prettier --write ." 추가
RUN npx prettier --write .

RUN npm run build

# Runtime 스테이지 : 이미지 생성
# 불필요한 의존성을 가져가지 않기 위해 base를 가져오지 않고, node:18-alpine를 사용
FROM node:18-alpine AS runner

WORKDIR /app

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

# next의 standalone을 통해 필요한 파일만 복사하는 도깁 실행형 폴더를 자동 생성
# 아래 설정 next.config.mjs에 설정
# module.exports = {
#   output: 'standalone',
# }
# <https://nextjs.org/docs/pages/api-reference/next-config-js/output>

# standalone을 설정해 줬기 때문에 Build 스테이지에서 /app/.next/standalone가 형성
# 이를 COPY해서 사용 

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

USER nextjs

EXPOSE 3000
ENV PORT 3000

# 어플리케이션 start
CMD ["node", "server.js"]

# Base stage (base):
# npm install --production: 프로덕션에서만 필요한 의존성을 설치합니다. (예: react, next 등)
# 이 스테이지에서는 프로덕션에서 실행에 필요한 최소한의 패키지만 설치합니다.

# Dependencies stage (deps):   
# npm install: 개발 의존성까지 포함하여 모든 의존성을 설치합니다. 이 단계는 빌드에 필요한 패키지를 포함하는 것입니다.
# 이 단계에서 storybook, eslint 등 개발 도구와 관련된 패키지들이 설치됩니다.

# Build stage (builder):    
# npm run build: Next.js 애플리케이션을 빌드하는 단계입니다.
# builder는 소스 파일 전체를 복사하고, 빌드를 통해 정적 자원을 생성합니다.

# Runtime stage (runner):    
# 최종 런타임 이미지로, builder에서 빌드된 결과물과 프로덕션 의존성만 복사해 옵니다.
# 이 단계에서는 개발 도구나 빌드에 필요한 패키지들이 포함되지 않으며, 실행에 필요한 것들만 포함된 최소한의 이미지를 생성합니다.

위와 같이 Dockerfile을 만들고 .dockerignore 파일을 만들어

이미지 빌드 과정에서 올라가면 안되는 파일 및 폴더를 정해줍니다.

/.dockerignore

node_modules
dist
.env
.git
.dockerignore
logs
*.log
tests
.eslintrc.js
.prettierrc
docker-compose.yml
Dockerfile
README.md

 

이렇게 이미지 빌드에 필요한 파일들을 생성해 주고, 터미널에 다음 명령어를 작성하여 이미지가 빌드 되는 것을 확인합니다.

docker build -t {원하는 이름} .
# docker build -t nextjs-docker . 전 이렇게 작성했습니다.

아래처럼 naming to docker.io/library/nextjs-docker가 나오면 nextjs-docker라는 이미지가 잘 빌드된 것입니다.

 => => exporting layers                                                                                                              0.1s
 => => writing image ~~~~                                        																	 0.0s
 => => naming to docker.io/library/nextjs-docker

 


2. 도커 컴포즈 연동

도커 컴포즈의 역할

  • 여러 컨테이너를 함께 실행:
    • 웹 서버, 데이터베이스, 캐시 서버 등 여러 컨테이너를 함께 실행할 수 있습니다.
  • 컨테이너 간의 네트워크 설정:
    • 컨테이너들 간의 네트워크를 자동으로 설정하여, 서비스들이 서로 통신할 수 있도록 돕습니다.
  • 환경 구성 및 배포의 간소화:
    • 여러 컨테이너의 설정을 하나의 docker-compose.yml 파일에 정의하여, 손쉽게 환경 설정 및 배포를 할 수 있습니다.

즉, Next.js 애플리케이션을 컨테이너화 하여 자동 재시작 및 네트워크 설정 등을 통해 관리할 수 있는 환경을 제공합니다. 확장성을 생각하여 추가했습니다.

/docker-compose.yml

# 도커 컴포즈 -> 여러개의 도커 컨테이너를 듸우기 위해 사용되는 간단한 오케스트레이션 도구
# 참고 : <https://github.com/vercel/next.js/blob/canary/examples/with-docker-compose/docker-compose.prod.yml>

version: '3' # 도커 컴포즈의 버전

services:
  next-app:
    container_name: next-app
    build:
      context: . # Dockerfile이 있는 현재 디렉토리
      dockerfile: Dockerfile
    restart: always
    ports:
      - '3000:3000' # 호스트 머신의 3000번 포트를 도커 컨테이너 내부의 3000번 포트로 연결
    networks: # docker network create my_network로 네트워크를 미리 만들어줘야 한다.
      - my_network

networks:
  # 네트워크 정의를 통해 여러 컨테이너가 서로 통신할 수 있도록 도와주는 기능
  # 각 컨테이너가 같은 네트워크에 속할 때, 컨테이너 이름을 호스트네임으로 사용하여 쉽게 통신할 수 있도록 도와줌
  # 현재 하나의 Container Registry를 사용하기 때문에 필요
  my_network:
    external: true
# 해당 코드를 작성한 뒤
# docker-compose -f docker-compose.yml up -d
# 를 하여 next-app 서비스를 만든다.
# 즉 docker-compose.yml 파일을 통해서 Next.js 애플리케이션을 컨테이너화 하여
# 자동 재시작 및 네트워크 설정 등을 통해 관리할 수 있는 환경을 제공합니다.

 


3. NCP와 연동하여 CI/CD 구축

NCP에서 도커 컨테이너 이미지를 간편하게 저장, 관리, 배포할 수 있는 서비스 Container Registry를 제공해 주는데 이를 이용할 예정입니다.

참고 자료 :

 

NCP Container Registry를 활용하여 CI·CD 환경 구축하기

## 이 글에서 하는 것들 (요약) - NCP Container Registry 생성 - GitHub action 다루기 - Docker image를 빌드하여 NCP...

sungbin.dev

 

위의 링크를 참고해 CI/CD를 구축하겠습니다.

 

 

우선 Container Registry와 연동이 잘되는지 테스트부터 해봅시다.

위의 코드를 입력하고 Container Registry를 생성하고 얻는  Username과 Password를 입력하면 됩니다.

 

이미지 빌드 후 push하기

docker build -t /<target_image[:tag]> -f ./Dockerfile .
# docker build -t [cnergy-registry.kr.ncr.ntruss.com](<http://cnergy-registry.kr.ncr.ntruss.com/>)/cnergy-frontend -f ./Dockerfile . 
</target_image[:tag]>
docker push <CONTAINER_REGISTRY_URL>/<TARGET_IMAGE[:TAG]>
# docker push cnergy-registry.kr.ncr.ntruss.com/cnergy-frontend

해당 과정을 CI/CD를 통해서 main에 push 이벤트가 일어났을 때 실행합니다.

 

 

CI/CD deploy.yml 파일 만들기

루트 폴더에 .github 폴더를 만들고, 내부에 workflows 파일을 만들어 deploy.yml 파일을 생성합니다.

그리고 다음 코드를 작성합니다.

각 코드에 대한 내용은 주석과 name을 읽으면 쉽게 이해할 수 있습니다.

push_to_registry와 pull_from_registry를 구분하여 작업 모듈화

 

push_to_registry

name: deploy next to ncloud

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  push_to_registry:
    name: Push to mcp container registry
    runs-on: ubuntu-latest
    steps:
      - name: Checkout 
        # GitHub Action은 해당 프로젝트가 만들어진 환경에서 checkout하고 나서 실행
        uses: actions/checkout@v3
      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3
      - name: Login to NCP Container Registry
        uses: docker/login-action@v3
        with:
          registry: ${{ secrets.NCP_CONTAINER_REGISTRY }}
          username: ${{ secrets.NCP_ACCESS_KEY }}
          password: ${{ secrets.NCP_SECRET_ACCESS_KEY }}
      - name: build and push
        uses: docker/build-push-action@v3
        with:
          context: .
          file: Dockerfile
          push: true
          tags: ${{ secrets.NCP_CONTAINER_REGISTRY }}/cnergy-frontend:S{{ github.sha }} 
          #깃허브 커밋 해시값으로 이미지 구분

 

pull_from_registry

  pull_from_registry:
    name: NCP 접속 후 이미지 다운로드 및 배포
    needs: push_to_registry
    runs-on: ubuntu-latest
    steps:
      - name: ssh 연결
        uses: appleboy/ssh-action@master
        with:
          host: ${{ secrets.NCP_HOST }}
          username: ${{ secrets.NCP_USERNAME }}
          password: ${{ secrets.NCP_PASSWORD }}
          port: ${{ secrets.NCP_PORT }}
          script: |
            docker pull ${{ secrets.NCP_CONTAINER_REGISTRY }}/cnergy-frontend:${{ github.sha }} 
            docker stop cnergy-frontend || true
            docker rm cnergy-frontend || true
            docker run -d -p 80:3000 --name cnergy-frontend ${{ secrets.NCP_CONTAINER_REGISTRY }}/cnergy-frontend:${{ github.sha }}
            docker image prune -f

 

 

GITHUB_SHA 환경 변수를 사용한 이미지 선택

 

GITHUB_SHA 환경 변수를 이용해 특정 커밋에 해당하는 이미지를 가져오거나, 만약 설정되지 않았을 때 최신 이미지를 가져오는 방식입니다. 이전 코드에서는 항상 커밋 해시값을 사용하고 있는데, 이와 같이환경 변수를 체크하여, 필요시 최신 이미지를 사용할 수 있는 옵션을 추가할 수 있습니다.

 

최종코드입니다.

# 작업 모듈화
name: deploy next to ncloud

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  push_to_registry:
    name: Push to mcp container registry
    runs-on: ubuntu-latest
    steps:
      - name: Checkout # GitHub Action은 해당 프로젝트가 만들어진 환경에서 checkout하고 나서 실행
        uses: actions/checkout@v3

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

      - name: NCP 레지스트리 로그인
        uses: docker/login-action@v3
        with:
          registry: ${{ secrets.NCP_CONTAINER_REGISTRY }}
          username: ${{ secrets.NCP_ACCESS_KEY }}
          password: ${{ secrets.NCP_SECRET_ACCESS_KEY }}

      - name: 도커 이미지 빌드 후 푸시
        uses: docker/build-push-action@v3
        with:
          context: .
          file: Dockerfile
          push: true
          tags: ${{ secrets.NCP_CONTAINER_REGISTRY }}/cnergy-frontend:S{{ github.sha }} #깃허브 커밋 해시값으로 이미지 구분

  pull_from_registry:
    name: NCP 접속 후 이미지 다운로드 및 배포
    needs: push_to_registry
    runs-on: ubuntu-latest
    steps:
      - name: ssh 연결
        uses: appleboy/ssh-action@master
        with:
          host: ${{ secrets.NCP_HOST }}
          username: ${{ secrets.NCP_USERNAME }}
          password: ${{ secrets.NCP_PASSWORD }}
          port: ${{ secrets.NCP_PORT }}
          script: |
            if [-z "$GITHUB_SHA" ]; then
              echo "GITHUB_SHA 환경 변수가 설정되지 않았습니다. 최신 이미지를 사용합니다."
              IMAGE_ID=$(docker images --format "{{.ID}}" | head -n 1)
            else
              echo "GITHUB_SHA: $GITHUB_SHA 이미지를 사용합니다."
              IMAGE_ID=${{ secrets.NCP_CONTAINER_REGISTRY }}/cnergy-frontend:${{ github.sha }}
            fi

            docker stop cnergy-frontend || true
            docker rm cnergy-frontend || true
            docker run -d -p 80:3000 --name cnergy-frontend $IMAGE_ID
            docker image prune -f

 


4. 서버에 도커 설치

서버 접속

윈도우 → cmd (관리자 권한으로 실행)

맥 → 터미널에서 sudo로 실행

 

필요 패키지 설치 및 키 등록

sudo apt-get update
sudo apt-get install ca-certificates curl
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL <https://download.docker.com/linux/ubuntu/gpg> -o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc

echo \\
  "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] <https://download.docker.com/linux/ubuntu> \\
  $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | \\
  sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
  
  
sudo apt-get update

 

 

도커 설치

sudo apt-get install docker-ce docker-ce-cli containerd.io docker-buildx-plugin

stand-alone 방식으로 설정해서 도커 컴포즈 플러그인은 설치 x

 

도커 잘 설치되었는지 확인

sudo docker run hello-world

 

 

이렇게 진행한 후 깃허브 액션에 시크릿 키들을 설정하면 끝입니다!

긴 글 읽어주셔서 감사합니다 :)

+ Recent posts