Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 21 additions & 18 deletions .github/workflows/deploy-main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ on:
branches:
- main

concurrency:
group: deploy-production
cancel-in-progress: false

permissions:
contents: read
packages: write
Expand Down Expand Up @@ -69,6 +73,12 @@ jobs:
- self-hosted

steps:
- name: Checkout deploy scripts
uses: actions/checkout@v4
with:
sparse-checkout: |
scripts

- name: Check version to deploy
run: |
echo "Deploying target version: ${{ needs.setup.outputs.docker_tag }}"
Expand All @@ -77,23 +87,16 @@ jobs:
run: |
echo "${{ secrets.DOCKER_PASSWORD }}" | sudo docker login --username ${{ secrets.DOCKER_USERNAME }} --password-stdin

- name: Stop existing container and pull latest image
- name: Run Blue-Green Deploy
env:
DOCKER_IMAGE: godqhr721/moa_server
run: |
if sudo docker inspect moa-server &>/dev/null; then
sudo docker stop moa-server || true
sudo docker rm -f moa-server || true
sudo docker image prune -af || true
fi

sudo docker pull godqhr721/moa_server:${{ needs.setup.outputs.docker_tag }}

- name: Run container
chmod +x scripts/deploy.sh
sudo -E bash scripts/deploy.sh "${{ needs.setup.outputs.docker_tag }}"

Comment on lines +90 to +96
- name: Verify deployment
run: |
sudo docker run \
-v /home/ubuntu/app/logs:/app/logs \
--name moa-server \
--add-host host.docker.internal:host-gateway \
--restart unless-stopped \
-p 8080:8080 \
-e SPRING_PROFILES_ACTIVE=prod \
-d godqhr721/moa_server:${{ needs.setup.outputs.docker_tag }}
echo "=== Deployment Status ==="
echo "Active color: $(cat /home/ubuntu/app/active-color)"
echo "Last 5 deployments:"
tail -5 /home/ubuntu/app/deploy-history
46 changes: 46 additions & 0 deletions .github/workflows/rollback.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
name: Manual Rollback

on:
workflow_dispatch:
inputs:
docker_tag:
description: 'Rollback할 Docker 태그 (git SHA). 비우면 직전 성공 버전으로 롤백'
required: false
type: string

concurrency:
group: deploy-production
cancel-in-progress: false

permissions:
contents: read

jobs:
rollback:
runs-on:
- self-hosted

steps:
- name: Checkout deploy scripts
uses: actions/checkout@v4
with:
sparse-checkout: |
scripts

- name: Log in to Docker Hub
run: |
echo "${{ secrets.DOCKER_PASSWORD }}" | sudo docker login --username ${{ secrets.DOCKER_USERNAME }} --password-stdin

- name: Execute Rollback
env:
DOCKER_IMAGE: godqhr721/moa_server
run: |
chmod +x scripts/rollback.sh scripts/deploy.sh
sudo -E bash scripts/rollback.sh "${{ github.event.inputs.docker_tag }}"

- name: Verify rollback
run: |
echo "=== Rollback Status ==="
echo "Active color: $(cat /home/ubuntu/app/active-color)"
echo "Last 5 deployments:"
tail -5 /home/ubuntu/app/deploy-history
181 changes: 181 additions & 0 deletions scripts/deploy.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
#!/bin/bash
set -euo pipefail

# ============================================================
# MOA Blue-Green 무중단 배포 스크립트
# 사용법: ./deploy.sh <docker_tag>
# ============================================================

DOCKER_TAG=${1:?"Usage: ./deploy.sh <docker_tag>"}
DOCKER_IMAGE=${DOCKER_IMAGE:-"godqhr721/moa_server"}
IMAGE="${DOCKER_IMAGE}:${DOCKER_TAG}"
APP_DIR="/home/ubuntu/app"
STATE_FILE="${APP_DIR}/active-color"
DEPLOY_HISTORY="${APP_DIR}/deploy-history"
NGINX_UPSTREAM_CONF="/etc/nginx/conf.d/moa-upstream.conf"
LOCK_FILE="${APP_DIR}/deploy.lock"
Comment on lines +12 to +16
SMOKE_TEST_HOST="moa-official.kr"
HEALTH_CHECK_MAX_RETRY=30
HEALTH_CHECK_INTERVAL=2

# ============================================================
# flock: 동시 배포 방지
# ============================================================
exec 200>"${LOCK_FILE}"
if ! flock -n 200; then
echo "[ERROR] Another deployment is already running. Exiting."
exit 1
fi

# ============================================================
# Step 1: 현재 활성 컬러 확인
# ============================================================
if [ -f "$STATE_FILE" ] && [ "$(cat "$STATE_FILE")" = "blue" ]; then
CURRENT="blue"
NEXT="green"
CURRENT_PORT=8080
NEXT_PORT=8081
else
CURRENT="green"
NEXT="blue"
CURRENT_PORT=8081
NEXT_PORT=8080
fi
Comment on lines +33 to +43

echo "========================================"
echo " Current: ${CURRENT} (:${CURRENT_PORT})"
echo " Next: ${NEXT} (:${NEXT_PORT})"
echo " Image: ${IMAGE}"
echo "========================================"

# ============================================================
# Step 2: 새 이미지 Pull
# ============================================================
echo "[Step 2] Pulling image: ${IMAGE}"
sudo docker pull "${IMAGE}"

# ============================================================
# Step 3: 기존 대기 컨테이너 정리 후 새 컨테이너 시작
# ============================================================
NEXT_CONTAINER="moa-${NEXT}"

if sudo docker inspect "${NEXT_CONTAINER}" &>/dev/null; then
echo "[Step 3] Removing existing standby container: ${NEXT_CONTAINER}"
sudo docker stop "${NEXT_CONTAINER}" 2>/dev/null || true
sudo docker rm "${NEXT_CONTAINER}" 2>/dev/null || true
fi

echo "[Step 3] Starting new container: ${NEXT_CONTAINER} on port ${NEXT_PORT}"
sudo docker run \
-v ${APP_DIR}/logs:/app/logs \
--name "${NEXT_CONTAINER}" \
--add-host host.docker.internal:host-gateway \
--restart unless-stopped \
-p "${NEXT_PORT}:8080" \
-e SPRING_PROFILES_ACTIVE=prod \
-e DEPLOY_COLOR="${NEXT}" \
-e APP_VERSION="${DOCKER_TAG}" \
-d "${IMAGE}"

# ============================================================
# Step 4: Health Check (최대 60초 대기)
# ============================================================
echo "[Step 4] Waiting for health check on port ${NEXT_PORT}..."

for i in $(seq 1 ${HEALTH_CHECK_MAX_RETRY}); do
HEALTH=$(curl -sf "http://localhost:${NEXT_PORT}/actuator/health" 2>/dev/null || true)

if echo "${HEALTH}" | grep -q '"status":"UP"'; then
echo "[Step 4] Health check PASSED (attempt ${i}/${HEALTH_CHECK_MAX_RETRY})"
break
fi

if [ "${i}" -eq "${HEALTH_CHECK_MAX_RETRY}" ]; then
echo "[Step 4] Health check FAILED after ${HEALTH_CHECK_MAX_RETRY} attempts"
echo "[Rollback] Stopping failed container: ${NEXT_CONTAINER}"
sudo docker stop "${NEXT_CONTAINER}" 2>/dev/null || true
sudo docker rm "${NEXT_CONTAINER}" 2>/dev/null || true
echo "[Rollback] Current container (${CURRENT}) is still active. No downtime occurred."

echo "$(date '+%Y-%m-%d %H:%M:%S')|${NEXT}|${DOCKER_TAG}|FAILED_HEALTH_CHECK" >> "${DEPLOY_HISTORY}"
exit 1
fi

echo " ... attempt ${i}/${HEALTH_CHECK_MAX_RETRY} - waiting ${HEALTH_CHECK_INTERVAL}s"
sleep ${HEALTH_CHECK_INTERVAL}
done

# ============================================================
# Step 5: Nginx upstream 전환
# ============================================================
echo "[Step 5] Switching Nginx upstream to ${NEXT} (:${NEXT_PORT})"

sudo tee "${NGINX_UPSTREAM_CONF}" > /dev/null <<EOF
upstream moa_backend {
server 127.0.0.1:${NEXT_PORT};
}
EOF

sudo nginx -t
sudo nginx -s reload
echo "[Step 5] Nginx reloaded successfully"

# ============================================================
# Step 6: Smoke Test (nginx 경유 확인)
# ============================================================
echo "[Step 6] Running smoke test via Nginx (HTTPS)..."
sleep 2

SMOKE_RESULT=$(curl -sf \
--resolve "${SMOKE_TEST_HOST}:443:127.0.0.1" \
"https://${SMOKE_TEST_HOST}/api/v1/deploy-info" 2>/dev/null || true)

if echo "${SMOKE_RESULT}" | grep -q "\"version\":\"${DOCKER_TAG}\""; then
echo "[Step 6] Smoke test PASSED - new version confirmed via Nginx"
else
echo "[Step 6] Smoke test FAILED - reverting Nginx to ${CURRENT} (:${CURRENT_PORT})"

sudo tee "${NGINX_UPSTREAM_CONF}" > /dev/null <<EOF
upstream moa_backend {
server 127.0.0.1:${CURRENT_PORT};
}
EOF
sudo nginx -t
sudo nginx -s reload

sudo docker stop "${NEXT_CONTAINER}" 2>/dev/null || true
sudo docker rm "${NEXT_CONTAINER}" 2>/dev/null || true

echo "[Rollback] Reverted to ${CURRENT}. Smoke test response: ${SMOKE_RESULT}"
echo "$(date '+%Y-%m-%d %H:%M:%S')|${NEXT}|${DOCKER_TAG}|FAILED_SMOKE_TEST" >> "${DEPLOY_HISTORY}"
exit 2
fi

# ============================================================
# Step 7: 이전 컨테이너 종료 (Graceful Shutdown)
# ============================================================
CURRENT_CONTAINER="moa-${CURRENT}"

if sudo docker inspect "${CURRENT_CONTAINER}" &>/dev/null; then
echo "[Step 7] Stopping previous container: ${CURRENT_CONTAINER} (graceful, 70s timeout)"
sudo docker stop --time 70 "${CURRENT_CONTAINER}" 2>/dev/null || true
sudo docker rm "${CURRENT_CONTAINER}" 2>/dev/null || true
fi

# ============================================================
# Step 8: 상태 파일 + 배포 이력 업데이트
# ============================================================
echo "${NEXT}" | sudo tee "${STATE_FILE}" > /dev/null
echo "$(date '+%Y-%m-%d %H:%M:%S')|${NEXT}|${DOCKER_TAG}|SUCCESS" >> "${DEPLOY_HISTORY}"

# ============================================================
# Step 9: Docker 이미지 정리
# ============================================================
echo "[Step 9] Cleaning up unused Docker images..."
sudo docker image prune -f

echo "========================================"
echo " Deploy complete!"
echo " Active: ${NEXT} (:${NEXT_PORT})"
echo " Version: ${DOCKER_TAG}"
echo "========================================"
46 changes: 46 additions & 0 deletions scripts/rollback.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
#!/bin/bash
set -euo pipefail

# ============================================================
# MOA 수동 롤백 스크립트
# 사용법:
# ./rollback.sh <docker_tag> → 지정한 버전으로 배포
# ./rollback.sh → 직전 성공 버전으로 배포
# ============================================================

DEPLOY_HISTORY="/home/ubuntu/app/deploy-history"
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"

if [ -n "${1:-}" ]; then
ROLLBACK_TAG="$1"
echo "[Rollback] Target version specified: ${ROLLBACK_TAG}"
else
if [ ! -f "${DEPLOY_HISTORY}" ]; then
echo "[ERROR] Deploy history file not found: ${DEPLOY_HISTORY}"
echo "Cannot determine previous version. Please specify a docker tag."
echo "Usage: ./rollback.sh <docker_tag>"
exit 1
fi

CURRENT_TAG=$(grep "|SUCCESS" "${DEPLOY_HISTORY}" | tail -1 | cut -d'|' -f3)
ROLLBACK_TAG=$(grep "|SUCCESS" "${DEPLOY_HISTORY}" | grep -v "|${CURRENT_TAG}|" | tail -1 | cut -d'|' -f3)

if [ -z "${ROLLBACK_TAG}" ]; then
echo "[ERROR] No previous successful deployment found in history."
echo "Deploy history contents:"
cat "${DEPLOY_HISTORY}"
exit 1
fi

echo "[Rollback] Previous successful version found: ${ROLLBACK_TAG}"
echo " (Current version: ${CURRENT_TAG})"
fi

echo "========================================"
echo " Rolling back to: ${ROLLBACK_TAG}"
echo "========================================"
echo ""

# deploy.sh를 재호출하여 롤백 실행
# → 동일한 health check, smoke test, graceful shutdown 안전장치 적용
exec bash "${SCRIPT_DIR}/deploy.sh" "${ROLLBACK_TAG}"
24 changes: 24 additions & 0 deletions src/main/kotlin/com/moa/controller/DeployInfoController.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package com.moa.controller

import com.moa.common.response.ApiResponse
import com.moa.service.dto.DeployInfoResponse
import io.swagger.v3.oas.annotations.tags.Tag
import org.springframework.beans.factory.annotation.Value
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
import java.time.Instant

@Tag(name = "Deploy Info", description = "배포 정보 API — Blue-Green 검증용")
@RestController
@RequestMapping("/api/v1/deploy-info")
class DeployInfoController(
@Value("\${app.version:dev}") private val version: String,
@Value("\${app.deploy-color:local}") private val color: String,
) {
private val startedAt: Instant = Instant.now()

@GetMapping
fun deployInfo() =
ApiResponse.success(DeployInfoResponse.of(version, color, startedAt))
}
33 changes: 33 additions & 0 deletions src/main/kotlin/com/moa/service/dto/DeployInfoResponse.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package com.moa.service.dto

import java.time.Duration
import java.time.Instant

data class DeployInfoResponse(
val version: String,
val color: String,
val startedAt: String,
val uptime: String,
val uptimeSeconds: Long,
) {
companion object {
fun of(version: String, color: String, startedAt: Instant): DeployInfoResponse {
val duration = Duration.between(startedAt, Instant.now())
return DeployInfoResponse(
version = version,
color = color,
startedAt = startedAt.toString(),
uptime = formatDuration(duration),
uptimeSeconds = duration.toSeconds(),
)
}

private fun formatDuration(duration: Duration): String =
String.format(
"%dh %dm %ds",
duration.toHours(),
duration.toMinutesPart(),
duration.toSecondsPart(),
)
}
}
Loading
Loading