mTLS로 OTLP 엔드포인트 보호하기 (feat. Claude Code Hook)
Kubernetes에서 OTLP 수집기를 외부에 노출할 때 mTLS로 접근을 제한하는 방법을 다룹니다. 인증서 생성부터 Nginx Ingress 설정, Claude Code Hook 연동까지 실제 구현 과정을 공유합니다.
클러스터 외부에서 OTLP Collector에 접근하려면
Kubernetes 클러스터 내부에 OpenTelemetry Collector(Alloy)를 운영하고 있습니다. 클러스터 내부 워크로드는 Service DNS로 텔레메트리를 전송할 수 있습니다:
alloy.observability.svc.cluster.local:4317네트워크 정책으로 접근을 제한할 수 있고, 클러스터 내부 통신이라 별도 인증 없이도 신뢰할 수 있습니다.
그러나 클러스터 외부 워크로드는 Ingress를 통해 인터넷으로 노출된 엔드포인트를 사용해야 합니다:
https://otel.heeho.net문제는 이 엔드포인트가 누구에게나 열려 있다는 것입니다. 아무나 텔레메트리 데이터를 보낼 수 있으면:
- 스팸 데이터로 백엔드 리소스 낭비
- 메트릭/로그 오염
- 잘못된 알림 발생
접근을 제한해야 합니다.
OTLP 엔드포인트 보안 방법
OpenTelemetry 공식 문서와 보안 가이드를 조사했습니다.
| 방법 | 설명 | 장점 | 단점 |
|---|---|---|---|
| mTLS | 클라이언트/서버 상호 인증서 검증 | 강력한 인증, Zero Trust 모델 | 인증서 관리 필요 |
| API Key/Bearer Token | HTTP 헤더에 토큰 포함 | 구현 간단 | 토큰 노출 위험, 정적 |
| OAuth2/OIDC | 동적 토큰 발급 | 표준화, 세분화된 권한 | 복잡한 설정 |
| IP 화이트리스트 | 허용된 IP만 접근 | 간단 | 동적 IP 대응 어려움 |
왜 TLS가 아니라 mTLS인가?
TLS와 mTLS의 핵심 차이:
| 구분 | TLS | mTLS |
|---|---|---|
| 서버 인증 | ✅ 서버가 인증서로 신원 증명 | ✅ 동일 |
| 클라이언트 인증 | ❌ 없음 (누구나 접속 가능) | ✅ 클라이언트도 인증서 필요 |
| 검증 방향 | 단방향 (클라이언트 → 서버) | 양방향 (상호 검증) |
TLS:클라이언트 ──────────────────→ 서버 "서버가 맞는지 확인" (클라이언트는 누군지 모름)
mTLS:클라이언트 ←─────────────────→ 서버 "서로가 맞는지 확인"TLS만 적용하면:
# HTTPS로 암호화는 되지만, 아무나 데이터를 보낼 수 있음curl https://otel.heeho.net/v1/metrics -d '스팸 데이터'# → 성공 (서버는 클라이언트가 누군지 모름)mTLS를 적용하면:
# 인증서 없이는 접근 자체가 차단됨curl https://otel.heeho.net/v1/metrics -d '스팸 데이터'# → 400 Bad Request (인증서 없음)mTLS를 선택한 이유
- Zero Trust: 양방향 인증으로 클라이언트/서버 모두 신원 확인
- 토큰 노출 위험 없음: 인증서는 파일 시스템에 저장, 네트워크로 전송되지 않음
- 고정 클라이언트: Mac 1대 → 인증서 관리 부담 적음
- OpenTelemetry 권장: 공식 문서에서 프로덕션 환경에 mTLS 권장
“In a production environment, use TLS certificates for secure communication or mTLS for mutual authentication.” — OpenTelemetry Collector Security Best Practices
아키텍처
flowchart LR subgraph External["External (Mac)"] Client["Claude Code<br/>+ 클라이언트 인증서"] end
subgraph K8s["Kubernetes Cluster"] subgraph Ingress["Nginx Ingress"] IG["otel-http.heeho.net<br/>mTLS 검증"] end
subgraph Obs["observability namespace"] Alloy["Alloy<br/>OTLP Collector"] Tempo["Tempo"] Prom["Prometheus"] Loki["Loki"] end end
Client -->|"HTTPS + 클라이언트 인증서"| IG IG -->|"인증서 검증 통과"| Alloy Alloy --> Tempo Alloy --> Prom Alloy --> Loki흐름:
- 클라이언트가 인증서와 함께 HTTPS 요청
- Nginx Ingress가 클라이언트 인증서를 CA로 검증
- 검증 통과 시 Alloy로 프록시
- 검증 실패 시 400 Bad Request 반환
인증서 생성하기
1. CA (Certificate Authority) 생성
클라이언트 인증서를 서명할 CA를 먼저 만듭니다.
# 작업 디렉토리mkdir -p ~/.certs/otlp && cd ~/.certs/otlp
# CA 개인키 생성 (4096bit RSA)openssl genrsa -out client-ca.key 4096
# CA 인증서 생성 (10년 유효)openssl req -new -x509 -days 3650 -key client-ca.key \ -out client-ca.crt -subj "/CN=OTLP Client CA/O=homelab"2. 클라이언트 인증서 생성
Mac에서 사용할 클라이언트 인증서를 생성합니다.
# 클라이언트 개인키openssl genrsa -out mac-client.key 4096
# CSR (Certificate Signing Request) 생성openssl req -new -key mac-client.key \ -out mac-client.csr -subj "/CN=heeho-mac/O=homelab"
# CA로 서명하여 인증서 발급 (1년 유효)openssl x509 -req -days 365 -in mac-client.csr \ -CA client-ca.crt -CAkey client-ca.key -CAcreateserial \ -out mac-client.crt
# CSR 삭제 (더 이상 필요 없음)rm mac-client.csr
# 통합 PEM 파일 생성 (일부 클라이언트용)cat mac-client.crt mac-client.key > mac-client.pem생성된 파일 구조
~/.certs/otlp/├── client-ca.crt # CA 인증서 → Kubernetes에 등록├── client-ca.key # CA 개인키 → 안전하게 보관├── client-ca.srl # 시리얼 번호 (자동 생성)├── mac-client.crt # 클라이언트 인증서├── mac-client.key # 클라이언트 개인키└── mac-client.pem # 인증서+개인키 통합본Kubernetes 설정하기
1. CA 인증서를 Secret으로 등록
Nginx Ingress가 클라이언트 인증서를 검증할 때 사용할 CA입니다.
kubectl create secret generic otel-client-ca \ --from-file=ca.crt=~/.certs/otlp/client-ca.crt \ -n observability2. Alloy Ingress 설정
mTLS가 적용된 Ingress를 생성합니다.
# alloy.yaml (Helm values)extraObjects: - apiVersion: networking.k8s.io/v1 kind: Ingress metadata: name: alloy-otlp-http namespace: observability annotations: nginx.ingress.kubernetes.io/ssl-redirect: "true" # mTLS 핵심 설정 nginx.ingress.kubernetes.io/auth-tls-verify-client: "on" nginx.ingress.kubernetes.io/auth-tls-secret: "observability/otel-client-ca" nginx.ingress.kubernetes.io/auth-tls-verify-depth: "1" spec: ingressClassName: nginx rules: - host: otel-http.heeho.net http: paths: - path: / pathType: Prefix backend: service: name: alloy port: number: 4318 # OTLP HTTP tls: - secretName: wildcard.heeho.net hosts: - otel-http.heeho.net핵심 annotation 설명:
| Annotation | 값 | 설명 |
|---|---|---|
auth-tls-verify-client | on | 클라이언트 인증서 필수 |
auth-tls-secret | observability/otel-client-ca | 검증에 사용할 CA Secret |
auth-tls-verify-depth | 1 | 인증서 체인 검증 깊이 |
3. Helm 배포
helm upgrade alloy grafana/alloy -n observability -f alloy.yamlmTLS 동작 확인하기
OTLP 엔드포인트는 POST 요청만 허용합니다. POST로 테스트합니다.
테스트 1: 인증서 없이 접근
curl -s -w "\nHTTP Status: %{http_code}\n" \ -X POST https://otel-http.heeho.net/v1/metrics \ -H "Content-Type: application/x-protobuf"결과:
<html><head><title>400 No required SSL certificate was sent</title></head><body><center><h1>400 Bad Request</h1></center><center>No required SSL certificate was sent</center><hr><center>nginx</center></body></html>HTTP Status: 400인증서 없이는 Ingress 단계에서 차단됩니다.
테스트 2: 인증서로 접근
curl -s -w "\nHTTP Status: %{http_code}\n" \ -X POST https://otel-http.heeho.net/v1/metrics \ -H "Content-Type: application/json" \ --cert ~/.certs/otlp/mac-client.crt \ --key ~/.certs/otlp/mac-client.key \ -d '{ "resourceMetrics": [{ "resource": { "attributes": [{"key": "service.name", "value": {"stringValue": "mtls-test"}}] }, "scopeMetrics": [{ "metrics": [{ "name": "test.counter", "sum": { "dataPoints": [{"asInt": "1", "timeUnixNano": "1234567890000000000"}], "isMonotonic": true } }] }] }] }'결과:
{"partialSuccess":{}}HTTP Status: 200HTTP 200과 JSON 응답이 반환되면 Alloy까지 정상 도달한 것입니다.
테스트 3: 잘못된 인증서로 접근
# 임시 인증서 생성 (등록된 CA가 아닌 self-signed)openssl req -x509 -newkey rsa:2048 -keyout /tmp/fake.key -out /tmp/fake.crt \ -days 1 -nodes -subj "/CN=fake" 2>/dev/null
# 접근 시도curl -s -w "\nHTTP Status: %{http_code}\n" \ -X POST https://otel-http.heeho.net/v1/metrics \ --cert /tmp/fake.crt --key /tmp/fake.key결과:
<html><head><title>400 The SSL certificate error</title></head><body><center><h1>400 Bad Request</h1></center><center>The SSL certificate error</center><hr><center>nginx</center></body></html>HTTP Status: 400등록된 CA가 서명하지 않은 인증서는 거부됩니다.
Claude Code Hook 연동하기
Claude Code는 Hook 시스템을 통해 세션과 도구 사용을 외부로 전송할 수 있습니다.
Claude Code Hook이란? Claude Code CLI가 특정 이벤트(세션 시작/종료, 도구 호출 등) 발생 시 사용자 정의 스크립트를 실행하는 기능입니다.
Hook 발동 시점 용도 SessionStartClaude Code 시작 세션 추적 시작 PostToolUse도구 호출 완료 후 도구 사용 기록
Hook 스크립트에서 OpenTelemetry Python SDK를 사용해 mTLS로 보호된 OTLP 엔드포인트로 trace를 전송합니다. SessionStart에서 생성한 trace context를 저장하고, PostToolUse에서 이를 parent로 사용하여 하나의 세션을 하나의 trace로 추적합니다.
1. OTLP Tracer Hook 스크립트
~/.claude/hooks/otlp-tracer.py:
#!/usr/bin/env -S uv run --script# /// script# requires-python = ">=3.11"# dependencies = [# "opentelemetry-sdk",# "opentelemetry-exporter-otlp-proto-http",# ]# ///"""OTLP Tracer for Claude Code세션별로 하나의 Trace를 생성하고, 도구 사용을 child span으로 연결합니다."""
import jsonimport osimport sysfrom pathlib import Path
STATE_DIR = Path.home() / ".claude" / "hooks" / "state"STATE_DIR.mkdir(parents=True, exist_ok=True)
def get_otlp_exporter(): from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
endpoint = os.environ.get("OTEL_EXPORTER_OTLP_ENDPOINT") if not endpoint: return None
return OTLPSpanExporter( endpoint=f"{endpoint}/v1/traces", client_certificate_file=os.environ.get("OTLP_CLIENT_CERTIFICATE"), client_key_file=os.environ.get("OTLP_CLIENT_KEY"), timeout=30, # mTLS 첫 연결 시 SSL 세션 설정 시간 필요 )
def get_tracer_provider(): from opentelemetry.sdk.trace import TracerProvider from opentelemetry.sdk.trace.export import SimpleSpanProcessor from opentelemetry.sdk.resources import Resource
exporter = get_otlp_exporter() if not exporter: return None
resource = Resource.create({ "service.name": "claude-code-hook", "service.version": "1.0.0", })
provider = TracerProvider(resource=resource) provider.add_span_processor(SimpleSpanProcessor(exporter)) return provider
def save_state(session_id, data): (STATE_DIR / f"otlp-{session_id}.json").write_text(json.dumps(data))
def load_state(session_id): state_file = STATE_DIR / f"otlp-{session_id}.json" if state_file.exists(): return json.loads(state_file.read_text()) return {}
def create_parent_context(trace_id_hex, span_id_hex): """저장된 trace/span ID로 parent context를 재구성합니다.""" from opentelemetry import trace from opentelemetry.trace import SpanContext, TraceFlags
parent_context = SpanContext( trace_id=int(trace_id_hex, 16), span_id=int(span_id_hex, 16), is_remote=False, trace_flags=TraceFlags(TraceFlags.SAMPLED), ) return trace.set_span_in_context(trace.NonRecordingSpan(parent_context))
def handle_session_start(data, provider): """세션 시작 시 root span을 생성하고 context를 저장합니다.""" tracer = provider.get_tracer("claude-code-hook") session_id = data.get("session_id", "unknown")
with tracer.start_as_current_span("session") as span: span.set_attribute("session.id", session_id) span.set_attribute("session.cwd", data.get("cwd", ""))
# child span을 위해 trace context 저장 ctx = span.get_span_context() save_state(session_id, { "trace_id": format(ctx.trace_id, '032x'), "span_id": format(ctx.span_id, '016x'), })
def handle_post_tool_use(data, provider): """도구 사용을 세션의 child span으로 기록합니다.""" from opentelemetry.context import attach, detach
session_id = data.get("session_id", "unknown") tool_name = data.get("tool_name", "unknown") state = load_state(session_id) tracer = provider.get_tracer("claude-code-hook")
# 저장된 context를 parent로 설정 token = None if state.get("trace_id") and state.get("span_id"): token = attach(create_parent_context(state["trace_id"], state["span_id"]))
try: with tracer.start_as_current_span(f"tool:{tool_name}") as span: span.set_attribute("session.id", session_id) span.set_attribute("tool.name", tool_name) finally: if token: detach(token)
def main(): input_data = json.load(sys.stdin) hook_event = input_data.get("hook_event_name", "")
provider = get_tracer_provider() if not provider: sys.exit(0)
if hook_event == "SessionStart": handle_session_start(input_data, provider) elif hook_event == "PostToolUse": handle_post_tool_use(input_data, provider)
provider.force_flush()
if __name__ == "__main__": main()스크립트에 실행 권한을 부여합니다:
chmod +x ~/.claude/hooks/otlp-tracer.pyNote: 첫 실행 시 uv가 의존성을 자동으로 다운로드합니다. 이후에는 캐시된 패키지를 사용하므로 빠르게 실행됩니다.
2. settings.json 설정
~/.claude/settings.json에 환경변수와 Hook을 등록합니다.
{ "env": { "OTEL_EXPORTER_OTLP_ENDPOINT": "https://otel-http.heeho.net", "OTLP_CLIENT_CERTIFICATE": "~/.certs/otlp/mac-client.crt", "OTLP_CLIENT_KEY": "~/.certs/otlp/mac-client.key", "REQUESTS_CA_BUNDLE": "/etc/ssl/cert.pem" }, "hooks": { "SessionStart": [ { "hooks": [ { "type": "command", "command": "~/.claude/hooks/otlp-tracer.py", "timeout": 30 } ] } ], "PostToolUse": [ { "matcher": "*", "hooks": [ { "type": "command", "command": "~/.claude/hooks/otlp-tracer.py", "timeout": 30 } ] } ] }}주요 환경변수:
| 환경변수 | 설명 |
|---|---|
OTEL_EXPORTER_OTLP_ENDPOINT | OTLP 엔드포인트 (mTLS 적용) |
OTLP_CLIENT_CERTIFICATE | 클라이언트 인증서 경로 |
OTLP_CLIENT_KEY | 클라이언트 개인키 경로 |
REQUESTS_CA_BUNDLE | CA 번들 (macOS: /etc/ssl/cert.pem) |
3. 동작 확인
Claude Code를 실행하고 Ingress 로그를 확인합니다:
kubectl logs -n ingress-nginx -l app.kubernetes.io/name=ingress-nginx --tail=10 \ | grep "OTel-OTLP-Exporter-Python"결과:
"POST /v1/traces HTTP/1.1" 200 ... "OTel-OTLP-Exporter-Python/1.39.1" ... [observability-alloy-4318]Python OTLP exporter가 mTLS 인증서로 Alloy에 trace를 전송한 것을 확인할 수 있습니다.
Tempo에서 trace를 조회하면 Claude Code 세션과 도구 사용 기록을 확인할 수 있습니다.

트러블슈팅
gRPC vs HTTP 프로토콜 선택
증상: gRPC 프로토콜로 OTLP 엔드포인트에 연결 실패
원인: Nginx Ingress를 통한 gRPC는 추가 설정 필요
gRPC는 HTTP/2 기반이지만, HTTPS 엔드포인트와 직접 호환되지 않습니다. Nginx Ingress를 통해 gRPC를 사용하려면:
backend-protocol: "GRPC"annotation 필요- 클라이언트가 gRPC over TLS를 지원해야 함
- 일부 OTEL SDK는 이 조합에서 호환성 이슈 발생
해결: HTTP 프로토콜(http/protobuf)을 사용하고, OTLP HTTP 포트(4318)를 노출
| 프로토콜 | 전송 방식 | Ingress 설정 | 호환성 |
|---|---|---|---|
grpc | HTTP/2 + Protobuf | backend-protocol: "GRPC" | 일부 클라이언트 이슈 |
http/protobuf | HTTP/1.1 + Protobuf | 기본 HTTP | 대부분 호환 |
Python SDK의 OTLPSpanExporter는 HTTP 프로토콜을 사용하므로 별도 설정 없이 mTLS와 잘 동작합니다.
정리
외부 OTLP 엔드포인트에 mTLS를 적용하면:
| 요청 | 결과 |
|---|---|
| 인증서 없음 | 400 Bad Request |
| 잘못된 인증서 | 400 Bad Request |
| 올바른 인증서 | 200 OK (Alloy 도달) |
최종 아키텍처:
Claude Code Hook (SessionStart, PostToolUse) ↓ otlp-tracer.py (OpenTelemetry Python SDK) ↓ mTLS (클라이언트 인증서) Nginx Ingress (otel-http.heeho.net) ↓ Alloy (OTLP Receiver :4318) ↓ Tempo (Traces)DevOps 관점에서 얻은 것:
- Zero Trust 네트워크: 클러스터 외부 통신도 상호 인증
- Observability 파이프라인 보안: 텔레메트리 데이터 위변조 방지
- Kubernetes Ingress 활용: 애플리케이션 변경 없이 mTLS 적용
- Claude Code 확장성: Hook 시스템으로 다양한 텔레메트리 통합 가능
OpenTelemetry 공식 문서에서도 프로덕션 환경에 mTLS를 권장합니다. 외부에서 OTLP 데이터를 받아야 한다면 mTLS 적용을 고려해보세요.
참고 자료
관련 콘텐츠
HTTP/2와 gRPC 이해하기
HTTP/2 프레임 구조부터 gRPC 동작 방식, Protobuf 인코딩까지 직접 확인하며 이해합니다.
DevOpsLinkedIn에서 발견한 Tencent WeKnora, GraphRAG PoC하고 PR까지 Merged
LinkedIn에서 발견한 Tencent WeKnora를 홈 Kubernetes 클러스터에서 PoC하고, Helm Chart PR까지 Merge한 여정
productivityClaude Code로 포트폴리오 + 기술 블로그 운영하기
AI 코딩 에이전트와 협업하여 포트폴리오 + 기술 블로그를 구축한 경험을 공유합니다. 효과적인 협업 방식, 자동화 시스템 구현, 운영 워크플로우까지 다룹니다.