Skip to content

RezenkovD/MLOps2

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

2 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

MLOps Лабораторна №2: CI/CD та ML API

CI

Автор: Дмитро Рєзєнков Дата: 2026-05-06

Примітка щодо варіанту: методичні вказівки до Лаб. №2 не містять окремих варіантів — для всіх студентів використовується класифікатор Iris (як у прикладах коду PDF).

Опис проєкту

Наскрізний MLOps-конвеєр для real-time inference: від навчання логістичної регресії на датасеті Iris до публічного REST API, упакованого в Docker та автоматично тестованого через GitHub Actions.

Стек технологій

Компонент Інструмент
Мова Python 3.11
ML scikit-learn 1.5.2, joblib 1.4.2
Web API FastAPI 0.115, Pydantic 2.9, Uvicorn 0.30
Тести pytest 8.3, httpx 0.27, FastAPI TestClient
Контейнер Docker (base: python:3.11-slim)
CI GitHub Actions (ubuntu-latest)
Деплой Render (Docker environment, free tier)

Структура репозиторію

ml-api-lab2/
├── .github/workflows/ci.yml     # GitHub Actions: test + docker-build
├── app/
│   ├── __init__.py
│   ├── main.py                  # FastAPI: /, /health, /predict
│   └── schemas.py               # Pydantic-моделі IrisFeatures, PredictionResponse
├── ml/
│   ├── __init__.py
│   └── train.py                 # train_and_save() -> model.joblib
├── tests/
│   ├── __init__.py
│   ├── conftest.py              # client fixture (TestClient + lifespan)
│   ├── test_model.py            # 3 unit-тести моделі
│   └── test_api.py              # 6 інтеграційних тестів API
├── Dockerfile
├── .dockerignore
├── .gitignore
├── requirements.txt
└── README.md

Як запустити локально

git clone <repo-url>
cd ml-api-lab2

python3 -m venv .venv
source .venv/bin/activate          # Windows: .venv\Scripts\activate
pip install -r requirements.txt

python -m ml.train                  # створить model.joblib (test accuracy 0.9667)
uvicorn app.main:app --reload       # http://localhost:8000

Інтерактивна документація — http://localhost:8000/docs (Swagger UI з можливістю надіслати запит у /predict).

Запуск через Docker

docker build -t ml-api:lab2 .
docker run --rm -p 8000:8000 ml-api:lab2
curl http://localhost:8000/health

Образ містить уже навчену model.joblib — крок RUN python -m ml.train виконується під час build, тому контейнер стартує миттєво без додаткових залежностей від тренувального коду.

Як запустити тести

pytest -q

Очікуваний результат: 9 passed.

Файл Тести
tests/test_model.py test_train_creates_model_file, test_model_predicts_three_classes, test_model_predict_proba_sums_to_one
tests/test_api.py test_root_endpoint, test_health_endpoint, test_predict_setosa, test_predict_virginica, test_predict_invalid_input, test_predict_out_of_range

tests/conftest.py містить session-fixture client, що використовує with TestClient(app) — це необхідно у FastAPI 0.115+, щоб lifespan (завантаження model.joblib) спрацював перед тестами.

Як працює API

Метод Шлях Опис Тіло запиту Відповідь
GET / Service info {"status":"ok","service":"Iris ML API"}
GET /health Liveness probe {"status":"healthy","model_loaded":true}
POST /predict Inference IrisFeatures (4 floats у см) PredictionResponse (class_id, class_name, probability)
GET /docs Swagger UI HTML

Приклад запиту до /predict

curl -X POST http://localhost:8000/predict \
  -H "Content-Type: application/json" \
  -d '{"sepal_length":5.1,"sepal_width":3.5,"petal_length":1.4,"petal_width":0.2}'
{"class_id": 0, "class_name": "setosa", "probability": 0.9784}

Pydantic-валідація

IrisFeatures обмежує кожне поле ge=0, le=10. Помилкові тіла (рядок замість float, від'ємне значення, відсутнє поле) автоматично повертають 422 Unprocessable Entity з детальним описом — це покривається тестами test_predict_invalid_input і test_predict_out_of_range.

CI/CD

.github/workflows/ci.yml запускається на push та pull_request до main і складається з двох jobs:

  1. testactions/checkout@v4setup-python@v5 (3.11, pip cache) → install deps → python -m ml.trainpytest -q.
  2. docker-buildneeds: test (стартує лише після зеленого тесту) → docker build -t ml-api:ci . (підтверджує коректність Dockerfile).

Бейдж стану CI на самому верху README.

Посилання на деплой

Ресурс URL
GitHub repo https://github.com/RezenkovD/MLOps2
Render service https://mlops2-phvc.onrender.com
Health check https://mlops2-phvc.onrender.com/health
Swagger UI https://mlops2-phvc.onrender.com/docs

Безкоштовний tier Render «засинає» при відсутності трафіку — перший запит після паузи може займати ~30 сек.

Deployment інструкції (Render)

  1. Зареєструватися на render.com через GitHub (дозволити читання репозиторію).
  2. New → Web Service → вибрати цей репозиторій.
  3. Environment: Docker (Render автоматично підхопить Dockerfile).
  4. Region: Frankfurt (найближче до України).
  5. Instance Type: Free.
  6. Create Web Service → дочекатися завершення білду (~5–7 хвилин).
  7. Перевірити: curl https://<your-url>/health має повернути {"status":"healthy","model_loaded":true}.

Контрольні питання

1. У чому полягає принципова різниця між Continuous Integration і Continuous Deployment?

CI (Continuous Integration) — практика, за якої кожна зміна в коді автоматично інтегрується у спільну гілку через build і запуск тестів. Зупиняється на артефакті, готовому до випуску, але не випускає його. Мета: впіймати regression у момент пушу, поки контекст ще свіжий у голові розробника.

CD (Continuous Deployment) — продовження CI, при якому кожен зелений артефакт автоматично розгортається у production без ручного підтвердження. Між ними існує проміжна форма — Continuous Delivery: артефакт автоматично готовий до релізу, але натиск кнопки робить людина (наприклад, релізний інженер).

Аспект CI Continuous Delivery Continuous Deployment
Останній крок run tests manual approve → deploy auto-deploy
Артефакт у проді після push ні після ручного approve так, одразу
Ручне втручання потрібне не потрібне

У цій лабораторній GitHub Actions реалізує CI (test + docker-build), а Render — Continuous Deployment (auto-redeploy при кожному push до main).

2. Поясніть взаємозв'язок понять Workflow, Job та Step у GitHub Actions.

Це триярусна ієрархія:

  • Workflow — увесь автоматизований процес, описаний YAML-файлом у .github/workflows/. Запускається подіями (on: push, on: pull_request, on: schedule...). Один репозиторій може мати кілька workflow для різних цілей (CI, security scan, release).
  • Job — логічно завершена частина workflow, що виконується на одному runner-і (віртуальній машині). У межах workflow jobs можуть йти паралельно (за замовчуванням) або послідовно через директиву needs:. Кожен job має чисте середовище — стан між jobs не зберігається, окрім явно завантажених артефактів.
  • Step — одна дія всередині job. Кроки виконуються послідовно в одному runner-і та мають доступ до спільної файлової системи. Step — це або shell-команда (run:), або переусний action із Marketplace (uses:).

У нашому workflow:

  • 1 workflow (ci.yml),
  • 2 jobs (test, docker-build — другий чекає на перший через needs: test),
  • у job test — 5 steps (checkout → setup-python → install → train → pytest).

3. Яку проблему розв'язує винесення завантаження моделі у хук @app.on_event("startup") (lifespan) у файлі app/main.py? Як змінилися б продуктивність і час відповіді, якщо б joblib.load(...) виконувався всередині функції predict?

Розв'язана проблема: ML-модель завантажується один раз при старті процесу, а не при кожному HTTP-запиті. Це гарантує:

  • мінімальний latency для /predict (немає disk I/O на гарячому шляху),
  • передбачувану поведінку /health (модель або є, або процес не запускається),
  • одну копію моделі в RAM на воркер uvicorn — економія пам'яті.

Якщо joblib.load буде всередині predict:

  • Latency: для логістичної регресії (~1 КБ) — додаткові ~5–20 мс на запит; для великих моделей (BERT-base ~500 МБ) — кілька сотень мс до кількох секунд.
  • Throughput: падає у разі (disk_read_time + cpu_inference_time) / (cpu_inference_time). На SSD з малою моделлю — у 2–3 рази; на HDD з великою — у десятки разів.
  • Витрати CPU: joblib.load для LogReg незначний, але для скриптів з custom-десеріалізацією може блокувати event loop, а у async-FastAPI — ще й сериалізувати запити.
  • Пам'ять: не зростає (Python звільнить попередній об'єкт через GC), але активний RSS флуктуює, що може провокувати OOM-kill під навантаженням.
  • Атомарність: найгірше — якщо файл моделі оновлюється на льоту (наприклад, retraining-job), різні запити отримають різні версії моделі в межах одного процесу.

У поточній реалізації використано lifespan (сучасна заміна on_event) — context manager, що завантажує модель у state["model"] під час startup і очищає при shutdown. Render використовує endpoint /health як liveness probe — він просто читає state["model"] is not None, не торкаючись диска.

4. Поясніть, чому у Dockerfile спочатку виконується COPY requirements.txt і встановлення залежностей, а лише потім — COPY коду. Як це впливає на час повторної збірки образу при зміні лише одного рядка у app/main.py?

Docker будує образ пошарово. Кожен новий шар обчислюється з хешу інструкції + хешу контексту, що вона торкає. Якщо хеш співпав з попереднім build — Docker бере шар з кешу та йде далі.

Послідовність шарів:

  1. FROM python:3.11-slim — базовий шар.
  2. ENV ... — невеликий шар.
  3. WORKDIR /app.
  4. COPY requirements.txt . — інвалідується лише при зміні requirements.txt.
  5. RUN pip install -r requirements.txt — інвалідується, якщо інвалідувався крок 4 (тобто майже ніколи; виконується ~60 секунд при першому build).
  6. COPY app ./app — інвалідується при будь-якій зміні app/.
  7. COPY ml ./ml.
  8. RUN python -m ml.train — швидко (~1 сек).
  9. CMD [...].

Зміна одного рядка у app/main.py:

  • Кроки 1–5 — cache hit, шари перевикористовуються (включно з 60-секундним pip install).
  • Крок 6 — інвалідовано, виконується (мс).
  • Кроки 7–9 — теж перевиконуються, але вони дешеві.
  • Загальний час повторної збірки: ~2–5 секунд замість ~70 секунд.

Якби порядок був інвертований (COPY . . до встановлення залежностей) — кожна зміна коду інвалідувала б шар із залежностями, і pip install запускався б щоразу. На CI з лімітованим часом (особливо free tier) це недопустимо.

Це класичний прийом Docker layer caching: повільні + рідко-змінні інструкції розміщуються вище, швидкі + часто-змінні — нижче.

5. Як FastAPI обробить ситуацію, коли клієнт надсилає у /predict тіло з рядком замість числа (наприклад, "sepal_length": "abc")? Яку роль у цьому відіграє Pydantic-схема IrisFeatures та який саме HTTP-код буде повернуто?

Послідовність обробки запиту FastAPI:

  1. Клієнт надсилає JSON: {"sepal_length": "abc", ...}.
  2. Starlette парсить тіло у dict.
  3. FastAPI знаходить, що handler-параметр анотований як features: IrisFeatures — і викликає IrisFeatures.model_validate(...).
  4. Pydantic пробує перетворити "abc" у float. Spesha: pydantic-core спробує int/float coercion, але рядок не парситься як число — кидається ValidationError.
  5. FastAPI ловить ValidationError у глобальному exception handler і повертає HTTP 422 Unprocessable Entity з тілом виду:
{
  "detail": [
    {
      "type": "float_parsing",
      "loc": ["body", "sepal_length"],
      "msg": "Input should be a valid number, unable to parse string as a number",
      "input": "abc"
    }
  ]
}

Роль IrisFeatures:

  • Контракт API — Pydantic-схема описує очікувану структуру тіла; вона ж генерує OpenAPI-схему для /docs.
  • Автоматична валідація — без єдиного рядка у predict() тіло перевіряється на типи (float), діапазони (ge=0, le=10) і обов'язковість (... у Field(...)). Тести test_predict_invalid_input (рядок замість float) і test_predict_out_of_range (від'ємне значення) це підтверджують.
  • Документація — поле description="cm" у Field потрапляє у Swagger UI.
  • Безпека — handler ніколи не отримує невалідні дані, що захищає від exception-ів усередині predict і від injection-векторів.

Семантика коду 422 (а не 400 чи 500): запит синтаксично коректний JSON, але семантично невалідний за схемою. Це відповідає RFC 4918 та конвенціям REST API.

Troubleshooting

Проблема Рішення
RuntimeError: Model file not found при старті API Спочатку: python -m ml.train. У Docker — крок RUN python -m ml.train уже вшито у Dockerfile.
503 Model is not loaded від /predict Lifespan не спрацював. Переконатися, що тести використовують with TestClient(app) as client через fixture у conftest.py.
422 на правильному JSON Перевірити поля та діапазон 0–10; FastAPI повертає у detail точне поле, що завалилось.
Render перший запит йде ~30 сек Free tier «засинає» після ~15 хв простою. Альтернатива — keep-alive ping (наприклад, через UptimeRobot).
pip install падає у CI Перевірити кеш actions/setup-python@v5 cache: pip — інколи перший запуск після зміни requirements.txt довший.

About

No description, website, or topics provided.

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors