Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
de58741
πŸ”€ Merge develop into infra/prometheus-grafana (#12)
simGPT Apr 23, 2026
2c0766e
:bug:Fix: cd μ‹œμ— 이미지 νƒœκ·Έ μ—…λ°μ΄νŠΈ ν•˜λ„λ‘ μˆ˜μ •(v1 -> 컀밋SHA) (#14)
simGPT Apr 23, 2026
663b1b2
✨ Feat: MLflow 기반 S3 λͺ¨λΈ μ €μž₯ 및 λ‘œλ“œ ν™˜κ²½ ꡬ좕 (#17)
simGPT May 12, 2026
aaa9dce
:bug:Fix: volume mount μΆ”κ°€
simGPT May 12, 2026
edc1425
:bug:Fix: pvc.yaml(ν΄λŸ¬μŠ€ν„°μš© μ €μž₯μ†Œ)ꡬ좕을 μœ„ν•œ μ„€μ •νŒŒμΌ μΆ”κ°€
simGPT May 12, 2026
861bf16
πŸ› Fix: mlflow-db 데이터 μ΄ˆκΈ°ν™” λ˜λŠ” 문제 (#20)
simGPT May 12, 2026
0568e8b
Merge branch 'develop' of https://github.com/simGPT/sw-mlops into dev…
simGPT May 13, 2026
f467d73
Merge branch 'main' into develop
simGPT May 13, 2026
969359b
:twisted_rightwards_arrows:Merge: main<-develop pr 좩돌 ν•΄κ²°
simGPT May 13, 2026
7492ca9
πŸ› Fix: λͺ¨λΈ μ„œλΉ™ μ‹œ mlflow의 403 μ—λŸ¬ ν•΄κ²° (#23)
simGPT May 15, 2026
d34fc68
ci: trigger CI
simGPT May 15, 2026
261977c
:wrench:Settings: retrigger CI
simGPT May 15, 2026
ec12462
:wrench:Infra: ci νŠΈλ¦¬κ±°μ— pull_request μΆ”κ°€
simGPT May 15, 2026
9c5ccc7
:wrench:Settings: ci 트리거 main push μ œκ±°ν•˜λ„λ‘ μˆ˜μ •
simGPT May 18, 2026
76ace0a
:wrench:Settings: awsκ΄€λ ¨ secrets env둜 μΆ”κ°€
simGPT May 18, 2026
a6eaacd
:card_file_box:Comment: 주석 μˆ˜μ •
simGPT May 18, 2026
49224cc
:wrench:Settings: 이미지 νƒœκ·Έ v1 -> latest둜 μˆ˜μ •
simGPT May 18, 2026
a6fbfb0
:wrench:Settings: localhost:5000 ν—ˆμš© μ„€μ •
simGPT May 18, 2026
63604fd
πŸ”§ Infra: EC2 k3s 운영 ν™˜κ²½ 배포 νŒŒμ΄ν”„λΌμΈ ꡬ좕 (#26)
simGPT May 20, 2026
4fdf761
:card_file_box:Comment: CI/CD νŒŒμ΄ν”„λΌμΈ 트리거 ν™•μΈμš© 주석 μΆ”κ°€
simGPT May 20, 2026
962b61c
:twisted_rightwards_arrows:Merge: merge origin main into develop
simGPT May 20, 2026
816bf5d
:bug:Fix: pull_request 쀑볡 μ‚­μ œ
simGPT May 20, 2026
b947c07
:sparkles:Feat: churn modeling test (#29)
simGPT May 27, 2026
94f20c9
✨ Feat: 고객 μ΄νƒˆ 예츑 λͺ¨λΈ ν•™μŠ΅ 및 μ„œλΉ™ (#30)
simGPT May 27, 2026
88b75ab
✨ Feat: 도메인 ꡬ맀 및 μ„œλΉ„μŠ€ 인프라 μ—°κ²° (#32)
simGPT May 30, 2026
ce3cabe
:twisted_rightwards_arrows:Merge: develop -> main 좩돌 ν•΄κ²°
simGPT May 30, 2026
c29b709
:bug:Fix: 이미지 쀑볡 제거
simGPT May 30, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/cd-production.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ jobs:
run: kubectl apply -k k8s/model-api/overlays/production/

- name: νŒŒλ“œ μž¬μ‹œμž‘ # (이미지 νƒœκ·Έκ°€ 계속 latest여도 μƒˆ μ΄λ―Έμ§€λ‘œ λ°°ν¬λ˜λ„λ‘)
run: kubectl rollout restart deployment/model-api
run: kubectl rollout restart deployment/model-api -n model-api # λ„€μž„μŠ€νŽ˜μ΄μŠ€ μ§€μ •

- name: 배포 확인
run: kubectl rollout status deployment/model-api
run: kubectl rollout status deployment/model-api -n model-api # λ„€μž„μŠ€νŽ˜μ΄μŠ€ μ§€μ •
14 changes: 14 additions & 0 deletions k8s/cert-manager/cluster-issuer.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
apiVersion: cert-manager.io/v1
kind: ClusterIssuer # μΈμ¦μ„œ λ°œκΈ‰, ν΄λŸ¬μŠ€ν„° μ „μ²΄μ—μ„œ μ‚¬μš© κ°€λŠ₯
metadata:
name: swmlops-issuer
spec:
acme: # acme ν”„λ‘œν† μ½œμ΄ Let’s Encrypt와 ν†΅μ‹ ν•˜κΈ° μœ„ν•œ ν‘œμ€€ ν”„λ‘œν† μ½œ
server: https://acme-v02.api.letsencrypt.org/directory
email: simgpt02@gmail.com
privateKeySecretRef:
name: swmlops-issuer
solvers:
- http01:
ingress:
ingressClassName: traefik # k3s μ—μ„œ κΈ°λ³Έ μ œκ³΅ν•΄μ£ΌλŠ” ingress controller
1 change: 1 addition & 0 deletions k8s/mlflow-db/deployment.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ apiVersion: apps/v1
kind: Deployment
metadata:
name: mlflow-db
namespace: mlflow
spec:
replicas: 1
selector:
Expand Down
1 change: 1 addition & 0 deletions k8s/mlflow-db/pvc.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: mlflow-db-pvc
namespace: mlflow
spec:
accessModes:
- ReadWriteOnce
Expand Down
1 change: 1 addition & 0 deletions k8s/mlflow-db/service.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ apiVersion: v1
kind: Service
metadata:
name: mlflow-db
namespace: mlflow
spec:
type: ClusterIP
selector:
Expand Down
4 changes: 3 additions & 1 deletion k8s/mlflow/deployment.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ apiVersion: apps/v1
kind: Deployment
metadata:
name: mlflow
namespace: mlflow
spec:
replicas: 1
selector:
Expand All @@ -14,6 +15,7 @@ spec:
spec:
containers:
- name: mlflow
image: burakince/mlflow:3.7.0 # postgreSQL 연결이 λ˜λŠ” 버전 이미지
image: burakince/mlflow:3.7.0 # postgreSQL 연결이 λ˜λŠ” 버전 이미지
ports:
- containerPort: 5000
Expand All @@ -39,5 +41,5 @@ spec:
- --port=5000
- --default-artifact-root=s3://kube-ml-bucket/mlflow-artifacts # λͺ¨λΈ μ €μž₯
- --backend-store-uri=postgresql://mlflow:1234@mlflow-db:5432/mlflow
- --allowed-hosts=mlflow,mlflow:5000,mlflow.default.svc.cluster.local,localhost,localhost:5000 # μ‹€μ œ 운영 ν™˜κ²½μ—μ„œλŠ” μ‹€μ œ 도메인을 μž…λ ₯ν•˜λ©΄ 될 λ“―
- --allowed-hosts=mlflow.mlflow.svc.cluster.local,localhost,localhost:5000,mlflow.swmlops.site # ν’€ DNS, localhost, 도메인 ν—ˆμš©

24 changes: 24 additions & 0 deletions k8s/mlflow/ingress.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: mlflow
namespace: mlflow
annotations:
cert-manager.io/cluster-issuer: swmlops-issuer # cert-managerμ—μ„œ μΈμ¦μ„œλ₯Ό λ°œκΈ‰λ°›μ„ λ•Œ μ‚¬μš©ν•  ClusterIssuer μ§€μ •
spec:
ingressClassName: traefik # k3s μ—μ„œ κΈ°λ³Έ μ œκ³΅ν•΄μ£ΌλŠ” ingress controller
tls:
- hosts:
- mlflow.swmlops.site # 이 λ„λ©”μΈμœΌλ‘œ 접속할 λ•Œ TLS μΈμ¦μ„œ μ‚¬μš©
secretName: mlflow-tls
rules:
- host: mlflow.swmlops.site # 이 λ„λ©”μΈμœΌλ‘œ 접속할 λ•Œ μ•„λž˜μ˜ http 경둜둜 λΌμš°νŒ…
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: mlflow # mlflow μ„œλΉ„μŠ€λ‘œ 이동 + 5000 포트둜 이동
port:
number: 5000
4 changes: 2 additions & 2 deletions k8s/mlflow/service.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,11 @@ apiVersion: v1
kind: Service
metadata:
name: mlflow
namespace: mlflow
spec:
type: NodePort
type: ClusterIP
selector:
app: mlflow
ports:
- port: 5000
targetPort: 5000
nodePort: 30050
4 changes: 3 additions & 1 deletion k8s/model-api/base/deployment.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ spec:
- containerPort: 8000
env:
- name: MLFLOW_TRACKING_URI
value: http://mlflow:5000
value: http://mlflow.mlflow.svc.cluster.local:5000
- name: AWS_ACCESS_KEY_ID
valueFrom:
secretKeyRef:
Expand All @@ -32,6 +32,8 @@ spec:
key: secret-access-key
- name: AWS_DEFAULT_REGION
value: ap-northeast-2
- name: CHURN_MODEL_VERSION # λͺ¨λΈ 버전 μΆ”κ°€
value: "v5"
resources:
requests:
cpu: "250m"
Expand Down
2 changes: 2 additions & 0 deletions k8s/model-api/overlays/local/kustomization.yaml
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization

namespace: model-api

resources:
- ../../base # base κ°€μ Έλ‹€ μ“΄λ‹€
- service.yaml
11 changes: 10 additions & 1 deletion k8s/model-api/overlays/production/ingress.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,18 @@ apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: model-api
namespace: model-api
annotations:
cert-manager.io/cluster-issuer: swmlops-issuer # cert-managerμ—μ„œ μΈμ¦μ„œλ₯Ό λ°œκΈ‰λ°›μ„ λ•Œ μ‚¬μš©ν•  ClusterIssuer μ§€μ •
spec:
ingressClassName: traefik # k3s μ—μ„œ κΈ°λ³Έ μ œκ³΅ν•΄μ£ΌλŠ” ingress controller
tls:
- hosts:
- model.swmlops.site # 이 λ„λ©”μΈμœΌλ‘œ 접속할 λ•Œ TLS μΈμ¦μ„œ μ‚¬μš©
secretName: model-api-tls
rules:
- http:
- host: model.swmlops.site # 이 λ„λ©”μΈμœΌλ‘œ 접속할 λ•Œ μ•„λž˜μ˜ http 경둜둜 λΌμš°νŒ…
http:
paths:
- path: /
pathType: Prefix
Expand Down
2 changes: 2 additions & 0 deletions k8s/model-api/overlays/production/kustomization.yaml
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization

namespace: model-api # λ„€μž„μŠ€νŽ˜μ΄μŠ€ μΆ”κ°€(μΌλΆ€λŸ¬ deployment.yamlμ—μ„œ λ„€μž„μŠ€νŽ˜μ΄μŠ€ μ•ˆμ“°κ³  μ—¬κΈ°μ„œλ§Œ 씀)

resources:
- ../../base
- service.yaml
Expand Down
15 changes: 14 additions & 1 deletion k8s/monitoring/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,17 @@ prometheus:
- job_name: model-api
static_configs:
- targets:
- model-api.default.svc.cluster.local:8000
- model-api.model-api.svc.cluster.local:8000 # λ‹€λ₯Έ λ„€μž„μŠ€νŽ˜μ΄μŠ€μ— μžˆλŠ” model-api의 ν΄λŸ¬μŠ€ν„° λ‚΄λΆ€ μ£Όμ†Œμ™€ 포트

grafana:
ingress:
enabled: true
ingressClassName: traefik
annotations:
cert-manager.io/cluster-issuer: swmlops-issuer
hosts:
- monitoring.swmlops.site
tls:
- secretName: grafana-tls
hosts:
- monitoring.swmlops.site
14 changes: 14 additions & 0 deletions k8s/namespaces.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
apiVersion: v1
kind: Namespace
metadata:
name: mlflow
---
apiVersion: v1
kind: Namespace
metadata:
name: model-api
---
apiVersion: v1
kind: Namespace
metadata:
name: monitoring
30 changes: 27 additions & 3 deletions services/model-api/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,10 @@ def health():

# MNIST 데이터 λͺ¨λΈ 예츑 API
@app.post("/mnist_predict", response_model=ResponseSchema)
def predict(request: RequestSchema):
def mnist_predict(request: RequestSchema):
try:
service = get_service(request.type) # λͺ¨λΈμ— λ§žλŠ” service μ§€μ •
output = service.predict(request.data) # 예츑 결과
service = get_service(request.type)
output = service.predict(request.data)

return ResponseSchema(
success=True,
Expand All @@ -37,3 +37,27 @@ def predict(request: RequestSchema):
metadata=None,
error=str(e),
)


# 고객 μ΄νƒˆ 예츑 API
@app.post("/churn_predict", response_model=ResponseSchema)
def churn_predict(request: RequestSchema):
try:
service = get_service("churn")
output = service.predict(request.data)

return ResponseSchema(
success=True,
model="churn",
result=output["result"],
metadata=output["metadata"],
error=None,
)
except Exception as e:
return ResponseSchema(
success=False,
model="churn",
result=None,
metadata=None,
error=str(e),
)
20 changes: 18 additions & 2 deletions services/model-api/app/models/loader.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,17 @@
import os
import mlflow.pytorch
import mlflow.sklearn


_model_cache: dict = {}


# mlflowμ—μ„œ mnist λͺ¨λΈ λ‘œλ“œν•˜λŠ” ν•¨μˆ˜
def load_model(model_name: str, version: str):
cache_key = f"{model_name}_{version}"
if cache_key in _model_cache:
return _model_cache[cache_key]

mlflow_uri = os.getenv("MLFLOW_TRACKING_URI", "http://mlflow:5000")
mlflow_uri = os.getenv("MLFLOW_TRACKING_URI", "http://mlflow:5000") # ν™˜κ²½λ³€μˆ˜μ—μ„œ mlflow tracking uri κ°€μ Έμ˜€κΈ°, μ—†μœΌλ©΄ κΈ°λ³Έκ°’μœΌλ‘œ http://mlflow:5000 μ‚¬μš©
mlflow.set_tracking_uri(mlflow_uri)

model_uri = f"models:/{model_name}-{version}/latest"
Expand All @@ -19,3 +20,18 @@ def load_model(model_name: str, version: str):

_model_cache[cache_key] = model
return model

# mlflowμ—μ„œ 고객 μ΄νƒˆ 예츑 λͺ¨λΈ λ‘œλ“œν•˜λŠ” ν•¨μˆ˜
def load_churn_model(model_name: str, version: str):
cache_key = f"{model_name}_{version}_sklearn"
if cache_key in _model_cache:
return _model_cache[cache_key]

mlflow_uri = os.getenv("MLFLOW_TRACKING_URI", "http://mlflow:5000")
mlflow.set_tracking_uri(mlflow_uri)

model_uri = f"models:/{model_name}-{version}/latest"
model = mlflow.sklearn.load_model(model_uri)

_model_cache[cache_key] = model
return model
65 changes: 65 additions & 0 deletions services/model-api/app/services/churn_service.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import os
import time
from datetime import datetime, timezone

from app.models.loader import load_churn_model
from prometheus_client import Counter, Histogram

MODEL_NAME = "churn"
MODEL_VERSION = os.getenv("CHURN_MODEL_VERSION", "v5")

FEATURES = [
'account_age_months',
'avg_order_value',
'total_orders',
'days_since_last_purchase',
'discount_usage_rate',
'return_rate',
'browsing_frequency_per_week',
'cart_abandonment_rate',
]

prediction_counter = Counter(
"churn_predictions_total",
"μ΄νƒˆ 예츑 횟수",
["predicted"],
)
confidence_histogram = Histogram(
"churn_confidence_score",
"μ΄νƒˆ 예츑 신뒰도 뢄포",
buckets=[0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0],
)


def predict(data: dict) -> dict:
missing = [f for f in FEATURES if f not in data]
if missing:
raise ValueError(f"λˆ„λ½λœ ν”Όμ²˜: {missing}")

model = load_churn_model(MODEL_NAME, MODEL_VERSION)

x = [[data[f] for f in FEATURES]]

start = time.time()
predicted = int(model.predict(x)[0])
proba = model.predict_proba(x)[0]
confidence = float(proba[predicted])
time_ms = round((time.time() - start) * 1000, 3)

prediction_counter.labels(predicted=str(predicted)).inc()
confidence_histogram.observe(confidence)

return {
"result": {
"predicted_class": predicted,
"churned": bool(predicted),
"confidence": confidence,
"probabilities": {"retained": float(proba[0]), "churned": float(proba[1])},
},
"metadata": {
"model": MODEL_NAME,
"version": MODEL_VERSION,
"inference_time_ms": time_ms,
"timestamp": datetime.now(timezone.utc).isoformat(),
},
}
3 changes: 2 additions & 1 deletion services/model-api/app/services/service_factory.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
from app.services import mnist_service
from app.services import mnist_service, churn_service


_services = {
"mnist": mnist_service,
"churn": churn_service,
}

# λͺ¨λΈμ˜ μ’…λ₯˜μ— 맞게 service 파일 λΆˆλŸ¬μ˜€λŠ” λΌμš°ν„°
Expand Down
Loading
Loading