Dockerfile 部署
This commit is contained in:
@@ -41,5 +41,6 @@ EXPOSE 5050
|
|||||||
# 1. 清理可能因异常重启遗留的虚拟屏幕锁文件(防止 xvfb 报错退出)
|
# 1. 清理可能因异常重启遗留的虚拟屏幕锁文件(防止 xvfb 报错退出)
|
||||||
# 2. 切换到 web_ui 目录执行 gunicorn
|
# 2. 切换到 web_ui 目录执行 gunicorn
|
||||||
# 3. 使用 xvfb-run -a 自动分配空闲的虚拟屏幕
|
# 3. 使用 xvfb-run -a 自动分配空闲的虚拟屏幕
|
||||||
# 4. 增加 --access-logfile - 参数,让 Gunicorn 输出 HTTP 访问日志
|
# 4. 浏览器自动化服务必须单 worker 运行,避免多个 Gunicorn 进程同时抢占 Chromium DevTools 端口
|
||||||
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"
|
# 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"
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ ERP 登录模块 - DrissionPage
|
|||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
import datetime
|
import datetime
|
||||||
|
import tempfile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
from DrissionPage import ChromiumPage, ChromiumOptions
|
from DrissionPage import ChromiumPage, ChromiumOptions
|
||||||
@@ -22,6 +23,22 @@ ERP_USERNAME = os.getenv("ERP_USERNAME", "")
|
|||||||
ERP_PASSWORD = os.getenv("ERP_PASSWORD", "")
|
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):
|
def log(level: str, msg: str):
|
||||||
icons = {"INFO": "ℹ️ ", "OK": "✅", "WARN": "⚠️ ", "ERR": "❌"}
|
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:
|
def get_page(headless: bool = False, port: int = 9222) -> ChromiumPage:
|
||||||
co = ChromiumOptions()
|
co = ChromiumOptions()
|
||||||
# 强制在生产环境下使用无头模式
|
# 强制在生产环境下使用无头模式
|
||||||
is_docker = os.path.exists('/.dockerenv')
|
is_docker = is_docker_env()
|
||||||
|
|
||||||
if headless or is_docker:
|
if headless or is_docker:
|
||||||
co.set_argument("--headless=new")
|
co.set_argument("--headless=new")
|
||||||
@@ -68,6 +85,10 @@ def get_page(headless: bool = False, port: int = 9222) -> ChromiumPage:
|
|||||||
if is_docker:
|
if is_docker:
|
||||||
# Docker 生产环境:每次启动分配随机端口,避免前一个僵尸进程占用导致 404
|
# Docker 生产环境:每次启动分配随机端口,避免前一个僵尸进程占用导致 404
|
||||||
co.auto_port(True)
|
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 脚本调用的
|
# 很多 Debian/Ubuntu 系统的 Chromium 实际上是通过 wrapper 脚本调用的
|
||||||
# 直接指定确切的执行路径,防止 DrissionPage 底层启动失败
|
# 直接指定确切的执行路径,防止 DrissionPage 底层启动失败
|
||||||
if os.path.exists('/usr/bin/chromium'):
|
if os.path.exists('/usr/bin/chromium'):
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ services:
|
|||||||
- "5050:5050"
|
- "5050:5050"
|
||||||
environment:
|
environment:
|
||||||
- TZ=Asia/Shanghai
|
- TZ=Asia/Shanghai
|
||||||
|
- ENABLE_BACKGROUND_SCHEDULER=1
|
||||||
|
- DRISSION_USER_DATA_ROOT=/tmp
|
||||||
volumes:
|
volumes:
|
||||||
# 既然用 Git 拉取了完整代码,直接用相对路径挂载更优雅
|
# 既然用 Git 拉取了完整代码,直接用相对路径挂载更优雅
|
||||||
# 直接挂载整个 output 文件夹,里面的 erp_data.db 自动持久化
|
# 直接挂载整个 output 文件夹,里面的 erp_data.db 自动持久化
|
||||||
|
|||||||
@@ -118,15 +118,33 @@ def background_sync_job():
|
|||||||
finally:
|
finally:
|
||||||
release_browser()
|
release_browser()
|
||||||
|
|
||||||
# 初始化定时调度器
|
def should_start_scheduler():
|
||||||
scheduler = BackgroundScheduler()
|
"""
|
||||||
scheduler.add_job(func=background_sync_job, trigger="interval", minutes=30)
|
控制定时任务是否启用。
|
||||||
# 启动调度器
|
浏览器自动化服务若运行在多 worker 场景,必须避免每个 worker 都起一份调度器。
|
||||||
scheduler.start()
|
"""
|
||||||
|
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
|
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():
|
def get_db_connection():
|
||||||
"""获取数据库连接"""
|
"""获取数据库连接"""
|
||||||
@@ -1281,4 +1299,4 @@ if __name__ == '__main__':
|
|||||||
port=port,
|
port=port,
|
||||||
threaded=True,
|
threaded=True,
|
||||||
use_reloader=False
|
use_reloader=False
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user