배포 후에야 알게 되는 gRPC 스키마 불일치, Kubernetes에서 자동으로 잡기
개요
Kubernetes 환경에서 gRPC 마이크로서비스를 여러 개 운영하고 있었다. Buf Schema Registry(BSR)로 proto 파일을 중앙 관리했는데, 실제 배포된 서비스와 BSR 스키마가 안 맞는 문제가 자꾸 생겼다. 이걸 자동으로 감지하는 도구를 Go로 만들어서 오픈소스로 공개했다.
문제
BSR로 proto를 관리하면 끝일 줄 알았는데, 현실은 달랐다.
자주 겪은 시나리오:
- 개발자가 proto 수정
- BSR 업데이트 없이 Kubernetes에 배포
- BSR 기준으로 개발한 클라이언트가 런타임 에러
- 원인 찾는 데 수 시간
Kubernetes라서 더 귀찮았던 점:
- Pod이 여러 네임스페이스에 흩어져 있음
- 서비스마다 proto 파일 경로가 다름
- Rolling update 중이면 버전이 섞여 있음
- 일일이 Pod 들어가서 확인하기 번거로움
수동으로 하기엔 너무 귀찮았다. 자동화가 필요했다.
해결

Kubernetes 클러스터 안에서 도는 모니터링 도구를 Go로 만들었다. client-go로 Kubernetes API랑 직접 통신하면서 Pod을 자동으로 찾고 스키마를 검증한다.
핵심 아이디어
- Kubernetes API로 gRPC Pod 자동 발견
- gRPC Reflection으로 실시간 스키마 추출
- Buf CLI로 BSR 스키마 가져오기
- 양쪽 비교해서 불일치 감지
- 웹 대시보드로 시각화
기술 스택
언어: 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분마다 스캔 (환경변수로 조절)구현
1. Pod 자동 발견
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,
})
포트 자동 감지 우선순위:
grpc이름을 가진 containerPort- TCP 프로토콜 포트
- 기본값 9090
2. 라이브 스키마 추출
gRPC Reflection으로 실행 중인 서비스에서 스키마를 가져온다. proto 파일 없이도 런타임에 스키마를 읽을 수 있다.
// 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)
}
3. BSR 스키마 가져오기
처음엔 HTTP API를 썼는데 문제가 있었다:
- FileDescriptorSet만 줘서 타입 참조 해석이 복잡함
- 에러 처리가 불안정함
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...)
완전한 proto 파일을 받아서 타입 참조도 자동으로 해석된다. 에러율도 줄었다.
4. Intersection 기반 비교
처음엔 모든 서비스를 비교했다. 문제가 있었다:
- Live에만 있는 서비스 → MISMATCH
- BSR에만 있는 서비스 → MISMATCH
- 테스트 서비스나 deprecated 서비스 때문에 오탐이 많았다
개선:
// 양쪽에 모두 있는 서비스만 비교
for liveSvcName, liveMethods := range liveServicesMap {
if truthMethods, exists := truthServicesMap[liveSvcName]; exists {
if !methodsMatch(liveMethods, truthMethods) {
match = false
}
}
}
// Live에만 있는 서비스 → 정보성 표시, 상태에 영향 X
// BSR에만 있는 서비스 → 정보성 표시, 상태에 영향 X
오탐이 대폭 줄었다. 의미 있는 불일치만 잡는다.
5. 동시성 처리
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를 썼다. Go interface로 외부 의존성을 추상화했다.
디렉토리 구조
protodiff/
├── cmd/protodiff/ # 엔트리포인트
├── internal/
│ ├── core/
│ │ ├── domain/ # 비즈니스 모델
│ │ └── store/ # Thread-safe 저장소
│ ├── adapters/
│ │ ├── k8s/ # Kubernetes client
│ │ ├── grpc/ # gRPC reflection client
│ │ ├── bsr/ # BSR client (Buf CLI wrapper)
│ │ └── web/ # HTTP 서버
│ ├── scanner/ # 오케스트레이터
│ └── config/ # 설정 관리
└── deploy/k8s/ # Kubernetes manifestsInterface 활용
// BSR 클라이언트 인터페이스
type Client interface {
FetchSchema(ctx context.Context, module string) (*domain.SchemaDescriptor, error)
}
// 구현체는 갈아끼울 수 있음
type BufClient struct { ... }
// 의존성 주입
scanner := scanner.NewScanner(
k8sClient,
grpcClient,
bsrClient, // interface로 주입
store,
cfg,
)
배포
단일 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"
---
# Secret으로 BSR 토큰
apiVersion: v1
kind: Secret
metadata:
name: bsr-token
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 보안 모범 사례를 따랐다:
- Non-root 실행
- Read-only 파일시스템
- 최소 권한 RBAC
- 모든 Linux capability 제거
성능
리소스:
- CPU: 0.1 core 미만 (I/O bound라서)
- Memory: ~10MB + Pod당 ~1KB
- 네트워크: Pod당 수 KB
스캔:
- 체감상 거의 즉시
- BSR 조회는 캐시 가능 (향후 개선 예정)
결과
Before:
- 수동 확인
- 불일치 발견: 런타임 에러 나고 나서
- 원인 파악: 각 서비스 proto 비교해야 함
- 대응: 수 시간
After:
- 자동 감지
- 불일치 발견: 30분 이내
- 원인 파악: 대시보드에서 바로
- 대응: 수 분
대시보드
웹 대시보드로 상태를 볼 수 있다.
표시 정보:
- 서비스 상태 (SYNC / MISMATCH / UNKNOWN)
- Live vs BSR 메서드 목록
- 불일치 메서드 상세
- 마지막 확인 시간
색상:
- Green: 일치
- Red: 불일치
- Yellow: 확인 불가
30분마다 자동 새로고침.
기술적 선택
In-memory Storage
외부 DB 없이 메모리에 저장한다.
- 외부 의존성 없음
- 빠름
- 단일 바이너리 배포
- MVP엔 충분
Buf CLI vs HTTP API
HTTP API는 FileDescriptorSet 파싱이 복잡하고 에러 처리가 불안정했다. Buf CLI는 완전한 proto 파일을 주고, 안정적이다. 컨테이너에 buf 설치해야 하는 건 트레이드오프지만, 안정성이 더 중요했다.
gRPC Reflection 의존성
서비스에서 reflection을 켜야 한다:
- Go:
reflection.Register(server)한 줄 - Java:
server.addService(ProtoReflectionService.newInstance())
코드 변경이 최소화돼서 도입 부담이 적다.
향후
고려 중인 것들:
- 슬랙 알림
- Prometheus 메트릭
- 이력 저장 (PostgreSQL)
- BSR 캐시
- CRD 기반 설정
정리
gRPC 스키마 불일치를 자동으로 잡는 도구를 만들었다. client-go로 Pod을 찾고, gRPC Reflection으로 라이브 스키마를 뽑고, BSR이랑 비교한다. Intersection 기반 비교로 오탐을 줄였고, 대시보드로 바로 확인할 수 있다.
수동으로 수 시간 걸리던 걸 30분 이내로 줄였다.
오픈소스
Apache 2.0으로 공개했다. Kubernetes에서 gRPC 마이크로서비스 운영하는 팀이면 바로 쓸 수 있다.