본문으로 건너뛰기
← Systems Notebook / Ops
MLOps Notes13 / 15

재현 가능한 ML을 위한 스토리지와 버전 관리

PVC·S3/MinIO·MLflow의 역할을 구분하고, 같은 실험을 다시 확인할 수 있도록 데이터·코드·이미지·Python 의존성·모델을 함께 기록하고 고정하는 방법을 설명합니다.

이 글의 배경

HPE 엔지니어의 작업 자료를 바탕으로 정리한 글입니다. 개인 실험 기록이나 HPE 공식 문서와는 구분합니다. 본문의 환경과 버전은 글 작성 당시를 기준으로 합니다.

모델의 정확도가 97%였다고 기록했더라도 그 기록만으로 같은 모델을 다시 만들 수는 없습니다. 같은 소스 코드를 실행해도 데이터나 라이브러리의 하위 버전이 바뀌면 결과가 달라집니다. 학습 이미지와 서빙 이미지의 전처리 코드가 어긋나는 경우에도 같은 문제가 생깁니다.

“랜덤 시드를 42로 설정했다”는 기록에 더해, 코드, 데이터, 실행 환경, 파라미터, 아티팩트를 식별자로 연결한 기록이 있어야 학습 조건을 다시 확인할 수 있습니다. 이 글에서는 Kubernetes의 PVC, S3 호환 오브젝트 스토리지, MLflow, 컨테이너 이미지에 각각 무엇을 보관해야 하는지 정리합니다. 이어서 필요한 저장소와 식별자를 실제 RayJob에 주입하는 방법을 살펴봅니다.

이 글에서 다루는 것

  • hostPath, RWX PVC, S3/MinIO, MLflow 백엔드의 올바른 역할
  • 코드·데이터·이미지·실험·모델 버전을 하나의 계보로 연결하는 법
  • uv.lock과 컨테이너로 Python 환경을 고정하는 방법
  • KubeRay head와 worker에 동일한 스토리지·Secret을 주입하는 예
  • 학습 환경과 서빙 환경의 불일치를 CI에서 조기에 발견하는 방법

실습 환경과 치환값

아래 값은 예시입니다. 실제 주소와 암호를 문서나 Git에 넣지 말고 CI 변수 또는 Secret 관리 시스템에서 주입합니다.

변수 예시 의미
MLFLOW_TRACKING_URI http://mlflow.mlflow.svc.cluster.local:5000 클러스터 내부 MLflow 주소
MLFLOW_S3_ENDPOINT_URL https://minio.lab.example.com S3 호환 endpoint
DATASET_URI s3://ml-datasets/digits/v1/dataset.parquet 불변 데이터 버전
TRAINING_IMAGE harbor.lab.example.com/mlops/digits-trainer:a1b2c3d4 학습 이미지
DATASET_VERSION digits-v1-sha256-… 데이터 해시 또는 카탈로그 버전
GIT_COMMIT_SHA CI가 제공하는 전체 커밋 SHA 소스 식별자

문서의 …는 실제 Secret이나 digest를 넣으라는 표시가 아니라 값의 형태를 설명하는 표기입니다. 배포 파일에서는 CI가 확정한 전체 값을 사용합니다.

저장소를 하나로 통일하면 오히려 문제가 생긴다

ML 시스템에서 다루는 파일은 종류에 따라 접근 방식이 다릅니다. 모든 파일을 NFS나 오브젝트 스토리지 한곳에 넣으면 서로 다른 권한, 성능, 수명주기 요구를 함께 관리해야 합니다. 다음 표처럼 데이터의 용도에 맞게 저장소를 나누면 각 요구사항을 구분할 수 있습니다.

계층 잘 맞는 데이터 특징 피해야 할 사용
컨테이너 임시 파일시스템 캐시, 중간 임시 파일 Pod와 함께 사라짐 최종 모델·유일한 결과 저장
hostPath 단일 노드 디버깅 노드에 종속 다중 노드 학습의 공유 데이터
RWX PVC 여러 Ray Pod가 동시에 읽는 작업 데이터 POSIX 경로, 공유 읽기/쓰기 무한히 쌓이는 모델 버전 저장
S3/MinIO 데이터셋 스냅샷, 모델, 보고서 불변 객체·버전 관리에 유리 파일 잠금이 필요한 작업 디렉터리
MLflow DB run·메트릭·태그·Registry 메타데이터 질의와 관계 관리 모델 바이너리 직접 저장
Git 코드, Helm chart, 작은 설정 리뷰·차이·감사 대용량 데이터·모델 바이너리

hostPath는 왜 실습을 넘기기 어려운가

hostPath: /data를 마운트한 Pod가 다른 노드로 이동하면 그 노드의 /data는 전혀 다른 디렉터리입니다. 노드 장애 시 데이터와 함께 사라질 수도 있습니다. 실습용 단일 노드 캐시가 아니라면 PVC나 오브젝트 스토리지를 사용합니다.

RWX PVC와 S3는 경쟁 관계가 아니다

Ray worker 여러 개가 작은 파일을 반복해서 열고 닫는 작업에는 POSIX 공유 경로가 편합니다. 반면 학습 입력 스냅샷과 모델 결과는 버전별 prefix를 가진 오브젝트로 보관해야 계보와 수명주기를 관리하기 쉽습니다.

권장 패턴은 다음과 같습니다.

  1. 원본·정제 데이터의 버전을 S3/MinIO에 불변 경로로 저장합니다.
  2. 학습 시작 시 필요한 데이터를 RWX PVC 또는 Pod 로컬 캐시로 준비합니다.
  3. Ray worker가 같은 읽기 전용 입력을 사용합니다.
  4. 최종 모델과 평가 보고서를 S3/MinIO에 새 버전으로 저장합니다.
  5. MLflow에는 URI, 해시, 메트릭과 실행 계보를 기록합니다.

재현성의 식별자 사슬

한 번의 학습 run은 최소한 다음 값들을 연결해야 합니다.

text
Git commit SHA
  └─ training image tag + OCI digest
       └─ dataset URI + checksum/catalog version
            └─ lockfile hash
                 └─ RayJob name/UID
                      └─ MLflow run ID
                           └─ registered model version
                                └─ serving artifact URI
                                     └─ GitOps commit

이 중 하나라도 latest, current, 덮어쓰기 가능한 파일명이라면 과거 상태를 정확히 복원하기 어렵습니다.

학습 코드에서 이 연결을 명시적으로 기록합니다. 다음 블록은 기존 with mlflow.start_run(): 내부에서 실행해야 하며, 별도 provenance run을 실수로 만들지 않도록 active run이 없으면 실패시킵니다.

python
import os

import mlflow

REQUIRED = (
    "GIT_COMMIT_SHA",
    "TRAINING_IMAGE",
    "DATASET_URI",
    "DATASET_VERSION",
    "LOCKFILE_SHA256",
)

missing = [name for name in REQUIRED if not os.getenv(name)]
if missing:
    raise RuntimeError(f"missing provenance values: {', '.join(missing)}")

if mlflow.active_run() is None:
    raise RuntimeError("provenance tags must be logged inside the active training run")

mlflow.set_tags(
    {
        "source.git_commit": os.environ["GIT_COMMIT_SHA"],
        "runtime.training_image": os.environ["TRAINING_IMAGE"],
        "data.uri": os.environ["DATASET_URI"],
        "data.version": os.environ["DATASET_VERSION"],
        "runtime.lockfile_sha256": os.environ["LOCKFILE_SHA256"],
        "kubernetes.rayjob": os.getenv("RAY_JOB_NAME", "local"),
    }
)

태그 이름은 팀 표준에 맞춰 정합니다. 필수 계보 값은 UI에 사람이 메모하는 데 그치지 않고 코드에서 검사해야 합니다. 값이 누락되면 실행을 중단하도록 하여, 필요한 기록이 빠진 채 다음 단계로 진행되지 않게 합니다.

데이터 버전은 경로와 해시를 함께 기록한다

s3://ml-datasets/digits/latest/data.parquet는 데이터의 위치를 나타내지만 특정 버전을 식별하지는 못합니다. 버킷 버저닝을 사용하더라도 어떤 데이터를 사용했는지 알아보고 감사할 수 있도록 식별자를 별도로 남기는 편이 좋습니다.

text
s3://ml-datasets/digits/
├── manifests/
│   └── 2026-07-14.json
└── objects/
    └── sha256-7f4c...c21.parquet

manifest에는 다음을 기록합니다.

json
{
  "dataset": "digits",
  "created_at": "2026-07-14T00:00:00Z",
  "object_uri": "s3://ml-datasets/digits/objects/sha256-7f4c...c21.parquet",
  "sha256": "7f4c...c21",
  "row_count": 1797,
  "schema_version": "1",
  "label_column": "target"
}

실제 해시는 생략하지 않은 전체 값이어야 합니다. 가능하면 오브젝트 잠금 또는 버킷 정책으로 확정 데이터의 덮어쓰기를 막습니다. ETag는 업로드 방식과 암호화 설정에 따라 파일 MD5와 같지 않을 수 있으므로, 데이터 무결성 검증용 SHA-256을 별도로 관리하는 편이 명확합니다.

RWX PVC를 Ray head와 worker가 함께 사용하게 하기

먼저 NFS CSI 같은 동적 프로비저너가 제공하는 StorageClass 이름을 확인합니다.

bash
kubectl get storageclass

실습에서는 nfs-csi라는 이름을 가정합니다.

yaml
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: ray-data
  namespace: ml-training
spec:
  accessModes:
    - ReadWriteMany
  storageClassName: nfs-csi
  resources:
    requests:
      storage: 20Gi
bash
kubectl apply -f ray-data-pvc.yaml
kubectl wait --for=jsonpath='{.status.phase}'=Bound \
  pvc/ray-data -n ml-training --timeout=120s

RayJob에서는 head와 worker에 같은 claim과 mount path를 넣습니다. head만 마운트하면 remote task가 worker에서 실행될 때 파일을 찾지 못합니다.

yaml
# RayJob의 rayClusterSpec 아래에서 필요한 부분만 발췌
headGroupSpec:
  template:
    spec:
      volumes:
        - name: ray-data
          persistentVolumeClaim:
            claimName: ray-data
      containers:
        - name: ray-head
          volumeMounts:
            - name: ray-data
              mountPath: /mnt/datasets
workerGroupSpecs:
  - groupName: workers
    replicas: 2
    minReplicas: 2
    maxReplicas: 2
    rayStartParams: {}
    template:
      spec:
        volumes:
          - name: ray-data
            persistentVolumeClaim:
              claimName: ray-data
        containers:
          - name: ray-worker
            volumeMounts:
              - name: ray-data
                mountPath: /mnt/datasets

읽기 전용 입력이라면 readOnly: true를 지정하고, 각 run의 출력 디렉터리는 /mnt/datasets/runs/<run-id>처럼 충돌하지 않게 나눕니다.

MinIO 자격 증명은 Git 밖에서 주입한다

아래 환경변수는 터미널 또는 CI의 보호된 변수에 이미 설정돼 있다고 가정합니다.

bash
test -n "${MINIO_ACCESS_KEY:-}" || { echo "MINIO_ACCESS_KEY is required"; exit 1; }
test -n "${MINIO_SECRET_KEY:-}" || { echo "MINIO_SECRET_KEY is required"; exit 1; }

kubectl create secret generic ml-s3-credentials \
  --namespace ml-training \
  --from-literal=AWS_ACCESS_KEY_ID="${MINIO_ACCESS_KEY}" \
  --from-literal=AWS_SECRET_ACCESS_KEY="${MINIO_SECRET_KEY}" \
  --from-literal=AWS_DEFAULT_REGION="${MINIO_REGION:-us-east-1}" \
  --dry-run=client -o yaml | kubectl apply -f -

이 방법을 운영에 적용할 때는 자격 증명이 노출될 수 있는 경로를 더 살펴봐야 합니다. 셸 히스토리에는 변수명만 남지만 로컬 프로세스 인자로 값이 노출될 가능성이 있습니다. Secret 역시 기본적으로 암호화되지 않고 base64로 인코딩됩니다. 운영에서는 External Secrets Operator, SOPS 같은 방식과 Kubernetes API의 저장 시 암호화를 함께 검토합니다.

Ray head와 worker에는 envFrom을 동일하게 넣고 endpoint는 비밀이 아니므로 일반 환경변수로 분리합니다.

yaml
env:
  - name: MLFLOW_TRACKING_URI
    value: http://mlflow.mlflow.svc.cluster.local:5000
  - name: MLFLOW_S3_ENDPOINT_URL
    value: https://minio.lab.example.com
envFrom:
  - secretRef:
      name: ml-s3-credentials

학습 전용 키에는 입력 bucket 읽기와 지정 prefix 쓰기만 허용합니다. 버킷 전체 삭제 권한이나 관리자 키를 Pod에 주입하지 않습니다.

KServe가 MinIO 모델을 읽는 자격 증명

KServe의 storage initializer는 모델 서버가 시작되기 전에 storageUri의 모델을 내려받습니다. S3 호환 endpoint와 Secret을 ServiceAccount에 연결하는 방식은 학습 Pod의 단순 envFrom과 다릅니다.

다음 템플릿은 구조를 보여 주기 위한 것입니다. 실제 Secret 값은 Git에 커밋하지 않습니다.

yaml
apiVersion: v1
kind: Secret
metadata:
  name: kserve-s3-credentials
  namespace: ml-serving
  annotations:
    serving.kserve.io/s3-endpoint: minio.minio.svc.cluster.local:9000
    serving.kserve.io/s3-usehttps: "0"
    serving.kserve.io/s3-region: us-east-1
    serving.kserve.io/s3-useanoncredential: "false"
type: Opaque
stringData:
  AWS_ACCESS_KEY_ID: ${MINIO_ACCESS_KEY}
  AWS_SECRET_ACCESS_KEY: ${MINIO_SECRET_KEY}
---
apiVersion: v1
kind: ServiceAccount
metadata:
  name: kserve-model-reader
  namespace: ml-serving
secrets:
  - name: kserve-s3-credentials

${...}는 kubectl apply가 자동으로 바꾸지 않습니다. CI의 secret substitution 단계나 외부 Secret 리소스로 렌더링해야 합니다. 자체 서명 인증서를 쓰는 HTTPS endpoint라면 검증을 꺼 버리는 대신 CA bundle을 배포합니다.

Python 환경은 lockfile과 컨테이너를 함께 고정한다

requirements.txt에 다음처럼 범위만 적으면 설치 시점마다 결과가 달라질 수 있습니다.

text
mlflow>=3
scikit-learn>=1.5
ray[default]

애플리케이션이 직접 의존하는 패키지는 pyproject.toml에 선언하고, 해결된 전이 의존성 전체는 uv.lock에 기록합니다.

toml
[project]
name = "digits-trainer"
version = "0.1.0"
requires-python = ">=3.11,<3.12"
dependencies = [
  "boto3",
  "mlflow",
  "ray[default]",
  "scikit-learn",
]

[dependency-groups]
dev = ["pytest", "ruff"]
bash
uv lock
uv sync --frozen
uv lock --check
git add pyproject.toml uv.lock

uv.lock은 반드시 Git에 커밋합니다. CI에서는 의존성을 새로 결정하지 않도록 --frozen 또는 --locked 계열 검사를 사용합니다. 외부 배포 도구가 requirements.txt만 받는다면 lockfile에서 내보낸 파일을 생성할 수 있습니다. 이 경우에도 의존성을 결정하는 원본은 lockfile 하나로 유지합니다.

컨테이너는 lockfile이 달라질 때만 의존성 레이어가 다시 만들어지도록 구성합니다.

dockerfile
ARG UV_IMAGE
ARG RAY_BASE_IMAGE

FROM ${UV_IMAGE} AS uv
FROM ${RAY_BASE_IMAGE}

USER root
COPY --from=uv /uv /uvx /bin/
RUN mkdir -p /app && chown 1000:100 /app

WORKDIR /app

COPY --chown=1000:100 pyproject.toml uv.lock ./

USER 1000
RUN uv sync --frozen --no-dev --no-install-project

COPY --chown=1000:100 src ./src
ENV PATH="/app/.venv/bin:${PATH}" \
    PYTHONPATH=/app \
    PYTHONDONTWRITEBYTECODE=1

CMD ["python", "-m", "src.train"]

UV_IMAGE에는 승인된 ghcr.io/astral-sh/uv@sha256:..., RAY_BASE_IMAGE에는 프로젝트와 호환되는 rayproject/ray:<version>-py311-cpu@sha256:...를 CI build argument로 전달합니다. 공식 uv 이미지에서 바이너리를 복사하므로 Ray base image에 uv가 미리 설치돼 있지 않아도 됩니다. 문서의 생략 기호를 실제 값으로 쓰지 말고 조직이 검증한 전체 digest를 넣습니다.

CUDA·시스템 라이브러리 결합이 큰 프로젝트라면 Python 패키지만 잠그는 도구로 충분하지 않을 수 있습니다. 그때는 CUDA base image digest, 드라이버 호환 범위, OS 패키지와 Conda/Pixi 계열 lock까지 함께 관리합니다.

학습 환경과 서빙 환경의 차이를 CI에서 잡는다

흔한 장애는 학습 이미지에서는 동작한 모델이 KServe runtime에서 역직렬화되지 않는 것입니다. scikit-learn, Python, cloudpickle 계열의 차이가 원인이 될 수 있습니다.

학습 시 모델 signature와 입력 예제를 남깁니다.

python
from mlflow.models import infer_signature

signature = infer_signature(x_train, model.predict(x_train))

mlflow.sklearn.log_model(
    sk_model=model,
    artifact_path="model",
    signature=signature,
    input_example=x_train[:2],
    registered_model_name="digits-classifier",
)

그리고 CI에서 최소한 다음 두 검사를 수행합니다.

bash
# 잠금 상태가 최신인지
uv lock --check

# 저장 직후 같은 잠금 환경에서 다시 읽고 예측할 수 있는지
uv run pytest -q tests/test_model_roundtrip.py
python
# tests/test_model_roundtrip.py
import mlflow.sklearn
from sklearn.datasets import load_digits
from sklearn.ensemble import RandomForestClassifier

def test_model_roundtrip(tmp_path):
    dataset = load_digits()
    trained_model = RandomForestClassifier(n_estimators=5, random_state=42)
    trained_model.fit(dataset.data, dataset.target)

    model_path = tmp_path / "model"
    mlflow.sklearn.save_model(trained_model, str(model_path))
    restored = mlflow.sklearn.load_model(str(model_path))
    batch = dataset.data[:2]
    assert restored.predict(batch).shape == (2,)

서빙 환경에서의 동작까지 확인하려면 실제 KServe ServingRuntime과 같은 컨테이너에서 모델을 로드하고 V2 inference 요청을 보내는 통합 테스트가 필요합니다. 운영 승격 전에는 이 검사를 통과해야 합니다.

검증 체크리스트

bash
# PVC가 실제로 Bound인가
kubectl get pvc -n ml-training ray-data

# Ray head와 worker 모두 같은 볼륨을 갖는가
kubectl get pod -n ml-training -l ray.io/cluster \
  -o jsonpath='{range .items[*]}{.metadata.name}{"\t"}{range .spec.volumes[*]}{.name}{","}{end}{"\n"}{end}'

# Secret 값이 아니라 키 이름만 확인한다
kubectl get secret -n ml-training ml-s3-credentials \
  -o jsonpath='{range $k,$v := .data}{$k}{"\n"}{end}'

# MLflow run에 계보 태그가 있는지 API/UI에서 확인한다
# source.git_commit, runtime.training_image, data.uri, data.version, runtime.lockfile_sha256

완료 기준은 다음과 같습니다.

  • 같은 Git commit과 lockfile로 이미지를 다시 만들 수 있습니다.
  • 이미지 식별자는 변경 불가능한 SHA 태그 또는 digest입니다.
  • 데이터 URI와 전체 checksum이 MLflow run에 기록됩니다.
  • Ray head와 모든 worker가 필요한 데이터·Secret에 접근합니다.
  • 모델 아티팩트 경로가 run ID나 모델 버전으로 구분되어 덮어쓰이지 않습니다.
  • 서빙 GitOps 커밋에서 정확한 모델 아티팩트까지 역추적할 수 있습니다.

자주 생기는 문제

PVC가 Pending에 머문다

bash
kubectl describe pvc -n ml-training ray-data
kubectl get storageclass nfs-csi -o yaml
kubectl get events -n ml-training --sort-by=.lastTimestamp

StorageClass 이름, CSI controller 상태, NFS export 권한, 요청 access mode 지원 여부를 확인합니다. RWO StorageClass에 RWX claim을 요청하면 프로비저닝되지 않을 수 있습니다.

worker에서만 FileNotFoundError가 난다

head와 worker의 volumes, volumeMounts, mountPath를 비교합니다. 상대 경로 대신 /mnt/datasets/...처럼 명시적 절대 경로를 사용하고, 작업이 어느 노드에서 실행됐는지 Ray Dashboard와 Pod 로그를 확인합니다.

MLflow 메트릭은 보이지만 모델 아티팩트가 없다

Tracking DB 쓰기와 S3 쓰기는 다른 경로입니다. MLFLOW_S3_ENDPOINT_URL, bucket 정책, access key 권한, CA 인증서, MLflow 서버의 artifact proxy 설정을 확인합니다. 403은 권한, 연결 시간 초과는 endpoint/DNS/NetworkPolicy, 인증서 오류는 신뢰 체인 문제일 가능성이 높습니다.

같은 코드인데 메트릭이 조금 다르다

랜덤 시드만으로 모든 연산이 결정적이 되지는 않습니다. 데이터 순서, 병렬 연산, BLAS 구현, GPU 커널, 드라이버 차이를 기록합니다. 재현성 목표가 “비트 단위 동일”인지 “허용 오차 내 지표 동일”인지 먼저 정의하고 검증 범위를 정합니다.

운영·보안 원칙

  • 모델·데이터 bucket은 역할별로 읽기/쓰기 prefix를 나누고 최소 권한을 줍니다.
  • MLflow backend DB와 object store를 함께 백업해야 run 메타데이터와 파일의 참조가 보존됩니다.
  • 모델·데이터의 보존 기간과 삭제 정책을 별도로 정의합니다.
  • Secret을 base64로 인코딩했다고 안전해지는 것은 아닙니다. 저장 시 암호화와 접근 감사를 켭니다.
  • 자체 서명 인증서를 이유로 TLS 검증을 끄지 말고 신뢰할 CA를 배포합니다.
  • 공급망 검증을 위해 base image와 최종 이미지의 digest, SBOM, 취약점 검사 결과를 남깁니다.
  • 가변 별칭은 “승인된 대상”을 가리키는 편의 수단이고, 실제 배포 기록에는 불변 식별자를 남깁니다.

정리

ML 실행을 재현하려면 저장소마다 보관할 대상을 나누고 그 기록을 연결해야 합니다. RWX PVC는 실행 중 공유 파일을, S3/MinIO는 버전이 있는 데이터와 모델을 보관합니다. MLflow DB에는 계보 메타데이터를 기록하고, Git에는 코드와 원하는 상태를 둡니다. 여기에 lockfile과 불변 컨테이너 이미지를 연결해야 학습부터 서빙까지 어떤 환경을 사용했는지 설명할 수 있습니다.

다음 글에서는 이 식별자들을 GitLab CI, Harbor, GitLab Chart Repo, Argo CD 사이에 전달해 소프트웨어 파이프라인과 모델 파이프라인을 하나의 GitOps 흐름으로 연결합니다.

이전 글: MLOps 플랫폼을 역할로 이해하기
다음 글: GitOps와 ML 파이프라인이 만나는 지점

공식 참고자료