Published on

Kubeflow 없이 GitHub Actions로 최소 MLOps 구축

Authors

Kubeflow는 강력하지만, 도입 순간부터 쿠버네티스 운영(클러스터, RBAC, 네트워킹, 스토리지, 관측성)과 파이프라인 DSL 학습이 함께 따라옵니다. 팀 규모가 작거나 모델이 1~3개 수준인 서비스에서는 이 비용이 과투자일 때가 많습니다.

이 글은 GitHub Actions만으로도 충분히 굴러가는 최소 MLOps 파이프라인을 목표로 합니다. 핵심은 “완벽한 플랫폼”이 아니라 반복 가능한 자동화입니다.

  • 코드 푸시나 스케줄에 따라 자동 실행
  • 데이터/피처 스키마 변경 감지
  • 학습과 평가를 표준화된 러너에서 재현
  • 모델 아티팩트를 버전으로 남김
  • 승인 후 배포(또는 자동 배포)

아래 구성은 쿠버네티스가 없어도 되고, 필요하면 나중에 자연스럽게 확장할 수 있습니다.

최소 MLOps의 범위: 꼭 필요한 6단계

Kubeflow를 대체하려고 하면 끝이 없습니다. 대신 “최소”를 다음으로 정의합니다.

  1. 데이터 검증: 컬럼/타입/범위/결측치 등 기본 품질 체크
  2. 학습: 고정된 환경에서 재현 가능하게 실행
  3. 평가: 메트릭 산출 및 기준 미달 시 실패 처리
  4. 패키징: 모델 파일, 설정, 메타데이터를 묶어 아티팩트로 저장
  5. 레지스트리/저장소 업로드: S3, GCS, Hugging Face, GitHub Releases 등
  6. 배포: API 서버 이미지 빌드 및 배포(Cloud Run, ECS, VM 등)

여기서 중요한 포인트는 환경과 산출물의 표준화입니다. Kubeflow가 해주던 일의 상당 부분은 사실 “표준화된 실행기 + 아티팩트 관리 + 승인 흐름”입니다.

레포지토리 구조 예시

가장 단순한 형태로 시작합니다.

  • src/: 학습/추론 코드
  • pipelines/: 스크립트 엔트리포인트
  • configs/: 실험 설정
  • tests/: 유닛 테스트
  • model_card/: 모델 설명 템플릿

예시:

.
├── src/
│   ├── data.py
│   ├── train.py
│   ├── evaluate.py
│   └── infer.py
├── pipelines/
│   ├── validate_data.py
│   ├── run_train.py
│   ├── run_eval.py
│   └── package_model.py
├── configs/
│   └── train.yaml
├── model_card/
│   └── README.md
└── pyproject.toml

파이프라인 설계 원칙: “스크립트 우선, 워크플로는 얇게”

GitHub Actions YAML에 로직을 과도하게 넣으면 유지보수가 어려워집니다. 대신:

  • 로직은 Python 스크립트
  • Actions는 트리거, 캐시, 아티팩트 전달, 시크릿 주입만 담당

이렇게 하면 나중에 Jenkins, Argo, Airflow로 옮겨도 스크립트는 그대로 재사용할 수 있습니다.

데이터 검증: 스키마와 분포를 최소한으로 잡기

최소 MLOps에서 데이터 검증은 “완벽한 데이터 품질”이 아니라 **급격한 변화(스키마 깨짐, 결측 폭증, 라벨 누락)**를 빨리 감지하는 장치입니다.

간단한 검증 스크립트 예시입니다.

# pipelines/validate_data.py
import json
import pandas as pd

REQUIRED_COLS = {
    "user_id": "int64",
    "feature_a": "float64",
    "feature_b": "float64",
    "label": "int64",
}


def main(path: str) -> None:
    df = pd.read_parquet(path)

    missing = [c for c in REQUIRED_COLS if c not in df.columns]
    if missing:
        raise SystemExit(f"Missing columns: {missing}")

    for col, dtype in REQUIRED_COLS.items():
        if str(df[col].dtype) != dtype:
            raise SystemExit(f"Type mismatch: {col} is {df[col].dtype}, expected {dtype}")

    na_rate = df.isna().mean().to_dict()
    if na_rate["label"] > 0:
        raise SystemExit("Label contains NA")

    if na_rate["feature_a"] > 0.2:
        raise SystemExit(f"Too many NA in feature_a: {na_rate['feature_a']}")

    report = {
        "rows": int(len(df)),
        "na_rate": na_rate,
        "dtypes": {c: str(df[c].dtype) for c in REQUIRED_COLS},
    }
    print(json.dumps(report, ensure_ascii=False, indent=2))


if __name__ == "__main__":
    import argparse

    p = argparse.ArgumentParser()
    p.add_argument("--data", required=True)
    args = p.parse_args()

    main(args.data)

데이터 전처리에서 merge를 많이 쓰는 팀이라면, 파이프라인이 갑자기 깨질 때 원인이 “조인 키 누락”이나 “중복 컬럼”인 경우가 많습니다. 이런 유형은 학습 단계까지 가기 전에 데이터 검증에서 잡는 게 비용이 가장 적습니다. 관련해서는 Pandas merge KeyError·중복 컬럼 오류 완전 정복도 함께 참고하면 좋습니다.

학습과 평가: 메트릭 게이트를 걸어 자동 실패시키기

학습 스크립트는 반드시 다음을 남겨야 합니다.

  • 모델 파일(예: model.pkl, model.onnx)
  • 메트릭 JSON(예: metrics.json)
  • 학습에 사용한 설정(예: train.yaml 복사본)
  • 데이터 버전(가능하면 해시)

평가 스크립트는 기준 미달이면 프로세스를 실패 코드로 종료해야 합니다. 그래야 Actions에서 빨간불이 켜지고, 배포로 넘어가지 않습니다.

# pipelines/run_eval.py
import json

THRESHOLDS = {
    "auc": 0.80,
    "f1": 0.60,
}


def main(metrics_path: str) -> None:
    with open(metrics_path, "r", encoding="utf-8") as f:
        metrics = json.load(f)

    for k, v in THRESHOLDS.items():
        if metrics.get(k, 0) < v:
            raise SystemExit(f"Metric gate failed: {k}={metrics.get(k)} < {v}")

    print("Metric gate passed")


if __name__ == "__main__":
    import argparse

    p = argparse.ArgumentParser()
    p.add_argument("--metrics", required=True)
    args = p.parse_args()

    main(args.metrics)

GitHub Actions 워크플로: 학습부터 아티팩트까지

다음 워크플로는 PR에서는 빠른 검증만 하고, main 머지 또는 스케줄에서 전체 학습을 돌리는 형태입니다.

주의: MDX 렌더링에서 부등호가 본문에 노출되면 문제될 수 있으므로, 아래는 코드 블록 안에서만 사용합니다.

# .github/workflows/mlops.yml
name: minimal-mlops

on:
  pull_request:
  push:
    branches: ["main"]
  schedule:
    - cron: "0 2 * * *"
  workflow_dispatch:

jobs:
  test-and-validate:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-python@v5
        with:
          python-version: "3.11"

      - name: Install
        run: |
          python -m pip install -U pip
          pip install -r requirements.txt

      - name: Unit tests
        run: pytest -q

      - name: Validate data
        run: |
          python pipelines/validate_data.py --data data/train.parquet

  train-eval-package:
    if: github.ref == 'refs/heads/main'
    needs: [test-and-validate]
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-python@v5
        with:
          python-version: "3.11"

      - name: Restore pip cache
        uses: actions/cache@v4
        with:
          path: |
            ~/.cache/pip
          key: ${{ runner.os }}-pip-${{ hashFiles('requirements.txt') }}
          restore-keys: |
            ${{ runner.os }}-pip-

      - name: Install
        run: |
          python -m pip install -U pip
          pip install -r requirements.txt

      - name: Train
        run: |
          python pipelines/run_train.py --config configs/train.yaml --out artifacts

      - name: Evaluate (metric gate)
        run: |
          python pipelines/run_eval.py --metrics artifacts/metrics.json

      - name: Package
        run: |
          python pipelines/package_model.py --in artifacts --out dist

      - name: Upload artifact
        uses: actions/upload-artifact@v4
        with:
          name: model-dist
          path: dist

캐시가 안 먹으면 파이프라인이 “느려서 실패”합니다

최소 MLOps에서 은근히 치명적인 문제가 캐시입니다. 러너가 매번 의존성을 새로 깔면 학습 이전 단계에서 시간을 다 쓰고, 팀은 결국 자동화를 꺼버립니다.

캐시 키 설계, restore-keys, 경로 지정 실수 등으로 cache-hit가 계속 0%가 되는 경우가 많습니다. 이 문제를 겪고 있다면 GitHub Actions 캐시가 안 먹을 때 - cache-hit 0% 원인 정리를 먼저 확인하는 편이 빠릅니다.

모델 버전 관리: “레지스트리”가 없어도 버전은 남겨야 합니다

Kubeflow를 쓰지 않더라도, 최소한 다음 3가지는 버전으로 남겨야 합니다.

  • 코드 버전: Git SHA
  • 데이터 버전: 파일 해시 또는 날짜 파티션
  • 모델 버전: 위 둘을 묶은 태그

가장 단순한 방법은 dist/metadata.json에 메타데이터를 넣고, Actions에서 아티팩트로 업로드하는 것입니다.

# pipelines/package_model.py
import hashlib
import json
from pathlib import Path


def sha256_file(p: Path) -> str:
    h = hashlib.sha256()
    with p.open("rb") as f:
        for chunk in iter(lambda: f.read(1024 * 1024), b""):
            h.update(chunk)
    return h.hexdigest()


def main(inp: str, out: str) -> None:
    inp_dir = Path(inp)
    out_dir = Path(out)
    out_dir.mkdir(parents=True, exist_ok=True)

    model_path = inp_dir / "model.pkl"
    metrics_path = inp_dir / "metrics.json"

    meta = {
        "model_sha256": sha256_file(model_path),
        "metrics": json.loads(metrics_path.read_text(encoding="utf-8")),
    }

    (out_dir / "metadata.json").write_text(
        json.dumps(meta, ensure_ascii=False, indent=2),
        encoding="utf-8",
    )

    # 실제 환경에서는 model.pkl, tokenizer.json 같은 파일도 함께 복사
    (out_dir / "model.pkl").write_bytes(model_path.read_bytes())


if __name__ == "__main__":
    import argparse

    p = argparse.ArgumentParser()
    p.add_argument("--in", dest="inp", required=True)
    p.add_argument("--out", dest="out", required=True)
    args = p.parse_args()

    main(args.inp, args.out)

이렇게 하면 최소한 “어떤 모델이 어떤 메트릭으로 만들어졌는지”는 남습니다. 이후 성숙도가 올라가면 MLflow, Weights and Biases, SageMaker Model Registry 같은 도구를 붙이면 됩니다.

배포: 승인(Approval)과 환경 분리를 GitHub 환경으로 처리

GitHub Actions는 environment 기능으로 승인 워크플로를 만들 수 있습니다.

  • staging: 자동 배포
  • production: 승인자 리뷰 후 배포

예시(개념):

deploy-prod:
  needs: [train-eval-package]
  runs-on: ubuntu-latest
  environment: production
  steps:
    - uses: actions/download-artifact@v4
      with:
        name: model-dist
        path: dist

    - name: Deploy
      run: |
        ./scripts/deploy.sh dist

배포 대상은 쿠버네티스가 아니어도 됩니다. 오히려 최소 파이프라인에서는 Cloud Run, ECS, 혹은 단일 VM systemd 배포가 운영 난이도가 낮습니다. Cloud Run을 쓴다면 콜드 스타트나 503을 겪기 쉬운데, 이때는 GCP Cloud Run 503·콜드스타트 줄이는 설정 7가지를 같이 적용하면 “모델은 잘 나왔는데 서비스가 불안정한” 상황을 줄일 수 있습니다.

시크릿과 권한: 장기 키 대신 OIDC를 기본값으로

최소 MLOps라도 배포/스토리지 업로드가 들어가면 시크릿 관리가 필요합니다.

  • 가능하면 클라우드별 OIDC 연동으로 단기 토큰을 발급
  • 부득이하면 GitHub Secrets에 저장하되, 권한을 최소화
  • production 환경에는 별도 시크릿을 두고 승인 후만 접근

이 원칙은 Kubeflow를 쓰든 안 쓰든 동일합니다.

운영에서 자주 터지는 지점과 예방책

1) 러너 디스크 부족으로 학습이 중간에 죽음

대형 데이터/체크포인트를 다루면 GitHub-hosted runner 디스크가 빡빡해집니다. 로그에 “No space left on device”가 뜨지 않더라도, 캐시/도커 레이어가 쌓여서 실패할 수 있습니다. 리눅스에서 디스크가 100%인데 원인이 안 보일 때는 열린 파일 핸들이 원인일 때가 많습니다. 이 패턴은 리눅스 디스크 100%인데 용량이 안 보일 때 해결 방식으로 추적할 수 있습니다.

대응:

  • 학습 산출물만 남기고 중간 체크포인트는 정리
  • 도커 빌드가 많다면 레이어 캐시 정책 점검
  • 필요하면 self-hosted runner로 스토리지 확장

2) 재현 불가: 같은 커밋인데 메트릭이 흔들림

  • 랜덤 시드 고정
  • 데이터 스냅샷(날짜 파티션) 고정
  • 의존성 버전 고정(requirements.txt 핀)

3) 모델은 좋아졌는데 배포 후 성능이 나빠짐

  • 학습 피처와 서빙 피처가 달라지는 training-serving skew
  • 전처리 로직을 src/data.py 같은 공용 모듈로 통합
  • 입력 스키마를 테스트로 고정

“Kubeflow 없이”의 한계와 확장 포인트

GitHub Actions 기반 최소 MLOps는 다음 상황에서 특히 잘 맞습니다.

  • 모델 수가 적고, 배포 빈도가 주 1회 이하
  • 데이터 규모가 중간 이하(단일 러너에서 학습 가능)
  • 팀이 쿠버네티스 운영을 당장 원치 않음

반대로 아래 요구가 생기면 확장(또는 Kubeflow/Argo/Airflow)을 고려하세요.

  • 대규모 분산 학습, GPU 다수 스케줄링
  • 피처 스토어/온라인 서빙 일관성 강제
  • 파이프라인 DAG가 복잡해지고 의존성이 많아짐
  • 실험 추적/모델 레지스트리/승인 프로세스가 조직 표준으로 필요

다만 이 글의 방식대로 스크립트 우선으로 파이프라인을 쪼개 두면, 오케스트레이터를 바꾸는 비용이 크게 줄어듭니다.

마무리: 최소 파이프라인의 목표는 “계속 돌아가게 하기”

Kubeflow가 제공하는 기능을 100% 따라 하는 게 목표가 아닙니다. 최소 MLOps의 목표는 다음 한 문장으로 정리됩니다.

  • 학습부터 배포까지의 경로를 자동화하고, 실패를 빨리 드러내며, 산출물을 남긴다.

GitHub Actions는 이미 대부분의 팀이 쓰고 있고, 접근 제어와 리뷰 문화도 내장되어 있습니다. 여기에 데이터 검증, 메트릭 게이트, 아티팩트 버전만 얹어도 “사람이 수동으로 돌리던 학습”에서 “재현 가능한 파이프라인”으로 크게 점프할 수 있습니다.

다음 단계로는 (1) OIDC 기반 업로드, (2) 모델 카드 자동 생성, (3) 온라인 모니터링과 드리프트 감지, (4) 블루/그린 배포를 하나씩 붙여가면 됩니다.