CI CD 파이프라인 구현 - 100-hours-a-week/KTB4-16th-Cloud GitHub Wiki
| 구분 | v1 | v2 |
|---|---|---|
| 검증 순서 | 정적 검사 → 컴파일 | 정적 검사 → 컴파일 → 테스트 실행 → 커버리지 검사 |
| 테스트 | CI에서 실행하지 않음 | CI에서 자동 실행 |
| 커버리지 | 측정하지 않음 | 60% 미만이면 실패 (풀스택, AI 팀 개발 이후 변경 가능성 있음) |
| 대상 서비스 | Backend, Frontend, AI | Backend, Chat Server, AI |
| 파일 구성 | 서비스당 CI 파일 하나에 main, develop, PR 흐름이 같이 있음 | dev, prod 파일로 분리 |
| 개발 서버 배포 | CI 파일 안의 deploy job | CD 파일(cd-dev.yml)로 이동 |
- CI는 검증과 이미지 릴리즈까지만 하고 배포는 CD가 맡는다. 그래서 CI 파일 안에 있던 개발 서버 배포를 cd-dev.yml로 옮겼다.
- 파일을 나눴기 때문에 브랜치는 on 블록에서 정해진다. job마다 걸어 둔 ref_name 가드는 없앴고, event_name 가드(push인지 pull_request인지)는 그대로 뒀다.
- 파일 이름과 워크플로 name이 바뀌어서 CD의 workflow_run.workflows도 같이 바꿨다. 이름이 다르면 오류 없이 CD가 안 돌기 때문에 CI name을 바꿀 때는 CD도 같이 봐야 한다.
서비스마다 .github/workflows 아래에 4개를 둔다. develop과 main 두 브랜치에 같은 내용으로 둔다.
| 파일 | 워크플로 name | 트리거 | 하는 일 |
|---|---|---|---|
| ci-dev.yml | (서비스) CI dev | develop 대상 PR, develop push | PR 검증, 개발 이미지 릴리즈 |
| ci-prod.yml | (서비스) CI prod | main 대상 PR, main push | PR 검증, 프로덕션 이미지 릴리즈 |
| cd-dev.yml | (서비스) CD dev | CI dev 성공(develop), 수동 | 개발 서버 배포 |
| cd-prod.yml | (서비스) CD prod | CI prod 성공(main), 수동 | MIG Rolling Update |
- CI 파일은 이 파트(1번), CD 파일은 2번 CD 파트에 서비스별로 정리했다.
- Chat Server는 개발 서버에 배포하지 않아서 cd-dev.yml이 없다. cd-prod.yml도 수동 실행만 한다.
- pr-ci와 pr-notify는 dev, prod 파일 내용이 같고 job 이름과 알림 문구만 다르다. 검사 내용을 바꿀 때는 두 파일을 같이 고쳐야 한다.
- 커버리지 기준은 60%로 한다. 테스트 코드를 처음 도입하는 단계라서 핵심 기능의 정상 동작과 예외 동작 테스트를 먼저 확보하는 것이 목표다.
- 측정 도구는 Backend와 Chat Server는 JaCoCo, AI Server는 coverage.py를 쓴다.
- 합격 여부는 Line 커버리지로 판단하고, Branch 커버리지는 리포트로만 확인한다.
- 테스트가 하나라도 실패하거나 커버리지가 60% 미만이면 해당 검사가 실패한다.
- 60%라는 수치는 인프라팀에서 정한 수치이기 때문에 추후 풀스택, AI 팀의 개발 진행도를 확인한 후 변경 가능성이 있다.
- 병합 차단은 GitHub 저장소 Settings → Branches의 브랜치 보호 규칙에서 6번의 job을 Required status checks로 지정해야 동작한다.
v1 대비 변경점은 다음과 같다.
- pull_request 대상 브랜치에 main 추가 (develop에 PR 없이 직접 push하는 경우가 있기 때문에 - CI 수정, CD 이름 수정 같은 것)
- pr-ci: 컴파일을 compileJava compileTestJava로 바꾸고, 테스트 및 커버리지 검사와 리포트 업로드를 추가
- main-ci: 테스트 및 커버리지 검사를 추가
- push-ci: 테스트 및 커버리지 검사를 추가
- 개발 서버 배포(deploy)는 CI에서 빼고 cd-dev.yml로 옮김. 알림도 CI 결과(ci-dev)와 배포 결과(cd-dev)로 나뉨
ci-dev.yml
name: Backend CI dev
# develop 브랜치 전용 CI. PR 검증, push 시 개발 이미지 릴리즈까지만 한다.
# 개발 서버 배포는 cd-dev.yml이 이 워크플로가 성공하면 이어서 한다.
on:
push:
branches:
- develop
pull_request:
branches:
- develop
jobs:
pr-ci:
name: Backend CI dev 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 compileJava compileTestJava
# 테스트 실행 + 커버리지 검사 (60% 미만이면 여기서 실패)
- name: Run Tests and Coverage Verification
run: ./gradlew test jacocoTestReport jacocoTestCoverageVerification
# 리포트는 실패했을 때도 확인할 수 있도록 항상 올린다
- name: Upload coverage report
if: ${{ always() }}
uses: actions/upload-artifact@v4
with:
name: jacoco-report
path: build/reports/jacoco/test/html
retention-days: 7
- name: Upload test report
if: ${{ failure() }}
uses: actions/upload-artifact@v4
with:
name: test-report
path: build/reports/tests/test
retention-days: 7
pr-notify:
name: Discord notification (dev PR)
runs-on: ubuntu-latest
needs: pr-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: "백엔드 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
}
]
}
]
push-ci:
if: github.event_name == 'push'
name: Backend CI dev 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
# 테스트 실행 + 커버리지 검사 (60% 미만이면 여기서 실패)
- name: Run Tests and Coverage Verification
run: ./gradlew test jacocoTestReport jacocoTestCoverageVerification
# 리포트는 실패했을 때도 확인할 수 있도록 항상 올린다
- name: Upload coverage report
if: ${{ always() }}
uses: actions/upload-artifact@v4
with:
name: jacoco-report
path: build/reports/jacoco/test/html
retention-days: 7
- name: Upload test report
if: ${{ failure() }}
uses: actions/upload-artifact@v4
with:
name: test-report
path: build/reports/tests/test
retention-days: 7
- name: Build jar
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
push-notify:
name: Discord notification (dev release)
runs-on: ubuntu-latest
needs: push-ci
if: ${{ always() && github.event_name == 'push' }}
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 Dev push CI 결과 알림"
env:
DISCORD_USERNAME: MURO_BOT
DISCORD_EMBEDS: |
[
{
"title": "Backend Dev 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' && 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
}
]
}
]ci-prod.yml
name: Backend CI prod
# main 브랜치 전용 CI. PR 검증, push 시 프로덕션 이미지 릴리즈까지만 한다.
# 프로덕션 배포는 cd-prod.yml이 이 워크플로가 성공하면 이어서 한다.
on:
push:
branches:
- main
pull_request:
branches:
- main
jobs:
pr-ci:
name: Backend CI prod 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 compileJava compileTestJava
# 테스트 실행 + 커버리지 검사 (60% 미만이면 여기서 실패)
- name: Run Tests and Coverage Verification
run: ./gradlew test jacocoTestReport jacocoTestCoverageVerification
# 리포트는 실패했을 때도 확인할 수 있도록 항상 올린다
- name: Upload coverage report
if: ${{ always() }}
uses: actions/upload-artifact@v4
with:
name: jacoco-report
path: build/reports/jacoco/test/html
retention-days: 7
- name: Upload test report
if: ${{ failure() }}
uses: actions/upload-artifact@v4
with:
name: test-report
path: build/reports/tests/test
retention-days: 7
pr-notify:
name: Discord notification (prod PR)
runs-on: ubuntu-latest
needs: pr-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: "백엔드 Main 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-ci:
if: github.event_name == 'push'
name: Backend CI prod 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
# 테스트 실행 + 커버리지 검사 (60% 미만이면 여기서 실패)
- name: Run Tests and Coverage Verification
run: ./gradlew test jacocoTestReport jacocoTestCoverageVerification
# 리포트는 실패했을 때도 확인할 수 있도록 항상 올린다
- name: Upload coverage report
if: ${{ always() }}
uses: actions/upload-artifact@v4
with:
name: jacoco-report
path: build/reports/jacoco/test/html
retention-days: 7
- name: Upload test report
if: ${{ failure() }}
uses: actions/upload-artifact@v4
with:
name: test-report
path: build/reports/tests/test
retention-days: 7
- name: Build jar
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으로 복사해서 쓴다.
- 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 (prod release)
runs-on: ubuntu-latest
needs: main-ci
if: ${{ always() && github.event_name == 'push' }}
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
}
]
}
]저장소는 KTB4-16th-CHAT이다. Backend와 같은 구조에서 개발 서버 배포 job(deploy)만 뺐다.
- Chat Server 테스트는 실제 MySQL이 있어야 돈다. ChatRoomRepositoryTest는 @DataJpaTest인데 내장 DB로 바꾸지 않고(replace = NONE), MuloChatApplicationTests는 @SpringBootTest라 Flyway 마이그레이션까지 실행한다. application.yaml의 CHAT_DB_URL에도 기본값이 없다.
- 그래서 테스트를 돌리는 job(pr-ci, main-ci, push-ci)에 MySQL 8.4 서비스 컨테이너를 붙이고, 테스트 스텝에 CHAT_DB_* 환경변수를 넣었다. CI 안에서만 쓰는 테스트 값이라 시크릿으로 만들지 않았다.
- 저장소에 checkstyle 플러그인, jacoco 설정, Dockerfile, release.config 파일이 아직 없어서 추가해야 한다. (build.gradle은 5번 참고)
- checkstyle.xml과 release.config.main.json, release.config.develop.json은 Backend 파일을 그대로 복사한다.
- Dockerfile은 Backend 것에서 HEIC 변환용 apt 설치를 빼고 포트를 application.yaml의 server.port인 8081로 바꾼다.
- 현재 develop 코드로 로컬에서 MySQL을 띄워 측정했더니 테스트 8개는 통과했고 Line 커버리지는 59%였다. 기준 60%에 1%p 모자라서 이대로 적용하면 CI가 실패한다. 테스트를 더 추가하기 전까지는 Required status check 지정을 미룬다.
ci-dev.yml
name: Chat CI dev
# develop 브랜치 전용 CI. PR 검증, push 시 개발 이미지 릴리즈까지만 한다.
# 개발 서버 배포는 cd-dev.yml이 이 워크플로가 성공하면 이어서 한다.
on:
push:
branches:
- develop
pull_request:
branches:
- develop
jobs:
pr-ci:
name: Chat CI dev pull_request
runs-on: ubuntu-latest
# 테스트가 실제 MySQL을 쓴다 (@DataJpaTest는 DB 교체 안 함, @SpringBootTest는 Flyway 실행)
services:
mysql:
image: mysql:8.4
env:
MYSQL_DATABASE: mulo_chat
MYSQL_USER: chat
MYSQL_PASSWORD: chat
MYSQL_ROOT_PASSWORD: root
ports:
- 3306:3306
options: >-
--health-cmd="mysqladmin ping -h localhost -u root -proot --silent"
--health-interval=5s
--health-timeout=5s
--health-retries=20
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 compileJava compileTestJava
# 테스트 실행 + 커버리지 검사 (60% 미만이면 여기서 실패)
- name: Run Tests and Coverage Verification
env:
CHAT_DB_URL: jdbc:mysql://127.0.0.1:3306/mulo_chat
CHAT_DB_USERNAME: chat
CHAT_DB_PASSWORD: chat
run: ./gradlew test jacocoTestReport jacocoTestCoverageVerification
# 리포트는 실패했을 때도 확인할 수 있도록 항상 올린다
- name: Upload coverage report
if: ${{ always() }}
uses: actions/upload-artifact@v4
with:
name: jacoco-report
path: build/reports/jacoco/test/html
retention-days: 7
- name: Upload test report
if: ${{ failure() }}
uses: actions/upload-artifact@v4
with:
name: test-report
path: build/reports/tests/test
retention-days: 7
pr-notify:
name: Discord notification (dev PR)
runs-on: ubuntu-latest
needs: pr-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: "Chat Develop pull_request CI 결과 알림"
env:
DISCORD_USERNAME: MURO_BOT
DISCORD_EMBEDS: |
[
{
"title": "Chat 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
}
]
}
]
push-ci:
if: github.event_name == 'push'
name: Chat CI dev release
runs-on: ubuntu-latest
# 테스트가 실제 MySQL을 쓴다 (@DataJpaTest는 DB 교체 안 함, @SpringBootTest는 Flyway 실행)
services:
mysql:
image: mysql:8.4
env:
MYSQL_DATABASE: mulo_chat
MYSQL_USER: chat
MYSQL_PASSWORD: chat
MYSQL_ROOT_PASSWORD: root
ports:
- 3306:3306
options: >-
--health-cmd="mysqladmin ping -h localhost -u root -proot --silent"
--health-interval=5s
--health-timeout=5s
--health-retries=20 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
# 테스트 실행 + 커버리지 검사 (60% 미만이면 여기서 실패)
- name: Run Tests and Coverage Verification
env:
CHAT_DB_URL: jdbc:mysql://127.0.0.1:3306/mulo_chat
CHAT_DB_USERNAME: chat
CHAT_DB_PASSWORD: chat
run: ./gradlew test jacocoTestReport jacocoTestCoverageVerification
# 리포트는 실패했을 때도 확인할 수 있도록 항상 올린다
- name: Upload coverage report
if: ${{ always() }}
uses: actions/upload-artifact@v4
with:
name: jacoco-report
path: build/reports/jacoco/test/html
retention-days: 7
- name: Upload test report
if: ${{ failure() }}
uses: actions/upload-artifact@v4
with:
name: test-report
path: build/reports/tests/test
retention-days: 7
- name: Build jar
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
push-notify:
name: Discord notification (dev release)
runs-on: ubuntu-latest
needs: push-ci
if: ${{ always() && github.event_name == 'push' }}
permissions: {}
env:
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
steps:
- name: Send CI result to Discord
if: ${{ env.DISCORD_WEBHOOK != '' }}
uses: Ilshidur/[email protected]
with:
args: "Chat Dev push CI 결과 알림"
env:
DISCORD_USERNAME: MURO_BOT
DISCORD_EMBEDS: |
[
{
"title": "Chat Dev 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' && 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
}
]
}
]ci-prod.yml
name: Chat CI prod
# main 브랜치 전용 CI. PR 검증, push 시 프로덕션 이미지 릴리즈까지만 한다.
# 프로덕션 배포는 cd-prod.yml이 이 워크플로가 성공하면 이어서 한다.
on:
push:
branches:
- main
pull_request:
branches:
- main
jobs:
pr-ci:
name: Chat CI prod pull_request
runs-on: ubuntu-latest
# 테스트가 실제 MySQL을 쓴다 (@DataJpaTest는 DB 교체 안 함, @SpringBootTest는 Flyway 실행)
services:
mysql:
image: mysql:8.4
env:
MYSQL_DATABASE: mulo_chat
MYSQL_USER: chat
MYSQL_PASSWORD: chat
MYSQL_ROOT_PASSWORD: root
ports:
- 3306:3306
options: >-
--health-cmd="mysqladmin ping -h localhost -u root -proot --silent"
--health-interval=5s
--health-timeout=5s
--health-retries=20
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 compileJava compileTestJava
# 테스트 실행 + 커버리지 검사 (60% 미만이면 여기서 실패)
- name: Run Tests and Coverage Verification
env:
CHAT_DB_URL: jdbc:mysql://127.0.0.1:3306/mulo_chat
CHAT_DB_USERNAME: chat
CHAT_DB_PASSWORD: chat
run: ./gradlew test jacocoTestReport jacocoTestCoverageVerification
# 리포트는 실패했을 때도 확인할 수 있도록 항상 올린다
- name: Upload coverage report
if: ${{ always() }}
uses: actions/upload-artifact@v4
with:
name: jacoco-report
path: build/reports/jacoco/test/html
retention-days: 7
- name: Upload test report
if: ${{ failure() }}
uses: actions/upload-artifact@v4
with:
name: test-report
path: build/reports/tests/test
retention-days: 7
pr-notify:
name: Discord notification (prod PR)
runs-on: ubuntu-latest
needs: pr-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: "Chat Main pull_request CI 결과 알림"
env:
DISCORD_USERNAME: MURO_BOT
DISCORD_EMBEDS: |
[
{
"title": "Chat 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-ci:
if: github.event_name == 'push'
name: Chat CI prod release
runs-on: ubuntu-latest
# 테스트가 실제 MySQL을 쓴다 (@DataJpaTest는 DB 교체 안 함, @SpringBootTest는 Flyway 실행)
services:
mysql:
image: mysql:8.4
env:
MYSQL_DATABASE: mulo_chat
MYSQL_USER: chat
MYSQL_PASSWORD: chat
MYSQL_ROOT_PASSWORD: root
ports:
- 3306:3306
options: >-
--health-cmd="mysqladmin ping -h localhost -u root -proot --silent"
--health-interval=5s
--health-timeout=5s
--health-retries=20 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
# 테스트 실행 + 커버리지 검사 (60% 미만이면 여기서 실패)
- name: Run Tests and Coverage Verification
env:
CHAT_DB_URL: jdbc:mysql://127.0.0.1:3306/mulo_chat
CHAT_DB_USERNAME: chat
CHAT_DB_PASSWORD: chat
run: ./gradlew test jacocoTestReport jacocoTestCoverageVerification
# 리포트는 실패했을 때도 확인할 수 있도록 항상 올린다
- name: Upload coverage report
if: ${{ always() }}
uses: actions/upload-artifact@v4
with:
name: jacoco-report
path: build/reports/jacoco/test/html
retention-days: 7
- name: Upload test report
if: ${{ failure() }}
uses: actions/upload-artifact@v4
with:
name: test-report
path: build/reports/tests/test
retention-days: 7
- name: Build jar
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 (prod release)
runs-on: ubuntu-latest
needs: main-ci
if: ${{ always() && github.event_name == 'push' }}
permissions: {}
env:
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
steps:
- name: Send CI result to Discord
if: ${{ env.DISCORD_WEBHOOK != '' }}
uses: Ilshidur/[email protected]
with:
args: "Chat main CI 결과 알림"
env:
DISCORD_USERNAME: MURO_BOT
DISCORD_EMBEDS: |
[
{
"title": "Chat 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
}
]
}
]v1 대비 변경점은 다음과 같다.
- 워크플로 이름을 AI CI main에서 AI CI dev, AI CI prod로 바꿈
- 릴리즈 스텝에서 cp release.config.develop.json .releaserc.json (dev) 또는 cp release.config.main.json .releaserc.json (prod)을 실행
- 의존성 설치를 requirements-dev.txt로 변경 (pytest, coverage 포함)
- requirements.txt에 coverage를 넣으면 운영 이미지에도 들어가서 용량만 늘고, 개발 도구가 운영에 섞이게 됨
- 컴파일 뒤에 테스트 및 커버리지 검사와 리포트 업로드 추가
- 개발 서버 배포(deploy)는 CI에서 빼고 cd-dev.yml로 옮김
ci-dev.yml
name: AI CI dev
# develop 브랜치 전용 CI. PR 검증, push 시 개발 이미지 릴리즈까지만 한다.
# 개발 서버 배포는 cd-dev.yml이 이 워크플로가 성공하면 이어서 한다.
on:
push:
branches:
- develop
pull_request:
branches:
- develop
jobs:
pr-ci:
name: AI CI dev pull_request
runs-on: ubuntu-latest
permissions:
contents: read
steps:
# 1. 코드 체크아웃
- name: Checkout code
uses: actions/checkout@v7
with:
fetch-depth: 1
# 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-dev.txt
# 6. 컴파일
- name: Check Python syntax
run: python -m compileall .
# 7. 테스트 실행 중 커버리지 측정
- name: Run tests with coverage
run: coverage run -m pytest
# 8. 커버리지 60% 미만이면 여기서 실패
- name: Coverage verification
run: |
coverage report --fail-under=60
coverage xml
- name: Upload coverage report
if: ${{ always() }}
uses: actions/upload-artifact@v4
with:
name: coverage-report
path: coverage.xml
retention-days: 7
pr-notify:
name: Discord notification (dev PR)
runs-on: ubuntu-latest
needs: pr-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 CI 결과 알림"
env:
DISCORD_USERNAME: MURO_BOT
DISCORD_EMBEDS: |
[
{
"title": "AI develop 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
}
]
}
]
push-ci:
if: github.event_name == 'push'
concurrency:
group: ai-develop-release
cancel-in-progress: false
name: AI CI dev release
runs-on: ubuntu-latest
permissions:
contents: write
id-token: write
env:
IMAGE_BASE: ${{ secrets.GCP_REGION }}-docker.pkg.dev/${{ secrets.GCP_PROJECT_ID_Develop }}/${{ secrets.PROJECT_REPOSITORY }}/${{ secrets.IMAGE_NAME }}
steps:
# 1. 코드 체크아웃
- name: Checkout code
uses: actions/checkout@v7
with:
# semantic-release가 Git 태그 및 히스토리를 분석해야 하므로 0 유지 필수
fetch-depth: 0
# 2. Python 환경 설정
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: "3.11"
# 3. 애플리케이션 의존성 설치
- name: Install dependencies
run: python -m pip install -r requirements-dev.txt
# 4. 컴파일
- name: Check Python syntax
run: python -m compileall .
# 7. 테스트 실행 중 커버리지 측정
- name: Run tests with coverage
run: coverage run -m pytest
# 8. 커버리지 60% 미만이면 여기서 실패
- name: Coverage verification
run: |
coverage report --fail-under=60
coverage xml
- name: Upload coverage report
if: ${{ always() }}
uses: actions/upload-artifact@v4
with:
name: coverage-report
path: coverage.xml
retention-days: 7
# 5. Node.js 설정
- 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
- name: Release ai 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
push-notify:
name: Discord notification (dev release)
runs-on: ubuntu-latest
needs: push-ci
if: ${{ always() && github.event_name == 'push' }}
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 Dev push CI 결과 알림"
env:
DISCORD_USERNAME: MURO_BOT
DISCORD_EMBEDS: |
[
{
"title": "AI Dev 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' && 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
}
]
}
]ci-prod.yml
name: AI CI prod
# main 브랜치 전용 CI. PR 검증, push 시 프로덕션 이미지 릴리즈까지만 한다.
# 프로덕션 배포는 cd-prod.yml이 이 워크플로가 성공하면 이어서 한다.
on:
push:
branches:
- main
pull_request:
branches:
- main
jobs:
pr-ci:
name: AI CI prod pull_request
runs-on: ubuntu-latest
permissions:
contents: read
steps:
# 1. 코드 체크아웃
- name: Checkout code
uses: actions/checkout@v7
with:
fetch-depth: 1
# 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-dev.txt
# 6. 컴파일
- name: Check Python syntax
run: python -m compileall .
# 7. 테스트 실행 중 커버리지 측정
- name: Run tests with coverage
run: coverage run -m pytest
# 8. 커버리지 60% 미만이면 여기서 실패
- name: Coverage verification
run: |
coverage report --fail-under=60
coverage xml
- name: Upload coverage report
if: ${{ always() }}
uses: actions/upload-artifact@v4
with:
name: coverage-report
path: coverage.xml
retention-days: 7
pr-notify:
name: Discord notification (prod PR)
runs-on: ubuntu-latest
needs: pr-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 CI 결과 알림"
env:
DISCORD_USERNAME: MURO_BOT
DISCORD_EMBEDS: |
[
{
"title": "AI main 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-ci:
if: github.event_name == 'push'
name: AI CI prod release
runs-on: ubuntu-latest
concurrency:
group: ai-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. 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-dev.txt
# 6. 컴파일
- name: Check Python syntax
run: python -m compileall .
# 7. 테스트 실행 중 커버리지 측정
- name: Run tests with coverage
run: coverage run -m pytest
# 8. 커버리지 60% 미만이면 여기서 실패
- name: Coverage verification
run: |
coverage report --fail-under=60
coverage xml
- name: Upload coverage report
if: ${{ always() }}
uses: actions/upload-artifact@v4
with:
name: coverage-report
path: coverage.xml
retention-days: 7
# 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: |
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 (prod release)
runs-on: ubuntu-latest
needs: main-ci
# CI가 실패해도 알림 작업 실행
if: ${{ always() && github.event_name == 'push' }}
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.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
}
]
}
]설계서의 커버리지 기준은 Backend, Chat Server, AI Server에만 적용된다. Frontend CI 내용(Prettier, ESLint, TypeScript 검사, 빌드)은 바뀐 게 없고 파일만 dev, prod로 나눴다.
- 워크플로 이름을 Frontend CI main에서 Frontend CI dev, Frontend CI prod로 바꿈
- v1에는 pull_request 대상이 develop뿐이었는데, main 대상 PR도 검사하도록 ci-prod.yml에 pr-ci를 추가 (Frontend prod PR Check)
- 개발 서버 배포(deploy)는 CI에서 빼고 cd-dev.yml로 옮김
ci-dev.yml
name: Frontend CI dev
# develop 브랜치 전용 CI. PR 검증, push 시 개발 이미지 릴리즈까지만 한다.
# 개발 서버 배포는 cd-dev.yml이 이 워크플로가 성공하면 이어서 한다.
on:
push:
branches:
- develop
pull_request:
branches:
- develop
jobs:
pr-ci:
name: Frontend dev 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:
name: Discord notification (dev PR)
runs-on: ubuntu-latest
needs: pr-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: "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
}
]
}
]
push-ci:
if: github.event_name == 'push'
name: Frontend CI dev 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
push-notify:
name: Discord notification (dev release)
runs-on: ubuntu-latest
needs: push-ci
if: ${{ always() && github.event_name == 'push' }}
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 Dev push CI 결과 알림"
env:
DISCORD_USERNAME: MURO_BOT
DISCORD_EMBEDS: |
[
{
"title": "Frontend Dev 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' && 3066993 || 15158332 }},
"fields": [
{
"name": "커밋",
"value": "[커밋 확인]",
"inline": true
},
{
"name": "푸시한 사람",
"value": ${{ toJSON(github.actor) }},
"inline": true
},
{
"name": "브랜치",
"value": ${{ toJSON(github.ref_name) }},
"inline": true
}
]
}
]ci-prod.yml
name: Frontend CI prod
# main 브랜치 전용 CI. PR 검증, push 시 프로덕션 이미지 릴리즈까지만 한다.
# 프로덕션 배포는 cd-prod.yml이 이 워크플로가 성공하면 이어서 한다.
on:
push:
branches:
- main
pull_request:
branches:
- main
jobs:
pr-ci:
name: Frontend prod 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:
name: Discord notification (prod PR)
runs-on: ubuntu-latest
needs: pr-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: "Frontend main 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-ci:
if: github.event_name == 'push'
name: Frontend CI prod 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 (prod release)
runs-on: ubuntu-latest
needs: main-ci
# CI가 실패해도 알림 작업 실행
if: ${{ always() && github.event_name == 'push' }}
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) }}
}
]
}
]jacoco 설정은 둘 다 같다. Chat Server는 checkstyle 플러그인도 아직 없어서 plugins에 id 'checkstyle'을 같이 넣고, config/checkstyle/checkstyle.xml을 Backend 파일로 복사한다.
plugins {
id 'jacoco'
}
jacoco {
toolVersion = '0.8.13' // JDK 21을 지원하는 버전
}
// 측정에서 제외할 대상
ext.coverageExcludes = [
'**/*Application.class',
'**/config/**',
'**/dto/**',
'**/*Request.class',
'**/*Response.class'
]
tasks.named('test') {
useJUnitPlatform()
finalizedBy jacocoTestReport
}
jacocoTestReport {
dependsOn tasks.named('test')
reports {
xml.required = true
html.required = true
}
classDirectories.setFrom(files(classDirectories.files.collect {
fileTree(dir: it, exclude: coverageExcludes)
}))
}
jacocoTestCoverageVerification {
dependsOn tasks.named('test')
classDirectories.setFrom(files(classDirectories.files.collect {
fileTree(dir: it, exclude: coverageExcludes)
}))
violationRules {
rule {
limit {
counter = 'LINE'
value = 'COVEREDRATIO'
minimum = 0.60
}
}
}
}requirements-dev.txt의 ruff 줄 아래에 추가한다.
coverage>=7,<8
.coveragerc를 저장소 루트에 만든다.
[run]
source = app
omit =
app/__main__.py
*/migrations/*
*/tests/*
[report]
show_missing = true
릴리즈 설정 파일 두 개를 저장소 루트에 만든다. 기존 .releaserc.json은 삭제한다. 릴리즈 스텝에서 복사해서 쓰기 때문이다.
release.config.main.json (v1의 main 브랜치 .releaserc.json)
{
"branches": ["main"],
"tagFormat": "v${version}",
"plugins": [
[
"@semantic-release/commit-analyzer",
{
"releaseRules": [
{ "breaking": true, "release": "major" },
{ "type": "feat", "release": "minor" },
{ "release": "patch" }
]
}
],
"@semantic-release/release-notes-generator",
[
"@semantic-release/exec",
{
"prepareCmd": "docker buildx build --platform linux/amd64 --file Dockerfile --tag \"$IMAGE_BASE:${nextRelease.version}\" --push ."
}
],
[
"@semantic-release/github",
{
"successComment": false,
"failComment": false,
"releasedLabels": false
}
]
]
}release.config.develop.json (v1의 develop 브랜치 .releaserc.json)
{
"branches": ["develop"],
"tagFormat": "dev-v${version}",
"plugins": [
"@semantic-release/commit-analyzer",
"@semantic-release/release-notes-generator",
[
"@semantic-release/github",
{
"successComment": false,
"failComment": false
}
],
[
"@semantic-release/exec",
{
"prepareCmd": "docker buildx build --platform linux/amd64 -f Dockerfile -t \"$IMAGE_BASE:dev-${nextRelease.version}\" --push ."
}
]
]
}브랜치마다 하나씩만 지정한다. develop 대상 PR에는 dev 검사만, main 대상 PR에는 prod 검사만 생기기 때문에 둘을 한 브랜치에 같이 걸면 없는 검사를 기다리면서 PR이 멈춘다.
| 저장소 | 대상 브랜치 | job 이름 |
|---|---|---|
| Backend | develop | Backend CI dev pull_request |
| Backend | main | Backend CI prod pull_request |
| Chat Server | develop | Chat CI dev pull_request |
| Chat Server | main | Chat CI prod pull_request |
| AI Server | develop | AI CI dev pull_request |
| AI Server | main | AI CI prod pull_request |
| Frontend | develop | Frontend dev PR Check |
| Frontend | main | Frontend prod PR Check |
- v1 구조는 GitHub Actions에서 main branch에서 CI를 통과하게 되면 단일 VM Blue-green 방식으로 배포했다.
- v2에서는 MIG를 이용하면 Rolling Update 방식을 선택한다. (4단계 설계 문서 참고)
- MIG Rolling Update는 MIG를 구성하여 변경된 서비스의 VM을 하나씩 신버전으로 교체한다. 따라서 해당 서비스의 신버전용 VM 전체를 별도로 준비할 필요가 없다.
- 또한 현재 서비스는 배포 장애 발생 시 반드시 수 초 이내에 복구해야 하는 가용성 요구사항을 두고 있지 않기 때문에 즉시 롤백을 위해 전체 Blue 환경을 유지하는 비용과 운영 복잡성보다 서비스별 Rolling Update의 단순성과 비용 효율성이 더 크다고 판단했다.
- 개발 서버 배포는 MIG로 하지 않는다. v1처럼 develop VM에 SSH로 접속해서 컨테이너를 교체하고, CI 파일 안에 있던 것을 cd-dev.yml로 분리했다.
- Chat VM CD
- 채팅 서버는 사전에 안내한 점검 시간(00:00)에 Recreate 방식으로 배포를 선택했다.
- GCP Graceful Shutdown으로 처리 중인 메시지 저장과 연결 정리를 마친 뒤 신버전을 실행하고, 헬스 체크를 통과하면 채팅 서비스를 시작하고, 배포 실패 시 이전 버전으로 복구한다.
- 채팅 서버는 사전에 안내한 점검 시간(00:00)에 Recreate 방식으로 배포를 선택했다.
| 구분 | v1 | v2 |
|---|---|---|
| 배포 대상 | Standby VM 1대 | 서비스별 MIG |
| 배포 방식 | Blue-Green | Rolling Update (Chat은 Recreate) |
| 새 버전 전달 | SSH/SCP로 env 파일 전송, docker compose pull과 up | 새 인스턴스 템플릿의 이미지 태그 메타데이터를 바꾸고 VM이 부팅할 때 이미지를 받음 |
| 환경변수 | GitHub Secrets로 env 파일을 만들어 전송 | VM이 부팅할 때 Secret Manager에서 읽음 |
| 트래픽 전환 | 고정 IP와 role 라벨 교체, nginx reload | 없음. 로드밸런서가 헬스체크 통과한 VM으로만 보냄 |
| SSH | 사용 (IAP 터널) | 사용하지 않음 |
| 실패 처리 | 승인 전이면 중단, 승인 후 문제는 수동 | 교체 중 실패하면 이전 템플릿으로 자동 롤백 |
| 서비스 간 동기화 | 한 VM에 서비스가 같이 있어서 다른 서비스 버전도 맞춤 | 서비스별로 독립 배포, 동기화 불필요 |
| 파일 구성 | CD 파일 하나 | cd-dev.yml, cd-prod.yml로 분리 |
| 서비스 | MIG | 트리거 | 방식 | max-surge | max-unavailable |
|---|---|---|---|---|---|
| Backend | muro-backend-mig (존, 1대) | main CI 성공 시 자동 | Rolling Update | 1 | 0 |
| AI Server | muro-ai-mig (존, 2대) | main CI 성공 시 자동 | Rolling Update | 1 | 0 |
| Frontend | muro-frontend-mig (존, 1대) | main CI 성공 시 자동 | Rolling Update | 1 | 0 |
| Chat Server | muro-chat-mig (존, 1대) | 수동 실행만 | Recreate | 0 | 1 |
- max-unavailable을 0으로 두고 max-surge로 새 VM을 먼저 띄우기 때문에, 교체 중에도 기존 VM이 트래픽을 받는다. 1대짜리 Backend와 Frontend도 새 VM이 헬스체크를 통과한 뒤 기존 VM이 내려가서 끊김이 없다.
- Chat Server는 서비스 중단을 감수하고 점검 시간에 배포한다. 자동 실행하면 점검 안내와 상관없이 배포되므로 CI 성공에 연결하지 않고 수동 실행만 허용한다. 실행할 때 점검 안내를 했는지 확인을 받는다.
- MIG 이름과 대수, 위치는 v2 인프라를 만들 때 정해지는 값이라서 워크플로 상단의 env에서 한 번에 바꾸게 했다.
- cd-dev.yml은 develop CI(ci-dev)가 성공하면 workflow_run으로 실행되고, 수동 실행(workflow_dispatch)도 된다. 같은 서비스의 배포가 겹치지 않게 서비스별 concurrency를 걸었다. 기존 deploy job에는 없어서 develop에 연속으로 push하면 같은 VM에 배포가 겹칠 수 있었다.
- cd-dev.yml의 알림은 workflow_run 이벤트에 head_commit이 없어서 github.event.workflow_run의 head_commit, head_sha, actor를 쓴다. 수동 실행이면 github.sha와 github.actor를 쓴다.
- workflow_run으로 실행되는 워크플로는 기본 브랜치(main)에 있는 파일 기준으로 돈다. develop에서 CD 파일을 고쳐도 main에 머지되기 전에는 반영되지 않는다.
- cd-dev.yml: develop VM에 새 이미지를 배포한다. CI에 있던 deploy job을 그대로 옮겼고 알림만 CD 결과로 따로 보낸다.
- cd-prod.yml: MIG Rolling Update 배포다. 기존 CD 파일에서 name과 workflow_run.workflows 두 줄만 바뀌었다.
cd-dev.yml
name: Backend CD dev
# develop CI(ci-dev.yml)가 성공하면 자동으로 개발 서버에 배포한다.
# workflow_dispatch는 개발 서버에 다시 배포하고 싶을 때 수동으로 쓴다.
# 주의: workflow_run으로 실행되는 워크플로는 기본 브랜치(main)에 있는 파일 기준으로 실행된다.
on:
workflow_run:
workflows: ["Backend CI dev"]
types: [completed]
branches: [develop]
workflow_dispatch:
env:
ZONE: asia-northeast3-a
DEV_VM_NAME: mulo-v2-dev-vm # Terraform이 만든 개발 VM 이름
jobs:
deploy:
name: Deploy to Develop VM
runs-on: ubuntu-latest
if: ${{ github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success' }}
concurrency:
group: cd-backend-develop
cancel-in-progress: false
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. DEV_VM_NAME 이름의 개발 VM을 찾는다.
- id: develop
run: |
VM=$(gcloud compute instances list \
--filter="name=${{ env.DEV_VM_NAME }}" \
--zones="${{ env.ZONE }}" \
--format="value(name)")
if [ -z "$VM" ]; then
echo "${{ env.DEV_VM_NAME }} VM을 찾을 수 없습니다."
exit 1
fi
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 상태가 될 때까지 대기
- 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
"
# nginx는 컨테이너 DNS를 시작/리로드 시점에만 캐싱하므로, 방금 재생성된
# backend의 새 IP를 반영하도록 reload한다.
- 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
"
notify:
name: Discord notification
runs-on: ubuntu-latest
needs: deploy
if: ${{ always() && needs.deploy.result != 'skipped' }}
permissions: {}
env:
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
steps:
- name: Send CD result to Discord
if: ${{ env.DISCORD_WEBHOOK != '' }}
uses: Ilshidur/[email protected]
with:
args: "Backend Develop 배포 결과 알림"
env:
DISCORD_USERNAME: MURO_BOT
DISCORD_EMBEDS: |
[
{
"title": "Backend Develop 배포: ${{ needs.deploy.result }}",
"description": ${{ toJSON(github.event.workflow_run.head_commit.message || '수동 실행') }},
"url": "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}",
"color": ${{ needs.deploy.result == 'success' && 3066993 || 15158332 }},
"fields": [
{
"name": "커밋",
"value": "[커밋 확인](${{ github.server_url }}/${{ github.repository }}/commit/${{ github.event.workflow_run.head_sha || github.sha }})",
"inline": true
},
{
"name": "푸시한 사람",
"value": ${{ toJSON(github.event.workflow_run.actor.login || github.actor) }},
"inline": true
},
{
"name": "브랜치",
"value": "develop",
"inline": true
}
]
}
]
cd-prod.yml
name: Backend CD prod
# CI가 main에서 성공적으로 끝나면 자동으로 실행된다.
# workflow_dispatch는 특정 태그로 재배포하거나 이전 버전으로 되돌릴 때 수동으로 쓴다.
on:
workflow_run:
workflows: ["Backend CI prod"]
types: [completed]
branches: [main]
workflow_dispatch:
inputs:
image_tag:
description: "배포할 이미지 태그 (비우면 가장 최근 태그)"
required: false
type: string
env:
SERVICE: backend
MIG_NAME: muro-backend-mig
MIG_FLAG: "--zone=asia-northeast3-a"
MAX_SURGE: "1"
MAX_UNAVAILABLE: "0"
REPLACEMENT_METHOD: substitute
jobs:
# ============================================================
# 1단계: 배포할 이미지 태그와 현재 인스턴스 템플릿을 확인한다. (VM은 건드리지 않는다)
# ============================================================
prepare:
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:
image_tag: ${{ steps.image.outputs.tag }}
prev_template: ${{ steps.current.outputs.template }}
new_template: ${{ steps.name.outputs.template }}
steps:
- id: auth
uses: google-github-actions/auth@v3
with:
workload_identity_provider: ${{ secrets.WORKLOAD_IDENTITY_PROVIDER }}
service_account: ${{ secrets.SERVICE_ACCOUNT }}
- uses: google-github-actions/setup-gcloud@v3
# 이미지는 CD가 빌드하지 않고, CI가 push해둔 것을 그대로 쓴다.
# 수동 실행 때 태그를 넣으면 그 태그를, 비우면 가장 최근 태그를 쓴다.
- id: image
env:
INPUT_TAG: ${{ inputs.image_tag }}
IMAGE_REPO: ${{ secrets.GCP_REGION }}-docker.pkg.dev/${{ secrets.GCP_PROJECT_ID }}/${{ secrets.PROJECT_REPOSITORY }}/${{ secrets.IMAGE_NAME }}
run: |
if [ -n "$INPUT_TAG" ]; then
TAG="$INPUT_TAG"
else
TAG=$(gcloud artifacts docker images list "$IMAGE_REPO" \
--include-tags --sort-by="~UPDATE_TIME" --limit=1 \
--format="value(TAGS)" | cut -d',' -f1)
fi
if [ -z "$TAG" ]; then
echo "배포할 이미지 태그를 찾을 수 없습니다."
exit 1
fi
# 해당 태그의 이미지가 실제로 있는지 확인한다.
gcloud artifacts docker images describe "$IMAGE_REPO:$TAG" > /dev/null
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
# 롤백할 때 되돌아갈 현재 인스턴스 템플릿 이름
- id: current
run: |
TEMPLATE=$(gcloud compute instance-groups managed describe "$MIG_NAME" $MIG_FLAG \
--format="value(versions[0].instanceTemplate.basename())")
if [ -z "$TEMPLATE" ]; then
echo "$MIG_NAME 의 현재 인스턴스 템플릿을 찾을 수 없습니다."
exit 1
fi
echo "template=$TEMPLATE" >> "$GITHUB_OUTPUT"
# 새 템플릿 이름: 서비스-tpl-태그-실행번호 (소문자, 숫자, 하이픈만 가능)
- id: name
env:
TAG: ${{ steps.image.outputs.tag }}
run: |
SAFE=$(echo "$TAG" | tr '[:upper:]' '[:lower:]' | tr -c 'a-z0-9\n' '-' | sed 's/-*$//')
echo "template=${SERVICE}-tpl-${SAFE}-${{ github.run_number }}" >> "$GITHUB_OUTPUT"
# ============================================================
# 2단계: 새 인스턴스 템플릿을 만들고 MIG를 교체한다.
# environment: production 에 지정한 승인자가 승인해야 시작한다.
# ============================================================
rolling-update:
needs: prepare
runs-on: ubuntu-latest
environment: production
concurrency:
group: cd-backend-production
cancel-in-progress: false
permissions:
contents: read
id-token: write
env:
TAG: ${{ needs.prepare.outputs.image_tag }}
PREV_TEMPLATE: ${{ needs.prepare.outputs.prev_template }}
NEW_TEMPLATE: ${{ needs.prepare.outputs.new_template }}
steps:
- id: auth
uses: google-github-actions/auth@v3
with:
workload_identity_provider: ${{ secrets.WORKLOAD_IDENTITY_PROVIDER }}
service_account: ${{ secrets.SERVICE_ACCOUNT }}
- uses: google-github-actions/setup-gcloud@v3
# 현재 템플릿을 복사하고 이미지 태그 메타데이터만 바꾼 새 템플릿을 만든다.
# VM은 부팅할 때 이 태그의 이미지를 받아서 실행한다.
- name: Create instance template
run: |
gcloud compute instance-templates create "$NEW_TEMPLATE" \
--source-instance-template="$PREV_TEMPLATE" \
--metadata=app-image-tag="$TAG"
- id: start
name: Start rolling update
run: |
gcloud compute instance-groups managed rolling-action start-update "$MIG_NAME" $MIG_FLAG \
--version=template="$NEW_TEMPLATE" \
--type=proactive \
--minimal-action=replace \
--most-disruptive-allowed-action=replace \
--replacement-method="$REPLACEMENT_METHOD" \
--max-surge="$MAX_SURGE" \
--max-unavailable="$MAX_UNAVAILABLE"
# 모든 VM이 새 템플릿으로 교체되고 헬스체크를 통과할 때까지 기다린다.
- name: Wait until all instances are updated
run: |
gcloud compute instance-groups managed wait-until "$MIG_NAME" $MIG_FLAG \
--version-target-reached --timeout=900
- name: Wait until the group is stable
run: |
gcloud compute instance-groups managed wait-until "$MIG_NAME" $MIG_FLAG \
--stable --timeout=300
# 교체 도중 실패하면 이전 템플릿으로 되돌린다. (교체를 시작한 경우에만)
- name: Rollback to previous template
if: ${{ failure() && steps.start.outcome == 'success' }}
run: |
gcloud compute instance-groups managed rolling-action start-update "$MIG_NAME" $MIG_FLAG \
--version=template="$PREV_TEMPLATE" \
--type=proactive \
--minimal-action=replace \
--most-disruptive-allowed-action=replace \
--replacement-method="$REPLACEMENT_METHOD" \
--max-surge="$MAX_SURGE" \
--max-unavailable="$MAX_UNAVAILABLE"
gcloud compute instance-groups managed wait-until "$MIG_NAME" $MIG_FLAG \
--version-target-reached --timeout=900
# 오래된 템플릿은 최근 5개만 남기고 지운다. (사용 중인 템플릿은 삭제되지 않는다)
- name: Delete old instance templates
if: ${{ success() }}
continue-on-error: true
run: |
gcloud compute instance-templates list \
--filter="name~^${SERVICE}-tpl-" \
--sort-by="~creationTimestamp" \
--format="value(name)" \
| tail -n +6 \
| while read -r T; do
gcloud compute instance-templates delete "$T" --quiet || true
done
# 배포 결과 알림
notify:
name: Discord notification
runs-on: ubuntu-latest
needs: [prepare, rolling-update]
if: ${{ always() && needs.prepare.result != 'skipped' }}
permissions: {}
env:
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
steps:
- name: Send CD result to Discord
if: ${{ env.DISCORD_WEBHOOK != '' }}
uses: Ilshidur/[email protected]
with:
args: "Backend 배포 결과 알림"
env:
DISCORD_USERNAME: MURO_BOT
DISCORD_EMBEDS: |
[
{
"title": "Backend 배포: ${{ needs.rolling-update.result }}",
"description": "이미지 태그: ${{ needs.prepare.outputs.image_tag }}",
"url": "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}",
"color": ${{ needs.rolling-update.result == 'success' && 3066993 || 15158332 }},
"fields": [
{
"name": "실행한 사람",
"value": ${{ toJSON(github.actor) }},
"inline": true
},
{
"name": "배포 방식",
"value": "Rolling Update",
"inline": true
}
]
}
]개발 서버에 배포하지 않아서 cd-dev.yml은 없다. cd-prod.yml은 CI 성공에 연결하지 않고 수동 실행만 하므로 workflow_run 트리거가 없다. 기존 파일에서 name만 바뀌었다.
개발 서버에 Chat을 올릴지는 아직 정해지지 않았다. 올리기로 하면 개발 VM의 compose에 chat 서비스를 추가하고, Backend의 cd-dev.yml을 참고해서 CHAT_DB_URL, CHAT_DB_USERNAME, CHAT_DB_PASSWORD를 env 파일로 넘기는 cd-dev.yml을 만든다.
cd-prod.yml
name: Chat CD prod
# Chat Server는 사전에 안내한 점검 시간에만 배포한다.
# CI 성공으로 자동 실행하지 않고 수동 실행만 허용하며, 점검 안내를 했는지 확인을 받는다.
on:
workflow_dispatch:
inputs:
image_tag:
description: "배포할 이미지 태그 (비우면 가장 최근 태그)"
required: false
type: string
maintenance_confirmed:
description: "점검 시간을 사전에 안내했습니다"
required: true
type: boolean
default: false
env:
SERVICE: chat
MIG_NAME: muro-chat-mig
MIG_FLAG: "--zone=asia-northeast3-a"
MAX_SURGE: "0"
MAX_UNAVAILABLE: "1"
REPLACEMENT_METHOD: recreate
jobs:
# ============================================================
# 1단계: 배포할 이미지 태그와 현재 인스턴스 템플릿을 확인한다. (VM은 건드리지 않는다)
# ============================================================
prepare:
if: ${{ inputs.maintenance_confirmed == true }}
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write # GCP WIF 인증용 OIDC 토큰 발급 권한
outputs:
image_tag: ${{ steps.image.outputs.tag }}
prev_template: ${{ steps.current.outputs.template }}
new_template: ${{ steps.name.outputs.template }}
steps:
- id: auth
uses: google-github-actions/auth@v3
with:
workload_identity_provider: ${{ secrets.WORKLOAD_IDENTITY_PROVIDER }}
service_account: ${{ secrets.SERVICE_ACCOUNT }}
- uses: google-github-actions/setup-gcloud@v3
# 이미지는 CD가 빌드하지 않고, CI가 push해둔 것을 그대로 쓴다.
# 수동 실행 때 태그를 넣으면 그 태그를, 비우면 가장 최근 태그를 쓴다.
- id: image
env:
INPUT_TAG: ${{ inputs.image_tag }}
IMAGE_REPO: ${{ secrets.GCP_REGION }}-docker.pkg.dev/${{ secrets.GCP_PROJECT_ID }}/${{ secrets.PROJECT_REPOSITORY }}/${{ secrets.IMAGE_NAME }}
run: |
if [ -n "$INPUT_TAG" ]; then
TAG="$INPUT_TAG"
else
TAG=$(gcloud artifacts docker images list "$IMAGE_REPO" \
--include-tags --sort-by="~UPDATE_TIME" --limit=1 \
--format="value(TAGS)" | cut -d',' -f1)
fi
if [ -z "$TAG" ]; then
echo "배포할 이미지 태그를 찾을 수 없습니다."
exit 1
fi
# 해당 태그의 이미지가 실제로 있는지 확인한다.
gcloud artifacts docker images describe "$IMAGE_REPO:$TAG" > /dev/null
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
# 롤백할 때 되돌아갈 현재 인스턴스 템플릿 이름
- id: current
run: |
TEMPLATE=$(gcloud compute instance-groups managed describe "$MIG_NAME" $MIG_FLAG \
--format="value(versions[0].instanceTemplate.basename())")
if [ -z "$TEMPLATE" ]; then
echo "$MIG_NAME 의 현재 인스턴스 템플릿을 찾을 수 없습니다."
exit 1
fi
echo "template=$TEMPLATE" >> "$GITHUB_OUTPUT"
# 새 템플릿 이름: 서비스-tpl-태그-실행번호 (소문자, 숫자, 하이픈만 가능)
- id: name
env:
TAG: ${{ steps.image.outputs.tag }}
run: |
SAFE=$(echo "$TAG" | tr '[:upper:]' '[:lower:]' | tr -c 'a-z0-9\n' '-' | sed 's/-*$//')
echo "template=${SERVICE}-tpl-${SAFE}-${{ github.run_number }}" >> "$GITHUB_OUTPUT"
# ============================================================
# 2단계: 새 인스턴스 템플릿을 만들고 MIG를 교체한다.
# environment: production 에 지정한 승인자가 승인해야 시작한다.
# ============================================================
rolling-update:
needs: prepare
runs-on: ubuntu-latest
environment: production
concurrency:
group: cd-chat-production
cancel-in-progress: false
permissions:
contents: read
id-token: write
env:
TAG: ${{ needs.prepare.outputs.image_tag }}
PREV_TEMPLATE: ${{ needs.prepare.outputs.prev_template }}
NEW_TEMPLATE: ${{ needs.prepare.outputs.new_template }}
steps:
- id: auth
uses: google-github-actions/auth@v3
with:
workload_identity_provider: ${{ secrets.WORKLOAD_IDENTITY_PROVIDER }}
service_account: ${{ secrets.SERVICE_ACCOUNT }}
- uses: google-github-actions/setup-gcloud@v3
# 현재 템플릿을 복사하고 이미지 태그 메타데이터만 바꾼 새 템플릿을 만든다.
# VM은 부팅할 때 이 태그의 이미지를 받아서 실행한다.
- name: Create instance template
run: |
gcloud compute instance-templates create "$NEW_TEMPLATE" \
--source-instance-template="$PREV_TEMPLATE" \
--metadata=app-image-tag="$TAG"
- id: start
name: Start rolling update
run: |
gcloud compute instance-groups managed rolling-action start-update "$MIG_NAME" $MIG_FLAG \
--version=template="$NEW_TEMPLATE" \
--type=proactive \
--minimal-action=replace \
--most-disruptive-allowed-action=replace \
--replacement-method="$REPLACEMENT_METHOD" \
--max-surge="$MAX_SURGE" \
--max-unavailable="$MAX_UNAVAILABLE"
# 모든 VM이 새 템플릿으로 교체되고 헬스체크를 통과할 때까지 기다린다.
- name: Wait until all instances are updated
run: |
gcloud compute instance-groups managed wait-until "$MIG_NAME" $MIG_FLAG \
--version-target-reached --timeout=900
- name: Wait until the group is stable
run: |
gcloud compute instance-groups managed wait-until "$MIG_NAME" $MIG_FLAG \
--stable --timeout=300
# 교체 도중 실패하면 이전 템플릿으로 되돌린다. (교체를 시작한 경우에만)
- name: Rollback to previous template
if: ${{ failure() && steps.start.outcome == 'success' }}
run: |
gcloud compute instance-groups managed rolling-action start-update "$MIG_NAME" $MIG_FLAG \
--version=template="$PREV_TEMPLATE" \
--type=proactive \
--minimal-action=replace \
--most-disruptive-allowed-action=replace \
--replacement-method="$REPLACEMENT_METHOD" \
--max-surge="$MAX_SURGE" \
--max-unavailable="$MAX_UNAVAILABLE"
gcloud compute instance-groups managed wait-until "$MIG_NAME" $MIG_FLAG \
--version-target-reached --timeout=900
# 오래된 템플릿은 최근 5개만 남기고 지운다. (사용 중인 템플릿은 삭제되지 않는다)
- name: Delete old instance templates
if: ${{ success() }}
continue-on-error: true
run: |
gcloud compute instance-templates list \
--filter="name~^${SERVICE}-tpl-" \
--sort-by="~creationTimestamp" \
--format="value(name)" \
| tail -n +6 \
| while read -r T; do
gcloud compute instance-templates delete "$T" --quiet || true
done
# 배포 결과 알림
notify:
name: Discord notification
runs-on: ubuntu-latest
needs: [prepare, rolling-update]
if: ${{ always() && needs.prepare.result != 'skipped' }}
permissions: {}
env:
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
steps:
- name: Send CD result to Discord
if: ${{ env.DISCORD_WEBHOOK != '' }}
uses: Ilshidur/[email protected]
with:
args: "Chat 배포 결과 알림"
env:
DISCORD_USERNAME: MURO_BOT
DISCORD_EMBEDS: |
[
{
"title": "Chat 배포: ${{ needs.rolling-update.result }}",
"description": "이미지 태그: ${{ needs.prepare.outputs.image_tag }}",
"url": "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}",
"color": ${{ needs.rolling-update.result == 'success' && 3066993 || 15158332 }},
"fields": [
{
"name": "실행한 사람",
"value": ${{ toJSON(github.actor) }},
"inline": true
},
{
"name": "배포 방식",
"value": "Recreate (점검 시간)",
"inline": true
}
]
}
]- cd-dev.yml: develop VM에 ai 컨테이너만 교체한다. CI의 deploy job을 그대로 옮겼다.
- cd-prod.yml: 기존 CD 파일에서 name과 workflow_run.workflows 두 줄만 바뀌었다.
cd-dev.yml
name: AI CD dev
# develop CI(ci-dev.yml)가 성공하면 자동으로 개발 서버에 배포한다.
# workflow_dispatch는 개발 서버에 다시 배포하고 싶을 때 수동으로 쓴다.
# 주의: workflow_run으로 실행되는 워크플로는 기본 브랜치(main)에 있는 파일 기준으로 실행된다.
on:
workflow_run:
workflows: ["AI CI dev"]
types: [completed]
branches: [develop]
workflow_dispatch:
env:
ZONE: asia-northeast3-a
DEV_VM_NAME: mulo-v2-dev-vm # Terraform이 만든 개발 VM 이름
jobs:
deploy:
name: Deploy to Develop VM
runs-on: ubuntu-latest
if: ${{ github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success' }}
concurrency:
group: cd-ai-develop
cancel-in-progress: false
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. DEV_VM_NAME 이름의 개발 VM을 찾는다.
- id: develop
run: |
VM=$(gcloud compute instances list \
--filter="name=${{ env.DEV_VM_NAME }}" \
--zones="${{ env.ZONE }}" \
--format="value(name)")
if [ -z "$VM" ]; then
echo "${{ env.DEV_VM_NAME }} 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)
if [ -z "$TAG" ]; then
echo "조회된 Docker 이미지 태그가 없습니다."
exit 1
fi
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
# 3. ai.env 파일을 GitHub Secrets 값으로 생성한다.
- run: |
cat > ai.env <<ENVEOF
APP_ENV=${{ secrets.APP_ENV || 'develop' }}
INTERNAL_TOKEN=${{ secrets.AI_INTERNAL_TOKEN || secrets.INTERNAL_TOKEN }}
DATABASE_URL=${{ secrets.DATABASE_URL_Develop || 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_Develop || secrets.BACKEND_MYSQL_URL }}
BACKEND_INTERNAL_URL=${{ secrets.BACKEND_INTERNAL_URL_Develop || secrets.BACKEND_INTERNAL_URL }}
RUN_WORKER=true
ENVEOF
# 4. env 파일을 develop VM으로 전송하고, 새 이미지를 pull해서 ai 컨테이너만 교체한다.
# 같은 VM 위의 backend/frontend/nginx 컨테이너는 건드리지 않는다.
- run: |
gcloud compute scp ai.env "${{ steps.develop.outputs.vm }}:~/app/ai.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 ai
sudo --preserve-env=IMAGE_TAG,GCP_PROJECT_ID docker compose up -d ai
"
# 5. 컨테이너가 healthy 상태가 될 때까지 대기
- name: "Wait for ai 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 ai --format='{{.State.Health.Status}}' 2>/dev/null || echo 'unhealthy')
if [ \"\$status\" = \"healthy\" ]; then
echo \"ai healthy (시도 \$i/20)\"
exit 0
fi
echo \"대기 중... (\$i/20) 현재 상태: \$status\"
sleep 3
done
echo \"ai가 60초 안에 healthy가 되지 않았습니다.\"
exit 1
"
# nginx는 컨테이너 DNS를 시작/리로드 시점에만 캐싱하므로, 방금 재생성된
# ai의 새 IP를 반영하도록 reload한다.
- 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
"
notify:
name: Discord notification
runs-on: ubuntu-latest
needs: deploy
if: ${{ always() && needs.deploy.result != 'skipped' }}
permissions: {}
env:
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
steps:
- name: Send CD result to Discord
if: ${{ env.DISCORD_WEBHOOK != '' }}
uses: Ilshidur/[email protected]
with:
args: "AI Develop 배포 결과 알림"
env:
DISCORD_USERNAME: MURO_BOT
DISCORD_EMBEDS: |
[
{
"title": "AI Develop 배포: ${{ needs.deploy.result }}",
"description": ${{ toJSON(github.event.workflow_run.head_commit.message || '수동 실행') }},
"url": "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}",
"color": ${{ needs.deploy.result == 'success' && 3066993 || 15158332 }},
"fields": [
{
"name": "커밋",
"value": "[커밋 확인](${{ github.server_url }}/${{ github.repository }}/commit/${{ github.event.workflow_run.head_sha || github.sha }})",
"inline": true
},
{
"name": "푸시한 사람",
"value": ${{ toJSON(github.event.workflow_run.actor.login || github.actor) }},
"inline": true
},
{
"name": "브랜치",
"value": "develop",
"inline": true
}
]
}
]
cd-prod.yml
name: AI CD prod
# CI가 main에서 성공적으로 끝나면 자동으로 실행된다.
# workflow_dispatch는 특정 태그로 재배포하거나 이전 버전으로 되돌릴 때 수동으로 쓴다.
on:
workflow_run:
workflows: ["AI CI prod"]
types: [completed]
branches: [main]
workflow_dispatch:
inputs:
image_tag:
description: "배포할 이미지 태그 (비우면 가장 최근 태그)"
required: false
type: string
env:
SERVICE: ai
MIG_NAME: muro-ai-mig
MIG_FLAG: "--zone=asia-northeast3-a"
MAX_SURGE: "1"
MAX_UNAVAILABLE: "0"
REPLACEMENT_METHOD: substitute
jobs:
# ============================================================
# 1단계: 배포할 이미지 태그와 현재 인스턴스 템플릿을 확인한다. (VM은 건드리지 않는다)
# ============================================================
prepare:
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:
image_tag: ${{ steps.image.outputs.tag }}
prev_template: ${{ steps.current.outputs.template }}
new_template: ${{ steps.name.outputs.template }}
steps:
- id: auth
uses: google-github-actions/auth@v3
with:
workload_identity_provider: ${{ secrets.WORKLOAD_IDENTITY_PROVIDER }}
service_account: ${{ secrets.SERVICE_ACCOUNT }}
- uses: google-github-actions/setup-gcloud@v3
# 이미지는 CD가 빌드하지 않고, CI가 push해둔 것을 그대로 쓴다.
# 수동 실행 때 태그를 넣으면 그 태그를, 비우면 가장 최근 태그를 쓴다.
- id: image
env:
INPUT_TAG: ${{ inputs.image_tag }}
IMAGE_REPO: ${{ secrets.GCP_REGION }}-docker.pkg.dev/${{ secrets.GCP_PROJECT_ID }}/${{ secrets.PROJECT_REPOSITORY }}/${{ secrets.IMAGE_NAME }}
run: |
if [ -n "$INPUT_TAG" ]; then
TAG="$INPUT_TAG"
else
TAG=$(gcloud artifacts docker images list "$IMAGE_REPO" \
--include-tags --sort-by="~UPDATE_TIME" --limit=1 \
--format="value(TAGS)" | cut -d',' -f1)
fi
if [ -z "$TAG" ]; then
echo "배포할 이미지 태그를 찾을 수 없습니다."
exit 1
fi
# 해당 태그의 이미지가 실제로 있는지 확인한다.
gcloud artifacts docker images describe "$IMAGE_REPO:$TAG" > /dev/null
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
# 롤백할 때 되돌아갈 현재 인스턴스 템플릿 이름
- id: current
run: |
TEMPLATE=$(gcloud compute instance-groups managed describe "$MIG_NAME" $MIG_FLAG \
--format="value(versions[0].instanceTemplate.basename())")
if [ -z "$TEMPLATE" ]; then
echo "$MIG_NAME 의 현재 인스턴스 템플릿을 찾을 수 없습니다."
exit 1
fi
echo "template=$TEMPLATE" >> "$GITHUB_OUTPUT"
# 새 템플릿 이름: 서비스-tpl-태그-실행번호 (소문자, 숫자, 하이픈만 가능)
- id: name
env:
TAG: ${{ steps.image.outputs.tag }}
run: |
SAFE=$(echo "$TAG" | tr '[:upper:]' '[:lower:]' | tr -c 'a-z0-9\n' '-' | sed 's/-*$//')
echo "template=${SERVICE}-tpl-${SAFE}-${{ github.run_number }}" >> "$GITHUB_OUTPUT"
# ============================================================
# 2단계: 새 인스턴스 템플릿을 만들고 MIG를 교체한다.
# environment: production 에 지정한 승인자가 승인해야 시작한다.
# ============================================================
rolling-update:
needs: prepare
runs-on: ubuntu-latest
environment: production
concurrency:
group: cd-ai-production
cancel-in-progress: false
permissions:
contents: read
id-token: write
env:
TAG: ${{ needs.prepare.outputs.image_tag }}
PREV_TEMPLATE: ${{ needs.prepare.outputs.prev_template }}
NEW_TEMPLATE: ${{ needs.prepare.outputs.new_template }}
steps:
- id: auth
uses: google-github-actions/auth@v3
with:
workload_identity_provider: ${{ secrets.WORKLOAD_IDENTITY_PROVIDER }}
service_account: ${{ secrets.SERVICE_ACCOUNT }}
- uses: google-github-actions/setup-gcloud@v3
# 현재 템플릿을 복사하고 이미지 태그 메타데이터만 바꾼 새 템플릿을 만든다.
# VM은 부팅할 때 이 태그의 이미지를 받아서 실행한다.
- name: Create instance template
run: |
gcloud compute instance-templates create "$NEW_TEMPLATE" \
--source-instance-template="$PREV_TEMPLATE" \
--metadata=app-image-tag="$TAG"
- id: start
name: Start rolling update
run: |
gcloud compute instance-groups managed rolling-action start-update "$MIG_NAME" $MIG_FLAG \
--version=template="$NEW_TEMPLATE" \
--type=proactive \
--minimal-action=replace \
--most-disruptive-allowed-action=replace \
--replacement-method="$REPLACEMENT_METHOD" \
--max-surge="$MAX_SURGE" \
--max-unavailable="$MAX_UNAVAILABLE"
# 모든 VM이 새 템플릿으로 교체되고 헬스체크를 통과할 때까지 기다린다.
- name: Wait until all instances are updated
run: |
gcloud compute instance-groups managed wait-until "$MIG_NAME" $MIG_FLAG \
--version-target-reached --timeout=900
- name: Wait until the group is stable
run: |
gcloud compute instance-groups managed wait-until "$MIG_NAME" $MIG_FLAG \
--stable --timeout=300
# 교체 도중 실패하면 이전 템플릿으로 되돌린다. (교체를 시작한 경우에만)
- name: Rollback to previous template
if: ${{ failure() && steps.start.outcome == 'success' }}
run: |
gcloud compute instance-groups managed rolling-action start-update "$MIG_NAME" $MIG_FLAG \
--version=template="$PREV_TEMPLATE" \
--type=proactive \
--minimal-action=replace \
--most-disruptive-allowed-action=replace \
--replacement-method="$REPLACEMENT_METHOD" \
--max-surge="$MAX_SURGE" \
--max-unavailable="$MAX_UNAVAILABLE"
gcloud compute instance-groups managed wait-until "$MIG_NAME" $MIG_FLAG \
--version-target-reached --timeout=900
# 오래된 템플릿은 최근 5개만 남기고 지운다. (사용 중인 템플릿은 삭제되지 않는다)
- name: Delete old instance templates
if: ${{ success() }}
continue-on-error: true
run: |
gcloud compute instance-templates list \
--filter="name~^${SERVICE}-tpl-" \
--sort-by="~creationTimestamp" \
--format="value(name)" \
| tail -n +6 \
| while read -r T; do
gcloud compute instance-templates delete "$T" --quiet || true
done
# 배포 결과 알림
notify:
name: Discord notification
runs-on: ubuntu-latest
needs: [prepare, rolling-update]
if: ${{ always() && needs.prepare.result != 'skipped' }}
permissions: {}
env:
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
steps:
- name: Send CD result to Discord
if: ${{ env.DISCORD_WEBHOOK != '' }}
uses: Ilshidur/[email protected]
with:
args: "AI 배포 결과 알림"
env:
DISCORD_USERNAME: MURO_BOT
DISCORD_EMBEDS: |
[
{
"title": "AI 배포: ${{ needs.rolling-update.result }}",
"description": "이미지 태그: ${{ needs.prepare.outputs.image_tag }}",
"url": "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}",
"color": ${{ needs.rolling-update.result == 'success' && 3066993 || 15158332 }},
"fields": [
{
"name": "실행한 사람",
"value": ${{ toJSON(github.actor) }},
"inline": true
},
{
"name": "배포 방식",
"value": "Rolling Update",
"inline": true
}
]
}
]- cd-dev.yml: develop VM에 frontend 컨테이너만 교체한다. CI의 deploy job을 그대로 옮겼다.
- cd-prod.yml: 기존 CD 파일에서 name과 workflow_run.workflows 두 줄만 바뀌었다.
cd-dev.yml
name: Frontend CD dev
# develop CI(ci-dev.yml)가 성공하면 자동으로 개발 서버에 배포한다.
# workflow_dispatch는 개발 서버에 다시 배포하고 싶을 때 수동으로 쓴다.
# 주의: workflow_run으로 실행되는 워크플로는 기본 브랜치(main)에 있는 파일 기준으로 실행된다.
on:
workflow_run:
workflows: ["Frontend CI dev"]
types: [completed]
branches: [develop]
workflow_dispatch:
env:
ZONE: asia-northeast3-a
DEV_VM_NAME: mulo-v2-dev-vm # Terraform이 만든 개발 VM 이름
jobs:
deploy:
name: Deploy to Develop VM
runs-on: ubuntu-latest
if: ${{ github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success' }}
concurrency:
group: cd-frontend-develop
cancel-in-progress: false
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. DEV_VM_NAME 이름의 개발 VM을 찾는다.
- id: develop
run: |
VM=$(gcloud compute instances list \
--filter="name=${{ env.DEV_VM_NAME }}" \
--zones="${{ env.ZONE }}" \
--format="value(name)")
if [ -z "$VM" ]; then
echo "${{ env.DEV_VM_NAME }} 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한다.
- 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
"
notify:
name: Discord notification
runs-on: ubuntu-latest
needs: deploy
if: ${{ always() && needs.deploy.result != 'skipped' }}
permissions: {}
env:
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
steps:
- name: Send CD result to Discord
if: ${{ env.DISCORD_WEBHOOK != '' }}
uses: Ilshidur/[email protected]
with:
args: "Frontend Develop 배포 결과 알림"
env:
DISCORD_USERNAME: MURO_BOT
DISCORD_EMBEDS: |
[
{
"title": "Frontend Develop 배포: ${{ needs.deploy.result }}",
"description": ${{ toJSON(github.event.workflow_run.head_commit.message || '수동 실행') }},
"url": "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}",
"color": ${{ needs.deploy.result == 'success' && 3066993 || 15158332 }},
"fields": [
{
"name": "커밋",
"value": "[커밋 확인](${{ github.server_url }}/${{ github.repository }}/commit/${{ github.event.workflow_run.head_sha || github.sha }})",
"inline": true
},
{
"name": "푸시한 사람",
"value": ${{ toJSON(github.event.workflow_run.actor.login || github.actor) }},
"inline": true
},
{
"name": "브랜치",
"value": "develop",
"inline": true
}
]
}
]
cd-prod.yml
name: Frontend CD prod
# CI가 main에서 성공적으로 끝나면 자동으로 실행된다.
# workflow_dispatch는 특정 태그로 재배포하거나 이전 버전으로 되돌릴 때 수동으로 쓴다.
on:
workflow_run:
workflows: ["Frontend CI prod"]
types: [completed]
branches: [main]
workflow_dispatch:
inputs:
image_tag:
description: "배포할 이미지 태그 (비우면 가장 최근 태그)"
required: false
type: string
env:
SERVICE: frontend
MIG_NAME: muro-frontend-mig
MIG_FLAG: "--zone=asia-northeast3-a"
MAX_SURGE: "1"
MAX_UNAVAILABLE: "0"
REPLACEMENT_METHOD: substitute
jobs:
# ============================================================
# 1단계: 배포할 이미지 태그와 현재 인스턴스 템플릿을 확인한다. (VM은 건드리지 않는다)
# ============================================================
prepare:
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:
image_tag: ${{ steps.image.outputs.tag }}
prev_template: ${{ steps.current.outputs.template }}
new_template: ${{ steps.name.outputs.template }}
steps:
- id: auth
uses: google-github-actions/auth@v3
with:
workload_identity_provider: ${{ secrets.WORKLOAD_IDENTITY_PROVIDER }}
service_account: ${{ secrets.SERVICE_ACCOUNT }}
- uses: google-github-actions/setup-gcloud@v3
# 이미지는 CD가 빌드하지 않고, CI가 push해둔 것을 그대로 쓴다.
# 수동 실행 때 태그를 넣으면 그 태그를, 비우면 가장 최근 태그를 쓴다.
- id: image
env:
INPUT_TAG: ${{ inputs.image_tag }}
IMAGE_REPO: ${{ secrets.GCP_REGION }}-docker.pkg.dev/${{ secrets.GCP_PROJECT_ID }}/${{ secrets.PROJECT_REPOSITORY }}/${{ secrets.IMAGE_NAME }}
run: |
if [ -n "$INPUT_TAG" ]; then
TAG="$INPUT_TAG"
else
TAG=$(gcloud artifacts docker images list "$IMAGE_REPO" \
--include-tags --sort-by="~UPDATE_TIME" --limit=1 \
--format="value(TAGS)" | cut -d',' -f1)
fi
if [ -z "$TAG" ]; then
echo "배포할 이미지 태그를 찾을 수 없습니다."
exit 1
fi
# 해당 태그의 이미지가 실제로 있는지 확인한다.
gcloud artifacts docker images describe "$IMAGE_REPO:$TAG" > /dev/null
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
# 롤백할 때 되돌아갈 현재 인스턴스 템플릿 이름
- id: current
run: |
TEMPLATE=$(gcloud compute instance-groups managed describe "$MIG_NAME" $MIG_FLAG \
--format="value(versions[0].instanceTemplate.basename())")
if [ -z "$TEMPLATE" ]; then
echo "$MIG_NAME 의 현재 인스턴스 템플릿을 찾을 수 없습니다."
exit 1
fi
echo "template=$TEMPLATE" >> "$GITHUB_OUTPUT"
# 새 템플릿 이름: 서비스-tpl-태그-실행번호 (소문자, 숫자, 하이픈만 가능)
- id: name
env:
TAG: ${{ steps.image.outputs.tag }}
run: |
SAFE=$(echo "$TAG" | tr '[:upper:]' '[:lower:]' | tr -c 'a-z0-9\n' '-' | sed 's/-*$//')
echo "template=${SERVICE}-tpl-${SAFE}-${{ github.run_number }}" >> "$GITHUB_OUTPUT"
# ============================================================
# 2단계: 새 인스턴스 템플릿을 만들고 MIG를 교체한다.
# environment: production 에 지정한 승인자가 승인해야 시작한다.
# ============================================================
rolling-update:
needs: prepare
runs-on: ubuntu-latest
environment: production
concurrency:
group: cd-frontend-production
cancel-in-progress: false
permissions:
contents: read
id-token: write
env:
TAG: ${{ needs.prepare.outputs.image_tag }}
PREV_TEMPLATE: ${{ needs.prepare.outputs.prev_template }}
NEW_TEMPLATE: ${{ needs.prepare.outputs.new_template }}
steps:
- id: auth
uses: google-github-actions/auth@v3
with:
workload_identity_provider: ${{ secrets.WORKLOAD_IDENTITY_PROVIDER }}
service_account: ${{ secrets.SERVICE_ACCOUNT }}
- uses: google-github-actions/setup-gcloud@v3
# 현재 템플릿을 복사하고 이미지 태그 메타데이터만 바꾼 새 템플릿을 만든다.
# VM은 부팅할 때 이 태그의 이미지를 받아서 실행한다.
- name: Create instance template
run: |
gcloud compute instance-templates create "$NEW_TEMPLATE" \
--source-instance-template="$PREV_TEMPLATE" \
--metadata=app-image-tag="$TAG"
- id: start
name: Start rolling update
run: |
gcloud compute instance-groups managed rolling-action start-update "$MIG_NAME" $MIG_FLAG \
--version=template="$NEW_TEMPLATE" \
--type=proactive \
--minimal-action=replace \
--most-disruptive-allowed-action=replace \
--replacement-method="$REPLACEMENT_METHOD" \
--max-surge="$MAX_SURGE" \
--max-unavailable="$MAX_UNAVAILABLE"
# 모든 VM이 새 템플릿으로 교체되고 헬스체크를 통과할 때까지 기다린다.
- name: Wait until all instances are updated
run: |
gcloud compute instance-groups managed wait-until "$MIG_NAME" $MIG_FLAG \
--version-target-reached --timeout=900
- name: Wait until the group is stable
run: |
gcloud compute instance-groups managed wait-until "$MIG_NAME" $MIG_FLAG \
--stable --timeout=300
# 교체 도중 실패하면 이전 템플릿으로 되돌린다. (교체를 시작한 경우에만)
- name: Rollback to previous template
if: ${{ failure() && steps.start.outcome == 'success' }}
run: |
gcloud compute instance-groups managed rolling-action start-update "$MIG_NAME" $MIG_FLAG \
--version=template="$PREV_TEMPLATE" \
--type=proactive \
--minimal-action=replace \
--most-disruptive-allowed-action=replace \
--replacement-method="$REPLACEMENT_METHOD" \
--max-surge="$MAX_SURGE" \
--max-unavailable="$MAX_UNAVAILABLE"
gcloud compute instance-groups managed wait-until "$MIG_NAME" $MIG_FLAG \
--version-target-reached --timeout=900
# 오래된 템플릿은 최근 5개만 남기고 지운다. (사용 중인 템플릿은 삭제되지 않는다)
- name: Delete old instance templates
if: ${{ success() }}
continue-on-error: true
run: |
gcloud compute instance-templates list \
--filter="name~^${SERVICE}-tpl-" \
--sort-by="~creationTimestamp" \
--format="value(name)" \
| tail -n +6 \
| while read -r T; do
gcloud compute instance-templates delete "$T" --quiet || true
done
# 배포 결과 알림
notify:
name: Discord notification
runs-on: ubuntu-latest
needs: [prepare, rolling-update]
if: ${{ always() && needs.prepare.result != 'skipped' }}
permissions: {}
env:
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
steps:
- name: Send CD result to Discord
if: ${{ env.DISCORD_WEBHOOK != '' }}
uses: Ilshidur/[email protected]
with:
args: "Frontend 배포 결과 알림"
env:
DISCORD_USERNAME: MURO_BOT
DISCORD_EMBEDS: |
[
{
"title": "Frontend 배포: ${{ needs.rolling-update.result }}",
"description": "이미지 태그: ${{ needs.prepare.outputs.image_tag }}",
"url": "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}",
"color": ${{ needs.rolling-update.result == 'success' && 3066993 || 15158332 }},
"fields": [
{
"name": "실행한 사람",
"value": ${{ toJSON(github.actor) }},
"inline": true
},
{
"name": "배포 방식",
"value": "Rolling Update",
"inline": true
}
]
}
]CD는 인스턴스 템플릿의 app-image-tag 메타데이터만 바꾼다. 나머지는 템플릿에 미리 들어 있어야 하고, 이 부분은 v2 인프라 개발 때 만든다. 아래는 CD와 맞물리는 부분을 보여주기 위한 예시다.
템플릿에 넣을 메타데이터는 다음과 같다.
| 키 | 값 | 누가 바꾸나 |
|---|---|---|
| app-image | 이미지 저장소 경로 (태그 제외) | 인프라 (고정) |
| app-image-tag | 이미지 태그 | CD (배포마다) |
| app-env-secret | env 내용이 들어 있는 Secret Manager 시크릿 이름 | 인프라 (고정) |
| app-port | 컨테이너 포트 | 인프라 (고정) |
부팅 스크립트 예시는 다음과 같다. Docker와 Google Cloud CLI가 설치된 이미지를 쓴다고 가정한다. (v1의 startup-script.sh는 첫 부팅 때 설치까지 하는데, MIG에서는 VM이 자주 새로 만들어지므로 설치를 포함한 커스텀 이미지를 쓰는 편이 부팅이 빠르다)
#!/bin/bash
set -euo pipefail
md() { curl -fs -H "Metadata-Flavor: Google" \
"<http://metadata.google.internal/computeMetadata/v1/instance/attributes/$1>"; }
IMAGE=$(md app-image)
TAG=$(md app-image-tag)
SECRET=$(md app-env-secret)
PORT=$(md app-port)
gcloud auth configure-docker asia-northeast3-docker.pkg.dev --quiet
# 환경변수는 Secret Manager에서 읽는다
mkdir -p /opt/app
gcloud secrets versions access latest --secret="$SECRET" > /opt/app/app.env
chmod 600 /opt/app/app.env
docker pull "$IMAGE:$TAG"
docker rm -f app 2>/dev/null || true
docker run -d --name app --restart unless-stopped \
--env-file /opt/app/app.env \
-p "$PORT:$PORT" \
--stop-timeout 60 \
"$IMAGE:$TAG"종료 스크립트는 다음과 같다. VM이 내려갈 때 컨테이너에 종료 신호를 보내고 최대 60초를 기다린다. 애플리케이션은 종료 신호를 받으면 처리 중인 요청과 채팅 메시지 저장을 마치고 종료해야 한다(Graceful Shutdown).
#!/bin/bash
docker stop -t 60 app || truev1에서는 CD가 GitHub 시크릿으로 backend.env, ai.env를 만들어서 SSH로 VM에 보냈다. v2의 MIG는 이 방식을 쓸 수 없어서 Secret Manager를 도입한다.
도입이 필요한 이유
- 오토힐링, 롤링 업데이트, VM 교체로 새 VM이 CD와 상관없이 만들어진다. 새 VM에는 아무도 env 파일을 보내주지 않으므로, VM이 부팅할 때 스스로 값을 가져올 곳이 필요하다.
- 인스턴스 템플릿 메타데이터에 값을 넣는 방법도 있지만, 콘솔과 API에서 값이 그대로 보이고 VM 안에서 누구나 읽을 수 있어서 DB 비밀번호와 API 키에는 쓰지 않는다.
- Secret Manager는 권한으로 접근을 제한하고, 버전 관리와 접근 기록이 남는다. 시크릿이 몇 개 수준이라 비용은 월 $1 안쪽이다.
서비스마다 시크릿을 하나씩 만들고, 그 서비스의 런타임 환경변수 전체를 env 파일 형식으로 넣는다. 부팅 스크립트가 한 번만 읽으면 되고, 설정값 변경도 같은 절차로 관리할 수 있다. 템플릿 메타데이터의 app-env-secret에 시크릿 이름을 적는다.
옮길 값은 v1 운영 CD가 VM에 주입하던 런타임 환경변수 전부다.
| 서비스 | 민감한 값 | 설정값 |
|---|---|---|
| Backend | DB_PASSWORD, DB_USERNAME, DB_URL, JWT_SECRET, SENTRY_DSN, KAKAO_REST_API_KEY, KMA_SERVICE_KEY, SPOTIFY_CLIENT_ID, SPOTIFY_CLIENT_SECRET, AI_INTERNAL_TOKEN, REPORT_OPERATION_TOKEN | GCS_BUCKET_NAME, GCS_PROJECT_ID, AI_BASE_URL, MULO_SECURITY_ALLOWED_ORIGINS, JAVA_TOOL_OPTIONS |
| AI Server | DATABASE_URL, INTERNAL_TOKEN, ANTHROPIC_API_KEY, OPENAI_API_KEY, REPLICATE_API_TOKEN, SPOTIFY_CLIENT_ID, SPOTIFY_CLIENT_SECRET, YOUTUBE_API_KEY, BACKEND_MYSQL_URL | APP_ENV, AI_CLIENT_MODE, LLM_MODEL, TEXT_EMBEDDING_MODEL, TEXT_EMBEDDING_DIM, CLIP_MODEL, CLIP_EMBEDDING_DIM, BACKEND_INTERNAL_URL |
| AI Server 신규 | RunPod 접속 키, Backend DB 읽기 전용 계정 정보 | RunPod 주소 |
| Frontend | 없음 | Frontend는 env 파일이 비어 있고, Sentry 값은 빌드 때 이미지에 들어간다 |
| Chat Server | CHAT_DB_PASSWORD, CHAT_DB_USERNAME, CHAT_DB_URL (application.yaml 기준, 인증 설정이 추가되면 더함) |
GitHub 시크릿에 그대로 남기는 값은 다음과 같다. CI/CD가 쓰는 값이라 VM 런타임에는 필요하지 않다.
| 구분 | 값 |
|---|---|
| 배포 인증 | WORKLOAD_IDENTITY_PROVIDER, SERVICE_ACCOUNT, GCP_PROJECT_ID, GCP_REGION, PROJECT_REPOSITORY, IMAGE_NAME |
| 릴리즈와 알림 | GIT_TOKEN, DISCORD_WEBHOOK |
| 빌드 때 이미지에 들어가는 값 | VITE_SENTRY_DSN, SENTRY_AUTH_TOKEN, SENTRY_ORG, SENTRY_PROJECT |
| v2에서 사라지는 값 | MURO_PROD_IP (v1의 고정 IP 교체용) |
개발서버용 시크릿은 GitHub 시크릿 방식을 그대로 쓴다.
CD용 서비스 계정에 필요한 권한은 다음과 같다.
| 역할 | 용도 |
|---|---|
| Compute Instance Admin (v1) | 인스턴스 템플릿 생성과 삭제, MIG 교체 |
| Service Account User (VM이 쓰는 서비스 계정에 대해) | 템플릿에 서비스 계정을 지정해 VM을 만들 때 필요 |
| Artifact Registry Reader | 이미지 태그 조회와 확인 |
VM이 쓰는 서비스 계정(런타임 계정)에는 별도로 다음 권한이 필요하다. CD용 계정이 아니라 VM용 계정이다.
| 역할 | 용도 |
|---|---|
| Secret Manager Secret Accessor | 부팅할 때 서비스별 env 시크릿 읽기 |
| Artifact Registry Reader | 부팅할 때 이미지 받기 |
Chat 저장소를 새로 쓰기 때문에 GitHub, GCP 쪽에서 아래도 같이 해야 한다.
- Chat 저장소의 Settings → Secrets and variables → Actions에 위 표의 배포 인증, 릴리즈와 알림 시크릿을 등록한다. 개발용은 _Develop 접미사가 붙은 시크릿(GCP_PROJECT_ID_Develop, WORKLOAD_IDENTITY_PROVIDER_Develop, SERVICE_ACCOUNT_Develop)도 필요하다. IMAGE_NAME은 다른 서비스와 겹치지 않게 정한다. (예: mulo-chat)
- Settings → Environments에 production을 만들고 승인자를 지정한다. cd-prod.yml의 rolling-update가 이 환경을 쓴다. 다른 서비스 저장소도 마찬가지다.
- Workload Identity 공급자의 attribute condition이 저장소 이름으로 제한되어 있으면 100-hours-a-week/KTB4-16th-CHAT을 추가한다. 안 하면 인증 단계에서 실패한다.
- Chat 이미지가 올라가는 Artifact Registry 저장소에 CI용 서비스 계정의 쓰기 권한과 VM 서비스 계정의 읽기 권한이 있어야 한다.
- MIG 헬스체크 경로가 필요한데 Chat build.gradle에는 actuator가 없다. actuator를 넣거나 헬스체크용 엔드포인트를 만들어야 한다.