はじめに#
最近、軽量の画像タイルサーバーを探していました。先週、@Vincent Sarago が自身のツール rio-tiler と lambda-proxy に基づいて提供しているタイルサービスを見ていました。いくつかの派生プロジェクト、例えば lambda-tiler、landsat-tiler、rio-viz などを見て、簡単なテストを行った結果、前者の 2 つのツールは正常なパフォーマンスを発揮するために Lambda を利用する必要があると感じました。3 つ目のアプリケーションフレームワークは tornado を使用しており、並行処理の問題を考慮していますが、単一のマシンアプリケーションであり、「移植」するにはかなりの工数がかかります。自分で試してみた結果、断念しました。単一ノードの状況では、リクエストのブロッキング問題が非常に深刻で、いくつかのアプリケーションとサーバーの組み合わせを試しましたが、大きな改善は見られませんでした。また、単一ノードの状況では、各リクエストごとにデータに再度アクセスする方法は経済的ではありません。
簡単なアプリケーションでは不十分で、COG の詳細ページで Geotrellis プロジェクトを見つけました。このフレームワークは Scala で実装されており、projects 内でニーズに非常に近い実験プロジェクトを見つけましたが、クローンして実行しても成功しませんでした。アプリケーションのエントリーポイントが変更されているようで、失敗しました。自分で修正するのが面倒だったので(どう修正するか分からなかった)、quick start で小さな例を探してタイルサービスを実行するのは簡単だろうと思いました(呸)。Scala は国内の初心者にとって本当に難しく、Golang よりも難しいです。ビルドツール sbt は maven 中央リポジトリからファイルを取得し、起動速度は亀のように遅く、国内のソースを変更する方法を探していると、途中で吐きそうになりました🤮。最終的に Huawei Cloud のソースに変更したところ、ようやく受け入れられるようになりました。sbt.build の奇妙な構文に苦しみながら、io 画像に到達しましたが、最新バージョンの API はドキュメントと大きく異なっていました。API を見ながらあれこれ修正していると、悪魔のような implicit パラメータに十数分も悩まされました:
$ rm -rf repos/geotrellis-test ~/.sbt
$ brew rmtree sbt
逃げました。
テラコッタ#
Github のフィードは本当に素晴らしいもので、多くの有用なものを推薦してくれました。Terracottaもその一つです(名前が難しいので、陶器と呼びましょう)。公式の説明は以下の通りです:
テラコッタは、専用の Web サーバー上で WSGI アプリとして実行される純粋な Python タイルサーバーであり、AWS Lambda 上でサーバーレスアプリとしても実行されます。これは、Flask、Zappa、Rasterioなどの素晴らしいオープンソースソフトウェアによって支えられた、モダンな Python 3.6 スタックに基づいて構築されています。
従来のデプロイと Lambda の 2 つの方法を提供しており、軽量で純粋な Python であり、私の好みに合っています。「技術スタック」も比較的新しいです。
陶器は、同じく関数計算に基づく lambda-tiler と比較して、構造的にも理解しやすく、後者は非常にシンプルです。後者の全体のプロセスは非常に直接的で、COG の部分リクエスト特性と GDAL のVFS(仮想ファイルシステム) に基づいています。データのローカルアドレスまたは HTTP アドレスを教えてくれれば、どこにデータがあっても、どれだけ大きくても、リアルタイムでスライスを取得できます。Lambda の環境下では、この方法はパフォーマンス上の大きな問題はありません。しかし、国内での使用やデプロイには 2 つの問題があります。
- AWS は国内では非常に不適切で、国内で Lambda を使用する際に障害を引き起こします。Aliyun などの国内のプロバイダーも関数計算サービスを提供していますが、まだ成熟しておらず、プロキシなどの移植コストも非常に高いです。
- Landsat 8やSentinel-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 のアクセスが不十分で、阿里雲や腾讯云のサーバーレスへの移植コストが高すぎるため、この方法を放棄しました。
従来のデプロイ方法は以下の通りです:
私は 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 モジュールへのパスとアプリオブジェクトを含みます:
terracotta.server.app:app
サービスでは Gunicorn の実行パスを指定し、ワーカーの数を設定し、ソケットファイルにバインドし、アプリケーションエントリを指定する必要があります。
起動時に自動起動を設定し、サービスを起動します。
$ sudo systemctl start terracotta
$ sudo systemctl enable terracotta
$ sudo systemctl restart terracotta
これでサービスの説明が表示されます。
$ curl localhost:5000/swagger.json
もちろん、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
少し変更すれば、入力ファイル名と出力フォルダー名を渡すことができ、画像の最適化と注入のワークフローを実現できます。