개발자라면 누구나 꿈꾸는 자동화된 배포 파이프라인! 코드를 깃헙에 푸시하기만 하면 자동으로 테스트, 빌드, 배포까지 완료되는 멋진 세상을 만들어 봅시다.
이 글에서는 GitHub Actions, Docker, Docker Compose를 사용해 Node.js 애플리케이션을 AWS EC2에 거의 중단 없이(near-zero downtime) 배포하는 전체 과정을 단계별로 살펴보겠습니다. 이 가이드를 통해 효율적이고 안정적인 CI/CD 파이프라인을 직접 구축할 수 있습니다.
📜 전체적인 배포 전략
우리의 목표는 다음과 같은 흐름을 자동화하는 것입니다.
- 개발자가 로컬에서 작업 후 main 브랜치에 코드를 푸시(Push)합니다.
- GitHub Actions가 이벤트를 감지하고 워크플로우를 실행합니다.
- (Build Job) 최적화된 Dockerfile을 사용해 Node.js 애플리케이션을 빌드하고, Docker Hub에 이미지로 푸시합니다.
- (Deploy Job) 빌드가 성공하면, GitHub Actions는 SSH를 통해 EC2 서버에 접속합니다.
- EC2 서버에서 Docker Hub의 최신 이미지를 받아오고(pull), docker-compose를 이용해 기존 컨테이너를 내리고 새 컨테이너를 실행하여 서비스를 업데이트합니다.
- 배포 성공/실패 여부를 최종적으로 알려줍니다.
0. 사전 준비: GitHub Secrets 및 EC2 설정
자동화 파이프라인을 구축하기 전에, GitHub Actions가 EC2 서버에 안전하게 접속하고 Docker Hub에 이미지를 푸시할 수 있도록 몇 가지 설정을 해야 합니다.
1. EC2 접속을 위한 SSH 키 생성 및 등록
GitHub Actions가 비밀번호 없이 EC2 서버에 접속하려면 SSH 키 페어(공개키, 개인키)가 필요합니다.
- SSH 키 페어 생성: 로컬 컴퓨터 터미널에서 아래 명령어를 실행하여 GitHub Actions 전용 SSH 키를 생성합니다. 개인키를 사용하는 것보다 전용 키를 만드는 것이 보안상 안전합니다.이 명령은 github-actions-key(개인키)와 github-actions-key.pub(공개키) 두 개의 파일을 생성합니다.
-
Bash
# 'github-actions-key'라는 이름으로 암호 없이 RSA 키 생성 ssh-keygen -t rsa -b 4096 -C "github-actions-deploy-key" -f github-actions-key -N "" - EC2에 공개키 등록: 이제 생성된 공개키의 내용을 EC2 서버에 등록해야 합니다. EC2 서버에 접속한 후 아래 명령을 실행하세요. (ubuntu는 EC2 인스턴스의 사용자 이름이며, Amazon Linux의 경우 ec2-user일 수 있습니다.)이제 github-actions-key 개인키를 가진 클라이언트는 비밀번호 없이 이 EC2 인스턴스에 접속할 수 있습니다.
-
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 기능을 사용해 안전하게 관리해야 합니다.
- 설정 메뉴로 이동: 해당 GitHub 저장소에서 Settings > Secrets and variables > Actions로 이동합니다.
- 새 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) 기법을 사용해 최종 이미지의 크기를 줄이고 빌드 속도를 높일 것입니다.
# 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 서버에서 우리 애플리케이션을 어떻게 실행할지 정의하는 "설계도"입니다.
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 브랜치에 푸시되면 아래의 작업들을 순차적으로 실행합니다.
# .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 |