CI CD 파이프라인 구성 - 100-hours-a-week/KTB4-16th-Cloud GitHub Wiki
name: Frontend CI main
on:
push:
branches:
- main
- develop
pull_request:
branches:
- develop
env:
ZONE: asia-northeast3-a
jobs:
# ============================================================
# develop PR: 머지 전 검증만 (배포 없음)
# ============================================================
pr-ci:
if: github.event_name == 'pull_request'
name: Frontend develop PR Check
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout code
uses: actions/checkout@v7
with:
# PR 검증은 최신 커밋만 검사하므로 얕은 복사(shallow clone) 사용
fetch-depth: 1
- name: Set up Node.js
uses: actions/setup-node@v7
with:
node-version: "24"
cache: "npm"
- name: Install dependencies
run: npm ci
# Phase 1: 스타일 검사
- name: Run Prettier
run: npm run prettier
# Phase 2: Lint 검사
- name: Run ESLint
run: npm run lint
# Phase 3: TypeScript 타입 검사
- name: Typecheck
run: npm run typecheck
# PR 검증 결과 알림
notify-pr:
name: Discord notification (Develop PR)
runs-on: ubuntu-latest
needs: pr-ci
if: ${{ always() && github.event_name == 'pull_request' }}
permissions: {}
env:
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
steps:
- name: Send CI result to Discord
if: ${{ env.DISCORD_WEBHOOK != '' }}
uses: Ilshidur/[email protected]
with:
args: "Frontend develop PR 검증 결과 알림"
env:
DISCORD_USERNAME: MURO_BOT
DISCORD_EMBEDS: |
[
{
"title": "Frontend PR CI: ${{ needs.pr-ci.result }}",
"description": ${{ toJSON(github.event.pull_request.title) }},
"url": "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}",
"color": ${{ needs.pr-ci.result == 'success' && 3066993 || 15158332 }},
"fields": [
{
"name": "PR",
"value": "[#${{ github.event.pull_request.number }}](${{ github.event.pull_request.html_url }})",
"inline": true
},
{
"name": "작성자",
"value": ${{ toJSON(github.event.pull_request.user.login) }},
"inline": true
},
{
"name": "작업 브랜치",
"value": ${{ toJSON(github.head_ref) }},
"inline": true
},
{
"name": "대상 브랜치",
"value": ${{ toJSON(github.base_ref) }},
"inline": true
}
]
}
]
# ============================================================
# main push: 운영 릴리즈 (기존 로직 그대로, ref_name 가드만 추가)
# ============================================================
main-ci:
if: github.event_name == 'push' && github.ref_name == 'main'
name: Frontend CI (main release)
runs-on: ubuntu-latest
concurrency:
group: frontend-main-release
cancel-in-progress: false
# 워크플로우 권한 정의
permissions:
contents: write # Git 태그, Release 생성
id-token: write # GCP Workload Identity와 통신하기 위한 OIDC 토큰 발행 권한
env:
IMAGE_BASE: ${{ secrets.GCP_REGION }}-docker.pkg.dev/${{ secrets.GCP_PROJECT_ID }}/${{ secrets.PROJECT_REPOSITORY }}/${{ secrets.IMAGE_NAME }}
steps:
# 1. 코드 체크아웃
- name: Checkout code
uses: actions/checkout@v7
with:
fetch-depth: 0
# 2. Node.js
- name: Set up Node.js
uses: actions/setup-node@v7
with:
node-version: "24"
# 3. 의존성 설치
- name: Install dependencies
run: npm ci
# Phase 1: Prettier
- name: Run Prettier
run: npm run prettier
# Phase 2: Lint (ESlint)
- name: Run ESlint
run: npm run lint
# Phase 3: TypeScript 타입 오류 검사
- name: Typecheck
run: npm run typecheck
# Phase 4: Build
- name: Build
run: npm run build
# Phase 5: Docker Build 준비
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
# Phase 6: OIDC를 이용한 서비스 계정 인증
- name: Authenticate to Google Cloud
uses: google-github-actions/auth@v3
with:
project_id: ${{ secrets.GCP_PROJECT_ID }}
workload_identity_provider: ${{ secrets.WORKLOAD_IDENTITY_PROVIDER }}
service_account: ${{ secrets.SERVICE_ACCOUNT }}
# Phase 7: gcloud 명령어 사용 준비
- name: Set up gcloud
uses: google-github-actions/setup-gcloud@v3
# Phase 8: Docker가 GCP 인증 정보를 사용하도록 연결
- name: Configure Docker authentication
run: |
gcloud auth configure-docker "${{ secrets.GCP_REGION }}-docker.pkg.dev" --quiet
# Phase 9: 버전 계산 -> image build, push -> Git 태그, Release 생성
- name: Release frontend image
env:
GITHUB_TOKEN: ${{ secrets.GIT_TOKEN }}
# release.config.main.json의 prepareCmd(docker buildx build)가 이 값들을 참조
VITE_SENTRY_DSN: ${{ secrets.VITE_SENTRY_DSN }}
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
run: |
cp release.config.main.json .releaserc.json
npx --yes \
--package=semantic-release@25 \
--package=@semantic-release/exec@7 \
semantic-release
main-notify:
name: Discord notification (main release)
runs-on: ubuntu-latest
needs: main-ci
# CI가 실패해도 알림 작업 실행
if: ${{ always() && github.event_name == 'push' && github.ref_name == 'main' }}
permissions: {}
env:
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
steps:
- name: Send CI result to Discord
if: ${{ env.DISCORD_WEBHOOK != '' }}
uses: Ilshidur/[email protected]
with:
args: "Frontend main CI 결과 알림"
env:
DISCORD_USERNAME: MURO_BOT
DISCORD_EMBEDS: |
[
{
"title": "Frontend CI: ${{ needs.main-ci.result }}",
"description": ${{ toJSON(github.event.head_commit.message || '커밋 메시지 없음') }},
"url": "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}",
"color": ${{ needs.main-ci.result == 'success' && 3066993 || 15158332 }},
"fields": [
{
"name": "커밋",
"value": "[커밋 확인](${{ github.server_url }}/${{ github.repository }}/commit/${{ github.sha }})",
"inline": true
},
{
"name": "푸시한 사람",
"value": ${{ toJSON(github.actor) }},
"inline": true
},
{
"name": "브랜치",
"value": ${{ toJSON(github.ref_name) }}
}
]
}
]
# ============================================================
# develop push: 개발서버 릴리즈 + 배포
# ============================================================
# 2. 개발 서버 빌드 (develop 브랜치 머지 시 실행)
push-ci:
if: github.event_name == 'push' && github.ref_name == 'develop'
name: Frontend develop Release
runs-on: ubuntu-latest
concurrency:
group: frontend-develop-release
cancel-in-progress: false
permissions:
contents: write # Git 태그 및 GitHub Release 생성 권한
id-token: write # GCP Workload Identity OIDC 인증 권한
issues: write
pull-requests: write
env:
IMAGE_BASE: ${{ secrets.GCP_REGION }}-docker.pkg.dev/${{ secrets.GCP_PROJECT_ID_Develop }}/${{ secrets.PROJECT_REPOSITORY }}/${{ secrets.IMAGE_NAME }}
steps:
- name: Checkout code
uses: actions/checkout@v7
with:
# semantic-release가 커밋 로그와 태그를 추적해야 하므로 전체 히스토리 필요
fetch-depth: 0
- name: Set up Node.js
uses: actions/setup-node@v7
with:
node-version: "24"
cache: "npm"
- name: Install dependencies
run: npm ci
# Phase 1: 애플리케이션 빌드
- name: Build
run: npm run build
# Phase 2: Docker Buildx 설정
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
# Phase 3: GCP 인증 (OIDC)
- name: Authenticate to Google Cloud
uses: google-github-actions/auth@v3
with:
project_id: ${{ secrets.GCP_PROJECT_ID_Develop }}
workload_identity_provider: ${{ secrets.WORKLOAD_IDENTITY_PROVIDER_Develop }}
service_account: ${{ secrets.SERVICE_ACCOUNT_Develop }}
# Phase 4: gcloud CLI 준비
- name: Set up gcloud
uses: google-github-actions/setup-gcloud@v3
# Phase 5: Artifact Registry Docker 인증
- name: Configure Docker authentication
run: |
gcloud auth configure-docker "${{ secrets.GCP_REGION }}-docker.pkg.dev" --quiet
# release.config.develop.json을 .releaserc.json으로 복사해서 쓴다 (main-ci와 같은 방식).
- name: Release frontend image
env:
GITHUB_TOKEN: ${{ secrets.GIT_TOKEN }}
run: |
cp release.config.develop.json .releaserc.json
npx --yes \
--package=semantic-release@25 \
--package=@semantic-release/exec@7 \
semantic-release
# 3. Develop VM에 프론트엔드 새 버전을 자동으로 배포한다.
deploy:
name: Deploy to Develop VM
runs-on: ubuntu-latest
needs: push-ci
if: github.event_name == 'push' && github.ref_name == 'develop'
permissions:
contents: read
id-token: write # GCP WIF 인증용 OIDC 토큰 발급 권한
outputs:
vm: ${{ steps.develop.outputs.vm }}
steps:
# WIF로 GCP 서비스 계정 인증 (키 파일 없이 임시 토큰 발급)
- id: auth
uses: google-github-actions/auth@v3
with:
project_id: ${{ secrets.GCP_PROJECT_ID_Develop }}
workload_identity_provider: ${{ secrets.WORKLOAD_IDENTITY_PROVIDER_Develop }}
service_account: ${{ secrets.SERVICE_ACCOUNT_Develop }}
- uses: google-github-actions/setup-gcloud@v3
# 1. 현재 role=develop 라벨이 붙은 VM을 찾는다.
- id: develop
run: |
VM=$(gcloud compute instances list \
--filter="labels.role=develop" \
--zones="${{ env.ZONE }}" \
--format="value(name)")
if [ -z "$VM" ]; then
echo "role=develop 라벨을 가진 VM을 찾을 수 없습니다."
exit 1
fi
echo "vm=$VM" >> "$GITHUB_OUTPUT"
# 2. CI(semantic-release)가 방금 push한 이미지 중 가장 최근 태그를 조회한다.
- id: image
run: |
TAG=$(gcloud artifacts docker images list \
"${{ secrets.GCP_REGION }}-docker.pkg.dev/${{ secrets.GCP_PROJECT_ID_Develop }}/${{ secrets.PROJECT_REPOSITORY }}/${{ secrets.IMAGE_NAME }}" \
--include-tags --sort-by="~UPDATE_TIME" --limit=1 \
--format="value(TAGS)" | cut -d',' -f1)
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
# 3. 빈 env 파일 생성
- run: touch frontend.env
# 4. env 파일을 develop VM으로 전송하고, 새 이미지를 pull해서 frontend 컨테이너만 교체한다.
# 같은 VM 위의 backend/ai/nginx 컨테이너는 건드리지 않는다.
- run: |
gcloud compute scp frontend.env "${{ steps.develop.outputs.vm }}:~/app/frontend.env" \
--zone="${{ env.ZONE }}" --tunnel-through-iap
gcloud compute ssh "${{ steps.develop.outputs.vm }}" \
--zone="${{ env.ZONE }}" --tunnel-through-iap --command="
set -e
sudo gcloud auth configure-docker '${{ secrets.GCP_REGION }}-docker.pkg.dev' --quiet
cd ~/app
export IMAGE_TAG=${{ steps.image.outputs.tag }}
export GCP_PROJECT_ID=${{ secrets.GCP_PROJECT_ID_Develop }}
sudo --preserve-env=IMAGE_TAG,GCP_PROJECT_ID docker compose pull frontend
sudo --preserve-env=IMAGE_TAG,GCP_PROJECT_ID docker compose up -d frontend
"
# 5. 컨테이너가 healthy 상태가 될 때까지 대기
- name: "Wait for frontend container to be healthy"
run: |
gcloud compute ssh "${{ steps.develop.outputs.vm }}" --zone="${{ env.ZONE }}" --tunnel-through-iap --command="
for i in \$(seq 1 20); do
status=\$(sudo docker inspect frontend --format='{{.State.Health.Status}}' 2>/dev/null || echo 'unhealthy')
if [ \"\$status\" = \"healthy\" ]; then
echo \"frontend healthy (시도 \$i/20)\"
exit 0
fi
echo \"대기 중... (\$i/20) 현재 상태: \$status\"
sleep 3
done
echo \"frontend가 60초 안에 healthy가 되지 않았습니다.\"
exit 1
"
# 6. nginx는 컨테이너 DNS를 시작/리로드 시점에만 캐싱하므로, 방금 재생성된
# frontend의 새 IP를 반영하도록 reload한다. 개발서버는 VM이 하나뿐이라
# 트래픽 전환 과정이 없어 이 단계가 빠져있었고, 그래서 이미지는 최신인데
# 실제로는 화면에 반영이 안 되는 문제가 있었다(2026-10-01 실제 발생).
- name: "Reload nginx to pick up new container IP"
run: |
gcloud compute ssh "${{ steps.develop.outputs.vm }}" --zone="${{ env.ZONE }}" --tunnel-through-iap --command="
sudo docker exec nginx nginx -s reload
"
# push CI 및 배포 결과 알림
notify-push:
name: Discord notification
runs-on: ubuntu-latest
needs: [push-ci, deploy]
if: ${{ always() && github.event_name == 'push' && github.ref_name == 'develop' }}
permissions: {}
env:
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
steps:
- name: Send CI result to Discord
if: ${{ env.DISCORD_WEBHOOK != '' }}
uses: Ilshidur/[email protected]
with:
args: "Frontend Develop push CI 및 개발 서버 배포 결과 알림"
env:
DISCORD_USERNAME: MURO_BOT
DISCORD_EMBEDS: |
[
{
"title": "Frontend Develop 배포: ${{ needs.deploy.result }} (CI: ${{ needs.push-ci.result }})",
"description": ${{ toJSON(github.event.head_commit.message || '커밋 메시지 없음') }},
"url": "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}",
"color": ${{ (needs.push-ci.result == 'success' && needs.deploy.result == 'success') && 3066993 || 15158332 }},
"fields": [
{
"name": "커밋",
"value": "[커밋 확인]",
"inline": true
},
{
"name": "푸시한 사람",
"value": ${{ toJSON(github.actor) }},
"inline": true
},
{
"name": "브랜치",
"value": ${{ toJSON(github.ref_name) }},
"inline": true
}
]
}
]
name: Backend CI
# main push -> 프로덕션 릴리즈, develop push -> 개발서버(Mulo test 프로젝트) 릴리즈+배포,
# develop PR -> 검증(checkstyle+compile)만. 세 흐름이 한 파일에 공존하므로 각 job은
# event_name/ref_name으로 자기 몫만 골라 실행되게 가드를 건다.
on:
push:
branches:
- main
- develop
pull_request:
branches:
- develop
env:
ZONE: asia-northeast3-a
jobs:
# ============================================================
# develop PR: 머지 전 검증만 (배포 없음)
# ============================================================
pr-ci:
if: github.event_name == 'pull_request'
name: Backend CI pull_request
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout code
uses: actions/checkout@v7
with:
fetch-depth: 1
- name: Set up JDK 21
uses: actions/setup-java@v6
with:
java-version: '21'
distribution: 'temurin'
- name: Set up Gradle
uses: gradle/actions/setup-gradle@v6
- name: Grant execute permission
run: chmod +x gradlew
# PR 게이트웨이 검사 유지
- name: Run Checkstyle
run: ./gradlew checkstyleMain
# 컴파일 에러 및 기본 테스트 검증
- name: Run Compile
run: ./gradlew bootJar
develop-pr-notify:
name: Discord notification (develop PR)
runs-on: ubuntu-latest
needs: pr-ci
if: ${{ always() && github.event_name == 'pull_request' }}
permissions: {}
env:
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
steps:
- name: Send CI result to Discord
if: ${{ env.DISCORD_WEBHOOK != '' }}
uses: Ilshidur/[email protected]
with:
args: "백엔드 Develop pull_request CI 결과 알림"
env:
DISCORD_USERNAME: MURO_BOT
DISCORD_EMBEDS: |
[
{
"title": "Backend CI: ${{ needs.pr-ci.result }}",
"description": ${{ toJSON(github.event.pull_request.title) }},
"url": "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}",
"color": ${{ needs.pr-ci.result == 'success' && 3066993 || 15158332 }},
"fields": [
{
"name": "PR",
"value": "[#${{ github.event.pull_request.number }}](${{ github.event.pull_request.html_url }})",
"inline": true
},
{
"name": "작성자",
"value": ${{ toJSON(github.event.pull_request.user.login) }},
"inline": true
},
{
"name": "작업 브랜치",
"value": ${{ toJSON(github.head_ref) }},
"inline": true
},
{
"name": "대상 브랜치",
"value": ${{ toJSON(github.base_ref) }},
"inline": true
}
]
}
]
# ============================================================
# main push: 프로덕션 릴리즈 (기존 로직 그대로, ref_name 가드만 추가)
# ============================================================
main-ci:
if: github.event_name == 'push' && github.ref_name == 'main'
name: Backend CI (main release)
runs-on: ubuntu-latest
concurrency:
group: backend-main-release
cancel-in-progress: false
permissions:
contents: write # Git 태그, Release 생성
id-token: write # GCP Workload Identity와 통신하기 위한 OIDC 토큰 발행 권한
env:
IMAGE_BASE: ${{ secrets.GCP_REGION }}-docker.pkg.dev/${{ secrets.GCP_PROJECT_ID }}/${{ secrets.PROJECT_REPOSITORY }}/${{ secrets.IMAGE_NAME }}
steps:
- name: Checkout code
uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Set up JDK 21
uses: actions/setup-java@v6
with:
java-version: '21'
distribution: 'temurin'
- name: Set up Gradle
uses: gradle/actions/setup-gradle@v6
- name: Grant execute permission
run: chmod +x gradlew
- name: Run Checkstyle
run: ./gradlew checkstyleMain
- name: Run Compile
run: ./gradlew bootJar
- name: Set up Node.js
uses: actions/setup-node@v7
with:
node-version: "24"
package-manager-cache: false
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Authenticate to Google Cloud
uses: google-github-actions/auth@v3
with:
project_id: ${{ secrets.GCP_PROJECT_ID }}
workload_identity_provider: ${{ secrets.WORKLOAD_IDENTITY_PROVIDER }}
service_account: ${{ secrets.SERVICE_ACCOUNT }}
- name: Set up gcloud
uses: google-github-actions/setup-gcloud@v3
- name: Configure Docker authentication
run: |
gcloud auth configure-docker "${{ secrets.GCP_REGION }}-docker.pkg.dev" --quiet
# release.config.main.json을 이 job 안에서만 .releaserc.json으로 복사해서 쓴다.
# `semantic-release --extends <path>`로 직접 넘기면 플러그인 모듈 해석 기준 경로가
# 달라져서 semantic-release 코어에 번들된 기본 플러그인(commit-analyzer 등)조차
# 못 찾는 문제가 있었다(2026-09-30 실제 발생, main-ci에서 재현 확인). cosmiconfig가
# 원래 쓰던 자동 탐색 방식 그대로 두고, 파일만 이 job 시작 시점에 맞바꿔 충돌을 피한다.
- name: Release backend image
env:
GITHUB_TOKEN: ${{ secrets.GIT_TOKEN }}
run: |
cp release.config.main.json .releaserc.json
npx --yes \
--package=semantic-release@25 \
--package=@semantic-release/exec@7 \
semantic-release
main-notify:
name: Discord notification (main release)
runs-on: ubuntu-latest
needs: main-ci
if: ${{ always() && github.event_name == 'push' && github.ref_name == 'main' }}
permissions: {}
env:
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
steps:
- name: Send CI result to Discord
if: ${{ env.DISCORD_WEBHOOK != '' }}
uses: Ilshidur/[email protected]
with:
args: "Backend main CI 결과 알림"
env:
DISCORD_USERNAME: MURO_BOT
DISCORD_EMBEDS: |
[
{
"title": "Backend CI: ${{ needs.main-ci.result }}",
"description": ${{ toJSON(github.event.head_commit.message || '커밋 메시지 없음') }},
"url": "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}",
"color": ${{ needs.main-ci.result == 'success' && 3066993 || 15158332 }},
"fields": [
{
"name": "커밋",
"value": "[커밋 확인](${{ github.server_url }}/${{ github.repository }}/commit/${{ github.sha }})",
"inline": true
},
{
"name": "푸시한 사람",
"value": ${{ toJSON(github.actor) }},
"inline": true
},
{
"name": "브랜치",
"value": ${{ toJSON(github.ref_name) }},
"inline": true
}
]
}
]
# ============================================================
# develop push: 개발서버(별도 GCP 프로젝트 "Mulo test") 릴리즈 + 배포
# ============================================================
push-ci:
if: github.event_name == 'push' && github.ref_name == 'develop'
name: Backend CI (develop release)
runs-on: ubuntu-latest
concurrency:
group: backend-develop-release
cancel-in-progress: false
permissions:
contents: write
id-token: write
issues: write
pull-requests: write
env:
IMAGE_BASE: ${{ secrets.GCP_REGION }}-docker.pkg.dev/${{ secrets.GCP_PROJECT_ID_Develop }}/${{ secrets.PROJECT_REPOSITORY }}/${{ secrets.IMAGE_NAME }}
steps:
- name: Checkout code
uses: actions/checkout@v7
with:
# semantic-release가 Git 태그 및 히스토리를 분석해야 하므로 0 유지 필수
fetch-depth: 0
- name: Set up JDK 21
uses: actions/setup-java@v6
with:
java-version: '21'
distribution: 'temurin'
- name: Set up Gradle
uses: gradle/actions/setup-gradle@v6
- name: Grant execute permission
run: chmod +x gradlew
- name: Run Compile
run: ./gradlew bootJar
- name: Set up Node.js
uses: actions/setup-node@v7
with:
node-version: "24"
package-manager-cache: false
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Authenticate to Google Cloud
uses: google-github-actions/auth@v3
with:
project_id: ${{ secrets.GCP_PROJECT_ID_Develop }}
workload_identity_provider: ${{ secrets.WORKLOAD_IDENTITY_PROVIDER_Develop }}
service_account: ${{ secrets.SERVICE_ACCOUNT_Develop }}
- name: Set up gcloud
uses: google-github-actions/setup-gcloud@v3
- name: Configure Docker authentication
run: |
gcloud auth configure-docker "${{ secrets.GCP_REGION }}-docker.pkg.dev" --quiet
# release.config.develop.json을 .releaserc.json으로 복사해서 쓴다 (main-ci와 동일한 이유).
- name: Release backend image
env:
GITHUB_TOKEN: ${{ secrets.GIT_TOKEN }}
run: |
cp release.config.develop.json .releaserc.json
npx --yes \
--package=semantic-release@25 \
--package=@semantic-release/exec@7 \
semantic-release
# 개발서버(Mulo test 프로젝트) VM에 새 버전을 자동으로 배포한다.
deploy:
name: Deploy to Develop VM
runs-on: ubuntu-latest
needs: push-ci
if: github.event_name == 'push' && github.ref_name == 'develop'
permissions:
contents: read
id-token: write # GCP WIF 인증용 OIDC 토큰 발급 권한
outputs:
vm: ${{ steps.develop.outputs.vm }}
steps:
# WIF로 GCP 서비스 계정 인증 (키 파일 없이 임시 토큰 발급)
- id: auth
uses: google-github-actions/auth@v3
with:
project_id: ${{ secrets.GCP_PROJECT_ID_Develop }}
workload_identity_provider: ${{ secrets.WORKLOAD_IDENTITY_PROVIDER_Develop }}
service_account: ${{ secrets.SERVICE_ACCOUNT_Develop }}
- uses: google-github-actions/setup-gcloud@v3
# 1. 현재 role=develop 라벨이 붙은 VM을 찾는다.
- id: develop
run: |
VM=$(gcloud compute instances list \
--filter="labels.role=develop" \
--zones="${{ env.ZONE }}" \
--format="value(name)")
echo "vm=$VM" >> "$GITHUB_OUTPUT"
# 2. CI(semantic-release)가 방금 push한 이미지 중 가장 최근 태그를 조회한다.
# CD는 이미지를 직접 빌드하지 않고, CI가 이미 push해둔 걸 그대로 가져다 쓴다.
- id: image
run: |
TAG=$(gcloud artifacts docker images list \
"${{ secrets.GCP_REGION }}-docker.pkg.dev/${{ secrets.GCP_PROJECT_ID_Develop }}/${{ secrets.PROJECT_REPOSITORY }}/${{ secrets.IMAGE_NAME }}" \
--include-tags --sort-by="~UPDATE_TIME" --limit=1 \
--format="value(TAGS)" | cut -d',' -f1)
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
# 3. backend.env 파일을 GitHub Secrets 값으로 생성한다.
# 이 파일은 여기서만 잠깐 만들어지고, 실제 값은 워크플로 로그에 자동으로 마스킹된다.
- run: |
cat > backend.env <<ENVEOF
DB_URL=${{ secrets.DB_URL_Develop }}
DB_USERNAME=${{ secrets.DB_USERNAME_Develop }}
DB_PASSWORD=${{ secrets.DB_PASSWORD_Develop }}
JWT_SECRET=${{ secrets.JWT_SECRET }}
GCS_BUCKET_NAME=${{ secrets.GCS_BUCKET_NAME_Develop }}
GCS_PROJECT_ID=${{ secrets.GCP_PROJECT_ID_Develop }}
KAKAO_REST_API_KEY=${{ secrets.KAKAO_REST_API_KEY }}
KMA_SERVICE_KEY=${{ secrets.KMA_SERVICE_KEY }}
SPOTIFY_CLIENT_ID=${{ secrets.SPOTIFY_CLIENT_ID }}
SPOTIFY_CLIENT_SECRET=${{ secrets.SPOTIFY_CLIENT_SECRET }}
AI_BASE_URL=${{ secrets.AI_BASE_URL }}
AI_INTERNAL_TOKEN=${{ secrets.AI_INTERNAL_TOKEN }}
MULO_SECURITY_ALLOWED_ORIGINS=${{ secrets.MULO_SECURITY_ALLOWED_ORIGINS_Develop }}
REPORT_OPERATION_TOKEN=${{ secrets.REPORT_OPERATION_TOKEN }}
# 컨테이너 JVM 기본 시간대가 UTC라, java.sql.Timestamp -> LocalDateTime 변환 시
# (Timestamp가 상속하는 java.util.Date의 레거시 getter가 JVM 기본시간대를 씀)
# DB에 정상 저장된 KST 값을 9시간 밀려서 읽는 문제가 있었다. JVM 기본시간대 자체를
# 앱이 실제 쓰는 Asia/Seoul로 맞춰서 이 변환 단계의 시간대 불일치를 없앤다.
JAVA_TOOL_OPTIONS=-Duser.timezone=Asia/Seoul
ENVEOF
# 4. env 파일을 develop VM으로 전송하고, 새 이미지를 pull해서 backend 컨테이너만 교체한다.
# 같은 VM 위의 frontend/ai/nginx 컨테이너는 건드리지 않는다.
- run: |
gcloud compute scp backend.env "${{ steps.develop.outputs.vm }}:~/app/backend.env" \
--zone="${{ env.ZONE }}" --tunnel-through-iap
gcloud compute ssh "${{ steps.develop.outputs.vm }}" \
--zone="${{ env.ZONE }}" --tunnel-through-iap --command="
set -e
sudo gcloud auth configure-docker '${{ secrets.GCP_REGION }}-docker.pkg.dev' --quiet
cd ~/app
export IMAGE_TAG=${{ steps.image.outputs.tag }}
export GCP_PROJECT_ID=${{ secrets.GCP_PROJECT_ID_Develop }}
sudo --preserve-env=IMAGE_TAG,GCP_PROJECT_ID docker compose pull backend
sudo --preserve-env=IMAGE_TAG,GCP_PROJECT_ID docker compose up -d backend
"
# 5. 컨테이너가 healthy 상태가 될 때까지 대기 (비즈니스 로직 검증용 스모크 테스트는 아님).
# 이게 없으면 승인이 컨테이너 기동 완료 전에 빨리 눌렸을 때, 아직 준비 안 된
# 컨테이너로 바로 트래픽이 전환되어 502 다운타임이 생긴다. 최대 60초(3초 x 20회) 대기.
- name: "Wait for backend container to be healthy"
run: |
gcloud compute ssh "${{ steps.develop.outputs.vm }}" --zone="${{ env.ZONE }}" --tunnel-through-iap --command="
for i in \$(seq 1 20); do
status=\$(sudo docker inspect backend --format='{{.State.Health.Status}}')
if [ \"\$status\" = \"healthy\" ]; then
echo \"backend healthy (시도 \$i/20)\"
exit 0
fi
echo \"대기 중... (\$i/20) 현재 상태: \$status\"
sleep 3
done
echo \"backend가 60초 안에 healthy가 되지 않았습니다.\"
exit 1
"
develop-push-notify:
name: Discord notification (develop deploy)
runs-on: ubuntu-latest
needs: [push-ci, deploy]
if: ${{ always() && github.event_name == 'push' && github.ref_name == 'develop' }}
permissions: {}
env:
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
steps:
- name: Send CI result to Discord
if: ${{ env.DISCORD_WEBHOOK != '' }}
uses: Ilshidur/[email protected]
with:
args: "백엔드 Develop push CI 및 개발 서버 배포 결과 알림"
env:
DISCORD_USERNAME: MURO_BOT
DISCORD_EMBEDS: |
[
{
"title": "Backend Develop 배포: ${{ needs.deploy.result }} (CI: ${{ needs.push-ci.result }})",
"description": ${{ toJSON(github.event.head_commit.message || '커밋 메시지 없음') }},
"url": "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}",
"color": ${{ (needs.push-ci.result == 'success' && needs.deploy.result == 'success') && 3066993 || 15158332 }},
"fields": [
{
"name": "커밋",
"value": "[커밋 확인](${{ github.server_url }}/${{ github.repository }}/commit/${{ github.sha }})",
"inline": true
},
{
"name": "푸시한 사람",
"value": ${{ toJSON(github.actor) }},
"inline": true
},
{
"name": "브랜치",
"value": ${{ toJSON(github.ref_name) }},
"inline": true
}
]
}
]name: AI CI main
on:
push:
branches:
- main
# 버전 작업 동시에 실행되지 않게
concurrency:
group: ai-main-release
cancel-in-progress: false
jobs:
ci:
name: AI CI
runs-on: ubuntu-latest
# 워크플로우 권한 정의
permissions:
contents: write # Git 태그, Release 생성
id-token: write # GCP Workload Identity와 통신하기 위한 OIDC 토큰 발행 권한
env:
IMAGE_BASE: ${{ secrets.GCP_REGION }}-docker.pkg.dev/${{ secrets.GCP_PROJECT_ID }}/${{ secrets.PROJECT_REPOSITORY }}/${{ secrets.IMAGE_NAME }}
steps:
# 1. 코드 체크아웃
- name: Checkout code
uses: actions/checkout@v7
with:
fetch-depth: 0
# 2. Ruff 설치 + 린트 검사
- name: Ruff 실행
uses: astral-sh/ruff-action@v3
with:
args: "check"
# 3. Ruff로 포맷 검사
- name: Run Ruff Formatter Check
run: ruff format --check .
# 4. Python 환경 설정
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: "3.11"
# 5. 애플리케이션 의존성 설치
- name: Install dependencies
run: python -m pip install -r requirements.txt
# 6. 컴파일
- name: Check Python syntax
run: python -m compileall .
# Phase 1: semantic-release 실행 환경 구성
- name: Set up Node.js
uses: actions/setup-node@v7
with:
node-version: "24"
package-manager-cache: false
# Phase 2: Docker Build 준비
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
# Phase 3: OIDC를 이용한 서비스 계정 인증
- name: Authenticate to Google Cloud
uses: google-github-actions/auth@v3
with:
project_id: ${{ secrets.GCP_PROJECT_ID }}
workload_identity_provider: ${{ secrets.WORKLOAD_IDENTITY_PROVIDER }}
service_account: ${{ secrets.SERVICE_ACCOUNT }}
# Phase 4: gcloud 명령어 사용 준비
- name: Set up gcloud
uses: google-github-actions/setup-gcloud@v3
# Phase 5: Docker가 GCP 인증 정보를 사용하도록 연결
- name: Configure Docker authentication
run: |
gcloud auth configure-docker "${{ secrets.GCP_REGION }}-docker.pkg.dev" --quiet
# Phase 6: 버전 계산 -> image build, push -> Git 태그, Release 생성
- name: Release AI image
env:
GITHUB_TOKEN: ${{ secrets.GIT_TOKEN }}
run: |
npx --yes \
--package=semantic-release@25 \
--package=@semantic-release/exec@7 \
semantic-release
notify:
name: Discord notification
runs-on: ubuntu-latest
needs: ci
# CI가 실패해도 알림 작업 실행
if: ${{ always() }}
permissions: {}
env:
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
steps:
- name: Send CI result to Discord
if: ${{ env.DISCORD_WEBHOOK != '' }}
uses: Ilshidur/[email protected]
with:
args: "AI main CI 결과 알림"
env:
DISCORD_USERNAME: MURO_BOT
DISCORD_EMBEDS: |
[
{
"title": "AI CI: ${{ needs.ci.result }}",
"description": ${{ toJSON(github.event.head_commit.message || '커밋 메시지 없음') }},
"url": "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}",
"color": ${{ needs.ci.result == 'success' && 3066993 || 15158332 }},
"fields": [
{
"name": "커밋",
"value": "[커밋 확인](${{ github.server_url }}/${{ github.repository }}/commit/${{ github.sha }})",
"inline": true
},
{
"name": "푸시한 사람",
"value": ${{ toJSON(github.actor) }},
"inline": true
},
{
"name": "브랜치",
"value": ${{ toJSON(github.ref_name) }},
"inline": true
}
]
}
]https://github.com/vivekratnavel/omniboard/blob/master/.releaserc.json
https://docs.astral.sh/ruff/linter/#ruff-check
https://twentytwentyone.tistory.com/534
https://prettier.io/docs/cli#--check
https://weaklion1.tistory.com/37
https://dilution0216.tistory.com/185
https://semantic-release.org/recipes/ci-configurations/github-actions
name: Frontend CD
# CI(Frontend CI main)가 성공적으로 끝나면 자동으로 실행된다.
# workflow_dispatch는 CI 없이 재배포만 다시 시도하고 싶을 때 수동으로 쓰기 위한 것.
on:
workflow_run:
workflows: ["Frontend CI main"]
types: [completed]
# main push로 제한한다.
branches: [main]
workflow_dispatch:
env:
ZONE: asia-northeast3-a
jobs:
# ============================================================
# 1단계 job: Standby VM에 새 버전을 자동으로 배포한다.
# 스모크 테스트는 CI 쪽 테스트 코드로 대체하기로 해서 여기선 하지 않는다.
# 이 job이 실패하면 switch-traffic job은 아예 시작되지 않고 승인 요청도 안 뜬다.
# ============================================================
deploy:
if: ${{ github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success' }}
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write # GCP WIF 인증용 OIDC 토큰 발급 권한
outputs:
standby_vm: ${{ steps.standby.outputs.vm }} # switch-traffic job에서도 이 값을 써야 함
steps:
# WIF로 GCP 서비스 계정 인증 (키 파일 없이 임시 토큰 발급)
- id: auth
uses: google-github-actions/auth@v2
with:
workload_identity_provider: ${{ secrets.WORKLOAD_IDENTITY_PROVIDER }}
service_account: ${{ secrets.SERVICE_ACCOUNT }}
- uses: google-github-actions/setup-gcloud@v2
# 1. 현재 role=standby 라벨이 붙은 VM을 찾는다.
# 배포는 항상 Standby(트래픽 안 받는 쪽)에만 한다. Active는 여기서 절대 안 건드린다.
- id: standby
run: |
VM=$(gcloud compute instances list \
--filter="labels.role=standby" \
--zones="${{ env.ZONE }}" \
--format="value(name)")
echo "vm=$VM" >> "$GITHUB_OUTPUT"
# 2. CI(semantic-release)가 방금 push한 이미지 중 가장 최근 태그를 조회한다.
# CD는 이미지를 직접 빌드하지 않고, CI가 이미 push해둔 걸 그대로 가져다 쓴다.
- id: image
run: |
TAG=$(gcloud artifacts docker images list \
"${{ secrets.GCP_REGION }}-docker.pkg.dev/${{ secrets.GCP_PROJECT_ID }}/${{ secrets.PROJECT_REPOSITORY }}/${{ secrets.IMAGE_NAME }}" \
--include-tags --sort-by="~UPDATE_TIME" --limit=1 \
--format="value(TAGS)" | cut -d',' -f1)
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
# 3. frontend.env 생성.
# Vite 빌드 산출물은 런타임 env가 아니라 CI 빌드 타임에 값이 굳어지는 구조라
# (VITE_* 값은 이미 CI에서 빌드된 정적 파일에 포함됨), 컨테이너 실행에는 별도
# 런타임 환경변수가 필요 없다. 다만 docker-compose.yml이 env_file로 이 파일을
# 참조하므로, 파일 자체가 없으면 docker compose가 실패한다 — 매 배포마다 빈
# 파일을 새로 만들어 VM으로 보낸다 (VM에 수동으로 만들어둔 파일에 의존하지 않기 위함).
- run: touch frontend.env
# 4. env 파일을 Standby VM으로 전송하고, 새 이미지를 pull해서 frontend 컨테이너만 교체한다.
# 같은 VM 위의 backend/ai/nginx 컨테이너는 건드리지 않는다.
- run: |
gcloud compute scp frontend.env "${{ steps.standby.outputs.vm }}:~/app/frontend.env" \
--zone="${{ env.ZONE }}" --tunnel-through-iap
gcloud compute ssh "${{ steps.standby.outputs.vm }}" --zone="${{ env.ZONE }}" --tunnel-through-iap --command="
cd ~/app &&
export IMAGE_TAG=${{ steps.image.outputs.tag }} &&
docker compose pull frontend &&
docker compose up -d frontend
"
# 5. 컨테이너가 healthy 상태가 될 때까지 대기 (Backend CD와 동일한 패턴).
# 이게 없으면 승인이 컨테이너 기동 완료 전에 빨리 눌렸을 때 502 다운타임이 생긴다.
- name: "Wait for frontend container to be healthy"
run: |
gcloud compute ssh "${{ steps.standby.outputs.vm }}" --zone="${{ env.ZONE }}" --tunnel-through-iap --command="
for i in \$(seq 1 20); do
status=\$(sudo docker inspect frontend --format='{{.State.Health.Status}}')
if [ \"\$status\" = \"healthy\" ]; then
echo \"frontend healthy (시도 \$i/20)\"
exit 0
fi
echo \"대기 중... (\$i/20) 현재 상태: \$status\"
sleep 3
done
echo \"frontend가 60초 안에 healthy가 되지 않았습니다.\"
exit 1
"
# ============================================================
# 2단계 job: 실제 트래픽 전환 (Blue-Green Switch).
# environment: production 이 붙어있어서, GitHub Settings에서 지정한 승인자가
# Actions 탭에서 "Approve and deploy"를 눌러야만 이 job이 실행된다.
# deploy가 실패하면 이 job은 아예 시작되지 않고 승인 요청도 안 뜬다.
# ============================================================
switch-traffic:
needs: deploy
runs-on: ubuntu-latest
environment: production
permissions:
contents: read
id-token: write
steps:
- id: auth
uses: google-github-actions/auth@v2
with:
workload_identity_provider: ${{ secrets.WORKLOAD_IDENTITY_PROVIDER }}
service_account: ${{ secrets.SERVICE_ACCOUNT }}
- uses: google-github-actions/setup-gcloud@v2
# 6. 현재 role=active인 VM(지금까지 트래픽을 받던 VM)을 찾는다.
# 7. 고정 IP를 Active VM에서 떼어내고 검증이 끝난 Standby VM에 붙인다.
# 이 순간부터 실제 사용자 트래픽이 새 버전으로 넘어간다.
# 8. 라벨을 서로 바꾼다 — 방금 전환된 VM이 새 active, 기존 active는 새 standby가 되어
# 다음 배포 때 그쪽이 배포 대상이 된다.
- run: |
ACTIVE_VM=$(gcloud compute instances list \
--filter="labels.role=active" \
--zones="${{ env.ZONE }}" \
--format="value(name)")
STANDBY_VM="${{ needs.deploy.outputs.standby_vm }}"
# ACTIVE와 STANDBY가 같으면 이미 전환됐거나 오래된 승인 요청이다 — 라벨/IP를 망가뜨리지 않도록 중단한다.
if [ "$ACTIVE_VM" = "$STANDBY_VM" ]; then
echo "ACTIVE_VM과 STANDBY_VM이 동일합니다 ($ACTIVE_VM). 이미 전환되었거나 오래된 승인 요청일 수 있어 중단합니다."
exit 1
fi
# 세 CD(backend/frontend/ai)가 서로 독립적으로 이 라벨/IP를 바꾸다 보니, 이
# CD가 스위치해도 다른 두 서비스는 이 VM에 예전에 마지막으로 배포됐던 버전이 남아있을 수도 있음.
# active가 되기 전에 다른 서비스들도 최신 태그인지 확인해 동기화한다.
for SERVICE in backend ai; do
LATEST=$(gcloud artifacts docker images list \
"${{ secrets.GCP_REGION }}-docker.pkg.dev/${{ secrets.GCP_PROJECT_ID }}/$SERVICE/$SERVICE" \
--include-tags --sort-by="~UPDATE_TIME" --limit=1 --format="value(TAGS)" | cut -d',' -f1)
gcloud compute ssh "$STANDBY_VM" --zone="${{ env.ZONE }}" --tunnel-through-iap --command="
cd ~/app &&
CURRENT=\$(docker inspect $SERVICE --format='{{.Config.Image}}' | rev | cut -d: -f1 | rev) &&
if [ \"\$CURRENT\" != \"$LATEST\" ]; then
echo \"$SERVICE 동기화: \$CURRENT -> $LATEST\" &&
export IMAGE_TAG=$LATEST &&
docker compose pull $SERVICE &&
docker compose up -d $SERVICE
else
echo \"$SERVICE 이미 최신 (\$CURRENT)\"
fi
"
done
gcloud compute instances delete-access-config "$ACTIVE_VM" \
--zone="${{ env.ZONE }}" --access-config-name="External NAT"
# Cloud NAT 미도입 상태라 standby가 자체 임시 외부 IP를 갖고 있을 수 있다.
# 고정 IP를 붙이기 전에 기존 access-config를 먼저 제거한다 (없으면 무시하고 진행).
gcloud compute instances delete-access-config "$STANDBY_VM" \
--zone="${{ env.ZONE }}" --access-config-name="External NAT" || true
gcloud compute instances add-access-config "$STANDBY_VM" \
--zone="${{ env.ZONE }}" --access-config-name="External NAT" \
--address="${{ secrets.MURO_PROD_IP }}"
gcloud compute instances add-labels "$STANDBY_VM" --zone="${{ env.ZONE }}" --labels=role=active
gcloud compute instances add-labels "$ACTIVE_VM" --zone="${{ env.ZONE }}" --labels=role=standby
# 임시 외부 IP를 다시 붙여서 인터넷 접속을 유지시켜준다.
gcloud compute instances add-access-config "$ACTIVE_VM" \
--zone="${{ env.ZONE }}" --access-config-name="External NAT"
# 9. nginx는 backend/frontend를 도커 서비스명(예: server frontend:3000)으로 프록시하는데,
# 시작/리로드 시점에 한 번만 DNS를 해석해 캐싱한다. 방금 재배포로 frontend 컨테이너의
# IP가 바뀌었으므로, 지금 active가 된 이 VM의 nginx를 reload해 새 IP를 반영한다
# (2026-09-28 backend에서 이 문제로 전체 502가 났던 사고의 재발 방지).
gcloud compute ssh "$STANDBY_VM" --zone="${{ env.ZONE }}" --tunnel-through-iap --command="
sudo docker exec nginx nginx -s reload
"name: Backend CD
# CI(Backend CI)가 성공적으로 끝나면 자동으로 실행된다.
# workflow_dispatch는 CI 없이 재배포만 다시 시도하고 싶을 때 수동으로 쓰기 위한 것.
# 2026-10-01: CI.yaml이 develop 병합 과정에서 "main CI with backend" -> "Backend CI"로
# 이름이 바뀌었는데 여기를 안 맞춰서, 그 사이 Backend CD가 자동으로 전혀 안 돌았다.
on:
workflow_run:
workflows: ["Backend CI"]
types: [completed]
# CI 트리거를 develop 등 다른 브랜치로 넓혀도 CD가 따라 돌지 않도록 방어적으로 명시한다.
branches: [main]
workflow_dispatch:
env:
ZONE: asia-northeast3-a
jobs:
# ============================================================
# 1단계 job: Standby VM에 새 버전을 자동으로 배포한다.
# 스모크 테스트는 CI 쪽 테스트 코드로 대체하기로 해서 여기선 하지 않는다.
# 이 job이 실패하면 switch-traffic job은 아예 시작되지 않고 승인 요청도 안 뜬다.
# ============================================================
deploy:
if: ${{ github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success' }}
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write # GCP WIF 인증용 OIDC 토큰 발급 권한
outputs:
standby_vm: ${{ steps.standby.outputs.vm }} # switch-traffic job에서도 이 값을 써야 함
steps:
# WIF로 GCP 서비스 계정 인증 (키 파일 없이 임시 토큰 발급)
- id: auth
uses: google-github-actions/auth@v2
with:
workload_identity_provider: ${{ secrets.WORKLOAD_IDENTITY_PROVIDER }}
service_account: ${{ secrets.SERVICE_ACCOUNT }}
- uses: google-github-actions/setup-gcloud@v2
# 1. 현재 role=standby 라벨이 붙은 VM을 찾는다.
# 배포는 항상 Standby(트래픽 안 받는 쪽)에만 한다. Active는 여기서 절대 안 건드린다.
- id: standby
run: |
VM=$(gcloud compute instances list \
--filter="labels.role=standby" \
--zones="${{ env.ZONE }}" \
--format="value(name)")
echo "vm=$VM" >> "$GITHUB_OUTPUT"
# 2. CI(semantic-release)가 방금 push한 이미지 중 가장 최근 태그를 조회한다.
# CD는 이미지를 직접 빌드하지 않고, CI가 이미 push해둔 걸 그대로 가져다 쓴다.
- id: image
run: |
TAG=$(gcloud artifacts docker images list \
"${{ secrets.GCP_REGION }}-docker.pkg.dev/${{ secrets.GCP_PROJECT_ID }}/${{ secrets.PROJECT_REPOSITORY }}/${{ secrets.IMAGE_NAME }}" \
--include-tags --sort-by="~UPDATE_TIME" --limit=1 \
--format="value(TAGS)" | cut -d',' -f1)
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
# 3. backend.env 파일을 GitHub Secrets 값으로 생성한다.
# 이 파일은 여기서만 잠깐 만들어지고, 실제 값은 워크플로 로그에 자동으로 마스킹된다.
- run: |
cat > backend.env <<ENVEOF
DB_URL=${{ secrets.DB_URL }}
DB_USERNAME=${{ secrets.DB_USERNAME }}
DB_PASSWORD=${{ secrets.DB_PASSWORD }}
JWT_SECRET=${{ secrets.JWT_SECRET }}
SENTRY_DSN=${{ secrets.SENTRY_DSN }}
GCS_BUCKET_NAME=${{ secrets.GCS_BUCKET_NAME }}
GCS_PROJECT_ID=${{ secrets.GCP_PROJECT_ID }}
KAKAO_REST_API_KEY=${{ secrets.KAKAO_REST_API_KEY }}
KMA_SERVICE_KEY=${{ secrets.KMA_SERVICE_KEY }}
SPOTIFY_CLIENT_ID=${{ secrets.SPOTIFY_CLIENT_ID }}
SPOTIFY_CLIENT_SECRET=${{ secrets.SPOTIFY_CLIENT_SECRET }}
AI_BASE_URL=${{ secrets.AI_BASE_URL }}
AI_INTERNAL_TOKEN=${{ secrets.AI_INTERNAL_TOKEN }}
MULO_SECURITY_ALLOWED_ORIGINS=${{ secrets.MULO_SECURITY_ALLOWED_ORIGINS }}
REPORT_OPERATION_TOKEN=${{ secrets.REPORT_OPERATION_TOKEN }}
# 컨테이너 JVM 기본 시간대가 UTC라, java.sql.Timestamp -> LocalDateTime 변환 시
# (Timestamp가 상속하는 java.util.Date의 레거시 getter가 JVM 기본시간대를 씀)
# DB에 정상 저장된 KST 값을 9시간 밀려서 읽는 문제가 있었다. JVM 기본시간대 자체를
# 앱이 실제 쓰는 Asia/Seoul로 맞춰서 이 변환 단계의 시간대 불일치를 없앤다.
JAVA_TOOL_OPTIONS=-Duser.timezone=Asia/Seoul
ENVEOF
# 4. env 파일을 Standby VM으로 전송하고, 새 이미지를 pull해서 backend 컨테이너만 교체한다.
# 같은 VM 위의 frontend/ai/nginx 컨테이너는 건드리지 않는다.
- run: |
gcloud compute scp backend.env "${{ steps.standby.outputs.vm }}:~/app/backend.env" \
--zone="${{ env.ZONE }}" --tunnel-through-iap
gcloud compute ssh "${{ steps.standby.outputs.vm }}" --zone="${{ env.ZONE }}" --tunnel-through-iap --command="
cd ~/app &&
export IMAGE_TAG=${{ steps.image.outputs.tag }} &&
docker compose pull backend &&
docker compose up -d backend
"
# 5. 컨테이너가 healthy 상태가 될 때까지 대기 (비즈니스 로직 검증용 스모크 테스트는 아님).
# 이게 없으면 승인이 컨테이너 기동 완료 전에 빨리 눌렸을 때, 아직 준비 안 된
# 컨테이너로 바로 트래픽이 전환되어 502 다운타임이 생긴다. 최대 60초(3초 x 20회) 대기.
- name: "Wait for backend container to be healthy"
run: |
gcloud compute ssh "${{ steps.standby.outputs.vm }}" --zone="${{ env.ZONE }}" --tunnel-through-iap --command="
for i in \$(seq 1 20); do
status=\$(sudo docker inspect backend --format='{{.State.Health.Status}}')
if [ \"\$status\" = \"healthy\" ]; then
echo \"backend healthy (시도 \$i/20)\"
exit 0
fi
echo \"대기 중... (\$i/20) 현재 상태: \$status\"
sleep 3
done
echo \"backend가 60초 안에 healthy가 되지 않았습니다.\"
exit 1
"
# ============================================================
# 2단계 job: 실제 트래픽 전환 (Blue-Green Switch).
# environment: production 이 붙어있어서, GitHub Settings에서 지정한 승인자가
# Actions 탭에서 "Approve and deploy"를 눌러야만 이 job이 실행된다.
# deploy가 실패하면 이 job은 아예 시작되지 않고 승인 요청도 안 뜬다.
# ============================================================
switch-traffic:
needs: deploy
runs-on: ubuntu-latest
environment: production
permissions:
contents: read
id-token: write
steps:
- id: auth
uses: google-github-actions/auth@v2
with:
workload_identity_provider: ${{ secrets.WORKLOAD_IDENTITY_PROVIDER }}
service_account: ${{ secrets.SERVICE_ACCOUNT }}
- uses: google-github-actions/setup-gcloud@v2
# 6. 현재 role=active인 VM(지금까지 트래픽을 받던 VM)을 찾는다.
# 7. 고정 IP를 Active VM에서 떼어내고 검증이 끝난 Standby VM에 붙인다.
# 이 순간부터 실제 사용자 트래픽이 새 버전으로 넘어간다.
# 8. 라벨을 서로 바꾼다 — 방금 전환된 VM이 새 active, 기존 active는 새 standby가 되어
# 다음 배포 때 그쪽이 배포 대상이 된다.
- run: |
ACTIVE_VM=$(gcloud compute instances list \
--filter="labels.role=active" \
--zones="${{ env.ZONE }}" \
--format="value(name)")
STANDBY_VM="${{ needs.deploy.outputs.standby_vm }}"
# ACTIVE와 STANDBY가 같으면 이미 전환됐거나 오래된 승인 요청이다 — 라벨/IP를 망가뜨리지 않도록 중단한다.
if [ "$ACTIVE_VM" = "$STANDBY_VM" ]; then
echo "ACTIVE_VM과 STANDBY_VM이 동일합니다 ($ACTIVE_VM). 이미 전환되었거나 오래된 승인 요청일 수 있어 중단합니다."
exit 1
fi
# 세 CD(backend/frontend/ai)가 서로 독립적으로 이 라벨/IP를 바꾸다 보니, 이
# CD가 스위치해도 다른 두 서비스는 이 VM에 예전에 마지막으로 배포됐던 버전이 남아있을 수도 있음.
# active가 되기 전에 다른 서비스들도 최신 태그인지 확인해 동기화한다.
for SERVICE in frontend ai; do
LATEST=$(gcloud artifacts docker images list \
"${{ secrets.GCP_REGION }}-docker.pkg.dev/${{ secrets.GCP_PROJECT_ID }}/$SERVICE/$SERVICE" \
--include-tags --sort-by="~UPDATE_TIME" --limit=1 --format="value(TAGS)" | cut -d',' -f1)
gcloud compute ssh "$STANDBY_VM" --zone="${{ env.ZONE }}" --tunnel-through-iap --command="
cd ~/app &&
CURRENT=\$(docker inspect $SERVICE --format='{{.Config.Image}}' | rev | cut -d: -f1 | rev) &&
if [ \"\$CURRENT\" != \"$LATEST\" ]; then
echo \"$SERVICE 동기화: \$CURRENT -> $LATEST\" &&
export IMAGE_TAG=$LATEST &&
docker compose pull $SERVICE &&
docker compose up -d $SERVICE
else
echo \"$SERVICE 이미 최신 (\$CURRENT)\"
fi
"
done
gcloud compute instances delete-access-config "$ACTIVE_VM" \
--zone="${{ env.ZONE }}" --access-config-name="External NAT"
# Cloud NAT 미도입 상태라 standby가 자체 임시 외부 IP를 갖고 있을 수 있다.
# 고정 IP를 붙이기 전에 기존 access-config를 먼저 제거한다 (없으면 무시하고 진행).
gcloud compute instances delete-access-config "$STANDBY_VM" \
--zone="${{ env.ZONE }}" --access-config-name="External NAT" || true
gcloud compute instances add-access-config "$STANDBY_VM" \
--zone="${{ env.ZONE }}" --access-config-name="External NAT" \
--address="${{ secrets.MURO_PROD_IP }}"
gcloud compute instances add-labels "$STANDBY_VM" --zone="${{ env.ZONE }}" --labels=role=active
gcloud compute instances add-labels "$ACTIVE_VM" --zone="${{ env.ZONE }}" --labels=role=standby
# 방금 standby가 된 VM(옛 active)은 고정 IP를 뗀 채로 남는다.
# Cloud NAT이 없는 상태라 이대로 두면 다음 배포 때 이미지 pull이 안 되므로,
# 임시(ephemeral) 외부 IP를 다시 붙여서 인터넷 접속을 유지시켜준다.
gcloud compute instances add-access-config "$ACTIVE_VM" \
--zone="${{ env.ZONE }}" --access-config-name="External NAT"name: AI CD
on:
workflow_run:
workflows: ["AI CI main"]
types: [completed]
workflow_dispatch:
env:
ZONE: asia-northeast3-a
jobs:
# 스모크 테스트는 CI 쪽 테스트 코드로 대체하기로 해서 여기선 배포만 한다.
deploy:
if: ${{ github.event_name == 'workflow_dispatch' || (github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.head_branch == 'main') }}
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
outputs:
standby_vm: ${{ steps.standby.outputs.vm }}
steps:
- id: auth
uses: google-github-actions/auth@v2
with:
workload_identity_provider: ${{ secrets.WORKLOAD_IDENTITY_PROVIDER }}
service_account: ${{ secrets.SERVICE_ACCOUNT }}
- uses: google-github-actions/setup-gcloud@v2
# 1. 현재 Standby VM 판별
- id: standby
run: |
VM=$(gcloud compute instances list \
--filter="labels.role=standby" \
--zones="${{ env.ZONE }}" \
--format="value(name)")
echo "vm=$VM" >> "$GITHUB_OUTPUT"
# 2. CI가 방금 push한 최신 이미지 태그 조회
- id: image
run: |
TAG=$(gcloud artifacts docker images list \
"${{ secrets.GCP_REGION }}-docker.pkg.dev/${{ secrets.GCP_PROJECT_ID }}/${{ secrets.PROJECT_REPOSITORY }}/${{ secrets.IMAGE_NAME }}" \
--include-tags --sort-by="~UPDATE_TIME" --limit=1 \
--format="value(TAGS)" | cut -d',' -f1)
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
# 3. ai.env 생성 (GitHub Secrets 값으로)
- run: |
cat > ai.env <<ENVEOF
APP_ENV=${{ secrets.APP_ENV }}
INTERNAL_TOKEN=${{ secrets.INTERNAL_TOKEN }}
DATABASE_URL=${{ secrets.DATABASE_URL }}
AI_CLIENT_MODE=${{ secrets.AI_CLIENT_MODE }}
ANTHROPIC_API_KEY=${{ secrets.ANTHROPIC_API_KEY }}
LLM_MODEL=${{ secrets.LLM_MODEL }}
OPENAI_API_KEY=${{ secrets.OPENAI_API_KEY }}
TEXT_EMBEDDING_MODEL=${{ secrets.TEXT_EMBEDDING_MODEL }}
TEXT_EMBEDDING_DIM=${{ secrets.TEXT_EMBEDDING_DIM }}
REPLICATE_API_TOKEN=${{ secrets.REPLICATE_API_TOKEN }}
CLIP_MODEL=${{ secrets.CLIP_MODEL }}
CLIP_EMBEDDING_DIM=${{ secrets.CLIP_EMBEDDING_DIM }}
SPOTIFY_CLIENT_ID=${{ secrets.SPOTIFY_CLIENT_ID }}
SPOTIFY_CLIENT_SECRET=${{ secrets.SPOTIFY_CLIENT_SECRET }}
YOUTUBE_API_KEY=${{ secrets.YOUTUBE_API_KEY }}
BACKEND_MYSQL_URL=${{ secrets.BACKEND_MYSQL_URL }}
BACKEND_INTERNAL_URL=${{ secrets.BACKEND_INTERNAL_URL }}
RUN_WORKER=false
ENVEOF
# 4. Standby VM에 env 전송 + ai 컨테이너만 새 이미지로 교체
- run: |
gcloud compute scp ai.env "${{ steps.standby.outputs.vm }}:~/app/ai.env" \
--zone="${{ env.ZONE }}" --tunnel-through-iap
gcloud compute ssh "${{ steps.standby.outputs.vm }}" --zone="${{ env.ZONE }}" --tunnel-through-iap --command="
cd ~/app &&
export IMAGE_TAG=${{ steps.image.outputs.tag }} &&
docker compose pull ai &&
docker compose up -d ai
"
switch-traffic:
needs: deploy
runs-on: ubuntu-latest
environment: production
permissions:
contents: read
id-token: write
steps:
- id: auth
uses: google-github-actions/auth@v2
with:
workload_identity_provider: ${{ secrets.WORKLOAD_IDENTITY_PROVIDER }}
service_account: ${{ secrets.SERVICE_ACCOUNT }}
- uses: google-github-actions/setup-gcloud@v2
# 6~8. 현재 Active VM 판별 → 고정 IP를 Standby로 이전 → 라벨 교체
- run: |
ACTIVE_VM=$(gcloud compute instances list \
--filter="labels.role=active" \
--zones="${{ env.ZONE }}" \
--format="value(name)")
STANDBY_VM="${{ needs.deploy.outputs.standby_vm }}"
# ACTIVE와 STANDBY가 같으면 이미 전환됐거나 오래된 승인 요청이다 — 라벨/IP를 망가뜨리지 않도록 중단한다.
if [ "$ACTIVE_VM" = "$STANDBY_VM" ]; then
echo "ACTIVE_VM과 STANDBY_VM이 동일합니다 ($ACTIVE_VM). 이미 전환되었거나 오래된 승인 요청일 수 있어 중단합니다."
exit 1
fi
# 세 CD(backend/frontend/ai)가 서로 독립적으로 이 라벨/IP를 바꾸다 보니, 이
# CD가 스위치해도 다른 두 서비스는 이 VM에 예전에 마지막으로 배포됐던 버전
# 그대로 남아있을 수 있다(2026-09-30 backend/frontend 양쪽에서 실제 발생).
# active가 되기 전에 다른 서비스들도 최신 태그인지 확인해 동기화한다.
for SERVICE in backend frontend; do
LATEST=$(gcloud artifacts docker images list \
"${{ secrets.GCP_REGION }}-docker.pkg.dev/${{ secrets.GCP_PROJECT_ID }}/$SERVICE/$SERVICE" \
--include-tags --sort-by="~UPDATE_TIME" --limit=1 --format="value(TAGS)" | cut -d',' -f1)
gcloud compute ssh "$STANDBY_VM" --zone="${{ env.ZONE }}" --tunnel-through-iap --command="
cd ~/app &&
CURRENT=\$(docker inspect $SERVICE --format='{{.Config.Image}}' | rev | cut -d: -f1 | rev) &&
if [ \"\$CURRENT\" != \"$LATEST\" ]; then
echo \"$SERVICE 동기화: \$CURRENT -> $LATEST\" &&
export IMAGE_TAG=$LATEST &&
docker compose pull $SERVICE &&
docker compose up -d $SERVICE
else
echo \"$SERVICE 이미 최신 (\$CURRENT)\"
fi
"
done
# 새 운영 VM 워커 켜기 — 아직 트래픽을 안 받는 상태라 재시작해도 서비스 영향 없음.
# IMAGE_TAG는 deploy job의 SSH 세션에서만 export됐고 여긴 새 세션이라 비어있으므로,
# 재pull 없이 그 VM이 지금 실제로 돌리고 있는 이미지 태그를 그대로 읽어서 재사용한다.
gcloud compute ssh "$STANDBY_VM" --zone="${{ env.ZONE }}" --tunnel-through-iap --command="
cd ~/app &&
export IMAGE_TAG=\$(docker inspect ai --format='{{.Config.Image}}' | cut -d: -f2) &&
sed -i 's/^RUN_WORKER=.*/RUN_WORKER=true/' ai.env &&
docker compose up -d --force-recreate ai
"
gcloud compute instances delete-access-config "$ACTIVE_VM" \
--zone="${{ env.ZONE }}" --access-config-name="External NAT"
# Cloud NAT 미도입 상태라 standby가 자체 임시 외부 IP를 갖고 있을 수 있다.
# 고정 IP를 붙이기 전에 기존 access-config를 먼저 제거한다 (없으면 무시하고 진행).
gcloud compute instances delete-access-config "$STANDBY_VM" \
--zone="${{ env.ZONE }}" --access-config-name="External NAT" || true
gcloud compute instances add-access-config "$STANDBY_VM" \
--zone="${{ env.ZONE }}" --access-config-name="External NAT" \
--address="${{ secrets.MURO_PROD_IP }}"
gcloud compute instances add-labels "$STANDBY_VM" --zone="${{ env.ZONE }}" --labels=role=active
gcloud compute instances add-labels "$ACTIVE_VM" --zone="${{ env.ZONE }}" --labels=role=standby
# 방금 standby가 된 VM(옛 active)은 고정 IP를 뗀 채로 남는다.
# Cloud NAT이 없는 상태라 이대로 두면 다음 배포 때 이미지 pull이 안 되므로,
# 임시(ephemeral) 외부 IP를 다시 붙여서 인터넷 접속을 유지시켜준다.
gcloud compute instances add-access-config "$ACTIVE_VM" \
--zone="${{ env.ZONE }}" --access-config-name="External NAT"
# 옛 운영 VM 워커 끄기 — 이미 트래픽이 빠진 상태라 재시작해도 서비스 영향 없음.
# 여기도 새 세션이라 IMAGE_TAG가 비어있으므로, 이 VM이 지금 돌리고 있는(옛) 이미지
# 태그를 그대로 읽어서 재사용한다 — 새로 배포된 태그로 실수로 덮어쓰지 않기 위함이다.
gcloud compute ssh "$ACTIVE_VM" --zone="${{ env.ZONE }}" --tunnel-through-iap --command="
cd ~/app &&
export IMAGE_TAG=\$(docker inspect ai --format='{{.Config.Image}}' | cut -d: -f2) &&
(grep -q '^RUN_WORKER=' ai.env && sed -i 's/^RUN_WORKER=.*/RUN_WORKER=false/' ai.env || echo 'RUN_WORKER=false' >> ai.env) &&
docker compose up -d --force-recreate ai
"- 참고자료 : https://blog.cliveai.com/setting-up-ci-cd-from-github-actions-to-a-virtual-machine-on-google-cloud-platform-gcp-5859a9a7a77d
- 참고자료 : https://dev.to/ivanchiou/cicd-pipeline-using-github-actions-to-deploy-to-google-cloud-platform-gcp-3105
- 참고자료 : https://medium.com/@pichakul03/ci-cd-pipelines-with-github-actions-and-gcp-4dc12c650a71
- 참고자료 : https://shiba-holic.tistory.com/82
- 참고자료 : https://medium.com/cloudtype/github-actions-private-registry%EB%A5%BC-%ED%99%9C%EC%9A%A9%ED%95%9C-ci-cd-%ED%8C%8C%EC%9D%B4%ED%94%84%EB%9D%BC%EC%9D%B8-%EA%B5%AC%EC%B6%95%ED%95%98%EA%B8%B0-a20e82af27fb
- GCP 서비스 계정(SA)에 Artifact Registry에 이미지 push할 권한을 준다.
- GCP한테 "내 GitHub 레포에서 오는 요청이면 저 서비스 계정을 잠깐 빌려줘도 된다"고 등록해둔다 (이게 WIF Pool + Provider + 바인딩).
- GitHub Actions는 실행될 때마다 "나 진짜 이 레포에서 실행 중이야"라는 일회용 신분증(OIDC 토큰)을 만들고, GCP는 그걸 확인해서 2번 등록된 요청이면 임시 토큰을 내어준다. 그 토큰으로 push한다.
- 키 파일을 저장 안 해도 되는 이유는 "영구 비밀번호" 대신 "매번 새로 발급되는 1회용 출입증"을 쓰기 때문
PROJECT_ID="project-7b071b48-8637-467a-80d"
SA_NAME="github-actions-ci"
GITHUB_ORG="100-hours-a-week"1) 서비스 계정 생성 + Artifact Registry 권한
gcloud iam service-accounts create"github-actions-ci" \
--project="project-7b071b48-8637-467a-80d" \
--display-name="GitHub Actions CI"
gcloud projects add-iam-policy-binding"project-7b071b48-8637-467a-80d" \
--member="serviceAccount:"github-actions-ci"@"project-7b071b48-8637-467a-80d".iam.gserviceaccount.com" \
--role="roles/artifactregistry.writer"2) Workload Identity Pool 생성
gcloudiamworkload-identity-poolscreate"github-pool"\
--project="project-7b071b48-8637-467a-80d"\
--location="global"\
--display-name="GitHub Actions Pool"3) Provider 생성
gcloud iam workload-identity-pools providers create-oidc "github-provider" \
--project="project-7b071b48-8637-467a-80d" \
--location="global" \
--workload-identity-pool="github-pool" \
--display-name="GitHub Provider" \
--attribute-mapping="google.subject=assertion.sub,attribute.repository=assertion.repository" \
--attribute-condition="assertion.repository_owner=='100-hours-a-week'" \
--issuer-uri="https://token.actions.githubusercontent.com"4) 서비스 계정 바인딩 — 레포 3개
for REPOin"KTB4-16th-FE""KTB4-16th-BE""KTB4-16th-AI";do
gcloudiamservice-accountsadd-iam-policy-binding\
""github-actions-ci"@"project-7b071b48-8637-467a-80d".iam.gserviceaccount.com"\
--project="project-7b071b48-8637-467a-80d"\
--role="roles/iam.workloadIdentityUser"\
--member="principalSet://iam.googleapis.com/projects/953840519216/locations/global/workloadIdentityPools/github-pool/attribute.repository/100-hours-a-week/KTB4-16th-FE"
--member="principalSet://iam.googleapis.com/projects/953840519216/locations/global/workloadIdentityPools/github-pool/attribute.repository/100-hours-a-week/KTB4-16th-BE"
--member="principalSet://iam.googleapis.com/projects/953840519216/locations/global/workloadIdentityPools/github-pool/attribute.repository/100-hours-a-week/KTB4-16th-AI"
done5) Provider 전체 경로 확인 (워크플로우에 넣을 값)
gcloud iam workload-identity-pools providers describe "github-provider" \
--project="project-7b071b48-8637-467a-80d" \
--location="global" \
--workload-identity-pool="github-pool" \
--format="value(name)"- CD 를 위한 권한 부여
- Compute Engine(VM)에 SSH로 로그인할 때, 가장 높은 관리자(root/sudo) 권한
- IAP 터널 우회 접속 권한 부여
gcloud projects add-iam-policy-binding "project-7b071b48-8637-467a-80d" \
--member="serviceAccount:github-actions-ci@project-7b071b48-8637-467a-80d.iam.gserviceaccount.com" \
--role="roles/compute.osAdminLogin"
gcloud projects add-iam-policy-binding "project-7b071b48-8637-467a-80d" \
--member="serviceAccount:github-actions-ci@project-7b071b48-8637-467a-80d.iam.gserviceaccount.com" \
--role="roles/iap.tunnelResourceAccessor"최종 값들
GCP_PROJECT_ID = project-7b071b48-8637-467a-80d
WORKLOAD_IDENTITY_PROVIDER = projects/953840519216/locations/global/workloadIdentityPools/github-pool/providers/github-provider
SERVICE_ACCOUNT = github-actions-ci@project-7b071b48-8637-467a-80d.iam.gserviceaccount.com
ARTIFACT_REGISTRY_HOST = asia-northeast3-docker.pkg.dev