Всем привет! Я Влад, сооснователь MapMagic — каталога готовых маршрутов и планировщика для пеших походов и велопутешествий.
В первой статье я рассказал, как мы создали собственные топографические карты: откуда берём данные, как генерируем векторные тайлы и контуры изолиний. В этой статье разберем вопросы: как наладить процесс регулярного обновления карт и отдачи пользователям в векторном и растровом форматах.
Архитектура обновления карт
Общая схема процесса
Полный цикл обновления карт выглядит так:
Lambda Trigger → Preemptible VM → planet.osm.pbf → Tilemaker → PMTiles → S3
Разберём каждый компонент:
Lambda Trigger — Yandex Cloud Function, которая запускает процесс по расписанию
Preemptible VM — временная виртуальная машина генерации тайлов
planet.osm.pbf — источник свежих данных OpenStreetMap
Tilemaker — генератор векторных тайлов (о нём подробно в первой статье)
PMTiles — итоговый файл с тайлами в оптимизированном формате
S3 — объектное хранилище Yandex Cloud
Почему Preemptible VM
Preemptible VM (прерываемые виртуальные машины) — это ключевой элемент нашей стратегии экономии. В Yandex Cloud они стоят существенно дешевле обычных инстансов.
Трейдофф в том, что такая ВМ может быть принудительно остановлена в любой момент и не может работать дольше 24 часов. Но для нашей задачи это не проблема — если процесс прервался, просто запускаем заново.
Ещё один важный момент — авто-удаление дисков. Когда ВМ удаляется, все подключенные диски с флагом auto_delete тоже удаляются.
Пример конфигурации из python скрипта создания ВМ:
# Создаём политику планирования для прерываемой ВМ scheduling_policy = SchedulingPolicy( preemptible=config['preemptible'] # True по умолчанию ) # Ресурсы resources_spec=ResourcesSpec( memory=config['vm_memory_gb'] * 1024 * 1024 * 1024, cores=config['vm_cores'], core_fraction=config['vm_core_fraction'] ) # Загрузочный диск с авто-удалением boot_disk_spec=AttachedDiskSpec( auto_delete=config['auto_delete_disks'], disk_spec=AttachedDiskSpec.DiskSpec( type_id='network-ssd', size=config['boot_disk_size_gb'] * 1024 * 1024 * 1024, image_id=config['image_id'] ) )
Параметры реальной VM, используемой для генерации тайлов для всего мира:
24 vCPU, 240 GB RAM
50 GB загрузочный SSD
1023 GB кэш-диск (network-ssd-nonreplicated) — для хранения входных и выходных данных
HTTP API для управления генерацией
На ВМ крутится простой HTTP-сервер, который принимает команды:
Endpoint | Метод | Описание |
|---|---|---|
/status | GET | Статус генерации (ready/running/done/error) |
/run | POST | Запуск генерации с URL на PBF-файл |
/kill | POST | Принудительная остановка |
/log | GET | Логи процесса |
/health | GET | Health check |
Пример использования:
# Запуск генерации curl -X POST http://vm-ip:8000/run \ -H "Content-Type: application/json" \ -d '{"url": "https://planet.maps.mail.ru/pbf/planet-latest.osm.pbf"}' # Проверка статуса curl http://vm-ip:8000/status # Получение логов curl http://vm-ip:8000/log
Оркестрация через Lambda функции
За автоматизацию всего процесса отвечают две Yandex Cloud Functions.
vm_creator.py — создание ВМ
Первая функция создаёт виртуальную машину с нужными параметрами. Она вызывается по расписанию (раз в неделю) или вручную.
Ключевой элемент — cloud-init конфигурация, которая выполняется при первом запуске ВМ:
metadata={ 'user-data': f"""#cloud-config users: - name: {config['vm_user']} sudo: ['ALL=(ALL) NOPASSWD:ALL'] ssh-authorized-keys: - {config['ssh_public_key']} runcmd: # Монтируем кэш-диск - mkfs.ext4 -F /dev/vdb - mount /dev/vdb /mnt/cache - echo "/dev/vdb /mnt/cache ext4 defaults,nofail 0 2" >> /etc/fstab # Клонируем репозиторий - cd /mnt/cache - sudo -u {config['vm_user']} git clone {config['git_repo']} # Запускаем сервер - cd $REPO_DIR - sudo -u {config['vm_user']} nohup python3 server.py > /mnt/cache/server.log 2>&1 & """ }
Что происходит при старте:
Создаётся пользователь с SSH-ключом
Форматируется и монтируется кэш-диск в
/mnt/cacheКлонируется Git-репозиторий с конфигурацией Tilemaker
Запускается HTTP-сервер на порту 8000
Вся настройка занимает около 2-3 минут — после этого ВМ готова принимать команды.
vm_trigger.py — мониторинг и удаление
Вторая функция вызывается каждые нескольку минут и выполняет управление:
Находит ВМ по имени
Проверяет её статус через HTTP API
Запускает генерацию, если ВМ готова
Удаляет ВМ при выполнении условий
Логика принятия решения об удалении:
def should_delete_vm(vm, status_data, config, uptime_minutes, vm_name): """Определяет, нужно ли удалять ВМ""" uptime_hours = uptime_minutes / 60 current_status = status_data.get('status') # Условие 1: статус "done" — генерация завершена if current_status == config['target_status']: return True, "target_status_reached" # Условие 2: ошибка выполнения скрипта if current_status and current_status.startswith('error'): return True, "script_error" # Условие 3: прошло 20 минут, но сервер не ответил if (uptime_minutes >= config['max_startup_minutes'] and current_status in ['unknown', 'no_ip', 'connection_failed']): return True, "startup_timeout" return False, "no_reason"
Итого, три условия для удаления:
Статус “done” — нормальное завершение генерации
Ошибка выполнения скрипта — returncode ≠ 0
Сервер не ответил за 20 минут — что-то пошло не так при старте
Telegram уведомления
Все важные события дублируются в Telegram-чат:
def create_telegram_message(vm_name, vm_id, reason, uptime_minutes, vm_status, ...): if action == 'triggered': emoji = "🚀" message = f"{emoji} <b>Запущено выполнение скрипта</b>\n\n" elif reason == "target_status_reached": emoji = "🎉" message = f"{emoji} <b>Задача успешно выполнена!</b>\n\n" elif reason == "startup_timeout": emoji = "⏰" message = f"{emoji} <b>ВМ удалена - сервер не ответил</b>\n\n" else: emoji = "🗑️" message = f"{emoji} <b>Виртуальная машина удалена</b>\n\n"
Примеры сообщений:
Запущено выполнение обновления карт
Задача успешно выполнена! (время работы: 245 мин.)
Отдача векторных карт — Martin Tileserver
Когда PMTiles файл готов и загружен в S3, его нужно отдавать пользователям. Для этого мы используем Martin — высокопроизводительный tile-сервер на Rust.
Почему Martin
Rust — высокая производительность, минимальное потребление памяти
PMTiles “из коробки” — прямое чтение файлов из S3 без скачивания на сервер
Простая конфигурация — один YAML-файл
Схема отдачи
Клиент → Yandex CDN → Martin → S3 (PMTiles)
Ключевое преимущество: Martin читает PMTiles напрямую из S3. Можно не скачивать файл pmtiles на сервер — tileserver обращается к объектному хранилищу по HTTP Range-запросам. Однако такой подход влечет дополнительную тарификацию за запросы к S3 хранилищу. Для долговременного использования может иметь смысл обращение к локальному pmtiles файлу.
Конфигурация Martin (config.yaml):
server: listen_addresses: "0.0.0.0:3001" pmtiles: sources: osm: url: "https://storage.yandexcloud.net/$YC_BUCKET/osm-v4-latest.pmtiles" cache: type: memory size: 1000MB
Автоматическое обновление конфигурации
Когда в S3 появляется новый PMTiles файл, нужно обновить конфигурацию Martin. Это делает демон martin_updater.sh:
Проверяет S3 на наличие новых файлов
Валидирует размер файла
Атомарно меняет
config.yamlПерезапускает Martin с health check
check_updates() { # Получаем список файлов из S3 s3_output=$(aws --endpoint-url="$S3_ENDPOINT" s3 ls "s3://$YC_BUCKET/" --region ru-central1) # Находим последний файл NEW_OSM=$(echo "$s3_output" | grep "osm-v4-.*\.pmtiles" | sort -r | head -1 | awk '{print $4}') # Сравниваем с текущей версией if [[ "$NEW_OSM" != "$CURRENT_OSM" ]] && [[ -n "$NEW_OSM" ]]; then # Валидируем файл перед обновлением if validate_pmtiles "$NEW_OSM"; then return 0 fi fi }
Атомарная замена конфигурации:
replace_config() { local new_osm="$1" # Создаём новый конфиг во временном файле sed "s|{{osm-v4-latest}}|$new_osm|" "$MARTIN_DIR/config.yaml.templ" > "$temp_config" # Бэкап текущего cp "$final_config" "$backup_config" # Атомарная замена через mv mv "$temp_config" "$final_config" }
Отдача растровых тайлов
Растровые тайлы — это PNG-изображения, которые клиент получает напрямую. В отличие от векторных, они не требуют рендеринга на устройстве пользователя, что важно для нашего веб-планировщика, который работает только с растром.
Архитектура отдачи растровых тайлов
┌─────────────────────────────────────┐ │ Nginx Balancer │ │ tile.mapmagic.app │ └─────────────────┬───────────────────┘ │ ┌───────────────────────┼───────────────────────┐ │ │ │ ▼ ▼ ▼ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │ gl-tiles-1 │ │ gl-tiles-2 │ │ gl-tiles-3 │ │ nginx:8080 │ │ nginx:8080 │ │ nginx:8080 │ └────────┬────────┘ └────────┬────────┘ └────────┬────────┘ │ │ │ ┌────────┴────────┐ ┌────────┴────────┐ ┌────────┴────────┐ │ tileserver-gl │ │ tileserver-gl │ │ tileserver-gl │ │ martin │ │ martin │ │ martin │ │ contours │ │ contours │ │ contours │ └─────────────────┘ └─────────────────┘ └─────────────────┘
Сервер-балансировщик имеет заранее подготовленные закешированные тайлы вплоть до 10 зума. Если тайла нет в кэше, делегирует одному из трех серверов рендеринг.
tileserver-gl — рендеринг PNG
Для генерации растровых тайлов используем tileserver-gl от MapTiler. Он рендерит PNG из векторных тайлов “на лету” по стилю style.json.
Конфигурация tileserver-gl:
{ "options": { "paths": { "mbtiles": "" }, "formatOptions": { "png": { "quality": 80 } }, "minRendererPoolSizes": [8], "maxRendererPoolSizes": [8], "maxScaleFactor": 2 }, "styles": { "outdoor": { "style": "/data/style.json" } } }
Ключевые параметры:
minRendererPoolSizes/maxRendererPoolSizes: [8] — пул из 8 рендереров для параллельной генерации
maxScaleFactor: 2 — поддержка @2x retina-тайлов
contours — генерация изолиний
Также на каждом сервере крутится сервис, который генерирует изолинии из terrain-тайлов. Подробнее об этом я писал в первой статье.
Многоуровневое кэширование
Кэширование — ключ к производительности растровых тайлов:
┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ CDN │ ──▶ │ Balancer │ ──▶ │ Renderer │ │ (edge) │ │ (диск) │ │ nginx cache │ └──────────────┘ └──────────────┘ └──────────────┘ 4 дня кэш до 10 зума 4 недели
Уровень 1 — Nginx на балансировщике:
location ~ ^/(?<z>\d+)/(?<x>\d+)/(?<y>\d+)\.png$ { root /maps/tiles; # Сначала проверяем диск try_files $uri @fetch_tile; # Если нет — идём к бэкенду expires 30d; add_header Cache-Control "public, immutable"; } location @fetch_tile { proxy_pass http://backend/styles/outdoor/512/$z/$x/$y.png; expires 30d; } upstream backend { hash $request_uri consistent; server ip1:8080; server ip2:8080; server ip3:8080; }
Уровень 2 — Nginx на рендерере:
proxy_cache_path /var/cache/nginx/tileserver keys_zone=TileserverCache:50m levels=1:2 inactive=4w max_size=100g; server { listen 8080; location / { proxy_pass http://127.0.0.1:8081/; # tileserver-gl proxy_cache TileserverCache; proxy_cache_valid 200 4w; } }
Итого: до 100 GB кэша на каждом из 3 серверов. Попадание в кэш — мгновенный ответ, промах — рендеринг за 50-200 мс.
Для распределения запросов между серверами используем nginx с consistent hashing.
Почему consistent hashing?
Обычный round-robin отправляет одинаковые запросы на разные серверы. При consistent hashing запрос /12/2048/1500.png всегда пойдёт на один и тот же сервер. Это максимизирует попадания в кэш.
Сценарий:
Первый запрос
/12/2048/1500.png→ gl-tiles-1 → кэш-промах → рендеринг → сохранение в кэшВторой запрос
/12/2048/1500.png→ gl-tiles-1 → кэш-хит → мгновенный ответ
При round-robin второй запрос мог бы пойти на gl-tiles-2 и снова вызвать рендеринг.
Rate Limiting
Защита от злоупотреблений:
map $http_x_forwarded_for $real_ip { default $binary_remote_addr; ~^(\d+\.\d+\.\d+\.\d+) $1; } limit_req_zone $real_ip|$http_user_agent zone=tiles_limit:100m rate=35r/s; location ~ ^/(?<z>\d+)/(?<x>\d+)/(?<y>\d+)\.png$ { limit_req zone=tiles_limit burst=35; ... }
Ограничение: 35 запросов в секунду на клиента, с burst до 35 дополнительных. Этого достаточно для нормальной работы, но блокирует массовые скачивания. Тут важно использовать http_x_forwarded_for, чтобы лимитировать по ip адресу реального клиента, а не CDN.
Полный пайплайн обновления
Итоговая схема обновления карт:
1. Timer (раз в месяц) → vm_creator.py 2. Создание Preemptible VM 3. vm_trigger.py опрашивает /status 4. Запуск генерации через POST /run 5. Tilemaker генерирует PMTiles 6. Загрузка в S3 7. Telegram уведомление 8. Удаление VM (диски удаляются автоматически) 9. martin_updater.sh обнаруживает новый файл 10. Обновление конфигурации Martin
Время полного цикла для planet.osm около 7 часов. Стоимость работы прерываемой VM для обновления тайлов на 2026 год около 1500 рублей.
Масштабируемость
Векторные тайлы (Martin)
Для векторных тайлов в обозримом будущем необходимости в масштабировании нет — представленная конфигурация держит серьёзные нагрузки, вычисления минимальны: просто отдать тайл из PMTiles или из кэша.
Растровые тайлы (tileserver-gl)
Для растровых тайлов масштабирование более актуально — рендеринг тайла является уже ресурсоемкой задачей. Но в предложенной архитектуре добавление нового сервера рендеринга не вызовет никаких сложностей.
Заключение
Что мы получили в итоге:
Автоматизированное обновление — Lambda функции запускают процесс по расписанию, Telegram информирует о статусе
Вменяемая стоимость инфраструктуры — Использование прерываемой VM снижает стоимость
Масштабируемость — можно добавлять инстансы рендер-серверов за балансировщиком
Ключевые технологии:
Martin: https://github.com/maplibre/martin — tile-сервер на Rust
PMTiles: https://github.com/protomaps/PMTiles — формат для облачного хранения тайлов
Tilemaker: https://github.com/systemed/tilemaker — генератор векторных тайлов
Есть вопросы или предложения? Пишите в комментариях или в Telegram-канал.
