feat(devops): self-hosted OSRM routing engine for site-finder (#39) (#1929)
All checks were successful
Deploy / changes (push) Successful in 7s
Deploy / build-worker (push) Successful in 46s
Deploy / deploy (push) Successful in 1m18s
Deploy / build-frontend (push) Successful in 31s
Deploy / build-backend (push) Successful in 33s

This commit is contained in:
bot-backend 2026-06-26 19:18:21 +00:00
parent 4846197d54
commit 4fe20a9cbd
5 changed files with 335 additions and 0 deletions

6
.gitignore vendored
View file

@ -82,5 +82,11 @@ sf_anton_snapshot.db-*
*.csv.zip
!backend/db/init/*.sql
# OSRM routing graph build output (#39) — ~1.5GB .osrm* + intermediate .pbf.
# Строится offline на VPS через scripts/build_osrm.sh, монтируется в osrm-service.
# НЕ коммитим артефакты; .gitkeep держит каталог.
data/osrm/*
!data/osrm/.gitkeep
# Log cruft at repo root
debug.log

0
data/osrm/.gitkeep Normal file
View file

View file

@ -55,6 +55,47 @@ services:
timeout: 3s
retries: 5
# OSRM routing engine (#39 — site-finder /analyze road/walking distances to POI
# вместо straight-line ST_Distance). INFRA-only здесь: интеграция в /analyze —
# отдельный follow-up (A2/A3). Backend ходит к нему по http://osrm:5000 через
# default-сеть. НЕ публикуется наружу (нет ports:) — internal-only, Caddy его
# не проксирует.
#
# Region: Свердловская обл (oblast-clip из Ural-FO extract). Префикс .osrm-файлов
# задаётся OSRM_REGION (default `sverdlovsk`). Файлы /data/<region>.osrm* кладёт
# scripts/build_osrm.sh в ./data/osrm/ (host) → /data (container). Артефакты
# .gitignored (не коммитим ~1.5GB build output) — на VPS строятся offline.
#
# --algorithm mld → pipeline extract→partition→customize (НЕ contract). max-table-size
# поднят для будущих many-to-many table-запросов (изохроны A2).
#
# ВАЖНО: пока scripts/build_osrm.sh не отработал, /data/<region>.osrm нет →
# osrm-routed падает и контейнер crash-loop'ит (restart: unless-stopped). Это
# БЕЗВРЕДНО для деплоя: backend НЕ depends_on osrm, поэтому `compose up -d` в
# deploy.yml не блокируется. Оркестратор строит граф offline и поднимает osrm
# отдельно (docs/osrm-routing.md).
osrm:
image: osrm/osrm-backend:latest
restart: unless-stopped
command: osrm-routed --algorithm mld --max-table-size 8000 /data/${OSRM_REGION:-sverdlovsk}.osrm
volumes:
- ./data/osrm:/data
mem_limit: 1.5g
# TCP-probe порта 5000 через bash /dev/tcp. osrm/osrm-backend (Debian-slim)
# НЕ содержит curl/wget — HTTP-healthcheck падал бы на missing binary (ср.
# glitchtip #644). bash в образе есть → /dev/tcp надёжно проверяет, что
# osrm-routed слушает (= граф загружен в RAM, движок принимает запросы).
# start_period щедрый: загрузка MLD-графа Свердл в RAM занимает время.
# Полный route-probe (driving) — в scripts/build_osrm.sh и в PR-инструкции
# для оркестратора (curl с хоста VPS, см. docs/osrm-routing.md).
healthcheck:
test: ["CMD-SHELL", "timeout 3 bash -c '</dev/tcp/127.0.0.1/5000' || exit 1"]
interval: 30s
timeout: 5s
retries: 5
start_period: 40s
networks: [default]
backend:
image: ghcr.io/lekss361/gendesign-backend:${IMAGE_TAG:-latest}
restart: unless-stopped

126
docs/osrm-routing.md Normal file
View file

@ -0,0 +1,126 @@
# OSRM routing engine (self-hosted) — #39
Self-hosted [OSRM](https://project-osrm.org/) для site-finder `/analyze`: реальные
дорожные / пешие расстояния до POI вместо straight-line `ST_Distance(geography)`.
> **Scope этого PR — INFRA ONLY.** Здесь стоит контейнер + build-скрипт + healthcheck.
> Интеграция в `/analyze` (замена `ST_Distance` на OSRM `route`/`table`) — отдельный
> follow-up (A2 изохроны, A3 per-category routing-decay). Backend пока к osrm НЕ ходит.
## Архитектура
```
backend ──http://osrm:5000──▶ osrm (osrm-routed --algorithm mld)
│ volume ./data/osrm:/data
└─ /data/sverdlovsk.osrm* (MLD-граф, ~ХХХ MB)
```
- Compose service `osrm` в `docker-compose.prod.yml`, сеть `default` (project `gendesign`).
- **Наружу НЕ публикуется** — нет `ports:`. Достижим только из backend по `http://osrm:5000`.
Caddy его не проксирует (routing — internal API, не должен быть публичным).
- `mem_limit: 1.5g`, `restart: unless-stopped`.
- Граф (`/data/*.osrm*`) НЕ в git (`.gitignore``data/osrm/*`). Строится offline на VPS.
## Build / refresh графа
Скрипт: [`scripts/build_osrm.sh`](../scripts/build_osrm.sh). Запускается **вручную**
на VPS (НЕ часть `deploy.yml` — download+build тяжёлые).
```bash
cd /opt/gendesign
bash scripts/build_osrm.sh # car-профиль, Свердл-clip (default)
```
### Источник pbf и решение по региону
Geofabrik **не** публикует Свердловскую область отдельно — есть только
[Уральский ФО](https://download.geofabrik.de/russia/ural-fed-district-latest.osm.pbf)
(≈600 MB). Скрипт по умолчанию (`CLIP=1`) скачивает Ural-FO и **обрезает по bbox
Свердл. обл.** через `osmium extract` → легче граф, меньше RAM в runtime.
| Режим | Команда | pbf | Граф в RAM (warm) | Когда |
|---|---|---|---|---|
| **Свердл-clip** (default) | `bash scripts/build_osrm.sh` | clip из Ural-FO | ~0.81.2 GB | рекоменд., extensibility на обл |
| ЕКБ-tight | `EKB_TIGHT=1 bash scripts/build_osrm.sh` | узкий bbox ЕКБ | ~0.40.6 GB | если RAM на VPS критична |
| Ural-FO zero-clip | `CLIP=0 bash scripts/build_osrm.sh` | весь Ural-FO | ~1.5 GB+ | если нужен запас на соседние регионы |
bbox Свердл (с запасом): `lon 57.2..66.2, lat 56.0..62.0`. ЕКБ-tight: `lon 59.0..62.5, lat 56.2..58.2`.
### Pipeline (MLD, не CH)
`--algorithm mld` в compose требует pipeline **extract → partition → customize**
(скрипт делает это через тот же `osrm/osrm-backend` образ). `osrm-contract`
это CH-алгоритм, он несовместим с `--algorithm mld`, поэтому НЕ используется.
### Walking-профиль (Phase-2, опционально)
Issue хочет оба профиля (car + foot) eventually. Этот PR **car-first**. Пеший граф:
```bash
WALK=1 bash scripts/build_osrm.sh # дополнительно соберёт sverdlovsk-foot.osrm*
```
Для пешего routing нужен второй compose-service (он не добавлен в этом PR, чтобы не
плодить idle-контейнер пока /analyze не интегрирован). Блок для будущего:
```yaml
osrm-walk:
image: osrm/osrm-backend:latest
restart: unless-stopped
command: osrm-routed --algorithm mld --max-table-size 8000 /data/${OSRM_REGION:-sverdlovsk}-foot.osrm
volumes: [./data/osrm:/data]
mem_limit: 1g
healthcheck:
test: ["CMD-SHELL", "timeout 3 bash -c '</dev/tcp/127.0.0.1/5000' || exit 1"]
interval: 30s
timeout: 5s
retries: 5
start_period: 40s
networks: [default]
```
Backend ходил бы к нему по `http://osrm-walk:5000`.
### Monthly refresh
osm.pbf на Geofabrik обновляется ежедневно. Граф освежать ~ежемесячно:
```cron
0 4 1 * * cd /opt/gendesign && bash scripts/build_osrm.sh >> /var/log/osrm-build.log 2>&1 \
&& docker compose -p gendesign -f docker-compose.prod.yml up -d --force-recreate --no-deps osrm
```
Скрипт idempotent: `wget -N` качает только если на сервере новее, граф перестраивается overwrite.
## Развёртывание (что выполняет оркестратор на VPS)
```bash
cd /opt/gendesign
git fetch origin main && git reset --hard origin/main # подтянуть compose + скрипт
# 1. Build графа (~3050 мин, ~600MB download + ~3GB peak RAM на extract)
bash scripts/build_osrm.sh
# 2. Поднять контейнер
docker compose -p gendesign -f docker-compose.prod.yml up -d osrm
# 3. Verify (curl ВНУТРИ образа нет — используем sidecar curl в той же сети)
docker run --rm --network gendesign_default curlimages/curl:8 -fsS \
'http://osrm:5000/route/v1/driving/60.6,56.84;60.65,56.86'
# ожидаем JSON: {"code":"Ok","routes":[{...}], ...}
```
## Healthcheck
Compose-healthcheck — TCP-probe порта 5000 через `bash /dev/tcp` (образ
`osrm/osrm-backend` НЕ содержит curl/wget, HTTP-probe падал бы на missing binary).
Полный route-probe — verify-команда выше (sidecar curl).
## Риски / open
- **RAM на Beget VPS**: +~1 GB warm для Свердл-clip. Перед `up -d osrm` проверить
свободную RAM (`free -h`). Если жмёт — `EKB_TIGHT=1` (~0.40.6 GB) или upgrade plan.
- **Build на VPS медленный** (50+ мин). Альтернатива: собрать локально и `scp`
`data/osrm/*.osrm*` в `/opt/gendesign/data/osrm/`.
- **Размер pbf неточен**: Ural-FO ≈600 MB на скачку; «1.5GB» из issue — оценка по
всему ФО + intermediate-файлы на диске во время build (нужно ~5 GB свободного диска).

162
scripts/build_osrm.sh Executable file
View file

@ -0,0 +1,162 @@
#!/usr/bin/env bash
# Build / refresh OSRM routing graph для site-finder (#39).
#
# Готовит .osrm-файлы (MLD pipeline) из OpenStreetMap-экстракта Свердловской
# области и кладёт их в ./data/osrm/, откуда их монтирует docker-compose.prod.yml
# (service `osrm`, volume ./data/osrm:/data → osrm-routed читает /data/<region>.osrm).
#
# === Где запускать ===
# На VPS из /opt/gendesign: bash scripts/build_osrm.sh
# (требует docker + ~5GB свободного диска + ~3GB свободной RAM на время extract).
#
# === ВАЖНО: это offline-задача, НЕ часть deploy.yml ===
# Download ~600MB-1.5GB + build 30-50 мин. Запускается ВРУЧНУЮ оркестратором,
# НЕ на каждом push. deploy.yml только поднимает уже готовый osrm-контейнер.
#
# === Idempotent / re-runnable ===
# Повторный запуск перекачивает свежий pbf и пересобирает граф (overwrite).
# Безопасно гонять ежемесячно (osm.pbf на Geofabrik обновляется ежедневно).
# # monthly refresh: cron на VPS, напр. 0 4 1 * * bash /opt/gendesign/scripts/build_osrm.sh >> /var/log/osrm-build.log 2>&1
# После refresh: docker compose -p gendesign -f docker-compose.prod.yml up -d --force-recreate --no-deps osrm
#
# === Регион / источник pbf ===
# Geofabrik НЕ публикует Свердловскую область отдельным extract'ом — только
# «Уральский ФО» (ural-fed-district). Два режима (CLIP=1 по умолчанию):
#
# CLIP=1 (default, РЕКОМЕНДОВАНО): качаем Ural-FO pbf и обрезаем по bbox
# Свердл. обл. через `osmium extract`. Результат легче → меньше RAM/диск
# в build и в runtime (osrm-routed warm ~0.8-1.2GB вместо ~1.5GB+ на
# весь Ural-FO). Минус: нужен extra-шаг osmium (запускаем в docker).
#
# CLIP=0: используем весь Ural-FO pbf как есть (zero-clip, проще, но тяжелее —
# граф Курган/Тюмень/ХМАО/ЯНАО грузится в RAM без пользы для Свердл).
# Бери, если RAM не жмёт и нужен запас на соседние регионы.
#
# bbox Свердловской обл (грубый, с запасом): lon 57.2..66.2, lat 56.0..62.0.
# Можно сузить до ЕКБ-агломерации если RAM критична (lon 59.0..62.5, lat 56.2..58.2)
# через EKB_TIGHT=1.
#
# === Профили ===
# Car (driving) — основной, строится всегда → <region>.osrm*.
# Foot (walking) — опциональный Phase-2, строится при WALK=1 → <region>-foot.osrm*.
# Для walking нужен второй compose-service (osrm-walk) — см. docs/osrm-routing.md.
# Этот PR car-first; walk выключен по умолчанию (issue хочет оба eventually).
#
# === Проверка после build ===
# docker compose -p gendesign -f docker-compose.prod.yml up -d osrm
# docker compose -p gendesign -f docker-compose.prod.yml exec osrm \
# sh -c 'echo OK' # контейнер жив
# # route-probe С ХОСТА (внутри образа нет curl) — временно прокинуть порт:
# docker run --rm --network gendesign_default curlimages/curl:8 -fsS \
# 'http://osrm:5000/route/v1/driving/60.6,56.84;60.65,56.86'
# ↑ ожидаем JSON {"code":"Ok","routes":[...]}.
set -euo pipefail
# --- Config (override через env) -------------------------------------------
OSRM_IMAGE="${OSRM_IMAGE:-osrm/osrm-backend:latest}"
OSMIUM_IMAGE="${OSMIUM_IMAGE:-stefda/osmium-tool:latest}"
REGION="${OSRM_REGION:-sverdlovsk}" # имя выходных .osrm* (= OSRM_REGION в compose)
PBF_URL="${PBF_URL:-https://download.geofabrik.de/russia/ural-fed-district-latest.osm.pbf}"
CLIP="${CLIP:-1}" # 1 = обрезать по bbox Свердл, 0 = весь Ural-FO
EKB_TIGHT="${EKB_TIGHT:-0}" # 1 = узкий bbox только ЕКБ-агломерация
WALK="${WALK:-0}" # 1 = ещё и пеший профиль <region>-foot.osrm*
CAR_PROFILE="${CAR_PROFILE:-/opt/car.lua}" # встроенные профили в osrm-backend образе
FOOT_PROFILE="${FOOT_PROFILE:-/opt/foot.lua}"
# bbox: left,bottom,right,top (lon_min,lat_min,lon_max,lat_max)
if [ "$EKB_TIGHT" = "1" ]; then
BBOX="${BBOX:-59.0,56.2,62.5,58.2}" # ЕКБ-агломерация (минимум RAM)
else
BBOX="${BBOX:-57.2,56.0,66.2,62.0}" # вся Свердловская обл (с запасом)
fi
# Каталог host (= ./data/osrm в compose). Скрипт зовём из /opt/gendesign.
DATA_DIR="${DATA_DIR:-$(pwd)/data/osrm}"
mkdir -p "$DATA_DIR"
RAW_PBF="$DATA_DIR/${REGION}-raw.osm.pbf" # сырой Ural-FO (или clip-источник)
WORK_PBF="$DATA_DIR/${REGION}.osm.pbf" # то, что реально пойдёт в osrm-extract
echo "=== OSRM build ==="
echo " region : $REGION"
echo " pbf source : $PBF_URL"
echo " clip to bbox : CLIP=$CLIP BBOX=$BBOX (EKB_TIGHT=$EKB_TIGHT)"
echo " walking profile : WALK=$WALK"
echo " data dir (host) : $DATA_DIR"
echo ""
# --- 1. Download ------------------------------------------------------------
# -N: качаем только если на сервере новее (idempotent monthly refresh).
echo "=== 1. Download pbf (Ural-FO ≈600MB) ==="
if command -v wget >/dev/null 2>&1; then
wget -N -O "$RAW_PBF" "$PBF_URL"
else
curl -fSL --time-cond "$RAW_PBF" -o "$RAW_PBF" "$PBF_URL"
fi
# --- 2. Clip (optional) -----------------------------------------------------
if [ "$CLIP" = "1" ]; then
echo "=== 2. Clip to Свердл bbox через osmium ($BBOX) ==="
docker run --rm -v "$DATA_DIR:/data" "$OSMIUM_IMAGE" \
osmium extract -b "$BBOX" "/data/$(basename "$RAW_PBF")" \
-o "/data/$(basename "$WORK_PBF")" --overwrite
else
echo "=== 2. No clip (CLIP=0) — используем Ural-FO целиком ==="
cp -f "$RAW_PBF" "$WORK_PBF"
fi
# --- helper: запуск osrm-backend образа на /data --------------------------
osrm() {
docker run --rm -v "$DATA_DIR:/data" "$OSRM_IMAGE" "$@"
}
# --- 3. MLD pipeline (car) --------------------------------------------------
# MLD = extract → partition → customize. НЕ osrm-contract (то CH-алгоритм,
# несовместим с --algorithm mld в compose).
#
# osrm-extract пишет <pbf-без-.osm.pbf>.osrm. Имя входного pbf специально равно
# целевому базовому имени (<region>.osm.pbf → <region>.osrm; <region>-foot.osm.pbf
# → <region>-foot.osrm), поэтому partition/customize работают по тому же базовому
# имени и переименование не требуется.
build_profile() {
local profile="$1" src_pbf="$2"
local base extracted
base="$(basename "${src_pbf%.osm.pbf}")"
extracted="/data/${base}.osrm"
echo "--- extract ($profile${base}.osrm) ---"
osrm osrm-extract -p "$profile" "/data/$(basename "$src_pbf")"
echo "--- partition (${base}.osrm) ---"
osrm osrm-partition "$extracted"
echo "--- customize (${base}.osrm) ---"
osrm osrm-customize "$extracted"
}
echo "=== 3. Build car profile → ${REGION}.osrm* ==="
build_profile "$CAR_PROFILE" "$WORK_PBF"
# --- 4. MLD pipeline (foot, optional) --------------------------------------
if [ "$WALK" = "1" ]; then
echo "=== 4. Build foot profile → ${REGION}-foot.osrm* ==="
# Отдельная копия pbf под пеший граф (osrm-extract overwrite'ит базу).
WALK_PBF="$DATA_DIR/${REGION}-foot.osm.pbf"
cp -f "$WORK_PBF" "$WALK_PBF"
build_profile "$FOOT_PROFILE" "$WALK_PBF"
else
echo "=== 4. Skip foot profile (WALK=0) — car-first, см. docs/osrm-routing.md ==="
fi
# --- 5. Cleanup heavy intermediates ----------------------------------------
echo "=== 5. Cleanup промежуточных .pbf (граф .osrm* уже собран) ==="
rm -f "$RAW_PBF" "$WORK_PBF" "${DATA_DIR}/${REGION}-foot.osm.pbf" 2>/dev/null || true
echo ""
echo "=== DONE ==="
echo "Артефакты в $DATA_DIR:"
ls -lh "$DATA_DIR"/*.osrm* 2>/dev/null | awk '{print " "$5"\t"$9}' || true
echo ""
echo "Дальше:"
echo " docker compose -p gendesign -f docker-compose.prod.yml up -d osrm"
echo " # verify (curl с хоста — внутри образа curl нет):"
echo " docker run --rm --network gendesign_default curlimages/curl:8 -fsS \\"
echo " 'http://osrm:5000/route/v1/driving/60.6,56.84;60.65,56.86'"