본문 바로가기

우아한테크코스/크루위키

크루위키 Next.js CD구축 (EC2, CodeDeploy, PM2)

2월 초 크루위키 마이그레이션 작업을 끝내고 시간에 맞춰 재출시를 하기 위해 EC2 인스턴스를 열어서 환경을 셋팅하고 직접 인스턴스 안에서 빌드를 수행하고 서버를 실행하는 수동 배포 방식으로 배포했다. 이제 어느 정도 서비스가 안정화되고 조금 여유가 생겨 자동 배포를 도전해 봤다.

 

현재 수동 배포 과정을 그림으로 살펴보면

 

EC2 인스턴스에 직접 접근한 뒤 디렉터리에 들어가 git pull origin main을 입력해서 main 코드를 EC2로 가져온다. 그 뒤에 next의 build 명령을 실행해서 main 코드에 기반한 빌드 파일을 만들고 background로 실행하기 위해 PM2로 next start를 실행해 주는 과정으로 수동 배포를 했었다.

 

하지만 이 과정에서 문제점이 있었는데

 

1. EC2 프리티어 환경에서 build 속도가 매우 느림

2. 빌드를 하며 Heap 공간이 부족한 현상

 

EC2를 프리티어로 사용하려면 t2.micro를 사용해야 하기 때문에 스토리지 최대 30G, 메모리 1G를 사용할 수 있다. 이 환경에서 next build를 돌리면 정말 오랜 시간이 걸린다. 우리 서비스의 경우 정적 파일을 생성하는 양이 꽤 많아서 로컬에서 빌드를 돌렸을 때 .next 디렉터리가 980MB를 차지했다. 이 작업을 t2.micro에 맡기다 보니 빌드를 마무리하는데 5분 이상의 시간이 걸리게 되어 정말 답답함을 느꼈다. 실제로 EC2에서 빌드했을 때 시간을 측정해봤을 때 6분 37초나 걸렸다...

 

또한 빌드하는 과정에서 메모리가 부족해서 빌드에 실패하게 되는 현상이 종종 발생했다. 그래서 EC2에서 빌드할 때는 package.json의 build script를 아래와 같이 설정해서 사용하는 힙을 늘려주어야 오류가 발생하지 않았다.

"build": "export NODE_OPTIONS=--max-old-space-size=2048 && next build"

 

만일 EC2에서 무거운 작업인 build를 하지 않고 다른 곳에서 빌드를 해서 EC2에 빌드한 파일만 넘겨준다면 위 문제를 해결할 수 있다고 생각했다. 이에 대해 찾아본 결과 CodeDeploy를 사용해서 빌드한 파일을 EC2에 넘겨주는 방법이 있었다. 레퍼런스를 참고하면서 구축을 해보니 EC2 인스턴스에 부하를 주지 않으면서 배포할 수 있게 되었고, 자동 배포도 적용할 수 있었다.

 

역시 자동 배포 과정을 그림으로 먼저 보면

 

Main 브랜치에 Push를 하면 Github Action이 돌면서 next.js를 빌드해 준다. 그 뒤에 S3에 루트 디렉터리 자체를 압축해서 업로드해 준다. 그 뒤에 워크플로우에서 Code Deploy를 실행시켜 S3에 있는 파일을 다운로드하게 해 주면 된다. 그러면 Code Deploy가 실행되고 S3로부터 파일을 가져와 압축을 푼 뒤에 EC2에 파일을 업로드한다. 그리고 현재 실행 중인 PM2를 잠시 종료하고 새로운 코드를 기반으로 PM2를 시작하면 새로운 코드르 기반으로 배포를 할 수 있게 된다.

 

큰 그림을 살펴봤으니 이제 자세한 적용 방식을 살펴보자

 

1. Github Action workflow

... 위는 생략
steps:
  # 1. Git 리포지토리 체크아웃
  - name: Checkout code
    uses: actions/checkout@v4

  # 2. Node.js 20.15.1 version으로 셋팅
  - name: Set up Node.js
    uses: actions/setup-node@v4
    with:
      node-version: "20.15.1"

  # 3. 의존성 설치
  - name: Install dependencies
    run: yarn install

  # 4. 환경변수 파일 github secret에서 가져와서 생성
  - name: Create environments from github secrets
    run: |
      echo "NEXT_PUBLIC_API_BASE_URL=${{ secrets.NEXT_PUBLIC_API_BASE_URL }}" >> .env
	  ... ohters env

  # 5. Prod 환경으로 빌드
  - name: Build for Prod environment
    run: yarn build

 

Checkout 후 node 버전을 맞춰주고 의존성을 설치한 뒤 환경변수를 가져와서 env로 만든 뒤 build를 실행해 준다.

 

# 6. S3에 업로드할 zip 파일 생성
- name: zip create for S3 upload
  run: zip -qq -r ./crew-wiki-build.zip .
  shell: bash
  
# 7. AWS 인증 설정
- name: Configure AWS credentials
  uses: aws-actions/configure-aws-credentials@v3
  with:
    aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY }}
    aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
    aws-region: ${{ secrets.AWS_REGION }}

# 8. S3에 zip 파일 업로드
- name: Upload to S3
  env:
    S3_BUILD_BUCKET: ${{ secrets.S3_BUILD_BUCKET }}
    AWS_REGION: ${{ secrets.AWS_REGION }}
  run: |
    aws s3 cp --region $AWS_REGION ./crew-wiki-build.zip s3://$S3_BUILD_BUCKET/crew-wiki-build.zip

 

빌드 결과를 포함한 디렉터리를 zip으로 압축해서 S3에 업로드하는 과정이다. 이 과정에서 AWS 서비스를 사용하기 위해서 먼저 인증을 해준 뒤에 지정해 준 버킷에 zip으로 압축한 디렉터리를 업로드한다. 그러면 아래처럼 S3에 업로드가 된다.

로컬에서 빌드하고 .next 디렉터리 크기를 확인한 결과 980MB가 나왔는데, .next 디렉터리를 포함한 전체 코드를 압축한 결과 크기는 237MB로 S3에 업로드하는 용량을 줄일 수 있었다.

 

 

# 9. S3에 올라간 zip 파일을 CodeDeploy로 가져옴
- name: Code Deploy using S3 zip file
  env:
    CODE_DEPLOY_APPLICATION_NAME: ${{ secrets.CODE_DEPLOY_APPLICATION_NAME }}
    CODE_DEPLOY_GROUP_NAME: ${{ secrets.CODE_DEPLOY_GROUP_NAME }}
    S3_BUILD_BUCKET: ${{ secrets.S3_BUILD_BUCKET }}
  run: aws deploy create-deployment
    --application-name $CODE_DEPLOY_APPLICATION_NAME
    --deployment-config-name CodeDeployDefault.AllAtOnce // deploy 방식
    --deployment-group-name $CODE_DEPLOY_GROUP_NAME
    --s3-location bucket=$S3_BUILD_BUCKET,key=crew-wiki-build.zip,bundleType=zip

 

마지막으로 S3의 zip 파일을 Code Deploy로 가져가도록 설정을 해주면 워크플로우의 역할은 끝난다.

 

2. Code Deploy

Code Deploy의 역할은 S3에 있는 파일을 EC2에 배포해 주는 것이다. 위 워크플로우에서 애플리케이션 이름, 배포 그룹 이름을 넣어야 하는데 지금부터는 AWS Code Deploy 애플리케이션을 만드는 과정을 보려고 한다. 

 

그전에 먼저 해주어야 할 설정이 있는데  EC2 인스턴스에 연결해 줄 역할 하나와 Code Deploy에서 사용하는 역할 두 개를 먼저 만들어주어야 한다.

 

먼저 EC2에 연결할 역할부터 보면 총 3개의 권한을 설정해줘야 한다

  • AmazonEC2RoleforAWSCodeDeploy
  • AmazonS3FullAccess
  • AmazonCodeDeployFullAccess

 

그다음으로 Code Deploy를 위한 역할이다.

  • AWSCodeDeployRole

 

그리고 EC2 인스턴스 -> 작업 -> 보안 -> IAM 역할 수정에 들어가서 EC2 역할을 설정해 주면 된다.

 

그리고 Code Deploy에 들어가서 애플리케이션 생성을 누르면 위 화면이 보인다. 애플리케이션 이름과 컴퓨팅 플랫폼을 입력해야 하는데 애플리케이션은 원하는 이름, 컴퓨팅 플랫폼은 EC2/온프레미스를 선택해 주면 된다. 그리고 생성 버튼을 누르면 애플리케이션 하나가 만들어진다.

 

애플리케이션이 생성됐으면 배포 그룹을 만들어주면 된다. 배포 그룹 생성을 누르면 아래처럼 배포 그룹 이름과 서비스 역할을 설정하는 입력이 나오는데, 배포 그룹 이름은 원하는 값을 넣고 서비스 역할은 아까 만든 Code Deploy 역할을 넣으면 된다.

 

배포 유형 선택으로 현재 위치, 블루 / 그린을 선택하게 되는데 우리는 무중단 배포가 목적이 아니기 때문에 현재 위치로 선택해 줬다. 그리고 환경 구성에서 Amazon EC2 인스턴스를 클릭한 후 key에 Name, 값에 Code Deploy를 사용할 인스턴스를 선택하면 된다.

 

다음으로는 AWS Systems Manager를 설정하는 화면이 나온다. EC2에서 Code Deploy를 사용하려면 EC2에 Code Deploy Agent라는 것을 설치해야 한다. 이것을 여기서 설정할 것인지 나중에 EC2에서 직접 설치할 것인지에 대한 선택인데 나는 EC2에 접속해서 직접 설치해 줬다.

 

마지막으로 배포 설정과 로드 밸런서 설정이다. 먼저 배포 설정으로는 3가지 옵션이 있다.

  • OneAtTime (한 번에 한 인스턴스 씩)
  • HalfAtTime (절반씩)
  • AllAtOnce (한 번에 전부)

만약 인스턴스를 여러 개 둔다면 이 설정이 의미가 있을 것 같지만 현재 우리 인프라는 프론트 인스턴스를 하나만 사용하기 때문에 3개 설정이 의미가 없다고 생각한다. 그래서 AllAtOnce를 선택해 줬다. 밑의 로드 밸런서도 이와 같은 의미로 설정하지 않았다.

 

이렇게 설정하고 배포 그룹을 생성하면 된다.

 

3. EC2에 Code Deploy Agent 설치

EC2에 Code Deploy Agent를 설치하러 가보자. 먼저 EC2 인스턴스에 접속해 준 뒤 아래 명령어를 입력해 주면 된다.

$ sudo apt update
$ sudo apt install ruby-full
$ sudo apt install wget
$ wget https://aws-codedeploy-ap-northeast-2.s3.ap-northeast-2.amazonaws.com/latest/install
$ chmod +x ./install
$ sudo ./install auto

 

그리고 아래 명령어를 입력했을 때 초록색 active: running이 나오면 설치 성공이다.

$ sudo service codedeploy-agent status

 

4. Code Deploy 동작 설정

이제 Code Deploy의 동작을 설정할 차례이다. 이는 Code Deploy가 EC2에 접근해서 어떤 일을 수행할 것인지 정해주는 것이다.

 

AWS에서 다시 프로젝트로 돌아와서 프로젝트의 루트 디렉터리에 appspec.yml 파일을 만들어준다. 루트 디렉터리의 기준은 워크플로우 단계에서 파일을 압축할 때의 디렉터리이다. 우리의 경우 워크플로우에서 working-directory를 client로 설정해 줬기 때문에 client 디렉터리 안에 appspec.yml을 만들어줬다. 참고로 이 파일이 없으면 Code Deploy에서 에러가 난다.

version: 0.0
os: linux

files:
  - source: /
    destination: {your_ec2_directory}
    overwrite: yes
permissions:
  - object: {your_ec2_directory}
    owner: ubuntu
    group: ubuntu
    mode: 755

hooks:
  BeforeInstall:
    - location: ./clean.sh
      timeout: 30
      runas: ubuntu

  AfterInstall:
    - location: ./deploy.sh
      timeout: 60
      runas: ubuntu

 

S3에서 가져온 파일을 EC2의 어느 디렉터리에 설치할 것인지 설정하는 files 단계, 읽기 쓰기 실행 권한을 주는 permission을 설정해 주고 아래 hooks의 BeforeInstall, AfterInstall 단계에서 실행할 스크립트 파일(clean.sh, deploy.sh)을 같은 디렉터리에 만들어준다. 이것은 EC2에 설치하기 전, EC2에 설치한 후의 동작을 넣어주는 것으로 각각 아래와 같이 적어주면 된다.

 

clean.sh

먼저 이전에 실행 중인 pm2 0번을 삭제하고 해당 디렉터리를 지워버린다. 이때 rm -rf 뒤에 디렉터리는 정말 잘 확인해서 써야 한다. 잘못 적으면 통째로 날아가는 수가 있으니... 이 작업이 수행되고 Code Deploy가 EC2에 디렉터리 파일을 설치한다. 

#!/bin/bash

pm2 delete 0

echo "Removing existing client directory..."
sudo rm -rf {your_ec2_directory}

 

 

deploy.sh

EC2에 디렉터리 설치가 끝난 뒤 실행되는 명령이다. 먼저 nvm 경로를 잡아준다. (nvm을 전역으로 설치했다면 이렇게 해줄 필요가 없는 듯싶다.) 그리고 디렉터리 이동을 한 뒤에 yarn deploy 명령어를 실행시켜 준다. 이는 package.json에 있는 script를 실행해 주는 것이다.

#!/bin/bash

export NVM_DIR="$HOME/.nvm"
source "$NVM_DIR/nvm.sh"

REPOSITORY={your_ec2_directory}

cd $REPOSITORY

yarn deploy

 

package.json

스크립트에 deploy를 추가하고 pm2를 시작하는 명령어를 입력해 준다. 이때 ecosystem.config.js는 pm2를 어떻게 시작할 것인지 설정해 주는 파일로 역시 같은 디렉터리에 만들어주면 된다.

 "scripts": {
 	...
    "deploy": "pm2 start ecosystem.config.js --env production"
  },

 

ecosystem.config.js

pm2를 어떻게 시작할 것인지에 대한 설정파일이다. 여기서 exec_mode는 cluster가 아니라 fork를 사용했는데 우리는 CPU 코어 1개를 사용하기 때문에 fork나 cluster 어떤 것을 선택해도 상관이 없다. 

module.exports = {
  apps: [
    {
      name: 'crew-wiki',
      script: './node_modules/next/dist/bin/next',
      args: 'start',
      exec_mode: 'fork',
      autorestart: true,
      watch: false,
      max_memory_restart: '1G',
      env: {
        NODE_ENV: 'production',
      },
    },
  ],
};

 

deploy.sh에서 yarn deploy를 실행하고 pm2를 실행해서 아까 delete 했던 pm2를 다시 시작해 준다. 이렇게 해주면 코드를 main에 push 됐을 때 자동 배포가 이뤄지게 된다.

 

자동 배포 적용 결과

수동 배포를 했을 때 인스턴스에 접근해서 main 코드를 pull 받은 뒤 pm2를 멈추고 build를 한 뒤 다시 pm2를 직접 해줘야 했던 귀찮음을 git push만 하면 자동으로 적용이 되는 간편함을 얻을 수 있었으며 시간도 줄일 수 있었다.

 

아래 사진을 보면 자동 배포에 걸린 시간은 workflow가 실행되는 시간 2분 4초, code deploy가 실행된 시간 1분 19초를 합해서 3분 23초가 걸렸다. 수동 배포에서 build를 하는 시간만 5분이 넘어갔던 것에 비해서 시간적으로도 크게 이득을 볼 수 있었다.

 

레퍼런스

[Infra] Github-Actions + S3 + EC2 + CodeDeploy + pm2로 Next.js CICD 구축하기