클라우드 3단계: CD(지속적 배포) 파이프라인 구축 - 100-hours-a-week/16-Hot6-wiki GitHub Wiki

상위 문서: 클라우드 위키
관련 문서: 클라우드 WHY? 문서

CD 파이프라인 구축


목차


설계

왜 CD 과정이 필요한가요?

  1. CD 적용 범위 및 방식 결정

dev에는 자동 CI/CD (deployment), prod에는 수동으로 deploy를 진행합니다(delivery)
아래 위키 문서에서 왜 각각을 그렇게 구분했는지 상세히 작성했습니다.

왜 이 dev 구조인가요?
왜 이 prod 구조인가요?

  1. CD 아키텍처 설계

image

왜 VPN을 사용했나요?
왜 WireGuard VPN을 사용했나요?

CD 아키텍처는 위와 같습니다. 아래 스크립트들은 구체적인 아키텍처인 MVP 버전 아키텍처에 맞추어 작성되었습니다.

CD 파이프라인 흐름도

✅ Dev 환경 Deployment 흐름도 ✅ Prod 환경 Delivery 시나리오

prod

0.   Github Actions 아티펙트 준비 완료
1.   Cloud Engineer 배포 시작 트리거  
2-1. FE 정적 자산 S3 업로드 (v2)  
2-2. BE/AI 서버 인스턴스 준비 (v2) 
3.   v1 서버로 오는 요청은 Nginx가 HTTP 상태 코드 503 응답(배포 중이라는 의미)
4.   CloudFront 오리진 전환 v1 → v2
5.   CloudFront 캐시 무효화
6.   LB 타겟 그룹에 v2로 전환
7.   수동 테스트  
8-1. 테스트 통과: v1 제거 및 배포 완료
8-2. 테스트 실패: v1 으로 롤백

dev

FE

0. Github Actions 아티펙트 준비 완료
1. 정적 파일 S3 업로드
2. CloudFront 캐시 무효화

BE/AI

0. Github Actions 아티펙트 준비 완료
1. Shared 서버 SSH 접속
2. 개발 서버 SSH 접속
3. 아티펙트 다운로드
4. 서버 재시작(Systemctl)
5. 배포 완료 / 실패 시 알람 전송

CD 설계 설명서

설정 및 스크립트 명세

BE / AI systemd 서버 설정 및 배포 명령

Spring

[Unit]
Description=Spring Boot Backend Service
After=network.target

[Service]
User=ec2-user
ExecStart=/usr/bin/java -jar /home/ec2-user/artifacts/backend-latest.jar
SuccessExitStatus=143
Restart=on-failure
RestartSec=5

[Install]
WantedBy=multi-user.target

FastAPI

[Unit]
Description=FastAPI AI Service
After=network.target

[Service]
User=ec2-user
WorkingDirectory=/home/ec2-user/onthetop-ai
ExecStart=/home/ec2-user/onthetop-ai/venv/bin/uvicorn app.main:app --host 0.0.0.0 --port 8000
Restart=always
RestartSec=5

[Install]
WantedBy=multi-user.target

배포

# Spring
sudo systemctl restart backend.service

# FastAPI
sudo systemctl restart ai.service

Github Actions

Prod 배포

name: Blue-Green Deploy to Production

on:
  workflow_dispatch:

jobs:
  blue-green-deploy:
    name: Blue-Green Deploy
    runs-on: ubuntu-latest
    env:
      PROJECT_ID: ${{ secrets.GCP_PROJECT }}
      REGION: asia-northeast3
      ZONE: asia-northeast3-a
      BACKEND_SERVICE: my-backend-service
      S3_BUCKET: ${{ secrets.S3_BUCKET_NAME }}

    steps:
      - name: Checkout Infra Repo
        uses: actions/checkout@v3

      - name: Auth to GCP
        uses: google-github-actions/auth@v1
        with:
          credentials_json: ${{ secrets.GCP_SA_KEY }}

      - name: Setup gcloud CLI
        uses: google-github-actions/setup-gcloud@v1
        with:
          project_id: ${{ env.PROJECT_ID }}

      - name: Auth to AWS (for CloudFront)
        uses: aws-actions/configure-aws-credentials@v2
        with:
          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
          aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          aws-region: ap-northeast-2

      - name: Determine Current Target Group
        id: determine-group
        run: |
          CURRENT_GROUP=$(gcloud compute backend-services describe "$BACKEND_SERVICE" \
            --global \
            --format="value(backends[].group)")

          if echo "$CURRENT_GROUP" | grep -q "green"; then
            echo "NEXT_GROUP=backend-blue-group" >> $GITHUB_ENV
            echo "NEXT_TEMPLATE=backend-blue-template" >> $GITHUB_ENV
            echo "PREV_GROUP=backend-green-group" >> $GITHUB_ENV
          else
            echo "NEXT_GROUP=backend-green-group" >> $GITHUB_ENV
            echo "NEXT_TEMPLATE=backend-green-template" >> $GITHUB_ENV
            echo "PREV_GROUP=backend-blue-group" >> $GITHUB_ENV
          fi

      - name: Upload new FE static files to S3
        run: |
          aws s3 sync ./frontend/dist s3://$S3_BUCKET/v2 --delete

      - name: Create Instance Template for ${{ env.NEXT_GROUP }}
        run: |
          gcloud compute instance-templates create "$NEXT_TEMPLATE" \
            --machine-type=e2-medium \
            --image-family=ubuntu-2204-lts \
            --image-project=ubuntu-os-cloud \
            --metadata=startup-script='#! /bin/bash
              cd /home/ec2-user
              wget https://example.com/backend-latest.jar -O backend.jar
              nohup java -jar backend.jar --spring.profiles.active=prod > app.log 2>&1 &' \
            --tags=http-server \
            --region=$REGION

      - name: Create New Instance Group for ${{ env.NEXT_GROUP }}
        run: |
          gcloud compute instance-groups managed create "$NEXT_GROUP" \
            --base-instance-name="$NEXT_GROUP" \
            --size=2 \
            --template="$NEXT_TEMPLATE" \
            --zone=$ZONE

      - name: Wait for Instances to Start
        run: |
          echo "⏳ Waiting 90s for app to boot..."
          sleep 90

      - name: SSH into Jump Server and Deploy Backend + AI
        run: |
          JUMP_HOST=your-jump-host
          TARGET_HOST=your-target-server
          
          echo "SSH to Jump Server ($JUMP_HOST)"

          ssh -o StrictHostKeyChecking=no ec2-user@$JUMP_HOST << 'EOS'

            echo "Cloning Cloud Repository..."
            if [ -d "cloud-repo" ]; then
              cd infra-repo && git pull
            else
              git clone https://github.com/your-org/your-repo.git cloud-repo
              cd cloud-repo
            fi

            echo "🚚 Copying Scripts to Target Host ($TARGET_HOST)..."
            scp -o StrictHostKeyChecking=no ./scripts/deploy-backend.sh ec2-user@$TARGET_HOST:/home/ec2-user/deploy-backend.sh
            scp -o StrictHostKeyChecking=no ./scripts/deploy-ai.sh ec2-user@$TARGET_HOST:/home/ec2-user/deploy-ai.sh

            echo "🚀 Executing Scripts on Target Host..."
            ssh -o StrictHostKeyChecking=no ec2-user@$TARGET_HOST << 'INNER'
              chmod +x deploy-backend.sh
              chmod +x deploy-ai.sh

              sudo ./deploy-backend.sh
              sudo ./deploy-ai.sh
            INNER

          EOS

      # 이곳에 v1 503 전환 로직 추가

      - name: Get Current CloudFront Config
        id: get-config
        run: |
          aws cloudfront get-distribution-config \
            --id ${{ secrets.CLOUDFRONT_ID }} > dist-config.json
          echo "etag=$(jq -r '.ETag' dist-config.json)" >> $GITHUB_OUTPUT
          echo "origin=$(jq -r '.DistributionConfig.DefaultCacheBehavior.TargetOriginId' dist-config.json)" >> $GITHUB_OUTPUT

      - name: Determine Next Origin
        id: determine-origin
        run: |
          if [ "${{ steps.get-config.outputs.origin }}" == "s3-blue" ]; then
            echo "NEXT_ORIGIN=s3-green" >> $GITHUB_ENV
          else
            echo "NEXT_ORIGIN=s3-blue" >> $GITHUB_ENV
          fi

      - name: Switch CloudFront Origin
        run: |
          jq --arg origin "$NEXT_ORIGIN" \
            '.DistributionConfig.DefaultCacheBehavior.TargetOriginId = $origin' dist-config.json > new-config.json

          aws cloudfront update-distribution \
            --id ${{ secrets.CLOUDFRONT_ID }} \
            --distribution-config file://new-config.json \
            --if-match "${{ steps.get-config.outputs.etag }}"

      - name: Invalidate CloudFront Cache
        run: |
          aws cloudfront create-invalidation \
            --distribution-id ${{ secrets.CLOUDFRONT_ID }} \
            --paths "/*"

      - name: Switch LB to ${{ env.NEXT_GROUP }}
        run: |
          gcloud compute backend-services update "$BACKEND_SERVICE" \
            --global \
            --instance-groups="$NEXT_GROUP" \
            --instance-group-zone=$ZONE

      - name: Manual Verification
        run: |
          echo "✅ Please verify deployment at https://onthe-top.com"
          echo "🧹 Once verified, run apply workflow to delete previous group: $PREV_GROUP"

Prod 배포 완료(기존 버전 삭제)

name: Finalize Deployment (Delete v1)

on:
  workflow_dispatch:
    inputs:
      group_to_delete:
        description: "Enter the instance group to delete (e.g., backend-blue-group)"
        required: true

jobs:
  delete-old:
    name: Remove Previous Version (v1)
    runs-on: ubuntu-latest

    steps:
      - name: Delete old instance group
        run: |
          gcloud compute instance-groups managed delete ${{ github.event.inputs.group_to_delete }} \
            --zone ${{ env.REGION }}-a --quiet

      - name: Notify
        run: echo "🧹 이전 인스턴스 그룹 (${{ github.event.inputs.group_to_delete }}) 삭제 완료. 배포 최종 완료."

Prod 배포 롤백

name: Rollback Deployment

on:
  workflow_dispatch:
    inputs:
      rollback_target_group:
        description: "복구할 인스턴스 그룹 이름 (예: backend-blue-group)"
        required: true
      delete_group:
        description: "삭제할 신규 인스턴스 그룹 이름 (예: backend-green-group)"
        required: true

jobs:
  rollback:
    name: Rollback to Previous Version
    runs-on: ubuntu-latest
    env:
      PROJECT_ID: ${{ secrets.GCP_PROJECT }}
      REGION: asia-northeast3
      ZONE: asia-northeast3-a
      BACKEND_SERVICE: my-backend-service

    steps:
      - name: Auth to GCP
        uses: google-github-actions/auth@v1
        with:
          credentials_json: ${{ secrets.GCP_SA_KEY }}

      - name: Setup gcloud
        uses: google-github-actions/setup-gcloud@v1
        with:
          project_id: ${{ env.PROJECT_ID }}

      - name: 🔁 Revert LB to ${{ github.event.inputs.rollback_target_group }}
        run: |
          gcloud compute backend-services update "$BACKEND_SERVICE" \
            --global \
            --instance-groups=${{ github.event.inputs.rollback_target_group }} \
            --instance-group-zone=${{ env.ZONE }}

      - name: ❌ Delete broken group ${{ github.event.inputs.delete_group }}
        run: |
          gcloud compute instance-groups managed delete ${{ github.event.inputs.delete_group }} \
            --zone ${{ env.ZONE }} --quiet

      - name: ✅ Notify
        run: |
          echo "🔁 롤백 완료: LB는 ${{ github.event.inputs.rollback_target_group }}로 복구되었습니다."
          echo "🗑️ 실패한 그룹(${{ github.event.inputs.delete_group }})은 삭제되었습니다."

Dev Frontend

name: Deploy FE to Dev

on:
  push:
    branches:
      - dev

jobs:
  deploy-fe:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout Source
        uses: actions/checkout@v3

      - name: Build Frontend
        run: |
          cd frontend
          npm install
          npm run build

      - name: Upload to S3
        run: |
          aws s3 sync ./frontend/dist s3://${{ secrets.S3_BUCKET_DEV }} --delete
        env:
          AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
          AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          AWS_REGION: ap-northeast-2

      - name: Invalidate CloudFront (optional)
        run: |
          aws cloudfront create-invalidation \
            --distribution-id ${{ secrets.CLOUDFRONT_ID_DEV }} \
            --paths "/*"

Dev Backend

name: Deploy BE to Dev

on:
  push:
    branches:
      - dev

jobs:
  deploy-be:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout Source
        uses: actions/checkout@v3

      - name: Build Spring Boot JAR
        run: |
          cd backend
          ./gradlew clean build -x test

      - name: Copy Artifact via SSH
        run: |
          scp -o StrictHostKeyChecking=no backend/build/libs/*.jar ec2-user@${{ secrets.DEV_SERVER_IP }}:/home/ec2-user/artifacts/backend-latest.jar

      - name: Restart Backend Service
        run: |
          ssh -o StrictHostKeyChecking=no ec2-user@${{ secrets.DEV_SERVER_IP }} \
            "sudo systemctl restart backend.service"

Dev AI

name: Deploy AI to Dev

on:
  push:
    branches:
      - dev

jobs:
  deploy-ai:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout Source
        uses: actions/checkout@v3

      - name: Zip AI Source
        run: |
          cd ai
          zip -r ai.zip .

      - name: Transfer Code via SCP
        run: |
          scp -o StrictHostKeyChecking=no ai/ai.zip ec2-user@${{ secrets.DEV_SERVER_IP }}:/home/ec2-user/ai.zip

      - name: Deploy and Restart AI
        run: |
          ssh -o StrictHostKeyChecking=no ec2-user@${{ secrets.DEV_SERVER_IP }} << 'EOS'
            unzip -o /home/ec2-user/ai.zip -d /home/ec2-user/onthetop-ai
            sudo systemctl restart ai.service
          EOS

MVP 버전 아키텍처

아키텍처 다이어그램

alt text

왜 CloudFront를 사용했나요?

⚠️ **GitHub.com Fallback** ⚠️