개발서버를 구축하였다면 이젠 구축된 개발서버에 CI/CD 기능을 추가하여 완성도를 더 높여보자.
1. 개발자가 로컬에서 코드 수정
2. Git push로 GitHub에 코드 업로드
3. GitHub Actions가 자동으로 트리거 (.github/workflows/deploy.yml 실행)
4. deploy.yml이 실행되며 npm 설치 및 테스트 등 실행
5. deploy.yml 명령어로 NAS에 SSH 접속하여 배포 스크립트 실행
6. Git pull로 최신 코드 다운로드
7. docker-compose.yaml 이 실행되며 컨테이너 생성
8. docker-compose.yaml 명령어에 의해 Dockerfile 실행
9. Dockerfile에서 각종 패키지 설치 및 start-services.sh 생성 후 실행
10. start-services.sh은 NestJS 서버 및 Compodoc 서버 실행
11. 배포 결과 로그 기록
12. GitHub Actions Summary에 결과 표시
CI/CD가 완성되었다면 위와 같은 프로세스로 진행된다.
1. SSH 서비스 활성화
이전 1편에서의 작업을 진행했다면 설정되어있을 것이다.
제어판 - 터미널 및 SNMP - SSH 서비스 활성화
2. SSH 키 생성 및 복사
2.1. 로컬 SSH 키 생성
# 새 키를 생성합니다. 모든 질문에 그냥 엔터만 누르세요! (비밀번호 설정 안 함)
ssh-keygen -t rsa -b 4096 -C "github-actions-$(date +%Y-%m-%d)"
2.2. 새 공개키를 NAS에 등록
# 새로 만든 공개키(~/.ssh/id_rsa.pub)를 NAS의 계정에 복사합니다.
ssh-copy-id -i ~/.ssh/id_rsa.pub {NAS_ID}@{호스트 주소}
2.3. 키 값을 복사
# macOS 사용자:
pbcopy < ~/.ssh/id_rsa
2.4. Github Secrets에 등록
Github Repository - Settings - Secrets and variables 에 추가
3. NAS_PASSWORD 방식
개인적으로 SSH 키 방식으로 CI/CD가 제대로 작동하지 않는 이슈가 발생했다.
그래서 NAS에 접속하는 패스워드 방식으로 진행하였다.
SSH 키 방식과 마찬가지로 NAS_PASSWORD를 Secret을 만들고 내용으로는 NAS 에 접속할 패스워드를 입력한다.
4. NAS 연결 테스트
# 로컬에서 NAS SSH 접속 테스트
ssh -p 22 나스Id@호스트주소
# 키 인증 테스트 (개인키 사용)
ssh -i ~/.ssh/id_rsa -p 22 나스Id@호스트주소
Synology strongly advises you not to run commands as the root user, who has
the highest privileges on the system. Doing so may cause major damages
to the system. Please note that if you choose to proceed, all consequences are
at your own risk.
두 명령어 전부 ssh로 NAS에 접속하는 명령어이다. 마지막에 위와 같이 경고 문구를 띄워주는데,
루트 사용자로 명령을 실행하지 말 것을 강력히 권장합니다. 그렇지 않으면 시스템에 심각한 손상을 초래할 수 있습니다. 계속 진행하기로 선택할 경우 모든 결과에 대한 책임은 사용자에게 있습니다.
대략 이런 뜻의 경고이고, 해당 계정으로 ssh로 NAS에 잘 접속이되면 성공이다.
(Password 방식일 경우는 위의 과정이 불필요하다.)
5. Github Repository 설정
5.1. GitHub Secrets 등록
프로젝트의 Repository - Settings - Secrets and variables - Actions로 진입하여, Repository secrets에서 New repository secret 버튼을 누른다.
각각 하나하나씩 Secret을 만들어주면 되는데,
NAS_DDNS 를 제목으로, xxx.synology.me(DDNS 주소) 를 내용으로,
NAS_PORT 를 제목으로, 22 를 내용으로
이런식으로 생성해준다.
5.2. SSH 개인키 복사 방법
# NAS에서 개인키 내용 출력
cat ~/.ssh/id_rsa
# 출력된 전체 내용을 복사하여 GitHub Secret에 등록
# -----BEGIN RSA PRIVATE KEY----- 부터
# -----END RSA PRIVATE KEY----- 까지 모든 내용 포함
NAS에 접속하여 위 명령어를 입력하면 id_rsa 값이 보일텐데 전부 복사하여 등록한다.
SSH 방식을 쓰지 않는다면 굳이 SSH Secret을 만들 필요가 없다.
6. NAS에 배포 스크립트 작성
6.1. SSH로 접속한 뒤 프로젝트로 이동
cd /volume1/docker/NestJS_SNS
NAS 의 도커에 있는 프로젝트를 도커 컨테이너로 올려서 실행할 예정이므로 해당 프로젝트로 이동한다.
6.2. 심볼릭 링크 생성
# NAS에 SSH로 직접 접속해서 실행
sudo ln -sf /usr/local/bin/docker /usr/bin/docker
sudo ln -sf /usr/local/bin/docker-compose /usr/bin/docker-compose
# 확인
which docker
which docker-compose
ls -la /usr/bin/docker*
- 시스템에 따라 /usr/local/bin에 설치된 도커 바이너리를, 일반적으로 PATH에 먼저 검색되는 /usr/bin에도 그대로 접근할 수 있게 해 주기 위해 사용한다.
- 이렇게 하면 터미널에서 docker 또는 docker-compose 명령을 입력했을 때 /usr/local/bin 경로를 직접 지정하지 않아도 실행할 수 있게 된다.
위의 명령어를 차례대로 실행하면 심볼릭 링크가 생성된다.
명령어를 좀 더 자세히 살펴보자면,
- sudo
- 관리자 권한으로 명령을 실행.
- ln
- 리눅스에서 링크를 만드는 명령어(link).
- -s
- “심볼릭 링크”를 만든다. 실제 파일이 아닌, 파일 위치를 가리키는 “바로가기” 같은 역할.
- -f
- 기존에 같은 이름의 링크나 파일이 있으면 덮어쓰기(force) 한다.
- /usr/local/bin/docker → /usr/bin/docker
- 원본(소스)은 /usr/local/bin/docker (도커 실행 파일)이므로, 링크(목적지)를 /usr/bin/docker에 생성한다.
- 두 번째 줄도 동일하게, docker-compose 실행 파일을 /usr/bin/docker-compose 위치에 심볼릭 링크로 만들어 준다.
6.3. 배포 스크립트 작성
cat > deploy.sh << 'EOF'
#!/bin/bash
# 인수 확인 및 설정
if [ $# -lt 1 ]; then
echo "사용법: $0 <sudo_password> [ddns_host]"
echo "예시: $0 'your_password' 'xxx.synology.me'"
echo " $0 'your_password' # 기본값: xxx.synology.me 사용"
exit 1
fi
# 비밀번호를 첫 번째 인수로 받기
SUDO_PASSWORD="$1"
# DDNS 호스트를 두 번째 인수로 받기 (기본값: xxx.synology.me)
DDNS_HOST="${2:-xxx.synology.me}"
# 설정 변수
PROJECT_PATH="/volume1/docker/NestJS_SNS"
LOG_FILE="$PROJECT_PATH/logs/deploy.log"
TIMESTAMP=$(date '+%Y-%m-%d %H:%M:%S')
BACKUP_TAG="backup-$(date +%Y%m%d-%H%M%S)"
# 역방향 프록시 DDNS 설정
API_URL="https://$DDNS_HOST"
DOCS_URL="https://$DDNS_HOST:444"
# 보존할 베이스 이미지들
PRESERVE_IMAGES="postgres:15-alpine redis:7-alpine redis:7.4-alpine node:20-alpine alpine:latest"
# 보존할 컨테이너들 (데이터베이스 컨테이너들)
PRESERVE_CONTAINERS="sns-dev-postgres sns-dev-redis"
# 정리할 애플리케이션 컨테이너들
CLEANUP_CONTAINERS="sns-dev-app sns-dev-monitor nestjs_sns_app 2_cf_sns-app"
# 로그 디렉토리 생성
mkdir -p "$PROJECT_PATH/logs"
echo "=====================================" | tee -a $LOG_FILE
echo "[$TIMESTAMP] 🚀 배포 프로세스 시작" | tee -a $LOG_FILE
echo "[$TIMESTAMP] 🌍 DDNS 호스트: $DDNS_HOST" | tee -a $LOG_FILE
echo "[$TIMESTAMP] 📡 API URL: $API_URL" | tee -a $LOG_FILE
echo "[$TIMESTAMP] 📚 문서 URL: $DOCS_URL" | tee -a $LOG_FILE
echo "=====================================" | tee -a $LOG_FILE
# 프로젝트 디렉토리로 이동
cd $PROJECT_PATH || {
echo "[$TIMESTAMP] ❌ 프로젝트 디렉토리 접근 실패: $PROJECT_PATH" | tee -a $LOG_FILE
exit 1
}
# Docker 심볼릭 링크 생성 (필요시)
echo "[$TIMESTAMP] 🔧 Docker 심볼릭 링크 확인" | tee -a $LOG_FILE
if [ ! -L "/usr/bin/docker" ]; then
echo "$SUDO_PASSWORD" | sudo -S ln -sf /usr/local/bin/docker /usr/bin/docker
echo "[$TIMESTAMP] ✅ Docker 심볼릭 링크 생성" | tee -a $LOG_FILE
fi
if [ ! -L "/usr/bin/docker-compose" ]; then
echo "$SUDO_PASSWORD" | sudo -S ln -sf /usr/local/bin/docker-compose /usr/bin/docker-compose
echo "[$TIMESTAMP] ✅ Docker Compose 심볼릭 링크 생성" | tee -a $LOG_FILE
fi
# Docker 명령어 경로 확인
echo "[$TIMESTAMP] 🔧 Docker 명령어 경로 확인" | tee -a $LOG_FILE
echo "[$TIMESTAMP] Docker 경로: $(which docker)" | tee -a $LOG_FILE
echo "[$TIMESTAMP] Docker Compose 경로: $(which docker-compose)" | tee -a $LOG_FILE
# 최신 코드 가져오기
echo "[$TIMESTAMP] 📥 최신 코드 가져오기 시작" | tee -a $LOG_FILE
if git pull origin main 2>&1 | tee -a $LOG_FILE; then
echo "[$TIMESTAMP] ✅ Git pull 성공" | tee -a $LOG_FILE
else
echo "[$TIMESTAMP] ❌ Git pull 실패" | tee -a $LOG_FILE
exit 1
fi
# 현재 실행 중인 컨테이너 확인
echo "[$TIMESTAMP] 🔍 현재 실행 중인 컨테이너 확인" | tee -a $LOG_FILE
echo "$SUDO_PASSWORD" | sudo -S docker ps -a | tee -a $LOG_FILE
# ========== 선택적 정리 단계 ==========
echo "[$TIMESTAMP] 🧹 선택적 정리 시작 (DB 컨테이너 보존)" | tee -a $LOG_FILE
# 1. Docker Compose 서비스 우아하게 중지 (볼륨 보존)
echo "[$TIMESTAMP] 🛑 Docker Compose 서비스 중지 (볼륨 보존)" | tee -a $LOG_FILE
echo "$SUDO_PASSWORD" | sudo -S docker-compose stop 2>&1 | tee -a $LOG_FILE
# 2. 애플리케이션 컨테이너만 선택적 정리 (모니터링 컨테이너 포함)
echo "[$TIMESTAMP] 🗑️ 애플리케이션 컨테이너만 선택적 정리" | tee -a $LOG_FILE
echo "[$TIMESTAMP] 🛡️ 보존할 컨테이너: $PRESERVE_CONTAINERS" | tee -a $LOG_FILE
echo "[$TIMESTAMP] 🗑️ 정리할 컨테이너: $CLEANUP_CONTAINERS" | tee -a $LOG_FILE
for container in $CLEANUP_CONTAINERS; do
if echo "$SUDO_PASSWORD" | sudo -S docker ps -a --format "{{.Names}}" | grep -q "^${container}$"; then
echo "[$TIMESTAMP] 🛑 애플리케이션 컨테이너 정리: $container" | tee -a $LOG_FILE
echo "$SUDO_PASSWORD" | sudo -S docker stop "$container" 2>/dev/null || true
echo "$SUDO_PASSWORD" | sudo -S docker rm -f "$container" 2>/dev/null || true
else
echo "[$TIMESTAMP] ✅ 컨테이너 없음: $container" | tee -a $LOG_FILE
fi
done
# 3. 보존할 컨테이너 상태 확인
echo "[$TIMESTAMP] 🔍 보존할 컨테이너 상태 확인" | tee -a $LOG_FILE
for container in $PRESERVE_CONTAINERS; do
if echo "$SUDO_PASSWORD" | sudo -S docker ps -a --format "{{.Names}}" | grep -q "^${container}$"; then
CONTAINER_STATUS=$(echo "$SUDO_PASSWORD" | sudo -S docker ps -a --format "{{.Names}}\t{{.Status}}" | grep "^${container}" | cut -f2)
echo "[$TIMESTAMP] 🛡️ 보존된 컨테이너: $container - $CONTAINER_STATUS" | tee -a $LOG_FILE
else
echo "[$TIMESTAMP] ⚠️ 보존할 컨테이너 없음: $container (새로 생성됨)" | tee -a $LOG_FILE
fi
done
# 4. 프로젝트 관련 네트워크는 그대로 유지 (docker-compose가 관리)
echo "[$TIMESTAMP] 🌐 네트워크 상태 확인" | tee -a $LOG_FILE
echo "$SUDO_PASSWORD" | sudo -S docker network ls | grep -E "(nestjs_sns|sns-dev)" | tee -a $LOG_FILE || echo "관련 네트워크 없음" | tee -a $LOG_FILE
# 5. 애플리케이션 이미지만 선택적 정리 (DB 이미지 보존)
echo "[$TIMESTAMP] 🖼️ 애플리케이션 이미지 선택적 정리" | tee -a $LOG_FILE
echo "[$TIMESTAMP] 🛡️ 보존할 이미지: $PRESERVE_IMAGES" | tee -a $LOG_FILE
# 애플리케이션 이미지만 찾기 (sns-dev 관련)
APP_IMAGES=$(echo "$SUDO_PASSWORD" | sudo -S docker images --format "{{.Repository}}:{{.Tag}}" | grep -E "(sns-dev|nestjs_sns|2_cf_sns)" || true)
if [ -n "$APP_IMAGES" ]; then
echo "$APP_IMAGES" | while read image; do
if [ -n "$image" ]; then
# 보존할 이미지인지 확인
SHOULD_PRESERVE=false
for preserve_image in $PRESERVE_IMAGES; do
if echo "$image" | grep -q "$preserve_image"; then
SHOULD_PRESERVE=true
break
fi
done
if [ "$SHOULD_PRESERVE" = false ]; then
echo "[$TIMESTAMP] 🗑️ 애플리케이션 이미지 제거: $image" | tee -a $LOG_FILE
echo "$SUDO_PASSWORD" | sudo -S docker rmi -f "$image" 2>/dev/null || true
else
echo "[$TIMESTAMP] 🛡️ 보존된 이미지: $image" | tee -a $LOG_FILE
fi
fi
done
else
echo "[$TIMESTAMP] ✅ 제거할 애플리케이션 이미지가 없습니다." | tee -a $LOG_FILE
fi
# 6. 댕글링 이미지만 정리
echo "[$TIMESTAMP] 🧹 댕글링 이미지 정리" | tee -a $LOG_FILE
echo "$SUDO_PASSWORD" | sudo -S docker image prune -f 2>&1 | tee -a $LOG_FILE
# 7. 정리 후 상태 확인
echo "[$TIMESTAMP] 📋 정리 후 상태 확인" | tee -a $LOG_FILE
echo "[$TIMESTAMP] === 현재 컨테이너 상태 ===" | tee -a $LOG_FILE
echo "$SUDO_PASSWORD" | sudo -S docker ps -a | tee -a $LOG_FILE
echo "[$TIMESTAMP] === 보존된 이미지 확인 ===" | tee -a $LOG_FILE
for preserve_image in $PRESERVE_IMAGES; do
if echo "$SUDO_PASSWORD" | sudo -S docker images --format "{{.Repository}}:{{.Tag}}" | grep -q "^$preserve_image$"; then
echo "[$TIMESTAMP] ✅ 보존된 이미지: $preserve_image" | tee -a $LOG_FILE
else
echo "[$TIMESTAMP] ⚠️ 이미지 없음: $preserve_image (다운로드 필요)" | tee -a $LOG_FILE
fi
done
echo "[$TIMESTAMP] ✅ 선택적 정리 완료 - DB 컨테이너와 이미지 보존됨!" | tee -a $LOG_FILE
# ========== Docker 빌드 및 시작 단계 ==========
echo "[$TIMESTAMP] 🐳 Docker 컨테이너 빌드 및 시작" | tee -a $LOG_FILE
echo "[$TIMESTAMP] 💡 빌드 과정을 실시간으로 모니터링합니다..." | tee -a $LOG_FILE
# Docker Compose 빌드 및 시작을 단계별로 수행 (app과 monitor 모두 빌드)
echo "[$TIMESTAMP] 🔨 Docker 이미지 빌드 시작 (app + monitor)" | tee -a $LOG_FILE
echo "$SUDO_PASSWORD" | sudo -S docker-compose build --no-cache app monitor 2>&1 | tee -a $LOG_FILE
BUILD_EXIT_CODE=${PIPESTATUS[1]}
if [ $BUILD_EXIT_CODE -eq 0 ]; then
echo "[$TIMESTAMP] ✅ Docker 이미지 빌드 성공 (app + monitor)" | tee -a $LOG_FILE
echo "[$TIMESTAMP] 🚀 Docker 컨테이너 시작" | tee -a $LOG_FILE
echo "$SUDO_PASSWORD" | sudo -S docker-compose up -d 2>&1 | tee -a $LOG_FILE
UP_EXIT_CODE=${PIPESTATUS[1]}
if [ $UP_EXIT_CODE -eq 0 ]; then
echo "[$TIMESTAMP] ✅ Docker 컨테이너 시작 성공" | tee -a $LOG_FILE
else
echo "[$TIMESTAMP] ❌ Docker 컨테이너 시작 실패 (Exit Code: $UP_EXIT_CODE)" | tee -a $LOG_FILE
exit 1
fi
else
echo "[$TIMESTAMP] ❌ Docker 이미지 빌드 실패 (Exit Code: $BUILD_EXIT_CODE)" | tee -a $LOG_FILE
exit 1
fi
# 빌드 후 컨테이너 상태 즉시 확인
echo "[$TIMESTAMP] 📋 빌드 후 컨테이너 상태 확인" | tee -a $LOG_FILE
echo "$SUDO_PASSWORD" | sudo -S docker-compose ps | tee -a $LOG_FILE
# 모든 컨테이너 상태 확인
echo "[$TIMESTAMP] 📋 전체 컨테이너 상태" | tee -a $LOG_FILE
echo "$SUDO_PASSWORD" | sudo -S docker ps -a | tee -a $LOG_FILE
# 동적 서비스 시작 대기 (HTTPS로 변경)
echo "[$TIMESTAMP] ⏳ 서비스 시작 대기 중..." | tee -a $LOG_FILE
for i in {1..12}; do # 최대 60초 대기 (5초씩 12번)
# HTTPS 헬스체크로 변경 (SSL 검증 무시)
if curl -k -f "$API_URL/health" > /dev/null 2>&1; then
echo "[$TIMESTAMP] ✅ 서비스 시작 완료 ($((i*5))초 소요)" | tee -a $LOG_FILE
break
else
echo "[$TIMESTAMP] ⏳ 서비스 시작 대기 중... ($((i*5))초)" | tee -a $LOG_FILE
# 5번째 시도 후부터 컨테이너 로그 확인
if [ $i -eq 5 ]; then
echo "[$TIMESTAMP] 🔍 애플리케이션 컨테이너 로그 확인 (최근 10줄)" | tee -a $LOG_FILE
echo "$SUDO_PASSWORD" | sudo -S docker logs --tail=10 sns-dev-app 2>&1 | tee -a $LOG_FILE || echo "앱 컨테이너 로그를 가져올 수 없습니다." | tee -a $LOG_FILE
fi
sleep 5
fi
if [ $i -eq 12 ]; then
echo "[$TIMESTAMP] ⚠️ 서비스 시작 시간이 오래 걸리고 있습니다." | tee -a $LOG_FILE
# 최종 디버깅 정보
echo "[$TIMESTAMP] 🔍 최종 디버깅 정보" | tee -a $LOG_FILE
echo "[$TIMESTAMP] 네트워크 상태:" | tee -a $LOG_FILE
echo "$SUDO_PASSWORD" | sudo -S docker network ls | tee -a $LOG_FILE
echo "[$TIMESTAMP] 컨테이너 상세 상태:" | tee -a $LOG_FILE
echo "$SUDO_PASSWORD" | sudo -S docker ps -a --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}" | tee -a $LOG_FILE
exit 1
fi
done
# 헬스체크 수행 (HTTPS로 변경)
echo "[$TIMESTAMP] 🔍 헬스체크 수행 (HTTPS 역방향 프록시)" | tee -a $LOG_FILE
# NestJS API 헬스체크 (HTTPS)
echo "[$TIMESTAMP] 🔍 NestJS API 헬스체크 (HTTPS)" | tee -a $LOG_FILE
if curl -k -f "$API_URL/health" > /dev/null 2>&1; then
echo "[$TIMESTAMP] ✅ NestJS API HTTPS 헬스체크 통과" | tee -a $LOG_FILE
# API 응답 내용도 확인
API_RESPONSE=$(curl -k -s "$API_URL/health")
echo "[$TIMESTAMP] 🧪 API 응답: $API_RESPONSE" | tee -a $LOG_FILE
else
echo "[$TIMESTAMP] ❌ NestJS API HTTPS 헬스체크 실패" | tee -a $LOG_FILE
# 대체 헬스체크: 내부 네트워크로 시도
echo "[$TIMESTAMP] 🔄 내부 네트워크 헬스체크 시도" | tee -a $LOG_FILE
if curl -f http://localhost:3000/health > /dev/null 2>&1; then
echo "[$TIMESTAMP] ✅ 내부 네트워크 헬스체크 통과 (역방향 프록시 설정 확인 필요)" | tee -a $LOG_FILE
else
echo "[$TIMESTAMP] ❌ 내부 네트워크 헬스체크도 실패" | tee -a $LOG_FILE
exit 1
fi
fi
# Swagger UI 접근 확인
echo "[$TIMESTAMP] 🔍 Swagger UI 접근 확인" | tee -a $LOG_FILE
SWAGGER_STATUS=$(curl -k -s -o /dev/null -w "%{http_code}" "$API_URL/api" 2>/dev/null)
if [ "$SWAGGER_STATUS" = "200" ] || [ "$SWAGGER_STATUS" = "401" ]; then
echo "[$TIMESTAMP] ✅ Swagger UI 접근 가능 (HTTP $SWAGGER_STATUS)" | tee -a $LOG_FILE
else
echo "[$TIMESTAMP] ⚠️ Swagger UI 접근 상태: HTTP $SWAGGER_STATUS" | tee -a $LOG_FILE
fi
# GraphQL 접근 확인
echo "[$TIMESTAMP] 🔍 GraphQL 접근 확인" | tee -a $LOG_FILE
GRAPHQL_STATUS=$(curl -k -s -o /dev/null -w "%{http_code}" "$API_URL/graphql" 2>/dev/null)
if [ "$GRAPHQL_STATUS" = "200" ] || [ "$GRAPHQL_STATUS" = "400" ] || [ "$GRAPHQL_STATUS" = "405" ]; then
echo "[$TIMESTAMP] ✅ GraphQL 엔드포인트 접근 가능 (HTTP $GRAPHQL_STATUS)" | tee -a $LOG_FILE
else
echo "[$TIMESTAMP] ⚠️ GraphQL 엔드포인트 상태: HTTP $GRAPHQL_STATUS" | tee -a $LOG_FILE
fi
# Compodoc 문서 접근 확인
echo "[$TIMESTAMP] 🔍 Compodoc 문서 접근 확인" | tee -a $LOG_FILE
DOCS_STATUS=$(curl -k -s -o /dev/null -w "%{http_code}" "$DOCS_URL/" 2>/dev/null)
if [ "$DOCS_STATUS" = "200" ]; then
echo "[$TIMESTAMP] ✅ Compodoc 문서 접근 가능 (HTTP $DOCS_STATUS)" | tee -a $LOG_FILE
else
echo "[$TIMESTAMP] ⚠️ Compodoc 문서 상태: HTTP $DOCS_STATUS" | tee -a $LOG_FILE
fi
# 모니터링 컨테이너 상태 확인
echo "[$TIMESTAMP] 🔍 모니터링 컨테이너 상태 확인" | tee -a $LOG_FILE
MONITOR_STATUS=$(echo "$SUDO_PASSWORD" | sudo -S docker ps --format "{{.Names}}\t{{.Status}}" | grep "sns-dev-monitor" || echo "없음")
if [ "$MONITOR_STATUS" != "없음" ]; then
echo "[$TIMESTAMP] ✅ 모니터링 컨테이너: $MONITOR_STATUS" | tee -a $LOG_FILE
# 모니터링 로그 확인 (최근 5줄)
echo "[$TIMESTAMP] 📊 모니터링 컨테이너 로그 (최근 5줄):" | tee -a $LOG_FILE
echo "$SUDO_PASSWORD" | sudo -S docker logs --tail=5 sns-dev-monitor 2>&1 | tee -a $LOG_FILE
else
echo "[$TIMESTAMP] ❌ 모니터링 컨테이너가 실행되지 않았습니다." | tee -a $LOG_FILE
fi
echo "[$TIMESTAMP] 🌐 서비스 접근 정보 (역방향 프록시)" | tee -a $LOG_FILE
echo " 🔒 HTTPS 접근 (권장):" | tee -a $LOG_FILE
echo " 📡 NestJS API: $API_URL" | tee -a $LOG_FILE
echo " 📋 Swagger UI: $API_URL/api" | tee -a $LOG_FILE
echo " 🚀 GraphQL: $API_URL/graphql" | tee -a $LOG_FILE
echo " 🔍 Health Check: $API_URL/health" | tee -a $LOG_FILE
echo " 📚 Compodoc: $DOCS_URL" | tee -a $LOG_FILE
echo " 📊 모니터링:" | tee -a $LOG_FILE
echo " 🔍 모니터링 로그: docker exec sns-dev-monitor tail -f /monitor/logs/monitor.log" | tee -a $LOG_FILE
echo " 📈 실시간 모니터링: docker exec sns-dev-monitor ./monitor.sh" | tee -a $LOG_FILE
echo "=====================================" | tee -a $LOG_FILE
echo "[$TIMESTAMP] 🎉 배포 프로세스 완료" | tee -a $LOG_FILE
echo "=====================================" | tee -a $LOG_FILE
# 명시적으로 성공 상태 반환
echo "[$TIMESTAMP] 📤 배포 성공 상태 반환 (Exit Code: 0)" | tee -a $LOG_FILE
exit 0
EOF
위와 같이 배포 스크립트를 구현할 수 있는데, NAS IP와 DDNS 주소는 본인 것에 맞게 수정하여야 한다.
위의 deploy.sh 는 Github Actions의 deploy.yml에 의해 실행된다.
6.4. 스크립트 실행 권한 부여
# 스크립트 실행 권한 부여
chmod +x deploy.sh
6.5. 결과 확인
작성이 완료되면 프로젝트 내에 deploy.sh 파일이 있는 것을 확인할 수 있다.
7. GitHub Actions 워크플로우 작성
7.1. Github Repository로 이동
7.2. Github Actions 워크플로우 작성
name: 🚀 Deploy to NAS
on:
push:
branches: [ main, develop ]
pull_request:
branches: [ main ]
workflow_dispatch:
env:
NODE_VERSION: '20'
jobs:
lint-and-test:
runs-on: ubuntu-latest
steps:
- name: 📥 체크아웃
uses: actions/checkout@v4
- name: 📦 Node.js 설정
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
- name: 📦 pnpm 설치 (버전 통일)
uses: pnpm/action-setup@v2
with:
version: latest
- name: 📦 의존성 캐시
uses: actions/cache@v3
with:
path: ~/.pnpm-store
key: ${{ runner.os }}-pnpm-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-
- name: 🔍 pnpm 및 lock 파일 정보 확인
run: |
echo "=== 환경 정보 ==="
echo "Node.js 버전: $(node --version)"
echo "pnpm 버전: $(pnpm --version)"
echo "현재 디렉토리: $(pwd)"
echo "=== lock 파일 상태 ==="
if [ -f pnpm-lock.yaml ]; then
echo "✅ pnpm-lock.yaml 존재"
echo "파일 크기: $(wc -c < pnpm-lock.yaml) bytes"
echo "첫 5줄:"
head -5 pnpm-lock.yaml
else
echo "❌ pnpm-lock.yaml 없음"
fi
echo "=== package.json 확인 ==="
if [ -f package.json ]; then
echo "✅ package.json 존재"
else
echo "❌ package.json 없음"
fi
- name: 📦 의존성 설치
run: |
echo "=== 의존성 설치 시작 ==="
if [ -f pnpm-lock.yaml ]; then
echo "📦 pnpm-lock.yaml 발견 - lock 파일 검증 중..."
# lock 파일 호환성 확인
if pnpm install --frozen-lockfile --dry-run; then
echo "✅ lock 파일 호환됨 - frozen-lockfile 모드 사용"
pnpm install --frozen-lockfile
else
echo "⚠️ lock 파일 비호환 - lock 파일 재생성"
rm pnpm-lock.yaml
pnpm install
echo "📝 새로운 pnpm-lock.yaml 생성됨"
fi
else
echo "⚠️ pnpm-lock.yaml 없음 - 새로 생성"
pnpm install
fi
- name: 🔍 린트 검사
run: pnpm run lint
- name: 🧪 테스트 실행
run: pnpm run test
- name: 🏗️ 빌드 테스트
run: pnpm run build
deploy:
needs: lint-and-test
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main'
steps:
- name: 🚀 NAS 서버 배포 및 검증 (DDNS)
uses: appleboy/ssh-action@v1.1.0
with:
host: ${{ secrets.NAS_DDNS }}
username: ${{ secrets.NAS_USERNAME }}
password: ${{ secrets.NAS_PASSWORD }}
port: ${{ secrets.NAS_PORT }}
command_timeout: 30m
script: |
echo "======================================"
echo "🚀 GitHub Actions 배포 시작 (DDNS)"
echo "======================================"
echo "📊 배포 정보:"
echo " - Repository: ${{ github.repository }}"
echo " - Branch: ${{ github.ref_name }}"
echo " - Commit: ${{ github.sha }}"
echo " - Actor: ${{ github.actor }}"
echo " - DDNS Host: ${{ secrets.NAS_DDNS }}"
echo " - Timestamp: $(date '+%Y-%m-%d %H:%M:%S')"
echo "======================================"
cd ${{ secrets.NAS_PROJECT_PATH }}
git log --oneline -3
echo ""
# deploy.sh 실행 (DDNS 호스트 전달)
./deploy.sh "${{ secrets.NAS_PASSWORD }}" "${{ secrets.NAS_DDNS }}"
DEPLOY_EXIT_CODE=$?
if [ $DEPLOY_EXIT_CODE -eq 0 ]; then
echo "======================================"
echo "✅ 배포 완료 - 추가 검증 수행"
echo "======================================"
# 내부에서 포트별 서비스 상태 확인
echo "🔍 포트별 서비스 상태 확인"
# API 서버 포트 확인
if netstat -tuln | grep ":3000 " > /dev/null; then
echo "✅ API 서버 포트 3000 활성화"
else
echo "❌ API 서버 포트 3000 비활성화"
exit 1
fi
# 문서 서버 포트 확인
if netstat -tuln | grep ":8080 " > /dev/null; then
echo "✅ 문서 서버 포트 8080 활성화"
else
echo "⚠️ 문서 서버 포트 8080 비활성화 (선택적)"
fi
# 다양한 엔드포인트 테스트 (내부 네트워크)
echo "🧪 API 엔드포인트 테스트 (내부)"
# 헬스체크 (내부)
if curl -f -s http://localhost:3000/health > /dev/null; then
echo "✅ 헬스체크 API 정상 (내부)"
else
echo "❌ 헬스체크 API 실패 (내부)"
exit 1
fi
# HTTPS 헬스체크 (역방향 프록시)
echo "🧪 HTTPS 엔드포인트 테스트 (역방향 프록시)"
if curl -k -f -s "https://${{ secrets.NAS_DDNS }}/health" > /dev/null; then
echo "✅ HTTPS 헬스체크 정상"
else
echo "⚠️ HTTPS 헬스체크 실패 (역방향 프록시 설정 확인 필요)"
fi
# Swagger UI 확인 (401도 정상으로 처리)
SWAGGER_STATUS=$(curl -k -s -o /dev/null -w "%{http_code}" "https://${{ secrets.NAS_DDNS }}/api" 2>/dev/null)
if [ "$SWAGGER_STATUS" = "200" ] || [ "$SWAGGER_STATUS" = "401" ]; then
echo "✅ Swagger UI 접근 가능 (HTTP $SWAGGER_STATUS)"
else
echo "⚠️ Swagger UI 상태: HTTP $SWAGGER_STATUS"
fi
# GraphQL 확인 (400, 405도 정상으로 처리)
GRAPHQL_STATUS=$(curl -k -s -o /dev/null -w "%{http_code}" "https://${{ secrets.NAS_DDNS }}/graphql" 2>/dev/null)
if [ "$GRAPHQL_STATUS" = "200" ] || [ "$GRAPHQL_STATUS" = "400" ] || [ "$GRAPHQL_STATUS" = "405" ]; then
echo "✅ GraphQL 엔드포인트 접근 가능 (HTTP $GRAPHQL_STATUS)"
else
echo "⚠️ GraphQL 상태: HTTP $GRAPHQL_STATUS"
fi
# Compodoc 문서 확인
DOCS_STATUS=$(curl -k -s -o /dev/null -w "%{http_code}" "https://${{ secrets.NAS_DDNS }}:444/" 2>/dev/null)
if [ "$DOCS_STATUS" = "200" ]; then
echo "✅ Compodoc 문서 접근 가능 (HTTP $DOCS_STATUS)"
else
echo "⚠️ Compodoc 문서 상태: HTTP $DOCS_STATUS"
fi
# 컨테이너 상태 최종 확인
echo "📋 최종 컨테이너 상태"
docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}" | grep sns-dev
# 리소스 사용률 확인
echo "📊 시스템 리소스 사용률"
echo "메모리 사용률:"
free -h | head -2
echo "디스크 사용률:"
df -h | grep -E "(/$|/volume1)"
echo "======================================"
echo "✅ GitHub Actions 배포 및 검증 완료"
echo "🌐 HTTPS 서비스 접근:"
echo " 📡 API: https://${{ secrets.NAS_DDNS }}"
echo " 📋 Swagger: https://${{ secrets.NAS_DDNS }}/api"
echo " 🚀 GraphQL: https://${{ secrets.NAS_DDNS }}/graphql"
echo " 🔍 헬스체크: https://${{ secrets.NAS_DDNS }}/health"
echo " 📚 문서: https://${{ secrets.NAS_DDNS }}:444"
echo "💡 역방향 프록시를 통한 HTTPS 접근이 권장됩니다"
echo "======================================"
else
echo "❌ GitHub Actions 배포 실패 (Exit Code: $DEPLOY_EXIT_CODE)"
exit $DEPLOY_EXIT_CODE
fi
deploy-summary:
needs: [lint-and-test, deploy]
runs-on: ubuntu-latest
if: always()
steps:
- name: 📊 배포 결과 요약
run: |
echo "## 🚀 배포 결과 요약" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "| 항목 | 상태 |" >> $GITHUB_STEP_SUMMARY
echo "|------|------|" >> $GITHUB_STEP_SUMMARY
echo "| 🔍 코드 품질 검사 | ${{ needs.lint-and-test.result == 'success' && '✅ 성공' || '❌ 실패' }} |" >> $GITHUB_STEP_SUMMARY
echo "| 🚀 NAS 배포 및 검증 | ${{ needs.deploy.result == 'success' && '✅ 성공' || '❌ 실패' }} |" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "### 📋 배포 정보" >> $GITHUB_STEP_SUMMARY
echo "- **브랜치**: \`${{ github.ref_name }}\`" >> $GITHUB_STEP_SUMMARY
echo "- **커밋**: \`${{ github.sha }}\`" >> $GITHUB_STEP_SUMMARY
echo "- **작성자**: ${{ github.actor }}" >> $GITHUB_STEP_SUMMARY
echo "- **DDNS 호스트**: \`${{ secrets.NAS_DDNS }}\`" >> $GITHUB_STEP_SUMMARY
echo "- **시간**: $(date '+%Y-%m-%d %H:%M:%S UTC')" >> $GITHUB_STEP_SUMMARY
if [ "${{ needs.deploy.result }}" = "success" ]; then
echo "" >> $GITHUB_STEP_SUMMARY
echo "### 🌐 서비스 접근 링크 (HTTPS 권장)" >> $GITHUB_STEP_SUMMARY
echo "#### 🔒 HTTPS 접근 (역방향 프록시)" >> $GITHUB_STEP_SUMMARY
echo "- 📡 **API 서버**: [https://${{ secrets.NAS_DDNS }}](https://${{ secrets.NAS_DDNS }})" >> $GITHUB_STEP_SUMMARY
echo "- 📋 **Swagger UI**: [https://${{ secrets.NAS_DDNS }}/api](https://${{ secrets.NAS_DDNS }}/api)" >> $GITHUB_STEP_SUMMARY
echo "- 🚀 **GraphQL**: [https://${{ secrets.NAS_DDNS }}/graphql](https://${{ secrets.NAS_DDNS }}/graphql)" >> $GITHUB_STEP_SUMMARY
echo "- 🔍 **헬스체크**: [https://${{ secrets.NAS_DDNS }}/health](https://${{ secrets.NAS_DDNS }}/health)" >> $GITHUB_STEP_SUMMARY
echo "- 📚 **문서**: [https://${{ secrets.NAS_DDNS }}:444](https://${{ secrets.NAS_DDNS }}:444)" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "### 💡 참고사항" >> $GITHUB_STEP_SUMMARY
echo "- ✅ 내부 및 HTTPS 검증이 완료되었습니다" >> $GITHUB_STEP_SUMMARY
echo "- 🔒 HTTPS 접근이 권장됩니다 (SSL 인증서 적용)" >> $GITHUB_STEP_SUMMARY
echo "- 🌍 DDNS를 통한 안정적인 접근이 가능합니다" >> $GITHUB_STEP_SUMMARY
echo "- 📊 모니터링: \`docker exec sns-dev-monitor ./monitor.sh\`" >> $GITHUB_STEP_SUMMARY
fi
if [ "${{ needs.deploy.result }}" = "failure" ]; then
echo "" >> $GITHUB_STEP_SUMMARY
echo "### ❌ 배포 실패" >> $GITHUB_STEP_SUMMARY
echo "배포 중 오류가 발생했습니다. 로그를 확인하고 롤백을 고려하세요." >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "**롤백 명령어**:" >> $GITHUB_STEP_SUMMARY
echo "\`\`\`bash" >> $GITHUB_STEP_SUMMARY
echo "ssh ${{ secrets.NAS_USERNAME }}@${{ secrets.NAS_DDNS }}" >> $GITHUB_STEP_SUMMARY
echo "cd ${{ secrets.NAS_PROJECT_PATH }}" >> $GITHUB_STEP_SUMMARY
echo "./rollback.sh" >> $GITHUB_STEP_SUMMARY
echo "\`\`\`" >> $GITHUB_STEP_SUMMARY
fi
마찬가지로 필요한 부분은 쓰고 아닌 부분은 빼면 된다.
신기한 점은 Github Secrets으로 작성된 것들은, secrets.NAS_HOST 형식으로 접근이 가능하다는 점이다.
여기까지만 완료되고 큰 문제만 없다면 아래처럼 잘 실행되고 테스트되며 배포되는 것을 볼 수 있다.
8. 성공 결과 확인
8.1. 브라우저 엔드포인트 테스트
Swagger와 Compodoc이 브라우저를 통해 외부에서 잘 접속되는 것을 확인할 수 있다.
8.2. Postman 테스트
Postman으로 역시 성공적인 로그인이 되는 것을 확인하였다.
'NestJS' 카테고리의 다른 글
Github Actions를 사용한 CI/CD - Slack 알림 (0) | 2025.07.03 |
---|---|
Github Actions를 사용한 CI/CD - 롤백, 모니터링, 로그 정리 (0) | 2025.07.02 |
시놀로지 나스 NestJS 개발서버 구축하기 - Docker Container (0) | 2025.06.19 |
[NestJS] winston 로그 구현하기 (1) | 2025.06.05 |
[NestJS] 단위테스트 (1) | 2025.06.04 |