banner
陈不易

陈不易

没有技术想聊生活
twitter
medium
tg_channel

テラコッタ:軽量タイルサーバー

はじめに#

最近、軽量の画像タイルサービスを探していました。先週、@Vincent Sarago が自身のツール rio-tilerlambda-proxy を基にしたタイルサービスのプロバイダーを見ていました。いくつかの派生プロジェクトを見て、lambda-tilerlandsat-tilerrio-viz などを簡単にテストした結果、前者の 2 つのツールは、正常なパフォーマンスを発揮するために lambda を利用する必要があると感じました。3 つ目のアプリケーションフレームワークは tornado を使用しており、並行性の問題を考慮していますが、単一のマシンアプリケーションであり、「移植」するにはかなりの工数がかかります。自分で試してみた結果、あきらめました。単一ノードの状況では、リクエストのブロッキング問題が非常に深刻で、いくつかのアプリケーションとサーバーの組み合わせを試しましたが、大きな改善は見られませんでした。また、単一ノードの状況では、各リクエストがデータに再度アクセスする必要があるこの方法は経済的ではありません。

簡単なアプリケーションでは不十分で、COG の詳細ページで、Geotrellisプロジェクトを見つけました。これは Scala で実装されたフレームワークで、projects 内にニーズに非常に近い実験プロジェクトを見つけましたが、クローンして実行しても成功しませんでした。アプリケーションのエントリポイントが変更されているようで、失敗しました。自分で修正するのが面倒だったので(どう修正するかわからない)、クイックスタートで小さな例を探して、タイルサービスを実行するのは簡単だろうと思いました(呸)。Scala は国内の初心者にとって本当に難しい体験で、Golang よりも難しいです。ビルドツール sbt は Maven 中央リポジトリからファイルを取得し、亀のように起動が遅く、国内のソースを変更する方法を探していると、途中で吐きそうになりました🤮。最終的に Huawei Cloud のソースに変更してようやく受け入れられるようになりました。sbt.build の奇妙な構文に苦しみながら、io 画像に到達しましたが、最新バージョンの API はドキュメントとは大きく異なっていました。API を見ながらあれこれ修正していると、悪魔のような implicit パラメータに 10 分以上も悩まされました。

$ rm -rf repos/geotrellis-test ~/.sbt
$ brew rmtree sbt

逃げました。

テラコッタ#

Github のフィードは本当に素晴らしいもので、私に多くの有用なものを推薦してくれました。Terracottaもその一つです(名前が難しいので、陶器と呼びましょう)。公式の説明は以下の通りです:

テラコッタは、専用の Web サーバー上で WSGI アプリとして実行される純粋な Python タイルサーバーで、AWS Lambda 上のサーバーレスアプリとしても動作します。これは、FlaskZappa、およびRasterioなどの素晴らしいオープンソースソフトウェアに支えられた、現代的な Python 3.6 スタックで構築されています。

従来のデプロイメントと Lambda の 2 つの方法を提供し、軽量で純粋な Python であり、私の好みに合っています。「技術スタック」も比較的新しいです。

陶器は、同様に関数計算に基づく lambda-tiler と比較して、構造的にも理解しやすく、後者の方が簡単です。後者の全体のプロセスは非常に直接的で、COG の部分リクエスト特性と GDAL のVFS(仮想ファイルシステム) に基づいています。データがどこにあっても、どれだけ大きくても、データのローカルアドレスまたは HTTP アドレスを教えてくれれば、リアルタイムでスライスを取得できます。Lambda の環境下では、この方法はパフォーマンス上の大きな問題はありません。しかし、国内での使用やデプロイには 2 つの問題があります。

  • AWS は国内では非常に不適切で、国内で Lambda を使用する際に障害を引き起こします。Aliyun などの国内のプロバイダーにも関数計算サービスがありますが、まだ成熟しておらず、プロキシなどの移植コストも非常に高いです。
  • Landsat 8Sentinel-2などのオープンアクセスデータは S3 オブジェクトストレージにホストされており、Lambda でのスライスは AWS の各コンポーネントへの迅速なアクセスに大きく依存していますが、国内でサービスを提供する場合、アクセス速度に大きな影響を受けます。

もちろん、陶器も Lambda 関数上でのデプロイを推奨しています。確かに、この方法は動的スライスサービスに非常に適していますが、Lambda-tiler よりも、使いやすく信頼性の高いヘッダーファイルの「キャッシュメカニズム」を追加しています。

rio-tiler を使用して、単一のマシン上に迅速にデプロイでき、少数のユーザーと低リクエストの動的スライスサービスをサポートすることを実現しようとしたとき、同じソースのデータのヘッダーファイルをメモリにキャッシュすることを考えました。なぜなら、各タイルはソースデータを取得するために一度リクエストする必要があり、単一ノード環境では非常に無駄だからです。当時の私の考えは、データソースアドレスに基づいてヘッダーファイルを保存する dict を作成するか、sqlite データベースを作成することでした。dict を作成する方法を試しましたが、効果はあまり見られませんでした。

陶器はビジネスプロセス設計においてこの点を強制的に組み込んでおり、これにより新しいデータを追加する際に前処理プロセスが発生します。これは直接処理するよりも遅れがありますが、まさに「磨刀不誤砍柴工」と言えます。従来のプレカットよりもかなり速いと言わざるを得ません。

さらに、データの COG 化やヘッダーファイルの注入などのプロセスについて、陶器は非常に良い API サポートを提供しています。

クイックスタート#

試用は非常に簡単で、まず使用する環境に切り替え、次に

$ pip install -U pip
$ pip install terracotta

バージョンを確認します。

$ terracotta --version
$ terracotta, version 0.5.3.dev20+gd3e3da1

tif を保存するターゲットフォルダーに移動し、COG 形式で画像を最適化します。

$ terracotta optimize-rasters *.tif -o optimized/

次に、希望する画像をパターンマッチングに基づいて sqlite データベースファイルに保存します。

この機能について少し文句を言いたいのですが、最初は一般的な正規表現マッチングだと思っていましたが、結局は {} の単純なマッチングで、マッチングを使用しないこともできず、酔ってしまいました。

$ terracotta ingest optimized/LB8_{date}_{band}.tif -o test.sqlite

データベースへの注入が完了したら、サービスを起動します。

$ terracotta serve -d test.sqlite

サービスはデフォルトで:5000 で起動し、Web UI も提供されており、別途起動する必要があります。別のセッションを開いて:

$ terracotta connect localhost:5000

これで Web UI も起動しました。これにより、提示されたアドレスにアクセスできます。

デプロイメント#

lambda のデプロイ方法は見ていませんが、概ね lambda-tiler の方法と似ています。国内では AWS のアクセスが不安定で、Aliyun や Tencent Cloud のサーバーレスへの移植コストが非常に高いため、この方法を放棄しました。

従来のデプロイ方法は以下の通りです:

私は CentOS のクラウドホストにデプロイしましたが、docs の内容と大差ありません。

まず、新しい環境を作成し、ソフトウェアと依存関係をインストールします。

$ conda create --name gunicorn
$ source activate gunicorn
$ pip install cython
$ git clone https://github.com/DHI-GRAS/terracotta.git
$ cd /path/to/terracotta
$ pip install -e .
$ pip install gunicorn

データを準備します。例として、画像ファイルが/mnt/data/rasters/に保存されていると仮定します。

$ terracotta optimize-rasters /mnt/data/rasters/*.tif -o /mnt/data/optimized-rasters
$ terracotta ingest /mnt/data/optimized-rasters/{name}.tif -o /mnt/data/terracotta.sqlite

サービスを新規作成します。ここで私は 2 つの落とし穴にハマりました。公式の例では nginx が sock にリバースプロキシされている方法を使用していますが、私はいくつかの方法を試しましたが成功せず、深入りしたくありませんでした。

server {
    listen 80;
    server_name VM_IP;

    location / {
        include proxy_params;
        proxy_pass http://unix:/mnt/data/terracotta.sock;
    }
}

もう一つは、アプリケーションのエントリポイントのバージョンが更新されており、サービスの中で上下文が異なっていたため、修正後は以下のようになりました。

[Unit]
Description=Gunicorn instance to serve Terracotta
After=network.target

[Service]
User=root
WorkingDirectory=/mnt/data
Environment="PATH=/root/.conda/envs/gunicorn/bin"
Environment="TC_DRIVER_PATH=/mnt/data/terracotta.sqlite"
ExecStart=/root/.conda/envs/gunicorn/bin/gunicorn \
            --workers 3 --bind 0.0.0.0:5000  -m 007 terracotta.server.app:app

[Install]
WantedBy=multi-user.target

もう一つのポイントは、「0.0.0.0」を使用して外部からアクセスできるようにすることです。

公式の説明は以下の通りです:

  • Gunicorn 実行可能ファイルへの絶対パス
  • スポーンするワーカーの数(推奨は 2 * コア + 1)
  • 作業ディレクトリ内の unix ソケットファイルterracotta.sockにバインド
  • WSGI エントリポイントへのドットパス。これは、メイン Flask アプリを含む Python モジュールへのパスと app オブジェクトを含みます:terracotta.server.app:app

サービスでは Gunicorn の実行パスを指定し、ワーカーの数を設定し、ソケットファイルにバインドし、アプリケーションのエントリポイントを指定する必要があります。

起動時に自動起動を設定し、サービスを起動します。

$ sudo systemctl start terracotta
$ sudo systemctl enable terracotta
$ sudo systemctl restart terracotta

これでサービスの説明が表示されます。

$ curl localhost:5000/swagger.json

image

もちろん、terracotta に付属のクライアントを使用して効果を確認することもできます:

$ terracotta connect localhost:5000

ワークフロー#

ヘッダーファイルの保存方法の選択に関して、sqlite は確かに便利ですが、mysql の柔軟性と安定性は高いです。オンラインデータはリモート注入を実現できます。

ここで少し問題が発生しました。ドライバーの create メソッドが新規作成に失敗し、問題がどこにあるのか分からなかったので、ドライバーからテーブル定義を見つけて、手動で必要なテーブルを作成しました。

from typing import Tuple

import terracotta as tc
import pymysql


# driver = tc.get_driver("mysql://root:password@ip-address:3306/tilesbox'")
key_names = ('type', 'date', 'band')
keys_desc = {'type': 'type', 'date': 'data\'s date', 'band': 'raster band'}

_MAX_PRIMARY_KEY_LENGTH = 767 // 4  # MySQLの最大キー長は少なくとも767B
_METADATA_COLUMNS: Tuple[Tuple[str, ...], ...] = (
    ('bounds_north', 'REAL'),
    ('bounds_east', 'REAL'),
    ('bounds_south', 'REAL'),
    ('bounds_west', 'REAL'),
    ('convex_hull', 'LONGTEXT'),
    ('valid_percentage', 'REAL'),
    ('min', 'REAL'),
    ('max', 'REAL'),
    ('mean', 'REAL'),
    ('stdev', 'REAL'),
    ('percentiles', 'BLOB'),
    ('metadata', 'LONGTEXT')
)
_CHARSET: str = 'utf8mb4'
key_size = _MAX_PRIMARY_KEY_LENGTH // len(key_names)
key_type = f'VARCHAR({key_size})'

with pymysql.connect(host='ip-address', user='root',
                     password='password', port=3306,
                     binary_prefix=True, charset='utf8mb4', db='tilesbox') as cursor:
    cursor.execute(f'CREATE TABLE terracotta (version VARCHAR(255)) '
                   f'CHARACTER SET {_CHARSET}')

    cursor.execute('INSERT INTO terracotta VALUES (%s)', [str('0.5.2')])

    cursor.execute(f'CREATE TABLE key_names (key_name {key_type}, '
                   f'description VARCHAR(8000)) CHARACTER SET {_CHARSET}')
    key_rows = [(key, keys_desc[key]) for key in key_names]
    cursor.executemany('INSERT INTO key_names VALUES (%s, %s)', key_rows)

    key_string = ', '.join([f'{key} {key_type}' for key in key_names])
    cursor.execute(f'CREATE TABLE datasets ({key_string}, filepath VARCHAR(8000), '
                   f'PRIMARY KEY({", ".join(key_names)})) CHARACTER SET {_CHARSET}')

    column_string = ', '.join(f'{col} {col_type}' for col, col_type
                              in _METADATA_COLUMNS)
    cursor.execute(f'CREATE TABLE metadata ({key_string}, {column_string}, '
                   f'PRIMARY KEY ({", ".join(key_names)})) CHARACTER SET {_CHARSET}')

陶器のヘッダーファイルの保存には 4 つのテーブルが必要です。

テーブル説明
terracotta陶器のバージョン情報を保存
metadataデータのヘッダーファイルを保存
Key_namesキーのタイプと説明
Datasetsデータのアドレスと(キー)属性情報

サービス起動時の修正は以下の通りです:

[Unit]
Description=Gunicorn instance to serve Terracotta
After=network.target

[Service]
User=root
WorkingDirectory=/mnt/data
Environment="PATH=/root/.conda/envs/gunicorn/bin"
Environment="TC_DRIVER_PATH=root:password@ip-address:3306/tilesbox"
Environment="TC_DRIVER_PROVIDER=mysql"

ExecStart=/root/.conda/envs/gunicorn/bin/gunicorn \
            --workers 3 --bind 0.0.0.0:5000  -m 007 terracotta.server.app:app

[Install]
WantedBy=multi-user.target

ローカルファイルの注入については、以下の方法を参考にできます:

import os
import terracotta as tc
from terracotta.scripts import optimize_rasters, click_types
import pathlib

driver = tc.get_driver("/path/to/data/google/tc.sqlite")
print(driver.get_datasets())

local = "/path/to/data/google/Origin.tiff"
outdir = "/path/to/data/google/cog"
filename = os.path.basename(os.path.splitext(local)[0])
seq = [[pathlib.Path(local)]]
path = pathlib.Path(outdir)
# clickメソッドを呼び出す
optimize_rasters.optimize_rasters.callback(raster_files=seq, output_folder=path, overwrite=True)

outfile = outdir + os.sep + filename + ".tif"

driver.insert(filepath=outfile, keys={'nomask': 'yes'})

print(driver.get_datasets())

実行結果は以下の通りです。

Optimizing rasters:   0%|          | [00:00<?, file=Origin.tiff]

Reading:   0%|          | 0/992
Reading:  12%|█▎        | 124/992
Reading:  21%|██▏       | 211/992
Reading:  29%|██▉       | 292/992
Reading:  37%|███▋      | 370/992
Reading:  46%|████▌     | 452/992
Reading:  54%|█████▍    | 534/992
Reading:  62%|██████▏   | 612/992
Reading:  70%|██████▉   | 693/992
Reading:  78%|███████▊  | 771/992
Reading:  87%|████████▋ | 867/992
                                 
Creating overviews:   0%|          | 0/1
                                        
Compressing:   0%|          | 0/1
Optimizing rasters: 100%|██████████| [00:06<00:00, file=Origin.tiff]
{('nomask',): '/path/to/data/google/nomask.tif', ('yes',): '/path/to/data/google/cog/Origin.tif'}

Process finished with exit code 0

少し修正すれば、入力ファイル名と出力フォルダー名を渡すことができ、画像の最適化と注入のワークフローを実現できます。

参考文献#

読み込み中...
文章は、創作者によって署名され、ブロックチェーンに安全に保存されています。