diff --git a/backend/embedding-server/main.py b/backend/embedding-server/main.py index f96cd285..1e1b8b34 100644 --- a/backend/embedding-server/main.py +++ b/backend/embedding-server/main.py @@ -8,7 +8,7 @@ from contextlib import asynccontextmanager, contextmanager from pathlib import Path -from fastapi import FastAPI, HTTPException, Response +from fastapi import FastAPI, HTTPException, Request, Response from FlagEmbedding import BGEM3FlagModel from prometheus_client import ( CONTENT_TYPE_LATEST, @@ -409,7 +409,14 @@ def health(): @app.get("/metrics", include_in_schema=False) -def metrics(): +def metrics(request: Request): + # 1. 배포 환경은 관측 VM 주소를 명시해 지표만 제한한다. 임베딩 요청은 기존 앱 경로를 유지한다. + allowed_clients = os.getenv("EMBEDDING_METRICS_ALLOWED_CLIENTS", "") + if allowed_clients: + allowed = {client.strip() for client in allowed_clients.split(",") if client.strip()} + if request.client is None or request.client.host not in allowed: + raise HTTPException(status_code=403, detail="Metrics access denied") + # 2. 허용된 관측 클라이언트에만 본문과 지표를 반환한다. return Response(content=generate_latest(), media_type=CONTENT_TYPE_LATEST) diff --git a/backend/embedding-server/test_main.py b/backend/embedding-server/test_main.py index 90f58e13..88d62068 100644 --- a/backend/embedding-server/test_main.py +++ b/backend/embedding-server/test_main.py @@ -102,6 +102,18 @@ def test_metrics_exposes_request_admission_memory_and_process_series(client): assert "process_resident_memory_bytes" in response.text +def test_metrics_restricts_only_metrics_to_configured_observer(monkeypatch): + monkeypatch.setenv("EMBEDDING_METRICS_ALLOWED_CLIENTS", "observer-test") + embedding_server.model = Mock() + + app_client = TestClient(embedding_server.app) + assert app_client.get("/metrics").status_code == 403 + assert app_client.get("/health").status_code == 200 + + monkeypatch.setenv("EMBEDDING_METRICS_ALLOWED_CLIENTS", "testclient") + assert app_client.get("/metrics").status_code == 200 + + @pytest.mark.parametrize( ("raw_limit", "expected"), [ diff --git a/backend/src/main/java/com/opensource/docgrid/domain/rag/service/OllamaClient.java b/backend/src/main/java/com/opensource/docgrid/domain/rag/service/OllamaClient.java index fbaa9770..5d165149 100644 --- a/backend/src/main/java/com/opensource/docgrid/domain/rag/service/OllamaClient.java +++ b/backend/src/main/java/com/opensource/docgrid/domain/rag/service/OllamaClient.java @@ -6,6 +6,7 @@ import java.io.InputStreamReader; import java.nio.charset.StandardCharsets; import java.time.Duration; +import java.util.concurrent.TimeUnit; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -26,13 +27,15 @@ import com.opensource.docgrid.global.exception.DocGridException; import com.opensource.docgrid.global.exception.ErrorCode; +import io.micrometer.core.instrument.MeterRegistry; import lombok.extern.slf4j.Slf4j; /** * Ollama {@code /api/generate}를 호출해 프롬프트로부터 답변을 생성하는 순수 HTTP 클라이언트 (F-RAG-02). * *

검색 결과가 있는지, LLM 호출을 생략할지(NO_CONTEXT) 판단하지 않는다 — 항상 주어진 프롬프트를 그대로 - * 전송한다. 그 판단은 이 클라이언트를 호출하는 쪽(RagFacade)의 책임이다. + * 전송한다. 그 판단은 이 클라이언트를 호출하는 쪽(RagFacade)의 책임이다. HTTP 호출 결과만 + * 낮은 카디널리티의 Micrometer 지표로 기록하며 프롬프트·답변·사용자 식별자는 기록하지 않는다. */ @Slf4j @Service @@ -71,6 +74,7 @@ public class OllamaClient { private final int repeatLastN; private final Duration generateDeadline; private final RestClient restClient; + private final MeterRegistry meterRegistry; public OllamaClient( @Value("${ollama.model}") String model, @@ -81,7 +85,8 @@ public OllamaClient( @Value("${ollama.repeat-penalty}") double repeatPenalty, @Value("${ollama.repeat-last-n}") int repeatLastN, @Value("${ollama.generate-deadline}") Duration generateDeadline, - @Qualifier("ollamaRestClient") RestClient restClient + @Qualifier("ollamaRestClient") RestClient restClient, + MeterRegistry meterRegistry ) { this.model = model; this.keepAlive = keepAlive; @@ -92,6 +97,39 @@ public OllamaClient( this.repeatLastN = repeatLastN; this.generateDeadline = generateDeadline; this.restClient = restClient; + this.meterRegistry = meterRegistry; + } + + /** + * 생성 호출 하나를 완료·부분 답변·실패 중 정확히 한 결과로 계측한다. + * RAG Job의 최종 상태와 달리 이 지표는 Ollama HTTP 호출 자체만 나타낸다. + */ + public OllamaGenerateResult generate(String prompt) { + long startNanos = System.nanoTime(); + String outcome = "failure"; + OllamaGenerateResult result = null; + try { + // 1. 기존 스트림 처리와 예외 계약을 바꾸지 않고 답변 생성만 수행한다. + result = generateAnswer(prompt); + outcome = result.answerText().endsWith(TRUNCATION_NOTICE) ? "partial" : "complete"; + return result; + } finally { + // 2. 예외가 나도 호출 건수와 체감 시간을 남긴다. 본문·모델명은 레이블로 쓰지 않는다. + meterRegistry.counter("docgrid.ollama.generate.requests", "outcome", outcome).increment(); + meterRegistry.timer("docgrid.ollama.generate.duration", "outcome", outcome) + .record(System.nanoTime() - startNanos, TimeUnit.NANOSECONDS); + if (result != null) { + // 3. Ollama 응답이 실제로 제공한 토큰 수만 기록한다. 부분 스트림의 null은 0으로 꾸미지 않는다. + recordTokens("input", result.inputTokenCount()); + recordTokens("output", result.outputTokenCount()); + } + } + } + + private void recordTokens(String kind, Integer count) { + if (count != null && count >= 0) { + meterRegistry.summary("docgrid.ollama.generate.tokens", "kind", kind).record(count); + } } /** @@ -105,7 +143,7 @@ public OllamaClient( * 뒤바뀌면 안 된다 — 예를 들어 트리밍을 언어 혼입 제거보다 먼저 하면 아직 안 지워진 외국어 * 글자를 문장 경계로 착각할 수 있다. */ - public OllamaGenerateResult generate(String prompt) { + private OllamaGenerateResult generateAnswer(String prompt) { long start = System.currentTimeMillis(); long deadline = start + generateDeadline.toMillis(); diff --git a/backend/src/test/java/com/opensource/docgrid/domain/rag/service/OllamaClientTest.java b/backend/src/test/java/com/opensource/docgrid/domain/rag/service/OllamaClientTest.java index 9740336e..5588e897 100644 --- a/backend/src/test/java/com/opensource/docgrid/domain/rag/service/OllamaClientTest.java +++ b/backend/src/test/java/com/opensource/docgrid/domain/rag/service/OllamaClientTest.java @@ -33,6 +33,8 @@ import com.opensource.docgrid.global.exception.DocGridException; import com.opensource.docgrid.global.exception.ErrorCode; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; + @ExtendWith(MockitoExtension.class) @MockitoSettings(strictness = Strictness.LENIENT) @DisplayName("OllamaClient 단위 테스트") @@ -42,11 +44,14 @@ class OllamaClientTest { @Mock(answer = Answers.RETURNS_SELF) private RestClient.RequestBodyUriSpec requestBodyUriSpec; private OllamaClient ollamaClient; + private SimpleMeterRegistry meterRegistry; @BeforeEach void setUp() { + meterRegistry = new SimpleMeterRegistry(); ollamaClient = new OllamaClient( - "qwen2.5:3b", "30m", 300, 0.3, 0.8, 1.1, 256, Duration.ofSeconds(25), restClient + "qwen2.5:3b", "30m", 300, 0.3, 0.8, 1.1, 256, Duration.ofSeconds(25), restClient, + meterRegistry ); doReturn(requestBodyUriSpec).when(restClient).post(); } @@ -82,6 +87,14 @@ void generate_success() { assertThat(result.inputTokenCount()).isEqualTo(120); assertThat(result.outputTokenCount()).isEqualTo(45); assertThat(result.latencyMs()).isGreaterThanOrEqualTo(0); + assertThat(meterRegistry.get("docgrid.ollama.generate.requests") + .tag("outcome", "complete").counter().count()).isEqualTo(1); + assertThat(meterRegistry.get("docgrid.ollama.generate.duration") + .tag("outcome", "complete").timer().count()).isEqualTo(1); + assertThat(meterRegistry.get("docgrid.ollama.generate.tokens") + .tag("kind", "input").summary().totalAmount()).isEqualTo(120); + assertThat(meterRegistry.get("docgrid.ollama.generate.tokens") + .tag("kind", "output").summary().totalAmount()).isEqualTo(45); } @Test @@ -97,6 +110,8 @@ void generate_hitsNumPredict_appendsTruncationNotice() { assertThat(result.answerText()) .startsWith("1. 첫 항목 2. 둘째 항목") .contains("답변이 길어 일부 내용이 생략됐을 수 있습니다"); + assertThat(meterRegistry.get("docgrid.ollama.generate.requests") + .tag("outcome", "partial").counter().count()).isEqualTo(1); } @Test @@ -119,7 +134,8 @@ void generate_hitsNumPredict_trimsToLastSentence() { @DisplayName("데드라인 초과: 스트림을 중단하고 그때까지 받은 부분 답변에 잘림 안내를 덧붙인다") void generate_deadlineExceeded_returnsPartialAnswer() { ollamaClient = new OllamaClient( - "qwen2.5:3b", "30m", 300, 0.3, 0.8, 1.1, 256, Duration.ZERO, restClient + "qwen2.5:3b", "30m", 300, 0.3, 0.8, 1.1, 256, Duration.ZERO, restClient, + meterRegistry ); doReturn(requestBodyUriSpec).when(restClient).post(); givenStreamBody(""" @@ -262,6 +278,10 @@ void generate_serverUnavailable_throwsException() { assertThatThrownBy(() -> ollamaClient.generate("질문")) .isInstanceOf(DocGridException.class) .hasFieldOrPropertyWithValue("errorCode", ErrorCode.RAG_SERVICE_UNAVAILABLE); + assertThat(meterRegistry.get("docgrid.ollama.generate.requests") + .tag("outcome", "failure").counter().count()).isEqualTo(1); + assertThat(meterRegistry.get("docgrid.ollama.generate.duration") + .tag("outcome", "failure").timer().count()).isEqualTo(1); } @Test diff --git a/backend/src/test/java/com/opensource/docgrid/domain/rag/service/OllamaClientWireContractTest.java b/backend/src/test/java/com/opensource/docgrid/domain/rag/service/OllamaClientWireContractTest.java index 37eff0a0..6f9cccdf 100644 --- a/backend/src/test/java/com/opensource/docgrid/domain/rag/service/OllamaClientWireContractTest.java +++ b/backend/src/test/java/com/opensource/docgrid/domain/rag/service/OllamaClientWireContractTest.java @@ -20,6 +20,8 @@ import com.opensource.docgrid.global.config.OllamaServerConfig; import com.sun.net.httpserver.HttpServer; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; + /** * 운영 설정({@link OllamaServerConfig})으로 만든 실제 RestClient가 Ollama에 보내는 요청의 전송 형식을 검증한다. * @@ -58,7 +60,8 @@ void setUp() throws IOException { ReflectionTestUtils.setField(config, "connectTimeout", Duration.ofSeconds(2)); ReflectionTestUtils.setField(config, "readTimeout", Duration.ofSeconds(5)); ollamaClient = new OllamaClient( - "qwen2.5:3b", "30m", 300, 0.3, 0.8, 1.1, 256, Duration.ofSeconds(25), config.ollamaRestClient() + "qwen2.5:3b", "30m", 300, 0.3, 0.8, 1.1, 256, Duration.ofSeconds(25), + config.ollamaRestClient(), new SimpleMeterRegistry() ); } diff --git a/docs/test-results/evidence/issue-479/rag479-gcp-scrape-r1.md b/docs/test-results/evidence/issue-479/rag479-gcp-scrape-r1.md new file mode 100644 index 00000000..9431dce4 --- /dev/null +++ b/docs/test-results/evidence/issue-479/rag479-gcp-scrape-r1.md @@ -0,0 +1,18 @@ +# GCP BGE 수집·접근 경계 — `rag479-gcp-scrape-r1` + +- 목적: BGE 임베딩 API는 앱에 계속 열어 두면서 `/metrics`는 관측 VM만 읽게 하고, Prometheus/Grafana가 실제 수집하는지 확인한다. +- 실행 위치·시각: GCP 앱 A, BGE CPU VM, 관측 VM. 2026-10-10 03:37~03:56 UTC. +- 성공 기준: 앱→BGE `/health` 200·`/metrics` 403, 관측 VM→`/metrics` 200, Prometheus `up=1`, 실임베딩 후 요청 수 증가, Grafana 자료원 경유 값 일치. + +| 절차·명령 요약 | 결과 | 해석 | +| --- | --- | --- | +| BGE 앱에 `EMBEDDING_METRICS_ALLOWED_CLIENTS`를 관측 VM으로 지정한 이미지를 적용하고 컨테이너 readiness 확인 | `/health` **200**, BGE 컨테이너 정상 | 임베딩 경로 유지 | +| 앱 A에서 BGE `/health`와 `/metrics` 각각 HTTP 상태 조회 | **200 / 403** | 앱은 임베딩 가능하지만 지표 본문은 차단 | +| 관측 VM에서 BGE `/metrics` 조회 | 최초 네트워크 **타임아웃**; 관측 태그에서 BGE 포트로 가는 단일 방화벽 허용 추가 후 **200** | BGE의 기존 ingress deny를 확인하고 허용 출처만 좁게 추가 | +| 기존 Prometheus config 백업, `promtool check config`, 재시작 | 첫 검사는 비root 컨테이너가 0600 임시 파일을 읽지 못해 실패; root 검사 재실행 **통과** | 본체 문법 문제가 아닌 검증 파일 권한 문제 | +| Prometheus `up{job="embedding-provider"}`와 요청 Counter 조회 | `up` **1/1**, 실제 PDF 인덱싱·검색 뒤 BGE 요청 **2건** | 대상이 단순 등록만 된 것이 아니라 실제 작업 지표가 증가 | +| Grafana 인증 API의 `ha-prometheus` 자료원 `/api/ds/query`로 같은 PromQL 조회 | BGE `up=1`, 요청 **2건** | Prometheus 값과 Grafana 경유 값 일치 | + +BGE 배포는 이미 시험 VM에 있던 CPU 호환 이미지의 컨테이너에 수정 `main.py`를 넣고 새 로컬 Docker 이미지로 저장한 뒤, 기존 컨테이너를 보존한 상태에서 재생성했다. **저장소 Dockerfile로 이미지 전체를 처음부터 다시 빌드한 검증은 아니다.** VM 재생성 시에는 소스에서 이미지 빌드·배포가 별도로 필요하다. + +BGE `/metrics`와 `/embed`는 동일 TCP 포트다. 방화벽만으로 URL 경로를 분리했다고 주장하지 않는다. 방화벽은 관측 VM의 포트 접근만 추가했고, 애플리케이션의 클라이언트 주소 검사가 `/metrics`를 제한한다. 관측 종료 후 BGE VM을 원래의 `TERMINATED` 상태로 되돌렸으므로 현재 시점의 `up=1`을 보장하지 않는다. 이 문서의 값은 시험 중 수집한 시계열이다. diff --git a/docs/test-results/evidence/issue-479/rag479-live-034625.md b/docs/test-results/evidence/issue-479/rag479-live-034625.md new file mode 100644 index 00000000..1d31ce6a --- /dev/null +++ b/docs/test-results/evidence/issue-479/rag479-live-034625.md @@ -0,0 +1,8 @@ +# 공개 RAG 사전 실행의 네트워크 차단 — `rag479-live-034625` + +- 목적: 공개 API에서 합성 계정·PDF를 사용한 지표 E2E를 시작한다. +- 실행 위치·시각: 로컬 제한 네트워크 실행 환경, 2026-10-10 03:46:25 UTC. +- 방법: 시험 스크립트의 첫 `POST /auth/signup` 단계로 공개 API에 연결한다. +- 성공 기준: 회원가입 HTTP 201 뒤 업로드·인덱싱·RAG로 진행. +- 관측: 시작 직후 `URLError`, 종료 코드 **1**. HTTP 응답을 받지 못했고 계정·문서·RAG 요청이 생성됐다는 증거가 없다. +- 해석: 샌드박스 네트워크 제한에 따른 실행 환경 실패. 접근이 허용된 환경에서 새 실행 ID `rag479-live-034630`으로 재시작했으며 이 실패를 제품 HTTP 실패율에 합치지 않는다. URL·계정·토큰 문자열은 기록하지 않았다. diff --git a/docs/test-results/evidence/issue-479/rag479-live-034630.md b/docs/test-results/evidence/issue-479/rag479-live-034630.md new file mode 100644 index 00000000..a92a669f --- /dev/null +++ b/docs/test-results/evidence/issue-479/rag479-live-034630.md @@ -0,0 +1,18 @@ +# 공개 PDF·RAG와 Ollama 지표 — `rag479-live-034630` + +- 목적: 지표 코드가 적용된 GCP 앱 A에서 실제 공개 PDF 검색·RAG를 실행하고 Micrometer→Prometheus→Grafana의 값을 대조한다. +- 실행 위치·시각: 로컬 시험기 → 공개 Vercel API → GCP 앱 A → BGE·OpenSQL·Ollama, 2026-10-10 03:46:30~03:55:31 UTC. +- 워크로드·성공 기준: 합성 PDF **1건**, 해당 버전만 Worker 처리, 검색 결과 1건 이상, RAG 비어 있지 않은 답변, BGE 요청 증가, Ollama 결과·지연·토큰 지표 생성. 7B 완전 답변을 기대하지만 제한시간 초과 시 `partial`로 구분한다. + +| UTC 시각·단계 | 관측 결과 | 해석 | +| --- | --- | --- | +| 03:46:30~35 회원가입·로그인·업로드 | 각각 HTTP **201·200·201**, 시험 문서 버전 1건 | 공개 API·DB·GCS 쓰기 경로 통과 | +| 첫 Worker 활성화 | 시험 버전 필터를 넣었으나 `worker-scope-test` 프로필 누락으로 앱이 기동을 거부; 공개 폴링에서 **503** 반복, 자동 원복 실행 | 앱 안전장치 정상 동작. 첫 시도는 실패이며 숨기지 않음 | +| 두 번째 Worker 활성화 | 필터와 시험 프로필을 함께 적용, 앱 HTTP **200** | 해당 버전만 처리하도록 정상 기동 | +| 03:52:20~27 인덱싱·검색 | `INDEXING`→`INDEXED`; 공개 검색 HTTP **200**, 결과 **4건**, RAG `PROCESSING` | BGE 실제 요청 발생 | +| 03:55:31 답변 조회 | `SUCCESS`, 답변 **69자**, 인용 **3건**, 시험 정답 마커 미포함 | 상태·본문은 비어 있지 않으나 의미적 정답은 **미입증** | +| 관측 VM Prometheus·Grafana 질의 | BGE 요청 **2건**; Ollama `partial` **1건**, 생성 시간 합계 **177.658711562초**, 출력 토큰 합계 **350개** | 7B 호출이 180초 제한 근처에서 부분 답변으로 끝났음을 수치로 확인 | + +03:46:25의 별도 로컬 첫 실행 [`rag479-live-034625`](rag479-live-034625.md)는 샌드박스 네트워크 제한에 의한 `URLError`로 **HTTP 응답 전 실패**했다. 네트워크 접근이 허용된 실행 환경에서 다시 시작한 것이 이 문서의 실행이다. API 응답 본문·프롬프트·토큰·계정 비밀번호는 기록하지 않았다. 이 문서는 터미널 스트림을 사후 비식별 요약한 것으로, 실시간 원본 로그 파일이 저장됐다고 주장하지 않는다. + +새 지표의 `complete`·`failure` 분기는 로컬 결정적 테스트를 통과했지만, GCP 실제 RAG 실행에서는 `partial`만 관측했다. 이전 지표 적용 전 별도 공개 E2E에서는 384자 답변이 완료됐으며, 이 실행의 `complete` 지표로 소급할 수 없다. diff --git a/docs/test-results/evidence/issue-479/rag479-local-contract-r1.md b/docs/test-results/evidence/issue-479/rag479-local-contract-r1.md new file mode 100644 index 00000000..92d5dcc7 --- /dev/null +++ b/docs/test-results/evidence/issue-479/rag479-local-contract-r1.md @@ -0,0 +1,14 @@ +# RAG 관측 코드 계약 검증 — `rag479-local-contract-r1` + +- 목적: Ollama 결과별 호출·시간·토큰 지표와 BGE `/metrics` 접근 제한이 기존 응답 계약을 깨지 않는지 확인한다. +- 실행 위치·개정: 로컬 격리 작업 트리 `feat/479`, Java 17/Gradle 및 기존 BGE 런타임 Docker 이미지. +- 성공 기준: 관련 Java·Python 테스트 모두 통과, Prometheus 설정 문법 통과, 민감한 프롬프트·답변·사용자 ID가 새 레이블에 없음. + +| 명령·방법 | 결과 요약 | 판정 | +| --- | --- | --- | +| `./backend/gradlew -p backend test --tests '*OllamaClientTest' --tests '*OllamaClientWireContractTest' --no-daemon` | Java **16건 통과**, 실패 0건 | complete·partial·failure 호출과 시간, 제공된 토큰 값 기록 검증 | +| 기존 BGE 이미지에 수정한 `main.py`·`test_main.py`를 읽기 전용 마운트하고 `python -m pytest -q test_main.py` | Python **23건 통과**, 경고 2건 | `/metrics` 비허용 403·허용 200과 기존 임베딩 계약 통과 | +| `docker run --rm --entrypoint=promtool -v "$PWD/monitoring/prometheus:/etc/prometheus:ro" prom/prometheus:v3.5.5 check config /etc/prometheus/prometheus.yml` | config **성공**, 규칙 3개 파일·20개 규칙 확인 | file-discovery 추가 후 문법 통과 | +| `./backend/gradlew -p backend bootJar --no-daemon` | JAR 생성 성공, SHA-256 `153bb7f1cb343784112e3b6652955870e84d18361fd56521e2228b0e25144120` | GCP A/B에 동일 파일 적용 | + +첫 로컬 `python3 -m pytest`는 호스트 Python에 pytest가 없어 종료 코드 1이었다. 시험 코드를 변경한 실패가 아니라 실행 환경 의존성 문제였고, 기존 BGE 이미지 안에서 pytest를 설치해 재실행했다. 이어서 기존 이미지의 TestClient가 `client=` 인자를 지원하지 않아 최초 단독 점검이 `TypeError`로 실패했다. 테스트는 해당 런타임이 지원하는 기본 클라이언트로 수정했고 최종 23건이 통과했다. 이 파일은 시험 도구 출력에서 확인한 비식별 요약이며 실시간 raw 터미널 로그 파일은 아니다. diff --git a/docs/test-results/gimin-479-gcp-rag-observability-20261010.md b/docs/test-results/gimin-479-gcp-rag-observability-20261010.md new file mode 100644 index 00000000..ef25f838 --- /dev/null +++ b/docs/test-results/gimin-479-gcp-rag-observability-20261010.md @@ -0,0 +1,95 @@ +# GCP BGE-M3·Ollama RAG 관측 지표 연결 + +## 결론 + +BGE-M3의 실제 `/metrics`를 GCP 관측 VM Prometheus가 수집했고, 앱 A/B에 Ollama 호출 결과·시간·토큰 지표를 배포했다. 공개 PDF→검색→RAG 실요청 중 BGE 요청 **2건**, Ollama `partial` **1건**·**177.658711562초**·출력 토큰 **350개**를 Prometheus와 Grafana 인증 자료원에서 대조했다. 지표를 읽을 수 있는 경계는 앱→BGE `/health` **200**, 앱→BGE `/metrics` **403**, 관측 VM→BGE `/metrics` **200**으로 확인했다. + +이번 GCP 실요청의 답변은 `SUCCESS`와 인용 3건이었지만 **부분 답변**으로 분류됐고 시험 정답 마커도 포함하지 않았다. 답변 상태만으로 의미적 정답이나 7B의 충분한 성능을 주장하지 않는다. 이전 지표 적용 전 공개 E2E의 완전 답변은 [별도 실행](gimin-478-gcp-ollama-public-rag-e2e-20261010.md)으로 구분한다. + +```text +그림 1. 실제 수집 경로 + +공개 PDF·검색 요청 → 앱 A → BGE-M3 :8000 /embed + │ └─ 요청 Counter·대기열·메모리 지표 + │ + └─ Ollama :11434 /api/generate + └─ 앱 OllamaClient가 결과·시간·토큰 Micrometer 기록 + +관측 VM Prometheus ── /metrics ──▶ BGE-M3 + └── /actuator/prometheus ──▶ 앱 A/B + │ + ▼ + Grafana 인증 자료원 질의 +``` + +```text +그림 2. 같은 포트의 보안 경계 + +앱 A/B ── TCP 8000 ──▶ BGE /embed : 허용 + └──▶ BGE /health : 200 관측 + └──▶ BGE /metrics: 403 관측 + +관측 VM 태그 ── 좁은 방화벽 허용 ── TCP 8000 ──▶ BGE /metrics: 200 관측 +그 밖의 VM ── 기존 BGE ingress deny ───────────▶ 포트 차단 + +방화벽은 URL 경로를 모르므로, /metrics의 최종 판정은 +BGE 앱이 EMBEDDING_METRICS_ALLOWED_CLIENTS와 실제 클라이언트 주소를 비교한다. +``` + +```text +그림 3. OllamaClient 내부 계측 순서 + +RagFacade → OllamaClient.generate(prompt) + │ 시작 단조 시계 기록 + ├─ /api/generate 스트림 정상 종료 → complete + ├─ 답변 잘림 안내와 함께 반환 → partial + └─ 예외 → failure + │ + └─ finally: 결과 Counter + 소요 시간 Timer + └─ 응답에 실제 토큰 수가 있을 때만 input/output Summary + +레이블: outcome·kind만 사용. +프롬프트·답변·사용자·문서 ID는 메트릭에 기록하지 않는다. +``` + +```text +그림 4. 시험과 원복의 시간 경계 + +사전: 앱 A/B 실행, 관측·부하 VM 중지, BGE 중지 +시험: BGE·Ollama 실행 → 앱 JAR 배포 → 앱 B 임시 중지 + → 관측 VM 시작 → BGE scrape up=1 → 합성 PDF·RAG + → Grafana 수치 대조 +원복: Ollama·BGE 중지 → 앱 B·부하 VM 재시작 + → 앱 A Worker 필터·시험 프로필 제거 → 앱 A/B HTTP 200 + → OS Login·VM 메타데이터 임시 키 삭제 + +현재 BGE는 의도적으로 중지됐으므로 Grafana의 최신 up은 0이 될 수 있다. +시험 중의 up=1과 현재 상태를 혼동하지 않는다. +``` + +## 변경 내용 + +| 파일·구성요소 | 변경 목적 | +| --- | --- | +| `backend/src/main/java/com/opensource/docgrid/domain/rag/service/OllamaClient.java` | 호출 결과 `complete/partial/failure`, duration, 실제 응답 토큰 수를 저카디널리티 지표로 기록 | +| `backend/src/test/java/com/opensource/docgrid/domain/rag/service/OllamaClientTest.java`·`OllamaClientWireContractTest.java` | 세 결과 분기와 토큰 기록을 결정적 계약 테스트로 확인 | +| `backend/embedding-server/main.py`·`test_main.py` | 배포 설정이 있을 때 `/metrics`를 지정 클라이언트만 허용; `/embed`는 그대로 유지 | +| `monitoring/prometheus/prometheus.yml`·`targets/embedding-provider.yml` | 로컬 기본 BGE 대상과 외부 배포의 target 파일을 분리 | +| GCP 관측 VM Prometheus 설정·방화벽 | 기존 구성 백업 뒤 실제 BGE 대상·관측 태그 ingress만 추가, 문법 검사 후 적용 | + +## 실행·검증 + +| 실행 ID | 위치·명령 또는 방법 | 관측 결과 | 판정·상세 | +| --- | --- | --- | --- | +| `rag479-local-contract-r1` | 로컬 Gradle 대상 테스트, BGE 이미지 안 pytest, promtool config | Java **16/16**, Python **23/23**, promtool 통과 | [계약 로그](evidence/issue-479/rag479-local-contract-r1.md) | +| `rag479-gcp-scrape-r1` | GCP 앱·BGE·관측 VM에서 HTTP 상태, Prometheus·Grafana query | 200/403/200 접근 경계, `up=1`, BGE 요청 **2** | [수집 로그](evidence/issue-479/rag479-gcp-scrape-r1.md) | +| `rag479-live-034630` | 공개 API PDF 1건·검색·RAG + 지표 대조 | `INDEXED`, 검색 4건, 답변 69자·인용 3건; Ollama `partial=1`, **177.659초**, output **350토큰** | [E2E·지표 로그](evidence/issue-479/rag479-live-034630.md) | + +## 해석과 남는 범위 + +- 앱 A/B의 적용 JAR SHA-256은 동일하다. 두 앱의 공개 `/departments`가 **각각 200**이고 Worker가 **둘 다 비활성**인 상태로 끝났다. +- 첫 GCP 지표 시험의 Worker 설정은 필수 `worker-scope-test` 프로필 누락으로 기동에 실패했다. 반복된 공개 503을 서비스 실패 표본으로 남기고 원인을 보완한 뒤 동일 문서 버전만 다시 처리했다. +- Grafana 값은 인증 API의 Prometheus 자료원을 통한 **실제 질의**로 확인했다. 새 전용 대시보드 패널은 만들지 않았다. +- BGE VM에는 기존 CPU 호환 이미지에 수정 소스를 덮은 로컬 이미지를 적용했다. 저장소 Dockerfile로 의존성을 포함해 완전한 새 이미지를 빌드·기동하는 검증은 이 실행의 범위 밖이다. +- `complete`·`failure` 지표 분기는 로컬 테스트에서 확인했고, GCP 실요청에서는 `partial`만 발생했다. 이 차이를 성공·실패 집계에 숨기지 않는다. +- 관측 VM은 시험 전 실행 상태로 복귀했고 BGE·Ollama는 중지했다. 따라서 지금 공개 RAG 질문은 Ollama VM을 재시작하지 않으면 성공하지 않는다. CPU VM의 중지 후에도 부팅 디스크 보관 요금은 남는다. diff --git a/monitoring/prometheus/prometheus.yml b/monitoring/prometheus/prometheus.yml index 55991d32..ba2c3420 100644 --- a/monitoring/prometheus/prometheus.yml +++ b/monitoring/prometheus/prometheus.yml @@ -21,9 +21,7 @@ scrape_configs: - job_name: embedding-provider metrics_path: /metrics - static_configs: - - targets: - - embedding-server:8000 - labels: - environment: local - cluster: docgrid-local + file_sd_configs: + # GCP 관측 VM은 별도 target 파일만 바꾸고 로컬 기본값은 아래 파일을 사용한다. + - files: + - /etc/prometheus/targets/embedding-provider.yml diff --git a/monitoring/prometheus/targets/embedding-provider.yml b/monitoring/prometheus/targets/embedding-provider.yml new file mode 100644 index 00000000..6fccbd92 --- /dev/null +++ b/monitoring/prometheus/targets/embedding-provider.yml @@ -0,0 +1,5 @@ +- targets: + - embedding-server:8000 + labels: + environment: local + cluster: docgrid-local