클라우드 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 전환을 제어합니다.
deploy
Job – 백엔드 & 프론트엔드 동시 배포
2. 2‑1. 공통 준비
- 코드 체크아웃 (Infra Repo)
- GCP 서비스 계정 인증 :
google-github-actions/auth@v2
- gcloud SDK 설치
- 변수 설정 : 입력값을 환경 변수·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를 제거
-
기존 템플릿·MIG 정리
- 존재하면 삭제, 없으면 통과 (idempotent)
-
Health Check 보장 (없으면 생성)
-
Instance Template 생성
--metadata=startup-version
에 버전 주입--metadata-from-file=startup-script
로 셸 부트스트랩
-
MIG 생성 (1 인스턴스, 초기 딜레이 180s)
-
Named Port 설정 (
http:8080
) -
MIG 상태 확인 루프
for i in {1..30}; do # jq로 HEALTHY 여부 계산 done
-
Load Balancer 백엔드 교체
backend-services add-backend
→ 새 MIG 추가- 헬스 확인 후 롤백 로직 포함
- 슬롯 기준으로 이전 MIG 식별 후 제거
cdn-update
Job – CloudFront 오리진 전환
3. deploy
Job이 끝나면 이어서 실행됩니다.
- 배포 중인 배포판 설정 읽기 →
aws cloudfront get-distribution-config
- OriginPath 수정 →
/frontend/prod/{slot}
- 배포판 업데이트 (ETag 필수)
- 캐시 무효화 : 루트·index.html·정적 자산
왜 별도 Job인가?
- 백엔드 배포 성공 여부와 무관하게 FE 오리진만 교체하도록 책임 분리
needs: deploy
를 통해 의존성 선언
4. 안전 장치 & 롤백 전략
단계 | 실패 시 동작 |
---|---|
MIG Health Loop | 타임아웃 → 워크플로 실패 |
LB Health Check | 새 MIG 제거 후 종료 → 트래픽은 이전 MIG로 유지 |
추가로, GCP Instance Template과 MIG 이름에 버전 태깅을 적용하여 수동 롤백도 쉽습니다.
5. 마무리
이 워크플로는 다음과 같은 장점을 가집니다.
- 완전 무중단: 새 슬롯 가동 → 헬스체크 통과 → 트래픽 스위칭
- 단일 소스 CI/CD: GitHub Actions에서 인프라·앱·CDN까지 관리
- 멱등성: 기존 리소스가 있어도 안전하게 삭제 또는 건너뜀
- 가시성: Slack 알림·GitHub Checks로 배포 이력 추적 (필요 시 추가)
운영 배포를 자동화하면서도 사람이 언제든지 개입할 수 있게 설계된 것이 핵심입니다. Blue‑Green 슬롯 구조 덕분에 서비스 중단 없이 새로운 기능을 배포하고, 문제가 생기면 즉시 이전 버전으로 되돌릴 수 있습니다.