Создаём и оптимизируем свой tile-сервер для OpenStreetMap на основе Ubuntu 14.04

Сегодня я расскажу как довольно просто поднять и настроить свой собственный сервер карт (тайловый сервер) на основе Ubuntu Server 14.04 LTS и OpenStreetMap.

Итак начнём. Из Википедии:

OpenStreetMap (дословно «открытая карта улиц»), сокращённо OSM — некоммерческий веб-картографический проект по созданию силами сообщества участников-пользователей Интернета подробной свободной и бесплатной географической карты мира.

Есть довольно подробная официальная статья об установке и настройке tile-сервера, но есть ещё более простой и быстрый способ.

Установка OpenStreetMap

Ставим

sudo apt-get install python-software-properties

Добавляем репозиторий

sudo add-apt-repository ppa:kakrueger/openstreetmap

Обновляемся

sudo apt-get update

Ставим единственный пакет, что потянет за собой всё необходимое для работы полноценного сервера карт.

sudo apt-get install libapache2-mod-tile

По ходу инсталляции (openstreetmap-postgis-db-setup) вам будет задано несколько вопросов:

This will enable the apache config for mod_tile WARNING: This will disable the currently active default site config Do you want to enable mod_tile in the apache config?
Отвечаем Yes

This script downloads the necessary coastline and boundary data. WARNING: The data is about 400 — 500Mb in size. Do you want to download coastlines?
Отвечаем Yes

Do you want these scripts to create and setup a new postgis database ready to be used with e.g. Osm2pgsql. WARNING: This will delete an existing db. Do you want to create a postgis db?
Отвечаем Yes

If you don’t use the default name, you might need to adapt programs and scripts to use the new name. Name of the database to create:
Указываем gis

Please specify which users should have access to the newly created db. You will want the user www-data for rendering and your own user name to import data into the db. The list of users is blank separated: E.g. «www-data peter». Other users that should have access to the db:
Указываем www-data

Установлено.

Настройка PostgreSQL и Mapnik

Вначале о самой Mapnik:

Mapnik — это программа, которую мы используем для отрисовки основного Slippy Map слоя для OSM, вместе с другими слоями вроде «cycle map» и «noname». Также, это имя, данное основному слою, что вводит некоторых в заблуждение.

Mapnik — свободный инструментарий отрисовки карты. Он написан на C++ и Python. Использует библиотеку AGG и дает возможность сглаживать объекты на карте с большой точностью. Может читать данные в формате компании ESRI, PostGIS, точечные рисунки TIFF, файлы .osm, а также поддерживает любые GDAL или OGR форматы. Пакеты доступны для большинства выпусков Linux, двоичные файлы доступны для Mac OS X и Windows.

Довольно странный нюанс в том, что нам не предложили задать пароль для доступа пользователя gis (создаётся автоматически) к одноимённой БД.

Устанавливаем пароль для пользователя gis

su postgres -c "psql -d gis"
gis=# \password gis

Теперь необходимо указать пользователя и пароль в файле конфига mapnik.
Сделаем это простым способом — заменой с помощью утилиты rpl:

rpl \
'<Parameter name="dbname"><![CDATA[gis]]></Parameter>' \
'<Parameter name="dbname"><![CDATA[gis]]></Parameter><Parameter name="user"><![CDATA[gis]]></Parameter><Parameter name="password"><![CDATA[1234567890]]></Parameter>' \
/etc/mapnik-osm-carto-data/osm.xml

Где «1234567890» — это пароль, что мы указали для пользователя gis

Даём возможность подключаться с локального сервера, используя конфиг /etc/postgresql/9.3/main/pg_hba.conf
Добавляем в него строчку

local    	all             all             			md5

Настраиваем PostgreSQL (относительно характеристик сервера: 16 ядер, 16GB RAM, БД на HDD).

/etc/postgresql/9.3/main/postgresql.conf

Показываю только то, что менял:

listen_addresses = '*'
max_connections = 200
shared_buffers = 12GB
work_mem = 2GB
maintenance_work_mem = 8GB
synchronous_commit = off      # только для импорта, потом необходимо убрать!
fsync = off                   # только для импорта, потом необходимо убрать!
wal_buffers = 16MB
checkpoint_segments = 1024
checkpoint_completion_target = 0.9
random_page_cost = 2.0
cpu_tuple_cost = 0.001
cpu_index_tuple_cost = 0.0005
effective_cache_size = 14GB
autovacuum = off              # только для импорта, потом необходимо убрать!

И перезапускаем Постгрес для применения всех настроек

service postgresql restart

Потюним немного конфиг демона renderd рендерера тайлов (Mapnik rendering daemon) и увеличим количество потоков renderd до количества ядер (в моём случаи — 16)

vi /etc/renderd.conf
[renderd]
num_threads=16

Теперь необходимо перезапустить демон renderd рендерер тайлов (Mapnik rendering daemon)

service renderd restart

Настройка Apache

Сервер карт будет работать на связке Apache + PostgreSQL.
Я не в восторге от данной связки. После основных действий мы ещё доставим nginx для более быстрой работы в связке nginx + Apache + PostgreSQL.

Настраиваем Apache.
Названия сервера для примера будет osm.net.
Обратите внимание что порт я указал 8080.

grep -v '#' /etc/apache2/sites-available/tileserver_site.conf
<VirtualHost *:8080>
    ServerAdmin 	[email protected]
    ServerName  	osm.net
    DocumentRoot 	/var/www
    LogLevel info
    ModTileTileDir /var/lib/mod_tile
    LoadTileConfigFile /etc/renderd.conf
    ModTileRequestTimeout 10
    ModTileMissingRequestTimeout 30
    ModTileMaxLoadOld 2
    ModTileMaxLoadMissing 25
    ModTileRenderdSocketName /var/run/renderd/renderd.sock
    ModTileCacheDurationMax 604800
    ModTileCacheDurationDirty 900
    ModTileCacheDurationMinimum 10800
    ModTileCacheDurationMediumZoom 13 86400
    ModTileCacheDurationLowZoom 9 518400
    ModTileCacheLastModifiedFactor 0.20
    ModTileEnableTileThrottling Off
    ModTileEnableTileThrottlingXForward 0
    ModTileThrottlingTiles 10000 1
    ModTileThrottlingRenders 128 0.2
    <Directory />
        Options FollowSymLinks
        AllowOverride None
    </Directory>
    <Directory /var/www/>
        Options -Indexes -FollowSymLinks -MultiViews
        AllowOverride None
        Order Allow,Deny
        Allow From All
    </Directory>
</VirtualHost>

Правим порт в /etc/apache2/ports.conf на 8080

Listen 8080

Дописываем в /etc/apache2/apache2.conf название нашего сервера, т.к. Апач без этого работать не может.

echo "ServerName osm.net" >> /etc/apache2/apache2.conf

Перезапускаем.

service apache2 restart

Импорт карты

Скачиваем с проекта Geofabrik, для примера, карту Украины в формате .pbf и вливаем его в нашу БД.

mkdir /var/osm
wget -O /var/osm/ukraine.`date +%Y-%m-%d`.osm.pbf \
https://download.geofabrik.de/europe/ukraine-latest.osm.pbf
osm2pgsql -d gis -U gis -W -c --slim -C 4000 --number-processes 16 \
/var/osm/ukraine.`date +%Y-%m-%d`.osm.pbf

На виртуальной машине с 16 ядрами, 16GB ОЗУ и HDD RAID-10 массивом это занимает около 30 минут.
На скорость импорта довольно сильно влияет диск. На SSD вливается заметно быстрее.

Обратите внимание на параметр -c (--create) при импорте через osm2pgsql. С этим параметром удалится вся существующая информация с таблиц. Если вам необходимо сделать импорт ещё нескольких стран, то вместо -c указывайте параметр -a (--append).

После того как сделаете импорт всех необходимых стран (или всего мира) не забудьте вернуть в default из конфига PostgreSQL /etc/postgresql/9.3/main/postgresql.confпараметры synchronous_commit, fsync и autovacuum.

Проверка работоспособности карты

Текущая комплектация поставляется примером /var/www/osm/slippymap.html
Я предлагаю проверить на данном этапе правильность настройки PostgreSQL + Apache (mod_tile) + Mapnik, перед тем, как мы перейдём к настройке nginx.

/osm/11/1197/690.pngОткрываем в браузере https://osm.net:8080/osm/slippymap.html
Должен отобразится простой интерфейс и карта. Важно чтобы был выбран пункт в интерфейсе «Local Tiles».
Если позумить и поскроллить, то карта будет перерисовываться, постепенно подгружая разные тайлы.
Вот например, адрес тайла центра города Киева будет выгдядеть так: https://osm.net:8080/osm/11/1197/690.png
И если вы увидели картинку — значит всё сделали правильно.

Кэш отрендеренных тайлов будет складываться в папку /var/lib/mod_tile/default/.
И тут как-раз настало время отдавать этот кэш не через медленного монстра Apache, а через быстрый nginx.

Настройка nginx

Ставим

apt-get install nginx-light
cat /etc/nginx/nginx.conf
user                www-data    www-data;
worker_processes    32;
worker_priority     -20;
pid /var/run/nginx.pid;
error_log  /var/log/nginx/error.log  warn;
events {
    worker_connections  	1024;
    accept_mutex 	    	on;
    multi_accept 		on;
}
http {
    include         /etc/nginx/mime.types;
    default_type    application/octet-stream;
    reset_timedout_connection       on;
    server_tokens                   off;
    log_format                      main                '$remote_addr - [$time_local] '
                                                        '$host {$upstream_cache_status} "$request" $status $bytes_sent '
                                                        '"$http_referer" "$http_user_agent" '
                                                        '"$gzip_ratio" $upstream_response_time';
    log_format		            ip_only	        '$remote_addr';
    sendfile                        on;
    tcp_nopush                      on;
    tcp_nodelay                     on;
    keepalive_timeout  	            65;
    client_header_buffer_size	    16m;
    client_max_body_size            512m;
    server_names_hash_max_size      4096;
    server_names_hash_bucket_size   512;
    variables_hash_max_size 	    4096;
    variables_hash_bucket_size      512;
    types_hash_max_size 	    8192;
    port_in_redirect                off;
    gzip                            on;
    gzip_vary                       on;
    gzip_min_length                 1024;
    gzip_buffers                    16 8k;
    gzip_comp_level                 5;
    gzip_http_version               1.0;
    gzip_proxied                    any;
    gzip_disable                    "msie6";
    gzip_types                      text/plain text/css application/x-javascript text/xml
                                    application/xml application/xml+rss text/javascript text/json;
    fastcgi_temp_path               /var/cache/nginx/temp/fastcgi;
    proxy_temp_path                 /var/cache/nginx/temp/proxy;
    charset                         utf-8;
    index                           index.html index.htm;
    access_log			    off;
    error_log 		            /dev/null;
    include     	   	    /etc/nginx/conf.d/upstream.conf;
    include     		    /etc/nginx/sites-enabled/*;
}
cat /etc/nginx/conf.d/upstream.conf
upstream apache {
    server 127.0.0.1:8080;
}
cat /etc/nginx/sites-enabled/001-osm
server {
    listen              80;
    server_name         osm.net;
    access_log          /var/log/nginx/osm.net.access.log main;
    error_log       	/var/log/nginx/osm.net.error.log;
    root                /var/www;
    index               index.html;
    charset             utf-8;
    # OSM
    location ~* ^/osm/([0-9]+)/([0-9]+)/([0-9]+)\.png$ {
	proxy_pass	https://apache;
	include		/etc/nginx/proxy_params;
        access_log      off;
        autoindex       off;
        expires         1d;
        add_header      Cache-Control       'public';
    }
    # static resources
    location ~* ^.+\.(ico|htm|html|txt|jpg|jpeg|css|js|png|gif|map|woff|woff2|ttf|tif|tiff|pdf)$ {
        access_log      off;
        autoindex       off;
        expires         1d;
        add_header      Cache-Control       'public';
    }
}

Перезапускаем

service nginx restart

Оптимизируем OpenStreetMap (PostGIS)

(на основе статьи PostGIS Tuning)

CREATE INDEX idx_poly_idlanduse ON  planet_osm_polygon USING gist (way)
WHERE((landuse IS NOT NULL)
       OR (leisure IS NOT NULL)
       OR (aeroway = ANY ('{apron,aerodrome}'::text[]))
       OR (amenity = ANY ('{parking,university,college,school,hospital,kindergarten,grave_yard}'::text[]))
       OR (military = ANY ('{barracks,danger_area}'::text[]))
       OR ("natural" = ANY ('{field,beach,desert,heath,mud,grassland,wood,sand,scrub}'::text[]))
       OR (power = ANY ('{station,sub_station,generator}'::text[]))
       OR (tourism = ANY ('{attraction,camp_site,caravan_site,picnic_site,zoo}'::text[]))
       OR (highway = ANY ('{services,rest_area}'::text[])));
CREATE INDEX "planet_osm_polygon_nobuilding_index"
   ON "planet_osm_polygon"
     USING gist ("way")
       WHERE "building" IS NULL;
CREATE INDEX ferry_idx ON planet_osm_line USING gist (way) WHERE (route = 'ferry'::text);
CREATE INDEX "idx_poly_aeroway" on planet_osm_polygon  USING gist (way) WHERE "aeroway" IS NOT NULL ;
CREATE INDEX "idx_poly_aeroway" on planet_osm_polygon  USING gist (way) WHERE "aeroway" IS NOT NULL ;
CREATE INDEX "idx_poly_historic" on planet_osm_polygon  USING gist (way) WHERE "historic" IS NOT NULL ;
CREATE INDEX "idx_poly_leisure" on planet_osm_polygon  USING gist (way) WHERE "leisure" IS NOT NULL ;
CREATE INDEX "idx_poly_man_made" on planet_osm_polygon  USING gist (way) WHERE "man_made" IS NOT NULL ;
CREATE INDEX "idx_poly_military" on planet_osm_polygon  USING gist (way) WHERE "military" IS NOT NULL ;
CREATE INDEX "idx_poly_power" on planet_osm_polygon  USING gist (way) WHERE "power" IS NOT NULL ;
CREATE INDEX "idx_poly_landuse" on planet_osm_polygon  USING gist (way) WHERE "landuse" IS NOT NULL ;
CREATE INDEX "idx_poly_amenity" on planet_osm_polygon  USING gist (way) WHERE "amenity" IS NOT NULL ;
CREATE INDEX "idx_poly_natural" on planet_osm_polygon  USING gist (way) WHERE "natural" IS NOT NULL ;
CREATE INDEX "idx_poly_highway" on planet_osm_polygon  USING gist (way) WHERE "highway" IS NOT NULL ;
CREATE INDEX "idx_poly_tourism" on planet_osm_polygon  USING gist (way) WHERE "tourism" IS NOT NULL ;
CREATE INDEX "idx_poly_building" on planet_osm_polygon  USING gist (way) WHERE "building" IS NOT NULL ;
CREATE INDEX "idx_poly_barrier" on planet_osm_polygon  USING gist (way) WHERE "barrier" IS NOT NULL ;
CREATE INDEX "idx_poly_railway" on planet_osm_polygon  USING gist (way) WHERE "railway" IS NOT NULL ;
CREATE INDEX "idx_poly_aerialway" on planet_osm_polygon  USING gist (way) WHERE "aerialway" IS NOT NULL ;
CREATE INDEX "idx_poly_power_source" on planet_osm_polygon  USING gist (way) WHERE "power_source" IS NOT NULL ;
CREATE INDEX "idx_poly_generator:source" on planet_osm_polygon  USING gist (way) WHERE "generator:source" IS NOT NULL ;
CREATE INDEX "idx_line_aerialway" on planet_osm_line  USING gist (way) WHERE "aerialway" IS NOT NULL ;
CREATE INDEX "idx_line_waterway" on planet_osm_line  USING gist (way) WHERE "waterway" IS NOT NULL ;
CREATE INDEX "idx_line_bridge" on planet_osm_line  USING gist (way) WHERE "bridge" IS NOT NULL ;
CREATE INDEX "idx_line_tunnel" on planet_osm_line  USING gist (way) WHERE "tunnel" IS NOT NULL ;
CREATE INDEX "idx_line_access" on planet_osm_line  USING gist (way) WHERE "access" IS NOT NULL ;
CREATE INDEX "idx_line_railway" on planet_osm_line  USING gist (way) WHERE "railway" IS NOT NULL ;
CREATE INDEX "idx_line_power" on planet_osm_line  USING gist (way) WHERE "power" IS NOT NULL ;
CREATE INDEX "idx_line_name" on planet_osm_line  USING gist (way) WHERE "name" IS NOT NULL ;
CREATE INDEX "idx_line_ref" on planet_osm_line  USING gist (way) WHERE "ref" IS NOT NULL ;
CREATE INDEX "idx_point_aerialway" on planet_osm_point  USING gist (way) WHERE "aerialway" IS NOT NULL ;
CREATE INDEX "idx_point_power_source" on planet_osm_point  USING gist (way) WHERE "power_source" IS NOT NULL ;
CREATE INDEX "idx_point_shop" on planet_osm_point  USING gist (way) WHERE "shop" IS NOT NULL ;
CREATE INDEX "idx_point_place" on planet_osm_point  USING gist (way) WHERE "place" IS NOT NULL ;
CREATE INDEX "idx_point_barrier" on planet_osm_point  USING gist (way) WHERE "barrier" IS NOT NULL ;
CREATE INDEX "idx_point_railway" on planet_osm_point  USING gist (way) WHERE "railway" IS NOT NULL ;
CREATE INDEX "idx_point_amenity" on planet_osm_point  USING gist (way) WHERE "amenity" IS NOT NULL ;
CREATE INDEX "idx_point_natural" on planet_osm_point  USING gist (way) WHERE "natural" IS NOT NULL ;
CREATE INDEX "idx_point_highway" on planet_osm_point  USING gist (way) WHERE "highway" IS NOT NULL ;
CREATE INDEX "idx_point_tourism" on planet_osm_point  USING gist (way) WHERE "tourism" IS NOT NULL ;
CREATE INDEX "idx_point_power" on planet_osm_point  USING gist (way) WHERE "power" IS NOT NULL ;
CREATE INDEX "idx_point_aeroway" on planet_osm_point  USING gist (way) WHERE "aeroway" IS NOT NULL ;
CREATE INDEX "idx_point_historic" on planet_osm_point  USING gist (way) WHERE "historic" IS NOT NULL ;
CREATE INDEX "idx_point_leisure" on planet_osm_point  USING gist (way) WHERE "leisure" IS NOT NULL ;
CREATE INDEX "idx_point_man_made" on planet_osm_point  USING gist (way) WHERE "man_made" IS NOT NULL ;
CREATE INDEX "idx_point_waterway" on planet_osm_point  USING gist (way) WHERE "waterway" IS NOT NULL ;
CREATE INDEX "idx_point_generator:source" on planet_osm_point  USING gist (way) WHERE "generator:source" IS NOT NULL ;
CREATE INDEX "idx_point_capital" on planet_osm_point  USING gist (way) WHERE "capital" IS NOT NULL ;
CREATE INDEX "idx_point_lock" on planet_osm_point  USING gist (way) WHERE "lock" IS NOT NULL ;
CREATE INDEX "idx_point_landuse" on planet_osm_point  USING gist (way) WHERE "landuse" IS NOT NULL ;
CREATE INDEX "idx_point_military" on planet_osm_point  USING gist (way) WHERE "military" IS NOT NULL ;
CREATE INDEX idx_poly_wayarea_text ON  planet_osm_polygon USING gist (way)
     WHERE name IS NOT NULL
     AND place IS NULL
     AND way_area <= 320000;
CREATE INDEX idx_poly_text_poly ON planet_osm_polygon USING gist (way)
       where amenity is not null
                 or shop in ('supermarket','bakery','clothes','fashion','convenience','doityourself','hairdresser','department_store', 'butcher','car','car_repair','bicycle')
                 or leisure is not null
                 or landuse is not null
                 or tourism is not null
                 or "natural" is not null
                 or man_made in ('lighthouse','windmill')
                 or place='island'
                 or military='danger_area'
                 or historic in ('memorial','archaeological_site')
                 or highway='bus_stop';
CREATE INDEX "idx_line_cutline" on planet_osm_line  USING gist (way)
       where man_made='cutline';
CREATE INDEX idx_poly_buildings_lz ON planet_osm_polygon USING gist (way)
       where railway='station'
         or building in ('station','supermarket')
         or amenity='place_of_worship';
CREATE INDEX idx_poly_buildings ON planet_osm_polygon USING gist (way)
       where (building is not null
                and building not in ('no','station','supermarket','planned')
                and (railway is null or railway != 'station')
                and (amenity is null or amenity != 'place_of_worship'))
                 or aeroway = 'terminal';
CREATE INDEX water_areas_idx ON planet_osm_polygon USING gist (way)
       WHERE (((waterway IS NOT NULL)
       OR (landuse = ANY (ARRAY['reservoir'::text, 'water'::text, 'basin'::text])))
       OR ("natural" IS NOT NULL));

Сохраним эти запросы в файл /var/osm/osm.indexes.sql и выполним их

su postgres -с "psql -d gis -U gis -W"
gis=> \i /var/osm/osm.indexes.sql

Разогрев кэша Mapnik

Для часто используемых тайлов есть смысл «разогреть» кэш Mapnik.
Для этого есть очень простая команда:

cd /var/lib/mod_tile
render_list -m default -a -z 0 -Z 10 -n 16

Где
-z 0 — начальный зум (от)
-Z 10 — конечный зум (до)
-n 16 — количество ядер

Всё тот же пример с картой Украины для 16 ядер и с зумом от 0 до 10 занимает около 20 минут.