0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

qwc-services による Web GIS の構築 #6 MapCache 再び

Last updated at Posted at 2025-03-29

QGIS Server と QGIS Web Client 2 (QWC2) を中核とする統合的 Web GIS エコ・システムである qwc-services によって Web GIS を構築する作業記録の第6回です。

第1回では、qwc-docker によって qwc-services をインストールし、MapCache とも連携させて、QGIS のプロジェクトを Web 地図として表示するところまでやりました。

第2回では、ユーザの認証と権限管理について調べて、地図やレイヤを閲覧できるユーザを限定したり、ビューワの機能を使用できるユーザを限定したりする方法を確認しました。

第3回では、qwc-services によって実現される「編集」関連の機能に不可欠なデータベース環境である PostGIS を使えるようにしました。

第4回は、ちょっと寄り道をして、QWC2 Viewer とは独立したページを Web GIS に追加する方法として、Python のマイクロ・ウェブ・フレームワークである Flask の使用を検討しました。

前回は、qwc-servicesPostGIS によって実現される編集機能について、必要となる前提条件および具体的な使用方法を説明しました。

今回は地図画像キャッシュの問題を再び扱います。

1. 編集機能と地図画像キャッシュの問題

WebGIS で編集機能が使えるようになると、地物データと地図画像キャッシュとの間に生じるズレが問題となります。

地物データが更新されたのに古い地図画像キャッシュが残っていると、

  • 追加した地物が表示されない
  • 削除した地物がまだ表示されている
  • 位置や形状を変更した地物が古い位置や形状で表示される
  • 変更した文字ラベルが古いまま表示される

というようなことが生じるのです。

2. 地図画像キャッシュの無効化

上記のズレの問題を解消するためには、地物データに更新があるたびに影響を受ける範囲の地図画像キャッシュを無効化(削除)する必要があります。

キャッシュ・サーバは当該領域の地図画像データを要求されたときに、有ればそのキャッシュを返しますが、無ければ WMS サーバに要求して地図画像データを取得し、キャッシュを再作成して返します。(on-the-fly シーディング)

従って、旧くなったキャッシュを無効化(削除)してやりさえすれば良い、ということになりますが、リアルタイムで働くキャッシュ無効化の仕組みを作るとなると、少し工夫が必要になります。

ここでは、キャッシュ無効化の処理の流れを次のようにします。

  1. PostGIS 上で地物が更新される
    1. 更新に影響される範囲をログ・テーブルに記録する
    2. キャッシュ無効化サービスに更新を通知する
  2. キャッシュ無効化サービスが更新の通知を受ける
    1. ログ・テーブルから未処理レコードを読みとる
    2. 更新に影響される範囲のキャッシュを無効化する
    3. 処理済みフラグを立ててログ・テーブルを更新する

2-1. PostGIS 側の処理

2-1-1. キャッシュ無効化ログ・テーブル

次のようなキャッシュ無効化ログ・テーブル cache_invalidation_log を作成します。

cache_invalidation_log
CREATE TABLE cache_invalidation_log (
    id         serial PRIMARY KEY,
    layer_name text NOT NULL,
    bbox_xmin  double precision NOT NULL,
    bbox_ymin  double precision NOT NULL,
    bbox_xmax  double precision NOT NULL,
    bbox_ymax  double precision NOT NULL,
    event_time timestamptz DEFAULT now(),
    processed  boolean NOT NULL DEFAULT false
);
  • layer_name ... レイヤ名(タイル名)
  • bbox_xmin, bbox_ymin, bbox_xmax, bbox_ymax ... 無効化する範囲(CRS = EPSG:3857, 単位 = m)
  • event_time ... 更新イベント発生日時
  • processed ... 処理済みフラグ

テーブルの作成等には psql を使用して SQL を実行するのがお奨めです。

まず、psql を対話モードで起動します。

psql -U db_user db_name

Docker コンテナ内にある PostgreSQL サーバであれば、docker exec を使って、psql を起動します。

docker exec -it <container_id> psql -U db_user db_name

pgAdmin4GUI からでもテーブル等の作成は可能ですが、設定可能なオプションが多すぎて、却って使いづらく感じます。

いったん作成したものを修正するのは、pgAdmi4 の方が楽ですけれど。

2-1-2. キャッシュ設定テーブル

レイヤごとのキャッシュ設定データを保持するテーブル cache_config を次のように作成します。

cache_config
CREATE TABLE cache_config (
    layer_name       text PRIMARY KEY,
    buff_east_west   double precision NOT NULL,
    buff_north_south double precision NOT NULL
);
  • buff_east_west ... 東西方向のバッファ(単位 m)
  • buff_north_south ... 南北方向のバッファ(単位 m)

そして、次のように必要なデータを登録しておきます。

layer_name buff_east_west buff_north_south
spots 300 100
tanada 10 10
bld 10 10

spotsPoint 型のデータですが、地名・建物名などのラベルを表示する地物であるため、特に左右方向のバッファをかなり大きく取っています。tanadabldPolygon 型の地物で、現状では文字ラベルを表示していませんので、それほど大きなバッファは必要ありません。

2-1-3. 共通トリガ関数

各レイヤのテーブルからトリガで呼ばれてキャッシュ無効化ログを記録するトリガ関数 log_cache_invalidation() を作成します。

log_cache_invalidation()
CREATE OR REPLACE FUNCTION log_cache_invalidation()
    RETURNS trigger
    LANGUAGE 'plpgsql'
AS $BODY$
DECLARE
  bbox geometry;
  layer text := TG_ARGV[0];  -- トリガ実行時に渡される
  buff_x double precision;
  buff_y double precision;
  bbox_3857_old geometry := NULL;
  bbox_3857_new geometry := NULL;
BEGIN
  -- キャッシュ設定を取得
  SELECT buff_east_west, buff_north_south
  INTO buff_x, buff_y
  FROM cache_config WHERE layer_name = layer;

  IF buff_x IS NULL THEN
    RAISE WARNING 'No cache config for layer %, using default values', layer;
    buff_x := 10;
	buff_y := 10;
  END IF;

  -- bbox を取得 /(EPSG:6673)→ EPSG:3857 に変換 / バッファ処理
  IF TG_OP = 'DELETE' THEN
	  bbox_3857_old := ST_Transform(OLD.geom, 3857);
	  bbox_3857_old := ST_Expand(bbox_3857_old, buff_x, buff_y);
  ELSEIF TG_OP = 'INSERT' THEN
	  bbox_3857_new := ST_Transform(NEW.geom, 3857);
	  bbox_3857_new := ST_Expand(bbox_3857_new, buff_x, buff_y);
  ELSE
	  bbox_3857_old := ST_Transform(OLD.geom, 3857);
	  bbox_3857_old := ST_Expand(bbox_3857_old, buff_x, buff_y);
	  bbox_3857_new := ST_Transform(NEW.geom, 3857);
	  bbox_3857_new := ST_Expand(bbox_3857_new, buff_x, buff_y);
	  IF ST_Distance(bbox_3857_old, bbox_3857_new) < 1.0 THEN
	     bbox_3857_old := ST_Union(bbox_3857_old, bbox_3857_new);
		 bbox_3857_new := NULL;
	  END IF;
  END IF;
	
  -- BBOX を記録
  IF bbox_3857_old IS NOT NULL THEN
    INSERT INTO cache_invalidation_log (
      layer_name,
      bbox_xmin, bbox_ymin, bbox_xmax, bbox_ymax
    )
    SELECT
      layer,
      ST_XMin(bbox_3857_old),
      ST_YMin(bbox_3857_old),
      ST_XMax(bbox_3857_old),
      ST_YMax(bbox_3857_old);
  END IF;

  IF bbox_3857_new IS NOT NULL THEN
    INSERT INTO cache_invalidation_log (
      layer_name,
      bbox_xmin, bbox_ymin, bbox_xmax, bbox_ymax
    )
    SELECT
      layer,
      ST_XMin(bbox_3857_new),
      ST_YMin(bbox_3857_new),
      ST_XMax(bbox_3857_new),
      ST_YMax(bbox_3857_new);
  END IF;

  PERFORM pg_notify('cache_invalidation', layer);

  RETURN NEW;
END;
$BODY$;
  1. cache_config テーブルからレイヤのキャッシュ設定を取得
    • テーブルに設定が無い場合はデフォルト値を設定
  2. 更新された地物の geometrybbox を取得
    • トリガ起動操作別の処理
      • DELETE ... 旧いレコードの bbox を取得
      • INSERT ... 新しいレコードの bbox を取得
      • UPDATE ... 旧いレコードと新しいレコードの bbox を取得
        • 距離が近ければ結合して旧い方の bbox だけにまとめる
    • 共通の処理
      • レイヤの CRS から MapCache 用の EPSG:3857 に変換
      • バッファ分だけ bbox を拡大
  3. ログにレイヤ名、bbox を記録
    • 旧いレコードの分、または、新しいレコードの分、または、まれに新旧両方
  4. cache_invalidation 通知を発行
    • 以後の処理はキャッシュ無効化サービスにゆだねる

2-1-4. トリガ定義

各レイヤのテーブルに次のようにトリガを定義します。

trigger
CREATE TRIGGER trg_cache_log_xxxxxx
AFTER INSERT OR UPDATE OR DELETE ON xxxxxx
FOR EACH ROW
EXECUTE FUNCTION log_cache_invalidation('xxxxxx');

xxxxxx の所には spots, tanada, bld などのテーブル名(レイヤ名)が入ります。

2-1-5. 動作確認

psql で次のような SQL を実行してみます。

UPDATE xxxxxx SET geom = geom WHERE id = 1;

cache_invalidation_log テーブルに新しいログが記録されたら、正常に動作していると見て良いでしょう。

2-2. キャッシュ無効化サービス

Python でキャッシュ無効化サービスのスクリプトを作成します。

2-2-1. 準備作業

2-2-1-1. psycopg2-binary

作成するスクリプトが依存するライブラリ psycopg2-binary をインストールします。

pip install psycopg2-binary

この作業の前提条件として python3python3-pipdnf でインストールしておく必要がある場合もあります。

sudo dnf install python3 python3-pip
2-2-1-2. /etc/sudoers

/etc/sudoers の末尾に以下の行を追加します。

/etc/sudoers
## Allows people in group wheel to execute /usr/bin/mapcache_seed without password
%wheel ALL=(apache) NOPASSWD: /usr/bin/mapcache_seed

これによって、常用するユーザ(e.g. my-accout)が sudo -u apache mapcache_seed ... をパスワード無しで実行出来るようになります。

2-2-2. /usr/local/bin/cache_invalidate.py

次の内容で /usr/local/bin/cache_invalidate.py を作成します。

cache_invalidate.py
#!/usr/bin/env python3

import psycopg2
import psycopg2.extensions
import select
import subprocess
import time
import logging

# 設定
DB_NAME = "gisdb"
DB_USER = "gisdb"
DB_PASSWORD = "xxxxxxxx"
DB_HOST = "192.168.20.11"
DB_PORT = 5432
MAPCACHE_CONFIG = "/etc/mapcache.xml"

# ログ設定
logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s [%(levelname)s] %(message)s"
)
logger = logging.getLogger(__name__)

def invalidate_cache(layer_name, conn):
    with conn.cursor() as cur:
        cur.execute("""
            SELECT id, bbox_xmin, bbox_ymin, bbox_xmax, bbox_ymax
            FROM cache_invalidation_log
            WHERE processed = false AND layer_name = %s
        """, (layer_name,))
        rows = cur.fetchall()

        for row in rows:
            id, xmin, ymin, xmax, ymax = row
            logger.info(f"Invalidating {layer_name} (id={id})")

            cmd = [
                "sudo", "-u", "apache", "mapcache_seed",
                "-c", MAPCACHE_CONFIG,
                "-t", layer_name,
                "-e", f"{xmin},{ymin},{xmax},{ymax}",
                "-m", "delete"
            ]

            try:
                subprocess.run(cmd, check=True)
                cur.execute("""
                    UPDATE cache_invalidation_log
                    SET processed = true
                    WHERE id = %s
                """, (id,))
                conn.commit()
                logger.info(f"Successfully processed id={id}")
            except subprocess.CalledProcessError as e:
                logger.error(f"mapcache_seed failed for id={id}: {e}")

def main():
    while True:
        try:
            conn = psycopg2.connect(
                dbname=DB_NAME,
                user=DB_USER,
                password=DB_PASSWORD,
                host=DB_HOST,
                port=DB_PORT
            )
            break
        except psycopg2.OperationalError as e:
            logger.warning("DB 接続失敗。10秒後に再試行します…")
            time.sleep(10)
    conn.set_isolation_level(psycopg2.extensions.ISOLATION_LEVEL_AUTOCOMMIT)
    cur = conn.cursor()

    cur.execute("LISTEN cache_invalidation;")
    logger.info("Listening for cache_invalidation notifications...")

    try:
        while True:
            if select.select([conn], [], [], 10) == ([], [], []):
                continue
            conn.poll()
            while conn.notifies:
                notify = conn.notifies.pop(0)
                layer_name = notify.payload.strip()
                logger.info(f"Received notification for layer: {layer_name}")
                invalidate_cache(layer_name, conn)
    finally:
        cur.close()
        conn.close()

def outer_loop():
    try:
        while True:
            try:
                main()
            except psycopg2.OperationalError as e:
                logger.warning(f"DB 接続失敗または切断: {e}. 10秒後に再接続します…")
                time.sleep(10)
    except KeyboardInterrupt:
        logger.info("Exiting by user interrupt.")

if __name__ == "__main__":
    outer_loop()

やっていることは、PostGIS サーバとの接続を開いて、cache_invalidation 通知チャンネルを LISTEN し、

  1. 通知を受信したら、レイヤ名を通知から取得
  2. レイヤ名に該当する未処理のログ・データを取得
  3. mapcache_seed -m delete でキャッシュを削除
  4. ログ・データの処理済みフラグを立てる
  5. 最初に戻って通知を待つ

という仕事です。

なお、PostGIS サーバとの接続が確立出来なかったり、途中で切断された場合の再試行を行っているため、若干、構造が複雑になっています。

スクリプトのパーミッションを実行可能に設定しておきます。

chmod 775 /usr/local/bin/cache_invalidate.py

2-2-3. 動作確認

スクリプトを起動して、地物の編集を行って、想定通りにキャッシュが無効化されることを確認して下さい。

/usr/local/bin/cache_invalidate.py

スクリプトは Ctrl + C で中止できます。

2-2-4. 自動起動・常駐サービス化

以下の内容で /etc/systemd/system/cache_invalidator.service を作成します。

cache_invalidator.service
[Unit]
Description=PostGIS → MapCache Cache Invalidator
After=network.target

[Service]
Type=simple
ExecStart=/usr/local/bin/cache_invalidate.py
Restart=always
RestartSec=5
User=my-account
Group=my-group
Environment=PYTHONUNBUFFERED=1

[Install]
WantedBy=multi-user.target

そして、サービスを有効化し、起動します。

sudo systemctl daemon-reexec
sudo systemctl daemon-reload
sudo systemctl enable cache_invalidator
sudo systemctl start cache_invalidator

3. cron によるシーディング

ここまでの作業で、地物の編集によって必要になるキャッシュの無効化が実現されています。

しかし、無効化(=削除)されたキャッシュの再作成は完全には終っていないのが普通です。

と言うのは、ある領域のキャッシュを無効化すると、全ての縮尺においてその領域のキャッシュが削除されるのですが、削除されたキャッシュが全て再作成されるためには、その領域の全ての縮尺のデータを MapCache に要求する必要があるからです。ユーザが WebGIS でアクセスしなかった縮尺のキャッシュは再作成されずに残ります。

そこで、深夜、丑三つ時に、ひっそりと補完的かつ包括的なシーディングを行うことにします。

3-1. /usr/local/bin/nigthly_seed.sh

次の内容で /usr/local/bin/nightly_seed.sh を作成します。

nigthly_seed.sh
#!/bin/bash

# Layer list
LAYERS=("tanada" "spots" "bld")

# MapCache config path
CONFIG="/etc/mapcache.xml"

for LAYER in "${LAYERS[@]}"; do
  echo "Seeding layer: $LAYER"
  sudo -u apache mapcache_seed -c "$CONFIG" -t "$LAYER"
done

LAYERS は必要に応じて追加・修正して下さい。

このスクリプトを実行すると、指定されたレイヤの全ての縮尺において、未作成のキャッシュが作成されます。(作成済みの所はスキップされます。)

3-2. crontab への登録

sudo crontab -e

以下を追加します。

30 3 * * * /usr/local/bin/nightly_seed.sh >> /var/log/mapcache_seed.log 2>&1
  • 3:30 AM に実行
  • 実行ユーザは root
  • ログは /var/log/mapcache_seed.log

以上で編集機能と地図画像キャッシュとのズレの問題は解消されます。

And so, what's next?

一連の記事はこれで一通り完了です。次の予定はありません。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?