iBetter Books
수정

배포 스크립트 작성

배포는 단순히 파일을 복사하는 일이 아닙니다. 서비스가 멈추지 않아야 하고, 실패했을 때 즉시 되돌릴 수 있어야 하고, 몇 번을 실행해도 같은 결과가 나와야 합니다. 좋은 배포 스크립트가 갖춰야 할 세 가지 원칙입니다.

배포 스크립트의 핵심 원칙

멱등성(Idempotency). 여러 번 실행해도 동일한 결과가 나와야 합니다. 배포가 도중에 실패해서 다시 실행하거나, 실수로 두 번 실행하더라도 서버 상태가 깨지지 않아야 합니다.

원자적 배포(Atomic Deployment). 심볼릭 링크를 전환하는 방식으로 구현합니다. 새 버전을 완전히 준비한 다음 한 순간에 전환하면, 사용자는 배포 중에도 항상 완전한 버전의 앱을 사용합니다.

롤백(Rollback). 배포 후 문제가 발생하면 이전 버전으로 즉시 복구할 수 있어야 합니다.

원자적 배포 구조

심볼릭 링크 방식의 디렉토리 구조입니다.

/var/www/myapp/
├── current -> releases/20260424_143022/   ← 심볼릭 링크 (현재 버전)
├── releases/
│   ├── 20260424_143022/   ← 현재 버전 (complete)
│   ├── 20260423_091500/   ← 이전 버전 1
│   └── 20260422_180000/   ← 이전 버전 2
└── shared/                ← 버전 간 공유 파일 (.env, uploads/)
    ├── .env
    └── uploads/

nginx나 웹서버는 /var/www/myapp/current를 바라봅니다. 새 버전 배포 시 releases/ 아래에 새 디렉토리를 만들고, 모든 준비가 완료되면 current 심볼릭 링크만 교체합니다. 링크 교체는 원자적 연산이라 사용자가 중간 상태를 볼 수 없습니다.

deploy.sh 전체 코드

#!/bin/bash# 새 파일: deploy.sh# deploy.sh: 원자적 배포 스크립트 (무중단 배포 + 자동 롤백)set -euo pipefail# ─── 설정 ─────────────────────────────────────────────────────────────────────DEPLOY_HOST="${DEPLOY_HOST:-}"DEPLOY_USER="${DEPLOY_USER:-deploy}"DEPLOY_BASE="/var/www/myapp"RELEASES_DIR="$DEPLOY_BASE/releases"CURRENT_LINK="$DEPLOY_BASE/current"SHARED_DIR="$DEPLOY_BASE/shared"KEEP_RELEASES=5    # 유지할 이전 버전 수APP_DIR=$(pwd)TIMESTAMP=$(date +"%Y%m%d_%H%M%S")RELEASE_DIR="$RELEASES_DIR/$TIMESTAMP"# 컬러 출력RED='\033[0;31m'GREEN='\033[0;32m'YELLOW='\033[1;33m'CYAN='\033[0;36m'NC='\033[0m'log()     { echo -e "${CYAN}[$(date '+%H:%M:%S')]${NC} $*"; }success() { echo -e "${GREEN}[$(date '+%H:%M:%S')] ✓${NC} $*"; }warn()    { echo -e "${YELLOW}[$(date '+%H:%M:%S')] !${NC} $*"; }error()   { echo -e "${RED}[$(date '+%H:%M:%S')] ✗${NC} $*" >&2; }# ─── 원격 실행 헬퍼 ───────────────────────────────────────────────────────────remote() {    if [ -n "$DEPLOY_HOST" ]; then        ssh "$DEPLOY_USER@$DEPLOY_HOST" "$@"    else        # 로컬 배포 모드 (테스트용)        bash -c "$@"    fi}# ─── 롤백 함수 ────────────────────────────────────────────────────────────────rollback() {    local reason=$1    error "배포 실패: $reason"    warn "롤백을 시작합니다..."    # 이전 릴리즈 디렉토리 확인    local prev_release    prev_release=$(remote "ls -dt $RELEASES_DIR/*/ 2>/dev/null | sed -n '2p'")    if [ -z "$prev_release" ]; then        error "롤백할 이전 버전이 없습니다."        exit 1    fi    # 심볼릭 링크를 이전 버전으로 복구    remote "ln -sfn $prev_release $CURRENT_LINK"    remote "[ -f $DEPLOY_BASE/reload.sh ] && bash $DEPLOY_BASE/reload.sh || true"    warn "롤백 완료: $prev_release"    # 실패한 릴리즈 정리    if [ -d "$RELEASE_DIR" ]; then        remote "rm -rf $RELEASE_DIR"    fi    exit 1}# 배포 중 오류 발생 시 자동 롤백trap 'rollback "예상치 못한 오류 발생"' ERR# ─── 1단계: 사전 점검 ─────────────────────────────────────────────────────────log "1단계: 사전 점검"if [ -n "$DEPLOY_HOST" ]; then    if ! ssh -o ConnectTimeout=10 "$DEPLOY_USER@$DEPLOY_HOST" "echo ok" > /dev/null 2>&1; then        error "서버에 연결할 수 없습니다: $DEPLOY_HOST"        exit 1    fi    success "서버 연결 확인"fi# 디렉토리 구조 초기화 (멱등성: 이미 있어도 괜찮음)remote "mkdir -p $RELEASES_DIR $SHARED_DIR"success "디렉토리 구조 확인"# ─── 2단계: 파일 전송 ─────────────────────────────────────────────────────────log "2단계: 파일 전송 ($TIMESTAMP)"if [ -n "$DEPLOY_HOST" ]; then    rsync -avz --delete \        --exclude='.git' \        --exclude='.env*' \        --exclude='node_modules' \        --exclude='__pycache__' \        --exclude='*.pyc' \        "$APP_DIR/" \        "$DEPLOY_USER@$DEPLOY_HOST:$RELEASE_DIR/"else    # 로컬 모드: 현재 디렉토리를 릴리즈 디렉토리로 복사    mkdir -p "$RELEASE_DIR"    rsync -a --exclude='.git' --exclude='.env*' \        "$APP_DIR/" "$RELEASE_DIR/"fisuccess "파일 전송 완료"# ─── 3단계: 공유 파일 연결 ────────────────────────────────────────────────────log "3단계: 공유 파일 연결"# 공유 .env 파일 심볼릭 링크remote "[ -f $SHARED_DIR/.env ] && ln -sf $SHARED_DIR/.env $RELEASE_DIR/.env || true"# 공유 업로드 디렉토리 연결remote "[ -d $SHARED_DIR/uploads ] && ln -sf $SHARED_DIR/uploads $RELEASE_DIR/uploads || true"success "공유 파일 연결 완료"# ─── 4단계: 앱 빌드 (선택) ────────────────────────────────────────────────────log "4단계: 앱 빌드"# 예시: Node.js 프로젝트if remote "[ -f $RELEASE_DIR/package.json ]"; then    remote "cd $RELEASE_DIR && npm install --production --silent 2>&1 | tail -3"    success "npm install 완료"fi# 예시: Python 프로젝트if remote "[ -f $RELEASE_DIR/requirements.txt ]"; then    remote "cd $RELEASE_DIR && pip install -r requirements.txt -q"    success "pip install 완료"fi# ─── 5단계: 심볼릭 링크 전환 (원자적 배포) ────────────────────────────────────log "5단계: 심볼릭 링크 전환"remote "ln -sfn $RELEASE_DIR $CURRENT_LINK"success "current → $TIMESTAMP"# ─── 6단계: 서비스 재시작 ─────────────────────────────────────────────────────log "6단계: 서비스 재시작"remote "systemctl restart myapp 2>/dev/null || true"success "서비스 재시작 완료"# ─── 7단계: 헬스 체크 ─────────────────────────────────────────────────────────log "7단계: 헬스 체크"APP_URL="${APP_URL:-http://localhost:8000}"MAX_RETRIES=5RETRY_INTERVAL=3for i in $(seq 1 $MAX_RETRIES); do    HTTP_STATUS=$(curl -s -o /dev/null -w "%{http_code}" \        --connect-timeout 5 --max-time 10 "$APP_URL/health" 2>/dev/null || echo "000")    if [ "$HTTP_STATUS" = "200" ]; then        success "헬스 체크 통과 (HTTP $HTTP_STATUS)"        break    fi    if [ "$i" -eq "$MAX_RETRIES" ]; then        rollback "헬스 체크 실패 (HTTP $HTTP_STATUS) — $MAX_RETRIES회 시도"    fi    warn "헬스 체크 대기 중... ($i/$MAX_RETRIES, HTTP: $HTTP_STATUS)"    sleep $RETRY_INTERVALdone# ─── 8단계: 오래된 릴리즈 정리 ───────────────────────────────────────────────log "8단계: 오래된 릴리즈 정리"remote "ls -dt $RELEASES_DIR/*/ | tail -n +$((KEEP_RELEASES + 1)) | xargs rm -rf --"success "최근 $KEEP_RELEASES개 릴리즈만 유지"# ─── 완료 ─────────────────────────────────────────────────────────────────────trap - ERR   # 정상 완료 시 trap 해제echo ""success "배포 완료."echo "  버전: $TIMESTAMP"echo "  경로: $RELEASE_DIR"echo ""

배포 실행

# 로컬 테스트 (DEPLOY_HOST 없이)chmod +x deploy.sh./deploy.sh# 원격 서버 배포DEPLOY_HOST=192.168.1.100 \DEPLOY_USER=ubuntu \APP_URL=http://192.168.1.100 \./deploy.sh
[14:30:22]   1단계: 사전 점검
[14:30:22] ✓ 서버 연결 확인
[14:30:22] ✓ 디렉토리 구조 확인
[14:30:22]   2단계: 파일 전송 (20260424_143022)
[14:30:25] ✓ 파일 전송 완료
[14:30:25]   3단계: 공유 파일 연결
[14:30:25] ✓ 공유 파일 연결 완료
[14:30:25]   4단계: 앱 빌드
[14:30:28] ✓ npm install 완료
[14:30:28]   5단계: 심볼릭 링크 전환
[14:30:28] ✓ current → 20260424_143022
[14:30:28]   6단계: 서비스 재시작
[14:30:29] ✓ 서비스 재시작 완료
[14:30:29]   7단계: 헬스 체크
[14:30:31] ✓ 헬스 체크 통과 (HTTP 200)
[14:30:31]   8단계: 오래된 릴리즈 정리
[14:30:31] ✓ 최근 5개 릴리즈만 유지

[14:30:31] ✓ 배포 완료.
  버전: 20260424_143022
  경로: /var/www/myapp/releases/20260424_143022

헬스 체크에서 HTTP 200이 아닌 코드가 반환되면 자동으로 롤백이 실행됩니다.

[14:30:29]   7단계: 헬스 체크
! 헬스 체크 대기 중... (1/5, HTTP: 502)
! 헬스 체크 대기 중... (2/5, HTTP: 502)
...
✗ 배포 실패: 헬스 체크 실패 (HTTP 502) — 5회 시도
! 롤백을 시작합니다...
! 롤백 완료: /var/www/myapp/releases/20260423_091500