Железо: сколько на самом деле нужно
Изначально мне выдали машину: 4 ядра, 16 ГБ ОЗУ, 500 ГБ диска. Методом тыка быстро выяснилось, что это катастрофически мало — планета на таком даже не импортировалась. Я попросил больше, накинули немного. Со второй попытки у меня было 32 ГБ ОЗУ и 500 + 2000 ГБ диска. На этом я и проработал: медленно, но планета в итоге загрузилась и работала.
База планеты после импорта и всех индексов весила около 1728 ГБ на момент начала 2024 года (OSM растет, сейчас больше), и это без учета тайлов. Диск надо брать с запасом: при импорте он дополнительно расходуется под временные данные.
Отдельная засада с диском. Заодно развею миф, который часто гуляет по таким статьям: Postgres ничего не преаллоцирует и спокойно использует выросшую файловую систему. У меня проблема была проще: база лежала на отдельном смонтированном томе, который при добавлении диска я просто не расширил, и месту неоткуда было взяться. Переформатировать ничего не пришлось, но базу потребовалось вручную перенести на бо́льший том:
# черновая копия, база еще работает
sudo rsync -av /var/lib/postgresql /mnt/tiles/db
sudo systemctl stop postgresql
# докопировать изменившееся
sudo rsync -av /var/lib/postgresql /mnt/tiles/db
sudo mv /var/lib/postgresql/14/main /var/lib/postgresql/14/main.bak
# дальше data_directory в postgresql.conf указывает на новый путь
Настройка PostgreSQL под нагрузку
Дефолтный конфиг Postgres под такую нагрузку не годится совсем. Большинство параметров масштабируются от объема ОЗУ, поэтому удобнее держать в голове правила, а не абсолютные числа:
Рабочие значения для сервера с 32 ГБ:
shared_buffers = 8GB
work_mem = 256MB
maintenance_work_mem = 4GB
effective_cache_size = 20GB
max_wal_size = 10GB
hash_mem_multiplier = 4.0 # множитель work_mem для хеш-операций
hash_mem_multiplier я добавил позже, на этапе оптимизации рендера: геозапросы активно используют хеш-джойны. Но тут же признаю́ свою ошибку. В первой версии у меня было work_mem = 1GB при hash_mem_multiplier = 8.0. Важная оговорка: work_mem выделяется на каждую сортировку или хеш в каждом запросе, а hash_mem_multiplier его умножает — с моими значениями одна хеш-операция могла занять до 8 ГБ. На 32 ГБ при восьми потоках renderd — это на грани OOM, и зависания рендера на высоких зумах, о которых пойдет речь ниже, скорее всего, с этим связаны. Если память поджимает, то безопаснее держать work_mem в районе 256–512 МБ, а множитель — в пределах 2.0–4.0.
Импорт планеты
Дамп планеты (≈70 ГБ на момент моих работ, сейчас заметно больше) скачиваем с докачкой, чтобы пережить обрыв связи:
wget -c https://planet.openstreetmap.org/pbf/planet-latest.osm.pbf
Команда импорта в Postgres:
osm2pgsql --slim -d gis --hstore --multi-geometry
--number-processes 1
--tag-transform-script openstreetmap-carto.lua
--style openstreetmap-carto.style
-C 14000
planet-latest.osm.pbf
Чего в этой команде не хватает и что я узнал уже после: --flat-nodes /path/nodes.cache. Флаг выносит кеш узлов из базы в отдельный файл (около 90 ГБ для планеты), за счет чего slim-таблицы худеют на сотни гигабайт, а импорт заметно ускоряется. Для планеты он считается практически обязательным, для небольших регионов, наоборот, не нужен. Часть моих пяти суток импорта и 1,7 ТБ базы — это расплата за его отсутствие.
Почему --number-processes 1? Потому что с двумя и четырьмя процессами импорт падал с segmentation fault. Точную причину я так и не установил: времени докапываться не было, в один поток заработало — на этом остановился. Ниже не вердикт, а гипотезы по убыванию правдоподобия:
- OOM Killer убивает дочерний процесс. Linux при нехватке памяти выбирает самый «жирный» процесс и убивает его. Если он валит воркер
osm2pgsql, родитель получает неожиданное завершение дочернего и падает. По контексту (памяти хронически не хватало) ставлю на это. - Конфликт в shared memory. В multi-process режиме воркеры координируются через общую память, и при нехватке ОЗУ или кривом shmmax это может повреждать данные и давать
segfault. - Баг конкретной версии osm2pgsql на параллельном импорте с активными hstore и multi-geometry разом. Лечится проверкой changelog и обновлением.
Если будете разбираться: dmesg -T | grep -i "killed process" сразу после падения и coredumpctl для трейса дадут однозначный ответ. Кроме того, у меня были жесткие бюджетные ограничения, а на современном железе с 64+ ГБ ОЗУ параллельный импорт может сработать и без описанной проблемы.
Если упираетесь в память на импорте, помогает снизить -C (размер кеша osm2pgsql в МБ) и shared_buffers в конфиге Postgres: меньше давление на RAM, импорт идет медленнее, но стабильнее.
В один поток планета импортировалась примерно за пять суток (основное хранилище — HDD, на SSD будет быстрее). Почти всё это время держит сам osm2pgsql, разбирая 70 ГБ дампа и собирая геометрию.
Индексы: ускорение в 150 раз
Самый большой прирост дали индексы. До них рендер тайла на зуме 8–9 мог занимать минуты: Mapnik слал запрос в PostGIS, тот делал последовательное сканирование (seq scan), то есть перебирал все строки таблицы. Для planet_osm_polygon весом в сотни гигабайт это катастрофа.
Геоиндексы GIST ищут объекты в пространстве по дереву, а не перебором. А partial indexes с условием берут в индекс только нужные рендеру строки, поэтому он меньше и быстрее:
-- реки
CREATE INDEX planet_osm_line_river ON planet_osm_line
USING GIST (way) WHERE waterway = 'river';
-- населенные пункты с названиями (для подписей)
CREATE INDEX planet_osm_point_place ON planet_osm_point
USING GIST (way) WHERE place IS NOT NULL AND name IS NOT NULL;
-- крупные полигоны для низких зумов
CREATE INDEX planet_osm_polygon_way_area_z6 ON planet_osm_polygon
USING GIST (way) WHERE way_area > 5980000;
-- ... всего около 15 индексов
Важная деталь, которую я понял не сразу: эти индексы — это не ручная магия, которую нужно продумывать самому, а готовый файл indexes.sql в osm-carto. На голом сервере его запускают руками после импорта (psql -d gis -f indexes.sql), и шаг легко пропустить: после osm2pgsql неочевидно, что нужно делать что-то еще, и карта «вроде работает», просто рисует тайл минутами. Мне это знание пришло далеко не сразу.
После всех индексов база выросла с ≈1728 до ≈1770 ГБ, а рендер ускорился примерно в 150 раз (не один запрос, а именно рендер тайла целиком, требующий десятки запросов к разным слоям) — разница между «тайл рисуется почти три минуты» и «тайл рисуется секунду». Замеры не претендуют на строгий бенчмарк: сравнивался рендер одного и того же набора тайлов до и после создания индексов (после очистки кеша, разумеется).
Пререндер высоких зумов
Низкие зумы пререндерятся за минуты, на высоких счет идет на дни. Вот реальные суммарные времена рендера планеты — делите на число потоков, чтобы получить реальное время:
|
Зум |
Суммарное время |
|---|---|
| 0–3 | Секунды |
| 4 | ≈596 с |
| 5 | ≈2183 с |
| 6 | ≈9109 с |
| 7 | ≈68 325 с |
| 8 | ≈273 380 с (около 76 ч суммарно, ≈9,5 ч на 8 потоках) |
На высоких зумах рендер регулярно зависал — скорее всего, из-за нехватки памяти на отдельных тяжелых запросах: восемь потоков renderd, в каждом запросе несколько джойнов, и щедрые work_mem с hash_mem_multiplier из раздела про настройку Postgres в сумме легко выедали всю ОЗУ. На тот момент я выбрал стратегию рендера чанками со смещением по оси X (то есть создается весь набор метатайлов по вертикали, но по горизонтали он ограничен), просматривая после каждого чанка случайные тайлы в браузере:
render_list --all -f -x 0 -X 100 -n 8 -t /mnt/tiles -s /run/renderd/renderd.sock -z 9 -Z 9 render_list --all -f -x 100 -X 200 -n 8 -t /mnt/tiles -s /run/renderd/renderd.sock -z 9 -Z 9 # ... удобно обернуть в цикл на bash
Завис — стоп, рестарт сервисов, продолжаем с того чанка, где встали. Кончился диск посреди ночи — чистка, запрос на расширение тома, продолжаем. Это нормальный рабочий цикл, а не форс-мажор, хотя порой от него опускались руки. И именно он, а не отдельная команда, и есть настоящая стоимость такого проекта. Команды удобно автоматизировать при помощи bash-скриптов или makefile, потому что это то, что приходится делать постоянно.
Файловая система: inode кончаются раньше места
В первой части я упоминал, что renderd хранит тайлы метатайлами по 8 × 8 в том числе ради файловой системы. Здесь стоит раскрыть, почему это важно.
У ext4 число inode (индексных дескрипторов, по одному на каждый файл) фиксируется при создании файловой системы и потом не меняется. По умолчанию mkfs.ext4 закладывает один inode на каждые 16 КБ объема — на томе в 2 ТБ это около 134 млн inode.
Звучит внушительно, но один только зум 16 — это больше 4 млрд тайлов: без метатайлов, сокращающих число файлов в 64 раза, inode на планете кончились бы задолго до места на диске.
Следить за этим просто: df -i показывает занятость inode по томам так же, как df -h показывает место (ключи можно совмещать: df -ih выведет то же в человеко-читаемом формате). Если создаете том под тайлы с нуля, запас можно заложить заранее: mkfs.ext4 -i 4096 даст вчетверо больше inode, чем дефолт, ценой небольшой потери полезного объема. А в XFS inode выделяются динамически, и этой проблемы там нет вовсе.
Эксплуатация: куда копать дальше
Я разобрал, как собрать сервер и как его подготовить к продакшен-использованию. Есть еще отдельные большие темы, вот короткие отправные точки для изучения:
- Обновление данных и expiry. Я не использовал обновления из-за исторического контекста проекта — нам не были важны современные данные и их актуализация. Если же данные меняются, диффы на базу накатывает
osm2pgsql-replication, но уже нарендеренные тайлы на диске сами не обновятся — карта будет постепенно устаревать. Решает это expiry:osm2pgsql --expire-tiles 12-14при диффе выдает список координат затронутых тайлов, а скрипт помечает их (удаляет метатайлы), и renderd перерисовывает при следующем запросе уже не всю планету (если не запустили с ключом force), а только изменившиеся квадратики. Для статичных карт это неактуально, для динамичного сервиса — обязательно. - Безопасность. Дефолтная установка Postgres из пакетов слушает только локальные подключения, но во многих туториалах и Docker-образах в
pg_hba.confпрописаноhost all all 0.0.0.0/0, то есть база открыта всем. Замените на конкретную подсеть (например, 192.168.0.0/16), или спрячьте за SSH-туннель, или же проксируйте на определенный Docker-контейнер, а доступ к самим тайлам закрывайте аутентификацией на уровне Apache или nginx перед ним. - Мониторинг. Минимум, за чем стоит смотреть:
df -h(диск во время пререндера кончается первым),df -i(inode),htop/free -h(память), журнал renderd (journalctl -u renderd) и статистикаmod_tile— модуль умеет отдавать счетчики запросов и рендера на служебном URL.
Что в итоге
Стек OSM + PostGIS + renderd работает, но требует терпения, ресурсов и понимания связей между сервисами. Если коротко:
- Три обязательных вещи: индексы, тюнинг Postgres и внешние данные. Каждая по отдельности кажется необязательной, но без них рендер либо падает, либо рисует тайл минутами и отваливается по тайм-ауту.
- Главный неочевидный расход места — глобальные водные полигоны, а не импортируемый регион.
- Закладывайте время на нелинейность. Падения, рестарты, кончившийся ночью диск, перенос тома — это не форс-мажор, а реальность процесса, и именно на нее уходит больше всего сил.
И главный урок — не про технологию. Большая часть проблем, которые я описал, оказалась связана не с OSM как таковым, а с попыткой вместить планетарный набор данных в ограниченные ресурсы. Позже на сервере вдвое увеличили память: работать стало значительно приятнее, но я передал проект преемнику, поэтому могу сказать только то, что 64 ГБ — это нормальный минимум памяти, если задумали такой проект. Именно поэтому главный совет прост: если собираетесь работать с планетой целиком, сначала заложите достаточный запас по памяти и диску, а уже потом начинайте оптимизировать.
Пощупать тюнинг и индексы на маленьком регионе можно в демо — make index-benchmark и make render-benchmark показывают эффект GIST-индекса и разницу холодного и теплого тайла.
Ссылки