Всем привет! Я Влад, сооснователь MapMagicкаталога готовых маршрутов и планировщика для пеших походов и велопутешествий.

В первой статье я рассказал, как мы создали собственные топографические карты: откуда берём данные, как генерируем векторные тайлы и контуры изолиний. В этой статье разберем вопросы: как наладить процесс регулярного обновления карт и отдачи пользователям в векторном и растровом форматах.

Архитектура обновления карт

Общая схема процесса

Полный цикл обновления карт выглядит так:

Lambda Trigger → Preemptible VM → planet.osm.pbf → Tilemaker → PMTiles → S3

Разберём каждый компонент:

  1. Lambda Trigger — Yandex Cloud Function, которая запускает процесс по расписанию

  2. Preemptible VM — временная виртуальная машина генерации тайлов

  3. planet.osm.pbf — источник свежих данных OpenStreetMap

  4. Tilemaker — генератор векторных тайлов (о нём подробно в первой статье)

  5. PMTiles — итоговый файл с тайлами в оптимизированном формате

  6. 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 &
"""
}

Что происходит при старте:

  1. Создаётся пользователь с SSH-ключом

  2. Форматируется и монтируется кэш-диск в /mnt/cache

  3. Клонируется Git-репозиторий с конфигурацией Tilemaker

  4. Запускается HTTP-сервер на порту 8000

Вся настройка занимает около 2-3 минут — после этого ВМ готова принимать команды.

vm_trigger.py — мониторинг и удаление

Вторая функция вызывается каждые нескольку минут и выполняет управление:

  1. Находит ВМ по имени

  2. Проверяет её статус через HTTP API

  3. Запускает генерацию, если ВМ готова

  4. Удаляет ВМ при выполнении условий

Логика принятия решения об удалении:

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"

Итого, три условия для удаления:

  1. Статус “done” — нормальное завершение генерации

  2. Ошибка выполнения скрипта — returncode ≠ 0

  3. Сервер не ответил за 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:

  1. Проверяет S3 на наличие новых файлов

  2. Валидирует размер файла

  3. Атомарно меняет config.yaml

  4. Перезапускает 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 всегда пойдёт на один и тот же сервер. Это максимизирует попадания в кэш.

Сценарий:

  1. Первый запрос /12/2048/1500.png → gl-tiles-1 → кэш-промах → рендеринг → сохранение в кэш

  2. Второй запрос /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 снижает стоимость

  • Масштабируемость — можно добавлять инстансы рендер-серверов за балансировщиком

Ключевые технологии:


Есть вопросы или предложения? Пишите в комментариях или в Telegram-канал.