MINERVA CI/CD — GitHub Actions로 빌드·테스트·배포 묶기

07-0 Docker, 11-1 Reproducible Build, 12-1 CI 분리 위에 워크플로 얹기

07-0편이 컨테이너 이미지를 만드는 방법, 11-1편이 그 이미지를 재현 가능하게 만드는 방법, 12-1편이 테스트를 단계별로 분리하는 방법을 다뤘다면, 본 글은 그 셋을 GitHub Actions 워크플로로 묶어 자동화한다. PR 시점 lint/unit, integration test, 이미지 build/push, Azure Container Apps 배포까지 4개 워크플로와 시크릿 관리, 롤백 전략을 정리한다.

Agent
저자

Kwangmin Kim

공개

2026년 05월 06일

1 본 글의 위치

이 글은 Phase B 시리즈에서 세 편을 묶는 마무리다.

세 편이 자료(이미지·고정·테스트)를 깔아놨고, 본 글은 그 자료를 GitHub Actions 워크플로로 자동 실행 가능하게 묶는다. CI(Continuous Integration)에서 매 커밋 검증, CD(Continuous Deployment)에서 Azure Container Apps에 자동 배포까지의 흐름을 다룬다.

정의: CI/CD
  • 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:a3f2b1c

11-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 자주 발생하는 오류 패턴

WRONG:

env:
  AZURE_OPENAI_API_KEY: "sk-abc123..."   # yml에 직접 작성

CORRECT:

env:
  AZURE_OPENAI_API_KEY: ${{ secrets.AZURE_OPENAI_API_KEY }}

yml 파일에 시크릿을 직접 적으면 git history에 영구 보존된다. 한 번 commit된 시크릿은 rotation 외 방법이 없다. 처음부터 GitHub Secrets로 격리.

WRONG:

- name: Install
  run: pip install -r requirements.lock.txt   # 매 실행 의존성 다시 다운로드

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 기반으로 자동 무효화도 처리.

WRONG:

docker push minerva:latest    # latest 한 태그만

CORRECT:

docker push minerva:${SHORT_SHA}    # commit별 고유 태그
docker push minerva:latest          # 편의용 추가

latest만 push하면 어제 배포한 이미지를 다시 띄울 수 없다. commit hash 태그를 같이 push하면 정확한 롤백·재현이 가능하다.

WRONG:

- run: az containerapp update --image minerva:${{ short_sha }}
# 그대로 끝 — 새 revision이 실제로 떴는지 확인 안 함

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 자료)

Phase C 연결 (예정)

  • C19 실험 파이프라인 자동화 — 본 글 워크플로 위에 A/B 실험 자동 배포 연결
  • C34 관측성 설계 — 배포 메트릭 + commit hash가 모니터링 대시보드에 노출
  • C36 보안과 접근 제어 — OIDC + Key Vault 패턴 심화

다른 카테고리 연결

Subscribe

Enjoy this blog? Get notified of new posts by email: