프로젝트: 배포 자동화(Deployment Automation)
프로젝트: 배포 자동화(Deployment Automation)¶
난이도: ⭐⭐⭐⭐
이전: 14_Project_Task_Runner.md | 다음: 16_Project_Monitor.md
1. 개요¶
배포 자동화란?¶
배포 자동화는 소스 제어에서 프로덕션 서버로 코드를 자동으로 이동하는 프로세스입니다. 수동 단계를 제거하고 에러를 줄이며 더 빠르고 신뢰할 수 있는 릴리스를 가능하게 합니다.
주요 구성 요소는 다음과 같습니다:
- 원격 실행: SSH를 통해 대상 서버에서 명령어 실행
- 파일 동기화: 서버로 코드와 자산 복사
- 헬스 체크: 배포가 성공했는지 확인
- 롤백 기능: 실패 시 이전 버전으로 되돌리기
- 다중 서버 오케스트레이션: 여러 호스트에 순차적 또는 병렬로 배포
왜 순수 Bash인가?¶
Ansible, Terraform, Kubernetes와 같은 도구가 존재하지만, bash 배포 스크립트는 다음을 제공합니다:
- 제로 의존성: 에이전트나 오케스트레이션 도구 불필요
- 투명성: 각 서버에서 실행되는 것이 정확히 보임
- 단순성: 소규모에서 중규모 배포에 완벽
- SSH 네이티브: 기존 SSH 인프라 활용
- 커스터마이징: 특정 요구사항에 맞게 쉽게 적응
우리가 만들 것¶
이 레슨은 세 가지 배포 도구를 다룹니다:
- SSH 기반 배포: rsync와 SSH를 사용하여 원격 서버에 배포
- 롤링 배포: 헬스 체크와 함께 서버 플릿에 점진적으로 배포
- Docker 엔트리포인트 스크립트: 시그널 처리를 통한 적절한 컨테이너 초기화
2. 설계¶
아키텍처 개요¶
Deployment System
├── Configuration Management
│ ├── Environment variables (.env files)
│ ├── Target hosts (inventory)
│ └── Deployment strategy (rolling, blue-green)
│
├── Remote Execution
│ ├── SSH connection management
│ ├── Command execution on remote hosts
│ └── File synchronization (rsync)
│
├── Health Checks
│ ├── Application health endpoints
│ ├── Process verification
│ └── Log inspection
│
└── Rollback Strategy
├── Version tracking
├── Symlink-based releases
└── Automatic rollback on failure
배포 전략¶
| 전략 | 설명 | 사용 사례 | 위험도 |
|---|---|---|---|
| All-at-once | 모든 서버에 동시에 배포 | 낮은 트래픽 앱, 스테이징 | 높음 |
| Rolling | 서버에 하나씩 또는 배치로 배포 | 프로덕션, 점진적 롤아웃 | 중간 |
| Blue-Green | 두 환경 유지, 트래픽 전환 | 제로 다운타임 배포 | 낮음 |
| Canary | 하위 집합에 배포, 모니터링, 전체 롤아웃 | 높은 위험 변경 | 낮음 |
이 레슨에서는 롤링 배포를 구현합니다.
대상 디렉터리 구조¶
원격 서버에서:
/opt/myapp/
├── current -> releases/20240215_143022/ # Symlink to active version
├── releases/
│ ├── 20240215_143022/ # Current deployment
│ ├── 20240215_120000/ # Previous deployment
│ └── 20240214_093000/ # Older deployment
└── shared/
├── logs/
├── uploads/
└── .env # Shared environment config
이 구조는 다음을 가능하게 합니다: - 빠른 롤백: 심볼릭 링크만 변경 - 이전 버전 유지: N개의 이전 릴리스 보유 - 공유 상태: 로그와 업로드가 배포 간 유지됨
3. SSH 기반 배포¶
SSH 연결 기본¶
키 기반 인증으로 암호 없는 SSH 설정:
# Generate SSH key (if not already done)
ssh-keygen -t ed25519 -f ~/.ssh/deploy_key -N ""
# Copy public key to remote server
ssh-copy-id -i ~/.ssh/deploy_key.pub user@server.example.com
# Test connection
ssh -i ~/.ssh/deploy_key user@server.example.com "echo 'Connected successfully'"
재사용 가능한 SSH 함수¶
#!/usr/bin/env bash
set -euo pipefail
# SSH configuration
SSH_KEY="${SSH_KEY:-$HOME/.ssh/deploy_key}"
SSH_USER="${SSH_USER:-deploy}"
SSH_OPTS="-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR"
# Execute command on remote host
remote_exec() {
local host="$1"
shift
local cmd="$*"
ssh ${SSH_OPTS} -i "${SSH_KEY}" "${SSH_USER}@${host}" "${cmd}"
}
# Example usage
remote_exec "web01.example.com" "uptime"
remote_exec "web01.example.com" "df -h"
rsync를 통한 파일 동기화¶
rsync는 반복적인 배포에서 scp보다 효율적입니다:
# Sync local directory to remote server
sync_files() {
local host="$1"
local source="$2"
local destination="$3"
rsync -avz --delete \
-e "ssh ${SSH_OPTS} -i ${SSH_KEY}" \
"${source}/" \
"${SSH_USER}@${host}:${destination}/"
}
# Example: Deploy application code
sync_files "web01.example.com" "./build" "/opt/myapp/releases/$(date +%Y%m%d_%H%M%S)"
rsync 플래그 설명:
- -a: 아카이브 모드 (권한, 타임스탬프 등 보존)
- -v: 상세 출력
- -z: 전송 중 압축
- --delete: 소스에 없는 파일을 대상에서 제거
- -e: 원격 셸 명령어 지정
고급: 파일 제외¶
sync_files_with_exclusions() {
local host="$1"
local source="$2"
local destination="$3"
rsync -avz --delete \
--exclude='.git' \
--exclude='node_modules' \
--exclude='*.log' \
--exclude='.env' \
-e "ssh ${SSH_OPTS} -i ${SSH_KEY}" \
"${source}/" \
"${SSH_USER}@${host}:${destination}/"
}
SSH 연결 풀링 (ControlMaster)¶
반복되는 명령어를 위해 SSH 연결 재사용:
# Enable connection multiplexing
SSH_CONTROL_PATH="/tmp/ssh-control-%r@%h:%p"
SSH_OPTS+=" -o ControlMaster=auto -o ControlPath=${SSH_CONTROL_PATH} -o ControlPersist=10m"
# First command opens connection and keeps it alive for 10 minutes
remote_exec "web01.example.com" "echo 'First command'"
# Subsequent commands reuse the connection (much faster)
remote_exec "web01.example.com" "echo 'Second command'"
remote_exec "web01.example.com" "echo 'Third command'"
# Cleanup control socket when done
cleanup_ssh() {
local host="$1"
ssh ${SSH_OPTS} -O exit "${SSH_USER}@${host}" 2>/dev/null || true
}
4. 롤링 배포¶
롤링 배포 전략¶
서버에 순차적으로 배포하고, 계속 진행하기 전에 각각이 성공했는지 확인:
- 서버 1에 배포
- 서버 1에서 헬스 체크 실행
- 정상이면 서버 2로 계속; 그렇지 않으면 롤백하고 중단
- 모든 서버에 대해 반복
구현¶
#!/usr/bin/env bash
set -euo pipefail
# ============================================================================
# Rolling Deployment Script
# ============================================================================
# Configuration
APP_NAME="${APP_NAME:-myapp}"
APP_DIR="/opt/${APP_NAME}"
RELEASES_DIR="${APP_DIR}/releases"
CURRENT_LINK="${APP_DIR}/current"
SHARED_DIR="${APP_DIR}/shared"
KEEP_RELEASES=5
# Health check settings
HEALTH_CHECK_URL="${HEALTH_CHECK_URL:-http://localhost:8080/health}"
HEALTH_CHECK_RETRIES=5
HEALTH_CHECK_DELAY=2
# Colors
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
RESET='\033[0m'
log_info() {
echo -e "${BLUE}[$(date +'%H:%M:%S')]${RESET} $*"
}
log_success() {
echo -e "${GREEN}[$(date +'%H:%M:%S')] ✓${RESET} $*"
}
log_error() {
echo -e "${RED}[$(date +'%H:%M:%S')] ✗${RESET} $*" >&2
}
log_warn() {
echo -e "${YELLOW}[$(date +'%H:%M:%S')] !${RESET} $*"
}
# ============================================================================
# Remote Operations
# ============================================================================
remote_exec() {
local host="$1"
shift
ssh ${SSH_OPTS} -i "${SSH_KEY}" "${SSH_USER}@${host}" "$@"
}
# ============================================================================
# Deployment Functions
# ============================================================================
# Create directory structure on remote server
setup_remote_directories() {
local host="$1"
log_info "Setting up directories on ${host}..."
remote_exec "${host}" "mkdir -p ${RELEASES_DIR} ${SHARED_DIR}/logs"
log_success "Directories ready on ${host}"
}
# Deploy application code to remote server
deploy_release() {
local host="$1"
local release_name="$(date +%Y%m%d_%H%M%S)"
local release_path="${RELEASES_DIR}/${release_name}"
log_info "Deploying release ${release_name} to ${host}..."
# Create release directory
remote_exec "${host}" "mkdir -p ${release_path}"
# Sync application code
rsync -avz --delete \
--exclude='.git' \
--exclude='*.log' \
--exclude='.env' \
-e "ssh ${SSH_OPTS} -i ${SSH_KEY}" \
./build/ \
"${SSH_USER}@${host}:${release_path}/"
# Link shared resources
remote_exec "${host}" "ln -snf ${SHARED_DIR}/logs ${release_path}/logs"
remote_exec "${host}" "ln -snf ${SHARED_DIR}/.env ${release_path}/.env"
echo "${release_name}"
}
# Activate a release by updating the 'current' symlink
activate_release() {
local host="$1"
local release_name="$2"
local release_path="${RELEASES_DIR}/${release_name}"
log_info "Activating release ${release_name} on ${host}..."
# Update symlink atomically
remote_exec "${host}" "ln -snf ${release_path} ${CURRENT_LINK}"
# Restart application
restart_application "${host}"
log_success "Activated ${release_name} on ${host}"
}
# Restart the application
restart_application() {
local host="$1"
log_info "Restarting application on ${host}..."
# Systemd service restart
if remote_exec "${host}" "systemctl is-active --quiet ${APP_NAME}"; then
remote_exec "${host}" "sudo systemctl restart ${APP_NAME}"
else
log_warn "Service ${APP_NAME} not running on ${host}, starting..."
remote_exec "${host}" "sudo systemctl start ${APP_NAME}"
fi
sleep 2
}
# ============================================================================
# Health Checks
# ============================================================================
check_health() {
local host="$1"
local retries="${HEALTH_CHECK_RETRIES}"
log_info "Running health check on ${host}..."
while [ "${retries}" -gt 0 ]; do
if remote_exec "${host}" "curl -sf ${HEALTH_CHECK_URL} > /dev/null"; then
log_success "Health check passed on ${host}"
return 0
fi
retries=$((retries - 1))
if [ "${retries}" -gt 0 ]; then
log_warn "Health check failed, retrying in ${HEALTH_CHECK_DELAY}s... (${retries} attempts left)"
sleep "${HEALTH_CHECK_DELAY}"
fi
done
log_error "Health check failed on ${host} after ${HEALTH_CHECK_RETRIES} attempts"
return 1
}
# ============================================================================
# Rollback
# ============================================================================
rollback() {
local host="$1"
log_warn "Rolling back deployment on ${host}..."
# Get previous release
local previous_release=$(remote_exec "${host}" \
"ls -t ${RELEASES_DIR} | sed -n '2p'")
if [ -z "${previous_release}" ]; then
log_error "No previous release found for rollback on ${host}"
return 1
fi
log_info "Rolling back to ${previous_release} on ${host}"
# Reactivate previous release
activate_release "${host}" "${previous_release}"
if check_health "${host}"; then
log_success "Rollback successful on ${host}"
return 0
else
log_error "Rollback failed on ${host}"
return 1
fi
}
# ============================================================================
# Cleanup
# ============================================================================
cleanup_old_releases() {
local host="$1"
log_info "Cleaning up old releases on ${host}..."
remote_exec "${host}" \
"ls -t ${RELEASES_DIR} | tail -n +$((KEEP_RELEASES + 1)) | xargs -I {} rm -rf ${RELEASES_DIR}/{}"
log_success "Cleanup complete on ${host}"
}
# ============================================================================
# Rolling Deploy to Fleet
# ============================================================================
rolling_deploy() {
local hosts=("$@")
log_info "Starting rolling deployment to ${#hosts[@]} servers..."
local start_time=$(date +%s)
local failed_hosts=()
for host in "${hosts[@]}"; do
log_info "Deploying to ${host}..."
# Setup
setup_remote_directories "${host}"
# Deploy
local release_name=$(deploy_release "${host}")
# Activate
activate_release "${host}" "${release_name}"
# Health check
if check_health "${host}"; then
log_success "Deployment to ${host} successful"
cleanup_old_releases "${host}"
else
log_error "Deployment to ${host} failed health check"
# Rollback
if rollback "${host}"; then
log_warn "Rolled back ${host} successfully"
fi
failed_hosts+=("${host}")
# Abort on first failure
log_error "Aborting rolling deployment due to failure on ${host}"
break
fi
log_info "Waiting 5 seconds before next deployment..."
sleep 5
done
local end_time=$(date +%s)
local duration=$((end_time - start_time))
echo ""
if [ ${#failed_hosts[@]} -eq 0 ]; then
log_success "Rolling deployment completed successfully in ${duration}s"
return 0
else
log_error "Rolling deployment failed on: ${failed_hosts[*]}"
return 1
fi
}
# ============================================================================
# Main
# ============================================================================
main() {
# Load environment
if [ -f .env.deploy ]; then
source .env.deploy
fi
# Validate configuration
if [ -z "${SSH_KEY:-}" ]; then
log_error "SSH_KEY not set"
exit 1
fi
if [ ! -f "${SSH_KEY}" ]; then
log_error "SSH key not found: ${SSH_KEY}"
exit 1
fi
# Read hosts from file or arguments
local hosts=()
if [ -f "hosts.txt" ]; then
mapfile -t hosts < hosts.txt
elif [ $# -gt 0 ]; then
hosts=("$@")
else
log_error "No hosts specified. Provide hosts.txt or pass as arguments."
exit 1
fi
# Run rolling deployment
rolling_deploy "${hosts[@]}"
}
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
main "$@"
fi
5. Docker 엔트리포인트 스크립트¶
적절한 엔트리포인트 스크립트가 중요한 이유¶
Docker 컨테이너는 다음을 수행해야 합니다: - 시그널을 우아하게 처리 (종료를 위한 SIGTERM) - 의존성 대기 (데이터베이스, Redis 등) - 환경 변수로부터 자신을 구성 - 올바른 사용자로 실행 (root가 아님)
적절한 엔트리포인트 스크립트는 초기화를 오케스트레이션합니다.
기본 엔트리포인트 구조¶
#!/usr/bin/env bash
set -euo pipefail
# ============================================================================
# Docker Entrypoint Script
# ============================================================================
# Signal handling for graceful shutdown
shutdown() {
echo "Received SIGTERM, shutting down gracefully..."
# Kill child processes
kill -TERM "$APP_PID" 2>/dev/null || true
wait "$APP_PID"
exit 0
}
trap shutdown SIGTERM SIGINT
# ============================================================================
# Wait for Dependencies
# ============================================================================
wait_for_service() {
local host="$1"
local port="$2"
local max_attempts=30
local attempt=1
echo "Waiting for ${host}:${port}..."
while [ $attempt -le $max_attempts ]; do
if nc -z "${host}" "${port}" 2>/dev/null; then
echo "Service ${host}:${port} is ready!"
return 0
fi
echo "Attempt ${attempt}/${max_attempts}: ${host}:${port} not ready, waiting..."
sleep 2
attempt=$((attempt + 1))
done
echo "Service ${host}:${port} failed to become ready after ${max_attempts} attempts"
return 1
}
# Wait for PostgreSQL
if [ -n "${POSTGRES_HOST:-}" ]; then
wait_for_service "${POSTGRES_HOST}" "${POSTGRES_PORT:-5432}"
fi
# Wait for Redis
if [ -n "${REDIS_HOST:-}" ]; then
wait_for_service "${REDIS_HOST}" "${REDIS_PORT:-6379}"
fi
# ============================================================================
# Configuration from Environment
# ============================================================================
# Substitute environment variables in config template
if [ -f /app/config.template.yml ]; then
envsubst < /app/config.template.yml > /app/config.yml
echo "Generated config.yml from environment variables"
fi
# ============================================================================
# Run Migrations (if needed)
# ============================================================================
if [ "${RUN_MIGRATIONS:-false}" = "true" ]; then
echo "Running database migrations..."
python manage.py migrate
fi
# ============================================================================
# Start Application
# ============================================================================
echo "Starting application..."
# Start app in background so we can handle signals
exec "$@" &
APP_PID=$!
# Wait for application process
wait "$APP_PID"
고급: envsubst를 사용한 템플릿 처리¶
환경 변수로부터 설정 파일을 생성하기 위해 envsubst 사용:
# config.template.yml
database:
host: ${DB_HOST}
port: ${DB_PORT}
user: ${DB_USER}
password: ${DB_PASSWORD}
redis:
host: ${REDIS_HOST}
port: ${REDIS_PORT}
app:
debug: ${DEBUG}
secret_key: ${SECRET_KEY}
엔트리포인트에서:
export DB_HOST="${DB_HOST:-localhost}"
export DB_PORT="${DB_PORT:-5432}"
export REDIS_HOST="${REDIS_HOST:-localhost}"
export DEBUG="${DEBUG:-false}"
envsubst < config.template.yml > config.yml
Wait-for-it 패턴¶
의존성 대기를 위한 재사용 가능한 함수:
wait_for_it() {
local service="$1"
local timeout="${2:-30}"
if [[ "${service}" =~ ^([^:]+):([0-9]+)$ ]]; then
local host="${BASH_REMATCH[1]}"
local port="${BASH_REMATCH[2]}"
else
echo "Invalid service format: ${service} (expected host:port)"
return 1
fi
local start=$(date +%s)
while true; do
if nc -z "${host}" "${port}" 2>/dev/null; then
echo "✓ ${service} is ready"
return 0
fi
local now=$(date +%s)
local elapsed=$((now - start))
if [ $elapsed -ge $timeout ]; then
echo "✗ ${service} not ready after ${timeout}s"
return 1
fi
echo "Waiting for ${service}... (${elapsed}s)"
sleep 1
done
}
# Usage
wait_for_it "${DATABASE_HOST}:${DATABASE_PORT}" 60
wait_for_it "${REDIS_HOST}:${REDIS_PORT}" 30
비루트 사용자로 실행¶
보안 모범 사례: 애플리케이션을 비루트로 실행:
# In Dockerfile
RUN groupadd -r appuser && useradd -r -g appuser appuser
USER appuser
# Or in entrypoint (if you need root for initialization)
#!/bin/bash
# Do root-level setup
chown -R appuser:appuser /app/logs
# Drop privileges and execute app
exec gosu appuser "$@"
6. 환경 설정¶
.env 파일 로드¶
load_env() {
local env_file="${1:-.env}"
if [ ! -f "${env_file}" ]; then
echo "Warning: ${env_file} not found" >&2
return 1
fi
# Export all variables from .env
set -a
source "${env_file}"
set +a
echo "Loaded environment from ${env_file}"
}
# Usage
load_env ".env.production"
환경 변수 검증¶
필수 변수가 설정되었는지 확인:
validate_env() {
local required_vars=(
"DATABASE_URL"
"REDIS_URL"
"SECRET_KEY"
"API_KEY"
)
local missing_vars=()
for var in "${required_vars[@]}"; do
if [ -z "${!var:-}" ]; then
missing_vars+=("${var}")
fi
done
if [ ${#missing_vars[@]} -gt 0 ]; then
echo "Error: Missing required environment variables:" >&2
printf ' - %s\n' "${missing_vars[@]}" >&2
return 1
fi
echo "✓ All required environment variables are set"
}
# Usage in entrypoint
load_env ".env"
validate_env || exit 1
비밀 관리¶
비밀을 하드코딩하지 마세요. 환경 변수나 비밀 관리자를 사용하세요:
# Bad: Hardcoded secrets
DB_PASSWORD="super_secret_123"
# Good: From environment
DB_PASSWORD="${DB_PASSWORD}"
# Better: From secret file (Docker secrets, Kubernetes secrets)
if [ -f /run/secrets/db_password ]; then
DB_PASSWORD="$(cat /run/secrets/db_password)"
fi
# Best: From secret manager (AWS Secrets Manager, Vault)
DB_PASSWORD="$(aws secretsmanager get-secret-value --secret-id prod/db/password --query SecretString --output text)"
7. 완전한 배포 스크립트¶
모든 개념을 결합한 완전한 기능의 배포 스크립트입니다:
#!/usr/bin/env bash
set -euo pipefail
# ============================================================================
# Complete Deployment Automation Script
# ============================================================================
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(dirname "${SCRIPT_DIR}")"
# Configuration
APP_NAME="${APP_NAME:-myapp}"
DEPLOY_ENV="${DEPLOY_ENV:-production}"
SSH_KEY="${SSH_KEY:-${HOME}/.ssh/deploy_key}"
SSH_USER="${SSH_USER:-deploy}"
BUILD_DIR="${PROJECT_ROOT}/build"
# Colors
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
RESET='\033[0m'
log() { echo -e "${BLUE}[$(date +'%H:%M:%S')]${RESET} $*"; }
success() { echo -e "${GREEN}[$(date +'%H:%M:%S')] ✓${RESET} $*"; }
error() { echo -e "${RED}[$(date +'%H:%M:%S')] ✗${RESET} $*" >&2; }
warn() { echo -e "${YELLOW}[$(date +'%H:%M:%S')] !${RESET} $*"; }
# ============================================================================
# Preflight Checks
# ============================================================================
preflight_checks() {
log "Running preflight checks..."
# Check SSH key
if [ ! -f "${SSH_KEY}" ]; then
error "SSH key not found: ${SSH_KEY}"
return 1
fi
# Check build directory
if [ ! -d "${BUILD_DIR}" ]; then
error "Build directory not found: ${BUILD_DIR}"
return 1
fi
# Check hosts file
if [ ! -f "${SCRIPT_DIR}/hosts.${DEPLOY_ENV}.txt" ]; then
error "Hosts file not found: hosts.${DEPLOY_ENV}.txt"
return 1
fi
# Verify build
if [ ! -f "${BUILD_DIR}/index.html" ]; then
warn "index.html not found in build directory"
fi
success "Preflight checks passed"
}
# ============================================================================
# Build
# ============================================================================
build_application() {
log "Building application..."
cd "${PROJECT_ROOT}"
# Clean previous build
rm -rf "${BUILD_DIR}"
mkdir -p "${BUILD_DIR}"
# Run build command (customize for your project)
if [ -f "package.json" ]; then
npm run build
elif [ -f "Makefile" ]; then
make build
else
error "No build system detected"
return 1
fi
success "Build completed"
}
# ============================================================================
# Deploy to Single Host
# ============================================================================
deploy_to_host() {
local host="$1"
local release_name="$(date +%Y%m%d_%H%M%S)"
log "Deploying ${release_name} to ${host}..."
# Setup directories
ssh -i "${SSH_KEY}" "${SSH_USER}@${host}" \
"mkdir -p /opt/${APP_NAME}/{releases,shared/logs}"
# Sync files
rsync -avz --delete \
--exclude='.git' \
--exclude='node_modules' \
--exclude='*.log' \
-e "ssh -i ${SSH_KEY}" \
"${BUILD_DIR}/" \
"${SSH_USER}@${host}:/opt/${APP_NAME}/releases/${release_name}/"
# Activate release
ssh -i "${SSH_KEY}" "${SSH_USER}@${host}" \
"ln -snf /opt/${APP_NAME}/releases/${release_name} /opt/${APP_NAME}/current"
# Restart service
ssh -i "${SSH_KEY}" "${SSH_USER}@${host}" \
"sudo systemctl restart ${APP_NAME}"
sleep 3
# Health check
if ssh -i "${SSH_KEY}" "${SSH_USER}@${host}" \
"curl -sf http://localhost:8080/health >/dev/null"; then
success "Deployment to ${host} successful"
# Cleanup old releases
ssh -i "${SSH_KEY}" "${SSH_USER}@${host}" \
"ls -t /opt/${APP_NAME}/releases | tail -n +6 | xargs -I {} rm -rf /opt/${APP_NAME}/releases/{}"
return 0
else
error "Health check failed on ${host}"
return 1
fi
}
# ============================================================================
# Rolling Deploy
# ============================================================================
rolling_deploy() {
local hosts_file="${SCRIPT_DIR}/hosts.${DEPLOY_ENV}.txt"
mapfile -t hosts < "${hosts_file}"
log "Deploying to ${#hosts[@]} hosts in ${DEPLOY_ENV}..."
for host in "${hosts[@]}"; do
# Skip empty lines and comments
[[ -z "${host}" || "${host}" =~ ^# ]] && continue
if deploy_to_host "${host}"; then
log "Waiting 10 seconds before next deployment..."
sleep 10
else
error "Deployment failed on ${host}, aborting rollout"
return 1
fi
done
success "Rolling deployment completed successfully"
}
# ============================================================================
# Main
# ============================================================================
show_help() {
cat <<EOF
Usage: $0 [OPTIONS] COMMAND
Commands:
build Build the application
deploy Build and deploy to ${DEPLOY_ENV}
rollback HOST Rollback to previous release on HOST
Options:
-e, --env ENV Deployment environment (default: production)
-h, --help Show this help message
Environment Variables:
APP_NAME Application name (default: myapp)
DEPLOY_ENV Deployment environment (default: production)
SSH_KEY Path to SSH private key
SSH_USER SSH username (default: deploy)
Examples:
$0 build
$0 deploy
$0 -e staging deploy
$0 rollback web01.example.com
EOF
}
main() {
local command=""
# Parse arguments
while [ $# -gt 0 ]; do
case "$1" in
-e|--env)
DEPLOY_ENV="$2"
shift 2
;;
-h|--help)
show_help
exit 0
;;
build|deploy|rollback)
command="$1"
shift
break
;;
*)
error "Unknown option: $1"
show_help
exit 1
;;
esac
done
# Execute command
case "${command}" in
build)
build_application
;;
deploy)
preflight_checks
build_application
rolling_deploy
;;
rollback)
if [ $# -eq 0 ]; then
error "Rollback requires a host argument"
exit 1
fi
# Rollback logic here
;;
*)
show_help
exit 1
;;
esac
}
main "$@"
8. 사용 예시¶
프로덕션에 배포¶
# Build and deploy
./deploy.sh deploy
# Deploy to staging environment
./deploy.sh -e staging deploy
호스트 파일¶
hosts.production.txt 생성:
web01.example.com
web02.example.com
web03.example.com
환경 파일¶
.env.deploy 생성:
APP_NAME=myapp
DEPLOY_ENV=production
SSH_KEY=/home/user/.ssh/deploy_key
SSH_USER=deploy
HEALTH_CHECK_URL=http://localhost:8080/health
KEEP_RELEASES=5
Docker 엔트리포인트¶
Dockerfile에서:
FROM node:18-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --production
COPY . .
COPY entrypoint.sh /usr/local/bin/
RUN chmod +x /usr/local/bin/entrypoint.sh
ENTRYPOINT ["entrypoint.sh"]
CMD ["node", "server.js"]
컨테이너 실행:
docker run \
-e DB_HOST=postgres \
-e DB_PORT=5432 \
-e REDIS_HOST=redis \
myapp:latest
9. 확장¶
1. Blue-Green 배포¶
두 개의 동일한 환경(blue와 green) 유지:
blue_green_deploy() {
local current_env=$(get_active_environment)
local target_env=$( [ "${current_env}" = "blue" ] && echo "green" || echo "blue" )
log "Current: ${current_env}, deploying to: ${target_env}"
# Deploy to inactive environment
deploy_to_environment "${target_env}"
# Switch traffic
switch_load_balancer_target "${target_env}"
success "Traffic switched to ${target_env}"
}
2. Canary 배포¶
먼저 서버의 하위 집합에 배포:
canary_deploy() {
local canary_hosts=("web01.example.com")
local production_hosts=("web02.example.com" "web03.example.com")
# Deploy to canary
for host in "${canary_hosts[@]}"; do
deploy_to_host "${host}"
done
# Monitor metrics
log "Canary deployed. Monitor metrics for 10 minutes..."
sleep 600
# If metrics are good, deploy to production
read -p "Proceed with full rollout? (y/n) " -n 1 -r
if [[ $REPLY =~ ^[Yy]$ ]]; then
rolling_deploy "${production_hosts[@]}"
fi
}
3. Slack 알림¶
배포 알림 전송:
send_slack_notification() {
local message="$1"
local webhook_url="${SLACK_WEBHOOK_URL}"
curl -X POST -H 'Content-type: application/json' \
--data "{\"text\":\"${message}\"}" \
"${webhook_url}"
}
# Usage
send_slack_notification "Deployment started to production"
rolling_deploy
send_slack_notification "Deployment to production completed successfully"
4. 배포 잠금¶
동시 배포 방지:
acquire_lock() {
local lock_file="/tmp/deploy.lock"
if [ -f "${lock_file}" ]; then
error "Another deployment is in progress"
return 1
fi
echo $$ > "${lock_file}"
trap "rm -f ${lock_file}" EXIT
}
# Usage
acquire_lock || exit 1
rolling_deploy