1 본 글의 위치
이 글은 Phase B 시리즈에서 세 편을 묶는 마무리다.
- 07-0편 프로덕션 배포 — Dockerfile 멀티스테이지, 워커, env 우선순위
- 11-1편 Config 운영 — Reproducible Build (base digest, lock+hashes, baking)
- 12-1편 고급 테스트 패턴 — CI 분리 6마커 (unit/property/concurrency/contract/integration/snapshot)
세 편이 자료(이미지·고정·테스트)를 깔아놨고, 본 글은 그 자료를 GitHub Actions 워크플로로 자동 실행 가능하게 묶는다. CI(Continuous Integration)에서 매 커밋 검증, CD(Continuous Deployment)에서 Azure Container Apps에 자동 배포까지의 흐름을 다룬다.
- CI: 개발자가 코드를 push할 때마다 자동으로 lint·테스트·빌드를 수행해 회귀를 빠르게 잡는다
- CD: 검증된 빌드를 운영 환경에 자동 배포한다 (Continuous Delivery는 수동 승인, Continuous Deployment는 완전 자동)
- GitHub Actions: GitHub 저장소에 .github/workflows/*.yml로 정의하는 워크플로 엔진. push/PR/schedule trigger로 실행
2 CI/CD가 MINERVA에 갖는 의미
1.5명 PoC 단계에서는 매 빌드를 사람이 수동으로 돌릴 수 있다. 그러나 50명 단계로 가면 다음 문제가 누적된다.
| 문제 | 수동 운영의 한계 | CI/CD 도입 효과 |
|---|---|---|
| 회귀 탐지 지연 | “내 PC에서는 되는데” 패턴 — 머신·환경 차이로 회귀를 며칠 후에 발견 | PR 시점 자동 lint·unit으로 수 분 내 탐지 |
| 빌드 일관성 | 사람마다 다른 시점·환경에서 빌드 → 결과 다름 | 11-1편의 Reproducible Build 위에 동일 runner로 빌드 |
| 시크릿 노출 | 로컬에서 .env 다루다 실수 commit |
GitHub Secrets로 격리, 워크플로에서만 접근 |
| 배포 추적 | 누가 언제 배포했는지 기록 없음 | 워크플로 실행 이력 + commit hash baking으로 추적 |
| 비용·시간 분산 | LLM 호출 비용이 큰 integration test를 매 PR마다 돌리면 비용 폭주 | 12-1편 CI 분리 표대로 단계별 빈도 분리 |
마지막 행이 핵심이다 — 비용·시간이 다른 테스트를 같은 워크플로에서 묶지 않고, trigger와 빈도를 분리하는 것이 GitHub Actions 설계의 본질이다.
3 MINERVA 배포 파이프라인 — 4단계
본 시리즈의 자료를 4개 워크플로로 매핑한다.
[1] PR 검증 (매 커밋)
pull_request trigger
└─ lint + unit + property + concurrency + contract (12-1 CI 분리: 매 PR ~30초)
[2] 통합 테스트 (일 1회 또는 main merge 시)
schedule (cron) + main push trigger
└─ integration test (실제 Azure OpenAI · Azure Search 호출)
[3] 이미지 빌드 + push (main merge 시)
push to main trigger
└─ Docker 멀티스테이지 빌드 (07-0편)
└─ commit hash · build_date baking (11-1편)
└─ ACR(Azure Container Registry) push
[4] 배포 (이미지 push 후 자동 또는 수동 승인)
workflow_run trigger (이미지 push 완료 후)
└─ Azure Container Apps revision 갱신
└─ /health/build 검증 (11-1편)
└─ 실패 시 이전 revision으로 롤백
각 워크플로는 .github/workflows/ 디렉토리에 별도 yml 파일로 정의한다. 이 분리가 변경의 폭발 반경을 줄인다 — 테스트 변경이 배포 워크플로에 영향을 주지 않는다.
4 워크플로 1 — PR 검증 (.github/workflows/pr-check.yml)
매 PR·push에 lint + 빠른 테스트를 돌린다. 외부 의존이 없으므로 비용 무료, 30초~3분.
name: PR Check
on:
pull_request:
branches: [main, develop]
push:
branches: [develop]
jobs:
lint-and-unit:
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.11.10" # 11-1편 Reproducible Build와 일치
cache: pip
- name: Install lock 파일로 정확히 같은 버전
run: |
pip install --no-cache-dir --require-hashes -r requirements.lock.txt
- name: Lint (ruff + mypy)
run: |
ruff check src/
mypy src/
- name: Unit + Property + Contract 테스트 (외부 의존 0)
run: |
pytest -m "not integration and not snapshot" --cov=src --cov-report=xml
- name: Coverage 업로드
uses: codecov/codecov-action@v5
with:
files: coverage.xml
- name: Frontend lint + 빌드 (정적 검증)
run: |
cd frontend
npm ci
npm run lint
npm run build핵심 설계:
--require-hashes로 의존성 위변조 방지 (11-1편 5요소 중 lock 파일 검증)pytest -m "not integration and not snapshot"이 12-1편 CI 분리 표의 1~4단계를 모두 실행npm run build까지 실행해서 프론트엔드 빌드 회귀도 PR에서 잡음
이 워크플로가 통과하지 않으면 PR을 merge할 수 없도록 GitHub branch protection rule에 등록한다.
5 워크플로 2 — 통합 테스트 (.github/workflows/integration.yml)
실제 Azure OpenAI/Search 호출이 필요한 테스트는 비용·시간 때문에 매 PR마다 돌리지 않는다. 하루 1회 + main merge 시로 trigger 분리.
name: Integration Test
on:
schedule:
- cron: "0 17 * * *" # 매일 02:00 KST (UTC 17:00)
push:
branches: [main]
workflow_dispatch: {} # 수동 trigger 가능
jobs:
integration:
runs-on: ubuntu-22.04
timeout-minutes: 30
env:
AZURE_OPENAI_ENDPOINT: ${{ secrets.AZURE_OPENAI_ENDPOINT }}
AZURE_OPENAI_API_KEY: ${{ secrets.AZURE_OPENAI_API_KEY }}
AZURE_VECTOR_STORE_ENDPOINT: ${{ secrets.AZURE_VECTOR_STORE_ENDPOINT }}
AZURE_VECTOR_STORE_API_KEY: ${{ secrets.AZURE_VECTOR_STORE_API_KEY }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with: { python-version: "3.11.10", cache: pip }
- run: pip install --no-cache-dir --require-hashes -r requirements.lock.txt
- name: Integration 테스트 (12-1편 마커)
run: pytest -m "integration" --maxfail=3
- name: Snapshot 회귀 (주 1회만, 요일 체크)
if: github.event.schedule == '0 17 * * 0'
run: pytest -m "snapshot"
- name: 실패 시 Slack 알림
if: failure()
uses: 8398a7/action-slack@v3
with:
status: failure
webhook_url: ${{ secrets.SLACK_WEBHOOK }}왜 schedule + main push 둘 다인가: schedule만 두면 main merge 후 다음날까지 회귀를 모른다. main push trigger를 추가해 merge 직후 즉시 검증한다. 동시에 schedule은 외부 서비스 변화(Azure 모델 update 등)로 인한 silent regression을 catch한다.
6 워크플로 3 — 이미지 빌드 + push (.github/workflows/build-image.yml)
main merge 시점에 Docker 이미지를 빌드해 ACR(Azure Container Registry)에 push한다. 11-1편의 Reproducible Build 5요소를 그대로 적용.
name: Build & Push Image
on:
push:
branches: [main]
jobs:
build:
runs-on: ubuntu-22.04
permissions:
contents: read
id-token: write # OIDC for Azure login
steps:
- uses: actions/checkout@v4
- name: Azure 로그인 (OIDC, 시크릿 hardcode 회피)
uses: azure/login@v2
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
- name: ACR 로그인
run: az acr login --name minervaregistry
- name: 빌드 메타데이터 추출
id: meta
run: |
echo "commit_sha=${GITHUB_SHA}" >> $GITHUB_OUTPUT
echo "short_sha=${GITHUB_SHA::7}" >> $GITHUB_OUTPUT
echo "build_date=$(date -u +'%Y-%m-%dT%H:%M:%SZ')" >> $GITHUB_OUTPUT
- name: 멀티스테이지 빌드 + commit·날짜 baking
run: |
docker build \
--build-arg COMMIT_SHA=${{ steps.meta.outputs.commit_sha }} \
--build-arg BUILD_DATE=${{ steps.meta.outputs.build_date }} \
--tag minervaregistry.azurecr.io/minerva:${{ steps.meta.outputs.short_sha }} \
--tag minervaregistry.azurecr.io/minerva:latest \
.
- name: ACR push (두 태그 모두)
run: |
docker push minervaregistry.azurecr.io/minerva:${{ steps.meta.outputs.short_sha }}
docker push minervaregistry.azurecr.io/minerva:latest
- name: SBOM 생성 + 보안 스캔
uses: anchore/scan-action@v3
with:
image: minervaregistry.azurecr.io/minerva:${{ steps.meta.outputs.short_sha }}
fail-build: false # 경고만 — main 차단까지는 보수적OIDC 로그인의 의미: Azure 자격증명을 GitHub Secrets에 영구 저장하지 않고 단기 토큰으로 받는다. 시크릿 노출 위험이 줄어든다. 처음 설정에 Azure AD App + Federated Credential이 필요하지만 이후 운영은 더 안전하다.
short_sha 태그: latest 한 태그만 두면 롤백이 어렵다. commit hash 7자리 태그를 함께 push해 두면 어느 시점의 이미지든 정확히 다시 띄울 수 있다.
7 워크플로 4 — 배포 (.github/workflows/deploy.yml)
이미지 push가 끝나면 Azure Container Apps의 revision을 갱신한다.
name: Deploy
on:
workflow_run:
workflows: ["Build & Push Image"]
types: [completed]
branches: [main]
jobs:
deploy:
if: ${{ github.event.workflow_run.conclusion == 'success' }}
runs-on: ubuntu-22.04
permissions:
id-token: write
contents: read
steps:
- uses: actions/checkout@v4
- uses: azure/login@v2
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
- name: 짧은 commit hash 추출
id: meta
run: echo "short_sha=${GITHUB_SHA::7}" >> $GITHUB_OUTPUT
- name: Container Apps revision 업데이트
run: |
az containerapp update \
--name minerva-api \
--resource-group minerva-rg \
--image minervaregistry.azurecr.io/minerva:${{ steps.meta.outputs.short_sha }} \
--revision-suffix ${{ steps.meta.outputs.short_sha }}
- name: 배포 완료 후 /health/build 검증
run: |
for i in {1..30}; do
sha=$(curl -s https://minerva-api.example.com/health/build | jq -r '.commit')
if [ "$sha" = "${{ steps.meta.outputs.short_sha }}" ]; then
echo "Deployed commit verified: $sha"
exit 0
fi
echo "Attempt $i: deployed=$sha, expected=${{ steps.meta.outputs.short_sha }}"
sleep 10
done
echo "Deployment verification timeout"
exit 1
- name: 검증 실패 시 이전 revision으로 롤백
if: failure()
run: |
az containerapp revision list \
--name minerva-api \
--resource-group minerva-rg \
--query "[?properties.active==\`true\`] | [1].name" -o tsv \
| xargs -I {} az containerapp revision activate \
--name minerva-api \
--resource-group minerva-rg \
--revision {}핵심 설계 — /health/build 검증 루프: 11-1편의 commit hash baking이 여기서 활용된다. 새 revision이 실제로 트래픽을 받기 시작했는지 commit hash로 직접 확인한다. 30회 × 10초 = 5분 안에 새 commit이 응답하지 않으면 롤백.
자동 롤백: revision 목록에서 직전 active revision으로 traffic 전환. Container Apps의 revision 모드가 multiple이어야 동시 두 revision이 가능하다.
8 시크릿 관리 — GitHub Secrets vs Azure Key Vault
| 시크릿 종류 | 저장 위치 | 사용처 |
|---|---|---|
| Azure 로그인 (OIDC) | GitHub Secrets (CLIENT_ID, TENANT_ID 등) | GitHub Actions 워크플로 |
| Azure OpenAI API key | Azure Key Vault | 런타임 (Container Apps env로 주입) |
| Slack Webhook | GitHub Secrets | 알림용 |
| ACR 자격증명 | OIDC로 대체 (저장하지 않음) | 이미지 push |
원칙: GitHub Secrets는 워크플로 안에서만 쓰는 값(다른 클라우드에 접근하기 위한 메타). 런타임 시크릿(API key 등)은 Azure Key Vault에 두고 Container Apps에서 environment variable로 주입한다. 이 분리로 GitHub 침해 시 영향이 워크플로 권한에 한정된다.
9 12-1편 CI 분리 표와 워크플로 매핑
12-1편 고급 테스트 패턴의 CI 분리 표와 본 글의 워크플로를 직접 매핑한다.
| 12-1 마커 | 워크플로 | 빈도 | 비용 |
|---|---|---|---|
| (none) unit | pr-check | 매 PR/push | 무료 (Actions 무료 분) |
| property | pr-check | 매 PR/push | 무료 |
| concurrency | pr-check | 매 PR/push | 무료 |
| contract | pr-check | 매 PR/push | 무료 |
| integration | integration | 일 1회 + main merge | Azure OpenAI 호출 비용 |
| snapshot | integration (주 1회 if) | 주 1회 | Azure OpenAI 호출 비용 (회귀 검증용) |
pytest -m 명령이 마커별 분리를 강제하므로 워크플로 yml에서 명령 한 줄로 단계 분기가 자연스럽다.
10 롤백·재현 — 11-1편 자산의 활용
운영에서 회귀가 발견되면 다음 절차로 즉시 복구할 수 있다.
# 1. 어느 commit이 운영에 있는가 (11-1편 /health/build)
curl https://minerva-api.example.com/health/build
# {"commit": "a3f2b1c", "build_date": "2026-05-06T10:00:00Z"}
# 2. 이전 정상 commit으로 revision 활성화
az containerapp revision activate \
--name minerva-api \
--revision minerva-api--bef013d # 7자리 short_sha를 revision-suffix로 썼으므로 추적 가능
# 3. 로컬에서 동일 이미지 재현해 디버깅
docker run -p 8000:8000 \
--env-file .env.production \
minervaregistry.azurecr.io/minerva:a3f2b1c11-1편의 Reproducible Build가 없으면 “어제는 됐는데 오늘 다시 빌드하니 안 된다”가 발생한다. 11-1편이 빌드 결정성을, 본 글이 그 빌드의 자동 실행·배포·롤백을 담당한다.
11 MINERVA의 현실적 도입 단계
워크플로 4개를 한 번에 도입하려면 부담이 크다. 단계적 도입을 권장한다.
| 단계 | 작업 | 주당 예상 시간 |
|---|---|---|
| 1 | pr-check.yml만 도입 (lint + unit + property + concurrency + contract) | 2시간 |
| 2 | integration.yml 추가 (schedule + main merge) | 2시간 + Azure secret 설정 |
| 3 | build-image.yml 추가 (OIDC 설정 포함) | 4시간 (OIDC federated credential 설정) |
| 4 | deploy.yml 추가 + /health/build 검증 루프 | 3시간 |
| 5 | 자동 롤백 + Slack 알림 + SBOM 보안 스캔 | 2시간 |
각 단계가 독립적으로 가치를 만든다 — 1단계만 도입해도 PR 회귀 탐지가 자동화된다. 50명 단계로 가면 5단계까지 모두 필요하지만 1.5명 단계에서는 1~2단계만으로도 충분하다.
12 자주 발생하는 오류 패턴
CORRECT:
yml 파일에 시크릿을 직접 적으면 git history에 영구 보존된다. 한 번 commit된 시크릿은 rotation 외 방법이 없다. 처음부터 GitHub Secrets로 격리.
CORRECT:
- uses: actions/setup-python@v5
with:
python-version: "3.11.10"
cache: pip # pip 캐시 활성화
- run: pip install --no-cache-dir --require-hashes -r requirements.lock.txt의존성 캐시가 없으면 워크플로 실행 시간이 2~3배 늘고 GitHub Actions 무료 분을 빠르게 소진한다. setup-python의 cache 옵션은 lock 파일 hash 기반으로 자동 무효화도 처리.
CORRECT:
latest만 push하면 어제 배포한 이미지를 다시 띄울 수 없다. commit hash 태그를 같이 push하면 정확한 롤백·재현이 가능하다.
CORRECT:
- run: az containerapp update --image minerva:${{ short_sha }}
- run: |
for i in {1..30}; do
sha=$(curl -s .../health/build | jq -r '.commit')
[ "$sha" = "${{ short_sha }}" ] && exit 0
sleep 10
done
exit 1배포 명령이 성공해도 컨테이너가 실제로 뜨고 트래픽을 받기까지 시간이 걸린다. /health/build 엔드포인트로 commit hash가 응답되는지 확인하지 않으면 “배포 성공” 알림 후에도 운영이 깨진 상태일 수 있다.
13 정리
| 워크플로 | trigger | 12-1 마커 | 11-1·07-0 자산 활용 |
|---|---|---|---|
| pr-check | PR/push | unit/property/concurrency/contract | requirements.lock.txt + –require-hashes |
| integration | schedule + main push | integration, snapshot | Azure secret env로 주입 |
| build-image | main push | (테스트 없음, 빌드만) | Dockerfile 멀티스테이지 + commit·date baking |
| deploy | workflow_run | (검증만) | /health/build 엔드포인트로 commit 응답 검증 |
세 편(07-0 빌드 / 11-1 고정 / 12-1 테스트)이 따로 있을 때는 운영에서 활용도가 절반에 그친다. GitHub Actions로 한 번에 묶으면 매 PR 검증 → 매일 통합 검증 → 매 main merge 빌드·배포가 자동으로 일어나며, 회귀 탐지·롤백·재현이 모두 commit hash 한 키로 가능해진다.
50명 단계의 MINERVA 운영은 본 글의 4개 워크플로가 토대가 된다. 그 이상의 규모에서는 GitOps(Argo CD), Helm chart, multi-cluster deployment 같은 다음 단계가 필요하지만, MINERVA의 현재 단계에서는 GitHub Actions 4개 yml만으로도 충분하다.
14 관련 주제
선행 학습 (Phase B 자료)
- 07-0편 프로덕션 배포 — Dockerfile, env 우선순위, 워커
- 11-1편 Config 운영 패턴 — Reproducible Build, /health/build
- 12-1편 고급 테스트 패턴 — CI 분리 6마커
Phase C 연결 (예정)
- C19 실험 파이프라인 자동화 — 본 글 워크플로 위에 A/B 실험 자동 배포 연결
- C34 관측성 설계 — 배포 메트릭 + commit hash가 모니터링 대시보드에 노출
- C36 보안과 접근 제어 — OIDC + Key Vault 패턴 심화
다른 카테고리 연결
- Docker 기초 — 멀티스테이지 빌드 일반