클라우드 Prod 환경 CICD 및 Blue Green 배포 구축 - 100-hours-a-week/16-Hot6-wiki GitHub Wiki

개요

이 글에서는 Blue‑Green 전략을 활용해 GCP 백엔드와 S3 + CloudFront 프론트엔드를 한 번에 배포하는 GitHub Actions 워크플로 Deploy Prod를 해부합니다. 

  • 백엔드: GCP Managed Instance Group(MIG) + HTTP Load Balancer
  • 프론트엔드: S3 정적 호스팅 + CloudFront 오리진 전환
  • CI/CD: GitHub Actions workflow_dispatch (수동 트리거)

실 운영 환경에서 실제로 사용 중인 예시이며, 인프라 코드와 셸 스크립트가 어떻게 맞물려 동작하는지 단계별로 살펴봅니다.


워크플로 구조 한눈에 보기

Deploy Prod
├─ deploy job  (GCP 백엔드 · S3 프론트엔드)
│   ├─ FE 업로드 → S3 버킷/슬롯
│   ├─ BE 배포     → Instance Template → MIG 생성/교체
│   └─ LB 헬스체크 → 새 MIG 정상 확인 후 Old MIG 제거
└─ cdn-update job (CloudFront)
    ├─ 오리진 Path 갱신 (/frontend/prod/{slot})
    └─ 캐시 무효화

전체 코드

name: Deploy Prod

on:
  workflow_dispatch:
    inputs:
      be_version:
        description: '배포할 BE 버전 (예: 1.2.3)'
        required: true
      be_slot:
        description: 'BE 슬롯 선택 (예: blue, green)'
        required: true
        default: 'green'
      fe_version:
        description: '배포할 FE 버전 (예: 1.2.3)'
        required: true
      fe_slot:
        description: 'FE 슬롯 선택 (예: blue, green)'
        required: true
        default: 'green'

jobs:
  deploy:
    runs-on: ubuntu-latest
    outputs:
      fe_slot: ${{ steps.vars.outputs.fe_slot }}

    env:
      AWS_DEFAULT_REGION: ap-northeast-2
      REGION: asia-northeast3
      ZONE: asia-northeast3-a
      GH_FE_REPO: 100-hours-a-week/16-Hot6-fe

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

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

      - name: Set up gcloud SDK
        uses: google-github-actions/setup-gcloud@v2

      - name: Set variables
        id: vars
        run: |
          BE_VERSION="${{ github.event.inputs.be_version }}"
          BE_SLOT="${{ github.event.inputs.be_slot }}"
          FE_VERSION="${{ github.event.inputs.fe_version }}"
          FE_SLOT="${{ github.event.inputs.fe_slot }}"
          REGION="${{ env.REGION }}"
          ZONE="${{ env.ZONE }}"
      
          TEMPLATE_NAME="onthetop-backend-${BE_SLOT}-v${BE_VERSION//./-}"
          MIG_NAME="onthetop-backend-${BE_SLOT}"
      
          echo "be_version=$BE_VERSION" >> $GITHUB_OUTPUT
          echo "be_slot=$BE_SLOT" >> $GITHUB_OUTPUT
          echo "fe_version=$FE_VERSION" >> $GITHUB_OUTPUT
          echo "fe_slot=$FE_SLOT" >> $GITHUB_OUTPUT
          echo "region=$REGION" >> $GITHUB_OUTPUT
          echo "zone=$ZONE" >> $GITHUB_OUTPUT
          echo "template_name=$TEMPLATE_NAME" >> $GITHUB_OUTPUT
          echo "mig_name=$MIG_NAME" >> $GITHUB_OUTPUT

      # ------------------ FE 업로드 ------------------

      - name: Download FE artifact from GitHub Release
        run: |
          curl -L -o fe.zip https://github.com/$GH_FE_REPO/releases/download/v${{ steps.vars.outputs.fe_version }}/frontend-prod-build.zip
          unzip -o fe.zip -d fe-dist

      - name: Upload FE to S3
        env:
          AWS_ACCESS_KEY_ID: ${{ secrets.S3_ACCESS_KEY }}
          AWS_SECRET_ACCESS_KEY: ${{ secrets.S3_SECRET_KEY }}
          AWS_DEFAULT_REGION: ap-northeast-2
        run: |
          aws s3 sync fe-dist/ s3://onthe-top/frontend/prod/${{ steps.vars.outputs.fe_slot }} --delete

      # ------------------ BE 배포 ------------------

      - name: Clean up existing template/MIG if exists
        run: |
          TEMPLATE=${{ steps.vars.outputs.template_name }}
          MIG=${{ steps.vars.outputs.mig_name }}
          ZONE=${{ steps.vars.outputs.zone }}

          echo "🔍 Checking if MIG exists..."
          gcloud compute instance-groups managed describe $MIG --zone=$ZONE >/dev/null 2>&1 && \
            gcloud compute instance-groups managed delete $MIG --zone=$ZONE --quiet || echo "✅ MIG not found. Skipping delete."

          echo "🔍 Checking if instance template exists..."
          gcloud compute instance-templates describe $TEMPLATE >/dev/null 2>&1 && \
            gcloud compute instance-templates delete $TEMPLATE --quiet || echo "✅ Template not found. Skipping delete."

      - name: (Optional) Ensure Health Check Exists
        run: |
          gcloud compute health-checks describe http-health-check >/dev/null 2>&1 || \
          gcloud compute health-checks create http http-health-check \
            --port 8080 \
            --request-path=/api/v1/health \
            --check-interval=30s \
            --timeout=10s \
            --unhealthy-threshold=3 \
            --healthy-threshold=2

      - name: Create Instance Template
        run: |
          TEMPLATE=${{ steps.vars.outputs.template_name }}
      
          echo "🔍 Checking if instance template $TEMPLATE exists..."
          if gcloud compute instance-templates describe "$TEMPLATE" >/dev/null 2>&1; then
            echo "✅ Instance template $TEMPLATE already exists. Skipping creation."
          else
            echo "🚀 Creating instance template $TEMPLATE..."
            gcloud compute instance-templates create "$TEMPLATE" \
              --machine-type=e2-small \
              --region=${{ steps.vars.outputs.region }} \
              --network-interface=subnet=onthetop-subnet-prod-private-server-a,no-address \
              --image=projects/ubuntu-os-cloud/global/images/ubuntu-minimal-2404-noble-amd64-v20250514 \
              --boot-disk-size=20GB \
              --service-account=213622576886-compute@developer.gserviceaccount.com \
              --scopes=https://www.googleapis.com/auth/cloud-platform \
              --tags=http-server,https-server,onthetop-monitoring-target,onthetop-server \
              --metadata=startup-version=${{ steps.vars.outputs.be_version }} \
              --metadata-from-file=startup-script=scripts/cicd/startup-script.sh
          fi
          

      - name: Create Managed Instance Group (MIG)
        run: |
          gcloud compute instance-groups managed create ${{ steps.vars.outputs.mig_name }} \
            --base-instance-name=${{ steps.vars.outputs.mig_name }} \
            --size=1 \
            --template=${{ steps.vars.outputs.template_name }} \
            --zone=${{ steps.vars.outputs.zone }} \
            --health-check=http-health-check \
            --initial-delay=180

      - name: Set Named Port for MIG
        run: |
          MIG=${{ steps.vars.outputs.mig_name }}
          ZONE=${{ steps.vars.outputs.zone }}

          gcloud compute instance-groups managed set-named-ports $MIG \
            --zone=$ZONE \
            --named-ports=http:8080

      - name: Wait for all MIG instances to become HEALTHY
        run: |
          MIG=${{ steps.vars.outputs.mig_name }}
          ZONE=${{ steps.vars.outputs.zone }}

          echo "⏳ Waiting for all instances in $MIG to become HEALTHY..."

          for i in {1..30}; do
            UNHEALTHY_COUNT=$(gcloud compute instance-groups managed list-instances $MIG \
              --zone=$ZONE \
              --format=json | jq '[.[] | select((.instanceHealth[0].detailedHealthState // "") != "HEALTHY")] | length')

            echo "Attempt $i: Unhealthy instance count = $UNHEALTHY_COUNT"

            if [ "$UNHEALTHY_COUNT" -eq 0 ]; then
              echo "✅ All instances are HEALTHY!"
              exit 0
            fi

            sleep 15
          done

          echo "❌ Some instances did not become healthy in time"
          exit 1

      - name: Add MIG to Backend Service
        run: |
          MIG=${{ steps.vars.outputs.mig_name }}
          ZONE=${{ steps.vars.outputs.zone }}
          BACKEND_SERVICE="onthetop-backend-service"

          echo "🔄 Replacing backend in $BACKEND_SERVICE with $MIG..."

          # 새 MIG 추가
          gcloud compute backend-services add-backend $BACKEND_SERVICE \
            --global \
            --instance-group=$MIG \
            --instance-group-zone=$ZONE

          MIG_URL="https://www.googleapis.com/compute/v1/projects/onthetop-457202/zones/$ZONE/instanceGroups/$MIG"

          echo "⏳ Waiting for LB to report $MIG as HEALTHY..."

          for i in {1..20}; do
            STATE=$(gcloud compute backend-services get-health $BACKEND_SERVICE --global --format=json |
              jq -r --arg MIG "$MIG_URL" '
                .[] | select(.backend == $MIG) |
                .status.healthStatus[0].healthState // "UNKNOWN"
              ' | tr -d '[:space:]')

            echo "Attempt $i: $MIG LB health = '$STATE'"

            if [ "$STATE" = "HEALTHY" ]; then
              echo "✅ LB sees $MIG as healthy!"
              break
            fi

            sleep 15
          done

          if [ "$STATE" != "HEALTHY" ]; then
            echo "❌ LB never saw $MIG as healthy in time. Rolling back..."
            gcloud compute backend-services remove-backend $BACKEND_SERVICE \
              --global \
              --instance-group=$MIG \
              --instance-group-zone=$ZONE || true
            exit 1
          fi

          # 🔁 기존 MIG 제거
          echo "🧹 Removing old MIG from $BACKEND_SERVICE..."
          if [ "$MIG" == *"blue" ](/100-hours-a-week/16-Hot6-wiki/wiki/-"$MIG"-==-*"blue"-); then
            OLD_MIG="onthetop-backend-green"
          else
            OLD_MIG="onthetop-backend-blue"
          fi

          gcloud compute backend-services remove-backend $BACKEND_SERVICE \
            --global \
            --instance-group=$OLD_MIG \
            --instance-group-zone=$ZONE || true

      # ------------------ CloudFront Origin 변경 및 무효화 ------------------

  cdn-update:
    needs: deploy
    runs-on: ubuntu-latest
    env:
      AWS_ACCESS_KEY_ID: ${{ secrets.CLOUDFRONT_ACCESS_KEY }}
      AWS_SECRET_ACCESS_KEY: ${{ secrets.CLOUDFRONT_SECRET_KEY }}
      AWS_DEFAULT_REGION: ap-northeast-2
      CLOUDFRONT_DISTRIBUTION_ID: ${{ secrets.CLOUDFRONT_DISTRIBUTION_ID }}
    steps:
      - name: Update CloudFront Origin
        run: |
          SLOT="${{ needs.deploy.outputs.fe_slot }}"
          DIST_ID="$CLOUDFRONT_DISTRIBUTION_ID"

          # 원본 전체 구성 받아오기
          aws cloudfront get-distribution-config --id $DIST_ID > raw.json

          # ETag 추출
          ETAG=$(jq -r '.ETag' raw.json)

          # DistributionConfig만 추출
          jq '.DistributionConfig' raw.json > config-only.json

          # OriginPath 수정
          jq --arg SLOT "$SLOT" \
            '.Origins.Items[0].OriginPath = "/frontend/prod/\($SLOT)"' \
            config-only.json > updated-config.json

          # 업데이트 적용
          aws cloudfront update-distribution \
            --id $DIST_ID \
            --if-match $ETAG \
            --distribution-config file://updated-config.json

      - name: Invalidate CloudFront cache
        run: |
          SLOT="${{ needs.deploy.outputs.fe_slot }}"
          aws cloudfront create-invalidation \
            --distribution-id "$CLOUDFRONT_DISTRIBUTION_ID" \
            --paths "/" "/index.html" "/assets/*.js" "/assets/*.css"

1. 트리거 & 인풋 파라미터

workflow_dispatch 로 수동 실행하며 네 가지 인자를 받습니다.

입력 설명 예시
be_version 배포할 백엔드 Docker 버전 1.2.3
be_slot 백엔드 슬롯(blue/green) green
fe_version 프론트엔드 빌드 버전 (GitHub Release 태그) 1.2.3
fe_slot 프론트엔드 슬롯(blue/green) green

슬롯 값을 통해 Blue‑Green 전환을 제어합니다.


2. deploy Job – 백엔드 & 프론트엔드 동시 배포

2‑1. 공통 준비

  1. 코드 체크아웃 (Infra Repo)
  2. GCP 서비스 계정 인증 : google-github-actions/auth@v2
  3. gcloud SDK 설치 
  4. 변수 설정 : 입력값을 환경 변수·GitHub Output으로 저장

2‑2. 프론트엔드 업로드 (S3)

curl -L -o fe.zip https://github.com/$GH_FE_REPO/releases/download/v$FE_VERSION/frontend-prod-build.zip
unzip fe.zip -d fe-dist
aws s3 sync fe-dist/ s3://onthe-top/frontend/prod/$FE_SLOT --delete
  • 기 배포된 슬롯 폴더를 --delete로 정리 후 신규 빌드를 업로드

2‑3. 백엔드 배포 (GCP MIG)

핵심: 새 MIG를 만든 뒤 LB 헬스체크 통과 시점에 기존 MIG를 제거

  1. 기존 템플릿·MIG 정리

    • 존재하면 삭제, 없으면 통과 (idempotent)
  2. Health Check 보장 (없으면 생성)

  3. Instance Template 생성

    • --metadata=startup-version 에 버전 주입
    • --metadata-from-file=startup-script 로 셸 부트스트랩
  4. MIG 생성 (1 인스턴스, 초기 딜레이 180s)

  5. Named Port 설정 (http:8080)

  6. MIG 상태 확인 루프

    for i in {1..30}; do
      # jq로 HEALTHY 여부 계산
    done
    
  7. Load Balancer 백엔드 교체

    • backend-services add-backend → 새 MIG 추가
    • 헬스 확인 후 롤백 로직 포함
    • 슬롯 기준으로 이전 MIG 식별 후 제거

3. cdn-update Job – CloudFront 오리진 전환

deploy Job이 끝나면 이어서 실행됩니다.

  1. 배포 중인 배포판 설정 읽기 → aws cloudfront get-distribution-config
  2. OriginPath 수정 → /frontend/prod/{slot}
  3. 배포판 업데이트 (ETag 필수)
  4. 캐시 무효화 : 루트·index.html·정적 자산

왜 별도 Job인가?

  • 백엔드 배포 성공 여부와 무관하게 FE 오리진만 교체하도록 책임 분리
  • needs: deploy 를 통해 의존성 선언

4. 안전 장치 & 롤백 전략

단계 실패 시 동작
MIG Health Loop 타임아웃 → 워크플로 실패
LB Health Check 새 MIG 제거 후 종료 → 트래픽은 이전 MIG로 유지

추가로, GCP Instance Template과 MIG 이름에 버전 태깅을 적용하여 수동 롤백도 쉽습니다.


5. 마무리

이 워크플로는 다음과 같은 장점을 가집니다.

  1. 완전 무중단: 새 슬롯 가동 → 헬스체크 통과 → 트래픽 스위칭
  2. 단일 소스 CI/CD: GitHub Actions에서 인프라·앱·CDN까지 관리
  3. 멱등성: 기존 리소스가 있어도 안전하게 삭제 또는 건너뜀
  4. 가시성: Slack 알림·GitHub Checks로 배포 이력 추적 (필요 시 추가)

운영 배포를 자동화하면서도 사람이 언제든지 개입할 수 있게 설계된 것이 핵심입니다. Blue‑Green 슬롯 구조 덕분에 서비스 중단 없이 새로운 기능을 배포하고, 문제가 생기면 즉시 이전 버전으로 되돌릴 수 있습니다.


참고 링크