介紹#
最近一直想找一個輕量的影像瓦片的服務端,上週一直在看@Vincent Sarago 基於其自己一套工具 rio-tiler , lambda-proxy 的的瓦片服務的 provider,前前後後看了衍生的幾個項目,包括lambda-tiler,landsat-tiler,rio-viz等等,經過簡單的測試,感覺前兩個工具均需要借助 lambda 才能發揮正常的性能,第三個應用框架用的 tornado,考慮了並發問題,但是個單機應用,“移植” 起來工程量挺大的,自己試了試放棄了。在單節點的情況下,請求阻塞問題非常嚴重,自己試著換了幾個應用和服務端的組合,都沒太大的改善。另外在單節點情況下,這種每個請求都要重新訪問一次數據的方式並不經濟。
簡單的應用不行,在 COG 詳情頁看到了,Geotrellis項目,框架用 scala 實現的,在 projects 裡發現了一個和需求很相近的實驗項目,clone 下來運行,並不能成功,好像是應用入口有變化,失敗了,自己懶得上手改(不知道怎麼改),就想着去 quick start 裡找個小例子,跑個 tiles 服務應該挺容易的(呸),scala 在國內的新手使用體驗是真的難,甚至比 golang 還難,構建工具 sbt 從 maven 中心倉庫拉文件,烏龜似的啟動速度,自己找了那寥寥無幾的幾篇更換國內源的方法,中間一度想吐🤮,最後換了華為雲的源終於能接受了,sbt.build 的詭異語法,硬著頭皮堅持到 io 影像,最新版本的 api 根本跟 docs 大不一樣了,自己照著 api 東改西改,又被魔鬼般的 implict 參數坑了十幾分鐘後:
$ rm -rf repos/geotrellis-test ~/.sbt
$ brew rmtree sbt
溜了溜了。
陶罐#
Github 的 feed 真是個好東西,替我推薦了好多有用的玩意,Terracotta也是(太難打了,就叫他陶罐吧)。官方描述如下:
Terracotta 是一個純 Python 瓦片服務器,可以作為 WSGI 應用在專用的網絡服務器上運行,或作為 AWS Lambda 上的無服務器應用。它建立在現代 Python 3.6 棧上,依賴於一些很棒的開源軟件,如Flask、Zappa和Rasterio。
提供傳統部署和 Lambda 兩種方式,輕量,pure python,都挺符合我的口味,“技術棧” 也相對新。
陶罐與同樣基於函數計算的 lambda-tiler 相比,不管是從結構來講,或是理解起來,都是後者更簡單。後者的整個流程非常直接,基於 COG 的 portion 請求特性和 GDAL 的VFS(Virtual File Systems),不管你的數據在哪,多大,只要告訴我它數據的本地地址或者 HTTP 地址,它就可以實時的拉取切片。在 lambda 的環境下,這種方式在性能上不會有太大問題。但對於在國內使用、部署有兩個問題。
- AWS 在國內嚴重水土不服,給國內使用 Lambda 造成障礙,Aliyun 等國內廠商也有函數計算的服務,但還不太成熟,移植 proxy 等成本也很高。
- 一些 open access 的數據比如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/
然後將希望 serve 的影像根據模式匹配存進 sqlite 數據庫文件。
這裡想吐槽一下這個功能,開始的時候我以為是一般的正則匹配,搞半天發現是 {} 的簡單匹配,還不能不使用匹配,醉醉哒。
$ terracotta ingest optimized/LB8_{date}_{band}.tif -o test.sqlite
注入數據庫完成後,啟動服務
$ terracotta serve -d test.sqlite
服務默認在:5000 啟動,還提供了 Web UI,需要另行啟動,開另一個 session:
$ terracotta connect localhost:5000
這樣 Web UI 也就啟動了。這樣可以在提示的地址中訪問到了。
部署#
沒看 lambda 的部署方式,因為大致和 lambda-tiler 方式差不多,因為國內 aws 訪問半身不遂,移植到阿里雲,騰訊雲的 serverless 的成本又太高了,所以才放棄了這種方式。
傳統的部署方式如下:
我是在 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
新建服務,這裡自己踩了兩個坑,官方例子使用的是 nginx 反向代理到 sock 的方式,自己試了多個方法,沒成功,也不想深入了解了。
server {
listen 80;
server_name VM_IP;
location / {
include proxy_params;
proxy_pass http://unix:/mnt/data/terracotta.sock;
}
}
另一個是,應用入口裡的入口 版本更新過,service 裡的和上下文的不一樣,修改之後如下
[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",使外網可以訪問。
官方解釋如下:
- Absolute path to Gunicorn executable
- Number of workers to spawn (2 * cores + 1 is recommended)
- Binding to a unix socket file
terracotta.sock
in the working directory- Dotted path to the WSGI entry point, which consists of the path to the python module containing the main Flask app and the app object:
terracotta.server.app:app
服務裡需要指定 Gunicorn 的執行路徑,設置 workers 數量,綁定 socket file,指定應用入口。
設置開機啟動,啟動服務。
$ sudo systemctl start terracotta
$ sudo systemctl enable terracotta
$ sudo systemctl restart terracotta
這樣就能看到服務的表述了。
$ curl localhost:5000/swagger.json
當然,也可以用 terracotta 自帶的 client 來看一下效果:
$ terracotta connect localhost:5000
工作流程#
對於頭文件存儲方式的選擇,sqlite 自然是更方便,但 mysql 的靈活性和穩定性更高了,線上數據可以實現遠程注入。
這裡碰到點問題,driver 的 create 方法新建失敗,自己沒看出問題在哪,就從 driver 裡找出表定義,手動新建所需表。
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 # Max key length for MySQL is at least 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}')
瓦罐的頭文件存儲共需要四個表。
表格 | 描述 |
---|---|
terracotta | 存儲瓦罐版本信息 |
metadata | 存儲數據頭文件 |
Key_names | key 類型及描述 |
Datasets | 數據地址及(key)屬性信息 |
服務啟動修改如下:
[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
稍加修改就可以傳入 input 文件名 和 output 的文件夾名,就能實現影像優化、注入的工作流。