KServe Predict Proxy PII Masking (#1282 G-2 M3)
apps/api/src/gend_api/services/serving/inference_proxy.py 의 InferenceProxy.predict() 가 KServe 로 forwarding 전후로 request/response body 의 PII 를 자동 마스킹.
디자인 결정
1. 왜 fail-open?
- Production smoke 안전성 우선 — PII masker 라이브러리 (Presidio) 장애 시에도 predict 응답 유지
model.predict.pii_mask_erroraudit emit 으로 추적 가능- 마스킹 실패가 inference 자체를 차단하는 것은 더 큰 가용성 손실
2. 왜 ABAC 이후에?
- ABAC denied 케이스에서 PII masker 호출 0회 (불필요한 처리)
- 마스킹 성능 (~100ms) 을 차단된 요청에 낭비하지 않음
3. 왜 재귀 dict 순회?
- KServe v2 protocol body 는 임의 깊이의 dict/list 중첩 (예:
inputs[0].data[0]) - 모든 string leaf 노드에 PII 패턴 적용해야 함
- 비-string 값 (int/float/bool/None) 은 통과 — PII 가 numeric 에는 없음
코드 핵심
async def predict(self, *, body: dict, ...) -> dict:
# 1. ABAC check (생략)
if not allowed: raise ProxyDenied(...)
# 2. PII mask request
masked_body, mask_count_req = _mask_payload_pii(body, self._text_masker)
# 3. POST KServe
resp = await http.post(url, json=masked_body)
# 4. PII mask response
masked_resp, mask_count_resp = _mask_payload_pii(resp.json(), self._text_masker)
# 5. Audit
_audit_logger.info(
"model.predict ... mask_count=%d/%d",
mask_count_req, mask_count_resp,
)
return masked_resp
def _mask_payload_pii(payload: dict, masker: TextPIIMasker) -> tuple[dict, int]:
"""재귀 traversal — dict/list/str 마스킹 + 카운트."""
count = 0
def walk(obj):
nonlocal count
if isinstance(obj, dict):
return {k: walk(v) for k, v in obj.items()}
if isinstance(obj, list):
return [walk(x) for x in obj]
if isinstance(obj, str):
try:
masked = masker.mask(obj)
if masked != obj:
count += 1
return masked
except Exception:
logger.exception("PII mask error (fail-open)")
return obj # fail-open
return obj
return walk(payload), count
회귀 가드 — 7-9 tests
apps/api/tests/test_inference_proxy_pii_masking.py:
- request body 이메일/전화번호 마스킹 → upstream 으로 전송 검증
- response body PII 마스킹 → caller 반환 검증
- 중첩 dict (
{"inputs": [{"data": ["alice@x.com"]}]}) 재귀 - 비-문자열 값 통과
- TextPIIMasker Exception → fail-open + 원본 유지
- 마스킹 0건 → 기존 audit 라인 그대로
- ABAC denied → 마스킹 호출 0회
트러블슈팅
| 증상 | 원인 | 해결 |
|---|---|---|
| 마스킹 0회인데 PII 누출 | Presidio 미배포 | kubectl get pods -l app=presidio |
| ContextGateway 에 PII_PATTERNS 정의되었으나 사용 안 됨 | TextPIIMasker 가 Presidio 의존 | regex fallback 추가 follow-up |
| 마스킹으로 inference 실패 | 마스킹 결과가 모델 입력 schema 깨뜨림 | mask_replace_value="X" 같은 동일 길이 토큰 사용 |
관련
- PR #1282
- TextPIIMasker:
apps/api/src/gend_api/services/pii_masking/text_masker.py - ContextGateway PII_PATTERNS:
apps/api/src/gend_api/services/context_gateway.py