diff --git a/.env.example b/.env.example index 86fe96c..62f6d69 100644 --- a/.env.example +++ b/.env.example @@ -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__LOG_LEVEL=INFO SOCIAL_RUNTIME__SQL_LOG_QUERIES=false ############ -# Web 服务器配置(显式参数控制) +# Web 服务器配置(Uvicorn) ############ SOCIAL_WEB__HOST=0.0.0.0 SOCIAL_WEB__PORT=5775 -SOCIAL_WEB__RELOAD=false -SOCIAL_WEB__GUNICORN__WORKERS=2 -SOCIAL_WEB__GUNICORN__WORKER_CLASS=uvicorn.workers.UvicornWorker -SOCIAL_WEB__GUNICORN__TIMEOUT=30 -SOCIAL_WEB__GUNICORN__KEEPALIVE=2 +SOCIAL_WEB__WORKERS=2 ############ # Redis 配置 diff --git a/backend/Dockerfile b/backend/Dockerfile index f845494..c3d901b 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -12,4 +12,4 @@ COPY backend/alembic ./backend/alembic 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"] diff --git a/backend/src/core/config/settings.py b/backend/src/core/config/settings.py index 1138513..4e5d016 100644 --- a/backend/src/core/config/settings.py +++ b/backend/src/core/config/settings.py @@ -8,6 +8,23 @@ from pydantic import BaseModel, Field, computed_field, field_validator, model_va 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): environment: Literal["dev", "test", "prod"] = "dev" service_name: str = "app" @@ -171,6 +188,9 @@ def _resolve_env_file() -> str: return ".env" +PROJECT_ROOT = _resolve_project_root() + + class Settings(BaseSettings): runtime: RuntimeSettings = RuntimeSettings() cors: CorsSettings = CorsSettings() diff --git a/backend/src/core/logging/config.py b/backend/src/core/logging/config.py index 6d92ba3..e2cc430 100644 --- a/backend/src/core/logging/config.py +++ b/backend/src/core/logging/config.py @@ -6,7 +6,7 @@ from pathlib import Path import structlog -from core.config.settings import RuntimeSettings, Settings +from core.config.settings import PROJECT_ROOT, RuntimeSettings, Settings from core.logging.formatters import ( build_plain_formatter, build_processor_formatter, @@ -17,13 +17,20 @@ from core.logging.handlers import build_file_handler_config def _ensure_log_dirs(runtime: RuntimeSettings) -> None: - 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_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]: - log_dir = Path(runtime.log_dir) - error_dir = Path(runtime.log_error_dir) + log_dir = _resolve_log_path(runtime.log_dir) + error_dir = _resolve_log_path(runtime.log_error_dir) formatter_name = "json" if runtime.log_json else "plain" file_handler = build_file_handler_config( diff --git a/backend/src/core/taskiq/app.py b/backend/src/core/taskiq/app.py index 0570f34..12ae6c6 100644 --- a/backend/src/core/taskiq/app.py +++ b/backend/src/core/taskiq/app.py @@ -3,10 +3,14 @@ from __future__ import annotations from taskiq_redis import ListQueueBroker, RedisAsyncResultBackend from core.config.settings import config -from core.logging import configure_logging +from core.logging import configure_logging, log_service_banner configure_logging(config) +log_service_banner( + service_name=config.runtime.service_name, + environment=config.runtime.environment, +) def _build_broker(queue_name: str) -> ListQueueBroker: return ListQueueBroker( diff --git a/backend/tests/unit/core/taskiq/test_app.py b/backend/tests/unit/core/taskiq/test_app.py index ffe32d8..34387d5 100644 --- a/backend/tests/unit/core/taskiq/test_app.py +++ b/backend/tests/unit/core/taskiq/test_app.py @@ -22,12 +22,18 @@ def test_taskiq_app_configures_logging_on_import( sys.modules.pop("core.taskiq", None) called = {"count": 0, "args": None} + banner_called = {"count": 0, "kwargs": None} def _fake_configure_logging(*args: object, **__: object) -> None: called["count"] += 1 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.log_service_banner", _fake_log_service_banner) importlib.import_module("core.taskiq.app") @@ -35,3 +41,8 @@ def test_taskiq_app_configures_logging_on_import( assert called["count"] == 1 assert called["args"] == (config,) + assert banner_called["count"] == 1 + assert banner_called["kwargs"] == { + "service_name": config.runtime.service_name, + "environment": config.runtime.environment, + } diff --git a/backend/tests/unit/infra/test_worker_runtime_script.py b/backend/tests/unit/infra/test_worker_runtime_script.py index 4ca858d..c50ad6b 100644 --- a/backend/tests/unit/infra/test_worker_runtime_script.py +++ b/backend/tests/unit/infra/test_worker_runtime_script.py @@ -17,4 +17,17 @@ def test_worker_commands_use_taskiq() -> None: assert "core.taskiq.app:bulk_broker" in content assert 'pgrep -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 + + +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 diff --git a/backend/tests/unit/test_logging_config.py b/backend/tests/unit/test_logging_config.py index e6b15d2..4937ed8 100644 --- a/backend/tests/unit/test_logging_config.py +++ b/backend/tests/unit/test_logging_config.py @@ -71,6 +71,22 @@ def test_build_logging_config_plain_formatter_when_disabled(tmp_path: Path) -> N 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]: assert log_path.exists(), f"Expected log file at {log_path}" entries = [ diff --git a/backend/tests/unit/test_logging_settings.py b/backend/tests/unit/test_logging_settings.py index df1c54e..7da0573 100644 --- a/backend/tests/unit/test_logging_settings.py +++ b/backend/tests/unit/test_logging_settings.py @@ -2,7 +2,7 @@ from __future__ import annotations from pytest import MonkeyPatch -from core.config.settings import Settings +from core.config.settings import PROJECT_ROOT, Settings 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_file_name == "worker-default.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() diff --git a/docs/runtime/runtime-runbook.md b/docs/runtime/runtime-runbook.md index 7e0f38b..9f4e222 100644 --- a/docs/runtime/runtime-runbook.md +++ b/docs/runtime/runtime-runbook.md @@ -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 | 修正 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-06 | Web 启动从 gunicorn 迁移为纯 uvicorn,移除 `SOCIAL_WEB__GUNICORN__*` 配置,统一使用 `SOCIAL_WEB__WORKERS` | diff --git a/infra/scripts/app.sh b/infra/scripts/app.sh index 20f7524..871458f 100755 --- a/infra/scripts/app.sh +++ b/infra/scripts/app.sh @@ -41,20 +41,29 @@ start() { . "$ENV_FILE" 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 echo "Error: tmux session '$SESSION_NAME' already exists." >&2 echo "Hint: tmux kill-session -t $SESSION_NAME" >&2 exit 1 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'..." - WEB_CMD="cd '$ROOT_DIR' && PYTHONPATH=backend/src SOCIAL_RUNTIME__SERVICE_NAME=web uv run gunicorn app:app --bind \ -${SOCIAL_WEB__HOST:-0.0.0.0}:${SOCIAL_WEB__PORT:-8000} --workers \ -${SOCIAL_WEB__GUNICORN__WORKERS:-2} --worker-class \ -${SOCIAL_WEB__GUNICORN__WORKER_CLASS:-uvicorn.workers.UvicornWorker} --timeout \ -${SOCIAL_WEB__GUNICORN__TIMEOUT:-60} \ ---log-level ${SOCIAL_RUNTIME__LOG_LEVEL:-info}" + 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} --port ${WEB_PORT} --workers \ +${SOCIAL_WEB__WORKERS:-2} --log-level ${UVICORN_LOG_LEVEL}" 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}" @@ -88,6 +97,10 @@ stop() { fi 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 echo "Killing orphaned gunicorn processes..." pkill -f "gunicorn.*app:app" diff --git a/pyproject.toml b/pyproject.toml index b2c54f6..a864f69 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,7 +25,6 @@ dependencies = [ "taskiq-redis>=1.0.0", "supabase>=2.27.2", "uvicorn[standard]>=0.40.0", - "gunicorn>=25.1.0", ] [project.optional-dependencies]