Observability mTLS OTLP OpenTelemetry Kubernetes Claude Code

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 TokenHTTP 헤더에 토큰 포함구현 간단토큰 노출 위험, 정적
OAuth2/OIDC동적 토큰 발급표준화, 세분화된 권한복잡한 설정
IP 화이트리스트허용된 IP만 접근간단동적 IP 대응 어려움

왜 TLS가 아니라 mTLS인가?

TLS와 mTLS의 핵심 차이:

구분TLSmTLS
서버 인증✅ 서버가 인증서로 신원 증명✅ 동일
클라이언트 인증❌ 없음 (누구나 접속 가능)✅ 클라이언트도 인증서 필요
검증 방향단방향 (클라이언트 → 서버)양방향 (상호 검증)
TLS:
클라이언트 ──────────────────→ 서버
"서버가 맞는지 확인"
(클라이언트는 누군지 모름)
mTLS:
클라이언트 ←─────────────────→ 서버
"서로가 맞는지 확인"

TLS만 적용하면:

Terminal window
# HTTPS로 암호화는 되지만, 아무나 데이터를 보낼 수 있음
curl https://otel.heeho.net/v1/metrics -d '스팸 데이터'
# → 성공 (서버는 클라이언트가 누군지 모름)

mTLS를 적용하면:

Terminal window
# 인증서 없이는 접근 자체가 차단됨
curl https://otel.heeho.net/v1/metrics -d '스팸 데이터'
# → 400 Bad Request (인증서 없음)

mTLS를 선택한 이유

  1. Zero Trust: 양방향 인증으로 클라이언트/서버 모두 신원 확인
  2. 토큰 노출 위험 없음: 인증서는 파일 시스템에 저장, 네트워크로 전송되지 않음
  3. 고정 클라이언트: Mac 1대 → 인증서 관리 부담 적음
  4. 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

흐름:

  1. 클라이언트가 인증서와 함께 HTTPS 요청
  2. Nginx Ingress가 클라이언트 인증서를 CA로 검증
  3. 검증 통과 시 Alloy로 프록시
  4. 검증 실패 시 400 Bad Request 반환

인증서 생성하기

1. CA (Certificate Authority) 생성

클라이언트 인증서를 서명할 CA를 먼저 만듭니다.

Terminal window
# 작업 디렉토리
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에서 사용할 클라이언트 인증서를 생성합니다.

Terminal window
# 클라이언트 개인키
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입니다.

Terminal window
kubectl create secret generic otel-client-ca \
--from-file=ca.crt=~/.certs/otlp/client-ca.crt \
-n observability

2. 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-clienton클라이언트 인증서 필수
auth-tls-secretobservability/otel-client-ca검증에 사용할 CA Secret
auth-tls-verify-depth1인증서 체인 검증 깊이

3. Helm 배포

Terminal window
helm upgrade alloy grafana/alloy -n observability -f alloy.yaml

mTLS 동작 확인하기

OTLP 엔드포인트는 POST 요청만 허용합니다. POST로 테스트합니다.

테스트 1: 인증서 없이 접근

Terminal window
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: 인증서로 접근

Terminal window
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: 200

HTTP 200과 JSON 응답이 반환되면 Alloy까지 정상 도달한 것입니다.

테스트 3: 잘못된 인증서로 접근

Terminal window
# 임시 인증서 생성 (등록된 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 json
import os
import sys
from 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()

스크립트에 실행 권한을 부여합니다:

Terminal window
chmod +x ~/.claude/hooks/otlp-tracer.py

Note: 첫 실행 시 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_ENDPOINTOTLP 엔드포인트 (mTLS 적용)
OTLP_CLIENT_CERTIFICATE클라이언트 인증서 경로
OTLP_CLIENT_KEY클라이언트 개인키 경로
REQUESTS_CA_BUNDLECA 번들 (macOS: /etc/ssl/cert.pem)

3. 동작 확인

Claude Code를 실행하고 Ingress 로그를 확인합니다:

Terminal window
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 세션과 도구 사용 기록을 확인할 수 있습니다.

Tempo trace 조회


트러블슈팅

gRPC vs HTTP 프로토콜 선택

증상: gRPC 프로토콜로 OTLP 엔드포인트에 연결 실패

원인: Nginx Ingress를 통한 gRPC는 추가 설정 필요

gRPC는 HTTP/2 기반이지만, HTTPS 엔드포인트와 직접 호환되지 않습니다. Nginx Ingress를 통해 gRPC를 사용하려면:

  1. backend-protocol: "GRPC" annotation 필요
  2. 클라이언트가 gRPC over TLS를 지원해야 함
  3. 일부 OTEL SDK는 이 조합에서 호환성 이슈 발생

해결: HTTP 프로토콜(http/protobuf)을 사용하고, OTLP HTTP 포트(4318)를 노출

프로토콜전송 방식Ingress 설정호환성
grpcHTTP/2 + Protobufbackend-protocol: "GRPC"일부 클라이언트 이슈
http/protobufHTTP/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 관점에서 얻은 것:

  1. Zero Trust 네트워크: 클러스터 외부 통신도 상호 인증
  2. Observability 파이프라인 보안: 텔레메트리 데이터 위변조 방지
  3. Kubernetes Ingress 활용: 애플리케이션 변경 없이 mTLS 적용
  4. Claude Code 확장성: Hook 시스템으로 다양한 텔레메트리 통합 가능

OpenTelemetry 공식 문서에서도 프로덕션 환경에 mTLS를 권장합니다. 외부에서 OTLP 데이터를 받아야 한다면 mTLS 적용을 고려해보세요.


참고 자료

관련 콘텐츠

댓글