diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index 7d0d8bd..1cb6375 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -86,15 +86,16 @@ jobs: host: ${{ secrets.EC2_HOST }} username: ${{ secrets.EC2_USERNAME }} key: ${{ secrets.EC2_SSH_KEY }} - source: "docker-compose.yml,deploy.sh" + source: "docker/,scripts/deploy.sh" target: "~/deploy/" - - name: Deploy with docker-compose + - name: Deploy with blue-green strategy uses: appleboy/ssh-action@v1.0.3 with: host: ${{ secrets.EC2_HOST }} username: ${{ secrets.EC2_USERNAME }} key: ${{ secrets.EC2_SSH_KEY }} + command_timeout: 10m envs: >- DOCKER_IMAGE,BRANCH,SPRING_PROFILES_ACTIVE,DB_URL,DB_PASSWORD,REDIS_PASSWORD, DISCORD_WEBHOOK_URL,ANTHROPIC_API_KEY,OPENAI_API_KEY, @@ -103,8 +104,8 @@ jobs: JWT_SECRET,JWT_REDIRECT_URI,SERVER_DOMAIN script: | cd ~/deploy - chmod +x deploy.sh - ./deploy.sh + chmod +x scripts/deploy.sh + ./scripts/deploy.sh - name: Health check run: | diff --git a/Dockerfile b/Dockerfile index 9637874..06694ab 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,8 @@ # Java 17 FROM eclipse-temurin:17-jdk +RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/* + ARG JAR_FILE=build/libs/*.jar # jar 파일 볡사 diff --git a/deploy.sh b/deploy.sh deleted file mode 100644 index 409bbf9..0000000 --- a/deploy.sh +++ /dev/null @@ -1,37 +0,0 @@ -#!/bin/bash - -set -e - -echo "πŸš€ Deployment script started..." - -export DOCKER_IMAGE=${DOCKER_IMAGE} -export BRANCH=${BRANCH} -export SPRING_PROFILES_ACTIVE=${SPRING_PROFILES_ACTIVE} -export DB_URL=${DB_URL} -export DB_PASSWORD=${DB_PASSWORD} -export REDIS_HOST=${REDIS_HOST} -export REDIS_PORT=${REDIS_PORT} -export REDIS_PASSWORD=${REDIS_PASSWORD} -export DISCORD_WEBHOOK_URL=${DISCORD_WEBHOOK_URL} -export ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY} -export OPENAI_API_KEY=${OPENAI_API_KEY} -export KAKAO_REST_API_KEY=${KAKAO_REST_API_KEY} -export KAKAO_CLIENT_SECRET=${KAKAO_CLIENT_SECRET} -export APPLE_TEAM_ID=${APPLE_TEAM_ID} -export APPLE_KEY_ID=${APPLE_KEY_ID} -export APPLE_CLIENT_ID=${APPLE_CLIENT_ID} -export APPLE_PRIVATE_KEY=${APPLE_PRIVATE_KEY} -export JWT_SECRET=${JWT_SECRET} -export JWT_REDIRECT_URI=${JWT_REDIRECT_URI} -export SERVER_DOMAIN=${SERVER_DOMAIN} - -echo "🐳 Pulling latest docker image..." -docker compose pull - -echo "πŸš€ Starting application with docker-compose..." -docker compose up -d - -echo "🧹 Pruning old docker images..." -docker image prune -af - -echo "βœ… Deployment completed successfully!" \ No newline at end of file diff --git a/docker/docker-compose.blue.yml b/docker/docker-compose.blue.yml new file mode 100644 index 0000000..1ff8c01 --- /dev/null +++ b/docker/docker-compose.blue.yml @@ -0,0 +1,38 @@ +services: + app-blue: + image: ${DOCKER_IMAGE}:${BRANCH} + container_name: techfork-app-blue + restart: always + environment: + - JAVA_OPTS=-Xms2g -Xmx2g + - SPRING_PROFILES_ACTIVE=${SPRING_PROFILES_ACTIVE} + - DB_URL=${DB_URL} + - DB_USERNAME=techfork + - DB_PASSWORD=${DB_PASSWORD} + - REDIS_PASSWORD=${REDIS_PASSWORD} + - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY} + - OPENAI_API_KEY=${OPENAI_API_KEY} + - DISCORD_WEBHOOK_URL=${DISCORD_WEBHOOK_URL} + - KAKAO_REST_API_KEY=${KAKAO_REST_API_KEY} + - KAKAO_CLIENT_SECRET=${KAKAO_CLIENT_SECRET} + - APPLE_TEAM_ID=${APPLE_TEAM_ID} + - APPLE_KEY_ID=${APPLE_KEY_ID} + - APPLE_CLIENT_ID=${APPLE_CLIENT_ID} + - APPLE_PRIVATE_KEY_PATH=keys/AuthKey_${APPLE_KEY_ID}.p8 + - JWT_SECRET=${JWT_SECRET} + - JWT_REDIRECT_URI=${JWT_REDIRECT_URI} + - SERVER_DOMAIN=${SERVER_DOMAIN} + networks: + techfork-network: + aliases: + - app-blue + healthcheck: + test: ["CMD-SHELL", "curl -f http://localhost:8080/actuator/health || exit 1"] + interval: 10s + timeout: 5s + retries: 12 + start_period: 30s + +networks: + techfork-network: + external: true diff --git a/docker/docker-compose.green.yml b/docker/docker-compose.green.yml new file mode 100644 index 0000000..4f935d7 --- /dev/null +++ b/docker/docker-compose.green.yml @@ -0,0 +1,38 @@ +services: + app-green: + image: ${DOCKER_IMAGE}:${BRANCH} + container_name: techfork-app-green + restart: always + environment: + - JAVA_OPTS=-Xms2g -Xmx2g + - SPRING_PROFILES_ACTIVE=${SPRING_PROFILES_ACTIVE} + - DB_URL=${DB_URL} + - DB_USERNAME=techfork + - DB_PASSWORD=${DB_PASSWORD} + - REDIS_PASSWORD=${REDIS_PASSWORD} + - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY} + - OPENAI_API_KEY=${OPENAI_API_KEY} + - DISCORD_WEBHOOK_URL=${DISCORD_WEBHOOK_URL} + - KAKAO_REST_API_KEY=${KAKAO_REST_API_KEY} + - KAKAO_CLIENT_SECRET=${KAKAO_CLIENT_SECRET} + - APPLE_TEAM_ID=${APPLE_TEAM_ID} + - APPLE_KEY_ID=${APPLE_KEY_ID} + - APPLE_CLIENT_ID=${APPLE_CLIENT_ID} + - APPLE_PRIVATE_KEY_PATH=keys/AuthKey_${APPLE_KEY_ID}.p8 + - JWT_SECRET=${JWT_SECRET} + - JWT_REDIRECT_URI=${JWT_REDIRECT_URI} + - SERVER_DOMAIN=${SERVER_DOMAIN} + networks: + techfork-network: + aliases: + - app-green + healthcheck: + test: ["CMD-SHELL", "curl -f http://localhost:8080/actuator/health || exit 1"] + interval: 10s + timeout: 5s + retries: 12 + start_period: 30s + +networks: + techfork-network: + external: true diff --git a/docker-compose.yml b/docker/docker-compose.infra.yml similarity index 50% rename from docker-compose.yml rename to docker/docker-compose.infra.yml index b9750aa..dab7687 100644 --- a/docker-compose.yml +++ b/docker/docker-compose.infra.yml @@ -1,42 +1,7 @@ services: - app: - image: ${DOCKER_IMAGE}:${BRANCH} - container_name: tech-fork-app - restart: always - ports: - - "8080:8080" - environment: - - JAVA_OPTS=-Xms2g -Xmx2g - - SPRING_PROFILES_ACTIVE=${SPRING_PROFILES_ACTIVE} - - DB_URL=${DB_URL} - - DB_USERNAME=techfork - - DB_PASSWORD=${DB_PASSWORD} - - REDIS_PASSWORD=${REDIS_PASSWORD} - - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY} - - OPENAI_API_KEY=${OPENAI_API_KEY} - - DISCORD_WEBHOOK_URL=${DISCORD_WEBHOOK_URL} - - KAKAO_REST_API_KEY=${KAKAO_REST_API_KEY} - - KAKAO_CLIENT_SECRET=${KAKAO_CLIENT_SECRET} - - APPLE_TEAM_ID=${APPLE_TEAM_ID} - - APPLE_KEY_ID=${APPLE_KEY_ID} - - APPLE_CLIENT_ID=${APPLE_CLIENT_ID} - - APPLE_PRIVATE_KEY_PATH=keys/AuthKey_${APPLE_KEY_ID}.p8 - - JWT_SECRET=${JWT_SECRET} - - JWT_REDIRECT_URI=${JWT_REDIRECT_URI} - - SERVER_DOMAIN=${SERVER_DOMAIN} - networks: - - app-network - depends_on: - mysql: - condition: service_healthy - redis: - condition: service_started - elasticsearch: - condition: service_healthy - mysql: image: mysql:8.0 - container_name: tech-fork-mysql + container_name: techfork-mysql restart: always ports: - "3306:3306" @@ -53,21 +18,23 @@ services: volumes: - mysql-data:/var/lib/mysql networks: - - app-network + techfork-network: + aliases: + - mysql healthcheck: - test: [ "CMD", "mysqladmin", "ping", "-h", "localhost" ] + test: ["CMD", "mysqladmin", "ping", "-h", "localhost"] interval: 10s timeout: 5s retries: 5 redis: image: redis:7-alpine - container_name: tech-fork-redis + container_name: techfork-redis restart: always ports: - "6379:6379" command: - redis-server + redis-server --requirepass ${REDIS_PASSWORD} --maxmemory 1gb --maxmemory-policy allkeys-lru @@ -76,14 +43,16 @@ services: --rename-command KEYS "" --rename-command FLUSHALL "" --rename-command FLUSHDB "" - networks: - - app-network volumes: - redis-data:/data + networks: + techfork-network: + aliases: + - redis elasticsearch: image: docker.elastic.co/elasticsearch/elasticsearch:8.18.0 - container_name: tech-fork-elasticsearch + container_name: techfork-elasticsearch restart: always ports: - "9200:9200" @@ -92,10 +61,12 @@ services: - discovery.type=single-node - xpack.security.enabled=false - "ES_JAVA_OPTS=-Xms8g -Xmx8g" - networks: - - app-network volumes: - elasticsearch-data:/usr/share/elasticsearch/data + networks: + techfork-network: + aliases: + - elasticsearch ulimits: memlock: soft: -1 @@ -104,15 +75,29 @@ services: soft: 65536 hard: 65536 healthcheck: - test: [ "CMD-SHELL", "curl -f http://localhost:9200/_cluster/health || exit 1" ] + test: ["CMD-SHELL", "curl -f http://localhost:9200/_cluster/health || exit 1"] interval: 10s timeout: 5s retries: 30 start_period: 60s + nginx: + image: nginx:stable-alpine + container_name: techfork-nginx + restart: always + ports: + - "80:80" + volumes: + - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro + - ./nginx/conf.d:/etc/nginx/conf.d:ro + networks: + techfork-network: + aliases: + - nginx + networks: - app-network: - driver: bridge + techfork-network: + external: true volumes: mysql-data: diff --git a/docker-compose.local.yml b/docker/docker-compose.local.yml similarity index 100% rename from docker-compose.local.yml rename to docker/docker-compose.local.yml diff --git a/kibana.local.yml b/docker/kibana.local.yml similarity index 100% rename from kibana.local.yml rename to docker/kibana.local.yml diff --git a/docker/nginx/conf.d/default.conf b/docker/nginx/conf.d/default.conf new file mode 100644 index 0000000..aeb28a0 --- /dev/null +++ b/docker/nginx/conf.d/default.conf @@ -0,0 +1,45 @@ +# Cloudflare Real IP +set_real_ip_from 173.245.48.0/20; +set_real_ip_from 103.21.244.0/22; +set_real_ip_from 103.22.200.0/22; +set_real_ip_from 103.31.4.0/22; +set_real_ip_from 141.101.64.0/18; +set_real_ip_from 108.162.192.0/18; +set_real_ip_from 190.93.240.0/20; +set_real_ip_from 188.114.96.0/20; +set_real_ip_from 197.234.240.0/22; +set_real_ip_from 198.41.128.0/17; +set_real_ip_from 162.158.0.0/15; +set_real_ip_from 104.16.0.0/13; +set_real_ip_from 104.24.0.0/14; +set_real_ip_from 172.64.0.0/13; +set_real_ip_from 131.0.72.0/22; +real_ip_header CF-Connecting-IP; + +server { + listen 80; + server_name _; + + client_max_body_size 10M; + + access_log /var/log/nginx/tech-fork-access.log; + error_log /var/log/nginx/tech-fork-error.log; + + location / { + proxy_pass http://springapp; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + proxy_connect_timeout 60s; + proxy_send_timeout 60s; + proxy_read_timeout 60s; + } + + location /nginx-health { + access_log off; + return 200 "ok"; + add_header Content-Type text/plain; + } +} diff --git a/docker/nginx/conf.d/upstream.conf b/docker/nginx/conf.d/upstream.conf new file mode 100644 index 0000000..6c3bdb0 --- /dev/null +++ b/docker/nginx/conf.d/upstream.conf @@ -0,0 +1,3 @@ +upstream springapp { + server techfork-app-blue:8080 fail_timeout=0; +} diff --git a/docker/nginx/nginx.conf b/docker/nginx/nginx.conf new file mode 100644 index 0000000..ad96adb --- /dev/null +++ b/docker/nginx/nginx.conf @@ -0,0 +1,25 @@ +worker_processes auto; +error_log /var/log/nginx/error.log warn; +pid /tmp/nginx.pid; + +events { + worker_connections 1024; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + log_format main '$remote_addr - $remote_user [$time_local] "$request" ' + '$status $body_bytes_sent "$http_referer" ' + '"$http_user_agent" "$http_x_forwarded_for"'; + + access_log /var/log/nginx/access.log main; + + sendfile on; + tcp_nopush on; + keepalive_timeout 65; + gzip on; + + include /etc/nginx/conf.d/*.conf; +} diff --git a/infra/oracle/cloud-init.sh b/infra/oracle/cloud-init.sh index ec1c6ab..752892e 100644 --- a/infra/oracle/cloud-init.sh +++ b/infra/oracle/cloud-init.sh @@ -60,21 +60,6 @@ usermod -aG docker ubuntu echo "Docker version: $(docker --version)" -# =========================================== -# Nginx μ„€μΉ˜ -# =========================================== -echo "===== Install Nginx =====" -apt-get install -y nginx - -# =========================================== -# μ• ν”Œλ¦¬μΌ€μ΄μ…˜ 디렉토리 생성 -# =========================================== -echo "===== Create Application Directories =====" -mkdir -p /opt/tech-fork -mkdir -p /var/log/tech-fork -chown -R ubuntu:ubuntu /opt/tech-fork -chown -R ubuntu:ubuntu /var/log/tech-fork - # =========================================== # μ‹œμŠ€ν…œ νŠœλ‹ (Elasticsearch ꢌμž₯ μ„€μ •) # =========================================== @@ -84,219 +69,13 @@ echo "===== System Tuning for Elasticsearch =====" echo 'vm.max_map_count=262144' >> /etc/sysctl.conf sysctl -w vm.max_map_count=262144 -# 파일 λ””μŠ€ν¬λ¦½ν„° μ œν•œ 증가 -cat >> /etc/security/limits.conf < /etc/nginx/sites-available/tech-fork <<'NGINX' -upstream springapp { - server 127.0.0.1:8080 fail_timeout=0; -} - -server { - listen 80; - server_name ${domain_name} www.${domain_name} api.${domain_name}; - - client_max_body_size 10M; - - access_log /var/log/nginx/tech-fork-access.log; - error_log /var/log/nginx/tech-fork-error.log; - - # Cloudflare Real IP μ„€μ • - set_real_ip_from 173.245.48.0/20; - set_real_ip_from 103.21.244.0/22; - set_real_ip_from 103.22.200.0/22; - set_real_ip_from 103.31.4.0/22; - set_real_ip_from 141.101.64.0/18; - set_real_ip_from 108.162.192.0/18; - set_real_ip_from 190.93.240.0/20; - set_real_ip_from 188.114.96.0/20; - set_real_ip_from 197.234.240.0/22; - set_real_ip_from 198.41.128.0/17; - set_real_ip_from 162.158.0.0/15; - set_real_ip_from 104.16.0.0/13; - set_real_ip_from 104.24.0.0/14; - set_real_ip_from 172.64.0.0/13; - set_real_ip_from 131.0.72.0/22; - real_ip_header CF-Connecting-IP; - - location / { - proxy_pass http://springapp; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - - # νƒ€μž„μ•„μ›ƒ μ„€μ • (검색 응닡 μ‹œκ°„ κ³ λ €) - proxy_connect_timeout 60s; - proxy_send_timeout 60s; - proxy_read_timeout 60s; - } - - # Health Check μ—”λ“œν¬μΈνŠΈ - location /health { - proxy_pass http://springapp/actuator/health; - proxy_set_header Host $host; - } -} -NGINX - -# μ‚¬μ΄νŠΈ ν™œμ„±ν™” -ln -sf /etc/nginx/sites-available/tech-fork /etc/nginx/sites-enabled/ -rm -f /etc/nginx/sites-enabled/default - -# Nginx ν…ŒμŠ€νŠΈ 및 μž¬μ‹œμž‘ -nginx -t -systemctl restart nginx -systemctl enable nginx - -# =========================================== -# Docker Compose 파일 생성 (Oracle μ΅œμ ν™”) -# =========================================== -echo "===== Create Docker Compose File =====" -cat > /opt/tech-fork/docker-compose.yml <<'COMPOSE' -version: '3.8' - -services: - app: - image: $${DOCKER_IMAGE}:$${BRANCH} - container_name: tech-fork-app - restart: always - ports: - - "8080:8080" - environment: - - SPRING_PROFILES_ACTIVE=$${SPRING_PROFILES_ACTIVE} - - DB_URL=$${DB_URL} - - DB_USERNAME=$${DB_USERNAME} - - DB_PASSWORD=$${DB_PASSWORD} - - REDIS_HOST=redis - - REDIS_PORT=6379 - - REDIS_PASSWORD=$${REDIS_PASSWORD} - - ELASTICSEARCH_HOST=elasticsearch - - ELASTICSEARCH_PORT=9200 - - ANTHROPIC_API_KEY=$${ANTHROPIC_API_KEY} - - OPENAI_API_KEY=$${OPENAI_API_KEY} - networks: - - app-network - depends_on: - mysql: - condition: service_healthy - redis: - condition: service_started - elasticsearch: - condition: service_healthy - - mysql: - image: mysql:8.0 - container_name: tech-fork-mysql - restart: always - ports: - - "3306:3306" - environment: - - MYSQL_ROOT_PASSWORD=$${DB_PASSWORD} - - MYSQL_DATABASE=techblog - - MYSQL_USER=techfork - - MYSQL_PASSWORD=$${DB_PASSWORD} - - TZ=Asia/Seoul - command: - - --character-set-server=utf8mb4 - - --collation-server=utf8mb4_unicode_ci - - --innodb-buffer-pool-size=2G - volumes: - - mysql-data:/var/lib/mysql - networks: - - app-network - healthcheck: - test: ["CMD", "mysqladmin", "ping", "-h", "localhost"] - interval: 10s - timeout: 5s - retries: 5 - - redis: - image: redis:7-alpine - container_name: tech-fork-redis - restart: always - ports: - - "6379:6379" - command: > - redis-server - --requirepass $${REDIS_PASSWORD} - --maxmemory 1gb - --maxmemory-policy allkeys-lru - volumes: - - redis-data:/data - networks: - - app-network - - elasticsearch: - image: docker.elastic.co/elasticsearch/elasticsearch:8.18.0 - container_name: tech-fork-elasticsearch - restart: always - ports: - - "9200:9200" - - "9300:9300" - environment: - - discovery.type=single-node - - xpack.security.enabled=false - - "ES_JAVA_OPTS=-Xms8g -Xmx8g" - - cluster.routing.allocation.disk.threshold_enabled=true - - cluster.routing.allocation.disk.watermark.low=85% - - cluster.routing.allocation.disk.watermark.high=90% - volumes: - - elasticsearch-data:/usr/share/elasticsearch/data - networks: - - app-network - ulimits: - memlock: - soft: -1 - hard: -1 - nofile: - soft: 65536 - hard: 65536 - healthcheck: - test: ["CMD-SHELL", "curl -f http://localhost:9200/_cluster/health || exit 1"] - interval: 10s - timeout: 5s - retries: 30 - start_period: 120s - -networks: - app-network: - driver: bridge - -volumes: - mysql-data: - redis-data: - elasticsearch-data: -COMPOSE - -chown -R ubuntu:ubuntu /opt/tech-fork - # =========================================== -# ν™˜κ²½ λ³€μˆ˜ ν…œν”Œλ¦Ώ 생성 +# 배포 디렉토리 및 Docker λ„€νŠΈμ›Œν¬ 생성 # =========================================== -echo "===== Create Environment Template =====" -cat > /opt/tech-fork/.env.template </dev/null || true +chown -R ubuntu:ubuntu /home/ubuntu/deploy # =========================================== # λ°©ν™”λ²½ μ„€μ • (iptables) @@ -314,5 +93,4 @@ iptables -P OUTPUT ACCEPT echo "===== Cloud Init Completed: $(date) =====" echo "Next steps:" echo "1. SSH into the instance: ssh ubuntu@" -echo "2. Copy .env.template to .env and fill in the values" -echo "3. Run: cd /opt/tech-fork && docker compose up -d" +echo "2. Push to develop/main branch to trigger CD pipeline" \ No newline at end of file diff --git a/infra/oracle/main.tf b/infra/oracle/main.tf index 88bf784..fbf912a 100644 --- a/infra/oracle/main.tf +++ b/infra/oracle/main.tf @@ -205,7 +205,8 @@ resource "oci_core_instance" "app" { # μ‹€νŒ¨ μ‹œ μž¬μ‹œλ„ ν•„μš” lifecycle { ignore_changes = [ - source_details[0].source_id # 이미지 μ—…λ°μ΄νŠΈ λ¬΄μ‹œ + source_details[0].source_id, # 이미지 μ—…λ°μ΄νŠΈ λ¬΄μ‹œ + metadata # cloud-init λ³€κ²½ μ‹œ μΈμŠ€ν„΄μŠ€ μž¬μƒμ„± λ°©μ§€ ] } } \ No newline at end of file diff --git a/scripts/deploy.sh b/scripts/deploy.sh new file mode 100644 index 0000000..a4374fe --- /dev/null +++ b/scripts/deploy.sh @@ -0,0 +1,172 @@ +#!/bin/bash +set -euo pipefail + +DEPLOY_DIR="$(cd "$(dirname "$0")/.." && pwd)" +STATE_FILE="${DEPLOY_DIR}/.active-color" +LOCK_DIR="${DEPLOY_DIR}/.deploy.lock" +DOCKER_DIR="${DEPLOY_DIR}/docker" +COMPOSE_INFRA="${DOCKER_DIR}/docker-compose.infra.yml" +COMPOSE_BLUE="${DOCKER_DIR}/docker-compose.blue.yml" +COMPOSE_GREEN="${DOCKER_DIR}/docker-compose.green.yml" +UPSTREAM_CONF="${DOCKER_DIR}/nginx/conf.d/upstream.conf" + +HEALTH_CHECK_RETRIES=30 +HEALTH_CHECK_INTERVAL=5 + +# ========== Helper Functions ========== + +log() { + echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" +} + +cleanup() { + rmdir "$LOCK_DIR" 2>/dev/null || true +} + +get_active_color() { + if [ -f "$STATE_FILE" ]; then + cat "$STATE_FILE" + else + echo "" + fi +} + +get_target_color() { + if [ "$1" = "blue" ]; then + echo "green" + else + echo "blue" + fi +} + +get_compose_file() { + if [ "$1" = "blue" ]; then + echo "$COMPOSE_BLUE" + else + echo "$COMPOSE_GREEN" + fi +} + +get_container_name() { + echo "techfork-app-$1" +} + +health_check() { + local container="$1" + + log "Waiting for ${container} to become healthy..." + for i in $(seq 1 "$HEALTH_CHECK_RETRIES"); do + if docker exec techfork-nginx curl -sf "http://${container}:8080/actuator/health" > /dev/null 2>&1; then + log "Health check passed (attempt ${i}/${HEALTH_CHECK_RETRIES})" + return 0 + fi + log "Health check attempt ${i}/${HEALTH_CHECK_RETRIES} failed, waiting ${HEALTH_CHECK_INTERVAL}s..." + sleep "$HEALTH_CHECK_INTERVAL" + done + + log "ERROR: Health check failed after ${HEALTH_CHECK_RETRIES} attempts" + return 1 +} + +switch_upstream() { + local container + container=$(get_container_name "$1") + + log "Switching nginx upstream to ${container}..." + cat > "$UPSTREAM_CONF" < /dev/null 2>&1; then + log "ERROR: Nginx config test failed!" + return 1 + fi + + docker exec techfork-nginx nginx -s reload + log "Nginx reloaded successfully" +} + +# ========== Main ========== + +log "=== Blue-Green Deployment Started ===" + +# Lock +if ! mkdir "$LOCK_DIR" 2>/dev/null; then + log "ERROR: Another deployment is already running. Exiting." + exit 1 +fi +trap cleanup EXIT + +# Generate .env from SSH-injected environment variables +log "Writing .env file..." +env | grep -E '^(DOCKER_IMAGE|BRANCH|SPRING_PROFILES_ACTIVE|DB_|REDIS_|ANTHROPIC_|OPENAI_|DISCORD_|KAKAO_|APPLE_|JWT_|SERVER_)' > "${DOCKER_DIR}/.env" +chmod 600 "${DOCKER_DIR}/.env" + +# Step 1: Ensure Docker network exists +log "Ensuring Docker network exists..." +docker network create techfork-network 2>/dev/null || true + +# Step 2: Start infrastructure +log "Starting infrastructure services..." +docker compose -f "$COMPOSE_INFRA" up -d + +log "Waiting for Elasticsearch to be healthy..." +timeout 120 bash -c 'until docker exec techfork-elasticsearch curl -sf http://localhost:9200/_cluster/health > /dev/null 2>&1; do sleep 5; done' || { + log "WARNING: Elasticsearch health check timed out, proceeding anyway..." +} + +# Step 3: Determine colors +ACTIVE_COLOR=$(get_active_color) +if [ -z "$ACTIVE_COLOR" ]; then + log "No active color found (first deployment). Deploying blue." + TARGET_COLOR="blue" +else + TARGET_COLOR=$(get_target_color "$ACTIVE_COLOR") + log "Active: ${ACTIVE_COLOR}, Target: ${TARGET_COLOR}" +fi + +TARGET_COMPOSE=$(get_compose_file "$TARGET_COLOR") +TARGET_CONTAINER=$(get_container_name "$TARGET_COLOR") + +# Step 4: Pull new image +log "Pulling latest image for ${TARGET_COLOR}..." +docker compose -f "$TARGET_COMPOSE" pull + +# Step 5: Start target container +log "Starting ${TARGET_COLOR} container..." +docker compose -f "$TARGET_COMPOSE" up -d + +# Step 6: Health check +if health_check "$TARGET_CONTAINER"; then + log "Target container ${TARGET_CONTAINER} is healthy" +else + log "ROLLBACK: Stopping failed ${TARGET_COLOR} container..." + docker compose -f "$TARGET_COMPOSE" down + log "Rollback complete. Active deployment unchanged: ${ACTIVE_COLOR:-none}" + exit 1 +fi + +# Step 7: Switch nginx upstream +switch_upstream "$TARGET_COLOR" + +# Step 8: Grace period for in-flight requests +sleep 10 + +# Step 9: Stop old container +if [ -n "$ACTIVE_COLOR" ]; then + OLD_COMPOSE=$(get_compose_file "$ACTIVE_COLOR") + log "Stopping old ${ACTIVE_COLOR} container..." + docker compose -f "$OLD_COMPOSE" down +fi + +# Step 10: Save active color +echo "$TARGET_COLOR" > "$STATE_FILE" +log "Active color is now: ${TARGET_COLOR}" + +# Step 11: Cleanup +log "Pruning unused Docker images..." +docker image prune -af + +log "=== Blue-Green Deployment Completed Successfully ==="