MinIOの代わりにRustFSを使ってみる — DockerからGeoParquet・COG・PMTilesのE2Eまで
はじめに
オンプレミスやローカル環境でS3互換のObject Storageを用意するとき、これまではMinIOを使うことが多かったと思います。
自分もGeoParquet、COG、PMTiles、Zarr、Parquetなどを扱うデータ基盤では、S3互換の保存先としてMinIOを使う構成を考えていました。
ところが2026年現在、MinIOの公開GitHubリポジトリはarchiveされ、Community Editionはsource-only distributionとして案内されています。ライセンスはAGPLv3です。
そこで、MinIOの代替候補として試したのが RustFS です。
RustFSはRustで実装されたS3互換の分散Object Storageで、Apache License 2.0で公開されています。2026年9月16日にはRustFS 1.0.0がリリースされました。
この記事では、前回の記事の製品紹介だけではなく、実際にDockerで起動し、
boto3
↓
RustFS
↓
GeoParquet / COG / PMTiles
↓
DuckDB / FastAPI
↓
OpenLayers
まで動かします。
途中でいくつか問題にも当たりました。Docker image tag、Rasterioの共有ライブラリ、bind mountの所有権、Docker network、PMTilesの繰り返し表示などです。最終的には、そのあたりも含めて再実行できる形まで整理しました。
1. RustFSとは
RustFSは、Rustで実装されたS3互換Object Storageです。
主な特徴は次のとおりです。
- S3互換API
- Rustで実装
- Apache License 2.0
- Docker / Kubernetes対応
- 分散構成
- Erasure Coding
- Web Console
- IAM
- TLS
- Replication
- Versioning
- Object Lock
- OpenTelemetryを利用した監視
アプリケーションから見ると、RustFS専用APIを使うというより、AWS SDKなどのS3クライアントから接続する形になります。
RustFS公式ドキュメントでも、Pythonではboto3を使い、custom endpointとpath-style addressingを指定する例が紹介されています。
2. なぜMinIOの代替として見るのか
自分が重視したのは「Rustで書かれていること」より、次の点です。
S3 API
↓
Storageを交換できる
アプリケーション側を、
Application
│
│ S3 API
↓
Object Storage
という形にしておけば、保存先がRustFSでもMinIOでもAWS S3でも、変更箇所を小さくできます。
今回の実装でも、設定値は次のように一般化しています。
S3_ENDPOINT=http://rustfs:9000
S3_ACCESS_KEY=...
S3_SECRET_KEY=...
S3_REGION=us-east-1
S3_BUCKET=rustfs-demo
Python側にRustFSClientのような専用クラスを作るのではなく、boto3のS3 clientを使います。
3. S3互換でもAmazon S3そのものではない
RustFSはS3互換ですが、Amazon S3の全APIを完全に実装している、という意味ではありません。
RustFS公式のCompatibility Matrixでは、2026年8月9日時点で、
Implemented tests 455
Lifecycle behavior tests 5
が記載されています。
そのため、移行時は「S3互換だから大丈夫」と考えるより、実際に自分が使うAPIを確認した方が安全です。
今回のr001では、次を実際に確認しました。
CreateBucket
PutObject
GetObject
ListObjectsV2
DeleteObject
Presigned URL
4. 開発環境
今回の構成です。
Ubuntu 24.04系
Docker Engine
Docker Compose
Python 3.13
boto3
DuckDB
GeoPandas
Rasterio
rio-cogeo
PMTiles
FastAPI
OpenLayers
RustFSはS3 APIを9000番、Consoleを9001番で使います。
RustFS公式containerは非rootのUID 10001で動作します。RustFS自身へhost directoryをbind mountする場合は所有権に注意が必要です。
一方、今回のGIS変換用toolsコンテナは、host側のwork/へファイルを出すため、現在ユーザーのUID/GIDで動かしています。
5. RustFS imageはdigestで固定した
最初は、RustFS 1.0.0がリリースされたので、
image: rustfs/rustfs:v1.0.0
としていました。
しかし、このDocker tagは存在せず、manifest unknownになりました。
そこで一度latestをpullし、実際に取得できたRepoDigestを確認してから、検証環境ではdigest固定にしました。
実機で確認したimageは次です。
rustfs/rustfs@sha256:8cc9801755448b71a786705ce76692c77e14936cccd87cf2fc31842e58f4d1ff
latestをそのまま使い続けるより、動作確認できたdigestへ固定した方が、後から同じ環境を再現しやすくなります。
6. r001 — まずS3 APIを確認する
最初の段階ではGIS処理を入れず、RustFSそのものを確認しました。
Python
↓
boto3
↓
S3 API :9000
↓
RustFS
boto3 clientは次の考え方です。
from botocore.config import Config
s3 = boto3.client(
"s3",
endpoint_url="http://localhost:9000",
aws_access_key_id=access_key,
aws_secret_access_key=secret_key,
region_name="us-east-1",
config=Config(
signature_version="s3v4",
s3={"addressing_style": "path"},
),
)
r001では、Bucket作成からObjectの削除まで一連で実行するsmoke testを用意しました。
./scripts/run_smoke.sh
これが通ったので、次に実際のGISデータへ進みました。
7. r002 — GeoParquet・COG・PMTilesをRustFSへ置く
r002では次の流れを作りました。
GeoJSON / GeoTIFF
↓
変換
┌────┼────┐
↓ ↓ ↓
GeoParquet COG PMTiles
│ │ │
└───────┼───────┘
↓
RustFS
↓
┌───────┼────────┐
↓ ↓ ↓
DuckDB FastAPI OpenLayers
サンプルデータはプログラムで生成します。
別途ShapefileやGeoTIFFを用意しなくても、ZIPを展開してそのままE2Eを試せるようにしました。
参考のために、ZIPファイルを置いておきますので、ダウンロードして活用してください。
8. GeoParquet
ベクタ側は、サンプル領域を4分割したPolygonをGeoJSONとして作ります。
属性値は、確認しやすいように、
12
28
45
67
としています。
その後、GeoPandasでGeoParquetへ変換します。
変換後にはもう一度読み戻し、
- CRSがあるか
- Feature数が一致するか
を確認します。
RustFSへuploadした後も、別ディレクトリへdownloadしてSHA-256を照合します。
9. COG
ラスタ側は、PMTilesと同じ範囲になる256×256のGeoTIFFを作ります。
値は単純な平面ではなく、丘陵と勾配を組み合わせたサンプルにしています。
通常GeoTIFF
↓
rio-cogeo
↓
COG
↓
cog_validate(strict=True)
ここで一度、Docker内でRasterioをimportしたところ、
ImportError: libexpat.so.1: cannot open shared object file
になりました。
Python packageだけでは足りず、python:3.13-slim-bookworm側にlibexpat1が必要でした。
その後は、Docker image build時にも主要GIS moduleを実importして、native library不足をE2Eより前に検出するようにしています。
10. PMTiles
今回はE2E確認が目的なので、一般的な多段ズームのタイルピラミッドではなく、z=10の1タイルだけをPMTiles v3へ格納しています。
COG
↓
PNG 256×256
↓
PMTiles v3
PMTiles v3は先頭に127 byteの固定headerを持っています。
そのため、表示範囲を確認するだけなら、Object全体を取得する必要はありません。
Range: bytes=0-126
↓
PMTiles header
↓
bounds / minZoom / maxZoom / center
FastAPIの/api/map-metadataでは、この127 byteだけをRustFSからRange GETしています。
11. DuckDBからRustFSを直接読む
GeoParquetは、一度localへdownloadしてDuckDBへ渡すのではなく、DuckDBのhttpfsからRustFSを直接読みます。
RustFS
↓ S3 API
DuckDB
↓
read_parquet('s3://...')
custom S3 endpointなので、DuckDB側では、
ENDPOINT
URL_STYLE 'path'
USE_SSL false
を設定します。
実機での結果は、
Rows 4
Min value 12
Max value 67
となり、元の4Polygonと一致しました。
12. BrowserへAccess Keyを渡さない
OpenLayersからRustFSへ直接アクセスさせると、credentialの扱いが面倒になります。
そこで今回は、
Browser
↓ HTTP Range
FastAPI
↓ S3 Range GET
RustFS
という構成にしています。
FastAPIがRange headerを受け取り、boto3のget_object(Range=...)へ渡します。
これならJavaScriptへAccess Key / Secret Keyを埋め込む必要がありません。
13. 最初のOpenLayers画面には問題があった
E2EはPASSしていましたが、最初のWeb画面には問題がありました。
1タイルしか入っていないPMTilesのRasterが、右側一面へ繰り返して描画されていました。
原因は2つありました。
13.1 古いapp.jsがBrowser cacheに残っていた
HTMLだけ新しくなっていて、JavaScriptが古いままという状態が発生しました。
そこで、
<script src="/app.js?v=0.2.12"></script>
のようにbuild versionをURLへ付けました。
Nginx側も、検証用Web assetについては、
Cache-Control: no-store, no-cache, must-revalidate, max-age=0
を返します。
画面にも、
Web build: v0.2.12
を出すようにしました。
これで「本当に新しいJavaScriptが動いているか」を画面で確認できます。
13.2 PMTilesRasterSourceのTileGridがWeb Mercator全域だった
ol-pmtilesのPMTilesRasterSourceへTileGridを渡さないと、Web Mercator全域のXYZ gridが使われます。
今回のarchiveは1タイルだけなので、そのままだと周辺にも同じ表示が続いているように見えます。
ただし、ここで、
ol.tilegrid.createXYZ({ extent: pmtilesExtent })
とするだけではだめでした。
小さなextentをcreateXYZ()へ渡すと、そのextentを基準にz=0のresolutionが計算されるため、標準XYZのz/x/yと合わなくなります。
最終的には、標準Web Mercator gridからoriginとresolutionsを引き継ぎ、extentだけをPMTilesの実範囲へ限定しました。
const defaultGrid = ol.tilegrid.createXYZ({
maxZoom: archiveMaxZoom,
tileSize: 256,
});
const pmtilesTileGrid = new ol.tilegrid.TileGrid({
extent: pmtilesExtent,
origin: defaultGrid.getOrigin(0),
resolutions: defaultGrid.getResolutions(),
tileSize: defaultGrid.getTileSize(0),
minZoom: archiveMinZoom,
});
さらにLayer側にも、
raster.setExtent(pmtilesExtent);
raster.setVisible(true);
を設定しています。
sourceとlayerの両方で範囲を制限する形です。
14. 表示範囲もデータから決める
地図の初期位置をJavaScriptへ固定値で書くのではなく、PMTilesとGeoParquetの実データから決めます。
PMTiles header bounds ───┐
├─ union
GeoParquet total_bounds ─┘
↓
OpenLayers View.fit()
実機で返ったmetadataは次のとおりでした。
{
"crs": "EPSG:4326",
"pmtiles": {
"version": 3,
"bounds": [139.5703125, 35.4606699, 139.921875, 35.7465122],
"min_zoom": 10,
"max_zoom": 10
},
"geoparquet": {
"bounds": [139.5703125, 35.4606699514953, 139.921875, 35.7465122599185],
"feature_count": 4
}
}
これをEPSG:3857へ変換し、View.fit()しています。
15. 最終的なOpenLayers画面
最終画面です。
左側で次を確認できます。
接続状態 PASS / rustfs-demo
DuckDB Rows 4
Min value 12
Max value 67
Web build v0.2.12
PMTiles z10–10
PM bounds 表示あり
Geo bounds 表示あり
Features 4
PMTilesの疑似カラーRasterは、白いGeoParquetの4分割枠と同じ1タイル範囲だけに表示されています。
以前のように右側一面へ繰り返す表示はなくなりました。
16. Dockerで実際に実行する
展開後、まず環境ファイルを作ります。
chmod +x ./scripts/*.sh
./scripts/generate_env.sh
ここでhostのUID/GIDも.envへ保存します。
次に品質確認です。
./scripts/run_tests_docker.sh
確認しているのは、
Python target alignment
Python compileall
Ruff format
Ruff lint
mypy strict
pytest
です。
その後E2Eを実行します。
./scripts/run_e2e.sh
最終的に、
Map metadata: PASS
OpenLayers fix012 assets/cache: PASS
PMTiles Range: PASS
R002 E2E: PASS
まで出れば、バックエンド側は完了です。
ブラウザは、
RustFS Console : http://localhost:9001
FastAPI docs : http://localhost:8000/docs
OpenLayers : http://localhost:8080
です。
17. bind mountの所有権も直した
途中で、E2Eを2回実行すると、
Permission denied
になりました。
原因は、コンテナがwork/へroot所有のファイルを作っていたためです。
最終版では、
HOST_UID
HOST_GID
をgenerate_env.shで取得し、GIS処理用コンテナをhostと同じUID/GIDで動かします。
過去のroot所有ファイルが残っている場合は、reset_workdir.shがDocker側rootで掃除し、その後host UID/GIDへ所有権を戻します。
E2E中にも、
Work ownership: PASS (1000:1000)
を確認します。
18. Docker networkも増え続けないようにした
fixを繰り返していると、Docker Composeのdefault networkが残り、
all predefined address pools have been fully subnetted
になりました。
品質ゲートはネットワーク通信を必要としないので、最終版では、
docker build
↓
docker run --network none
で実行します。
E2E側ではRustFS r002系列の未使用networkだけを掃除し、他プロジェクトのnetworkには触れません。
19. 実機で確認できた結果
最終的なE2Eでは次まで確認できました。
RustFS image pull PASS
Docker network capacity PASS
Work directory reset PASS owner=1000:1000
RustFS readiness PASS
R002 GEO E2E PASS
feature_count 4
COG valid True
PMTiles version 3
DuckDB rows 4
DuckDB min 12.0
DuckDB max 67.0
Work ownership PASS
Map metadata PASS
OpenLayers assets/cache PASS
PMTiles HTTP Range PASS
R002 E2E PASS
品質ゲートでは、実機のfix012で、
Ruff lint PASS
mypy strict PASS
pytest PASS 49 passed
まで確認しています。
ruff format --checkだけ、テストコードの引用符整形が1件残っていましたが、最終配布版ではRuffが表示した差分どおりに修正しています。機能側の変更はありません。
20. 実装の構成
最終版は次の構成に整理しました。
rustfs_minio_replacement_r002_final/
├── compose.yaml
├── Dockerfile
├── Dockerfile.test
├── pyproject.toml
├── README.md
├── VALIDATION.md
├── src/
│ └── rustfs_demo/
├── scripts/
├── tests/
├── web/
├── images/
└── docs/
├── QIITA_FINAL.md
├── CODE_GUIDE.md
├── TESTING.md
├── MIGRATION.md
└── history/
21. RustFSをMinIOの代わりに使えるか
今回の範囲では、
boto3
GeoParquet
COG
PMTiles
DuckDB
FastAPI
OpenLayers
まで、RustFSをS3互換Object Storageとして使えました。
一方、これだけで本番のMinIOをすぐ置き換える、とは考えていません。
RustFS 1.0.0は出たばかりですし、本番ではさらに、
- 大量小Object
- 数GB以上のObject
- Multipart Upload
- 長時間連続運転
- Disk障害
- Node障害
- Network障害
- Healing
- Backup / Restore
- Upgrade
を確認する必要があります。
今回一番大事だったのは、RustFSをアプリケーションへ直接埋め込むのではなく、S3 APIを境界にしたことです。
この形なら、次の比較もやりやすくなります。
MinIO
VS
RustFS
次は、同じデータと同じDocker環境で、PUT / GET性能、CPU、Memory、Disk I/O、障害復旧まで比較する予定です。
参考資料
RustFS
-
RustFS Documentation
https://docs.rustfs.com/ -
RustFS Container
https://docs.rustfs.com/en/installation/container -
RustFS S3 Compatibility Matrix
https://docs.rustfs.com/en/reference/s3-compatibility -
RustFS boto3 Example
https://docs.rustfs.com/en/developer/examples/boto3 -
RustFS SDK Overview
https://docs.rustfs.com/en/developer/sdk -
RustFS GitHub Releases
https://github.com/RustFS/RustFS/releases
MinIO
- MinIO GitHub
https://github.com/minio/minio
PMTiles / OpenLayers
-
PMTiles v3 Specification
https://github.com/protomaps/PMTiles/blob/main/spec/v3/spec.md -
OpenLayers TileGrid
https://openlayers.org/en/latest/apidoc/module-ol_tilegrid_TileGrid-TileGrid.html -
OpenLayers View
https://openlayers.org/en/latest/apidoc/module-ol_View-View.html



