From d402668a5cb954b8bb0a6ceabdfcd28a4377d7e9 Mon Sep 17 00:00:00 2001 From: wzy-warehouse <18135009705@163.com> Date: Mon, 22 Jun 2026 11:01:15 +0800 Subject: [PATCH] =?UTF-8?q?QGIS=E7=9A=84docker=E7=AE=A1=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- QGIS_DOCKER_README.md | 94 ++++++++++++++-- app/api/qgis_map_export.py | 19 ++-- app/script/copy_data_to_container.py | 159 +++++++++++++++++++++++++++ app/services/qgis/qgis_env.py | 27 +++++ settings.toml | 15 ++- 5 files changed, 290 insertions(+), 24 deletions(-) create mode 100644 app/script/copy_data_to_container.py diff --git a/QGIS_DOCKER_README.md b/QGIS_DOCKER_README.md index 4c91e02..73905e0 100644 --- a/QGIS_DOCKER_README.md +++ b/QGIS_DOCKER_README.md @@ -66,7 +66,65 @@ docker run -d ^ | `-v 输出目录:输出目录` | 文件输出目录读写挂载,保持主机与容器路径一致 | | `sleep infinity` | 保持容器运行,等待 `docker exec` 调用 | -## 4. 安装中文字体(手动) +## 4. 预拷贝静态数据到容器本地 FS(必须,性能关键) + +WSL2 9P 文件系统随机读取极慢(GPKG 62MB 耗时 6-10s/模板,模板 ZIP 也慢),拷贝到容器内 `/data/` 后读取仅需 ~0.5s。 + +### 方式一:Python 脚本(推荐) + +```bash +python app/script/copy_data_to_container.py +``` + +输出示例: +``` +=== 预拷贝静态数据到容器 qgis-server === + + [GPKG] 主机目录: F:\project\xian\xian_algorithm_new\app\data\gpkg + 文件数: 23, 总大小: 62.3 MB + 容器目标: qgis-server:/data/gpkg + 拷贝完成: 3.2s, 容器内 23 个文件 + + [模板] 主机目录: F:\project\xian\xian_algorithm_new\app\data\template + 文件数: 34, 总大小: 45.1 MB + 容器目标: qgis-server:/data/template + 拷贝完成: 2.1s, 容器内 34 个文件 + +=== 总耗时: 5.4s === +``` + +其他用法: +```bash +python app/script/copy_data_to_container.py --dry-run # 仅查看信息 +python app/script/copy_data_to_container.py --only gpkg # 只拷贝 GPKG +python app/script/copy_data_to_container.py --only template # 只拷贝模板 +``` + +### 方式二:手动 docker cp + +```bash +# 清理旧文件 +docker exec qgis-server rm -rf /data/gpkg /data/template + +# 拷贝 GPKG +docker cp F:\project\xian\xian_algorithm_new\app\data\gpkg qgis-server:/data/gpkg + +# 拷贝模板 +docker cp F:\project\xian\xian_algorithm_new\app\data\template qgis-server:/data/template + +# 验证 +docker exec qgis-server ls -la /data/gpkg +docker exec qgis-server ls -la /data/template/rainfall +docker exec qgis-server ls -la /data/template/earthquake +``` + +### 何时需要重新执行 + +- GPKG 或模板文件有更新时 +- 容器重建后(`/data` 目录丢失) +- 首次部署时 + +## 5. 安装中文字体(手动) QGIS 模板使用了 SimHei(黑体)、SimSun(宋体)、Microsoft YaHei(微软雅黑)等 Windows 中文字体, Docker 镜像默认不包含这些字体,会导致中文全部乱码。**字体需手动安装,代码不会自动安装。** @@ -122,7 +180,7 @@ docker run -d ... \ ... ``` -## 5. 验证容器 +## 6. 验证容器 ```bash # 检查容器状态 @@ -135,7 +193,7 @@ docker exec qgis-server python3 -c "from qgis.core import QgsApplication; print( docker exec qgis-server ls /app/app/services/qgis/ ``` -## 6. 配置文件 +## 7. 配置文件 所有 Docker 相关配置集中在 `settings.toml` 的 `[default]` 段: @@ -158,6 +216,8 @@ QGIS_DOCKER_KEEP_ALIVE = "sleep infinity" # 容器保活命令 # ---- 专题图参数 ---- QGIS_GPKG_DIR = "app/data/gpkg" # GPKG 目录(相对于项目根) +QGIS_DOCKER_GPKG_DIR = "/data/gpkg" # 容器内 GPKG 本地路径(预拷贝后读取) +QGIS_DOCKER_TEMPLATE_DIR = "/data/template" # 容器内模板本地路径(预拷贝后读取) QGIS_EXPORT_DPI = 200 # 导出 DPI QGIS_PARALLEL_WORKERS = 4 # 并行 docker exec 子进程数 QGIS_MAX_CONCURRENT = 2 # 最大并发请求数 @@ -184,7 +244,7 @@ export QGIS_DOCKER_PYTHON="/usr/bin/python3" | `DB_HOST` | `"47.92.216.173"` | `"10.22.245.138"` | | `DB_PORT` | `7654` | `54321` | -## 7. 目录结构 +## 8. 目录结构 ``` 项目根目录/ @@ -200,6 +260,8 @@ export QGIS_DOCKER_PYTHON="/usr/bin/python3" │ │ └── map_exporter.py # 图片导出 │ └── api/ │ └── qgis_map_export.py # FastAPI 专题图导出接口 +├── script/ +│ └── copy_data_to_container.py # GPKG + 模板预拷贝脚本 ├── config.py # Dynaconf 配置入口 ├── settings.toml # 全部配置 ├── requirements.txt # Python 依赖 @@ -207,12 +269,12 @@ export QGIS_DOCKER_PYTHON="/usr/bin/python3" └── tmp/ # 临时文件目录(容器内映射为 /app/tmp/) ``` -## 8. 临时文件 +## 9. 临时文件 - 主机端临时 JSON(批量产图配置):写入 `{项目根}/tmp/`,容器内可通过 `/app/tmp/` 访问 - 容器端临时 .qgz(修改后的模板):写入容器内 `/tmp/`,由 runner 自动清理 -## 9. 故障排查 +## 10. 故障排查 ```bash # 容器未运行 @@ -242,14 +304,28 @@ zh = [f for f in db.families() if any(k in f for k in ['SimHei','YaHei','SimSun' print('中文字体:', zh if zh else '未安装!') " -# 检查 GPKG 文件 -docker exec qgis-server ls /app/app/data/gpkg/ +# 检查 GPKG 文件(容器本地路径,性能关键) +docker exec qgis-server ls /app/app/data/gpkg/ # 挂载路径(慢) +docker exec qgis-server ls /data/gpkg/ # 本地路径(快,需预拷贝) + +# 检查模板文件(容器本地路径) +docker exec qgis-server ls /data/template/rainfall/ # 本地路径(快) +docker exec qgis-server ls /data/template/earthquake/ # 检查临时文件目录 docker exec qgis-server ls /app/tmp/ ``` -## 10. 工作流程 +### 静态数据相关问题 + +| 问题 | 原因 | 解决 | +|------|------|------| +| 产图慢(>60s) | GPKG/模板从挂载目录读取(9P 慢) | 执行 `python app/script/copy_data_to_container.py` | +| 日志显示 `gpkg_dir=/app/...` | 未预拷贝或 `QGIS_DOCKER_GPKG_DIR` 为空 | 检查 settings.toml 配置 | +| 容器重建后变慢 | `/data` 目录丢失 | 重新执行拷贝脚本 | +| `docker cp` 权限错误 | 容器未运行 | `docker start qgis-server` | + +## 11. 工作流程 ``` 用户请求 POST /qgis/export/map diff --git a/app/api/qgis_map_export.py b/app/api/qgis_map_export.py index caac303..e8d9d5e 100644 --- a/app/api/qgis_map_export.py +++ b/app/api/qgis_map_export.py @@ -175,10 +175,15 @@ def _build_qgis_config(batch_folder: str) -> dict: is_docker = False if is_docker: - # GPKG 目录:容器内路径 = 项目挂载目录 + GPKG 子目录 - project_dir = get_docker_project_dir() - gpkg_subdir = getattr(settings, "QGIS_GPKG_DIR", "app/data/gpkg") - gpkg_dir = f"{project_dir}/{gpkg_subdir}" + # GPKG 目录:优先使用容器内本地路径(预拷贝后绕过 WSL2 9P) + # 需先执行 python script/copy_gpkg_to_container.py + gpkg_dir = getattr(settings, "QGIS_DOCKER_GPKG_DIR", "") or "" + if not gpkg_dir: + # fallback: 使用挂载路径(性能差) + project_dir = get_docker_project_dir() + gpkg_subdir = getattr(settings, "QGIS_GPKG_DIR", "app/data/gpkg") + gpkg_dir = f"{project_dir}/{gpkg_subdir}" + logger.warning(f"GPKG 将从挂载目录读取(慢),建议执行 copy_gpkg_to_container.py") # batch_folder:主机路径 → 容器路径 host_fs = get_host_file_store().rstrip("/") container_fs = get_container_file_store().rstrip("/") @@ -365,7 +370,7 @@ def _generate_batch_maps(models: list, config: dict, batch_key: str, import json, math, concurrent.futures, subprocess, tempfile, threading from app.services.qgis.qgis_env import ( get_docker_project_dir, get_container_python_path, build_docker_exec_cmd, - map_host_to_container, map_container_to_host, + map_host_to_container, map_container_to_host, map_template_to_container, ) max_workers = getattr(settings, "QGIS_PARALLEL_WORKERS", 4) @@ -397,9 +402,9 @@ def _generate_batch_maps(models: list, config: dict, batch_key: str, cm = dict(m) if "outFile" in cm: cm["outFile"] = map_host_to_container(cm["outFile"]) - # 模板 path 也需要映射:Windows主机路径 → 容器内路径 + # 模板 path 映射到容器本地路径(预拷贝后绕过 9P) if "path" in cm: - cm["path"] = map_host_to_container(cm["path"]) + cm["path"] = map_template_to_container(cm["path"]) container_models.append(cm) request = json.dumps({"config": config, "models": container_models}, ensure_ascii=False) diff --git a/app/script/copy_data_to_container.py b/app/script/copy_data_to_container.py new file mode 100644 index 0000000..c0acece --- /dev/null +++ b/app/script/copy_data_to_container.py @@ -0,0 +1,159 @@ +""" +将主机端 GPKG 和模板文件预拷贝到 Docker 容器本地文件系统。 + +WSL2 9P 文件系统随机读取极慢(GPKG 62MB 耗时 6-10s,模板 ZIP 也慢), +拷贝到容器内 /data/ 后读取仅需 ~0.5s。 + +用法: + python app/script/copy_data_to_container.py [--container qgis-server] [--dry-run] [--only gpkg|template] + +前置条件: + Docker 容器已启动(docker start qgis-server) +""" +import argparse +import os +import subprocess +import sys +import time +from pathlib import Path + +PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent + + +def _load_config(): + """从 settings.toml 读取配置""" + try: + from config import settings + return { + "gpkg_subdir": getattr(settings, "QGIS_GPKG_DIR", "app/data/gpkg"), + "template_subdir": "app/data/template", + "container": getattr(settings, "QGIS_DOCKER_CONTAINER", "qgis-server"), + "container_gpkg": getattr(settings, "QGIS_DOCKER_GPKG_DIR", "/data/gpkg"), + "container_template": getattr(settings, "QGIS_DOCKER_TEMPLATE_DIR", "/data/template"), + } + except ImportError: + pass + + # fallback: 解析 TOML + cfg = { + "gpkg_subdir": "app/data/gpkg", + "template_subdir": "app/data/template", + "container": "qgis-server", + "container_gpkg": "/data/gpkg", + "container_template": "/data/template", + } + toml_path = PROJECT_ROOT / "settings.toml" + if toml_path.exists(): + for line in toml_path.read_text(encoding="utf-8").splitlines(): + line = line.strip() + for key, prefix in [ + ("gpkg_subdir", "QGIS_GPKG_DIR"), + ("container", "QGIS_DOCKER_CONTAINER"), + ("container_gpkg", "QGIS_DOCKER_GPKG_DIR"), + ("container_template", "QGIS_DOCKER_TEMPLATE_DIR"), + ]: + if line.startswith(prefix) and "=" in line: + cfg[key] = line.split("=", 1)[1].strip().strip('"').strip("'") + return cfg + + +def _run(cmd, timeout=10, **kwargs): + """subprocess.run 封装,统一 UTF-8 编码,避免 Windows GBK 报错""" + result = subprocess.run( + cmd, capture_output=True, timeout=timeout, + encoding="utf-8", errors="replace", **kwargs, + ) + return result + + +def _check_container(container: str): + """检查容器是否运行""" + result = _run(["docker", "inspect", "--format={{.State.Running}}", container], timeout=5) + if (result.stdout or "").strip() != "true": + raise RuntimeError(f"容器 {container} 未运行,请先 docker start {container}") + + +def _copy_dir_to_container(host_dir: Path, container: str, container_dest: str, label: str, dry_run: bool): + """拷贝单个目录到容器""" + if not host_dir.is_dir(): + print(f" [{label}] 目录不存在,跳过: {host_dir}") + return 0 + + files = list(host_dir.rglob("*")) + files = [f for f in files if f.is_file()] + if not files: + print(f" [{label}] 目录为空,跳过: {host_dir}") + return 0 + + total_size = sum(f.stat().st_size for f in files) + print(f" [{label}] 主机目录: {host_dir}") + print(f" 文件数: {len(files)}, 总大小: {total_size / 1024 / 1024:.1f} MB") + print(f" 容器目标: {container}:{container_dest}") + + if dry_run: + print(f" [dry-run] 跳过") + return 0 + + # 确保目标目录存在 + parent_dir = container_dest.rsplit("/", 1)[0] + _run(["docker", "exec", container, "mkdir", "-p", parent_dir]) + + # 清理旧目录 + _run(["docker", "exec", container, "rm", "-rf", container_dest]) + + # docker cp + t0 = time.time() + result = _run(["docker", "cp", str(host_dir), f"{container}:{container_dest}"], timeout=120) + elapsed = time.time() - t0 + + if result.returncode != 0: + raise RuntimeError(f"[{label}] docker cp 失败: {result.stderr}") + + # 验证 + verify = _run(["docker", "exec", container, "find", container_dest, "-type", "f"]) + stdout = (verify.stdout or "").strip() + count = len([l for l in stdout.splitlines() if l.strip()]) + print(f" 拷贝完成: {elapsed:.1f}s, 容器内 {count} 个文件") + return elapsed + + +def copy_to_container(container: str = None, dry_run: bool = False, only: str = None): + """将 GPKG 和模板拷贝到容器本地文件系统""" + cfg = _load_config() + if container is None: + container = cfg["container"] + + _check_container(container) + + t_total = time.time() + print(f"=== 预拷贝静态数据到容器 {container} ===\n") + + if only != "template": + host_gpkg = PROJECT_ROOT / cfg["gpkg_subdir"] + _copy_dir_to_container(host_gpkg, container, cfg["container_gpkg"], "GPKG", dry_run) + print() + + if only != "gpkg": + host_template = PROJECT_ROOT / cfg["template_subdir"] + _copy_dir_to_container(host_template, container, cfg["container_template"], "模板", dry_run) + + elapsed = time.time() - t_total + print(f"\n=== 总耗时: {elapsed:.1f}s ===") + + +def main(): + parser = argparse.ArgumentParser(description="将 GPKG 和模板预拷贝到 Docker 容器本地 FS") + parser.add_argument("--container", default=None, help="Docker 容器名称") + parser.add_argument("--dry-run", action="store_true", help="仅显示信息,不实际拷贝") + parser.add_argument("--only", choices=["gpkg", "template"], help="只拷贝指定类型") + args = parser.parse_args() + + try: + copy_to_container(container=args.container, dry_run=args.dry_run, only=args.only) + except Exception as e: + print(f"错误: {e}", file=sys.stderr) + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/app/services/qgis/qgis_env.py b/app/services/qgis/qgis_env.py index 439f8e6..6a3b82c 100644 --- a/app/services/qgis/qgis_env.py +++ b/app/services/qgis/qgis_env.py @@ -167,6 +167,33 @@ def map_container_to_host(path: str) -> str: return path +def map_template_to_container(host_path: str) -> str: + """将主机端模板路径映射到容器内本地路径(预拷贝后绕过 9P)。 + + 主机: F:/project/xian/xian_algorithm_new/app/data/template/rainfall/xxx.qgz + 容器: /data/template/rainfall/xxx.qgz + """ + try: + from config import settings + container_tpl = getattr(settings, "QGIS_DOCKER_TEMPLATE_DIR", "") or "" + except Exception: + container_tpl = "" + + if not container_tpl: + # fallback: 用通用映射(走 9P,慢) + return map_host_to_container(host_path) + + normalized = host_path.replace("\\", "/").lower() + # 找 "app/data/template/" 后面的部分 + marker = "app/data/template/" + idx = normalized.find(marker) + if idx >= 0: + relative = host_path.replace("\\", "/")[idx + len(marker):] + return f"{container_tpl.rstrip('/')}/{relative}" + + return map_host_to_container(host_path) + + def build_docker_volume_mounts() -> list: """ 构建 Docker 卷挂载参数列表(用于 docker run)。 diff --git a/settings.toml b/settings.toml index 35531eb..291f775 100644 --- a/settings.toml +++ b/settings.toml @@ -8,9 +8,13 @@ RAIN_STATION_GRID_DIR = "/xian/rainfall/grid/images/:id" REDIS_RAIN_STATION_GRID_KEY = "xian:rainfall:rain_station_grid" REDIS_RAIN_STATION_IDENTIFIER_KEY = "xian:rainfall:rain_station_identifier" PREDICT_PROBABILITY_THRESHOLD = 50 -# 静态底图 GeoPackage 目录(相对于项目根目录) +# 静态底图 GeoPackage 目录 QGIS_GPKG_DIR = "app/data/gpkg" -# 专题图输出子目录(相对于 FILE_STORE_DIR) +# 容器内 GPKG 本地路径 +QGIS_DOCKER_GPKG_DIR = "/data/gpkg" +# 容器内模板本地路径 +QGIS_DOCKER_TEMPLATE_DIR = "/data/template" +# 专题图输出子目录 QGIS_OUTPUT_DIR = "xian/qgis/map/:eventType/:inferenceId" # 专题图默认参数 QGIS_DEFAULTS_MAP_LAYOUT = "A4" @@ -21,7 +25,7 @@ QGIS_DEFAULTS_MAP_UNIT = "制图单位:西安市应急管理局" QGIS_EXPORT_DPI = 200 # 并行 docker exec 子进程数 QGIS_PARALLEL_WORKERS = 4 -# 最大并发请求数(防止多人同时触发资源耗尽) +# 最大并发请求数 QGIS_MAX_CONCURRENT = 2 # ============================================================ # Docker QGIS 配置 @@ -91,11 +95,6 @@ REDIS_DB = 0 # 文件路径配置 # ============================================================ FILE_STORE_DIR = "G:/files" -# ============================================================ -# 专题图输出子目录 -# ============================================================ -QGIS_OUTPUT_DIR = "xian/qgis/map/:eventType/:inferenceId" -QGIS_DEFAULTS_MAP_UNIT = "制图单位:西安市应急管理局" # ============================================================ # 生产环境