15 Коммитов

Автор SHA1 Сообщение Дата
99d81722c4 docs: добавить общее описание лабораторного проекта 2025-12-08 23:03:04 +03:00
6fe4557041 docs: добавить примеры мониторинга 2025-12-08 23:03:04 +03:00
667b6ac966 docs: базовая документация всего сервиса и развёртывания 2025-12-08 23:03:04 +03:00
434f4776b1 fix: сделать доступ из контейнера к модели read-only 2025-12-08 23:03:03 +03:00
296471990a Merge branch 'lab_3/master' into lab_4/wip 2025-12-08 23:02:47 +03:00
e232b693ca использовать именованные volume-ы для БД Prometheus и Grafana 2025-12-08 22:57:08 +03:00
9a6b531951 неудачная попытка вытащить БД из Prometheus (см. примечания в compose.yaml) 2025-12-08 22:57:08 +03:00
aade830c20 refactor: в compose запускать тестеры только с профилем 'with-testers' 2025-12-08 22:57:08 +03:00
9780f6e710 fix: предотвращать аварийный выход load-tester при недоступности prices-predictor во время его потенциально долгого запуска через compose 2025-12-08 22:57:08 +03:00
fd9a932e6c feat: добавить конфигурацию grafana и включить её в compose 2025-12-08 22:57:06 +03:00
91e7437e29 feat: файл compose (prices predictor + load testers + prometheus) 2025-12-08 22:56:25 +03:00
b880d2d699 feat: интеграция prometheus в ml_service, конфигурация prometheus 2025-12-08 22:55:59 +03:00
813f622579 feat: load tester 2025-12-08 22:55:02 +03:00
e43c47dae6 fix: путь к README для сервиса ml_service 2025-12-08 20:25:02 +03:00
322e02aa0b добавлены методы вызовов HTTP в README 2025-11-29 09:43:04 +03:00
24 изменённых файлов: 1185 добавлений и 76 удалений

2
.gitignore поставляемый
Просмотреть файл

@@ -10,3 +10,5 @@ __pycache__/
# virtual environments
.venv/
.venv*/
# .env files
*.env

Просмотреть файл

@@ -2,6 +2,12 @@
**Выполняет**: **Сыропятов В.В.** (А-01м-24)
Реализация цикла разработки ML-сервиса:
* подготовка и исследование данных ([Jupyter](https://jupyter.org/), scientific Python),
* создание модели ML ([MLFlow](https://mlflow.org/), [scikit-learn](https://scikit-learn.org/stable/)),
* разработка REST-сервиса для выполнения модели ([FastAPI](https://fastapi.tiangolo.com/) (недоступна на 2025‑12‑08)),
* конфигурация инфрастуктуры сервиса с мониторингом ([Docker](https://www.docker.com/) (частично недоступна на 2025‑12‑08)), [Docker Compose](https://docs.docker.com/compose/) (частично недоступна на 2025‑12‑08)), [Prometheus](https://prometheus.io/) (недоступна на 2025‑12‑08)), [Grafana OSS](https://grafana.com/)).
## Данные
Используемый датасет: [Car price prediction(used cars)
@@ -10,7 +16,7 @@
## Сервис предсказания цен
См. `services/ml_service.md`.
См. `services/README.md`.
## Исследовательская часть проекта

169
services/README.md Обычный файл
Просмотреть файл

@@ -0,0 +1,169 @@
# Сервис предсказания цен
Веб-сервис предсказания цен на подержанные автомобили. Мониторинг в комплекте.
Обзор сервисов (по `compose.yaml`, см. о развёртывании ниже):
| Профили Compose | Имя | Объекты | Описание |
|-----------------|------------------|------------------|------------------|
| — | `prices-predictor` | код: `ml_service/` | Веб-сервис предсказания цен, только stateless API. Об используемой предсказательной модели см. `research/README.md`. |
| — | `prometheus` | конфигурация: `prometheus/` | Мониторинг сервиса ([Prometheus](https://prometheus.io/)). |
| — | `grafana` | сохранённая конфигурация: `grafana/` | Аналитика и визуализация данных мониторига сервиса ([Grafana](https://grafana.com/)). |
| `with-testers` | `load-tester` | код: `load-tester/` | Генератор потока случайных запросов к `prices-predictor` для тестирования. |
Дополнительно:
* `models/` — расположение файла модели `model.pkl` для использования сервисом `prices-predictor`.
* `fetch_model_as_pickle_from_mlflow.py` — скрипт для экспорта предиктивной модели scikit-learn из MLFlow в файл.
## API сервиса предсказания цен
**Базовый URL**: `/api`. Все указанные далее URL записаны **относительно базового URL**, если не указано иное.
* Полная интерактивная документация (Swagger UI): `/docs`.
* Предсказать цену подержанного автомобиля: `/predict` (POST).
Пример запроса:
* requst query: `item_id=16` (параметр `item_id` необходим!);
* request body:
```json
{
"selling_price": 5.59,
"driven_kms": 27000.0,
"age": 5.0,
"fuel_type": "petrol",
"selling_type": "dealer",
"transmission_type": "manual"
}
```
* response body:
```json
{
"item_id": 16,
"price": 3.743508852258851
}
```
* Тестовый эндпоинт: `/` (GET).
Возвращает простой демонстрационный объект JSON.
Может использоваться для проверки состояния сервиса.
## Мониторинг
### Prometheus UI
#### Примеры запросов
Гистограмма предсказанных цен `model_prediction_value_bucket` (запрос: `rate(model_prediction_value_bucket[5m]`):
![Гистограмма предсказанных цен как временные ряды](docs/screenshot-prometheus-query-model-1.png)
Гистограмма продолжительности предсказания цен моделью ML `model_prediction_seconds_bucket` (запрос: `rate(model_prediction_seconds_bucket[5m]`):
![Гистограмма продолжительности предсказания цен моделью ML как временные ряды](docs/screenshot-prometheus-query-model-2.png)
Интенсивность потока запросов к сервису предсказания цен с разными результатами (успех — коды HTTP `2xx`, ошибки со стороны клиента — коды HTTP `4xx`) `http_requests_total{handler="/predict"}` (запрос: `rate(http_requests_total{handler="/predict"}[5m]`):
![Интенсивность потока запросов к сервису предсказания цен с разными результатами](docs/screenshot-prometheus-query-http-1.png)
Интенсивность потока запросов к **веб-серверу** сервиса предсказания цен **с ошибками** `http_requests_total{handler="/predict"}` (запрос: `sum without(handler, method) (rate(http_requests_total{status=~"4..|5.."}[5m]))`):
![Интенсивность потока запросов к веб-серверу сервиса предсказания цен, заканчивающихся ошибками](docs/screenshot-prometheus-query-http-1.png)
### Дашборд в Grafana
Дашборд экспортирован в файл: `grafana/objects/dashboard-1765200932880.json`.
![Дашборд в Grafana](docs/screenshot-grafana-dashboard.png)
Элементы:
* мониторинг модели:
* гистограмма распределения предсказанных цен за период времени (10 мин);
* прикладной уровень:
* интенсивность потока запросов (всех запросов; запросов, заканчивающихся ошибкой);
* инфраструктурный уровень:
* состояние сервиса (up/down);
* выделенный процессу объём VRAM.
## Развёртывание
### Файл модели
Файл используемой предсказательной модели можно извлечь из MLFlow скриптом `models/fetch_model_as_pickle_from_mlflow.py`. Файл модели можно размещается в `models/model.pkl`.
Например, извлечь модель по имени (`<model-name>`) и версии (`<model-version>`) (например, `UsedCardPricePredictionFinal/1`) (команда запускается из корневой директории проекта &mdash; от этого зависит путь к создаваемому файлу):
python services/models/fetch_model_as_pickle_from_mlflow.py --model "models:/<model-name>/<model-version>" services/models/model.pkl
Можно указать адрес tracking сервера MLFlow, например: `--tracking-uri "http://localhost:5000"`.
Информация о других опциях доступна:
python services/models/fetch_model_as_pickle_from_mlflow.py --help
### Образы Docker
#### `ml_model` (для сервиса `prices-predictor`)
**Сборка образа** (замените `<version>` на номер версии) (команда запускается из корневой директории проекта &mdash; от этого зависит путь к директории):
docker build -t ml_service:<version> services/ml_service/
**Независимый запуск** (замените `<version>` на номер версии образа, `<models-dir>` на **абсолютный** путь к директории, где размещён файл предсказательной модели `model.pkl`, `<port>` на порт для запуска веб-сервиса (например, `8000`)):
docker run -v "<models-dir>:/models" -p <port>:8000 ml_service:<version>
Модель может быть размещена в `models/`; тогда, например, при запуске команды из корна проекта: `$(pwd)/services/models` (здесь `$(pwd)` используется потому, что необходим абсолютный путь).
#### `load-tester` (для сервиса `load-tester`)
**Сборка образа** (замените `<version>` на номер версии) (команда запускается из корневой директории проекта &mdash; от этого зависит путь к директории):
docker build -t load_tester:<version> services/load_tester/
**Независимый запуск** (замените `<version>` на номер версии образа, `<api-base-url>` на базовый URL сервиса `prices-predictor` (например, `http://prices-predictor:8000/api`)):
docker run -e "API_BASE_URL=<api-base-url>" ml_service:<version>
### Развёртывание сервиса посредством Compose
Конфигурация описана в файле `compose.yaml`. Имя системы: `mpei-iis-system`.
Рекомендуется (не обязательно) использовать env-файл `compose.env`. Используйте файл `compose.env.template` как шаблон.
**Директория `models/` используется сервисом `prices-predictor` как том** с файлом модели `model.pkl`. Поместите туда файл модели, см. [Файл модели](#файл-модели).
**Управление сервисом с мониторингом** (замените `<command>` и `[options...]`):
docker compose -f services/compose.yaml --env-file services/compose.env <command> [options...]
**Для запуска вместе с генераторами тестовых запросов** используйте опцию compose `--profile=with-tester`.
Основные команды `docker compose`:
* `up`: создать и запустить контейнеры (также тома, сети и прочее); оставляет вывод логов прикреплённым к терминалу, `SIGINT` останавливает контейнеры, **но не удаляет созданные объекты**;
* опция `-d`: то же, но открепляет процесс от терминала.
* `down`: остановить и удалить контейнеры (также сети и прочее; для удаления томов используйте опцию `-v`).
* `start`: запустить существующие контейнеры.
* `stop`: остановить контейнеры.
* `restart`: перезапустить контейнеры.
**Открытые на хосте интерфейсы**:
* `localhost:8010`: Сервис `prices-predictor`. Базовый URL: `/api`.
* `localhost:9090`: UI Prometheus.
* `localhost:3000`: Grafana.
**Доступные на хосте тома**:
* `mpei-iis-system_prometheus-storage`: БД Prometheus.
* `mpei-iis-system_grafana-storage`: БД Grafana.

2
services/compose.env.template Обычный файл
Просмотреть файл

@@ -0,0 +1,2 @@
GF_SECURITY_ADMIN_USER=admin
GF_SECURITY_ADMIN_PASSWORD=admin

64
services/compose.yaml Обычный файл
Просмотреть файл

@@ -0,0 +1,64 @@
name: mpei-iis-system
services:
prices-predictor:
image: ml_service:2
ports:
- "8010:8000"
volumes:
- './models:/models:ro'
load-tester:
image: load_tester:1
environment:
API_BASE_URL: "http://prices-predictor:8000/api"
# XXX: Предотвращает аварийный выход тестера при отсутствии ответа от prices-predictor
# во время его (потенциально долгого) запуска.
depends_on:
- prices-predictor
deploy:
replicas: 2
profiles:
- "with-testers"
prometheus:
image: prom/prometheus:v3.7.3
ports:
- "9090:9090"
user: nobody
command:
- "--config.file=/etc/prometheus/prometheus.yaml"
volumes:
- "./prometheus/prometheus.yaml:/etc/prometheus/prometheus.yaml:ro"
- "prometheus-storage:/prometheus"
grafana:
image: grafana/grafana:12.4.0-20012734117
ports:
- "3000:3000"
#environment:
# GF_SECURITY_ADMIN_USER: "$__file{/run/secrets/grafana-admin-user}"
# GF_SECURITY_ADMIN_PASSWORD: "$__file{/run/secrets/grafana-admin-password}"
#secrets:
# - grafana-admin-user
# - grafana-admin-password
environment:
GF_SECURITY_ADMIN_USER: "${GF_SECURITY_ADMIN_USER:-admin}"
GF_SECURITY_ADMIN_PASSWORD: "${GF_SECURITY_ADMIN_PASSWORD:-admin}"
volumes:
- "grafana-storage:/var/lib/grafana"
volumes:
prometheus-storage: {}
grafana-storage: {}
#secrets:
#
# grafana-admin-user:
# environment: GF_SECURITY_ADMIN_USER
#
# grafana-admin-password:
# environment: GF_SECURITY_ADMIN_PASSWORD

Двоичные данные
services/docs/screenshot-grafana-dashboard.png Обычный файл

Двоичный файл не отображается.

После

Ширина:  |  Высота:  |  Размер: 164 KiB

Двоичные данные
services/docs/screenshot-prometheus-query-http-1.png Обычный файл

Двоичный файл не отображается.

После

Ширина:  |  Высота:  |  Размер: 87 KiB

Двоичные данные
services/docs/screenshot-prometheus-query-http-2.png Обычный файл

Двоичный файл не отображается.

После

Ширина:  |  Высота:  |  Размер: 78 KiB

Двоичные данные
services/docs/screenshot-prometheus-query-model-1.png Обычный файл

Двоичный файл не отображается.

После

Ширина:  |  Высота:  |  Размер: 115 KiB

Двоичные данные
services/docs/screenshot-prometheus-query-model-2.png Обычный файл

Двоичный файл не отображается.

После

Ширина:  |  Высота:  |  Размер: 99 KiB

1
services/grafana/.gitattributes поставляемый Обычный файл
Просмотреть файл

@@ -0,0 +1 @@
objects/*.json -text

Просмотреть файл

@@ -0,0 +1,543 @@
{
"apiVersion": "dashboard.grafana.app/v2beta1",
"kind": "Dashboard",
"metadata": {
"name": "adcdfv7",
"generation": 9,
"creationTimestamp": "2025-12-08T12:51:53Z",
"labels": {},
"annotations": {}
},
"spec": {
"annotations": [
{
"kind": "AnnotationQuery",
"spec": {
"builtIn": true,
"enable": true,
"hide": true,
"iconColor": "rgba(0, 211, 255, 1)",
"name": "Annotations & Alerts",
"query": {
"group": "grafana",
"kind": "DataQuery",
"spec": {},
"version": "v0"
}
}
}
],
"cursorSync": "Off",
"editable": true,
"elements": {
"panel-1": {
"kind": "Panel",
"spec": {
"data": {
"kind": "QueryGroup",
"spec": {
"queries": [
{
"kind": "PanelQuery",
"spec": {
"hidden": false,
"query": {
"group": "prometheus",
"kind": "DataQuery",
"spec": {
"editorMode": "builder",
"exemplar": false,
"expr": "process_virtual_memory_bytes",
"instant": false,
"interval": "10s",
"legendFormat": "__auto",
"range": true
},
"version": "v0"
},
"refId": "A"
}
}
],
"queryOptions": {},
"transformations": []
}
},
"description": "",
"id": 1,
"links": [],
"title": "Выделенный объём VRAM",
"vizConfig": {
"group": "timeseries",
"kind": "VizConfig",
"spec": {
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"axisBorderShow": false,
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"barWidthFactor": 0.6,
"drawStyle": "bars",
"fillOpacity": 0,
"gradientMode": "none",
"hideFrom": {
"legend": false,
"tooltip": false,
"viz": false
},
"insertNulls": false,
"lineInterpolation": "stepAfter",
"lineWidth": 1,
"pointSize": 5,
"scaleDistribution": {
"type": "linear"
},
"showPoints": "auto",
"showValues": false,
"spanNulls": false,
"stacking": {
"group": "A",
"mode": "none"
},
"thresholdsStyle": {
"mode": "off"
}
},
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": 0
},
{
"color": "red",
"value": 80
}
]
},
"unit": "bytes"
},
"overrides": []
},
"options": {
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"hideZeros": false,
"mode": "single",
"sort": "none"
}
}
},
"version": "12.4.0-20012734117"
}
}
},
"panel-2": {
"kind": "Panel",
"spec": {
"data": {
"kind": "QueryGroup",
"spec": {
"queries": [
{
"kind": "PanelQuery",
"spec": {
"hidden": false,
"query": {
"group": "prometheus",
"kind": "DataQuery",
"spec": {
"editorMode": "builder",
"expr": "up",
"legendFormat": "__auto",
"range": true
},
"version": "v0"
},
"refId": "A"
}
}
],
"queryOptions": {},
"transformations": []
}
},
"description": "",
"id": 2,
"links": [],
"title": "Состояние",
"vizConfig": {
"group": "state-timeline",
"kind": "VizConfig",
"spec": {
"fieldConfig": {
"defaults": {
"color": {
"mode": "continuous-GrYlRd"
},
"custom": {
"axisPlacement": "auto",
"fillOpacity": 70,
"hideFrom": {
"legend": false,
"tooltip": false,
"viz": false
},
"insertNulls": false,
"lineWidth": 0,
"spanNulls": false
},
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": 0
}
]
},
"unit": "bool_on_off"
},
"overrides": []
},
"options": {
"alignValue": "left",
"legend": {
"displayMode": "list",
"placement": "bottom",
"showLegend": true
},
"mergeValues": true,
"rowHeight": 0.9,
"showValue": "auto",
"tooltip": {
"hideZeros": false,
"mode": "single",
"sort": "none"
}
}
},
"version": "12.4.0-20012734117"
}
}
},
"panel-3": {
"kind": "Panel",
"spec": {
"data": {
"kind": "QueryGroup",
"spec": {
"queries": [
{
"kind": "PanelQuery",
"spec": {
"hidden": false,
"query": {
"group": "prometheus",
"kind": "DataQuery",
"spec": {
"editorMode": "builder",
"expr": "sum without(instance, method, status) (rate(http_requests_total{handler=\"/predict\"}[$__rate_interval]))",
"interval": "1m",
"legendFormat": "__auto",
"range": true
},
"version": "v0"
},
"refId": "A"
}
},
{
"kind": "PanelQuery",
"spec": {
"hidden": false,
"query": {
"group": "prometheus",
"kind": "DataQuery",
"spec": {
"editorMode": "builder",
"expr": "rate(http_requests_total{handler=\"/predict\", status=~\"4..|5..\"}[$__rate_interval])",
"instant": false,
"interval": "1m",
"legendFormat": "__auto",
"range": true
},
"version": "v0"
},
"refId": "B"
}
}
],
"queryOptions": {},
"transformations": []
}
},
"description": "",
"id": 3,
"links": [],
"title": "HTTP-запросы",
"vizConfig": {
"group": "timeseries",
"kind": "VizConfig",
"spec": {
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"axisBorderShow": false,
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"barWidthFactor": 0.6,
"drawStyle": "line",
"fillOpacity": 0,
"gradientMode": "none",
"hideFrom": {
"legend": false,
"tooltip": false,
"viz": false
},
"insertNulls": false,
"lineInterpolation": "linear",
"lineWidth": 1,
"pointSize": 5,
"scaleDistribution": {
"type": "linear"
},
"showPoints": "auto",
"showValues": false,
"spanNulls": false,
"stacking": {
"group": "A",
"mode": "none"
},
"thresholdsStyle": {
"mode": "off"
}
},
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": 0
}
]
},
"unit": "reqps"
},
"overrides": []
},
"options": {
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"hideZeros": false,
"mode": "single",
"sort": "none"
}
}
},
"version": "12.4.0-20012734117"
}
}
},
"panel-4": {
"kind": "Panel",
"spec": {
"data": {
"kind": "QueryGroup",
"spec": {
"queries": [
{
"kind": "PanelQuery",
"spec": {
"hidden": false,
"query": {
"group": "prometheus",
"kind": "DataQuery",
"spec": {
"editorMode": "builder",
"exemplar": false,
"expr": "sum without(instance) (increase(model_prediction_value_bucket[10m])) / on() group_left sum(increase(model_prediction_value_count[10m]))",
"format": "heatmap",
"instant": false,
"interval": "10m",
"legendFormat": "<{{le}}",
"range": true
},
"version": "v0"
},
"refId": "A"
}
}
],
"queryOptions": {},
"transformations": []
}
},
"description": "Подпись под каждым столбцом обозначает его максимальное соответствующее значение",
"id": 4,
"links": [],
"title": "Предсказанные цены за 10 минут",
"vizConfig": {
"group": "bargauge",
"kind": "VizConfig",
"spec": {
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": 0
}
]
},
"unit": "percentunit"
},
"overrides": []
},
"options": {
"displayMode": "gradient",
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom",
"showLegend": false
},
"maxVizHeight": 300,
"minVizHeight": 16,
"minVizWidth": 8,
"namePlacement": "auto",
"orientation": "auto",
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"showUnfilled": false,
"sizing": "auto",
"valueMode": "color"
}
},
"version": "12.4.0-20012734117"
}
}
}
},
"layout": {
"kind": "GridLayout",
"spec": {
"items": [
{
"kind": "GridLayoutItem",
"spec": {
"element": {
"kind": "ElementReference",
"name": "panel-4"
},
"height": 8,
"width": 12,
"x": 0,
"y": 0
}
},
{
"kind": "GridLayoutItem",
"spec": {
"element": {
"kind": "ElementReference",
"name": "panel-2"
},
"height": 8,
"width": 12,
"x": 12,
"y": 0
}
},
{
"kind": "GridLayoutItem",
"spec": {
"element": {
"kind": "ElementReference",
"name": "panel-3"
},
"height": 8,
"width": 12,
"x": 0,
"y": 8
}
},
{
"kind": "GridLayoutItem",
"spec": {
"element": {
"kind": "ElementReference",
"name": "panel-1"
},
"height": 8,
"width": 12,
"x": 12,
"y": 8
}
}
]
}
},
"links": [],
"liveNow": false,
"preload": false,
"tags": [],
"timeSettings": {
"autoRefresh": "30s",
"autoRefreshIntervals": [
"5s",
"10s",
"30s",
"1m",
"5m",
"15m",
"30m",
"1h",
"2h",
"1d"
],
"fiscalYearStartMonth": 0,
"from": "now-1h",
"hideTimepicker": false,
"timezone": "browser",
"to": "now"
},
"title": "Сервис предсказания цен",
"variables": []
},
"status": {}
}

6
services/load_tester/.dockerignore Обычный файл
Просмотреть файл

@@ -0,0 +1,6 @@
### Python
__pycache__/
*.pyc
### Project
*.unused

13
services/load_tester/Dockerfile Обычный файл
Просмотреть файл

@@ -0,0 +1,13 @@
FROM python:3.11-slim
WORKDIR /load_tester
COPY ./requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["python", "-m", "tester"]
# docker build -t load_tester:1 services/load_tester/
# docker run -e "API_BASE_URL=http://prices-predictor:8000/api" load_tester:1

1
services/load_tester/requirements.txt Обычный файл
Просмотреть файл

@@ -0,0 +1 @@
requests >=2.32.5,<3

280
services/load_tester/tester.py Обычный файл
Просмотреть файл

@@ -0,0 +1,280 @@
from argparse import ArgumentParser
from collections.abc import Callable, MutableMapping
from dataclasses import dataclass, asdict
from enum import Enum
import logging
from os import getenv
from random import randint, uniform, expovariate, choice
from signal import SIGINT, SIGTERM, signal
import sys
from time import sleep
from types import FrameType
from typing import Any, cast
from requests import RequestException, Response, Session
def fixup_payload_enum_value(mapping: MutableMapping[str, Any], key: str) -> None:
mapping[key] = mapping[key].value
ENDPOINT_URL: str = '/predict'
class FuelType(Enum):
PETROL = 'petrol'
DIESEL = 'diesel'
CNG = 'cng'
class SellingType(Enum):
DEALER = 'dealer'
INDIVIDUAL = 'individual'
class TransmissionType(Enum):
MANUAL = 'manual'
AUTOMATIC = 'automatic'
@dataclass
class PricePredictionFeatures:
selling_price: float
driven_kms: float
age: float
fuel_type: FuelType
selling_type: SellingType
transmission_type: TransmissionType
MAX_RETRIES_DEFAULT = 3
def exp_delay_from_attempt_number(attempt_i: int) -> float:
return 0.2 * (2 ** attempt_i)
def post_item(
session: Session, url: str, item_id: int, features: PricePredictionFeatures,
*, max_retries: int = MAX_RETRIES_DEFAULT,
) -> Response:
if max_retries < 0:
raise ValueError('max_retries must be >= 0')
payload = asdict(features)
for k in ('fuel_type', 'selling_type', 'transmission_type'):
fixup_payload_enum_value(payload, k)
excs = []
for attempt_i in range(max_retries + 1):
try:
response = session.post(url, params={'item_id': item_id}, json=payload, timeout=10)
except RequestException as err:
excs.append(err)
sleep(exp_delay_from_attempt_number(attempt_i))
else:
return response
assert len(excs) > 0
# XXX: ...
raise IOError(
f'Failed to post an item in {max_retries + 1} attempts;'
' see the latest exception in __cause__'
) from excs[-1]
def generate_request_data() -> tuple[int, PricePredictionFeatures]:
item_id = randint(1, 100)
features = PricePredictionFeatures(
selling_price=round(uniform(2.0, 16.0), 2),
driven_kms=round(uniform(0.0, 100000.0), 0),
age=round(uniform(0.0, 10.0), 1),
fuel_type=choice(list(FuelType)),
selling_type=choice(list(SellingType)),
transmission_type=choice(list(TransmissionType)),
)
return (item_id, features)
INTERVAL_MEAN_DEFAULT = 4.0
INTERVAL_BOUNDS_DEFAULT: tuple[float | None, float | None] = (0.5, 10.0)
class Requester:
def __init__(
self,
base_url: str,
interval_mean: float = INTERVAL_MEAN_DEFAULT,
interval_bounds: tuple[float | None, float | None] = INTERVAL_BOUNDS_DEFAULT,
*, max_retries: int = MAX_RETRIES_DEFAULT,
):
self.base_url = base_url
self.interval_mean = interval_mean
self.interval_bounds = interval_bounds
self.max_retries = max_retries
self._session = Session()
self._stop_requested: bool = False
@property
def endpoint(self) -> str:
endpoint_url = ENDPOINT_URL
if (len(endpoint_url) > 0) and (not endpoint_url.startswith('/')):
endpoint_url = '/' + endpoint_url
return (self.base_url + endpoint_url)
@property
def session(self) -> Session:
return self._session
@property
def stop_requested(self) -> bool:
return self._stop_requested
def stop(self) -> None:
self._stop_requested = True
def _decide_delay(self) -> float:
interval_bounds = self.interval_bounds
val = expovariate(1. / self.interval_mean)
if interval_bounds[0] is not None:
val = max(val, interval_bounds[0])
if interval_bounds[1] is not None:
val = min(val, interval_bounds[1])
return val
def run(self) -> None:
while not self._stop_requested:
item_id, features = generate_request_data()
try:
response = post_item(
self._session, self.endpoint, item_id, features, max_retries=self.max_retries,
)
except IOError as err:
logging.warning('%s: %s', str(err), str(err.__cause__))
raise err
else:
logging.debug('Success: %s %s', response.status_code, response.reason)
sleep(self._decide_delay())
def _build_termination_handler(requester: Requester) -> Callable[[int, FrameType | None], None]:
def termination_handler(sig: int, frame: FrameType | None) -> None:
_ = sig
_ = frame
requester.stop()
return termination_handler
def _configure_logging(level: int, quiet: bool) -> None:
if quiet:
level = logging.CRITICAL + 1
logging.basicConfig(
level=level, format='%(asctime)s %(levelname)s %(message)s', stream=sys.stderr,
)
def _setup_signal_handlers(requester: Requester) -> None:
termination_handler = _build_termination_handler(requester)
for sig in (SIGINT, SIGTERM):
signal(sig, termination_handler)
def _validate_cli_interval_bound(string: str) -> float | None:
string = string.lower()
if string in ('', 'null', 'none'):
return None
return float(string)
def _validate_cli_interval_bounds(string: str) -> tuple[float | None, float | None]:
string = string.lower()
if string in ('', 'null', 'none'):
return (None, None)
min_string, max_string = string.split(',', 1)
return cast(
tuple[float | None, float | None],
tuple(map(_validate_cli_interval_bound, (min_string, max_string)))
)
def _validate_cli_max_retries(string: str) -> int:
val = int(string)
if val < 0:
raise ValueError(f'Max retries should be >=0, given {val}')
return val
def _validate_cli_logging_level(string: str) -> int:
return {
'debug': logging.DEBUG,
'info': logging.INFO,
'warning': logging.WARNING,
'error': logging.ERROR,
'critical': logging.CRITICAL,
}[string]
def parse_args(argv):
parser = ArgumentParser(
description=(
'Регулярная отправка POST-запросов на эндпоинт предсказания цены.'
' Остановка по SIGINT / SIGTERM.'
),
allow_abbrev=False,
exit_on_error=True,
)
parser.add_argument('base_url', type=str, nargs='?')
parser.add_argument('--interval-mean', type=float, dest='interval_mean')
parser.add_argument(
'--interval-bounds', type=_validate_cli_interval_bounds, dest='interval_bounds',
)
parser.add_argument(
'--max-retries',
type=_validate_cli_max_retries,
default=MAX_RETRIES_DEFAULT,
dest='max_retries',
)
parser.add_argument('-q', '--quiet', action='store_true', dest='quiet')
parser.add_argument(
'--log-level',
default=logging.WARNING,
type=_validate_cli_logging_level,
dest='logging_level',
)
args = parser.parse_args(argv[1:])
if args.base_url is None:
args.base_url = getenv('API_BASE_URL')
if args.base_url is None:
raise RuntimeError('No API base URL specified')
if (args.interval_mean is not None) and (args.interval_mean <= 0):
raise ValueError(f'Interval mean should be > 0, given {args.interval_mean}')
if (
(args.interval_bounds is not None)
and all((b is not None) for b in args.interval_bounds)
and (args.interval_bounds[0] > args.interval_bounds[1])
):
raise ValueError(f'Interval bounds should be b_1 <= b_2, given {args.interval_bounds!r}')
if args.interval_mean is not None:
if args.interval_bounds is None:
args.interval_bounds = ((args.interval_mean / 5), (args.interval_mean * 5))
else:
args.interval_mean = INTERVAL_MEAN_DEFAULT
args.interval_bounds = INTERVAL_BOUNDS_DEFAULT
return args
def main(argv):
args = parse_args(argv)
_configure_logging(args.logging_level, args.quiet)
logging.debug('Creating a Requester with base URL: %s', args.base_url)
requester = Requester(
args.base_url,
interval_mean=args.interval_mean,
interval_bounds=args.interval_bounds,
max_retries=args.max_retries,
)
_setup_signal_handlers(requester)
requester.run()
return 0
if __name__ == '__main__':
sys.exit(int(main(sys.argv) or 0))

Просмотреть файл

@@ -0,0 +1,37 @@
from os import (
getenv,
#kill,
)
#from signal import SIGINT, SIGTERM
from subprocess import (
run,
#Popen,
)
import sys
#_child_proc: Popen | None = None
#def _forward_signal(sig, frame):
# _ = frame
# if _child_proc is None:
# return
# if _child_proc.pid in (SIGINT, SIGTERM):
# kill(_child_proc.pid, sig)
# else:
# raise RuntimeError(f'Attempted to forward an unexpected signal: {sig}')
def main(argv):
argv = list(argv) # copy
base_url = getenv('API_BASE_URL')
if base_url is None:
raise RuntimeError('API_BASE_URL is not specified')
argv.append(base_url) # HACK: ...
result = run(['python', '-m', 'tester', *argv[1:]], check=False)
return result.returncode
if __name__ == '__main__':
sys.exit(int(main(sys.argv) or 0))

Просмотреть файл

@@ -15,5 +15,5 @@ ENV MODELS_PATH=/models
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8000"]
# docker build -t ml_service:1 services/ml_service/
# docker run -v "$(pwd)/services/models:/models" -p 8000:8000 ml_service:1
# docker build -t ml_service:2 services/ml_service/
# docker run -v "$(pwd)/services/models:/models" -p 8000:8000 ml_service:2

Просмотреть файл

@@ -1,71 +0,0 @@
# Сервис предсказания цен
Веб-сервис предсказания цен на подержанные автомобили; только stateless API. Об используемой предсказательной модели см. `research/README.md`.
## API
**Базовый URL**: `/api`. Все указанные далее URL записаны **относительно базового URL**, если не указано иное.
* Полная интерактивная документация (Swagger UI): `/docs`.
* Предсказать цену подержанного автомобиля: `/predict`.
Пример запроса:
* requst query: `item_id=16` (параметр `item_id` необходим!);
* request body:
```json
{
"selling_price": 5.59,
"driven_kms": 27000.0,
"age": 5.0,
"fuel_type": "petrol",
"selling_type": "dealer",
"transmission_type": "manual"
}
```
* response body:
```json
{
"item_id": 16,
"price": 3.743508852258851
}
```
* Тестовый эндпоинт: `/`
Возвращает простой демонстрационный объект JSON.
Может использоваться для проверки состояния сервиса.
## Развёртывание
### Файл модели
Файл используемой предсказательной модели `model.pkl` можно извлечь из MLFlow скриптом `services/models/fetch_model_as_pickle_from_mlflow.py`. Файл модели можно разместить в директории проекта, а именно в `services/models/`.
Например, извлечь модель по имени (`<model-name>`) и версии (`<model-version>`) (например, `UsedCardPricePredictionFinal/1`) (команда запускается из корневой директории проекта &mdash; от этого зависит путь к создаваемому файлу):
python services/models/fetch_model_as_pickle_from_mlflow.py --model "models:/<model-name>/<model-version>" services/models/model.pkl
Можно указать адрес tracking сервера MLFlow, например: `--tracking-uri "http://localhost:5000"`.
Информация о других опциях доступна:
python services/models/fetch_model_as_pickle_from_mlflow.py --help
### Образ Docker
Сборка образа (замените `<version>` на номер версии) (команда запускается из корневой директории проекта &mdash; от этого зависит путь к директории):
docker build -t ml_service:<version> services/ml_service/
Запуск образа (замените `<version>` на номер версии образа, `<models-dir>` на **абсолютный** путь к директории, где размещён файл предсказательной модели `model.pkl`, `<port>` на порт для запуска веб-сервиса (например, `8000`)):
docker run -v "<models-dir>:/models" -p <port>:8000 ml_service:<version>
Модель может быть размещена в директории проекта; тогда, например, при запуске команды из корна проекта: `$(pwd)/services/models` (здесь `$(pwd)` используется потому, что необходим абсолютный путь).

Просмотреть файл

@@ -2,6 +2,7 @@ from os import getenv
from pathlib import Path
from fastapi import FastAPI
from prometheus_fastapi_instrumentator import Instrumentator
from pydantic import BaseModel, Field
from ._meta import PACKAGE_PATH
@@ -35,6 +36,13 @@ app = FastAPI(
)
_ = (
Instrumentator(excluded_handlers=['/metrics'])
.instrument(app)
.expose(app, endpoint='/metrics')
)
@app.get('/', summary='Тестовый эндпоинт')
async def root():
return {'Hello': 'World'}

Просмотреть файл

@@ -1,7 +1,9 @@
from dataclasses import dataclass
from enum import Enum
from itertools import chain
from pandas import DataFrame
from pickle import load
from prometheus_client import Counter, Histogram
def open_model_file(file, *, buffering=-1, opener=None, **kwargs_extra):
@@ -61,6 +63,27 @@ class PricePredictionFeatures:
transmission_type: TransmissionType
metric_prediction_latency = Histogram(
'model_prediction_seconds', 'Время вычислений в модели',
buckets=(
list(chain.from_iterable((v * (10 ** p) for v in (1, 2, 5)) for p in range(-4, (1 + 1))))
+ [float('+inf')]
),
)
metric_prediction_errors = Counter(
'model_prediction_errors_total', 'Ошибки вычислений в модели по типу', ('error_type',),
)
metric_prediction_value = Histogram(
'model_prediction_value', 'Предсказанное значение цены',
buckets=(
list(chain.from_iterable((v * (10 ** p) for v in (1, 2, 5)) for p in range(-1, (2 + 1))))
+ [float('+inf')]
),
)
class PricePredictor:
def __init__(self, model_path):
@@ -76,6 +99,13 @@ class PricePredictor:
'transmission': features.transmission_type.value,
'age': features.age,
}])
try:
with metric_prediction_latency.time():
predictions = self._model.predict(features_df)
except Exception as err:
metric_prediction_errors.labels(error_type=type(err).__name__).inc()
raise
assert len(predictions) == 1
return float(predictions[0])
value = float(predictions[0])
metric_prediction_value.observe(value)
return value

Просмотреть файл

@@ -1,5 +1,7 @@
fastapi ~=0.120.4
mlxtend ~=0.23.4
pandas >=2.3.1,<3
prometheus_client ~=0.23.1
prometheus_fastapi_instrumentator >=7.0.2,<8
scikit-learn >=1.7.2,<2
uvicorn ~=0.38.0

1
services/prometheus/.gitignore поставляемый Обычный файл
Просмотреть файл

@@ -0,0 +1 @@
data/

15
services/prometheus/prometheus.yaml Обычный файл
Просмотреть файл

@@ -0,0 +1,15 @@
global:
scrape_interval: 15s
scrape_timeout: 5s
scrape_configs:
- job_name: "prices_predictor"
static_configs:
- targets:
- "prices-predictor:8000"
scheme: http
metrics_path: "/metrics"
#relabel_configs:
# - source_labels: ["__address__"]
# target_labels: "instance"