fix(gunicorn): preload 後重置 SQLAlchemy 連線池
Some checks failed
CD Pipeline / deploy (push) Failing after 9m32s

This commit is contained in:
OoO
2026-04-30 00:07:10 +08:00
parent 5a61c020e3
commit 8bc0fd7ff6
3 changed files with 73 additions and 4 deletions

View File

@@ -64,5 +64,5 @@ ENV FLASK_APP=app.py
# 暴露端口(容器內 app 綁 80docker-compose / k8s 對外映射依環境而定)
EXPOSE 80
# 啟動應用production 用 gunicorn4 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 用 gunicornpreload + post_fork DB pool reset 見 gunicorn.conf.py
CMD ["gunicorn", "--config", "gunicorn.conf.py", "app:app"]

View File

@@ -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

69
gunicorn.conf.py Normal file
View File

@@ -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,
)