Kubernetes 환경에서 여러 개의 gRPC 기반의 마이크로서비스를 운영하고 있었다. Buf Schema Registry로 proto 파일을 중앙 관리했지만, 실제 배포된 서비스와 BSR 스키마가 불일치하는 문제가 반복됐다. 이를 자동으로 감지하는 Kubernetes 네이티브 모니터링 도구를 Go로 개발하고 오픈소스로 공개했다.
Kubernetes 클러스터에서 여러개의 gRPC 기반의 마이크로서비스를 운영하고 있었다. Buf Schema Registry로(BSR) proto 파일을 중앙 관리했지만, 실제 배포된 Pod와 BSR 스키마가 불일치하는 문제가 반복됐다.
발생 시나리오:
1. 개발자가 proto 수정
2. BSR 업데이트 없이 Kubernetes에 배포
3. BSR 기준으로 개발한 클라이언트 서비스가 런타임 에러 발생
4. 문제 발견까지 수 시간 소요
Kubernetes 환경 특성상 발생하는 어려움:
너무 귀찮기 때문에 자동화가 필요했다.

Kubernetes 클러스터 내에서 동작하는 모니터링 도구를 Go로 개발했다. client-go를 사용해 Kubernetes API와 직접 통신하며, 현재 k8s 환경에서 돌아가고 있는 Pod들을 자동으로 발견하고 스키마를 검증한다.
언어: Go 1.21
핵심 라이브러리:
- client-go (Kubernetes API)
- grpcreflect (gRPC Reflection)
- protoreflect (Proto 파싱)
아키텍처: Hexagonal Architecture
배포: Kubernetes In-cluster Pod
저장소: In-memory (sync.RWMutex)
설정: ConfigMap + Secret
보안: RBAC, Non-root, Read-only FS
주기: 30분마다 스캔 (환경변수로 설정)
client-go를 사용해 Kubernetes API Server와 통신한다. In-cluster 환경에서 실행되므로 ServiceAccount의 토큰으로 인증한다.
// In-cluster config로 Kubernetes client 생성
config, err := rest.InClusterConfig()
clientset, err := kubernetes.NewForConfig(config)
// ConfigMap에서 서비스 매핑 로드
configMap, err := clientset.CoreV1().
ConfigMaps(namespace).
Get(ctx, configMapName, metav1.GetOptions{})
// app 레이블로 Pod 검색
labelSelector := fmt.Sprintf("app=%s", serviceName)
pods, err := clientset.CoreV1().Pods("").
List(ctx, metav1.ListOptions{
LabelSelector: labelSelector,
})
// 각 Pod의 정보 추출
// - Pod 이름, 네임스페이스
// - Pod IP (클러스터 내부 통신용)
// - gRPC 포트 (Container spec에서 자동 감지)
Kubernetes 네이티브 특성:
포트 자동 감지 우선순위:
1. grpc 이름을 가진 containerPort
2. TCP 프로토콜 포트
3. 기본값 9090
gRPC Reflection을 사용해 실행 중인 서비스에서 스키마를 가져온다.
// Pod IP:포트로 연결
conn, err := grpc.Dial(address, grpc.WithTransportCredentials(insecure.NewCredentials()))
// Reflection client 생성
refClient := grpcreflect.NewClientV1Alpha(ctx, reflectpb.NewServerReflectionClient(conn))
// 서비스 목록 조회
services, err := refClient.ListServices()
// 각 서비스의 메서드 정보 추출
for _, serviceName := range services {
serviceDesc, _ := refClient.ResolveService(serviceName)
// 메서드 이름 수집
}
proto 파일 없이 런타임에 스키마를 읽을 수 있다.
초기에는 HTTP API를 사용했으나 문제가 있었다:
Buf CLI로 전환:
// buf export로 proto 파일 전체를 내려받음
cmd := exec.CommandContext(ctx, "buf", "export", module, "-o", tmpDir)
output, err := cmd.CombinedOutput()
// protoparse로 파싱
parser := protoparse.Parser{
ImportPaths: []string{tmpDir},
}
fileDescs, err := parser.ParseFiles(relPaths...)
개선 결과:
초기에는 모든 서비스를 비교했다. 문제가 있었다:
개선된 로직:
// 양쪽에 모두 존재하는 서비스만 비교
for liveSvcName, liveMethods := range liveServicesMap {
if truthMethods, exists := truthServicesMap[liveSvcName]; exists {
// 메서드 비교
if !methodsMatch(liveMethods, truthMethods) {
// 불일치 발견
match = false
}
}
}
// Live에만 있는 서비스 → 정보성 표시, 상태에 영향 X
// BSR에만 있는 서비스 → 정보성 표시, 상태에 영향 X
비교 결과:
두 개의 goroutine이 동작한다:
// 1. Scanner goroutine (30분마다)
func (s *Scanner) Start(ctx context.Context) {
ticker := time.NewTicker(scanInterval)
for {
select {
case <-ticker.C:
runScan(ctx) // Store에 쓰기
}
}
}
// 2. Web server goroutine (요청마다)
func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
results := s.store.GetAll() // Store에서 읽기
}
Thread-safe 구현:
type Store struct {
mu sync.RWMutex
results map[string]*domain.ScanResult
}
// 여러 요청이 동시에 읽기 가능
func (s *Store) GetAll() []*ScanResult {
s.mu.RLock()
defer s.mu.RUnlock()
// ...
}
// 쓰기는 배타적 잠금
func (s *Store) Set(result *ScanResult) {
s.mu.Lock()
defer s.mu.Unlock()
// ...
}
Hexagonal Architecture (Ports and Adapters)를 사용했다. Go의 interface를 활용해 외부 의존성을 추상화했다.
protodiff/
├── cmd/protodiff/ # 애플리케이션 엔트리포인트
├── internal/
│ ├── core/
│ │ ├── domain/ # 비즈니스 모델 (ScanResult, DiffStatus)
│ │ └── store/ # Thread-safe 저장소 (sync.RWMutex)
│ ├── adapters/
│ │ ├── k8s/ # Kubernetes client (client-go)
│ │ ├── grpc/ # gRPC reflection client
│ │ ├── bsr/ # BSR client (Buf CLI wrapper)
│ │ └── web/ # HTTP 서버 (대시보드)
│ ├── scanner/ # 스키마 검증 오케스트레이터
│ └── config/ # 설정 관리
└── deploy/k8s/ # Kubernetes manifests
외부 세계
├─ Kubernetes API (client-go)
├─ gRPC Services (reflection)
├─ BSR (buf CLI)
└─ HTTP Browser
↓
Adapters Layer (Ports 구현)
├─ k8s.Client (interface)
├─ grpc.ReflectionClient (interface)
├─ bsr.Client (interface)
└─ web.Server
↓
Core Domain Layer
├─ Scanner (orchestrator)
├─ Store (sync.RWMutex)
└─ Domain Models
// BSR 클라이언트 인터페이스
type Client interface {
FetchSchema(ctx context.Context, module string) (*domain.SchemaDescriptor, error)
}
// HTTP API 구현
type HTTPClient struct { ... }
// Buf CLI 구현 (production)
type BufClient struct { ... }
// 의존성 주입
scanner := scanner.NewScanner(
k8sClient,
grpcClient,
bsrClient, // interface로 주입
store,
cfg,
)
Kubernetes 네이티브 설계:
단일 Pod로 클러스터 내에 배포된다. Kubernetes 리소스만으로 구성되어 별도 인프라가 필요없다.
# ConfigMap으로 서비스 매핑 관리
apiVersion: v1
kind: ConfigMap
metadata:
name: protodiff-mapping
namespace: protodiff-system
data:
user-service: "buf.build/acme/user"
payment-service: "buf.build/acme/payment"
order-service: "buf.build/acme/order"
---
# Secret으로 BSR 토큰 관리
apiVersion: v1
kind: Secret
metadata:
name: bsr-token
namespace: protodiff-system
type: Opaque
data:
token: <base64-encoded-token>
---
# RBAC: 최소 권한만 부여
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: protodiff
rules:
- apiGroups: [""]
resources: ["pods"]
verbs: ["get", "list", "watch"]
- apiGroups: [""]
resources: ["configmaps"]
verbs: ["get", "list"]
# 한 줄로 설치
kubectl apply -f https://raw.githubusercontent.com/uzdada/protodiff/main/deploy/k8s/install.yaml
# 대시보드 접근
kubectl port-forward -n protodiff-system svc/protodiff 18080:80
k8s 보안 모범 사례들을 따르고자 노력했다:
리소스 사용량:
스캔 성능:
Before:
After:
핵심 지표:
실시간 웹 대시보드를 제공한다.
표시 정보:
색상 구분:
대시보드는 30분마다 자동 새로고침된다.
선택 이유:
HTTP API 문제점:
Buf CLI 장점:
트레이드오프:
안정성이 더 중요했기 때문에 Buf CLI를 안쓸 이유가 없었다.
장점:
요구사항:
reflection.Register(server) 한 줄 추가server.addService(ProtoReflectionService.newInstance())기존 서비스 코드 변경이 최소화된다.
고려 중인 기능:
Kubernetes 환경 내 gRPC 스키마 불일치를 실시간으로 감지하는 Go 기반 모니터링 도구를 개발했다. client-go를 활용한 pod 추적 및 gRPC Reflection을 통해 라이브 스키마를 추출하고, 이를 Buf BSR 원본과 대조하는 파이프라인을 구축했습니다. 이때 교집합 기반의 비교 알고리즘을 적용해 오탐을 줄였으며, 웹 대시보드를 통해 변경 사항을 즉각 시각화했습니다.
핵심 성과:
Apache 2.0 라이센스로 오픈소스 공개했다. Kubernetes 환경에서 gRPC 기반의 마이크로서비스를 운영하는 팀이라면 바로 사용할 수 있다.
저장소: