⚙️ 1부: GitHub Actions - CI/CD 파이프라인 구축
GitHub Actions는 GitHub 저장소에서 직접 CI/CD 워크플로우를 자동화할 수 있게 해주는 도구입니다. 코드가 푸시될 때마다 자동으로 Docker 이미지를 빌드하고 Docker Hub에 푸시한 다음, AWS EC2 인스턴스에 배포하도록 설정합니다.
이번 섹션에서는 빌드(Build)와 배포(Deploy) 스텝을 명확히 분리하여, 각 단계의 역할과 발생할 수 있는 문제 해결 방안을 상세히 설명합니다.
1.1 워크플로우 파일 (.github/workflows/deploy.yml)
아래는 Docker 이미지를 빌드하고 Docker Hub에 푸시하는 build Job과, 이 이미지를 EC2에 배포하는 deploy Job으로 구성된 GitHub Actions 워크플로우 파일입니다.
YAML
name: Build and Deploy with Docker Compose
on:
push:
branches: [main]
workflow_dispatch: # 수동 실행도 가능하게
env:
IMAGE_NAME: ${{ secrets.DOCKER_HUB_USERNAME }}/shook # 실제 사용자명으로 변경
DEPLOY_PATH: ~/shook-deploy
jobs:
build: # 빌드 및 Docker Hub 푸시 Job
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
- name: Image digest
run: echo "Image pushed successfully"
deploy: # EC2 배포 Job
needs: build # build Job이 성공해야 deploy Job이 실행됨
runs-on: ubuntu-latest
steps:
- name: Checkout code # EC2로 보낼 docker-compose.yml 파일에 접근하기 위해 필요
uses: actions/checkout@v4
- name: Deploy to EC2 with Docker Compose
uses: appleboy/ssh-action@master # 최신 버전 사용 권장 (v0.1.7은 source/target 지원 안 함)
with:
host: ${{ secrets.EC2_HOST }}
username: ${{ secrets.EC2_USERNAME }}
key: ${{ secrets.EC2_PRIVATE_KEY }}
timeout: 300s
sync: true # 파일 동기화 활성화
source: "./docker-compose.yml" # 로컬 레포지토리의 docker-compose.yml
target: "${{ env.DEPLOY_PATH }}" # EC2 서버의 대상 디렉토리
script: |
echo "🚀 Starting deployment process..."
echo "Current user: $(whoami)"
echo "Initial working directory: $(pwd)" # 스크립트 시작 시 현재 경로 확인
# 배포 디렉토리 확인 및 생성
echo "DEBUG: Checking and creating deployment directory: ${{ env.DEPLOY_PATH }}"
mkdir -p "${{ env.DEPLOY_PATH }}" || { echo "ERROR: Failed to create directory ${{ env.DEPLOY_PATH }}. Check permissions for $(dirname ${{ env.DEPLOY_PATH }})"; exit 1; }
# 배포 디렉토리로 이동
cd "${{ env.DEPLOY_PATH }}" || { echo "ERROR: Failed to change directory to ${{ env.DEPLOY_PATH }}. Does it exist and have correct permissions?"; exit 1; }
echo "DEBUG: Current working directory AFTER CD: $(pwd)" # cd 후 현재 경로 확인
# 환경변수 파일 생성
echo "📝 Attempting to create environment file (.env)..."
cat > .env << EOF || { echo "ERROR: Failed to write to .env file. Check permissions for ${{ env.DEPLOY_PATH }} or disk space."; exit 1; }
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
echo "DEBUG: Current working directory AFTER .ENV CREATION: $(pwd)"
# .env 파일 생성 여부 및 내용 확인 (🚨 디버깅 후에는 반드시 제거/주석 처리하세요!)
echo "DEBUG: Verifying .env file existence and content:"
ls -la .env || { echo "ERROR: .env file was NOT created or is inaccessible!"; exit 1; }
cat .env # 🚨 시크릿 정보가 로그에 출력됩니다!
# 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; }
echo "DEBUG: Current working directory AFTER DOCKER LOGIN: $(pwd)"
# docker-compose.yml 파일 유효성 검사 (이제 파일이 있을 것입니다!)
echo "DEBUG: Validating docker-compose.yml file..."
cat docker-compose.yml # 🚨 docker-compose.yml 파일 내용 출력!
docker compose config || { echo "ERROR: docker-compose.yml validation failed. Check YAML syntax (indentation, tabs/spaces, hidden characters)."; exit 1; }
echo "DEBUG: Current working directory AFTER DOCKER COMPOSE CONFIG: $(pwd)"
# 최신 이미지 풀 (docker pull 대신 docker compose pull 권장)
echo "📥 Pulling latest image..."
docker compose pull || { echo "ERROR: Docker Compose pull failed."; exit 1; }
echo "DEBUG: Current working directory AFTER DOCKER COMPOSE PULL: $(pwd)"
# 기존 서비스 중지
echo "⏹️ Stopping existing services..."
docker compose down || echo "DEBUG: No existing services to stop, or graceful shutdown failed."
echo "DEBUG: Current working directory AFTER DOCKER COMPOSE DOWN: $(pwd)"
# 새 서비스 시작
echo "▶️ Starting new services..."
docker compose up -d || { echo "ERROR: Docker Compose up failed. Check application logs for details."; exit 1; }
echo "DEBUG: Current working directory AFTER DOCKER COMPOSE UP: $(pwd)"
# 잠시 대기
echo "⏳ Waiting for services to start..."
sleep 15
# 컨테이너 상태 확인
echo "✅ Checking container status..."
docker compose ps || { echo "ERROR: Docker Compose ps failed. Services might not be running."; exit 1; }
echo "DEBUG: Current working directory AFTER DOCKER COMPOSE PS: $(pwd)"
# 애플리케이션 로그 확인
echo "📋 Recent application logs:"
docker compose logs --tail 20 app || echo "DEBUG: Could not retrieve recent application logs."
echo "DEBUG: Current working directory AFTER DOCKER COMPOSE LOGS: $(pwd)"
# 헬스체크
echo "🔍 Performing health check..."
for i in {1..10}; do
if curl -f http://localhost:3000/health 2>/dev/null; then
echo "✅ Application is healthy!"
break
elif [ $i -eq 10 ]; then
echo "❌ Health check failed after 10 attempts"
docker compose logs app || echo "DEBUG: Could not retrieve application logs for healthcheck failure."
exit 1
else
echo "⏳ Attempt $i: Waiting for application to be ready..."
sleep 3
fi
done
echo "DEBUG: Current working directory AFTER HEALTH CHECK: $(pwd)"
# 사용하지 않는 이미지 정리
echo "🧹 Cleaning up unused images..."
docker image prune -f || echo "DEBUG: Image prune command failed or nothing to prune."
echo "DEBUG: Current working directory AFTER IMAGE PRUNE: $(pwd)"
echo "🎉 Deployment completed successfully!"
notify:
needs: [build, deploy]
runs-on: ubuntu-latest
if: always()
steps:
- name: Notify deployment result
run: |
if [ "${{ needs.deploy.result }}" == "success" ]; then
echo "✅ Deployment successful"
else
echo "❌ Deployment failed"
exit 1
fi
1.2 GitHub Secrets 설정 (매우 중요!)
민감한 정보(Docker Hub 비밀번호, SSH 키 등)는 코드에 직접 노출되어서는 안 됩니다. GitHub Secrets에 안전하게 저장하고 워크플로우에서 참조합니다.
- Docker Hub Personal Access Token (PAT) 생성:
- Docker Hub에 로그인 후, Account Settings > Security > New Access Token에서 PAT를 생성합니다.
- 권한은 Read & Write로 설정하고, 생성 즉시 토큰을 복사합니다. (다시 볼 수 없음)
- GitHub 저장소 Secrets 등록:
- GitHub 저장소 Settings > Secrets and variables > Actions로 이동합니다.
- New repository secret을 클릭하여 다음 Secret들을 등록합니다.
- DOCKER_HUB_USERNAME: 여러분의 Docker Hub 사용자 이름
- DOCKER_HUB_TOKEN: 위에서 생성한 Docker Hub PAT
- EC2_HOST: EC2 인스턴스의 퍼블릭 IP 주소 또는 도메인
- EC2_USERNAME: EC2 인스턴스의 사용자 이름 (예: ec2-user, ubuntu 등)
- EC2_PRIVATE_KEY: EC2 인스턴스 생성 시 받은 .pem 파일(예: default-key-pair.pem)의 내용 전체를 복사하여 붙여넣습니다. (-----BEGIN ...부터 -----END ...까지 모두 포함)
1.3 GitHub Actions 관련 문제 해결
문제 1: ssh.ParsePrivateKey: ssh: no key found 또는 ssh: handshake failed: ssh: unable to authenticate
- 증상: SSH 접속 시 키를 찾을 수 없거나 인증에 실패했다는 에러.
- 원인:
- EC2_PRIVATE_KEY Secret에 Private Key 내용이 잘못 복사되었거나 누락됨.
- EC2 인스턴스의 ~/.ssh/authorized_keys 파일에 해당 Private Key에 맞는 Public Key가 없거나 권한 문제.
- username (예: ubuntu, ec2-user)이 EC2 인스턴스의 실제 사용자 이름과 불일치.
- 해결:
- EC2_PRIVATE_KEY Secret에 .pem 파일의 내용을 정확히, 모든 줄을 포함하여 복사했는지 재확인.
- EC2 인스턴스 생성 시 사용한 .pem 파일은 Public Key가 자동으로 등록되므로, authorized_keys를 수동으로 수정할 필요는 없습니다.
- 워크플로우의 username을 ec2-user (또는 해당 AMI의 기본 사용자)로 정확히 설정했는지 확인.
☁️ 2부: EC2 배포 - 컨테이너 실행 환경
GitHub Actions가 Docker Hub에 이미지를 푸시하면, 이제 EC2 인스턴스에서 이 이미지를 가져와 실행해야 합니다.
2.1 EC2 환경 설정
- EC2 인스턴스 접속: SSH를 사용하여 EC2 인스턴스에 접속합니다.
- Docker 및 Docker Compose 설치: Amazon Linux 2023 환경에서 Docker 및 Docker Compose v2를 설치하는 방법은 다음과 같습니다.
-
Bash
# Docker 설치 sudo dnf update -y sudo dnf install -y docker sudo systemctl start docker sudo systemctl enable docker sudo usermod -aG docker ec2-user newgrp docker # 그룹 변경 적용 (재접속 필요) # Docker Compose v2 설치 (공식 binary 다운로드) DOCKER_CONFIG=${DOCKER_CONFIG:-$HOME/.docker} mkdir -p $DOCKER_CONFIG/cli-plugins curl -SL https://github.com/docker/compose/releases/latest/download/docker-compose-linux-x86_64 \ -o $DOCKER_CONFIG/cli-plugins/docker-compose chmod +x $DOCKER_CONFIG/cli-plugins/docker-compose # 정상 설치 확인 docker compose version - 배포 디렉토리 생성:
-
Bash
mkdir -p /home/ec2-user/shook-deploy # EC2_USERNAME에 맞게 경로 조정 cd /home/ec2-user/shook-deploy - 환경 변수 파일 (.env): GitHub Actions에서 .env 파일을 직접 생성하고 Secret 값을 주입하도록 워크플로우를 구성했으므로, EC2에 수동으로 환경 변수 파일을 생성할 필요는 없습니다. 워크플로우의 deploy 스텝에서 자동으로 생성됩니다.
🚧 3부: Docker Compose 배포 문제 해결 (추가)
만약 docker-compose를 사용하여 배포를 시도했다면, 다음과 같은 유효성 검사 오류를 겪을 수 있습니다.
문제 1: bash: line 28: docker-compose: command not found
- 증상: docker-compose 명령어를 찾을 수 없다는 에러.
- 원인: EC2 인스턴스에 docker-compose가 설치되어 있지 않거나, PATH에 등록되지 않았기 때문입니다.
- 해결: 위에 제시된 방법으로 EC2에 docker compose v2를 설치합니다. (docker-compose는 구 버전 바이너리 이름이며, 최신 docker compose v2는 docker compose로 실행됩니다.)
문제 2: validating /home//shook-deploy/docker-compose.yml: services.healthcheck additional properties 'interval', 'timeout', 'retries', 'start_period', 'test' not allowed
문제 3: validating /home//shook-deploy/docker-compose.yml: services.logging additional properties 'options', 'driver' not allowed
- 증상: docker-compose.yml 파일의 healthcheck 또는 logging 섹션에서 "additional properties 'X' not allowed" 오류 발생.
- 원인: docker-compose.yml 파일의 version: '3.x' 선언과 EC2에 설치된 docker compose 바이너리(v2.x) 간의 불일치. 최신 docker compose 바이너리는 Compose Specification v2를 따르는데, v3.x 구문과 일부 필드의 구조가 다릅니다. 이전에 발생했던 문제의 주된 원인은 YAML 들여쓰기/인코딩 오류였지만, 만약 파일이 올바르게 전송된 후에도 동일한 에러가 발생한다면 이 문제를 의심해봐야 합니다.
- 해결: docker-compose.yml 파일의 version 필드를 제거하거나 version: '2.x'로 변경하고, healthcheck 및 logging 구문을 Compose Specification v2에 맞게 수정해야 합니다.
YAML
# docker-compose.yml (수정 예시) # version: '3.8' # 이 줄을 제거하거나 version: '2.x' 로 변경 services: app: # 서비스 이름을 shook-app 대신 app으로 통일 (사용하는 서비스명에 따라 변경) image: your-docker-hub-username/shook:latest ports: - "80:3000" environment: - DATABASE_URL=${DATABASE_URL} # .env 파일에서 주입될 변수 # ... (다른 환경 변수) ... healthcheck: # healthcheck 구문 test: ["CMD", "curl", "-f", "http://localhost:3000/health"] # 컨테이너 내부에서 실행될 헬스체크 명령어 interval: 30s timeout: 10s retries: 3 start_period: 5s logging: # logging 구문 driver: "json-file" options: max-size: "10m" max-file: "3" - 예시 (Compose Specification v2에 맞는 healthcheck 및 logging 구문):
# docker-compose.yml (수정 예시) # version: '3.8' # 이 줄을 제거하거나 version: '2.x' 로 변경 services: app: # 서비스 이름을 shook-app 대신 app으로 통일 (사용하는 서비스명에 따라 변경) image: your-docker-hub-username/shook:latest ports: - "80:3000" environment: - DATABASE_URL=${DATABASE_URL} # .env 파일에서 주입될 변수 # ... (다른 환경 변수) ... healthcheck: # healthcheck 구문 test: ["CMD", "curl", "-f", "http://localhost:3000/health"] # 컨테이너 내부에서 실행될 헬스체크 명령어 interval: 30s timeout: 10s retries: 3 start_period: 5s logging: # logging 구문 driver: "json-file" options: max-size: "10m" max-file: "3"
'개발' 카테고리의 다른 글
| Vite와 Node.js 풀스택 개발에서 npm run dev를 통해 프론트와 백 동시에 실행하기(디버깅) (3) | 2025.08.02 |
|---|---|
| Node.js 앱 무중단 배포: GitHub Actions와 Docker로 EC2 자동화 파이프라인 구축하기 (5) | 2025.08.02 |
| Docker 컨테이너에서 Node.js 풀스택 앱 실행 시 흔한 문제 해결 가이드 (1) | 2025.07.30 |
| 프롬프트 엔지니어링 작성 방법 (1) | 2025.07.12 |
| MCP 사용기(Claude Desktop을 활용하여 Firecrawl, slack 연동) (4) | 2025.07.05 |