Skip to content

feat: 운영 가능 수준의 observability, security, CI 추가#9

Draft
dtype2100 wants to merge 1 commit into
masterfrom
cursor/operational-readiness-3e09
Draft

feat: 운영 가능 수준의 observability, security, CI 추가#9
dtype2100 wants to merge 1 commit into
masterfrom
cursor/operational-readiness-3e09

Conversation

@dtype2100

Copy link
Copy Markdown
Owner

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 limiting (IP당 분당 요청 수, RATE_LIMIT_PER_MINUTE)
  • Security headers (X-Content-Type-Options, X-Frame-Options 등)
  • CORS 설정 (CORS_ORIGINS)

Observability

  • /metrics — Prometheus metrics (HTTP latency, RAG queries, ingest counts)
  • Request ID middleware (X-Request-ID header)
  • Structured request logging (method, path, status, latency)
  • Global exception handlers with request_id in error responses

DevOps

  • GitHub Actions CI (lint + test)
  • Makefile python -m 사용으로 PATH 이슈 해결
  • Docker Compose API healthcheck + Dockerfile curl 설치

Testing

  • 73 tests passing (make test)
  • Lint passing (make lint)
  • New integration tests in tests/integration/test_ops.py

Deployment notes

API_KEY=your-production-secret
ENABLE_METRICS=true
HEALTH_CHECK_LLM=false  # true로 설정 시 readiness에서 LLM ping
RATE_LIMIT_PER_MINUTE=120

Docker Compose readiness: GET /api/v1/health/ready

Open in Web Open in Cursor 

- 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>
@coderabbitai

coderabbitai Bot commented Jul 8, 2026

Copy link
Copy Markdown

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 5e21b0dc-b993-4c7f-ab6e-063e04a70f50

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch cursor/operational-readiness-3e09

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread app/core/middleware.py
Comment on lines +52 to +86
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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

⚠️ 메모리 누수 및 프록시 IP 인식 문제 해결

현재 구현된 RateLimitMiddleware에는 운영 환경에서 심각한 장애를 유발할 수 있는 두 가지 문제가 있습니다.

  1. 메모리 누수 (Memory Leak):
    self._windowsdefaultdict(deque)로 클라이언트 IP별 요청 시간을 저장합니다. 하지만 특정 IP가 한 번만 요청하고 다시 요청하지 않는 경우, 해당 IP와 만료된 타임스탬프가 메모리에서 영구히 해제되지 않습니다. 트래픽이 지속되거나 분산 스캔이 발생하면 메모리 사용량이 계속 증가하여 OOM(Out Of Memory) 장애로 이어질 수 있습니다. 주기적으로 만료된 IP를 정리하는 로직이 필요합니다.

  2. 역방향 프록시(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)

Comment thread app/api/v1/health.py
Comment on lines +21 to +23
vector = await check_vectorstore()
redis = await check_redis()
llm = await check_llm()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

⚡ 헬스체크 비동기 병렬 처리 개선

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(),
    )

Comment thread app/api/v1/health.py
Comment on lines +48 to +50
vector = await check_vectorstore()
redis = await check_redis()
llm = await check_llm()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

⚡ Readiness 프로브 비동기 병렬 처리 개선

readiness 엔드포인트에서도 동일하게 의존성 검사를 순차적으로 수행하고 있습니다. 쿠버네티스나 로드 밸런서가 주기적으로 호출하는 프로브인 만큼, 응답 속도 최적화를 위해 asyncio.gather를 통한 병렬 처리를 권장합니다.

    import asyncio

    vector, redis, llm = await asyncio.gather(
        check_vectorstore(),
        check_redis(),
        check_llm(),
    )

Comment thread app/core/security.py
Comment on lines +24 to 25
if api_key != settings.api_key:
raise HTTPException(status_code=401, detail="Invalid or missing API key")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-medium medium

🔒 타이밍 공격(Timing Attack) 방지를 위한 안전한 비교 사용

일반적인 문자열 비교 연산자(!= 또는 ==)는 문자열의 앞에서부터 한 글자씩 비교하다가 다른 부분이 발견되면 즉시 비교를 중단합니다. 이로 인해 올바른 API 키와 입력된 키가 일치하는 글자 수에 따라 응답 시간에 미세한 차이가 발생하며, 공격자는 이를 측정하여 API 키를 유추하는 타이밍 공격(Timing Attack)을 수행할 수 있습니다.

보안 강화를 위해 secrets.compare_digest를 사용하여 항상 일정한 시간(Constant-time)에 비교가 완료되도록 개선하는 것을 권장합니다.

Suggested change
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")

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants