개발 팁/Git & Github

Github Actions로 EC2 배포 자동화하기 (feat. NestJS, prisma)

왈왈디 2025. 8. 3. 23:52
728x90

이슈 발생

사이드 프로젝트 배포를 수동으로 진행하고 있었다.

코드 변경 사항이 발생한다면 EC2 인스턴스에 접속하여 git pull origin main으로 코드를 내려 받아서,
npx prisma generate, npm run build를 실행하여 빌드하고, pm2로 restart 시켰다.

 

어느날 평소와 같이 변경 사항을 pull 받고, build를 실행하는데

cpu 사용량이 100%에 도달하여 인스턴스가 먹통이 됐다.

인스턴스를 재부팅해야 했다.

 

사용 중인 인스턴스 유형은 t2.micro인데, 비용 상 인스턴스 유형을 업그레이드 하고 싶지는 않았다.

 

결국 build는 다른 서버에서 하고,

build 산출물을 EC2 인스턴스에 업로드하여 EC2에서는 서버 실행만 하는 것이 옳다고 판단했다.

이를 Github Actions로 구현해보자.


Github Actions 배포 자동화

github의 Actions 기능은 CI/CD 등을 자동화할 수 있는 툴이다.

프로젝트 루트 디렉토리 .github/workflows 경로에 자동화 스크립트를 작성하면

조건에 따라 자동으로 실행시킬 수 있다.

 

예를 들어, on: 조건이 아래와 같이 작성된 경우 main 브랜치에 push가 된 경우

jobs 이하의 작업이 실행된다는 의미이다.

name: Build and Deploy NestJS App

on:
  push:
    branches: ['main']

 

이번 배포 자동화를 위해 작성한 스크립트는 아래와 같다.

전체를 한 번에 이해하기 보다는 단계별로 쪼개어 살펴보자.

 

build-and-deploy.yml

name: Build and Deploy NestJS App

on:
  push:
    branches: ['main']

jobs:
  build-and-deploy:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - name: Setup Node.js 18.x
        uses: actions/setup-node@v4
        with:
          node-version: 18.x
          cache: 'npm'

      - name: Install Dependencies (Production)
        run: npm ci --omit=dev

      - name: Build Nest App
        run: npm run build

      - name: Generate Prisma Client
        run: npx prisma generate

      - name: Check Prisma Client generated
        run: |
          test -f node_modules/@prisma/client/index.js || (echo "❌ Prisma client not found!" && exit 1)
          test -f node_modules/.prisma/client/default.js || echo "⚠️ .prisma/client/default.js 없음 (Prisma 5.x에서는 선택적일 수 있음)"

      - name: Prepare Output Directory
        run: |
          mkdir output
          cp -r dist node_modules prisma package*.json output/
          if [ -d .prisma ]; then cp -r .prisma output/; fi

      - name: Archive Build Output
        run: |
          cd output && tar czf ../nest-app.tar.gz .

      - name: Get Public IP
        id: ip
        uses: haythem/public-ip@v1.3

      - name: Print Public IP
        run: |
          echo "GitHub Actions IP: ${{ steps.ip.outputs.ipv4 }}"

      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
          aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          aws-region: 'ap-northeast-2'

      - name: Add GitHub Actions IP to Security Group
        run: |
          aws ec2 authorize-security-group-ingress \
            --group-id ${{ secrets.SECURITY_GROUP_ID }} \
            --protocol tcp \
            --port 22 \
            --cidr ${{ steps.ip.outputs.ipv4 }}/32

      - name: Upload Build to Server via SCP
        uses: appleboy/scp-action@v0.1.6
        with:
          host: ${{ secrets.SERVER_HOST }}
          username: ${{ secrets.SERVER_USER }}
          key: ${{ secrets.SERVER_SSH_KEY }}
          port: 22
          source: 'nest-app.tar.gz'
          target: '~/deploy'

      - name: Execute Remote Deploy Script via SSH
        uses: appleboy/ssh-action@v0.1.10
        with:
          host: ${{ secrets.SERVER_HOST }}
          username: ${{ secrets.SERVER_USER }}
          key: ${{ secrets.SERVER_SSH_KEY }}
          port: 22
          script: |
            bash ~/deploy/deploy.sh

      - name: Remove GitHub Actions IP from Security Group
        if: always()
        run: |
          aws ec2 revoke-security-group-ingress \
            --group-id ${{ secrets.SECURITY_GROUP_ID }} \
            --protocol tcp \
            --port 22 \
            --cidr ${{ steps.ip.outputs.ipv4 }}/32

 

전체 흐름 요약

위 배포 자동화 스크립트의 전체적인 흐름은 아래와 같다.

  1. GitHub Actions: CI 파이프라인에서 npm ci, nest build, npx prsima generate 실행
    → node_modules, prisma, dist/ 디렉토리 생성
  2. 빌드 산출물(zip/tar): dist/, node_modules, prisma, 필요한 설정파일만 포함하여 압축
  3. 서버 전송: scp로 대상 Linux 서버에 업로드
  4. 서버에서 실행: 서버에 미리 작성해둔 deploy.sh 파일로 pm2 서버 실행

1. node_modules, 빌드 산출물 생성

배포 자동화 작업의 첫 번째 단계는 서버를 구동하기 위해 필요한 파일들을 빌드하는 것이다.

1) node 설치

    steps:
      - uses: actions/checkout@v4

      - name: Setup Node.js 18.x
        uses: actions/setup-node@v4
        with:
          node-version: 18.x
          cache: 'npm'

먼저 필요한 node 버전을 설치한다.

 

2) npm ci

      - name: Install Dependencies (Production)
        run: npm ci --omit=dev

그 다음 패키지를 install 한다.

여기서 --omit=dev 는 상용 서버 구동에 불필요한 dev dependencies 패키지를 제외한다는 의미다.

즉, 서버 구동에 필요한 패키지는 모두 dependencies로 설치되어 있어야 한다.

나는 @nestjs/cli, prisma, @prisma/client 패키지가 dev dependencies 로 설치되어 있었어서 수정해주었다.

 

3) npm run build

      - name: Build Nest App
        run: npm run build

npm run build( =nest build) 로 typescript 코드를 빌드한다. 

 

4) npx prisma generate

      - name: Generate Prisma Client
        run: npx prisma generate

      - name: Check Prisma Client generated
        run: |
          test -f node_modules/@prisma/client/index.js || (echo "❌ Prisma client not found!" && exit 1)
          test -f node_modules/.prisma/client/default.js || echo "⚠️ .prisma/client/default.js 없음 (Prisma 5.x에서는 선택적일 수 있음)"

prisma를 사용하는 프로젝트여서 prisma generate 산출물도 필요하다.

처음에는 EC2에서 npx prisma를 실행하려고 했으나,

prsima 모듈을 찾을 수 없다는 에러가 계속 발생해서,

(아마 node_modules를 압축하고 해제하는 과정에서 이슈가 발생한 게 아닐까 싶다)

github actions 파이프라인에서 npx prisma generate 까지 실행하여 산출물을 서버로 전송하는 방식으로 변경했다.

 

generate 결과물이 잘 생성되었는지 확인하기 위해 test 절차를 추가했다.

prisma 버전에 따라 .prisma 는 없을 수 있다.


2. 산출물 압축

1) 필요한 결과물만 output 디렉토리로 이동

      - name: Prepare Output Directory
        run: |
          mkdir output
          cp -r dist node_modules prisma package*.json output/
          if [ -d .prisma ]; then cp -r .prisma output/; fi

output이라는 이름의 디렉토리를 생성하여

dist, node_modules, prisma, package.json, package-lock.json 등

서버 구동에 필요한 파일/디렉토리만 담는다.

.prisma는 버전에 따라 있거나 없을 수 있으므로 분기처리 했다.

 

EC2 서버로 보낼 대상 디렉토리의 구조는 아래와 같다.

output/
├── dist/
├── node_modules/
├── package.json
├── package-lock.json
├── prisma/
└── .prisma/

 

2) 압축

      - name: Archive Build Output
        run: |
          cd output && tar czf ../nest-app.tar.gz .

그리고  output 디렉토리로 이동하여 그 안의 모든 파일을

EC2 서버에 전송할 nest-app.tar.gz 파일로 압축한다.


3. 산출물 서버 전송

위 단계까지 실행하면 EC2 서버에 전송할 압축 파일이 완성된다.

rsync, scp 등의 방식으로 파일을 서버로 전송할 수 있는데,

나는 scp 방식을 사용했다.

 

1) github actions 서버 public ip 접근 허용하기

파일을 전송하기 전에 점검해야 하는 부분이 있다.

scp는 ssh 프로토콜을 사용한다.

즉, EC2 서버의 보안 그룹에서 github actions 서버의 ssh port 접근이 허용되어 있어야 한다.

 

https://api.github.com/meta

응답의 actions 배열을 확인하면

github actions 서버들의 public ip 리스트를 확인할 수 있다.

매번 사용되는 인스턴스 ip는 변경될 수 있는데, 리스트의 ip 수가 매우 많아서

모두 보안 그룹에서 허용해두기에는 부담이 된다.

 

이 때 사용 가능한 방식이

동적으로 현재의 github actions 인스턴스의 public ip를 확인하여

이 ip를 보안그룹 허용 ip에 추가하고

배포가 완료되면 보안그룹에서 ip를 제거하는 것이다.

 

현재 사용 중인 인스턴스의 public ip를 확인한다.

      - name: Get Public IP
        id: ip
        uses: haythem/public-ip@v1.3

      - name: Print Public IP
        run: |
          echo "GitHub Actions IP: ${{ steps.ip.outputs.ipv4 }}"

 

그리고 AWS에 접근하여 보안 그룹에 현재 public ip를 추가한다.

      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
          aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          aws-region: 'ap-northeast-2'

      - name: Add GitHub Actions IP to Security Group
        run: |
          aws ec2 authorize-security-group-ingress \
            --group-id ${{ secrets.SECURITY_GROUP_ID }} \
            --protocol tcp \
            --port 22 \
            --cidr ${{ steps.ip.outputs.ipv4 }}/32

 

이 과정에서 아래 세 가지 환경 변수가 필요하다.

  • AWS_ACCESS_KEY_ID
  • AWS_SECRET_ACCESS_KEY
  • SECURITY_GROUP_ID

Github Actions에서 사용되는 환경변수는

레포지토리의 Settings > Security > Secrets and variables > Actions 에서 설정할 수 있다.

 

아래 두 값은 AWS IAM 에서 사용자를 생성하여 발급할 수 있다.

권한 관리를 위해 github actions에서만 사용할 사용자를 별도로 발급하는 것이 좋다.

  • AWS_ACCESS_KEY_ID
  • AWS_SECRET_ACCESS_KEY

아래 값은 EC2 인스턴스의 보안 그룹 ID이다.

  • SECURITY_GROUP_ID

위 step을 거치면 보안 그룹 22포트 허용 ip에 현재 github actions 서버 public ip가 추가된다.

 

2) 압축 파일 전송

접근이 허용되면 scp로 압축 파일을 전송한다.

      - name: Upload Build to Server via SCP
        uses: appleboy/scp-action@v0.1.6
        with:
          host: ${{ secrets.SERVER_HOST }}
          username: ${{ secrets.SERVER_USER }}
          key: ${{ secrets.SERVER_SSH_KEY }}
          port: 22
          source: 'nest-app.tar.gz'
          target: '~/deploy'

EC2 서버의 디렉토리 구조는 아래와 같이 구성했다.

따라서 ~/deploy 디렉토리로 압축 파일을 전송한다.

디렉토리 구조

~/deploy/nest-app.tar.gz        ← GitHub Actions에서 전송
~/app/nest-app/                 ← 실제 실행 위치
~/app/nest-app/dist/main.js     ← 엔트리포인트
~/app/nest-app/prisma/          ← schema 및 migrations

 

파일 전송 시 필요한 환경 변수는 아래 세 가지다.

  • SERVER_HOST: EC2 서버의 ip 또는 도메인
  • SERVER_USER: EC2 서버 접속 시 사용하는 user
  • SERVER_SSH_KEY

이 중 SERVER_SSH_KEY는 공개키-개인키 방식으로 생성 방법은 아래와 같다.

ssh key 생성 방법

1. 터미널에서 키 생성 명령어 실행

ssh-keygen -t rsa -b 4096 -C "github-deploy"

 

 

GitHub Actions는 비밀번호 없이 사용해야 하므로, 패스프레이즈는 비워둬야 한다.

생성 결과: 

  • github_deploy_key: 개인 키 (이걸 GitHub Secret에 등록)
  • github_deploy_key.pub: 공개 키 (이걸 서버에 등록)

2. 서버에 공개키 등록

서버에서 ~/.ssh/authorized_keys에 공개 키 추가하거나

cat github_deploy_key.pub >> ~/.ssh/authorized_keys
chmod 600 ~/.ssh/authorized_keys

직접 편집할 수 있다.

vi ~/.ssh/authorized_keys

 

3. Github Actions Secrets에 개인키 등록

SERVER_SSH_KEY로 github_deploy_key 파일의 내용 전체를 복사한다. (예: cat github_deploy_key 결과)

 

4. 권한 확인(선택 사항)

GitHub Actions에서 사용할 사용자 계정이 서버에 실제로 접근 가능한지 사전에 확인 가능하다.

ssh -i github_deploy_key youruser@your.server.ip

4. 서버 실행

위 단계들을 통해 EC2 인스턴스로 서버 실행에 필요한 파일들이 모두 옮겨졌다면,

서버에 미리 작성해둔 deploy.sh 파일로 pm2 서버를 실행한다.

 

~/deploy/deploy.sh

#!/bin/bash

set -e

APP_DIR=~/app/nest-app
DEPLOY_DIR=~/deploy
ARCHIVE=$DEPLOY_DIR/nest-app.tar.gz

echo "[1/3] 아카이브 해제 중..."

ENV_BACKUP="$APP_DIR/.env"

if [ -f "$ENV_BACKUP" ]; then
  cp "$ENV_BACKUP" /tmp/.env.bak
fi

rm -rf $APP_DIR
mkdir -p $APP_DIR
tar -xzf $ARCHIVE -C $APP_DIR

# 복원
if [ -f /tmp/.env.bak ]; then
  mv /tmp/.env.bak "$APP_DIR/.env"
fi

cd $APP_DIR

echo "[2/3] PM2로 앱 시작 또는 재시작..."
if pm2 describe nest-app > /dev/null; then
  pm2 restart nest-app
else
  pm2 start dist/main.js --name nest-app
fi

echo "[3/3] 배포 완료!"

 

deploy.sh의 내용은 압축을 해제하고, pm2로 서버를 실행하는 것이다.

다만 여기서 주의할 점은 .env 파일은 미리 해당 디렉토리 내에 저장되어 있어야 하고,

삭제되면 안되기 때문에 .env 파일이 삭제되지 않도록 예외처리 해주었다.

 

또, PM2는 인스턴스 내에 글로벌 설치 되어 있어야 한다. (npm install -g pm2)

deploy.sh가 잘 실행되면 서버가 구동된다.

 

그럼 마지막으로 EC2 보안 그룹 허용 ip에 추가되었던 github actions 서버 ip를 제거해주면 끝이다.

      - name: Remove GitHub Actions IP from Security Group
        if: always()
        run: |
          aws ec2 revoke-security-group-ingress \
            --group-id ${{ secrets.SECURITY_GROUP_ID }} \
            --protocol tcp \
            --port 22 \
            --cidr ${{ steps.ip.outputs.ipv4 }}/32

 

728x90