배포 후에야 알게 되는 gRPC 스키마 불일치, Kubernetes에서 자동으로 잡기

개요

Kubernetes 환경에서 gRPC 마이크로서비스를 여러 개 운영하고 있었다. Buf Schema Registry(BSR)로 proto 파일을 중앙 관리했는데, 실제 배포된 서비스와 BSR 스키마가 안 맞는 문제가 자꾸 생겼다. 이걸 자동으로 감지하는 도구를 Go로 만들어서 오픈소스로 공개했다.

문제

BSR로 proto를 관리하면 끝일 줄 알았는데, 현실은 달랐다.

자주 겪은 시나리오:

  1. 개발자가 proto 수정
  2. BSR 업데이트 없이 Kubernetes에 배포
  3. BSR 기준으로 개발한 클라이언트가 런타임 에러
  4. 원인 찾는 데 수 시간

Kubernetes라서 더 귀찮았던 점:

  • Pod이 여러 네임스페이스에 흩어져 있음
  • 서비스마다 proto 파일 경로가 다름
  • Rolling update 중이면 버전이 섞여 있음
  • 일일이 Pod 들어가서 확인하기 번거로움

수동으로 하기엔 너무 귀찮았다. 자동화가 필요했다.

해결

Kubernetes 클러스터 안에서 도는 모니터링 도구를 Go로 만들었다. client-go로 Kubernetes API랑 직접 통신하면서 Pod을 자동으로 찾고 스키마를 검증한다.

핵심 아이디어

  1. Kubernetes API로 gRPC Pod 자동 발견
  2. gRPC Reflection으로 실시간 스키마 추출
  3. Buf CLI로 BSR 스키마 가져오기
  4. 양쪽 비교해서 불일치 감지
  5. 웹 대시보드로 시각화

기술 스택

언어: 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,
    })

포트 자동 감지 우선순위:

  1. grpc 이름을 가진 containerPort
  2. TCP 프로토콜 포트
  3. 기본값 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 manifests

Interface 활용

// 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 마이크로서비스 운영하는 팀이면 바로 쓸 수 있다.

GitHub: https://github.com/xhae123/protodiff