PostGISベクトルタイルサーバーの比較: tegola/martin/pg_tileserv

## 概要

通常、ベクトルタイルはGeoJSONなどから変換した静的なファイルとして配信されることが多いです（.pbfファイルの配信）。パフォーマンスやサーバーのコストの観点ではベストな方法と言えますが、いくつか典型的な問題があります。

- 変換に大きなコストがかかる（特にplanet級の、タイル数が膨大な場合に問題となる）。
- データの追加・削除や更新が苦手

そんなわけで、データベースから動的にタイルを配信出来ることはとても重要です。しかしこのアプローチにもパフォーマンス面などで課題があるため、どの方法をなぜ採用するのか、よく検討する必要があります。

FOSS4Gの世界のデータベースといえばPostGISです。世間では、PostGISのテーブルからベクトルタイルを動的に配信するためのアプリケーションがいくつか実装されています。

- [t-rex(Rust)](https://github.com/t-rex-tileserver/t-rex)
- [tegola(Go)](https://github.com/go-spatial/tegola)
- [martin(Rust)](https://github.com/maplibre/martin)
- [pg_tileserv(Go)](https://github.com/CrunchyData/pg_tileserv)

martinとpg_tileservはPostGISの`ST_AsMVT`関数を使っていて、ほかはそうではないというのが、大きな違いのひとつです。martinとpg_tileservは他より新しい実装であるためです。

本記事ではこのうち、tegolaとmartintとpg_tileservを比較します。

## PostGIS + ベクトルタイルサーバーを設定

### PostgreSQL

Dockerを使って、PostgreSQLを起動、データを投入します。
（以降、断りなくdocker-compose.yml形式で記述します）

```yml:docker-compose.yml
  postgis:
    image: kartoza/postgis:12.4
    environment:
      - POSTGRES_USER=docker
      - POSTGRES_PASS=docker
      - POSTGRES_DB=postgres
    ports:
      - "5432:5432"
    volumes:
      - postgis-data:/var/lib/postgresql
      - ./postgres:/usr/src/app
    networks:
      - default
    healthcheck:
      test: [ "CMD-SHELL", "pg_isready" ]
      interval: 10s
      timeout: 5s
      retries: 5
```

PostgreSQLを起動...

```sh
docker-compose up -d postgis
```

次にデータを投入します。今回はgeofabrikの関東の建物・道路データを利用しました。

```sh
wget https://download.geofabrik.de/asia/japan/kanto-latest-free.shp.zip
unzip kanto-latest-free.shp.zip
ogr2ogr PG:postgresql://docker:docker@localhost:5432/postgres gis_osm_roads_free_1.shp
ogr2ogr PG:postgresql://docker:docker@localhost:5432/postgres gis_osm_buildings_a_free_1.shp -nlt MultiPolygon
```

投入が完了しました。

```sql
SELECT * FROM gis_osm_roads_free_1 LIMIT 10;
SELECT * FROM gis_osm_buildings_a_free_1 LIMIT 10;
```

以降は、関東の建物・道路データをベクトルタイルで配信する設定をしていきます。

### tegola

```yml:docker-compose.yml
  tegola:
    image: gospatial/tegola
    ports:
      - "8080:8080"
    depends_on:
      postgis:
        condition: service_healthy
    volumes:
      - ./tegola:/opt/tegola_config
    command: --config /opt/tegola_config/config.toml serve
    networks:
      - default
```

`tegola`は`config.toml`なる設定ファイルを使用します。この設定ファイルの定義に基づき、PostgreSQLのテーブルをベクトルタイルとして配信します。

```toml
# /tegola/config.toml
[webserver]
port = ":8080"
CORSAllowedOrigin = "*"

# register data providers
[[providers]]
name = "japan"         # provider name is referenced from map layers (required)
type = "postgis"      # the type of data provider. currently only supports postgis (required)
host = "postgis"      # postgis database host (required)
port = 5432           # postgis database port (required)
database = "postgres"     # postgis database name (required)
user = "docker"       # postgis database user (required)
password = "docker"         # postgis database password (required)
srid = 4326             # The default srid for this provider. If not provided it will be WebMercator (3857)

  [[providers.layers]]
  name = "roads"
  geometry_fieldname = "wkb_geometry"
  id_fieldname = "ogc_fid"
  tablename = "gis_osm_roads_free_1"

  [[providers.layers]]
  name = "buildings"
  geometry_fieldname = "wkb_geometry"
  id_fieldname = "ogc_fid"
  tablename = "gis_osm_buildings_a_free_1"

[[maps]]
name = "japan"
center = [139.72120, 35.73273, 11.0] # set the center of the map so the user is auto navigated to Bonn


  [[maps.layers]]
  provider_layer = "japan.buildings"
  min_zoom = 5
  max_zoom = 20

  [[maps.layers]]
  provider_layer = "japan.roads"
  min_zoom = 5
  max_zoom = 20
```
これらの設定により、ベクトルタイルが`http://localhost:8080/maps/japan/{z}/{x}/{y}.vector.pbf`として配信されます。

### martin

```yml: docker-compose.yml
  martin:
    image: urbica/martin
    restart: unless-stopped
    ports:
      - 3000:3000
    depends_on:
      postgis:
        condition: service_healthy
    networks:
      - default
    volumes:
      - ./martin:/opt/martin_config
    command: martin --config /opt/martin_config/config.yaml
```

martinもtegolaと同じような設定ファイルを用います。

```yml
# The socket address to bind [default: 0.0.0.0:3000]
listen_addresses: '0.0.0.0:3000'
# Database connection string
connection_string: 'postgres://docker:docker@postgis/postgres'
# Maximum connections pool size [default: 20]
pool_size: 20
# Connection keep alive timeout [default: 75]
keep_alive: 75
# Number of web server workers
worker_processes: 8
# If a spatial table has SRID 0, then this default SRID will be used as a fallback
default_srid: 4326
# Enable watch mode
watch: false
# Trust invalid certificates. This introduces significant vulnerabilities, and should only be used as a last resort.
danger_accept_invalid_certs: true

# Associative arrays of table sources
table_sources:
  public.buildings:
    # Table source id (required)
    id: public.gis_osm_buildings_a_free_1
    # Table schema (required)
    schema: public
    # Table name (required)
    table: gis_osm_buildings_a_free_1
    # Geometry SRID (required)
    srid: 4326
    # Geometry column name (required)
    geometry_column: wkb_geometry
    # Feature id column name
    id_column: ogc_fid
    # An integer specifying the minimum zoom level
    minzoom: 0
    # An integer specifying the maximum zoom level. MUST be >= minzoom
    maxzoom: 30
    # The maximum extent of available map tiles. Bounds MUST define an area
    # covered by all zoom levels. The bounds are represented in WGS:84
    # latitude and longitude values, in the order left, bottom, right, top.
    # Values may be integers or floating point numbers.
    bounds: [-180.0, -90.0, 180.0, 90.0]
    # Tile extent in tile coordinate space
    extent: 4096
    # Buffer distance in tile coordinate space to optionally clip geometries
    buffer: 64
    # Boolean to control if geometries should be clipped or encoded as is
    clip_geom: true
    # Geometry type
    geometry_type: GEOMETRY
    # List of columns, that should be encoded as tile properties (required)
    properties:
      ogc_fid: integer
      name: string
      fclass: string

  public.roads:
    id: public.gis_osm_roads_free_1
    schema: public
    table: gis_osm_roads_free_1
    srid: 4326
    geometry_column: wkb_geometry
    id_column: ogc_fid
    minzoom: 0
    maxzoom: 30
    bounds: [-180.0, -90.0, 180.0, 90.0]
    extent: 4096
    buffer: 64
    clip_geom: true
    geometry_type: GEOMETRY
    properties:
      ogc_fid: integer
      name: string
      fclass: string
      maxspeed: smallint
```

ベクトルタイルは`http://localhost:3000/public.roads,public.buildings/{z}/{x}/{y}.pbf`で配信されます.

### pg_tileserv

```yml: docker-compose.yml
  pg_tileserv:
    image: pramsey/pg_tileserv
    container_name: pg_tileserv
    environment:
      - DATABASE_URL=postgres://docker:docker@postgis/postgres
    depends_on:
      - postgis
    ports:
      - 7800:7800
```

pg_tileservはゼロコンフィグで起動でき、自動的にPostgreSQLのテーブルをベクトルタイルとして配信してくれることは、特筆すべき点です。

ベクトルタイルは`http://localhost:7800/public.gis_osm_buildings_a_free_1,public.gis_osm_roads_free_1/{z}/{x}/{y}.vector.pbf`で配信されます.

## 比較

それではベクトルタイルサーバーを起動しましょう。

```
docker-compose up
```

QGISで、下記のようにベクトルタイルレイヤーを追加することができます。

1. Vector Tilesで「新規一般接続」
2. データの存在する領域を表示
3. ベクトルタイルレイヤーを追加

![68747470733a2f2f6465762d746f2d75706c6f6164732e73332e616d617a6f6e6177732e636f6d2f75706c6f6164732f61727469636c65732f616b366b7070327977666533327338706b6a6d692e706e67.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/73197/b37258e9-5ae2-c03d-d0e5-66ae1f604e2f.png)

![68747470733a2f2f6465762d746f2d75706c6f6164732e73332e616d617a6f6e6177732e636f6d2f75706c6f6164732f61727469636c65732f7179727566366270726b766331347534707630782e706e67.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/73197/5a941411-3bae-c3b2-8ab9-50030c190765.png)

![68747470733a2f2f6465762d746f2d75706c6f6164732e73332e616d617a6f6e6177732e636f6d2f75706c6f6164732f61727469636c65732f31317478697835327379737a713630326865706b2e706e67.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/73197/79352fa7-bb40-abd0-5ab4-c56e3c1c5a6e.png)

![68747470733a2f2f6465762d746f2d75706c6f6164732e73332e616d617a6f6e6177732e636f6d2f75706c6f6164732f61727469636c65732f336a35673264376c34733934766e3836677677302e706e67.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/73197/82b35521-5b0e-7473-403a-96db31c0a5da.png)


3つのレイヤーは全く同じ見た目で表示されます（色は自動で設定されますが）。全て同一のデータ（PostgreSQLのテーブル）を参照しているので当然といえば当然です。しかしながら、martinとpg_tileservはtegolaより遥かに高速にレスポンスされることがわかると思います。


### パフォーマンスの差の原因は？

tegolaやmartin/pg_tileservの詳細な実装の違いはわからないですが、martin/pg_tileservは`ST_AsMVT`を使っていて、おそらくこの関数が速いのだと思います。

調べた限り、`ST_AsMVT`は2018年に実装されたのち、2019年にパフォーマンスの改善があったらしいです。

[https://www.crunchydata.com/blog/waiting-for-postgis-3-st_asmvt-performance](https://www.crunchydata.com/blog/waiting-for-postgis-3-st_asmvt-performance)

（最新のtegolaでは`ST_AsMVT`に対応しているらしいのですが、エラーが解消出来ませんでした）

加えて、martinは明らかにpg_tileservよりも速いです。原因は定かではないですが、martinはRustで書かれていてかつ、Actixという高速なwebサーバーで動作しているというのが、理由のひとつかもしれません。

## 結論

- PostGISベクトルタイルサーバーではmartinが最速
- 次点はpg_tileserv、ゼロコンフィグでとても使いやすい
- これらはローカルで実行している限りは実用的な速度が出るが、タイル化するズームレベルは調整が必要でしょ

## ソースコード

https://github.com/Kanahiro/postgis-tileservers-compare