banner
陈不易

陈不易

没有技术想聊生活
twitter
medium
tg_channel

特羅科塔:一個輕量級的瓷磚伺服器

介紹#

最近一直想找一個輕量的影像瓦片的服務端,上週一直在看@Vincent Sarago 基於其自己一套工具 rio-tiler , lambda-proxy 的的瓦片服務的 provider,前前後後看了衍生的幾個項目,包括lambda-tilerlandsat-tilerrio-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 棧上,依賴於一些很棒的開源軟件,如FlaskZappaRasterio

提供傳統部署和 Lambda 兩種方式,輕量,pure python,都挺符合我的口味,“技術棧” 也相對新。

陶罐與同樣基於函數計算的 lambda-tiler 相比,不管是從結構來講,或是理解起來,都是後者更簡單。後者的整個流程非常直接,基於 COG 的 portion 請求特性和 GDAL 的VFS(Virtual File Systems),不管你的數據在哪,多大,只要告訴我它數據的本地地址或者 HTTP 地址,它就可以實時的拉取切片。在 lambda 的環境下,這種方式在性能上不會有太大問題。但對於在國內使用、部署有兩個問題。

  • AWS 在國內嚴重水土不服,給國內使用 Lambda 造成障礙,Aliyun 等國內廠商也有函數計算的服務,但還不太成熟,移植 proxy 等成本也很高。
  • 一些 open access 的數據比如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/

然後將希望 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

image

當然,也可以用 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_nameskey 類型及描述
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 的文件夾名,就能實現影像優化、注入的工作流。

參考#

載入中......
此文章數據所有權由區塊鏈加密技術和智能合約保障僅歸創作者所有。