refactor: Web 服务器从 gunicorn 迁移到 uvicorn
This commit is contained in:
+1
-1
@@ -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"]
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user