refactor: Web 服务器从 gunicorn 迁移到 uvicorn

This commit is contained in:
qzl
2026-03-06 17:55:01 +08:00
parent b6087fd195
commit 105e7849fe
12 changed files with 108 additions and 22 deletions
+3 -7
View File
@@ -4,21 +4,17 @@
############ ############
# 运行时配置 # 运行时配置
############ ############
SOCIAL_RUNTIME__ENVIRONMENT=dev # dev / prod (DEPRECATED: use SOCIAL_WEB__SERVER) SOCIAL_RUNTIME__ENVIRONMENT=dev
SOCIAL_RUNTIME__DEBUG=true SOCIAL_RUNTIME__DEBUG=true
SOCIAL_RUNTIME__LOG_LEVEL=INFO SOCIAL_RUNTIME__LOG_LEVEL=INFO
SOCIAL_RUNTIME__SQL_LOG_QUERIES=false SOCIAL_RUNTIME__SQL_LOG_QUERIES=false
############ ############
# Web 服务器配置(显式参数控制 # Web 服务器配置(Uvicorn
############ ############
SOCIAL_WEB__HOST=0.0.0.0 SOCIAL_WEB__HOST=0.0.0.0
SOCIAL_WEB__PORT=5775 SOCIAL_WEB__PORT=5775
SOCIAL_WEB__RELOAD=false SOCIAL_WEB__WORKERS=2
SOCIAL_WEB__GUNICORN__WORKERS=2
SOCIAL_WEB__GUNICORN__WORKER_CLASS=uvicorn.workers.UvicornWorker
SOCIAL_WEB__GUNICORN__TIMEOUT=30
SOCIAL_WEB__GUNICORN__KEEPALIVE=2
############ ############
# Redis 配置 # Redis 配置
+1 -1
View File
@@ -12,4 +12,4 @@ COPY backend/alembic ./backend/alembic
ENV PYTHONPATH=/app/backend/src ENV PYTHONPATH=/app/backend/src
CMD ["uv", "run", "gunicorn", "backend.src.app:app", "--bind", "0.0.0.0:8000", "--workers", "2"] CMD ["uv", "run", "uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "2"]
+20
View File
@@ -8,6 +8,23 @@ from pydantic import BaseModel, Field, computed_field, field_validator, model_va
from pydantic_settings import BaseSettings, SettingsConfigDict from pydantic_settings import BaseSettings, SettingsConfigDict
def _resolve_project_root() -> Path:
current = Path(__file__).resolve()
for parent in current.parents:
if (
(parent / "pyproject.toml").is_file()
and (parent / "backend").is_dir()
and (parent / "infra").is_dir()
):
return parent
for parent in current.parents:
if parent.name == "backend":
return parent.parent
return Path.cwd().resolve()
class RuntimeSettings(BaseModel): class RuntimeSettings(BaseModel):
environment: Literal["dev", "test", "prod"] = "dev" environment: Literal["dev", "test", "prod"] = "dev"
service_name: str = "app" service_name: str = "app"
@@ -171,6 +188,9 @@ def _resolve_env_file() -> str:
return ".env" return ".env"
PROJECT_ROOT = _resolve_project_root()
class Settings(BaseSettings): class Settings(BaseSettings):
runtime: RuntimeSettings = RuntimeSettings() runtime: RuntimeSettings = RuntimeSettings()
cors: CorsSettings = CorsSettings() cors: CorsSettings = CorsSettings()
+12 -5
View File
@@ -6,7 +6,7 @@ from pathlib import Path
import structlog import structlog
from core.config.settings import RuntimeSettings, Settings from core.config.settings import PROJECT_ROOT, RuntimeSettings, Settings
from core.logging.formatters import ( from core.logging.formatters import (
build_plain_formatter, build_plain_formatter,
build_processor_formatter, build_processor_formatter,
@@ -17,13 +17,20 @@ from core.logging.handlers import build_file_handler_config
def _ensure_log_dirs(runtime: RuntimeSettings) -> None: def _ensure_log_dirs(runtime: RuntimeSettings) -> None:
Path(runtime.log_dir).mkdir(parents=True, exist_ok=True) _resolve_log_path(runtime.log_dir).mkdir(parents=True, exist_ok=True)
Path(runtime.log_error_dir).mkdir(parents=True, exist_ok=True) _resolve_log_path(runtime.log_error_dir).mkdir(parents=True, exist_ok=True)
def _resolve_log_path(path: str) -> Path:
candidate = Path(path)
if candidate.is_absolute():
return candidate
return PROJECT_ROOT / candidate
def build_logging_config(runtime: RuntimeSettings) -> dict[str, object]: def build_logging_config(runtime: RuntimeSettings) -> dict[str, object]:
log_dir = Path(runtime.log_dir) log_dir = _resolve_log_path(runtime.log_dir)
error_dir = Path(runtime.log_error_dir) error_dir = _resolve_log_path(runtime.log_error_dir)
formatter_name = "json" if runtime.log_json else "plain" formatter_name = "json" if runtime.log_json else "plain"
file_handler = build_file_handler_config( file_handler = build_file_handler_config(
+5 -1
View File
@@ -3,10 +3,14 @@ from __future__ import annotations
from taskiq_redis import ListQueueBroker, RedisAsyncResultBackend from taskiq_redis import ListQueueBroker, RedisAsyncResultBackend
from core.config.settings import config from core.config.settings import config
from core.logging import configure_logging from core.logging import configure_logging, log_service_banner
configure_logging(config) configure_logging(config)
log_service_banner(
service_name=config.runtime.service_name,
environment=config.runtime.environment,
)
def _build_broker(queue_name: str) -> ListQueueBroker: def _build_broker(queue_name: str) -> ListQueueBroker:
return ListQueueBroker( return ListQueueBroker(
@@ -22,12 +22,18 @@ def test_taskiq_app_configures_logging_on_import(
sys.modules.pop("core.taskiq", None) sys.modules.pop("core.taskiq", None)
called = {"count": 0, "args": None} called = {"count": 0, "args": None}
banner_called = {"count": 0, "kwargs": None}
def _fake_configure_logging(*args: object, **__: object) -> None: def _fake_configure_logging(*args: object, **__: object) -> None:
called["count"] += 1 called["count"] += 1
called["args"] = args called["args"] = args
def _fake_log_service_banner(**kwargs: object) -> None:
banner_called["count"] += 1
banner_called["kwargs"] = kwargs
monkeypatch.setattr("core.logging.configure_logging", _fake_configure_logging) monkeypatch.setattr("core.logging.configure_logging", _fake_configure_logging)
monkeypatch.setattr("core.logging.log_service_banner", _fake_log_service_banner)
importlib.import_module("core.taskiq.app") importlib.import_module("core.taskiq.app")
@@ -35,3 +41,8 @@ def test_taskiq_app_configures_logging_on_import(
assert called["count"] == 1 assert called["count"] == 1
assert called["args"] == (config,) assert called["args"] == (config,)
assert banner_called["count"] == 1
assert banner_called["kwargs"] == {
"service_name": config.runtime.service_name,
"environment": config.runtime.environment,
}
@@ -17,4 +17,17 @@ def test_worker_commands_use_taskiq() -> None:
assert "core.taskiq.app:bulk_broker" in content assert "core.taskiq.app:bulk_broker" in content
assert 'pgrep -f "taskiq.*worker"' in content assert 'pgrep -f "taskiq.*worker"' in content
assert 'pkill -f "taskiq.*worker"' in content assert 'pkill -f "taskiq.*worker"' in content
assert 'pgrep -f "gunicorn.*app:app"' in content
assert 'pkill -f "gunicorn.*app:app"' in content
assert removed_runner not in content assert removed_runner not in content
def test_web_command_uses_uvicorn_only() -> None:
content = APP_SCRIPT.read_text(encoding="utf-8")
assert "uv run uvicorn app:app" in content
assert 'WEB_PORT="${SOCIAL_WEB__PORT:-5775}"' in content
assert "SOCIAL_WEB__WORKERS" in content
assert 'UVICORN_LOG_LEVEL="${UVICORN_LOG_LEVEL,,}"' in content
assert "SOCIAL_WEB__GUNICORN__" not in content
assert "uv run gunicorn" not in content
+16
View File
@@ -71,6 +71,22 @@ def test_build_logging_config_plain_formatter_when_disabled(tmp_path: Path) -> N
assert handlers["error"]["formatter"] == "plain" assert handlers["error"]["formatter"] == "plain"
def test_build_logging_config_resolves_default_logs_from_project_root(
monkeypatch: pytest.MonkeyPatch,
) -> None:
from core.config.settings import PROJECT_ROOT
monkeypatch.chdir(PROJECT_ROOT / "backend")
runtime = Settings().runtime
config = build_logging_config(runtime)
handlers = _get_handlers(config)
assert handlers["file"]["filename"] == str(PROJECT_ROOT / "logs" / "app.log")
assert handlers["error"]["filename"] == str(
PROJECT_ROOT / "logs" / "errors" / "app.error.log"
)
def _read_last_log_entry(log_path: Path) -> dict[str, object]: def _read_last_log_entry(log_path: Path) -> dict[str, object]:
assert log_path.exists(), f"Expected log file at {log_path}" assert log_path.exists(), f"Expected log file at {log_path}"
entries = [ entries = [
+7 -1
View File
@@ -2,7 +2,7 @@ from __future__ import annotations
from pytest import MonkeyPatch from pytest import MonkeyPatch
from core.config.settings import Settings from core.config.settings import PROJECT_ROOT, Settings
def test_runtime_settings_defaults() -> None: def test_runtime_settings_defaults() -> None:
@@ -52,3 +52,9 @@ def test_runtime_settings_default_file_names_follow_service_name(
assert settings.runtime.log_error_dir == "logs/errors" assert settings.runtime.log_error_dir == "logs/errors"
assert settings.runtime.log_file_name == "worker-default.log" assert settings.runtime.log_file_name == "worker-default.log"
assert settings.runtime.log_error_file_name == "worker-default.error.log" assert settings.runtime.log_error_file_name == "worker-default.error.log"
def test_project_root_points_to_repo_root() -> None:
assert (PROJECT_ROOT / "backend").is_dir()
assert (PROJECT_ROOT / "infra").is_dir()
assert (PROJECT_ROOT / "pyproject.toml").is_file()
+1
View File
@@ -272,3 +272,4 @@ docker compose --env-file .env -f infra/docker/docker-compose.yml up -d --force-
| 2026-03-02 | 文档整理:修正 auth 端点名称(/verifications)、补充 profile 路由文档、修复 L2/L3 验证命令 | | 2026-03-02 | 文档整理:修正 auth 端点名称(/verifications)、补充 profile 路由文档、修复 L2/L3 验证命令 |
| 2026-03-02 | 修正 bootstrap 命令:init-job 需要使用 `uv run python -m core.runtime.cli bootstrap` | | 2026-03-02 | 修正 bootstrap 命令:init-job 需要使用 `uv run python -m core.runtime.cli bootstrap` |
| 2026-03-05 | 新增 Agent Runtime run/resume/events 运维排障流程(Taskiq + Redis + Last-Event-ID | | 2026-03-05 | 新增 Agent Runtime run/resume/events 运维排障流程(Taskiq + Redis + Last-Event-ID |
| 2026-03-06 | Web 启动从 gunicorn 迁移为纯 uvicorn,移除 `SOCIAL_WEB__GUNICORN__*` 配置,统一使用 `SOCIAL_WEB__WORKERS` |
+19 -6
View File
@@ -41,20 +41,29 @@ start() {
. "$ENV_FILE" . "$ENV_FILE"
set +a set +a
UVICORN_LOG_LEVEL="${SOCIAL_RUNTIME__LOG_LEVEL:-info}"
UVICORN_LOG_LEVEL="${UVICORN_LOG_LEVEL,,}"
WEB_PORT="${SOCIAL_WEB__PORT:-5775}"
if tmux has-session -t "$SESSION_NAME" 2>/dev/null; then if tmux has-session -t "$SESSION_NAME" 2>/dev/null; then
echo "Error: tmux session '$SESSION_NAME' already exists." >&2 echo "Error: tmux session '$SESSION_NAME' already exists." >&2
echo "Hint: tmux kill-session -t $SESSION_NAME" >&2 echo "Hint: tmux kill-session -t $SESSION_NAME" >&2
exit 1 exit 1
fi fi
if command -v ss >/dev/null 2>&1; then
if ss -ltn | awk '{print $4}' | grep -qE "[:.]${WEB_PORT}$"; then
echo "Error: web port ${WEB_PORT} is already in use." >&2
echo "Hint: run '$0 stop' or change SOCIAL_WEB__PORT in .env" >&2
exit 1
fi
fi
echo "Starting web + worker processes in tmux session '$SESSION_NAME'..." echo "Starting web + worker processes in tmux session '$SESSION_NAME'..."
WEB_CMD="cd '$ROOT_DIR' && PYTHONPATH=backend/src SOCIAL_RUNTIME__SERVICE_NAME=web uv run gunicorn app:app --bind \ WEB_CMD="cd '$ROOT_DIR' && PYTHONPATH=backend/src SOCIAL_RUNTIME__SERVICE_NAME=web uv run uvicorn app:app --host \
${SOCIAL_WEB__HOST:-0.0.0.0}:${SOCIAL_WEB__PORT:-8000} --workers \ ${SOCIAL_WEB__HOST:-0.0.0.0} --port ${WEB_PORT} --workers \
${SOCIAL_WEB__GUNICORN__WORKERS:-2} --worker-class \ ${SOCIAL_WEB__WORKERS:-2} --log-level ${UVICORN_LOG_LEVEL}"
${SOCIAL_WEB__GUNICORN__WORKER_CLASS:-uvicorn.workers.UvicornWorker} --timeout \
${SOCIAL_WEB__GUNICORN__TIMEOUT:-60} \
--log-level ${SOCIAL_RUNTIME__LOG_LEVEL:-info}"
WORKER_CRITICAL_CMD="cd '$ROOT_DIR' && PYTHONPATH=backend/src SOCIAL_RUNTIME__SERVICE_NAME=worker-critical uv run taskiq worker core.taskiq.app:critical_broker core.agent.infrastructure.queue.tasks --workers ${SOCIAL_WORKER__GROUPS__CRITICAL__CONCURRENCY:-2}" WORKER_CRITICAL_CMD="cd '$ROOT_DIR' && PYTHONPATH=backend/src SOCIAL_RUNTIME__SERVICE_NAME=worker-critical uv run taskiq worker core.taskiq.app:critical_broker core.agent.infrastructure.queue.tasks --workers ${SOCIAL_WORKER__GROUPS__CRITICAL__CONCURRENCY:-2}"
WORKER_DEFAULT_CMD="cd '$ROOT_DIR' && PYTHONPATH=backend/src SOCIAL_RUNTIME__SERVICE_NAME=worker-default uv run taskiq worker core.taskiq.app:default_broker core.agent.infrastructure.queue.tasks --workers ${SOCIAL_WORKER__GROUPS__DEFAULT__CONCURRENCY:-2}" WORKER_DEFAULT_CMD="cd '$ROOT_DIR' && PYTHONPATH=backend/src SOCIAL_RUNTIME__SERVICE_NAME=worker-default uv run taskiq worker core.taskiq.app:default_broker core.agent.infrastructure.queue.tasks --workers ${SOCIAL_WORKER__GROUPS__DEFAULT__CONCURRENCY:-2}"
@@ -88,6 +97,10 @@ stop() {
fi fi
echo "Checking for orphaned processes..." echo "Checking for orphaned processes..."
if pgrep -f "uvicorn.*app:app" > /dev/null 2>&1; then
echo "Killing orphaned uvicorn processes..."
pkill -f "uvicorn.*app:app"
fi
if pgrep -f "gunicorn.*app:app" > /dev/null 2>&1; then if pgrep -f "gunicorn.*app:app" > /dev/null 2>&1; then
echo "Killing orphaned gunicorn processes..." echo "Killing orphaned gunicorn processes..."
pkill -f "gunicorn.*app:app" pkill -f "gunicorn.*app:app"
-1
View File
@@ -25,7 +25,6 @@ dependencies = [
"taskiq-redis>=1.0.0", "taskiq-redis>=1.0.0",
"supabase>=2.27.2", "supabase>=2.27.2",
"uvicorn[standard]>=0.40.0", "uvicorn[standard]>=0.40.0",
"gunicorn>=25.1.0",
] ]
[project.optional-dependencies] [project.optional-dependencies]