Day 5에서는 앞서 배운 구성을 하나의 흐름으로 연결합니다. Day 1~4에서 만든 Kubernetes, 스토리지, 인증, 관측성, GitLab, Harbor, Argo CD, MLflow, KubeRay, KServe를 이용해 작은 제품을 전달하는 최종 실습입니다. 새로운 이론을 추가하기보다 각 구성요소가 연결되는 과정을 확인합니다.
원래 과제의 완료 기준은 “소스가 바뀌면 CI가 실행되고 RayJob이 생성되는가”였습니다. 이 글에서는 그 뒤의 학습 결과와 서빙 응답까지 독자가 직접 검증할 수 있도록 실습 범위를 넓힙니다. Digits 분류 모델을 실제로 학습하고 MLflow와 MinIO에 결과를 저장한 다음, 승인한 아티팩트를 KServe로 배포합니다.
완성할 결과
src/train.py 변경으로 CI가 테스트를 실행합니다.
src/train.py 변경 → GitLab CI학습 코드 테스트
단계를 선택하면 자동 재생이 멈춥니다. 선의 번호와 아래 설명을 함께 읽어 주세요.
전체 단계 한눈에 읽기
- 변경한 학습 코드 테스트
src/train.py 변경으로 CI가 테스트를 실행합니다.
- src/train.py 변경 → GitLab CI: 학습 코드 테스트
- 실행할 학습 이미지 저장
rootless BuildKit이 이미지를 빌드하고 Harbor에 commit SHA tag로 저장합니다.
- GitLab CI → rootless BuildKit: 검증 후 빌드
- rootless BuildKit → Harbor: commit SHA 이미지 저장
- CI가 학습 선언 갱신
CI가 이미지 식별자를 Chart Repo의 values에 write-back하고 Argo CD가 읽습니다. 원본의 Harbor→Git 선은 단계 관계이며 Harbor가 Git에 commit한다는 뜻이 아닙니다.
- Harbor → GitLab Chart Repo: 이미지 식별자 반영
- GitLab Chart Repo → Argo CD: 학습 선언 읽기
- 학습 실행과 결과 보관
RayJob이 학습을 실행합니다. MLflow에는 run·metric·model version을, MinIO에는 불변 URI의 모델 아티팩트를 남깁니다.
- Argo CD → KubeRay RayJob: RayJob 동기화
- KubeRay RayJob → MLflow: 실험·모델 기록
- KubeRay RayJob → MinIO: 모델 아티팩트 저장
- 승인한 모델을 Git에 반영
평가·승인한 모델 URI를 Git에 write-back하면 Argo CD가 새 선언을 읽습니다. 학습 성공과 모델 운영 반영은 별도 판단입니다.
- MinIO → 승인·Git write-back: 모델 승인 대상
- 승인·Git write-back → Argo CD: 승인한 Git 변경
- 서빙 선언 동기화
Argo CD가 승인된 선언에 따라 KServe InferenceService를 동기화합니다.
- Argo CD → KServe: InferenceService 동기화
도식 원문
flowchart LR
A["src/train.py 변경"] --> B["GitLab CI\n테스트"]
B --> C["rootless BuildKit\n학습 이미지"]
C --> D["Harbor\ncommit SHA tag"]
D --> E["GitLab Chart Repo\nvalues write-back"]
E --> F["Argo CD"]
F --> G["KubeRay RayJob"]
G --> H["MLflow\nrun·metric·model version"]
G --> I["MinIO\n불변 모델 URI"]
I --> J["승인·Git write-back"]
J --> F
F --> K["KServe\nInferenceService"]실습 완료 조건은 다음과 같습니다.
src/train.py, Dockerfile, lockfile 등 학습 결과에 영향을 주는 파일이 바뀔 때만 pipeline이 시작됩니다.- privileged Docker daemon 없이 이미지를 빌드합니다.
- Harbor에 Git commit SHA로 식별되는 이미지가 생깁니다.
- CI가 Kubernetes를 직접 수정하지 않고 Chart Repo만 갱신합니다.
- Argo CD가 새
RayJob을 만들고 학습을 완료합니다. - MLflow에서 accuracy, Git commit, image, dataset hash, model version을 확인합니다.
- MinIO의 불변 경로에 KServe가 읽을 수 있는 MLflow 형식 모델이 있습니다.
- 승인한 URI를 Git에 반영하면
InferenceService가 Ready가 되고 예측 요청이 성공합니다.
이 글에서 다루는 것
- 두 GitLab 저장소와 필요한 CI 변수 구성
- Kubernetes Secret, ServiceAccount, RWX PVC 준비
- RayJob·InferenceService·선택적 HTTPRoute를 포함한 Helm chart 전체
- Digits 학습 코드,
pyproject.toml, Dockerfile, 테스트 파일 전체 - rootless BuildKit 기반
.gitlab-ci.yml전체 - Harbor → Chart Repo → Argo CD → RayJob → MLflow 검증
- 모델 승격, KServe V2 추론 요청, scale-to-zero 확인
- 단계별 장애 진단과 운영 전 보완점
전제 조건과 예시 값
이미 준비되어 있어야 하는 구성요소는 다음과 같습니다.
- RKE2 또는 호환 Kubernetes 클러스터
- RWX를 지원하는
nfs-csiStorageClass - GitLab과 Kubernetes executor Runner
- Harbor project와 push 가능한 robot account
- Argo CD와 private Git repository credential
- MLflow Tracking Server, DB backend, S3/MinIO artifact store
- KubeRay Operator와
RayJobCRD - KServe와
kserve-mlserverServingRuntime - Knative 모드를 사용할 경우 Knative Serving과 Kourier 등 호환 네트워킹 계층
- MinIO의
ml-modelsbucket과 prefix 제한 자격 증명
이 글에서는 다음 이름을 사용합니다.
| 항목 | 값 |
|---|---|
| 사용자 예시 | student-01 |
| Code Repo | mlops/digits-training |
| Chart Repo | mlops/ml-platform-gitops |
| Harbor image | harbor.lab.example.com/mlops/digits-trainer |
| namespace | mlops-demo |
| MLflow 내부 주소 | http://mlflow.mlflow.svc.cluster.local:5000 |
| MinIO 내부 주소 | http://minio.minio.svc.cluster.local:9000 |
| 선택적 Ray Dashboard host | ray-student-01.lab.example.com |
| Gateway | nginx-gateway/platform-gateway |
lab.example.com은 문서용 예약 도메인입니다. 실제 환경에서는 자신의 서비스 주소로 바꾸되 블로그 원고에는 실주소, 사설 IP, 계정, 토큰을 남기지 않습니다.
0. 플랫폼 준비 상태 확인
먼저 읽기 전용 명령으로 플랫폼의 준비 상태를 확인합니다.
kubectl get storageclass nfs-csi
kubectl get crd rayjobs.ray.io
kubectl get crd inferenceservices.serving.kserve.io
kubectl get clusterservingruntime kserve-mlserver
kubectl get pods -n kuberay-operator
kubectl get pods -n kserve
kubectl get svc -n mlflow
argocd version --clientKnative 모드를 사용할 때만 다음도 확인합니다.
kubectl get pods -n knative-serving
kubectl get svc -n kourier-system kourierCRD가 없거나 컨트롤러가 준비되지 않았다면 이 글의 애플리케이션 YAML부터 적용하지 않습니다. 플랫폼 버전 호환표에 맞춰 운영자가 먼저 설치를 완료해야 합니다.
1. namespace와 Secret 준비
namespace 생성
kubectl create namespace mlops-demo --dry-run=client -o yaml | kubectl apply -f -
kubectl label namespace mlops-demo gateway-access=true --overwriteHarbor pull secret
로컬 셸 또는 비밀 관리 세션에 다음 값이 이미 설정돼 있다고 가정합니다.
test -n "${HARBOR_PULL_ROBOT_USER:-}" || { echo "HARBOR_PULL_ROBOT_USER is required"; exit 1; }
test -n "${HARBOR_PULL_ROBOT_PASSWORD:-}" || { echo "HARBOR_PULL_ROBOT_PASSWORD is required"; exit 1; }
kubectl create secret docker-registry harbor-pull \
--namespace mlops-demo \
--docker-server=harbor.lab.example.com \
--docker-username="${HARBOR_PULL_ROBOT_USER}" \
--docker-password="${HARBOR_PULL_ROBOT_PASSWORD}" \
--dry-run=client -o yaml | kubectl apply -f -클러스터에는 pull 전용 자격 증명을 쓰고, CI에는 push 전용 robot account를 쓰는 것이 좋습니다.
Ray와 KServe가 함께 사용할 S3 Secret
KServe storage initializer가 S3 호환 endpoint를 이해하도록 Secret annotation이 필요합니다. 다음 파일은 변수 템플릿이며 렌더링된 Secret을 Git에 커밋하지 않습니다.
# ml-s3-secret.yaml.tmpl
apiVersion: v1
kind: Secret
metadata:
name: ml-s3-credentials
namespace: mlops-demo
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}
AWS_DEFAULT_REGION: us-east-1
---
apiVersion: v1
kind: ServiceAccount
metadata:
name: kserve-model-reader
namespace: mlops-demo
secrets:
- name: ml-s3-credentialstest -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; }
envsubst < ml-s3-secret.yaml.tmpl | kubectl apply -f -운영 환경에서는 이 템플릿 대신 External Secrets Operator나 SOPS 같은 방식을 권장합니다. 내부 HTTP endpoint는 폐쇄된 실습 네트워크를 가정한 것입니다. 운영에서는 TLS와 신뢰할 CA를 적용합니다.
Secret의 값은 출력하지 않고 키 이름과 ServiceAccount 연결만 확인합니다.
kubectl get secret -n mlops-demo ml-s3-credentials \
-o jsonpath='{range $k,$v := .data}{$k}{"\n"}{end}'
kubectl get serviceaccount -n mlops-demo kserve-model-reader -o yaml2. Chart Repo 만들기
GitLab에 mlops/ml-platform-gitops라는 private project를 만듭니다. 로컬에 clone한 뒤 다음 구조를 만듭니다.
ml-platform-gitops/
├── applications/
│ └── digits-ml.yaml
└── charts/
└── digits-ml/
├── Chart.yaml
├── values.yaml
└── templates/
├── pvc.yaml
├── rayjob.yaml
├── inferenceservice.yaml
└── httproute.yamlChart.yaml
apiVersion: v2
name: digits-ml
description: RayJob training and KServe inference for the Digits capstone
type: application
version: 0.1.0
appVersion: "0.1.0"values.yaml
초기 커밋에서는 학습과 서빙을 모두 끕니다. 첫 Code Repo pipeline이 학습 값을 갱신하면서 training.enabled를 켭니다.
training:
enabled: false
runId: bootstrap
sourceCommit: bootstrap
lockfileSha256: bootstrap
image:
repository: harbor.lab.example.com/mlops/digits-trainer
tag: bootstrap
pullPolicy: IfNotPresent
pullSecret: harbor-pull
rayVersion: "2.47.1"
entrypoint: python -m src.train
shutdownAfterJobFinishes: true
ttlSecondsAfterFinished: 600
head:
cpu: "1"
memory: 2Gi
worker:
replicas: 1
cpu: "1"
memory: 2Gi
storage:
createPvc: true
pvcName: ray-data
storageClassName: nfs-csi
size: 5Gi
mountPath: /mnt/datasets
mlflow:
trackingUri: http://mlflow.mlflow.svc.cluster.local:5000
experimentName: digits-ray-training
registeredModelName: digits-classifier
s3:
endpointUrl: http://minio.minio.svc.cluster.local:9000
modelBucket: ml-models
modelPrefix: digits
secretName: ml-s3-credentials
serving:
enabled: false
name: digits-classifier
modelFormat: mlflow
runtime: kserve-mlserver
protocolVersion: v2
storageUri: ""
mlflowModelVersion: ""
sourceRunId: ""
minReplicas: 0
maxReplicas: 3
serviceAccountName: kserve-model-reader
dashboard:
enabled: false
hostname: ray-student-01.lab.example.com
gateway:
name: platform-gateway
namespace: nginx-gateway
sectionName: httpsrayVersion은 학습 이미지에 설치된 Ray와 맞아야 합니다. 여기서는 검증 가능한 예시 버전으로 2.47.1을 사용하지만, 운영 환경에서는 KubeRay 호환표를 확인한 승인 버전과 이미지 digest를 사용합니다.
templates/pvc.yaml
{{- if .Values.storage.createPvc }}
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: {{ .Values.storage.pvcName }}
labels:
app.kubernetes.io/name: {{ .Chart.Name }}
spec:
accessModes:
- ReadWriteMany
storageClassName: {{ .Values.storage.storageClassName }}
resources:
requests:
storage: {{ .Values.storage.size }}
{{- end }}templates/rayjob.yaml
head와 worker에 동일한 이미지, S3 Secret, PVC를 넣습니다. runId가 바뀔 때 새 RayJob 이름이 만들어집니다.
{{- if .Values.training.enabled }}
apiVersion: ray.io/v1
kind: RayJob
metadata:
name: {{ printf "%s-%s" .Release.Name .Values.training.runId | trunc 63 | trimSuffix "-" }}
labels:
app.kubernetes.io/name: {{ .Chart.Name }}
app.kubernetes.io/instance: {{ .Release.Name }}
mlops.example.com/source-commit: {{ .Values.training.sourceCommit | quote }}
spec:
entrypoint: {{ .Values.training.entrypoint | quote }}
shutdownAfterJobFinishes: {{ .Values.training.shutdownAfterJobFinishes }}
ttlSecondsAfterFinished: {{ .Values.training.ttlSecondsAfterFinished }}
backoffLimit: 0
runtimeEnvYAML: |
env_vars:
DATA_DIR: {{ .Values.storage.mountPath | quote }}
MLFLOW_TRACKING_URI: {{ .Values.mlflow.trackingUri | quote }}
MLFLOW_EXPERIMENT_NAME: {{ .Values.mlflow.experimentName | quote }}
REGISTERED_MODEL_NAME: {{ .Values.mlflow.registeredModelName | quote }}
MLFLOW_S3_ENDPOINT_URL: {{ .Values.s3.endpointUrl | quote }}
MODEL_BUCKET: {{ .Values.s3.modelBucket | quote }}
MODEL_PREFIX: {{ .Values.s3.modelPrefix | quote }}
GIT_COMMIT_SHA: {{ .Values.training.sourceCommit | quote }}
TRAINING_IMAGE: {{ printf "%s:%s" .Values.training.image.repository .Values.training.image.tag | quote }}
LOCKFILE_SHA256: {{ .Values.training.lockfileSha256 | quote }}
RAY_JOB_NAME: {{ printf "%s-%s" .Release.Name .Values.training.runId | trunc 63 | trimSuffix "-" | quote }}
submitterPodTemplate:
spec:
restartPolicy: Never
imagePullSecrets:
- name: {{ .Values.training.image.pullSecret }}
containers:
- name: ray-job-submitter
image: "{{ .Values.training.image.repository }}:{{ .Values.training.image.tag }}"
imagePullPolicy: {{ .Values.training.image.pullPolicy }}
rayClusterSpec:
rayVersion: {{ .Values.training.rayVersion | quote }}
headGroupSpec:
rayStartParams:
dashboard-host: "0.0.0.0"
template:
metadata:
labels:
mlops.example.com/ray-dashboard: {{ printf "%s-%s" .Release.Name .Values.training.runId | trunc 63 | trimSuffix "-" | quote }}
spec:
securityContext:
runAsUser: 1000
runAsGroup: 100
fsGroup: 100
fsGroupChangePolicy: OnRootMismatch
imagePullSecrets:
- name: {{ .Values.training.image.pullSecret }}
volumes:
- name: ray-data
persistentVolumeClaim:
claimName: {{ .Values.storage.pvcName }}
containers:
- name: ray-head
image: "{{ .Values.training.image.repository }}:{{ .Values.training.image.tag }}"
imagePullPolicy: {{ .Values.training.image.pullPolicy }}
envFrom:
- secretRef:
name: {{ .Values.s3.secretName }}
ports:
- name: dashboard
containerPort: 8265
volumeMounts:
- name: ray-data
mountPath: {{ .Values.storage.mountPath }}
resources:
requests:
cpu: {{ .Values.training.head.cpu | quote }}
memory: {{ .Values.training.head.memory }}
limits:
cpu: {{ .Values.training.head.cpu | quote }}
memory: {{ .Values.training.head.memory }}
workerGroupSpecs:
- groupName: workers
replicas: {{ .Values.training.worker.replicas }}
minReplicas: {{ .Values.training.worker.replicas }}
maxReplicas: {{ .Values.training.worker.replicas }}
rayStartParams: {}
template:
spec:
securityContext:
runAsUser: 1000
runAsGroup: 100
fsGroup: 100
fsGroupChangePolicy: OnRootMismatch
imagePullSecrets:
- name: {{ .Values.training.image.pullSecret }}
volumes:
- name: ray-data
persistentVolumeClaim:
claimName: {{ .Values.storage.pvcName }}
containers:
- name: ray-worker
image: "{{ .Values.training.image.repository }}:{{ .Values.training.image.tag }}"
imagePullPolicy: {{ .Values.training.image.pullPolicy }}
envFrom:
- secretRef:
name: {{ .Values.s3.secretName }}
volumeMounts:
- name: ray-data
mountPath: {{ .Values.storage.mountPath }}
resources:
requests:
cpu: {{ .Values.training.worker.cpu | quote }}
memory: {{ .Values.training.worker.memory }}
limits:
cpu: {{ .Values.training.worker.cpu | quote }}
memory: {{ .Values.training.worker.memory }}
{{- end }}templates/inferenceservice.yaml
학습 직후에는 렌더링되지 않습니다. 모델을 승인하고 serving.enabled=true와 불변 storageUri를 커밋했을 때 생성됩니다.
{{- if .Values.serving.enabled }}
apiVersion: serving.kserve.io/v1beta1
kind: InferenceService
metadata:
name: {{ .Values.serving.name }}
labels:
app.kubernetes.io/name: {{ .Chart.Name }}
annotations:
mlops.example.com/mlflow-model-version: {{ .Values.serving.mlflowModelVersion | quote }}
mlops.example.com/source-run-id: {{ .Values.serving.sourceRunId | quote }}
spec:
predictor:
minReplicas: {{ .Values.serving.minReplicas }}
maxReplicas: {{ .Values.serving.maxReplicas }}
serviceAccountName: {{ .Values.serving.serviceAccountName }}
model:
modelFormat:
name: {{ .Values.serving.modelFormat }}
runtime: {{ .Values.serving.runtime }}
protocolVersion: {{ .Values.serving.protocolVersion }}
storageUri: {{ .Values.serving.storageUri | quote }}
{{- end }}minReplicas: 0은 KServe가 Knative 배포 모드로 구성된 경우의 scale-to-zero 설정입니다. Standard 모드라면 0개 축소를 기대하지 말고 minReplicas: 1로 바꿉니다.
templates/httproute.yaml
Ray Dashboard는 작업 진단용 관리 화면입니다. 외부 공개가 필수는 아니며 기본값은 꺼져 있습니다. 실습망에서 인증 프록시나 SSO로 보호한 경우에만 켭니다.
{{- if and .Values.training.enabled .Values.dashboard.enabled }}
apiVersion: v1
kind: Service
metadata:
name: {{ .Release.Name }}-ray-dashboard
spec:
selector:
mlops.example.com/ray-dashboard: {{ printf "%s-%s" .Release.Name .Values.training.runId | trunc 63 | trimSuffix "-" | quote }}
ports:
- name: dashboard
port: 8265
targetPort: 8265
---
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: {{ .Release.Name }}-ray-dashboard
spec:
parentRefs:
- name: {{ .Values.dashboard.gateway.name }}
namespace: {{ .Values.dashboard.gateway.namespace }}
sectionName: {{ .Values.dashboard.gateway.sectionName }}
hostnames:
- {{ .Values.dashboard.hostname | quote }}
rules:
- matches:
- path:
type: PathPrefix
value: /
backendRefs:
- name: {{ .Release.Name }}-ray-dashboard
port: 8265
{{- end }}Gateway listener의 allowedRoutes가 mlops-demo namespace를 허용해야 합니다. 외부 노출이 필요 없으면 kubectl port-forward를 사용합니다.
Argo CD Application
# applications/digits-ml.yaml
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: digits-ml
namespace: argocd
spec:
project: ml-platform
source:
repoURL: https://gitlab.lab.example.com/mlops/ml-platform-gitops.git
targetRevision: main
path: charts/digits-ml
destination:
server: https://kubernetes.default.svc
namespace: mlops-demo
syncPolicy:
automated:
prune: true
selfHeal: true
syncOptions:
- CreateNamespace=trueArgo CD가 private repository credential과 ml-platform AppProject를 이미 가지고 있다고 가정합니다. Application을 커밋한 뒤 bootstrap 방식으로 적용하거나 기존 app-of-apps에 연결합니다.
이 예제에서는 commit마다 RayJob 이름이 달라지고 Argo CD prune이 켜져 있습니다. 따라서 한 RayJob이 완료된 뒤에만 다음 학습 commit을 Chart Repo에 반영해야 합니다. 완료 전에 새 desired state가 들어오면 이전 RayJob이 prune되어 진행 중인 학습이 중단될 수 있습니다. 운영에서는 학습 실행별 manifest를 보존하는 별도 경로, Argo CD hook의 삭제 정책, queue와 동시성 제어 중 하나를 명시적으로 설계해야 합니다.
helm lint charts/digits-ml
helm template digits-ml charts/digits-ml > /dev/null
git add applications charts
git commit -m "add digits ml capstone chart"
git push origin main초기에는 PVC만 생성되고 RayJob과 InferenceService는 없어야 정상입니다.
argocd app get digits-ml
kubectl get pvc -n mlops-demo ray-data
kubectl get rayjob,inferenceservice -n mlops-demo3. Code Repo 만들기
GitLab에 mlops/digits-training이라는 별도 private project를 만들고 다음 구조를 준비합니다.
digits-training/
├── .dockerignore
├── .gitlab-ci.yml
├── Dockerfile
├── pyproject.toml
├── uv.lock
├── src/
│ ├── __init__.py
│ └── train.py
└── tests/
└── test_train.py먼저 서빙 런타임과 학습 환경을 맞춘다
MLflow 모델을 서빙하려면 파일 형식뿐 아니라 모델을 읽는 환경도 호환돼야 합니다. KServe의 MLServer는 현재 컨테이너 환경에서 모델을 역직렬화합니다. 따라서 Python, MLflow, NumPy, scikit-learn, cloudpickle의 버전 차이로 모델 로딩이 실패할 수 있습니다.
이 글은 **KServe v0.18의 seldonio/mlserver:1.7.1**을 기준선으로 삼습니다. 이 이미지의 Python은 3.10이고 공식 lock에는 MLflow 2.19.0, NumPy 1.26.4, scikit-learn 1.5.2, cloudpickle 3.1.0이 들어 있습니다. KServe 설치 방식에 따라 기본 runtime tag가 달라질 수 있으므로 먼저 클러스터의 실제 값을 확인합니다.
kubectl get clusterservingruntime kserve-mlserver \
-o jsonpath='{.spec.containers[0].image}{"\n"}'예상 기준선은 docker.io/seldonio/mlserver:1.7.1 또는 그 이미지를 검증해 사내 Harbor에 mirror한 digest입니다. 결과가 다르면 그대로 진행하지 않습니다. 다음 중 하나를 선택합니다.
- 플랫폼 운영자가
kserve-mlserver를 검증된 1.7.1 digest로 고정합니다. - 실제 runtime의 전체 패키지 lock에 맞춰 학습 환경을 다시 고정하고 모델 로딩 smoke test를 통과시킵니다.
모델의 requirements.txt는 호환성 정보를 남기지만 기본 MLServer가 모델별 가상 환경을 자동으로 만들어 준다고 가정해서는 안 됩니다. 이 실습은 학습 환경과 서빙 환경의 핵심 직렬화 패키지를 같은 버전으로 맞추는 방법을 택합니다.
Python 3.10은 2026년 10월 지원 종료 예정이므로 이 조합은 KServe v0.18 호환 실습 기준선이지 장기 운영 권장이 아닙니다. 운영 전에는 조직의 검증 절차를 거쳐 더 최신 KServe·MLServer 조합이나 Python 3.11+ custom ServingRuntime으로 올립니다.
pyproject.toml
[project]
name = "digits-training"
version = "0.1.0"
description = "RayJob, MLflow, and KServe capstone"
requires-python = ">=3.10,<3.11"
dependencies = [
"boto3>=1.35,<2",
"cloudpickle==3.1.0",
"mlflow==2.19.0",
"numpy==1.26.4",
"ray[default]==2.47.1",
"scikit-learn==1.5.2",
]
[dependency-groups]
dev = [
"pytest>=8,<9",
"ruff>=0.11,<1",
]
[tool.pytest.ini_options]
testpaths = ["tests"]
[tool.ruff]
line-length = 100
target-version = "py310"버전 범위를 실제 한 버전으로 해결한 uv.lock을 생성하고 커밋합니다.
uv lock
uv sync --frozen --dev
uv lock --check
git add pyproject.toml uv.lock이 예제에서는 KubeRay rayVersion, base image, Python 패키지의 Ray 버전을 모두 2.47.1로 맞춥니다. Python 3.10과 직렬화 관련 패키지도 서빙 runtime과 같은 버전으로 구성합니다. 다른 버전을 선택한다면 학습 이미지, Ray, MLServer를 함께 검증해야 합니다.
src/train.py
이 코드는 내장 Digits 데이터를 사용하므로 외부 데이터 다운로드가 필요 없습니다. 데이터 바이트의 SHA-256을 계산하고, Ray remote task에서 학습한 뒤 결과를 MLflow, MinIO, RWX PVC 세 곳에 목적에 맞게 남깁니다.
from __future__ import annotations
import hashlib
import json
import os
import tempfile
import time
from pathlib import Path
import boto3
import mlflow
import mlflow.sklearn
import numpy as np
import ray
from mlflow.models import infer_signature
from sklearn.datasets import load_digits
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import accuracy_score
from sklearn.model_selection import train_test_split
REQUIRED_ENV = (
"MLFLOW_TRACKING_URI",
"MLFLOW_S3_ENDPOINT_URL",
"MODEL_BUCKET",
"GIT_COMMIT_SHA",
"TRAINING_IMAGE",
"LOCKFILE_SHA256",
)
def require_environment() -> None:
missing = [name for name in REQUIRED_ENV if not os.getenv(name)]
if missing:
raise RuntimeError(f"missing required environment: {', '.join(missing)}")
def dataset_sha256(features: np.ndarray, labels: np.ndarray) -> str:
digest = hashlib.sha256()
digest.update(np.ascontiguousarray(features).tobytes())
digest.update(np.ascontiguousarray(labels).tobytes())
return digest.hexdigest()
def upload_directory(local_dir: Path, bucket: str, prefix: str) -> None:
client = boto3.client("s3", endpoint_url=os.environ["MLFLOW_S3_ENDPOINT_URL"])
for path in local_dir.rglob("*"):
if path.is_file():
relative = path.relative_to(local_dir).as_posix()
client.upload_file(str(path), bucket, f"{prefix.rstrip('/')}/{relative}")
@ray.remote(num_cpus=1)
def train() -> dict[str, object]:
require_environment()
started_at = time.time()
dataset = load_digits()
features = dataset.data.astype(np.float64)
labels = dataset.target
data_hash = dataset_sha256(features, labels)
x_train, x_test, y_train, y_test = train_test_split(
features,
labels,
test_size=0.2,
random_state=42,
stratify=labels,
)
model = LogisticRegression(max_iter=500, random_state=42)
model.fit(x_train, y_train)
predictions = model.predict(x_test)
accuracy = float(accuracy_score(y_test, predictions))
elapsed_seconds = round(time.time() - started_at, 3)
git_commit = os.environ["GIT_COMMIT_SHA"]
image = os.environ["TRAINING_IMAGE"]
bucket = os.environ["MODEL_BUCKET"]
base_prefix = os.getenv("MODEL_PREFIX", "digits").strip("/")
mlflow.set_tracking_uri(os.environ["MLFLOW_TRACKING_URI"])
mlflow.set_experiment(os.getenv("MLFLOW_EXPERIMENT_NAME", "digits-ray-training"))
with mlflow.start_run(run_name=f"digits-{git_commit[:12]}") as run:
run_id = run.info.run_id
model_prefix = f"{base_prefix}/{git_commit}/{run_id}/model"
model_storage_uri = f"s3://{bucket}/{model_prefix}"
signature = infer_signature(x_train, model.predict(x_train))
mlflow.log_params(
{
"algorithm": "LogisticRegression",
"max_iter": 500,
"random_state": 42,
"test_size": 0.2,
}
)
mlflow.log_metrics(
{
"accuracy": accuracy,
"elapsed_seconds": elapsed_seconds,
}
)
mlflow.set_tags(
{
"validation_status": "pending",
"source.git_commit": git_commit,
"runtime.training_image": image,
"runtime.lockfile_sha256": os.environ["LOCKFILE_SHA256"],
"data.name": "sklearn.datasets.load_digits",
"data.sha256": data_hash,
"kubernetes.rayjob": os.getenv("RAY_JOB_NAME", "local"),
"serving.storage_uri": model_storage_uri,
}
)
with tempfile.TemporaryDirectory() as temp_dir:
model_dir = Path(temp_dir) / "model"
mlflow.sklearn.save_model(
sk_model=model,
path=str(model_dir),
serialization_format="cloudpickle",
signature=signature,
input_example=x_test[:2],
)
upload_directory(model_dir, bucket, model_prefix)
model_info = mlflow.sklearn.log_model(
sk_model=model,
artifact_path="model",
serialization_format="cloudpickle",
signature=signature,
input_example=x_test[:2],
registered_model_name=os.getenv("REGISTERED_MODEL_NAME", "digits-classifier"),
)
summary = {
"run_id": run_id,
"registered_model_version": model_info.registered_model_version,
"git_commit": git_commit,
"training_image": image,
"dataset_sha256": data_hash,
"accuracy": accuracy,
"elapsed_seconds": elapsed_seconds,
"model_storage_uri": model_storage_uri,
}
mlflow.log_dict(summary, "summary.json")
data_dir = Path(os.getenv("DATA_DIR", "/mnt/datasets")) / "runs" / run_id
data_dir.mkdir(parents=True, exist_ok=True)
(data_dir / "summary.json").write_text(
json.dumps(summary, indent=2, sort_keys=True),
encoding="utf-8",
)
s3_client = boto3.client("s3", endpoint_url=os.environ["MLFLOW_S3_ENDPOINT_URL"])
s3_client.put_object(
Bucket=bucket,
Key=f"{base_prefix}/{git_commit}/{run_id}/summary.json",
Body=json.dumps(summary, indent=2, sort_keys=True).encode("utf-8"),
ContentType="application/json",
)
return summary
def main() -> None:
ray.init(address="auto")
result = ray.get(train.remote())
print(json.dumps(result, indent=2, sort_keys=True))
if __name__ == "__main__":
main()모델은 두 경로로 저장됩니다.
- MLflow가 관리하는 run artifact와 registered model version
- KServe가 직접 읽을 수 있는 불변
s3://ml-models/digits/<commit>/<run-id>/model
MLflow 서버 구성에 따라 run artifact URI가 mlflow-artifacts:/...처럼 프록시 전용 URI일 수 있으므로 두 번째 복사본을 만듭니다. KServe에는 이 복사본을 가리키는 명시적인 S3 URI를 전달합니다. 중복 저장 비용이 들지만, 이 작은 실습에서는 모델의 계보를 쉽게 확인하고 KServe가 읽을 수 있는 경로를 확보하는 데 중점을 둡니다.
tests/test_train.py
클러스터 없이 실행할 수 있는 결정적 부분부터 검사합니다.
import numpy as np
from src.train import dataset_sha256
def test_dataset_sha256_is_stable() -> None:
features = np.array([[1.0, 2.0], [3.0, 4.0]], dtype=np.float64)
labels = np.array([0, 1], dtype=np.int64)
assert dataset_sha256(features, labels) == dataset_sha256(features.copy(), labels.copy())
def test_dataset_sha256_changes_with_data() -> None:
first = np.array([[1.0]], dtype=np.float64)
second = np.array([[2.0]], dtype=np.float64)
labels = np.array([0], dtype=np.int64)
assert dataset_sha256(first, labels) != dataset_sha256(second, labels)Dockerfile
base image와 uv image는 CI 변수를 통해 승인된 digest를 넘깁니다. Dockerfile 안에 latest를 고정하지 않습니다.
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"].dockerignore
.git
.gitlab-ci.yml
.pytest_cache
.ruff_cache
.venv
__pycache__
tests
*.pyc4. GitLab CI 변수 등록
Code Repo의 Settings → CI/CD → Variables에 다음 값을 등록합니다. Secret은 masked와 protected를 켭니다.
| 변수 | 예시 또는 의미 | Secret |
|---|---|---|
HARBOR_PUSH_ROBOT_USER |
Harbor push 전용 robot account | 예 |
HARBOR_PUSH_ROBOT_PASSWORD |
push robot password | 예 |
CHART_REPO_TOKEN |
write_repository 최소 권한 project token |
예 |
BUILDKIT_IMAGE |
승인된 moby/buildkit:rootless@sha256:... |
아니오 |
UV_TEST_IMAGE |
승인된 uv+Python 3.10 테스트 이미지 digest | 아니오 |
UV_IMAGE |
승인된 ghcr.io/astral-sh/uv@sha256:... |
아니오 |
RAY_BASE_IMAGE |
승인된 rayproject/ray:2.47.1-py310-cpu@sha256:... |
아니오 |
Container registry의 실제 digest는 운영자가 검증한 값을 넣습니다. 문서의 ...를 그대로 변수 값에 입력하지 않습니다.
Chart Repo token은 ml-platform-gitops 저장소에만 쓸 수 있어야 합니다. Code Repo token이나 개인 access token을 광범위하게 재사용하지 않습니다.
5. .gitlab-ci.yml 작성
다음 pipeline은 test, rootless image build, Chart Repo write-back 세 단계로 구성됩니다. 문서만 바뀌면 실행하지 않고 학습 결과에 영향을 주는 파일이 바뀔 때 실행합니다.
stages:
- test
- build
- writeback
workflow:
rules:
- if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
- when: never
.training_changes: &training_changes
changes:
- src/**/*
- tests/**/*
- pyproject.toml
- uv.lock
- Dockerfile
- .dockerignore
- .gitlab-ci.yml
test-training-code:
stage: test
image: "${UV_TEST_IMAGE}"
rules:
- <<: *training_changes
script:
- uv lock --check
- uv sync --frozen --dev
- uv run ruff check src tests
- uv run pytest -q
build-training-image:
stage: build
image:
name: "${BUILDKIT_IMAGE}"
entrypoint: [""]
variables:
BUILDKITD_FLAGS: --oci-worker-no-process-sandbox
HARBOR_REGISTRY: harbor.lab.example.com
IMAGE_REPOSITORY: harbor.lab.example.com/mlops/digits-trainer
needs:
- test-training-code
rules:
- <<: *training_changes
before_script:
- mkdir -p "${HOME}/.docker"
- |
AUTH="$(printf '%s:%s' "${HARBOR_PUSH_ROBOT_USER}" "${HARBOR_PUSH_ROBOT_PASSWORD}" | base64 | tr -d '\n')"
printf '{"auths":{"%s":{"auth":"%s"}}}\n' \
"${HARBOR_REGISTRY}" "${AUTH}" > "${HOME}/.docker/config.json"
script:
- |
buildctl-daemonless.sh build \
--frontend dockerfile.v0 \
--local context=. \
--local dockerfile=. \
--opt filename=Dockerfile \
--opt "build-arg:UV_IMAGE=${UV_IMAGE}" \
--opt "build-arg:RAY_BASE_IMAGE=${RAY_BASE_IMAGE}" \
--output "type=image,name=${IMAGE_REPOSITORY}:${CI_COMMIT_SHA},push=true"
- LOCKFILE_SHA256="$(sha256sum uv.lock | awk '{print $1}')"
- |
printf 'IMAGE_REPOSITORY=%s\nIMAGE_TAG=%s\nLOCKFILE_SHA256=%s\n' \
"${IMAGE_REPOSITORY}" "${CI_COMMIT_SHA}" "${LOCKFILE_SHA256}" > image.env
artifacts:
reports:
dotenv: image.env
write-training-state:
stage: writeback
image: alpine:3.21
needs:
- job: build-training-image
artifacts: true
rules:
- <<: *training_changes
resource_group: ml-platform-gitops-writeback
variables:
CHART_REPO_URL: https://gitlab.lab.example.com/mlops/ml-platform-gitops.git
VALUES_FILE: charts/digits-ml/values.yaml
before_script:
- apk add --no-cache git helm yq
- git config --global user.name "mlops-ci"
- git config --global user.email "mlops-ci@lab.example.com"
script:
- |
set -eu
export GITLAB_USER=oauth2
export GITLAB_PASSWORD="${CHART_REPO_TOKEN}"
cat > /tmp/git-askpass <<'EOF'
#!/bin/sh
case "$1" in
*Username*) printf '%s\n' "$GITLAB_USER" ;;
*Password*) printf '%s\n' "$GITLAB_PASSWORD" ;;
esac
EOF
chmod 700 /tmp/git-askpass
export GIT_ASKPASS=/tmp/git-askpass
export GIT_TERMINAL_PROMPT=0
git clone "${CHART_REPO_URL}" gitops
cd gitops
export IMAGE_REPOSITORY IMAGE_TAG LOCKFILE_SHA256 CI_COMMIT_SHA
yq -i '.training.enabled = true' "${VALUES_FILE}"
yq -i '.training.image.repository = strenv(IMAGE_REPOSITORY)' "${VALUES_FILE}"
yq -i '.training.image.tag = strenv(IMAGE_TAG)' "${VALUES_FILE}"
yq -i '.training.runId = strenv(CI_COMMIT_SHA)' "${VALUES_FILE}"
yq -i '.training.sourceCommit = strenv(CI_COMMIT_SHA)' "${VALUES_FILE}"
yq -i '.training.lockfileSha256 = strenv(LOCKFILE_SHA256)' "${VALUES_FILE}"
helm template digits-ml charts/digits-ml > /dev/null 2>&1 || {
echo "rendered chart validation failed"
exit 1
}
git add "${VALUES_FILE}"
git diff --cached --quiet && exit 0
git commit -m "train: deploy ${CI_COMMIT_SHA} [skip ci]"
git push origin HEAD:main예제는 Alpine 패키지에서 Helm과 yq를 설치합니다. 운영에서는 매번 패키지를 내려받기보다 Helm·yq 버전과 digest를 고정한 사내 CI image를 사용하는 편이 재현성과 공급망 관리에 유리합니다. 단순화를 위해 chart 검증을 Chart Repo pipeline으로 옮겨도 되지만, 한 곳에서는 반드시 chart rendering을 검증해야 합니다.
운영에서는 Chart Repo에 merge request를 만들고 해당 pipeline에서 helm lint, schema, 정책 검사를 수행한 뒤 merge하는 방식이 적합합니다. 이 실습에서는 전체 흐름을 살펴볼 수 있도록 main write-back을 사용합니다.
6. 첫 pipeline 실행
Code Repo의 파일을 커밋합니다.
git add .
git commit -m "add digits ray training pipeline"
git push origin mainGitLab에서 다음 세 job이 순서대로 성공해야 합니다.
test-training-code
→ build-training-image
→ write-training-stateHarbor 확인
Harbor UI에서 다음 경로를 확인합니다.
Projects
→ mlops
→ digits-trainer
→ <전체 Git commit SHA> taglatest가 없어도 정상입니다. Chart Repo가 정확한 SHA tag를 참조합니다.
Chart Repo 확인
git pull --ff-only
yq '.training' charts/digits-ml/values.yaml다음 값이 같은 Code Repo commit을 가리켜야 합니다.
training.image.tagtraining.runIdtraining.sourceCommit
lockfileSha256도 64자리 SHA-256이어야 합니다.
Argo CD와 RayJob 확인
argocd app get digits-ml
kubectl get rayjob -n mlops-demo
kubectl get pods -n mlops-demo -o wideRayJob 상태를 기다립니다.
RAYJOB_NAME="$(kubectl get rayjob -n mlops-demo \
-l app.kubernetes.io/name=digits-ml \
-o jsonpath='{.items[0].metadata.name}')"
kubectl get rayjob -n mlops-demo "${RAYJOB_NAME}" -w완료 후 상태 값을 확인합니다.
kubectl get rayjob -n mlops-demo "${RAYJOB_NAME}" \
-o jsonpath='{.status.jobDeploymentStatus}{"\t"}{.status.jobStatus}{"\n"}'KubeRay 버전에 따라 상태 문자열과 필드 표시가 조금 다를 수 있지만 정상 완료라면 배포 상태는 Complete, job은 SUCCEEDED에 해당합니다.
학습 로그에서 최종 JSON을 찾습니다.
kubectl logs -n mlops-demo -l ray.io/node-type=head --tail=300예상 형태는 다음과 같습니다. 실제 식별자는 매 실행마다 다릅니다.
{
"accuracy": 0.96,
"dataset_sha256": "<64자리 SHA-256>",
"elapsed_seconds": 0.2,
"git_commit": "<전체 Git commit SHA>",
"model_storage_uri": "s3://ml-models/digits/<commit>/<run-id>/model",
"registered_model_version": 1,
"run_id": "<MLflow run ID>",
"training_image": "harbor.lab.example.com/mlops/digits-trainer:<commit>"
}정확도와 시간은 환경에 따라 달라집니다. 출력값을 예시와 똑같이 맞추기보다, 결과에 기록된 식별자들이 같은 학습 실행과 모델을 가리키는지 확인해야 합니다.
7. MLflow, MinIO, NFS 검증
MLflow
브라우저에서 https://mlflow.lab.example.com에 접속하거나 조직이 제공한 주소를 사용합니다.
Experiment: digits-ray-training
→ 최신 run
→ Metrics: accuracy, elapsed_seconds
→ Tags: source.git_commit, runtime.training_image, data.sha256
→ Artifacts: model, summary.json
→ Registered Models: digits-classifierModel Registry는 DB-backed backend store가 있어야 합니다. 모델 버전의 source run이 방금 확인한 run ID와 같은지 확인합니다.
MinIO
관리자 암호를 블로그나 명령에 적지 않습니다. 허용된 계정으로 다음 구조를 확인합니다.
ml-models/
└── digits/
└── <git-commit>/
└── <run-id>/
├── model/
│ ├── MLmodel
│ ├── model.pkl 또는 model.skops
│ ├── conda.yaml
│ └── requirements.txt
└── summary.json실제 직렬화 파일명은 MLflow 버전과 serialization_format에 따라 달라질 수 있습니다. MLmodel과 환경 메타데이터, 모델 본체가 모두 있어야 합니다.
NFS PVC
결과를 확인하는 임시 Pod를 만듭니다.
kubectl apply -n mlops-demo -f - <<'EOF'
apiVersion: v1
kind: Pod
metadata:
name: ray-data-check
spec:
restartPolicy: Never
volumes:
- name: ray-data
persistentVolumeClaim:
claimName: ray-data
containers:
- name: check
image: busybox:1.36
command: ["sh", "-c", "find /mnt/datasets/runs -maxdepth 3 -name summary.json -print -exec cat {} \\;"]
volumeMounts:
- name: ray-data
mountPath: /mnt/datasets
EOF
kubectl logs -n mlops-demo ray-data-check
kubectl delete pod -n mlops-demo ray-data-check이 Pod 삭제는 확인용 Pod만 제거합니다. PVC와 학습 결과는 삭제하지 않습니다.
8. 모델 평가와 승격
학습이 성공한 뒤에는 서빙을 켜기 전에 모델을 평가해야 합니다. 다음과 같이 승격에 필요한 최소 기준을 정합니다.
accuracy >= 0.95
source.git_commit == 배포한 Code Repo commit
runtime.training_image == Harbor의 SHA tag
data.sha256 존재
model_storage_uri가 s3://ml-models/digits/ 아래의 불변 run 경로
MLflow model version 상태 Ready기준을 통과하면 MLflow model version에 validation_status=passed 태그를 추가하고 champion 별칭을 지정합니다. UI에서 처리할 수도 있고 API를 사용할 수도 있습니다.
from mlflow import MlflowClient
client = MlflowClient(tracking_uri="https://mlflow.lab.example.com")
model_name = "digits-classifier"
model_version = "1" # 검증한 실제 버전으로 바꾼다.
client.set_model_version_tag(model_name, model_version, "validation_status", "passed")
client.set_registered_model_alias(model_name, "champion", model_version)별칭은 가변이므로 Git에는 로그에서 확인한 정확한 model_storage_uri, model version, run ID를 기록합니다.
export MODEL_STORAGE_URI='s3://ml-models/digits/<commit>/<run-id>/model'
export MODEL_VERSION='<검증한 모델 버전>'
export SOURCE_RUN_ID='<검증한 MLflow run ID>'
case "${MODEL_STORAGE_URI}" in
s3://ml-models/digits/*/*/model) ;;
*) echo "unexpected model URI"; exit 1 ;;
esac
yq -i '.serving.enabled = true' charts/digits-ml/values.yaml
MODEL_STORAGE_URI="${MODEL_STORAGE_URI}" yq -i '.serving.storageUri = strenv(MODEL_STORAGE_URI)' charts/digits-ml/values.yaml
MODEL_VERSION="${MODEL_VERSION}" yq -i '.serving.mlflowModelVersion = strenv(MODEL_VERSION)' charts/digits-ml/values.yaml
SOURCE_RUN_ID="${SOURCE_RUN_ID}" yq -i '.serving.sourceRunId = strenv(SOURCE_RUN_ID)' charts/digits-ml/values.yaml
helm lint charts/digits-ml
helm template digits-ml charts/digits-ml > /dev/null
git add charts/digits-ml/values.yaml
git commit -m "promote: digits model v${MODEL_VERSION}"
git push origin main<...> 값은 예시가 아니라 실제 검증 결과로 바꿔야 합니다. 운영 환경에서는 직접 main에 push하지 않고 MR 본문에 평가 근거를 붙여 승인받습니다.
Argo CD가 동기화한 뒤 Ready를 기다립니다.
kubectl get inferenceservice -n mlops-demo digits-classifier
kubectl wait -n mlops-demo \
--for=condition=Ready \
inferenceservice/digits-classifier \
--timeout=600s9. KServe V2 추론 요청
학습 환경과 같은 lockfile로 테스트 입력을 만듭니다.
uv run python - <<'PY' > input.json
import json
from sklearn.datasets import load_digits
row = load_digits().data[0].astype(float).tolist()
payload = {
"inputs": [
{
"name": "input-0",
"shape": [1, 64],
"datatype": "FP64",
"data": row,
}
]
}
print(json.dumps(payload))
PYInferenceService가 보고하는 host를 가져옵니다.
SERVICE_HOSTNAME="$(kubectl get inferenceservice -n mlops-demo digits-classifier \
-o jsonpath='{.status.url}' | sed -E 's#^https?://##; s#/.*$##')"
echo "${SERVICE_HOSTNAME}"우선 Kourier까지의 서버리스 경로를 직접 검증합니다. 이 방식은 외부 DNS·Gateway 설정과 분리해 KServe, Knative activator, scale-to-zero 경로가 실제로 동작하는지 확인합니다.
# 터미널 1
kubectl get svc -n kourier-system kourier
kubectl port-forward -n kourier-system svc/kourier 8080:80다른 터미널에서 요청합니다.
# 터미널 2
SERVICE_HOSTNAME="$(kubectl get inferenceservice -n mlops-demo digits-classifier \
-o jsonpath='{.status.url}' | sed -E 's#^https?://##; s#/.*$##')"
curl --fail-with-body \
-H "Host: ${SERVICE_HOSTNAME}" \
-H "Content-Type: application/json" \
"http://127.0.0.1:8080/v2/models/digits-classifier/infer" \
--data-binary @input.json예상 응답에는 outputs와 예측 class 데이터가 포함됩니다. Kourier를 다른 namespace나 Service 이름으로 설치했다면 먼저 실제 위치를 찾아 명령을 바꿉니다.
kubectl get svc -A | grep -i kourier외부 NGINX Gateway로 노출하려면
외부 노출 설정은 플랫폼 운영자가 관리하며, 애플리케이션 Chart에 포함하지 않습니다. 2편의 nginx-gateway/platform-gateway와 gateway-access=true namespace 선택자를 사용합니다.
Knative의 기본 host는 namespace까지 포함해 *.lab.example.com 인증서 범위를 벗어날 수 있습니다. 따라서 외부에는 한 단계짜리 문서용 host digits-student-01.lab.example.com을 쓰고, Gateway의 URLRewrite가 Kourier에 전달하는 Host를 실제 SERVICE_HOSTNAME으로 바꿉니다. Route를 Kourier와 같은 namespace에 두므로 backend ReferenceGrant는 필요하지 않습니다.
export PUBLIC_HOSTNAME=digits-student-01.lab.example.com
kubectl label namespace kourier-system gateway-access=true --overwrite
cat <<EOF | kubectl apply -f -
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: digits-classifier-kourier
namespace: kourier-system
spec:
parentRefs:
- name: platform-gateway
namespace: nginx-gateway
sectionName: https
hostnames:
- "${PUBLIC_HOSTNAME}"
rules:
- filters:
- type: URLRewrite
urlRewrite:
hostname: "${SERVICE_HOSTNAME}"
backendRefs:
- name: kourier
port: 80
EOF
kubectl get httproute -n kourier-system digits-classifier-kourier \
-o jsonpath='{range .status.parents[*].conditions[*]}{.type}={.status} {.reason}{"\n"}{end}'Accepted=True와 ResolvedRefs=True를 확인합니다. NGINX Gateway Fabric 버전이 hostname URLRewrite를 지원하지 않는다면 이 Route를 억지로 적용하지 말고, Knative domain template과 TLS 인증서를 함께 설계하거나 KServe 공식 노출 방식을 사용합니다.
DNS 반영 전에는 문서용 IP를 하드코딩하지 않고 Gateway 상태에서 주소를 읽어 --resolve로 검증합니다.
GATEWAY_ADDRESS="$(kubectl get gateway -n nginx-gateway platform-gateway \
-o jsonpath='{.status.addresses[0].value}')"
curl --fail-with-body \
-H "Content-Type: application/json" \
--resolve "${PUBLIC_HOSTNAME}:443:${GATEWAY_ADDRESS}" \
"https://${PUBLIC_HOSTNAME}/v2/models/digits-classifier/infer" \
--data-binary @input.json--resolve는 실제 DNS를 바꾸지 않고도 TLS SNI와 Host를 유지합니다. 자체 서명 인증서라면 -k로 우회하기보다 실습 클라이언트가 lab CA를 신뢰하도록 설정합니다.
10. scale-to-zero 확인
이 절은 KServe가 Knative 모드이고 minReplicas: 0일 때만 해당합니다.
kubectl get pod -n mlops-demo \
-l serving.kserve.io/inferenceservice=digits-classifier -w오토스케일러의 유휴 시간이 지난 뒤 predictor Pod가 0개가 되는지 확인합니다. 같은 curl 요청을 다시 보내면 첫 요청은 콜드 스타트를 기다릴 수 있고, 곧 Pod가 생성되어 응답해야 합니다.
상시 서비스에서 첫 요청 지연이 허용되지 않으면 다음처럼 바꿔 Git에 커밋합니다.
serving:
minReplicas: 1Standard 모드에서는 HPA가 0개까지 줄어들지 않습니다. 이 차이를 장애로 오해하지 않습니다.
11. 변경 트리거 검증
학습 코드 변경
src/train.py의 모델 파라미터를 바꿉니다. 예를 들어 max_iter를 조정하고 해당 MLflow param 값도 함께 바꿉니다.
git add src/train.py
git commit -m "tune digits training"
git push origin main다음 연쇄 변화를 확인합니다.
- 새 GitLab pipeline 실행
- Harbor에 새 commit SHA tag 생성
- Chart Repo의 image tag, runId, sourceCommit 변경
- Argo CD 동기화
- 새 이름의 RayJob 생성
- MLflow에 새 run과 model version 생성
- MinIO에 새 commit/run 경로 생성
새 모델은 자동으로 KServe에 배포되지 않습니다. 기존 serving.storageUri가 그대로여야 승인 게이트가 정상 작동한 것입니다.
문서만 변경
git add README.md
git commit -m "docs: explain pipeline"
git push origin mainrules:changes 때문에 학습 job이 생기지 않아야 합니다. README 변경도 이미지를 바꿔야 하는 조직이라면 규칙에 포함합니다.
12. 승인된 모델 롤백 검증
마지막 승격이 품질이나 운영 문제를 일으켰다고 가정합니다. Kubernetes에서 임의로 이전 URI를 patch하지 않고, Chart Repo의 승격 commit을 revert해 감사 가능한 새 변경으로 되돌립니다.
git switch main
git pull --ff-only
git log --oneline -- charts/digits-ml/values.yaml
read -r -p "되돌릴 promotion commit SHA: " PROMOTION_COMMIT
printf '%s' "${PROMOTION_COMMIT}" | grep -Eq '^[0-9a-fA-F]{7,40}$' || {
echo "유효한 Git commit SHA가 아닙니다." >&2
exit 1
}
git show --stat "${PROMOTION_COMMIT}"
git revert "${PROMOTION_COMMIT}"
git push origin mainArgo CD가 이전 불변 storageUri로 수렴하고 KServe가 다시 준비되는지 확인합니다.
argocd app wait digits-ml --sync --health --timeout 300
yq '.serving | {storageUri, mlflowModelVersion, sourceRunId}' charts/digits-ml/values.yaml
kubectl wait --for=condition=Ready inferenceservice/digits-classifier \
-n mlops-demo --timeout=300s이후 9절의 V2 요청을 다시 보내 응답을 확인합니다. MLflow의 champion alias도 운영 승인 상태를 뜻하게 쓴다면 이전 model version으로 되돌리는 별도 승인 기록을 남깁니다. 실제 서빙 대상을 결정하는 기준은 alias가 아니라 Git에 고정된 storageUri입니다.
문제 해결
Pipeline 자체가 만들어지지 않는다
- default branch가
main인지 확인합니다. workflow:rules가 push와 default branch를 허용하는지 확인합니다.- 변경 파일이
rules:changes경로에 포함되는지 확인합니다. - 첫 commit의 diff 동작이 예상과 다른 경우 GitLab rules 문서를 확인합니다.
BuildKit에서 operation not permitted가 난다
rootless BuildKit이 요구하는 사용자 namespace와 Runner 보안 정책을 확인합니다. 임의로 privileged를 켜기 전에 Runner 운영자와 executor 설정, seccomp/AppArmor 정책을 점검합니다. CI 변수의 BUILDKIT_IMAGE가 실제 rootless image인지도 확인합니다.
Dockerfile의 base image를 가져오지 못한다
UV_IMAGE, RAY_BASE_IMAGE 값이 실제 digest를 가진 완전한 image reference인지, Runner가 외부 registry 또는 내부 mirror에 접근 가능한지 확인합니다. Ray base image의 CPU architecture도 노드와 맞아야 합니다.
Harbor push는 성공했지만 Ray Pod가 ImagePullBackOff다
CI push 자격 증명과 Kubernetes pull Secret은 별개입니다.
kubectl get pods -n mlops-demo
read -r -p "실패한 Pod 이름: " FAILED_POD
test -n "${FAILED_POD}" && kubectl describe pod -n mlops-demo "${FAILED_POD}"
kubectl get secret -n mlops-demo harbor-pullregistry hostname, project path, image tag, Secret namespace를 확인합니다. 자체 CA를 쓰는 Harbor라면 클러스터 노드의 container runtime이 그 CA를 신뢰해야 합니다.
Chart write-back job에서 helm: not found가 난다
예제의 alpine image에는 Helm이 기본 포함되지 않습니다. Helm과 yq가 검증된 사내 CI image를 사용하거나 chart rendering 검사를 Chart Repo pipeline으로 옮깁니다. 검증 줄만 삭제하고 넘어가면 잘못된 YAML이 Argo CD까지 전달될 수 있습니다.
Argo CD가 OutOfSync 또는 ComparisonError다
argocd app get digits-ml
argocd app manifests digits-ml
helm template digits-ml charts/digits-ml --debugrepository credential, path, Helm 문법, value type을 확인합니다. sourceCommit 같은 SHA가 YAML 숫자로 해석되지 않도록 template에서 quote를 사용합니다.
RayJob submitter는 성공했지만 job이 실패한다
kubectl describe rayjob -n mlops-demo "${RAYJOB_NAME}"
kubectl get events -n mlops-demo --sort-by=.lastTimestamp
kubectl logs -n mlops-demo -l job-name="${RAYJOB_NAME}" --all-containers --tail=200
kubectl logs -n mlops-demo -l ray.io/node-type=head --tail=300entrypoint와 PYTHONPATH, Ray package/base image/rayVersion 일치, CPU·메모리 요청, Secret과 PVC를 확인합니다. shutdownAfterJobFinishes 때문에 완료 후 cluster Pod가 정리됐다면 RayJob 상태와 중앙 로그 시스템에서 확인합니다.
MLflow run은 있지만 모델 등록이 실패한다
Model Registry에는 DB-backed backend store가 필요합니다. MLflow 서버 로그에서 DB 연결과 권한을 확인합니다. 모델 artifact 업로드만 실패하면 MinIO endpoint, bucket 권한, 인증서와 MLFLOW_S3_ENDPOINT_URL을 분리해서 점검합니다.
MinIO에 모델은 있지만 KServe가 Ready가 아니다
kubectl get inferenceservice -n mlops-demo digits-classifier -o yaml
kubectl get pod -n mlops-demo
kubectl logs -n mlops-demo \
-l serving.kserve.io/inferenceservice=digits-classifier \
-c storage-initializer --tail=200
kubectl get clusterservingruntime kserve-mlserver -o yaml- 403: Secret 권한과 bucket policy
- DNS/timeout: endpoint와 NetworkPolicy
- TLS 오류: CA 신뢰 체인
- runtime 선택 실패:
kserve-mlserver와modelFormat: mlflow - 모델 로드 실패: MLflow/sklearn serialization 호환
KServe runtime과 학습 환경의 호환은 배포 전 통합 테스트로 고정합니다.
추론 요청이 404다
status.url에서 얻은 Host header를 쓰는지 확인합니다. port-forward는 성공하고 외부 요청만 404라면 kourier-system HTTPRoute의 Accepted, ResolvedRefs, 외부 hostname, https listener, Host rewrite를 봅니다. Standard 모드와 Knative 모드는 노출 방식이 다르므로 설치 모드의 공식 가이드를 따릅니다.
추론 요청이 400이다
V2 payload의 input name, shape, datatype을 모델 signature와 비교합니다. Digits는 한 행에 64개 FP64 값입니다. KServe runtime 버전에 따라 모델 이름이 URI path와 일치해야 하는지도 확인합니다.
운영 환경으로 가져가기 전에 보완할 것
이 실습에서는 작은 모델로 학습부터 서빙까지의 연결을 확인합니다. 운영 환경에 적용하려면 다음 항목을 추가로 갖춰야 합니다.
- Chart Repo 직접 push 대신 merge request, CODEOWNERS, 정책 검사
- 이미지 digest pinning, 서명, SBOM, Harbor 취약점 게이트
- 외부 Secret 관리, 짧은 수명 자격 증명, Kubernetes 저장 시 암호화
- MLflow와 MinIO의 TLS, 인증, NetworkPolicy, 백업·복구 훈련
- 학습/서빙 ServiceAccount 분리와 bucket prefix 최소 권한
- 평가 데이터 분리, 데이터 품질, 편향, 보안, 지연·메모리 기준
- KServe runtime과 모델의 실제 통합 smoke test
- Prometheus 지표, 중앙 로그, 모델 품질·드리프트 모니터링
- 실패 재시도 정책과 중복 학습 방지, queue와 quota
- dev/stage/prod 환경별 Git 경로와 명시적 승격
Airflow나 Kubeflow Pipelines는 이 기준선이 부족해질 때 추가합니다. 시간 기반 재학습, 복잡한 DAG, backfill, 여러 외부 시스템 조율이 필요하다면 오케스트레이션 계층이 의미가 있습니다. 단순한 source change → RayJob 흐름에는 필수 조건이 아닙니다.
최종 체크리스트
- 원문 환경의 실제 도메인, IP, 계정, 토큰이 코드와 문서에 없습니다.
- Code Repo와 Chart Repo가 분리되어 있습니다.
- lockfile이 Git에 있고 CI가
--frozen으로 검사합니다. - rootless BuildKit이 commit SHA tag를 Harbor에 push합니다.
- CI는 Kubernetes API나 Argo CD 관리자 계정을 직접 사용하지 않습니다.
- Chart Repo commit이 image, source commit, lockfile hash를 기록합니다.
- Argo CD가 RayJob을 생성하고 완료 상태를 확인합니다.
- MLflow run, model version, MinIO URI, NFS summary가 서로 같은 run을 가리킵니다.
- 평가 기준을 통과한 모델만
champion과 GitOps serving URI에 반영됩니다. - KServe
Ready=True와 V2 추론 응답을 확인합니다. - Knative 모드라면 scale-to-zero와 콜드 스타트 정책을 확인합니다.
- Secret과 관리 UI가 외부에 무방비로 노출되지 않습니다.
정리
이 실습에서 GitLab은 코드를 검증하고 이미지를 만들며, Harbor는 실행 환경을 보관합니다. Chart Repo는 학습과 서빙의 원하는 상태를 기록하고, Argo CD는 그 상태를 클러스터에 맞춥니다. KubeRay는 RayJob의 수명주기를 관리하고, Ray는 학습을 실행합니다. MLflow는 실험과 모델의 계보를 남기며, MinIO는 실제 아티팩트를 보관합니다. 마지막으로 KServe와 Knative가 승인된 모델을 추론 API로 제공합니다.
“도구를 모두 설치했다”는 확인 뒤에는 실제 전달 과정도 검증해야 합니다. 이 파이프라인은 코드 한 줄의 변경에서 운영 중인 모델 아티팩트까지 역추적할 수 있고, 승인되지 않은 모델은 배포되지 않으며, Git commit 하나로 이전 상태를 복원할 수 있어야 완성됐다고 볼 수 있습니다.
이전 글: GitOps와 ML 파이프라인이 만나는 지점
시리즈 처음으로: RKE2로 Kubernetes 기반 다지기
공식 참고자료
- GitLab CI/CD YAML 문법
- GitLab rootless BuildKit
- Harbor robot accounts
- Argo CD 자동 동기화
- KubeRay RayJob 빠른 시작
- MLflow Tracking
- MLflow 2.19 scikit-learn API
- MLflow Model Registry 워크플로
- KServe MLflow 모델 배포
- KServe ServingRuntime
- KServe v0.18 공식 cluster runtime manifest
- MLServer 1.7.1 Dockerfile
- MLServer 1.7.1 lockfile
- MLServer 1.7.1 MLflow runtime 의존성
- Python 버전 지원 일정
- KServe S3 모델 스토리지
- KServe Standard와 Knative 모드
- Knative scale-to-zero
- Kubernetes Gateway API HTTPRoute
- uv lock와 프로젝트 동기화