Workflow file for this run
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: "[PROD] Build Gradle and Deploy" | |
| on: | |
| release: | |
| types: [ published ] | |
| workflow_dispatch: | |
| inputs: | |
| tag_name: | |
| description: 'Docker Tag Name (e.g., v1.0.0)' | |
| required: true | |
| default: 'latest' | |
| jobs: | |
| # --- Job 1: 빌드 및 이미지 푸시 (쓰기 권한) --- | |
| build-and-push: | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: read | |
| packages: write | |
| outputs: | |
| image_tag: ${{ steps.image_meta.outputs.image_tag }} | |
| steps: | |
| - name: Checkout the code | |
| uses: actions/checkout@v4 | |
| # --- Java, Gradle 설정 --- | |
| - name: Set up JDK 21 | |
| uses: actions/setup-java@v4 | |
| with: | |
| java-version: '21' | |
| distribution: 'temurin' | |
| - name: Setup Gradle | |
| uses: gradle/actions/setup-gradle@v3 | |
| - name: Grant execute permission for Gradle wrapper | |
| run: chmod +x ./gradlew | |
| - name: Build with Gradle | |
| run: ./gradlew bootJar | |
| # --- Docker 설정 --- | |
| - name: Set up Docker Buildx | |
| uses: docker/setup-buildx-action@v3 | |
| with: | |
| platforms: linux/arm64 | |
| - name: Log in to GitHub Container Registry (GHCR) | |
| uses: docker/login-action@v3 | |
| with: | |
| registry: ghcr.io | |
| username: ${{ github.actor }} | |
| password: ${{ secrets.GITHUB_TOKEN }} | |
| # --- 이미지 메타데이터 정의 (Prod용 이미지 이름) --- | |
| - name: Define image name and tag | |
| id: image_meta | |
| run: | | |
| OWNER_LOWERCASE=$(echo "${{ github.repository_owner }}" | tr '[:upper:]' '[:lower:]') | |
| # Trigger가 Release인 경우: Release의 Tag Name (예: v1.0.0) 사용 | |
| if [ "${{ github.event_name }}" == "release" ]; then | |
| IMAGE_TAG="${{ github.ref_name }}" | |
| # Trigger가 수동(workflow_dispatch)인 경우: 입력받은 tag_name 사용 | |
| else | |
| IMAGE_TAG="${{ inputs.tag_name }}" | |
| fi | |
| echo "Docker Image Tag: $IMAGE_TAG" | |
| echo "image_name=ghcr.io/${OWNER_LOWERCASE}/solid-connection-server" >> $GITHUB_OUTPUT | |
| echo "image_tag=${IMAGE_TAG}" >> $GITHUB_OUTPUT | |
| # --- Docker 빌드 및 푸시 --- | |
| - name: Build, push, and cache Docker image | |
| uses: docker/build-push-action@v5 | |
| with: | |
| context: . | |
| platforms: linux/arm64 | |
| push: true | |
| tags: ${{ format('{0}:{1}', steps.image_meta.outputs.image_name, steps.image_meta.outputs.image_tag) }} | |
| cache-from: type=registry,ref=${{ steps.image_meta.outputs.image_name }}:buildcache | |
| cache-to: type=registry,ref=${{ steps.image_meta.outputs.image_name }}:buildcache,mode=max | |
| # --- 이미지 정리 --- | |
| - name: Clean up old image versions from GHCR | |
| uses: snok/container-retention-policy@v2 | |
| with: | |
| token: ${{ secrets.PACKAGE_DELETE_TOKEN }} | |
| image-names: solid-connection-server | |
| delete-untagged: true | |
| keep-n-tags: 5 | |
| account-type: org | |
| org-name: ${{ github.repository_owner }} | |
| cut-off: '7 days ago UTC' | |
| # --- Job 2: 배포 (읽기 권한) --- | |
| deploy: | |
| needs: build-and-push | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: read | |
| packages: read | |
| steps: | |
| - name: Checkout config files | |
| uses: actions/checkout@v4 | |
| with: | |
| sparse-checkout: | | |
| docker-compose.prod.yml | |
| docs/infra-config | |
| sparse-checkout-cone-mode: false | |
| - name: Copy config files to remote | |
| run: | | |
| echo "${{ secrets.PRIVATE_KEY }}" > deploy_key.pem | |
| chmod 600 deploy_key.pem | |
| scp -i deploy_key.pem \ | |
| -o StrictHostKeyChecking=no \ | |
| ./docker-compose.prod.yml \ | |
| ${{ secrets.USERNAME }}@${{ secrets.HOST }}:/home/${{ secrets.USERNAME }}/solid-connection-prod/ | |
| - name: Blue/Green deploy | |
| run: | | |
| ssh -i deploy_key.pem \ | |
| -o StrictHostKeyChecking=no \ | |
| ${{ secrets.USERNAME }}@${{ secrets.HOST }} \ | |
| ' | |
| set -e | |
| OWNER_LOWERCASE=$(echo "${{ github.repository_owner }}" | tr "[:upper:]" "[:lower:]") | |
| IMAGE_TAG_ONLY="${{ needs.build-and-push.outputs.image_tag }}" | |
| FULL_IMAGE_NAME="ghcr.io/${OWNER_LOWERCASE}/solid-connection-server:${IMAGE_TAG_ONLY}" | |
| IMAGE_NAME_BASE="ghcr.io/${OWNER_LOWERCASE}/solid-connection-server" | |
| WORK_DIR="/home/${{ secrets.USERNAME }}/solid-connection-prod" | |
| CONTAINER_BASE="solid-connection-server" | |
| # 1. Active 슬롯 확인 (upstream.conf 기준) | |
| UPSTREAM_PORT=$(grep -oE "server 127\.0\.0\.1:[0-9]+" /etc/nginx/conf.d/upstream.conf 2>/dev/null | grep -oE "[0-9]+$" || echo "8081") | |
| if [ "$UPSTREAM_PORT" = "8080" ]; then | |
| ACTIVE_SLOT="blue"; ACTIVE_PORT=8080; NEW_SLOT="green"; NEW_PORT=9080; MANAGEMENT_PORT=9081 | |
| else | |
| ACTIVE_SLOT="green"; ACTIVE_PORT=9080; NEW_SLOT="blue"; NEW_PORT=8080; MANAGEMENT_PORT=8081 | |
| fi | |
| echo "Active: ${ACTIVE_SLOT}(${ACTIVE_PORT}) → Deploy: ${NEW_SLOT}(${NEW_PORT}), management: ${MANAGEMENT_PORT}" | |
| # 2. Pull 전 디스크 정리 (태그 이미지 최근 2개 유지) | |
| docker images "${IMAGE_NAME_BASE}" --format "{{.Tag}}" | \ | |
| grep -v buildcache | sort -r | tail -n +3 | \ | |
| xargs -I {} docker rmi "${IMAGE_NAME_BASE}:{}" 2>/dev/null || true | |
| docker image prune -f | |
| # 3. GHCR 로그인 & Pull | |
| echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin | |
| docker pull "${FULL_IMAGE_NAME}" | |
| # 4. 새 슬롯 잔여 컨테이너 정리 | |
| docker stop "${CONTAINER_BASE}-${NEW_SLOT}" 2>/dev/null || true | |
| docker rm "${CONTAINER_BASE}-${NEW_SLOT}" 2>/dev/null || true | |
| # 5. 새 컨테이너 시작 | |
| cd "${WORK_DIR}" | |
| SLOT="${NEW_SLOT}" APP_PORT="${NEW_PORT}" MANAGEMENT_PORT="${MANAGEMENT_PORT}" OWNER_LOWERCASE="${OWNER_LOWERCASE}" IMAGE_TAG="${IMAGE_TAG_ONLY}" \ | |
| docker compose -p "${CONTAINER_BASE}-${NEW_SLOT}" -f docker-compose.prod.yml up -d solid-connection-server | |
| # 6. 헬스 체크 (앱 기동 대기, 최대 150초) | |
| # HTTP 200 = UP, 503 = DOWN (single-quote 내부 quoting 문제 없이 상태코드로 판단) | |
| echo "Waiting for app on management port ${MANAGEMENT_PORT}..." | |
| for i in $(seq 1 30); do | |
| HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" --connect-timeout 2 "http://localhost:${MANAGEMENT_PORT}/actuator/health" || true) | |
| [ "$HTTP_CODE" = "200" ] && { echo "App healthy (attempt ${i})"; break; } | |
| [ "$i" = "30" ] && { | |
| echo "Health check timed out after 150s" >&2 | |
| docker stop "${CONTAINER_BASE}-${NEW_SLOT}" 2>/dev/null || true | |
| docker rm "${CONTAINER_BASE}-${NEW_SLOT}" 2>/dev/null || true | |
| exit 1 | |
| } | |
| sleep 5 | |
| done | |
| # 7. Nginx upstream 전환 (무중단) | |
| sudo sed -i "s|server 127.0.0.1:[0-9]*;|server 127.0.0.1:${NEW_PORT};|" /etc/nginx/conf.d/upstream.conf | |
| sudo nginx -s reload | |
| echo "Traffic switched → ${NEW_SLOT}(${NEW_PORT})" | |
| # 8. 구 컨테이너 종료 | |
| docker compose -p "${CONTAINER_BASE}-${ACTIVE_SLOT}" -f docker-compose.prod.yml down 2>/dev/null || true | |
| echo "Deployment complete. Active: ${NEW_SLOT}(${NEW_PORT})" | |
| ' | |
| - name: Update Prometheus scrape target (prod) | |
| run: | | |
| echo "${{ secrets.MONITORING_PRIVATE_KEY }}" > monitoring_key.pem | |
| chmod 600 monitoring_key.pem | |
| UPSTREAM_PORT=$(ssh -i deploy_key.pem -o StrictHostKeyChecking=no \ | |
| "${{ secrets.USERNAME }}@${{ secrets.HOST }}" \ | |
| "grep -oE 'server 127\.0\.0\.1:[0-9]+' /etc/nginx/conf.d/upstream.conf 2>/dev/null | grep -oE '[0-9]+$'") | |
| if [ "$UPSTREAM_PORT" != "8080" ] && [ "$UPSTREAM_PORT" != "9080" ]; then | |
| echo "Unexpected UPSTREAM_PORT: '${UPSTREAM_PORT}'" >&2 | |
| exit 1 | |
| fi | |
| if [ "$UPSTREAM_PORT" = "8080" ]; then NEW_MGMT_PORT=8081; else NEW_MGMT_PORT=9081; fi | |
| PRIVATE_IP=$(ssh -i deploy_key.pem -o StrictHostKeyChecking=no \ | |
| "${{ secrets.USERNAME }}@${{ secrets.HOST }}" \ | |
| "TOKEN=\$(curl -sf -X PUT 'http://169.254.169.254/latest/api/token' -H 'X-aws-ec2-metadata-token-ttl-seconds: 21600') && curl -sf -H \"X-aws-ec2-metadata-token: \$TOKEN\" http://169.254.169.254/latest/meta-data/local-ipv4") | |
| if [ -z "$PRIVATE_IP" ]; then | |
| echo "Failed to retrieve private IP" >&2 | |
| exit 1 | |
| fi | |
| ssh -i monitoring_key.pem -o StrictHostKeyChecking=no \ | |
| "${{ secrets.MONITORING_USERNAME }}@${{ secrets.MONITORING_HOST }}" \ | |
| "echo '[{\"targets\":[\"${PRIVATE_IP}:${NEW_MGMT_PORT}\"]}]' \ | |
| | tee ~/solid-connection-monitor/prometheus/targets/prod.json > /dev/null \ | |
| && echo 'Prometheus target updated: ${PRIVATE_IP}:${NEW_MGMT_PORT}'" |