Node.js 앱 무중단 배포: GitHub Actions와 Docker로 EC2 자동화 파이프라인 구축하기

2025. 8. 2. 00:07·개발

개발자라면 누구나 꿈꾸는 자동화된 배포 파이프라인! 코드를 깃헙에 푸시하기만 하면 자동으로 테스트, 빌드, 배포까지 완료되는 멋진 세상을 만들어 봅시다.

이 글에서는 GitHub Actions, Docker, Docker Compose를 사용해 Node.js 애플리케이션을 AWS EC2에 거의 중단 없이(near-zero downtime) 배포하는 전체 과정을 단계별로 살펴보겠습니다. 이 가이드를 통해 효율적이고 안정적인 CI/CD 파이프라인을 직접 구축할 수 있습니다.

📜 전체적인 배포 전략

우리의 목표는 다음과 같은 흐름을 자동화하는 것입니다.

  1. 개발자가 로컬에서 작업 후 main 브랜치에 코드를 푸시(Push)합니다.
  2. GitHub Actions가 이벤트를 감지하고 워크플로우를 실행합니다.
  3. (Build Job) 최적화된 Dockerfile을 사용해 Node.js 애플리케이션을 빌드하고, Docker Hub에 이미지로 푸시합니다.
  4. (Deploy Job) 빌드가 성공하면, GitHub Actions는 SSH를 통해 EC2 서버에 접속합니다.
  5. EC2 서버에서 Docker Hub의 최신 이미지를 받아오고(pull), docker-compose를 이용해 기존 컨테이너를 내리고 새 컨테이너를 실행하여 서비스를 업데이트합니다.
  6. 배포 성공/실패 여부를 최종적으로 알려줍니다.

0. 사전 준비: GitHub Secrets 및 EC2 설정

자동화 파이프라인을 구축하기 전에, GitHub Actions가 EC2 서버에 안전하게 접속하고 Docker Hub에 이미지를 푸시할 수 있도록 몇 가지 설정을 해야 합니다.

1. EC2 접속을 위한 SSH 키 생성 및 등록

GitHub Actions가 비밀번호 없이 EC2 서버에 접속하려면 SSH 키 페어(공개키, 개인키)가 필요합니다.

  1. SSH 키 페어 생성: 로컬 컴퓨터 터미널에서 아래 명령어를 실행하여 GitHub Actions 전용 SSH 키를 생성합니다. 개인키를 사용하는 것보다 전용 키를 만드는 것이 보안상 안전합니다.이 명령은 github-actions-key(개인키)와 github-actions-key.pub(공개키) 두 개의 파일을 생성합니다.
  2. Bash
    # 'github-actions-key'라는 이름으로 암호 없이 RSA 키 생성
    ssh-keygen -t rsa -b 4096 -C "github-actions-deploy-key" -f github-actions-key -N ""
    
  3. EC2에 공개키 등록: 이제 생성된 공개키의 내용을 EC2 서버에 등록해야 합니다. EC2 서버에 접속한 후 아래 명령을 실행하세요. (ubuntu는 EC2 인스턴스의 사용자 이름이며, Amazon Linux의 경우 ec2-user일 수 있습니다.)이제 github-actions-key 개인키를 가진 클라이언트는 비밀번호 없이 이 EC2 인스턴스에 접속할 수 있습니다.
  4. Bash
    # 먼저 .ssh 디렉토리가 없으면 생성하고 권한 설정
    mkdir -p ~/.ssh
    chmod 700 ~/.ssh
    
    # 로컬에서 복사한 공개키 내용을 authorized_keys 파일에 추가
    # cat "github-actions-key.pub 파일의 내용" >> ~/.ssh/authorized_keys
    echo "ssh-rsa AAAA..." >> ~/.ssh/authorized_keys
    
    # authorized_keys 파일 권한 설정
    chmod 600 ~/.ssh/authorized_keys
    

2. GitHub Actions Secrets 설정

민감한 정보(API 키, 개인키 등)는 코드에 직접 하드코딩하면 절대 안 됩니다. GitHub의 Secrets 기능을 사용해 안전하게 관리해야 합니다.

  1. 설정 메뉴로 이동: 해당 GitHub 저장소에서 Settings > Secrets and variables > Actions로 이동합니다.
  2. 새 Secret 추가: New repository secret 버튼을 클릭하여 아래 목록의 Secret들을 모두 생성합니다.
Secret 이름 설명 값 (입력할 내용)
EC2_HOST 배포할 EC2 인스턴스의 Public IP 주소 또는 도메인 xx.xxx.xxx.xx
EC2_USERNAME EC2에 접속할 사용자 이름 ubuntu 또는 ec2-user
EC2_PRIVATE_KEY 개인키의 전체 내용 로컬에 생성된 github-actions-key 파일의 내용을 그대로 복사하여 붙여넣습니다. -----BEGIN... 부터 -----END... 까지 모두 포함해야 합니다.
DOCKER_HUB_USERNAME Docker Hub 계정 아이디 본인의 Docker Hub 사용자 이름
DOCKER_HUB_TOKEN Docker Hub 액세스 토큰 Docker Hub에 로그인 후 Account Settings > Security > New Access Token에서 생성한 토큰. 비밀번호가 아닙니다.
DATABASE_URL 데이터베이스 접속 주소 애플리케이션에서 사용하는 DB의 전체 URL
SESSION_SECRET 세션 암호화에 사용할 시크릿 키 원하는 임의의 긴 문자열
YOUTUBE_API_KEY 유튜브 API 키 발급받은 유튜브 API 키
(기타 필요한 Secret들) SLACK_BOT_TOKEN 등 애플리케이션에서 사용하는 모든 비밀 값 해당 서비스에서 발급받은 키 값

 


1. 똑똑한 애플리케이션 포장: Dockerfile 📦

좋은 배포의 시작은 가볍고 효율적인 Docker 이미지에서 비롯됩니다. 우리는 멀티 스테이지 빌드(Multi-stage builds) 기법을 사용해 최종 이미지의 크기를 줄이고 빌드 속도를 높일 것입니다.

Dockerfile
 
# Stage 1: Install dependencies
FROM node:22-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm npm ci

# Stage 2: Build the application
FROM deps AS builder
WORKDIR /app
COPY . .
# Build the server and client
RUN npm run build

# Stage 3: Production image
FROM node:22-alpine AS production
WORKDIR /app

# Set production environment
ENV NODE_ENV=production

# Copy production dependencies from 'deps' stage
COPY --from=deps /app/node_modules ./node_modules
COPY package.json ./

# Copy built server and client from 'builder' stage
# Assuming the build script outputs to 'dist/server' and 'dist/public'
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/shared ./shared

# Expose the port the server will run on
EXPOSE 3000

# Command to run the application
CMD ["node", "dist/server/index.js"]
  • Stage 1 (deps): 오직 npm 의존성 설치만을 위한 단계입니다. npm ci를 사용해 package-lock.json을 기반으로 빠르고 일관성 있게 패키지를 설치합니다. RUN --mount=type=cache는 GitHub Actions에서 빌드 캐시를 활용해 후속 빌드 속도를 크게 향상시킵니다.
  • Stage 2 (builder): deps 단계에서 설치된 의존성을 바탕으로, 전체 소스 코드를 복사하고 npm run build를 실행해 프로덕션용 코드를 생성합니다.
  • Stage 3 (production): 최종 배포될 이미지입니다. 가벼운 node:22-alpine에서 시작하여, 오직 실행에 필요한 파일들만 이전 스테이지에서 가져옵니다. 이렇게 함으로써 최종 이미지에는 소스 코드나 개발용 의존성이 포함되지 않아 용량이 작고 보안에 유리해집니다.

2. 서버 환경 정의: docker-compose.yml ⚙️

docker-compose.yml 파일은 EC2 서버에서 우리 애플리케이션을 어떻게 실행할지 정의하는 "설계도"입니다.

YAML
 
version: '3.8'

services:
  app:
    image: ssaulpark/shook:latest # GitHub Actions가 빌드하여 푸시한 이미지
    container_name: shook-app
    restart: unless-stopped      # 서버 재부팅 시 컨테이너 자동 재시작
    ports:
      - "80:3000"                # 호스트의 80번 포트를 컨테이너의 3000번 포트와 연결
    env_file:
      - .env                     # .env 파일의 환경변수를 컨테이너에 주입
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
      interval: 30s
      timeout: 10s
      retries: 3
      start_period: 40s
    logging:
      driver: "json-file"
      options:
        max-size: "10m"
        max-file: "3"

networks:
  default:
    name: shook-network
  • image: GitHub Actions가 빌드하여 Docker Hub에 올린 이미지(ssaulpark/shook:latest)를 사용하도록 지정합니다.
  • restart: unless-stopped: 예기치 않게 컨테이너가 종료되거나 서버가 재부팅되어도 자동으로 컨테이너를 다시 실행시켜 서비스 안정성을 높입니다.
  • healthcheck: 매우 중요한 설정입니다! Docker가 주기적으로 컨테이너 내부에서 curl 명령을 실행하여 애플리케이션이 정상적으로 응답하는지 확인합니다.
  • logging: 컨테이너 로그가 무한정 쌓여 서버 용량을 다 쓰는 것을 방지하기 위해 로그 파일의 최대 크기와 개수를 제한합니다.

3. 모든 것을 지휘하는 오케스트라: GitHub Actions 워크플로우 🤖

이 워크플로우 파일(.github/workflows/docker-image.yml)은 CI/CD 파이프라인의 핵심 두뇌입니다. 코드가 main 브랜치에 푸시되면 아래의 작업들을 순차적으로 실행합니다.

YAML
 
# .github/workflows/docker-image.yml
name: Build and Deploy with Docker Compose

on:
  push:
    branches: [main]
  workflow_dispatch:

env:
  IMAGE_NAME: ${{ secrets.DOCKER_HUB_USERNAME }}/shook
  DEPLOY_PATH: /home/${{ secrets.EC2_USERNAME }}/shook-deploy

jobs:
  build:
    runs-on: ubuntu-latest

    steps:
    - name: Checkout code
      uses: actions/checkout@v4

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

    - name: Login to Docker Hub
      uses: docker/login-action@v3
      with:
        username: ${{ secrets.DOCKER_HUB_USERNAME }}
        password: ${{ secrets.DOCKER_HUB_TOKEN }}

    - name: Build and push Docker image
      uses: docker/build-push-action@v5
      with:
        context: .
        platforms: linux/amd64
        push: true
        tags: |
          ${{ env.IMAGE_NAME }}:latest
          ${{ env.IMAGE_NAME }}:${{ github.sha }}
        cache-from: type=gha
        cache-to: type=gha,mode=max

  deploy:
    needs: build
    runs-on: ubuntu-latest

    steps:
      - name: Deploy to EC2
        uses: easingthemes/ssh-deploy@main
        with:
          SSH_PRIVATE_KEY: ${{ secrets.EC2_PRIVATE_KEY }}
          ARGS: "-rlgoDzvc -i --delete"
          SOURCE: "docker-compose.yml"
          REMOTE_HOST: ${{ secrets.EC2_HOST }}
          REMOTE_USER: ${{ secrets.EC2_USERNAME }}
          TARGET: ${{ env.DEPLOY_PATH }}
          SCRIPT_AFTER: |
            echo "🚀 Starting deployment process..."
            
            cd ${{ env.DEPLOY_PATH }} || { 
              echo "ERROR: Failed to change to deployment directory"; 
              exit 1; 
            }
            
            # 환경변수 파일 생성
            echo "📝 Creating environment file..."
            cat > .env << 'EOF'
            SESSION_SECRET=${{ secrets.SESSION_SECRET }}
            DATABASE_URL=${{ secrets.DATABASE_URL }}
            YOUTUBE_API_KEY=${{ secrets.YOUTUBE_API_KEY }}
            SLACK_BOT_TOKEN=${{ secrets.SLACK_BOT_TOKEN }}
            SLACK_CHANNEL_ID=${{ secrets.SLACK_CHANNEL_ID }}
            SLACK_BOT_USER_OAUTH_TOKEN=${{ secrets.SLACK_BOT_USER_OAUTH_TOKEN }}
            ANTHROPIC_API_KEY=${{ secrets.ANTHROPIC_API_KEY }}
            EOF
            
            # Docker 로그인
            echo "🔐 Logging into Docker Hub..."
            echo ${{ secrets.DOCKER_HUB_TOKEN }} | docker login -u ${{ secrets.DOCKER_HUB_USERNAME }} --password-stdin || { 
              echo "ERROR: Docker login failed"; 
              exit 1; 
            }
            
            # docker-compose.yml 유효성 검사
            echo "🔍 Validating docker-compose configuration..."
            docker compose config --quiet || { 
              echo "ERROR: Invalid docker-compose.yml syntax"; 
              exit 1; 
            }
            
            # 최신 이미지 풀
            echo "📥 Pulling latest images..."
            docker compose pull || { 
              echo "ERROR: Failed to pull Docker images"; 
              exit 1; 
            }
            
            # 기존 서비스 중지
            echo "⏹️ Stopping existing services..."
            docker compose down --remove-orphans 2>/dev/null || true
            
            # 새 서비스 시작
            echo "▶️ Starting services..."
            docker compose up -d || { 
              echo "ERROR: Failed to start services"; 
              docker compose logs --tail 50;
              exit 1; 
            }
            
            # 서비스 시작 대기
            echo "⏳ Waiting for services to initialize..."
            sleep 20
            
            # 컨테이너 상태 확인
            echo "✅ Checking service status..."
            docker compose ps --format "table {{.Service}}\t{{.Status}}\t{{.Ports}}"
            
            # 헬스체크 수행
            echo "🔍 Performing health check..."
            HEALTH_CHECK_PASSED=false
            for i in {1..12}; do
              if curl -sf http://localhost:3000/health >/dev/null 2>&1; then
                echo "✅ Application is healthy!"
                HEALTH_CHECK_PASSED=true
                break
              else
                echo "⏳ Health check attempt $i/12..."
                sleep 5
              fi
            done
            
            if [ "$HEALTH_CHECK_PASSED" = false ]; then
              echo "❌ Health check failed after 12 attempts"
              echo "📋 Application logs:"
              docker compose logs --tail 30 app
              exit 1
            fi
            
            # 사용하지 않는 이미지 정리
            echo "🧹 Cleaning up unused images..."
            docker image prune -f >/dev/null 2>&1 || true
            
            echo "🎉 Deployment completed successfully!"

  notify:
    needs: [build, deploy]
    runs-on: ubuntu-latest
    if: always()
    
    steps:
    - name: Notify deployment result
      run: |
        BUILD_STATUS="${{ needs.build.result }}"
        DEPLOY_STATUS="${{ needs.deploy.result }}"
        
        echo "📊 Deployment Summary:"
        echo "Build: $BUILD_STATUS"
        echo "Deploy: $DEPLOY_STATUS"
        
        if [ "$BUILD_STATUS" == "success" ] && [ "$DEPLOY_STATUS" == "success" ]; then
          echo "✅ All deployment stages completed successfully"
        else
          echo "❌ Deployment failed - Check logs for details"
          exit 1
        fi
  • SCRIPT_AFTER: 이 스크립트는 easingthemes/ssh-deploy 액션에 의해 EC2 서버에서 실행되는 자동화의 핵심입니다. 각 단계마다 에러가 발생하면 즉시 배포를 중단하고 에러 메시지를 출력하도록 || { ... exit 1; } 구문으로 안정성을 높였습니다. 특히 배포 실패 시 docker compose logs를 통해 마지막 로그를 보여주어 원인 파악을 쉽게 만듭니다.
  • notify Job: if: always() 조건 덕분에 build나 deploy가 실패하더라도 항상 실행됩니다. 이를 통해 배포의 최종 성공 여부를 명확하게 확인할 수 있습니다.

'개발' 카테고리의 다른 글

Node.js 풀스택 개발시 로컬 개발 환경과 배포 환경의 괴리에서 발생하는 문제점에 대한 논의  (0) 2025.08.02
Vite와 Node.js 풀스택 개발에서 npm run dev를 통해 프론트와 백 동시에 실행하기(디버깅)  (3) 2025.08.02
GitHub Actions을 활용한 도커 이미지 빌드 및 EC2에 배포하는 과정  (2) 2025.07.31
Docker 컨테이너에서 Node.js 풀스택 앱 실행 시 흔한 문제 해결 가이드  (1) 2025.07.30
프롬프트 엔지니어링 작성 방법  (1) 2025.07.12
'개발' 카테고리의 다른 글
  • Node.js 풀스택 개발시 로컬 개발 환경과 배포 환경의 괴리에서 발생하는 문제점에 대한 논의
  • Vite와 Node.js 풀스택 개발에서 npm run dev를 통해 프론트와 백 동시에 실행하기(디버깅)
  • GitHub Actions을 활용한 도커 이미지 빌드 및 EC2에 배포하는 과정
  • Docker 컨테이너에서 Node.js 풀스택 앱 실행 시 흔한 문제 해결 가이드
5jyan5
5jyan5
  • 5jyan5
    jyan
    5jyan5
  • 전체
    오늘
    어제
    • 분류 전체보기 (242)
      • 김영한의 스프링 핵심 원리(기본편) (8)
      • 김영한의 스프링 핵심 원리 - 고급편 (11)
      • 김영한의 스프링 MVC 1편 (1)
      • 김영한의 스프링 DB 1편 (3)
      • 김영한의 스프링 MVC 2편 (3)
      • 김영한의 ORM 표준 JPA 프로그래밍(기본편) (9)
      • 김영한의 스프링 부트와 JPA 활용2 (2)
      • 김영한의 실전 자바 - 중급 1편 (1)
      • 김영한의 실전 자바 - 고급 1편 (9)
      • 김영한의 실전 자바 - 고급 2편 (9)
      • Readable Code: 읽기 좋은 코드를 작성.. (2)
      • 김영한의 실전 자바 - 고급 3편 (9)
      • CKA (118)
      • 개발 (37)
      • 경제 (4)
      • 리뷰 (1)
      • 정보 (2)
  • 블로그 메뉴

    • 링크

    • 공지사항

    • 인기 글

    • 태그

      jdk 동적 프록시
      Thread
      hibernate5module
      JPQL
      cglib
      WAS
      log trace
      @args
      @discriminatorvalue
      페치 조인
      양방향 맵핑
      gesingleresult
      김영한
      단방향 맵핑
      프록시 팩토리
      조회 성능 최적화
      jpq
      @within
      스레드
      typequery
      빈 후처리기
      @discriminatorcolumn
      reentarantlock
      Target
      버퍼
      자바
      고급
      requset scope
      프록시
      락
    • 최근 댓글

    • 최근 글

    • hELLO· Designed By정상우.v4.10.2
    5jyan5
    Node.js 앱 무중단 배포: GitHub Actions와 Docker로 EC2 자동화 파이프라인 구축하기
    상단으로

    티스토리툴바