Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 25 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,20 @@ docker compose up -d --build

```bash
cp .env.example .env

# 프로덕션 스타일에서는 Docker secret 파일로 APP_SECRET을 주입합니다.
# (이 파일은 커밋 금지: .gitignore에 **/secrets/** 포함)
mkdir -p secrets
python - <<'PY'
import secrets

with open('secrets/app_secret', 'w', encoding='utf-8') as f:
f.write(secrets.token_urlsafe(48) + "\n")
PY

Comment thread
coderabbitai[bot] marked this conversation as resolved.
# Restrict secret file permissions (owner read/write only)
chmod 600 secrets/app_secret

docker compose -f compose.prod.yaml up -d --build
```

Expand All @@ -89,9 +103,19 @@ python -m venv .venv && source .venv/bin/activate
pip install -U pip
pip install -e .
alembic upgrade head
uvicorn app.main:app --reload
hypercorn --config python:app.hypercorn_config app.main:app \
--bind 0.0.0.0:8000 --reload \
--access-logfile - --error-logfile -
```

#### 운영 팁

- Hypercorn worker 수는 `HYPERCORN_WORKERS`(또는 `WEB_CONCURRENCY`)로 조절할 수
있습니다.
- `APP_SECRET`은 앱 DB에 저장되는 DSN 암호화 키로 사용되므로, 변경 시 기존 데이터
복호화에 영향을 줄 수 있습니다. 가능하면 `APP_SECRET_FILE`(예:
`/run/secrets/app_secret`) 방식으로 안전하게 주입하세요.

### Frontend

```bash
Expand Down
2 changes: 1 addition & 1 deletion backend/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -26,4 +26,4 @@ EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/healthz').read()" || exit 1

CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
CMD ["hypercorn", "--config", "python:app.hypercorn_config", "app.main:app", "--bind", "0.0.0.0:8000", "--access-logfile", "-", "--error-logfile", "-"]
10 changes: 3 additions & 7 deletions backend/app/db.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,7 @@ def get_sync_database_url() -> str:
return url


engine: AsyncEngine = create_async_engine(
settings.database_url, pool_pre_ping=True
)
engine: AsyncEngine = create_async_engine(settings.database_url, pool_pre_ping=True)
SessionLocal = async_sessionmaker(engine, expire_on_commit=False)

read_only_engine: AsyncEngine | None = (
Expand Down Expand Up @@ -82,7 +80,7 @@ def _run() -> str | None:
with psycopg.connect(
dsn,
password=password,
connect_timeout=str(timeout_seconds),
connect_timeout=timeout_seconds,
) as conn:
with conn.cursor() as cur:
cur.execute("SHOW VERSION;")
Expand All @@ -109,9 +107,7 @@ async def get_pooler_detection() -> PoolerDetectionResult:
if settings.db_pooler_kind is not None:
kind = PoolerKind(settings.db_pooler_kind)
detected = kind is not PoolerKind.NONE
return PoolerDetectionResult(
kind=kind, detected=detected, version_text=None
)
return PoolerDetectionResult(kind=kind, detected=detected, version_text=None)

now = time.monotonic()
if (
Expand Down
45 changes: 45 additions & 0 deletions backend/app/hypercorn_config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
from __future__ import annotations

import logging
import os

logger = logging.getLogger(__name__)


def _int_env(name: str, default: str) -> int:
"""Parse an integer environment variable with a safe fallback.

Hypercorn config modules are imported at server startup. A bad env value
should not crash the process with an unhelpful ValueError.
"""

raw = os.getenv(name)
value = (raw if raw is not None else default).strip()
try:
parsed = int(value)
except ValueError as exc:
try:
parsed = int(str(default).strip())
except ValueError as default_exc:
parsed = 1
logger.warning(
"Invalid %s=%r and default=%r; falling back to 1 (%s)",
name,
raw,
default,
default_exc,
)
else:
logger.warning(
"Invalid %s=%r; falling back to default=%r (%s)",
name,
raw,
default,
exc,
)

return max(1, parsed)
Comment thread
coderabbitai[bot] marked this conversation as resolved.


# Prefer explicit hypercorn knob; fall back to a common convention.
workers = _int_env("HYPERCORN_WORKERS", os.getenv("WEB_CONCURRENCY", "1"))
Comment thread
coderabbitai[bot] marked this conversation as resolved.
51 changes: 51 additions & 0 deletions backend/app/settings.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
from __future__ import annotations

from pathlib import Path
from typing import Literal

from pydantic import Field
from pydantic import model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict


Expand All @@ -27,7 +29,56 @@ class Settings(BaseSettings):
# Best-effort pooler probe timeout. Keep it small to avoid blocking request
# paths.
db_pooler_probe_timeout_seconds: float = Field(0.7, ge=0.0)
# Required encryption key material.
#
# Supports the Docker/Podman *_FILE pattern (e.g. /run/secrets/app_secret)
# to avoid putting secrets directly into environment variables.
app_secret: str
app_secret_file: str | None = Field(
default=None, validation_alias="APP_SECRET_FILE"
)

@model_validator(mode="before")
@classmethod
def _load_app_secret_from_file(cls, data: object) -> object:
if not isinstance(data, dict):
return data

secret_file = data.get("APP_SECRET_FILE") or data.get("app_secret_file")
if not secret_file:
return data

path = Path(str(secret_file))
try:
resolved = path.resolve(strict=True)
except FileNotFoundError as exc:
raise ValueError(f"APP_SECRET_FILE does not exist: {path}") from exc

# Security hardening: avoid symlink tricks and restrict secret files to
# the expected secrets mount.
#
# Docker/Podman secrets are typically mounted under /run/secrets.
allowed_base = Path("/run/secrets").resolve()
if path.is_symlink():
raise ValueError("APP_SECRET_FILE must not be a symlink")
if not resolved.is_relative_to(allowed_base):
raise ValueError(
f"APP_SECRET_FILE must be under {allowed_base}: {resolved}"
)
if not resolved.is_file():
raise ValueError(
f"APP_SECRET_FILE does not exist or is not a file: {resolved}"
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.

# Important: secret files commonly include a trailing newline.
secret = resolved.read_text(encoding="utf-8").rstrip("\r\n")
if secret == "":
raise ValueError("APP_SECRET_FILE is empty")

# If APP_SECRET_FILE is provided, prefer it deterministically.
new_data = dict(data)
new_data["app_secret"] = secret
return new_data

cors_origins: str = "http://localhost:5173"

Expand Down
8 changes: 4 additions & 4 deletions backend/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,23 +11,23 @@ name = "pg-erd-cloud-backend"
version = "0.1.0"
requires-python = ">=3.10"
dependencies = [
"fastapi>=0.115.0",
"uvicorn[standard]>=0.30.0",
"fastapi>=0.137.1",
"hypercorn>=0.16.0",
"prometheus-client>=0.24.0,<0.25.0",
"pydantic>=2.8.0",
"pydantic-settings>=2.4.0",
"sqlalchemy[asyncio]>=2.0.30",
"asyncpg>=0.31.0",
"psycopg[binary]>=3.2.0",
"alembic>=1.13.0",
"cryptography>=42.0.0",
"cryptography>=46.0.7",
"httpx>=0.27.0",
"python-jose[cryptography]>=3.4.0",
]

[project.optional-dependencies]
dev = [
"pytest>=8.0.0",
"pytest>=9.0.3",
"pytest-asyncio>=1.3.0",
"mypy>=1.10.0",
"asyncpg-stubs>=0.31.0",
Expand Down
Loading