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. 全体像

構成

  • 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)です。

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です

CAPTCHA