diff --git a/Dockerfile b/Dockerfile index f83a6e4..1dfedf4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -41,5 +41,6 @@ EXPOSE 5050 # 1. 清理可能因异常重启遗留的虚拟屏幕锁文件(防止 xvfb 报错退出) # 2. 切换到 web_ui 目录执行 gunicorn # 3. 使用 xvfb-run -a 自动分配空闲的虚拟屏幕 -# 4. 增加 --access-logfile - 参数,让 Gunicorn 输出 HTTP 访问日志 -CMD sh -c "rm -f /tmp/.X*-lock && cd web_ui && xvfb-run -a --server-args='-screen 0 1920x1080x24' gunicorn -w 4 -b 0.0.0.0:5050 --access-logfile - --timeout 120 app:app" +# 4. 浏览器自动化服务必须单 worker 运行,避免多个 Gunicorn 进程同时抢占 Chromium DevTools 端口 +# 5. 使用 gthread 提升单进程下的并发响应能力 +CMD sh -c "rm -f /tmp/.X*-lock && cd web_ui && xvfb-run -a --server-args='-screen 0 1920x1080x24' gunicorn -w 1 --threads 8 --worker-class gthread -b 0.0.0.0:5050 --access-logfile - --timeout 120 app:app" diff --git a/browser_login/login.py b/browser_login/login.py index b562506..2e781d5 100644 --- a/browser_login/login.py +++ b/browser_login/login.py @@ -5,6 +5,7 @@ ERP 登录模块 - DrissionPage import os import time import datetime +import tempfile from pathlib import Path from dotenv import load_dotenv from DrissionPage import ChromiumPage, ChromiumOptions @@ -22,6 +23,22 @@ ERP_USERNAME = os.getenv("ERP_USERNAME", "") ERP_PASSWORD = os.getenv("ERP_PASSWORD", "") +def is_docker_env() -> bool: + """判断当前是否运行在 Docker 容器中。""" + return os.path.exists("/.dockerenv") + + +def get_docker_user_data_dir() -> Path: + """ + 为每个进程分配独立的 Chromium 用户目录。 + 多 worker 或异常重启后复用同一 profile,容易导致 DevTools 会话错乱。 + """ + base_dir = Path(os.getenv("DRISSION_USER_DATA_ROOT", tempfile.gettempdir())) / "drission_profiles" + user_data_dir = base_dir / f"worker_{os.getpid()}" + user_data_dir.mkdir(parents=True, exist_ok=True) + return user_data_dir + + # ── 日志 ────────────────────────────────────────────────────────────────────── def log(level: str, msg: str): icons = {"INFO": "ℹ️ ", "OK": "✅", "WARN": "⚠️ ", "ERR": "❌"} @@ -47,7 +64,7 @@ def dump_page_state(page: ChromiumPage, label: str = ""): def get_page(headless: bool = False, port: int = 9222) -> ChromiumPage: co = ChromiumOptions() # 强制在生产环境下使用无头模式 - is_docker = os.path.exists('/.dockerenv') + is_docker = is_docker_env() if headless or is_docker: co.set_argument("--headless=new") @@ -68,6 +85,10 @@ def get_page(headless: bool = False, port: int = 9222) -> ChromiumPage: if is_docker: # Docker 生产环境:每次启动分配随机端口,避免前一个僵尸进程占用导致 404 co.auto_port(True) + # 为当前 worker 隔离 Chromium profile,防止多个进程共享 profile 导致握手错乱 + user_data_dir = get_docker_user_data_dir() + co.set_user_data_path(str(user_data_dir)) + log("INFO", f"Docker Chromium 用户目录: {user_data_dir}") # 很多 Debian/Ubuntu 系统的 Chromium 实际上是通过 wrapper 脚本调用的 # 直接指定确切的执行路径,防止 DrissionPage 底层启动失败 if os.path.exists('/usr/bin/chromium'): diff --git a/docker-compose.yml b/docker-compose.yml index 7d652c8..78ae2a2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,6 +8,8 @@ services: - "5050:5050" environment: - TZ=Asia/Shanghai + - ENABLE_BACKGROUND_SCHEDULER=1 + - DRISSION_USER_DATA_ROOT=/tmp volumes: # 既然用 Git 拉取了完整代码,直接用相对路径挂载更优雅 # 直接挂载整个 output 文件夹,里面的 erp_data.db 自动持久化 diff --git a/web_ui/app.py b/web_ui/app.py index 58744a9..556ef1a 100644 --- a/web_ui/app.py +++ b/web_ui/app.py @@ -118,15 +118,33 @@ def background_sync_job(): finally: release_browser() -# 初始化定时调度器 -scheduler = BackgroundScheduler() -scheduler.add_job(func=background_sync_job, trigger="interval", minutes=30) -# 启动调度器 -scheduler.start() +def should_start_scheduler(): + """ + 控制定时任务是否启用。 + 浏览器自动化服务若运行在多 worker 场景,必须避免每个 worker 都起一份调度器。 + """ + return os.getenv("ENABLE_BACKGROUND_SCHEDULER", "1") == "1" + + +scheduler = None +if should_start_scheduler(): + scheduler = BackgroundScheduler() + scheduler.add_job(func=background_sync_job, trigger="interval", minutes=30) + scheduler.start() + print("[调度器] APScheduler 已启动。") +else: + print("[调度器] 已按环境变量禁用后台定时任务。") # 确保在应用退出时关闭调度器 import atexit -atexit.register(lambda: scheduler.shutdown()) + + +def shutdown_scheduler(): + if scheduler and scheduler.running: + scheduler.shutdown() + + +atexit.register(shutdown_scheduler) def get_db_connection(): """获取数据库连接""" @@ -1281,4 +1299,4 @@ if __name__ == '__main__': port=port, threaded=True, use_reloader=False - ) \ No newline at end of file + )