실운영 Blue-Green 배포 후 안전한 Docker 슬롯 정리 자동화 (GitHub Actions + GCP)

안상운·2025년 6월 11일

OnTheTop - 프로젝트

목록 보기
9/12

배경

Blue-Green 배포를 사용하면 다운타임 없이 안전하게 서비스를 교체할 수 있습니다.
하지만 **배포 후 사용하지 않는 슬롯(Docker 컨테이너)**이 계속 남아 있으면 자원을 낭비하거나 장애의 원인이 될 수 있습니다.

그래서 저는 **"현재 Nginx가 바라보는 Docker 컨테이너만 남기고, 나머지는 자동 정리"**하는 GitHub Actions 워크플로우를 만들었습니다.


📌 주요 기능

  • ✅ GCP MIG에서 모든 인스턴스 IP 자동 수집

  • ✅ Jump Host를 통한 보안 SSH 접근

  • ✅ 각 인스턴스에서:

    • 실행 중인 컨테이너가 2개인지 확인
    • Nginx 설정을 통해 현재 사용 중인 슬롯 판단
    • 해당 슬롯의 컨테이너가 정상 실행 중인지 확인
    • 이미지 버전이 CONFIRM_VERSION과 일치하는지 확인
    • 나머지 슬롯은 안전하게 제거

🔒 안전장치

이 워크플로우는 다음을 철저히 확인하여 실수로 서비스 중단이 발생하는 것을 완벽히 방지합니다:

  1. Nginx가 바라보는 포트를 통해 정확한 슬롯 추정
  2. 그 슬롯 컨테이너가 살아 있는지 확인 (docker inspect -f '{{.State.Running}}')
  3. 그 슬롯 컨테이너의 버전이 CONFIRM_VERSION과 동일한지 확인
  4. ❌ 조건 하나라도 맞지 않으면 즉시 중단(exit 1)

🧪 예시 흐름

📦 SLOT_IPS 값: 10.21.10.4,10.21.10.5
🔍 분해된 IP 배열: 10.21.10.4 10.21.10.5
🧹 Cleaning old slot on 10.21.10.4...
✅ 유지: onthetop-backend-blue (version: v1.2.3)
🗑️ 삭제: onthetop-backend-green (version: v1.2.2)

🧹 Cleaning old slot on 10.21.10.5...
✅ 유지: onthetop-backend-green (version: v1.2.3)
🗑️ 삭제: onthetop-backend-blue (version: v1.2.1)

📋 [10.21.10.4] 컨테이너 정리 요약:
- blue 유지
- green 삭제

🚀 사용 방법

on:
  workflow_dispatch:
    inputs:
      confirm_version:
        description: '확정할 docker 배포 버전 (예: 1.2.3)'
        required: true

GitHub Actions에서 수동 실행 시 confirm_version을 입력하면 자동으로 전체 인스턴스를 점검하고 불필요한 슬롯을 제거합니다.


전체코드

name: Confirm Prod Deployment (Clean Old Docker Slot)

on:
  workflow_dispatch:
    inputs:

      confirm_version:
        description: '확정할 docker 배포 버전 (예: 1.2.3)'
        required: true

jobs:
  confirm:
    runs-on: ubuntu-latest

    env:
      REGION: asia-northeast3

    steps:
      - 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: |
          REGION="${{ env.REGION }}"
      
          MIG_NAME="onthetop-mig-prod"
      
          echo "region=$REGION" >> $GITHUB_OUTPUT
          echo "mig_name=$MIG_NAME" >> $GITHUB_OUTPUT

      - name: Get list of backend instance IPs
        id: get_ips
        run: |

          MIG="${{ steps.vars.outputs.mig_name }}"
          REGION="${{ steps.vars.outputs.region }}"
          INSTANCE_NAMES=$(gcloud compute instance-groups managed list-instances "$MIG" \
            --region="$REGION" \
            --format="get(instance)" | sed -n 's|.*/||p')
          
          if [ -z "$INSTANCE_NAMES" ]; then
            echo "❌ MIG 인스턴스를 찾을 수 없습니다. MIG 이름과 REGION을 확인하세요."
            exit 1
          fi


          ZONE_CANDIDATES=("asia-northeast3-a" "asia-northeast3-b" "asia-northeast3-c")

          IP_LIST=""
          for INSTANCE in $INSTANCE_NAMES; do
            for TRY_ZONE in "${ZONE_CANDIDATES[@]}"; do
              IP=$(gcloud compute instances describe "$INSTANCE" --zone "$TRY_ZONE" \
                --format="get(networkInterfaces[0].networkIP)" 2>/dev/null) || continue
              if [ -n "$IP" ]; then
                echo "✅ $INSTANCE ($TRY_ZONE) → $IP"
                IP_LIST+="$IP,"
                break
              fi
            done
          done

          IP_LIST="${IP_LIST%,}"

          echo " All IPs: $IP_LIST"
          echo "slot_ips=$IP_LIST" >> $GITHUB_OUTPUT

      - name: Set up SSH keys
        run: |
          mkdir -p ~/.ssh

          echo "${{ secrets.JUMP_SSH_KEY }}" > ~/.ssh/jump_key
          chmod 600 ~/.ssh/jump_key

          echo "${{ secrets.SSH_KEY }}" > ~/.ssh/dev_key
          chmod 600 ~/.ssh/dev_key


      - name: Confirm slot and remove old containers
        env:
            SLOT_IPS: ${{ steps.get_ips.outputs.slot_ips }}
            CONFIRM_VERSION: ${{ github.event.inputs.confirm_version }}
            JUMP_HOST: ${{ secrets.JUMP_SSH_HOST }}
        run: |
            mkdir -p ~/.ssh
            ssh-keyscan -H "$JUMP_HOST" >> ~/.ssh/known_hosts

            echo "📦 SLOT_IPS 값: $SLOT_IPS"
            IFS=',' read -ra IPS <<< "$SLOT_IPS"
            echo "🔍 분해된 IP 배열: ${IPS[@]}"
            echo "🔍 배열 길이: ${#IPS[@]}"

            for IP in "${IPS[@]}"; do
            echo "🧹 Cleaning old slot on $IP..."

            ssh -o StrictHostKeyChecking=no \
                -o UserKnownHostsFile=/dev/null \
                -o ProxyCommand="ssh -i ~/.ssh/jump_key -W %h:%p ubuntu@$JUMP_HOST" \
                -i ~/.ssh/dev_key ubuntu@$IP \
                CONFIRM_VERSION=$CONFIRM_VERSION bash -s <<'EOF'
                set -ex


                RUNNING_CONTAINERS=$(sudo docker ps --format '{{.Names}}' | grep '^onthetop-backend-' | wc -l)
                INSTANCE_NAME=$(hostname)
                SUMMARY+="[$INSTANCE_NAME] 🔎 처음 실행 중 컨테이너 수: $RUNNING_CONTAINERS\n"

                if [ "$RUNNING_CONTAINERS" -ne 2 ]; then
                echo "❌ Error: '$INSTANCE_NAME' 인스턴스에서 실행 중인 컨테이너 수가 $RUNNING_CONTAINERS 개입니다."
                sudo docker ps --format '  → {{.Names}}  ({{.Status}})' | grep '^  → onthetop-backend-' || true
                exit 1
                fi

                # 1. 현재 Nginx가 바라보는 포트 확인
                ACTIVE_PORT=$(grep "proxy_pass" /etc/nginx/sites-enabled/backend | grep -oE '[0-9]+')

                # 2. 각 슬롯의 버전 확인
                BLUE_VERSION=$(sudo docker inspect --format='{{index .Config.Image}}' onthetop-backend-blue 2>/dev/null | cut -d: -f2 || echo "")
                GREEN_VERSION=$(sudo docker inspect --format='{{index .Config.Image}}' onthetop-backend-green 2>/dev/null | cut -d: -f2 || echo "")

                # 3. ACTIVE_PORT 기준으로 실제 Nginx가 사용하는 슬롯 결정
                if [ "$ACTIVE_PORT" = "8080" ]; then
                  ACTIVE_SLOT=blue
                elif [ "$ACTIVE_PORT" = "8081" ]; then
                  ACTIVE_SLOT=green
                else
                  echo "❌ Nginx 설정에서 포트를 찾을 수 없습니다."
                  exit 1
                fi

                # 4. Nginx가 바라보는 컨테이너가 CONFIRM_VERSION인지 확인
                ACTIVE_CONTAINER="onthetop-backend-$ACTIVE_SLOT"
                ACTIVE_VERSION=$(sudo docker inspect --format='{{index .Config.Image}}' "$ACTIVE_CONTAINER" 2>/dev/null | cut -d: -f2 || echo "")


                IS_RUNNING=$(sudo docker inspect -f '{{.State.Running}}' "$ACTIVE_CONTAINER" 2>/dev/null || echo "false")
                if [ "$IS_RUNNING" != "true" ]; then
                  echo "❌ Error: Nginx가 바라보는 컨테이너 '$ACTIVE_CONTAINER'가 실행 중이 아닙니다!"
                  sudo docker ps -a --format '→ {{.Names}} ({{.Status}})' | grep onthetop-backend- || true
                  exit 1
                fi


                if [ "$ACTIVE_VERSION" != "$CONFIRM_VERSION" ]; then
                  echo "❌ Error: Nginx가 바라보는 슬롯 $ACTIVE_SLOT의 버전($ACTIVE_VERSION)이 CONFIRM_VERSION($CONFIRM_VERSION)과 다릅니다."
                  exit 1
                fi

                echo "✅ Nginx가 바라보는 슬롯 '$ACTIVE_SLOT'의 버전이 CONFIRM_VERSION=$CONFIRM_VERSION 과 일치합니다. 불필요한 슬롯을 정리합니다..."

                # 5. 불필요한 슬롯 제거
                for SLOT in blue green; do
                  CONTAINER="onthetop-backend-$SLOT"
                  VERSION=$(sudo docker inspect --format='{{index .Config.Image}}' "$CONTAINER" 2>/dev/null | cut -d: -f2 || echo "")
                  if [ "$SLOT" = "$ACTIVE_SLOT" ]; then
                    echo "✅ 유지: $CONTAINER (version: $VERSION)"
                    SUMMARY+="[$INSTANCE_NAME] ✅ 유지: $CONTAINER (version: $VERSION)\n"
                  elif [ "$VERSION" = "$CONFIRM_VERSION" ]; then
                    echo "🗑️ 삭제: $CONTAINER (동일 버전이지만 Nginx가 사용하지 않음)"
                    sudo docker rm -f "$CONTAINER"
                    SUMMARY+="[$INSTANCE_NAME] 🗑️ 삭제: $CONTAINER (동일 버전이지만 비활성)\n"
                  elif [ -n "$VERSION" ]; then
                    echo "🗑️ 삭제: $CONTAINER (version: $VERSION ≠ $CONFIRM_VERSION)"
                    sudo docker rm -f "$CONTAINER"
                    SUMMARY+="[$INSTANCE_NAME] 🗑️ 삭제: $CONTAINER (version: $VERSION)\n"
                  else
                    echo "❎ 존재하지 않음: $CONTAINER"
                    SUMMARY+="[$INSTANCE_NAME] ❎ 존재하지 않음: $CONTAINER\n"
                  fi
                done
                

                echo -e "\n📋 [$INSTANCE_NAME] 컨테이너 정리 요약:"
                echo -e "$SUMMARY"
            EOF
            done

📦 참고 기술 스택

  • GCP Managed Instance Group (MIG)
  • Docker
  • Nginx 포트 기반 라우팅 (8080/8081)
  • Jump Host Proxy SSH
  • GitHub Actions
  • Bash + Docker CLI

결과

0개의 댓글