feat: 운영 가능 수준의 observability, security, CI 추가#9
Conversation
- Split liveness/readiness health endpoints for Kubernetes and Docker - Enable optional API key auth on data endpoints via API_KEY env var - Add Prometheus metrics, request ID tracing, rate limiting, security headers - Register global exception handlers with request_id in error responses - Add GitHub Actions CI workflow and fix Makefile to use python -m - Add Docker healthcheck and curl in Dockerfile - Add integration tests for ops endpoints (73 tests passing) Co-authored-by: jwlee <dtype2100@users.noreply.github.com>
|
Important Review skippedDraft detected. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Code Review
This pull request introduces production-ready operational features, including API key authentication, sliding-window rate limiting, standard security headers, Prometheus metrics integration, and dedicated Kubernetes liveness and readiness probes. Feedback focuses on critical improvements: addressing a potential memory leak and reverse proxy IP misidentification in the rate limiter, optimizing health and readiness checks by running I/O-bound dependency pings in parallel using asyncio.gather, and mitigating timing attacks on API key verification by utilizing secrets.compare_digest.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| class RateLimitMiddleware(BaseHTTPMiddleware): | ||
| """Simple in-memory sliding-window rate limiter per client IP.""" | ||
|
|
||
| def __init__(self, app, limit_per_minute: int) -> None: | ||
| super().__init__(app) | ||
| self._limit = max(limit_per_minute, 0) | ||
| self._windows: dict[str, deque[float]] = defaultdict(deque) | ||
|
|
||
| async def dispatch(self, request: Request, call_next: RequestHandler) -> Response: | ||
| if self._limit <= 0 or request.url.path in _EXEMPT_PATHS: | ||
| return await call_next(request) | ||
|
|
||
| client_ip = request.client.host if request.client else "unknown" | ||
| now = time.monotonic() | ||
| window = self._windows[client_ip] | ||
|
|
||
| while window and now - window[0] > 60: | ||
| window.popleft() | ||
|
|
||
| if len(window) >= self._limit: | ||
| request_id = getattr(request.state, "request_id", "-") | ||
| logger.warning( | ||
| "rate limit exceeded client=%s path=%s request_id=%s", | ||
| client_ip, | ||
| request.url.path, | ||
| request_id, | ||
| ) | ||
| return JSONResponse( | ||
| status_code=429, | ||
| content={"detail": "Rate limit exceeded. Try again later."}, | ||
| headers={"Retry-After": "60"}, | ||
| ) | ||
|
|
||
| window.append(now) | ||
| return await call_next(request) |
There was a problem hiding this comment.
⚠️ 메모리 누수 및 프록시 IP 인식 문제 해결
현재 구현된 RateLimitMiddleware에는 운영 환경에서 심각한 장애를 유발할 수 있는 두 가지 문제가 있습니다.
-
메모리 누수 (Memory Leak):
self._windows는defaultdict(deque)로 클라이언트 IP별 요청 시간을 저장합니다. 하지만 특정 IP가 한 번만 요청하고 다시 요청하지 않는 경우, 해당 IP와 만료된 타임스탬프가 메모리에서 영구히 해제되지 않습니다. 트래픽이 지속되거나 분산 스캔이 발생하면 메모리 사용량이 계속 증가하여 OOM(Out Of Memory) 장애로 이어질 수 있습니다. 주기적으로 만료된 IP를 정리하는 로직이 필요합니다. -
역방향 프록시(Reverse Proxy) 환경에서의 IP 오인식:
운영 환경(Nginx, AWS ALB, Cloudflare 등)에서는request.client.host가 항상 프록시 서버의 IP(예:127.0.0.1)로 인식됩니다. 이 경우 모든 사용자의 요청이 하나의 IP로 합산되어 전체 서비스가 잘못 차단될 수 있습니다.X-Forwarded-For또는X-Real-IP헤더를 우선적으로 참조하도록 개선해야 합니다.
class RateLimitMiddleware(BaseHTTPMiddleware):
"""Simple in-memory sliding-window rate limiter per client IP."""
def __init__(self, app, limit_per_minute: int) -> None:
super().__init__(app)
self._limit = max(limit_per_minute, 0)
self._windows: dict[str, deque[float]] = defaultdict(deque)
self._last_cleanup = time.monotonic()
async def dispatch(self, request: Request, call_next: RequestHandler) -> Response:
if self._limit <= 0 or request.url.path in _EXEMPT_PATHS:
return await call_next(request)
# 역방향 프록시 헤더를 고려하여 실제 클라이언트 IP 추출
client_ip = "unknown"
if x_forwarded_for := request.headers.get("x-forwarded-for"):
client_ip = x_forwarded_for.split(",")[0].strip()
elif x_real_ip := request.headers.get("x-real-ip"):
client_ip = x_real_ip
elif request.client:
client_ip = request.client.host
now = time.monotonic()
# 메모리 누수 방지를 위해 주기적으로 만료된 윈도우 정리 (60초 주기)
if now - self._last_cleanup > 60:
self._cleanup_expired_windows(now)
self._last_cleanup = now
window = self._windows[client_ip]
while window and now - window[0] > 60:
window.popleft()
if len(window) >= self._limit:
request_id = getattr(request.state, "request_id", "-")
logger.warning(
"rate limit exceeded client=%s path=%s request_id=%s",
client_ip,
request.url.path,
request_id,
)
return JSONResponse(
status_code=429,
content={"detail": "Rate limit exceeded. Try again later."},
headers={"Retry-After": "60"},
)
window.append(now)
return await call_next(request)
def _cleanup_expired_windows(self, now: float) -> None:
expired_ips = []
for ip, window in self._windows.items():
while window and now - window[0] > 60:
window.popleft()
if not window:
expired_ips.append(ip)
for ip in expired_ips:
self._windows.pop(ip, None)| vector = await check_vectorstore() | ||
| redis = await check_redis() | ||
| llm = await check_llm() |
There was a problem hiding this comment.
⚡ 헬스체크 비동기 병렬 처리 개선
check_vectorstore(), check_redis(), check_llm()은 모두 I/O 바운드 작업입니다. 현재는 이를 순차적으로 await하고 있어, 각 의존성의 응답 지연 시간이 합산되어 전체 헬스체크 응답 속도가 느려질 수 있습니다. 특히 LLM 헬스체크가 활성화되어 있고 타임아웃(5초)이 발생할 경우 응답 지연이 심각해집니다.
asyncio.gather를 사용하여 세 작업을 병렬로 실행하면 전체 응답 시간을 가장 느린 작업 하나의 시간으로 단축할 수 있습니다.
import asyncio
vector, redis, llm = await asyncio.gather(
check_vectorstore(),
check_redis(),
check_llm(),
)| vector = await check_vectorstore() | ||
| redis = await check_redis() | ||
| llm = await check_llm() |
There was a problem hiding this comment.
| if api_key != settings.api_key: | ||
| raise HTTPException(status_code=401, detail="Invalid or missing API key") |
There was a problem hiding this comment.
🔒 타이밍 공격(Timing Attack) 방지를 위한 안전한 비교 사용
일반적인 문자열 비교 연산자(!= 또는 ==)는 문자열의 앞에서부터 한 글자씩 비교하다가 다른 부분이 발견되면 즉시 비교를 중단합니다. 이로 인해 올바른 API 키와 입력된 키가 일치하는 글자 수에 따라 응답 시간에 미세한 차이가 발생하며, 공격자는 이를 측정하여 API 키를 유추하는 타이밍 공격(Timing Attack)을 수행할 수 있습니다.
보안 강화를 위해 secrets.compare_digest를 사용하여 항상 일정한 시간(Constant-time)에 비교가 완료되도록 개선하는 것을 권장합니다.
| if api_key != settings.api_key: | |
| raise HTTPException(status_code=401, detail="Invalid or missing API key") | |
| import secrets | |
| if not secrets.compare_digest(api_key or "", settings.api_key): | |
| raise HTTPException(status_code=401, detail="Invalid or missing API key") |
Summary
프로덕션/스테이징 환경에서 운영 가능한 수준으로 서비스 안정성과 관측성을 강화했습니다.
Changes
Health & Readiness
/api/v1/health/live— liveness probe/api/v1/health/ready— readiness probe (vector store + optional Redis/LLM, 503 when not ready)/api/v1/health— 기존 호환 유지 + redis/llm/auth 상태 필드 추가Security
API_KEY환경변수 설정 시 ingest/search/query/jobs 엔드포인트에X-API-Key인증 적용RATE_LIMIT_PER_MINUTE)CORS_ORIGINS)Observability
/metrics— Prometheus metrics (HTTP latency, RAG queries, ingest counts)X-Request-IDheader)request_idin error responsesDevOps
python -m사용으로 PATH 이슈 해결Testing
make test)make lint)tests/integration/test_ops.pyDeployment notes
Docker Compose readiness:
GET /api/v1/health/ready