Ubuntuサーバ:PostGIS + PostgreSQL + Flask 連携セットアップ(非Docker)
目的:PostgreSQL + PostGIS に地理情報(POINT/ポリゴン等)を格納し、Flask API から CRUD と空間検索(近傍/範囲/内包判定)を動かす。
推奨:PostgreSQL 16+ / PostGIS 3.x / Python 3.10+(Ubuntu 22.04/24.04想定)。
- 1. 全体像
- 1.1 PostGISを使うメリット(例つき・Leafletだけだとダメな理由)
- 2. Ubuntu:PostgreSQL + PostGIS インストール
- 3. DB作成(ユーザー・DB・拡張・推奨設定)
- 4. Python環境(venv)と依存導入
- 5. Flaskサンプル(Flask-SQLAlchemy + GeoAlchemy2)
- 6. マイグレーション(Flask-Migrate)
- 7. API動作確認(登録・一覧・近傍・範囲・ハザード内包)
- 8. API ⇔ PostGIS関数 対応表
- 9. 実運用の勘所(インデックス・SRID・距離単位・性能)
- 10. よくあるトラブルと即解決
1. 全体像
構成
- DB:PostgreSQL(RDB)
- 拡張:PostGIS(空間型・空間関数・GIST/SP-GiSTインデックス)
- アプリ:Flask(API)
- ORM:Flask-SQLAlchemy + GeoAlchemy2
この手順のゴール
placesに緯度経度(POINT)を格納- 半径○m以内の近傍検索をAPIで返す
hazard_areas(ポリゴン)を用意し「点がエリア内か」判定する
距離を「メートル」で扱うため、POINTは Geography型(WGS84/SRID4326)を採用。
ポリゴン(ハザード等)は Geometry型(SRID4326)で管理し、判定時に必要なら地理(geography)キャストでm計算もできます。
1.1 PostGISを使うメリット(例つき・Leafletだけだとダメな理由)
PostGISは「地図を表示するための部品」じゃなく、空間検索と判定を“正しく・速く・運用可能に”するDB拡張です。
つまり、Leafletがいくら優秀でも、Leafletだけで空間ロジックを背負わせると破綻しやすい。
PostGISのメリット(実務で効く順)
-
① 近傍検索が速い(インデックスで勝つ)
例:「現在地から避難所を半径500mで抽出」。
PostGISならST_DWithin+ GIST で候補だけを絞り、ST_Distanceで距離順に並べられます。
件数が数万〜数百万でも「DBが得意な方法」で捌ける。 -
② “点がポリゴン内か” をDBで正確に判定できる
例:「撮影地点が浸水想定区域(ポリゴン)に入ってる?」。
PostGISならST_Contains/ST_Withinで一発。
(ブラウザ側でやると、データ転送・精度・更新・セキュリティで痛い目を見がち) -
③ “メートル”の世界で距離や半径が扱える(事故りにくい)
例:「半径R=30mに入ったらPOI侵入」。
Geographyなら距離はmで揃う。geometryのまま度で計算して「なんかズレる」事故が減ります。 -
④ DBのJOIN/集計と空間ロジックを合体できる
例:「過去24時間のアラート件数が多いエリアだけ抽出」+「その中でハザード濃度が濃い」を同時にやる。
これ、フロントだけだとまず無理(データが重すぎ、処理が複雑、再現性が死ぬ)。 -
⑤ GeoJSON出力や変換が楽(APIが薄くなる)
ST_AsGeoJSON等でDB→JSONが直結。Flaskは“配達員”に徹してよくなります。
Leaflet「だけ」だとなぜダメか(刺さる現実)
-
データが増えた瞬間に終わる
Leafletで近傍検索をやる=候補データをブラウザへ大量に送る or 逐次取得して総当たり。
数万件を超えると回線・メモリ・CPUで詰みます。スマホなら即死。 -
セキュリティ/権限が崩壊しやすい
ポリゴン(ハザード、重要施設、契約情報付きPOIなど)をクライアントに配る時点で漏れます。
「見せていい範囲だけDBで絞る」が基本。Leafletは表示係。 -
“正しさ”の担保が難しい
ポリゴンの自己交差、不正ジオメトリ、SRID混在、境界の扱い…こういう地雷をフロントで抱えるのは罰ゲーム。
PostGISならST_IsValidや制約、統一SRIDで整合性を守れます。 -
運用(更新・再計算・監査・再現性)が死ぬ
「いつのデータで判定した?」をサーバ側で追跡できない構成は、後から揉めます。
DBで処理して結果と条件をログに残すのが現実的。
まとめ:Leafletは表示とUIが強い。PostGISは検索と判定が強い。役割分担しないと、だいたい燃えます。
2. Ubuntu:PostgreSQL + PostGIS インストール
Ubuntu PostgreSQL本体:
sudo apt update
sudo apt install -y postgresql postgresql-contrib
PostGIS:
# だいたいこれで入る
sudo apt install -y postgis
# extension が見つからない/入らない場合は、PGメジャーに合わせて入れる
PG_MAJOR=$(psql -V | awk '{print $3}' | cut -d. -f1)
sudo apt install -y "postgresql-$PG_MAJOR-postgis-3" "postgresql-$PG_MAJOR-postgis-3-scripts"
サービス確認:
sudo systemctl enable --now postgresql
sudo systemctl status postgresql
外部(別PC)から接続するなら、postgresql.conf(listen_addresses)と pg_hba.conf を設定し、FWも開けます。
ただし最初は サーバ内でpsqlを叩く のが一番早い(切り分けが楽)。
3. DB作成(ユーザー・DB・拡張・推奨設定)
まずpostgresでpsqlへ:
sudo -u postgres psql
アプリ用ユーザーとDB:
CREATE USER appuser WITH PASSWORD 'apppass';
CREATE DATABASE appdb OWNER appuser;
\c appdb
CREATE EXTENSION IF NOT EXISTS postgis;
SELECT PostGIS_Version();
パスワードは例です。実運用では強いものに変更。
(任意)日本語ソートや全文検索もやるなら
-- 日本語照合が必要ならDB作成時にlocaleを選ぶ(後から変更しにくい)
-- ここでは割愛。必要なら要件次第で決める。
4. Python環境(venv)と依存導入
プロジェクト作成(Ubuntuサーバ内):
mkdir -p /opt/pg_flask_demo
cd /opt/pg_flask_demo
python3 -m venv .venv
source .venv/bin/activate
pip install --upgrade pip
依存(この構成でマイグレーション運用が安定):
pip install flask flask-sqlalchemy flask-migrate psycopg2-binary geoalchemy2 python-dotenv
本番でより堅くするなら、psycopg2 をソースビルド(libpq)にする/ psycopg へ移行もありますが、まずは動かすのが先。
5. Flaskサンプル(Flask-SQLAlchemy + GeoAlchemy2)
ファイル構成
/opt/pg_flask_demo/
app.py
.env
migrations/ (後で作られる)
.env(接続文字列)
DATABASE_URL=postgresql+psycopg2://appuser:apppass@127.0.0.1:5432/appdb
FLASK_ENV=development
app.py(コピペで動く。空間検索も入ってます)
from __future__ import annotations
import os
from typing import Any, Dict, List, Optional
from dotenv import load_dotenv
from flask import Flask, jsonify, request
from flask_migrate import Migrate
from flask_sqlalchemy import SQLAlchemy
from geoalchemy2 import Geography, Geometry
from sqlalchemy import Index, cast, func
load_dotenv()
db = SQLAlchemy()
migrate = Migrate()
# ---- Models ----
class Place(db.Model):
__tablename__ = "places"
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(200), nullable=False)
# 距離をmで扱うためGeography(POINT)
# SRIDはGPSのWGS84で4326固定
location = db.Column(Geography(geometry_type="POINT", srid=4326), nullable=False)
Index("ix_places_location_gist", Place.location, postgresql_using="gist")
class HazardArea(db.Model):
"""
ハザード例(浸水/土砂/津波など)を想定:ポリゴン(MULTIPOLYGON)
SRID=4326のGeometryで保持(判定はST_Contains等)。
"""
__tablename__ = "hazard_areas"
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(200), nullable=False)
kind = db.Column(db.String(50), nullable=False) # flood/landslide/tsunami etc
geom = db.Column(Geometry(geometry_type="MULTIPOLYGON", srid=4326), nullable=False)
Index("ix_hazard_areas_geom_gist", HazardArea.geom, postgresql_using="gist")
# ---- App Factory ----
def create_app() -> Flask:
app = Flask(__name__)
db_url = os.environ.get("DATABASE_URL")
if not db_url:
raise RuntimeError("DATABASE_URL is not set. Put it in .env")
app.config["SQLALCHEMY_DATABASE_URI"] = db_url
app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
db.init_app(app)
migrate.init_app(app, db)
# ---- Routes ----
@app.get("/health")
def health():
return {"ok": True}
@app.post("/api/places")
def create_place():
"""
body: { "name": "...", "lat": 35.6, "lon": 139.7 }
"""
data: Dict[str, Any] = request.get_json(force=True)
name = str(data.get("name", "")).strip()
lat = data.get("lat")
lon = data.get("lon")
if not name:
return jsonify({"error": "name is required"}), 400
if lat is None or lon is None:
return jsonify({"error": "lat/lon are required"}), 400
# WKT: POINT(lon lat) ※順序はlonが先
wkt = f"SRID=4326;POINT({float(lon)} {float(lat)})"
p = Place(name=name, location=wkt)
db.session.add(p)
db.session.commit()
return jsonify({"id": p.id, "name": p.name, "lat": float(lat), "lon": float(lon)}), 201
@app.get("/api/places")
def list_places():
"""
GET /api/places?limit=50
"""
limit = int(request.args.get("limit", "50"))
limit = max(1, min(limit, 500))
# Geographyからlon/latを取り出すには geometry にキャストしてST_X/ST_Y
geom_point = cast(Place.location, Geometry(geometry_type="POINT", srid=4326))
rows = (
db.session.query(
Place.id,
Place.name,
func.ST_Y(geom_point).label("lat"),
func.ST_X(geom_point).label("lon"),
)
.order_by(Place.id.desc())
.limit(limit)
.all()
)
out = [{"id": r.id, "name": r.name, "lat": float(r.lat), "lon": float(r.lon)} for r in rows]
return jsonify(out)
@app.get("/api/nearby")
def nearby():
"""
/api/nearby?lat=35.681&lon=139.767&r=300
r: meters
"""
lat = request.args.get("lat")
lon = request.args.get("lon")
r = request.args.get("r", "300")
if lat is None or lon is None:
return jsonify({"error": "lat/lon are required"}), 400
lat_f = float(lat)
lon_f = float(lon)
r_m = float(r)
# 対象点(geography)
point_geog = func.ST_GeogFromText(f"SRID=4326;POINT({lon_f} {lat_f})")
geom_point = cast(Place.location, Geometry(geometry_type="POINT", srid=4326))
rows = (
db.session.query(
Place.id,
Place.name,
func.ST_Y(geom_point).label("lat"),
func.ST_X(geom_point).label("lon"),
func.ST_Distance(Place.location, point_geog).label("distance_m"),
)
.filter(func.ST_DWithin(Place.location, point_geog, r_m))
.order_by(func.ST_Distance(Place.location, point_geog))
.limit(200)
.all()
)
out = [
{"id": r.id, "name": r.name, "lat": float(r.lat), "lon": float(r.lon), "distance_m": float(r.distance_m)}
for r in rows
]
return jsonify(out)
@app.get("/api/places/bbox")
def places_bbox():
"""
/api/places/bbox?minlat=...&minlon=...&maxlat=...&maxlon=...
地図の表示範囲(BBox)内の点だけ返す(フロントの地図移動で定番)
"""
minlat = request.args.get("minlat")
minlon = request.args.get("minlon")
maxlat = request.args.get("maxlat")
maxlon = request.args.get("maxlon")
if None in (minlat, minlon, maxlat, maxlon):
return jsonify({"error": "minlat/minlon/maxlat/maxlon are required"}), 400
minlat_f, minlon_f = float(minlat), float(minlon)
maxlat_f, maxlon_f = float(maxlat), float(maxlon)
env = func.ST_MakeEnvelope(minlon_f, minlat_f, maxlon_f, maxlat_f, 4326)
geom_point = cast(Place.location, Geometry(geometry_type="POINT", srid=4326))
rows = (
db.session.query(
Place.id,
Place.name,
func.ST_Y(geom_point).label("lat"),
func.ST_X(geom_point).label("lon"),
)
.filter(func.ST_Intersects(geom_point, env))
.order_by(Place.id.desc())
.limit(2000)
.all()
)
out = [{"id": r.id, "name": r.name, "lat": float(r.lat), "lon": float(r.lon)} for r in rows]
return jsonify(out)
@app.get("/api/hazards/check")
def hazards_check():
"""
/api/hazards/check?lat=...&lon=...&kind=flood
点がハザードポリゴン内に入っているかを返す
"""
lat = request.args.get("lat")
lon = request.args.get("lon")
kind = request.args.get("kind") # 任意
if lat is None or lon is None:
return jsonify({"error": "lat/lon are required"}), 400
lat_f, lon_f = float(lat), float(lon)
point = func.ST_SetSRID(func.ST_MakePoint(lon_f, lat_f), 4326)
q = db.session.query(HazardArea.id, HazardArea.name, HazardArea.kind).filter(
func.ST_Contains(HazardArea.geom, point)
)
if kind:
q = q.filter(HazardArea.kind == kind)
rows = q.order_by(HazardArea.id.desc()).limit(200).all()
out = [{"id": r.id, "name": r.name, "kind": r.kind} for r in rows]
return jsonify(out)
return app
app = create_app()
if __name__ == "__main__":
# 開発用途。運用はgunicorn等へ。
app.run(host="127.0.0.1", port=5000, debug=True)
6. マイグレーション(Flask-Migrate)
この構成(Flask-SQLAlchemy)なら、マイグレーションが素直に回ります。
cd /opt/pg_flask_demo
source .venv/bin/activate
# 初期化(migrations/ が作られる)
flask --app app.py db init
# 差分作成
flask --app app.py db migrate -m "create places and hazard_areas"
# 反映
flask --app app.py db upgrade
DB側で CREATE EXTENSION postgis; を忘れていると、空間型が作れずに詰みます。先に有効化してからmigrate。
7. API動作確認(登録・一覧・近傍・範囲・ハザード内包)
起動:
cd /opt/pg_flask_demo
source .venv/bin/activate
python app.py
7.1 places登録
curl -X POST "http://127.0.0.1:5000/api/places" \
-H "Content-Type: application/json" \
-d '{"name":"Tokyo","lat":35.681236,"lon":139.767125}'
7.2 places一覧
curl "http://127.0.0.1:5000/api/places?limit=50"
7.3 近傍検索(半径300m)
curl "http://127.0.0.1:5000/api/nearby?lat=35.681236&lon=139.767125&r=300"
7.4 表示範囲(BBox)で点を取る
curl "http://127.0.0.1:5000/api/places/bbox?minlat=35.67&minlon=139.75&maxlat=35.70&maxlon=139.78"
7.5 ハザードポリゴンを1件入れて内包判定(サンプル)
ここはSQLで入れるのが最速です(ポリゴンの例は「四角形」。実データは行政のGeoJSON等を取込)。
注意: MULTIPOLYGON を期待しているので、例では単一POLYGONをMULTIPOLYGONに変換します。
# 別ターミナルでDBへ
sudo -u postgres psql appdb
-- テスト用:東京駅付近を雑に囲う“テスト浸水”ポリゴン(四角)
INSERT INTO hazard_areas (name, kind, geom)
VALUES (
'テスト浸水エリア',
'flood',
ST_Multi(
ST_GeomFromText(
'SRID=4326;POLYGON((139.760 35.678, 139.775 35.678, 139.775 35.686, 139.760 35.686, 139.760 35.678))'
)
)
);
-- 判定(東京駅点が入っているか)
-- API側でやるのでここは確認用
SELECT id, name, ST_Contains(geom, ST_SetSRID(ST_MakePoint(139.767125,35.681236),4326)) AS inside
FROM hazard_areas;
APIで判定:
curl "http://127.0.0.1:5000/api/hazards/check?lat=35.681236&lon=139.767125&kind=flood"
JSONが返れば勝ち。
8. API ⇔ PostGIS関数 対応表
「どのAPIがどのPostGIS関数に依存しているか」を固定で把握できる表。
これが無いと、後で誰かが “なんとなくSQL” をいじって地雷を踏みます。
| API | 用途 | 主に使うPostGIS関数 | 型/インデックス | 注意点(落とし穴) |
|---|---|---|---|---|
POST /api/places |
点(緯度経度)を登録 | (保存時)ST_GeogFromText 相当(WKTで格納) |
Geography(POINT) GIST推奨 |
POINTはlon→lat順。ここ間違えると全部ズレる。 |
GET /api/places |
点一覧(lat/lon表示) | ST_X, ST_Y(※ geography→geometryへキャスト) |
Geography→Geometryキャスト | ST_X/ST_Y は geometry向け。cast必須。 |
GET /api/nearby |
近傍検索(半径m)+距離順 | ST_DWithin, ST_Distance, ST_GeogFromText |
Geography + GIST |
GIST無し=遅い。 距離単位はm(Geography)。geometryにすると度で事故りやすい。 |
GET /api/places/bbox |
表示範囲内(BBox)だけ返す | ST_MakeEnvelope, ST_Intersects |
Pointをgeometryキャスト GIST推奨 |
Leafletの地図移動に合わせて呼ぶ定番。返す件数は必ず制限。 |
GET /api/hazards/check |
点がハザードポリゴン内か | ST_Contains, ST_MakePoint, ST_SetSRID |
Geometry(MULTIPOLYGON) + GIST |
ポリゴンが不正形状だと判定が壊れる。 取込時に ST_IsValid / ST_MakeValid を検討。
|
(追加でよく使う)API候補と関数
| 用途 | 関数 | 何が嬉しいか |
|---|---|---|
| 「半径Rのバッファ生成」 | ST_Buffer(geometry/geography) |
侵入ゾーン可視化、危険域生成(ただし単位と投影に注意) |
| 「ポリゴン同士の交差面積」 | ST_Intersection, ST_Area |
重なり割合でリスクスコア化ができる |
| 「最寄り1件を高速に」 | <->(KNN) |
GISTで最寄り近似を速く出せる(大量データ向け) |
| 「線にスナップ」 | ST_ClosestPoint, ST_LineLocatePoint |
道路中心線に寄せる、巡視ルート上の位置補正など |
9. 実運用の勘所(インデックス・SRID・距離単位・性能)
| 項目 | 結論(事故らない設定) |
|---|---|
| SRID | GPSの緯度経度は SRID=4326 固定(混ぜない) |
| 距離単位 | 距離をmで扱うなら Geography(今回のPOINT) |
| インデックス | GIST を必ず貼る(近傍・交差が桁違いに速くなる) |
| 返却件数 | APIは必ず limit を持つ(無制限返却はDoSと同じ) |
| 性能確認 | EXPLAIN (ANALYZE, BUFFERS) でインデックスが効いてるか確認 |
| DB保守 | 定期的に VACUUM (ANALYZE)(自動でも状況次第で手動) |
近傍検索が遅いときの9割は「GIST貼ってない」「キャストでインデックスが死んでる」「条件がズレて全件走査」です。
“遅い”はだいたい理由が単純。DBは正直。
10. よくあるトラブルと即解決
10.1 extension "postgis" is not available
結論:PostGISが入ってない / PGバージョンに合ってない。
sudo apt update
sudo apt install -y postgis
PG_MAJOR=$(psql -V | awk '{print $3}' | cut -d. -f1)
sudo apt install -y "postgresql-$PG_MAJOR-postgis-3" "postgresql-$PG_MAJOR-postgis-3-scripts"
10.2 FATAL: password authentication failed
- ユーザー名/パスワードが違う(まずここ)
pg_hba.confが peer/scram で想定と違う
まず psql で同じ認証情報で入れるか確認。入れないならアプリ以前の問題です。
10.3 “近傍検索が遅い”
結論:だいたい GISTインデックス無し か、キャストでインデックスが無効化。
-- DBで確認(appdbで)
\d places
-- ix_places_location_gist があるか確認
-- 効いてるか確認
EXPLAIN (ANALYZE, BUFFERS)
SELECT id
FROM places
WHERE ST_DWithin(
location,
ST_GeogFromText('SRID=4326;POINT(139.767125 35.681236)'),
300
);
10.4 緯度経度が逆っぽい
結論:WKTは POINT(lon lat)。lonが先。これを間違えると全データが海に飛びます。
ここまでで「Ubuntu上のPostGISを使って、Flask APIから空間検索・判定を動かす」まで一通り完成。
次の現実的な拡張は、①実データ(行政GeoJSON)取込スクリプト、②認証(JWT等)と権限、③gunicorn + nginx、④監視(pg_stat_statements)です。