diff --git a/Dockerfile b/Dockerfile index 0ce0977..d0ce644 100644 --- a/Dockerfile +++ b/Dockerfile @@ -64,5 +64,5 @@ ENV FLASK_APP=app.py # 暴露端口(容器內 app 綁 80,docker-compose / k8s 對外映射依環境而定) EXPOSE 80 -# 啟動應用(production 用 gunicorn,4 workers + 300s timeout + 啟用 access/error log) -CMD ["gunicorn", "--bind", "0.0.0.0:80", "--workers", "4", "--timeout", "300", "--access-logfile", "-", "--error-logfile", "-", "--preload", "app:app"] +# 啟動應用(production 用 gunicorn;preload + post_fork DB pool reset 見 gunicorn.conf.py) +CMD ["gunicorn", "--config", "gunicorn.conf.py", "app:app"] diff --git a/docker-compose.yml b/docker-compose.yml index bf6d132..c4a94b9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -47,8 +47,8 @@ services: - "com.centurylinklabs.watchtower.enable=true" ports: - "127.0.0.1:5003:80" # 僅本地連線,透過 Nginx 反向代理(nginx 反代 5003) - # 強制使用 gunicorn 綁定 port 80 (覆蓋 Dockerfile CMD) - command: ["gunicorn", "--bind", "0.0.0.0:80", "--workers", "4", "--timeout", "300", "--access-logfile", "-", "--error-logfile", "-", "--preload", "app:app"] + # 強制使用 gunicorn 綁定 port 80 (覆蓋 Dockerfile CMD);preload + post_fork DB pool reset 見 gunicorn.conf.py + command: ["gunicorn", "--config", "gunicorn.conf.py", "app:app"] volumes: # 持久化數據 - ./data:/app/data diff --git a/gunicorn.conf.py b/gunicorn.conf.py new file mode 100644 index 0000000..1b86dec --- /dev/null +++ b/gunicorn.conf.py @@ -0,0 +1,69 @@ +"""Gunicorn runtime config. + +`preload_app` imports Flask before worker fork. Any SQLAlchemy engines created +during import must drop inherited connection pools in each child worker. +""" + +import os +import sys + +from sqlalchemy.engine import Engine + + +bind = "0.0.0.0:80" +workers = int(os.getenv("WEB_CONCURRENCY", "4")) +timeout = int(os.getenv("GUNICORN_TIMEOUT", "300")) +accesslog = "-" +errorlog = "-" +preload_app = True + + +def _dispose_engine(engine, label, server): + try: + try: + engine.dispose(close=False) + except TypeError: + engine.dispose() + server.log.info("Disposed inherited SQLAlchemy engine after fork: %s", label) + return True + except Exception: + server.log.exception("Failed disposing inherited SQLAlchemy engine: %s", label) + return False + + +def post_fork(server, worker): + """Reset DB pools inherited from the preloaded master process. + + SQLAlchemy engines are safe to keep as objects after fork, but their pools + must be replaced so workers do not share PostgreSQL TCP sockets. + """ + disposed_ids = set() + disposed_count = 0 + prefixes = ("app", "database.", "routes.", "services.") + + for module_name, module in list(sys.modules.items()): + if module is None or not module_name.startswith(prefixes): + continue + + for attr_name, value in vars(module).items(): + candidates = [] + if isinstance(value, Engine): + candidates.append((value, f"{module_name}.{attr_name}")) + else: + engine = getattr(value, "engine", None) + if isinstance(engine, Engine): + candidates.append((engine, f"{module_name}.{attr_name}.engine")) + + for engine, label in candidates: + engine_id = id(engine) + if engine_id in disposed_ids: + continue + disposed_ids.add(engine_id) + if _dispose_engine(engine, label, server): + disposed_count += 1 + + server.log.info( + "Worker %s reset %s SQLAlchemy engine pool(s) after preload fork", + worker.pid, + disposed_count, + )