Merge pull request #203 from prgrms-be-devcourse/feat/202-모니터링 #42
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: deploy | |
| # ========================= | |
| # 전역 환경변수 | |
| # ========================= | |
| env: | |
| IMAGE_REPOSITORY: team01-backend # GHCR 이미지 리포지토리명 | |
| CONTAINER_1_NAME: app_blue # Blue 슬롯 컨테이너명 (고정) | |
| CONTAINER_2_NAME: app_green # Green 슬롯 컨테이너명 (고정) | |
| CONTAINER_PORT: 8080 # Spring Boot 포트 | |
| EC2_INSTANCE_TAG_NAME: devcos-team01-ec2-main | |
| DOCKER_NETWORK: common # Docker 네트워크 | |
| NPM_BASE_URL: https://127.0.0.1:81 # NPMplus 관리 API (EC2 내부에서 접근) | |
| concurrency: | |
| group: ${{ github.workflow }}-${{ github.ref }} | |
| cancel-in-progress: false | |
| on: | |
| push: | |
| paths: | |
| - ".github/workflows/*.yml" | |
| - ".github/workflows/*.yaml" | |
| - "src/**" | |
| - "build.gradle.kts" | |
| - "settings.gradle.kts" | |
| - "Dockerfile" | |
| branches: | |
| - dev # 테스트용. 완료 후 main으로 변경 | |
| permissions: | |
| contents: write | |
| packages: write | |
| defaults: | |
| run: | |
| shell: bash | |
| jobs: | |
| # --------------------------------------------------------- | |
| # 1) 다음 버전 계산 (dry-run, 태그 생성 X) | |
| # --------------------------------------------------------- | |
| calculateTag: | |
| runs-on: ubuntu-latest | |
| outputs: | |
| tag_name: ${{ steps.dry_run.outputs.new_tag }} | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - name: 다음 버전 태그 계산 (dry-run) | |
| id: dry_run | |
| uses: mathieudutour/github-tag-action@v6.2 | |
| with: | |
| github_token: ${{ secrets.GITHUB_TOKEN }} | |
| dry_run: true | |
| # --------------------------------------------------------- | |
| # 2) 도커 이미지 빌드/푸시 | |
| # --------------------------------------------------------- | |
| buildImageAndPush: | |
| name: 도커 이미지 빌드와 푸시 | |
| needs: calculateTag | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - name: Docker Buildx 설치 | |
| uses: docker/setup-buildx-action@v3 | |
| - name: 레지스트리 로그인 | |
| uses: docker/login-action@v3 | |
| with: | |
| registry: ghcr.io | |
| username: ${{ github.actor }} | |
| password: ${{ secrets.GITHUB_TOKEN }} | |
| - name: 저장소 소유자명 소문자 변환 | |
| run: echo "OWNER_LC=${OWNER,,}" >> "${GITHUB_ENV}" | |
| env: | |
| OWNER: "${{ github.repository_owner }}" | |
| - name: 빌드 앤 푸시 | |
| uses: docker/build-push-action@v6 | |
| with: | |
| context: . | |
| push: true | |
| cache-from: type=gha | |
| cache-to: type=gha,mode=max | |
| tags: | | |
| ghcr.io/${{ env.OWNER_LC }}/${{ env.IMAGE_REPOSITORY }}:${{ needs.calculateTag.outputs.tag_name }} | |
| ghcr.io/${{ env.OWNER_LC }}/${{ env.IMAGE_REPOSITORY }}:latest | |
| # --------------------------------------------------------- | |
| # 3) Blue/Green 무중단 배포 (단일 EC2 + NPMplus 스위치) | |
| # --------------------------------------------------------- | |
| deploy: | |
| name: Blue/Green 무중단 배포 | |
| runs-on: ubuntu-latest | |
| needs: [calculateTag, buildImageAndPush] | |
| steps: | |
| - uses: aws-actions/configure-aws-credentials@v4 | |
| with: | |
| aws-region: ${{ secrets.AWS_REGION }} | |
| aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} | |
| aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} | |
| # Name 태그로 EC2 인스턴스 ID 조회 | |
| - name: 인스턴스 ID 가져오기 | |
| run: | | |
| INSTANCE_ID=$(aws ec2 describe-instances \ | |
| --filters "Name=tag:Name,Values=${{ env.EC2_INSTANCE_TAG_NAME }}" "Name=instance-state-name,Values=running" \ | |
| --query "Reservations[].Instances[].InstanceId" --output text) | |
| [[ -n "${INSTANCE_ID}" && "${INSTANCE_ID}" != "None" ]] || { echo "No running instance found"; exit 1; } | |
| echo "INSTANCE_ID=${INSTANCE_ID}" >> "${GITHUB_ENV}" | |
| # .env를 base64로 인코딩해 SSM 스크립트에 안전하게 주입 | |
| - name: .env base64 인코딩 | |
| env: | |
| DOT_ENV: ${{ secrets.DOT_ENV }} | |
| run: | | |
| echo "DOT_ENV_B64=$(printf '%s' "${DOT_ENV}" | base64 -w0)" >> "${GITHUB_ENV}" | |
| # SSM으로 Blue/Green 스위치 수행 | |
| - name: AWS SSM 배포 | |
| run: | | |
| DEPLOY_SCRIPT=$(cat << 'SCRIPT_EOF' | |
| set -Eeuo pipefail | |
| # 실행 로그 (타임스탬프 부착) | |
| SCRIPT_TS="$(date +%Y%m%d_%H%M%S)" | |
| LOG="/tmp/ssm-${SCRIPT_TS}.log" | |
| exec > >(awk '{ fflush(); print strftime("[%Y-%m-%d %H:%M:%S]"), $0 }' | tee -a "$LOG") | |
| exec 2> >(awk '{ fflush(); print strftime("[%Y-%m-%d %H:%M:%S]"), $0 }' | tee -a "$LOG" >&2) | |
| # jq 설치 확인 (없으면 설치) | |
| which jq || apt-get install -y jq -qq | |
| # /etc/environment 로드 | |
| # PASSWORD_1, RDS_HOST, REDIS_HOST, GITHUB_ACCESS_TOKEN_1_OWNER 등 포함 | |
| set -a | |
| while IFS='=' read -r key value; do | |
| [[ -z "$key" || "$key" =~ ^# ]] && continue | |
| printf -v "$key" '%s' "$value" | |
| done < /etc/environment | |
| set +a | |
| OWNER_LC="${{ github.repository_owner }}" | |
| OWNER_LC="${OWNER_LC,,}" | |
| IMAGE_TAG="${{ needs.calculateTag.outputs.tag_name }}" | |
| IMAGE_REPOSITORY="${{ env.IMAGE_REPOSITORY }}" | |
| IMAGE="ghcr.io/${OWNER_LC}/${IMAGE_REPOSITORY}:${IMAGE_TAG}" | |
| SLOT1="${{ env.CONTAINER_1_NAME }}" | |
| SLOT2="${{ env.CONTAINER_2_NAME }}" | |
| PORT_IN="${{ env.CONTAINER_PORT }}" | |
| NET="${{ env.DOCKER_NETWORK }}" | |
| NPM_BASE_URL="${{ env.NPM_BASE_URL }}" | |
| APP_1_DOMAIN="${{ secrets.APP_DOMAIN }}" | |
| echo "🔹 Use image: ${IMAGE}" | |
| docker pull "${IMAGE}" | |
| # .env 파일 생성 (Spring Boot가 /app/.env에서 읽음) | |
| # RDS, Redis는 AWS 관리형 → DOT_ENV secret에 엔드포인트 포함 | |
| printf '%s' "${{ env.DOT_ENV_B64 }}" | base64 -d > /tmp/.env | |
| cp /tmp/.env "/tmp/.env.$(date +%Y_%m_%d__%H%M%S)" | |
| chmod 600 /tmp/.env | |
| # NPMplus API 로그인 (PASSWORD_1은 /etc/environment에서 로드) | |
| COOKIE_JAR="/tmp/npm_cookies.txt" | |
| LOGIN_CODE=$(curl -sk -o /dev/null -w "%{http_code}" -c "${COOKIE_JAR}" \ | |
| -X POST "${NPM_BASE_URL}/api/tokens" \ | |
| -H "Content-Type: application/json" \ | |
| -d "$(jq -nc --arg id "admin@npm.com" --arg pw "${PASSWORD_1:-}" '{identity:$id, secret:$pw}')") | |
| [[ "${LOGIN_CODE}" == "200" ]] || { echo "NPM login failed (HTTP ${LOGIN_CODE})"; exit 1; } | |
| [[ -n "${APP_1_DOMAIN:-}" ]] || { echo "APP_DOMAIN secret is empty"; exit 1; } | |
| # 프록시 호스트 ID 조회 (없으면 자동 등록) | |
| PROXY_ID=$(curl -sk -b "${COOKIE_JAR}" -X GET "${NPM_BASE_URL}/api/nginx/proxy-hosts" \ | |
| | jq ".[] | select(.domain_names[]==\"${APP_1_DOMAIN}\") | .id") | |
| if [[ -z "${PROXY_ID}" || "${PROXY_ID}" == "null" ]]; then | |
| echo "📝 Proxy host not found for ${APP_1_DOMAIN}, creating..." | |
| PROXY_ID=$(curl -sk -b "${COOKIE_JAR}" -X POST "${NPM_BASE_URL}/api/nginx/proxy-hosts" \ | |
| -H "Content-Type: application/json" \ | |
| -d "$(jq -nc \ | |
| --arg domain "${APP_1_DOMAIN}" \ | |
| --arg host "${SLOT1}" \ | |
| --argjson port ${PORT_IN} \ | |
| '{ | |
| domain_names: [$domain], | |
| forward_scheme: "http", | |
| forward_host: $host, | |
| forward_port: $port, | |
| access_list_id: 0, | |
| certificate_id: 0, | |
| ssl_forced: false, | |
| http2_support: true, | |
| hsts_enabled: false, | |
| hsts_subdomains: false, | |
| block_exploits: true, | |
| caching_enabled: false, | |
| allow_websocket_upgrade: true, | |
| advanced_config: "", | |
| locations: [], | |
| meta: {letsencrypt_agree: false, dns_challenge: false} | |
| }')" | jq '.id') | |
| [[ -n "${PROXY_ID}" && "${PROXY_ID}" != "null" ]] || { echo "❌ Failed to create proxy host"; exit 1; } | |
| echo "✅ Created proxy host ID: ${PROXY_ID}" | |
| fi | |
| # 현재 upstream(컨테이너명) 조회 | |
| CURRENT_HOST=$(curl -sk -b "${COOKIE_JAR}" -X GET "${NPM_BASE_URL}/api/nginx/proxy-hosts/${PROXY_ID}" \ | |
| | jq -r '.forward_host') | |
| echo "🔎 CURRENT_HOST: ${CURRENT_HOST:-none}" | |
| # Blue/Green 역할 판정 | |
| if [[ "${CURRENT_HOST:-}" == "${SLOT1}" ]]; then | |
| BLUE="${SLOT1}"; GREEN="${SLOT2}" | |
| elif [[ "${CURRENT_HOST:-}" == "${SLOT2}" ]]; then | |
| BLUE="${SLOT2}"; GREEN="${SLOT1}" | |
| else | |
| BLUE="none"; GREEN="${SLOT1}" # 초기 배포 | |
| fi | |
| echo "🎨 role -> blue(now): ${BLUE}, green(next): ${GREEN}" | |
| # Green 컨테이너 재기동 | |
| # --network common: NPMplus, Prometheus와 같은 네트워크 | |
| # RDS/Redis는 AWS 관리형이므로 별도 컨테이너 불필요 | |
| docker rm -f "${GREEN}" >/dev/null 2>&1 || true | |
| echo "🚀 run new container → ${GREEN}" | |
| docker run -d --name "${GREEN}" \ | |
| --restart unless-stopped \ | |
| --network "${NET}" \ | |
| -e TZ=Asia/Seoul \ | |
| --env-file /tmp/.env \ | |
| "${IMAGE}" | |
| # 헬스체크 (/actuator/health 200 OK까지 대기) | |
| echo "⏱ health-check: ${GREEN}" | |
| TIMEOUT=120 | |
| INTERVAL=3 | |
| ELAPSED=8 | |
| sleep 8 | |
| CONTAINER_IP=$(docker inspect --format '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "${GREEN}") | |
| CODE=000 | |
| while (( ELAPSED < TIMEOUT )); do | |
| CODE=$(curl -s -o /dev/null -w "%{http_code}" "http://${CONTAINER_IP}:${PORT_IN}/actuator/health" || echo 000) | |
| [[ "${CODE}" == "200" ]] && { echo "✅ ${GREEN} healthy"; break; } | |
| sleep "${INTERVAL}" | |
| ELAPSED=$((ELAPSED + INTERVAL)) | |
| done | |
| [[ "${CODE}" == "200" ]] || { echo "❌ ${GREEN} health failed"; docker logs --tail=200 "${GREEN}" || true; docker rm -f "${GREEN}" || true; exit 1; } | |
| # NPMplus 업스트림 전환 | |
| CURRENT_CFG=$(curl -sk -b "${COOKIE_JAR}" -X GET "${NPM_BASE_URL}/api/nginx/proxy-hosts/${PROXY_ID}") | |
| NEW_CFG=$(echo "${CURRENT_CFG}" | jq \ | |
| --arg host "${GREEN}" \ | |
| --argjson port ${PORT_IN} \ | |
| '{ | |
| access_list_id, advanced_config, allow_websocket_upgrade, | |
| block_exploits, caching_enabled, certificate_id, domain_names, | |
| forward_host: $host, forward_port: $port, forward_scheme, | |
| hsts_enabled, hsts_subdomains, http2_support, locations, | |
| meta, ssl_forced | |
| }') | |
| SWITCH_CODE=$(curl -sk -b "${COOKIE_JAR}" -o /dev/null -w "%{http_code}" \ | |
| -X PUT "${NPM_BASE_URL}/api/nginx/proxy-hosts/${PROXY_ID}" \ | |
| -H "Content-Type: application/json" \ | |
| -d "${NEW_CFG}") | |
| [[ "${SWITCH_CODE}" == "200" ]] || { echo "❌ NPM switch failed (HTTP ${SWITCH_CODE})"; exit 1; } | |
| echo "🔁 switch upstream → ${GREEN}:${PORT_IN}" | |
| # 이전 Blue 종료 | |
| if [[ "${BLUE}" != "none" ]]; then | |
| docker stop "${BLUE}" >/dev/null 2>&1 || true | |
| docker rm "${BLUE}" >/dev/null 2>&1 || true | |
| echo "🧹 removed old blue: ${BLUE}" | |
| fi | |
| # 이미지 정리 (현재 태그/latest 제외) | |
| { | |
| docker images --format '{{.Repository}}:{{.Tag}}' \ | |
| | grep -F "ghcr.io/${OWNER_LC}/${IMAGE_REPOSITORY}:" \ | |
| | grep -v -F ":${IMAGE_TAG}" \ | |
| | grep -v -F ":latest" \ | |
| | grep -v -F ":<none>" \ | |
| | xargs -r docker rmi | |
| } || true | |
| echo "🏁 Blue/Green switch complete. now blue = ${GREEN}" | |
| SCRIPT_EOF | |
| ) | |
| PARAMS=$(jq -nc --arg s "$DEPLOY_SCRIPT" '{"commands":["bash << '\''ENDBASH'\''", $s, "ENDBASH"]}') | |
| COMMAND_ID=$(aws ssm send-command \ | |
| --region "${{ secrets.AWS_REGION }}" \ | |
| --instance-ids "${{ env.INSTANCE_ID }}" \ | |
| --document-name "AWS-RunShellScript" \ | |
| --parameters "$PARAMS" \ | |
| --query "Command.CommandId" \ | |
| --output text) | |
| echo "SSM Command ID: ${COMMAND_ID}" | |
| # 완료까지 대기 (최대 15분, 10초 간격) | |
| WAIT_MAX=900 | |
| WAIT_INTERVAL=10 | |
| WAIT_ELAPSED=0 | |
| while (( WAIT_ELAPSED < WAIT_MAX )); do | |
| CMD_STATUS=$(aws ssm get-command-invocation \ | |
| --command-id "${COMMAND_ID}" \ | |
| --instance-id "${{ env.INSTANCE_ID }}" \ | |
| --query 'Status' --output text 2>/dev/null || echo "Unknown") | |
| [[ "${CMD_STATUS}" == "InProgress" || "${CMD_STATUS}" == "Pending" ]] || break | |
| sleep ${WAIT_INTERVAL} | |
| WAIT_ELAPSED=$(( WAIT_ELAPSED + WAIT_INTERVAL )) | |
| done | |
| INVOCATION=$(aws ssm get-command-invocation \ | |
| --command-id "${COMMAND_ID}" \ | |
| --instance-id "${{ env.INSTANCE_ID }}") | |
| STATUS=$(echo "$INVOCATION" | jq -r '.Status') | |
| echo "===== stdout =====" | |
| echo "$INVOCATION" | jq -r '.StandardOutputContent' | |
| echo "===== stderr =====" | |
| echo "$INVOCATION" | jq -r '.StandardErrorContent' | |
| echo "==================" | |
| echo "Status: ${STATUS}" | |
| [[ "$STATUS" == "Success" ]] || exit 1 | |
| # --------------------------------------------------------- | |
| # 4) 배포 성공 후 태그/릴리즈 생성 | |
| # --------------------------------------------------------- | |
| makeTagAndRelease: | |
| runs-on: ubuntu-latest | |
| needs: deploy | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - name: Create Tag | |
| id: create_tag | |
| uses: mathieudutour/github-tag-action@v6.2 | |
| with: | |
| github_token: ${{ secrets.GITHUB_TOKEN }} | |
| - name: Create Release | |
| uses: softprops/action-gh-release@v2 | |
| with: | |
| tag_name: ${{ steps.create_tag.outputs.new_tag }} | |
| name: Release ${{ steps.create_tag.outputs.new_tag }} | |
| body: ${{ steps.create_tag.outputs.changelog }} | |
| draft: false | |
| prerelease: false |