Автор: Дмитро Рєзєнков Дата: 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 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) спрацював перед тестами.
| Метод | Шлях | Опис | Тіло запиту | Відповідь |
|---|---|---|---|---|
| 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 |
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}IrisFeatures обмежує кожне поле ge=0, le=10. Помилкові тіла (рядок замість float, від'ємне значення, відсутнє поле) автоматично повертають 422 Unprocessable Entity з детальним описом — це покривається тестами test_predict_invalid_input і test_predict_out_of_range.
.github/workflows/ci.yml запускається на push та pull_request до main і складається з двох jobs:
- test —
actions/checkout@v4→setup-python@v5 (3.11, pip cache)→ install deps →python -m ml.train→pytest -q. - docker-build —
needs: 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 сек.
- Зареєструватися на render.com через GitHub (дозволити читання репозиторію).
- New → Web Service → вибрати цей репозиторій.
- Environment: Docker (Render автоматично підхопить
Dockerfile). - Region: Frankfurt (найближче до України).
- Instance Type: Free.
- Create Web Service → дочекатися завершення білду (~5–7 хвилин).
- Перевірити:
curl https://<your-url>/healthмає повернути{"status":"healthy","model_loaded":true}.
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).
Це триярусна ієрархія:
- 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 бере шар з кешу та йде далі.
Послідовність шарів:
FROM python:3.11-slim— базовий шар.ENV ...— невеликий шар.WORKDIR /app.COPY requirements.txt .— інвалідується лише при змініrequirements.txt.RUN pip install -r requirements.txt— інвалідується, якщо інвалідувався крок 4 (тобто майже ніколи; виконується ~60 секунд при першому build).COPY app ./app— інвалідується при будь-якій змініapp/.COPY ml ./ml.RUN python -m ml.train— швидко (~1 сек).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:
- Клієнт надсилає JSON:
{"sepal_length": "abc", ...}. - Starlette парсить тіло у
dict. - FastAPI знаходить, що handler-параметр анотований як
features: IrisFeatures— і викликаєIrisFeatures.model_validate(...). - Pydantic пробує перетворити
"abc"уfloat. Spesha:pydantic-coreспробує int/float coercion, але рядок не парситься як число — кидаєтьсяValidationError. - 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.
| Проблема | Рішення |
|---|---|
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 довший. |