k8s 환경 내 gRPC 스키마 정합성 확보를 위한 Drift Detection

woojin·2일 전

개요

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 환경 특성상 발생하는 어려움:

  • Pod이 여러 네임스페이스에 분산
  • 각 서비스마다 proto 파일 경로가 다름
  • Rolling update로 버전이 섞여 있는 경우도 있음
  • 수동으로 각 Pod의 스키마를 확인하기 번거로움

너무 귀찮기 때문에 자동화가 필요했다.

해결 방법


Kubernetes 클러스터 내에서 동작하는 모니터링 도구를 Go로 개발했다. client-go를 사용해 Kubernetes API와 직접 통신하며, 현재 k8s 환경에서 돌아가고 있는 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. Kubernetes API 연동 및 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,
    })

// 각 Pod의 정보 추출
// - Pod 이름, 네임스페이스
// - Pod IP (클러스터 내부 통신용)
// - gRPC 포트 (Container spec에서 자동 감지)

Kubernetes 네이티브 특성:

  • ConfigMap으로 서비스-BSR 매핑 중앙 관리
  • Pod IP로 직접 통신 (Service 불필요)
  • 모든 네임스페이스 스캔 가능
  • RBAC으로 최소 권한만 부여 (get/list/watch)

포트 자동 감지 우선순위:
1. grpc 이름을 가진 containerPort
2. TCP 프로토콜 포트
3. 기본값 9090

2. 라이브 스키마 추출

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 파일 없이 런타임에 스키마를 읽을 수 있다.

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 서비스 때문에 오탐(False Positive) 다수

개선된 로직:

// 양쪽에 모두 존재하는 서비스만 비교
for liveSvcName, liveMethods := range liveServicesMap {
    if truthMethods, exists := truthServicesMap[liveSvcName]; exists {
        // 메서드 비교
        if !methodsMatch(liveMethods, truthMethods) {
            // 불일치 발견
            match = false
        }
    }
}

// Live에만 있는 서비스 → 정보성 표시, 상태에 영향 X
// BSR에만 있는 서비스 → 정보성 표시, 상태에 영향 X

비교 결과:

  • 오탐률 대폭 감소
  • 의미 있는 불일치만 검출
  • UI에서 추가/누락 서비스는 정보로 표시

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 (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

Go Interface 활용

// 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 네이티브 설계:

  • In-cluster 실행 (ServiceAccount 인증)
  • ConfigMap으로 동적 설정
  • Pod IP 직접 통신 (오버헤드 최소화)
  • Graceful shutdown (SIGTERM 처리)

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 보안 모범 사례들을 따르고자 노력했다:

  • Non-root 실행
  • Read-only 파일시스템
  • 최소 권한 RBAC (get/list/watch)
  • 클러스터 내부 Pod IP만 사용
  • 모든 Linux capability 제거
  • SecurityContext로 권한 제한

성능

리소스 사용량:

  • CPU: 0.1 core 미만 (I/O bound)
  • Memory: ~10MB + Pod당 ~1KB
  • 네트워크: Pod당 수 KB (gRPC reflection)

스캔 성능:

  • 도구를 이용하여 인간이 지연을 거의 못느낄 수준임
  • BSR 조회는 캐시 가능 (향후 개선)

결과

Before:

  • 수동 확인
  • 불일치 발견 시점: 런타임 에러 발생 후
  • 원인 파악: 각 서비스 proto 파일 비교 필요
  • 대응 시간: 수 시간

After:

  • 자동 감지
  • 불일치 발견 시점: 모니터링 즉시
  • 원인 파악: 대시보드에서 즉시 확인 (서비스, 메서드 단위)
  • 대응 시간: 수 분

핵심 지표:

  • 발견 시간: 수 시간 → 30분 이내
  • 수동 작업: 없음
  • 오탐률: Intersection 기반 비교로 대폭 감소

대시보드

실시간 웹 대시보드를 제공한다.

표시 정보:

  • 서비스 상태 (SYNC / MISMATCH / UNKNOWN)
  • 서비스별 Live vs BSR 메서드 목록
  • 불일치 메서드 상세 (추가/누락)
  • 공통 서비스 / 추가 서비스 / 누락 서비스
  • 마지막 확인 시간

색상 구분:

  • Green: 일치
  • Red: 불일치
  • Yellow: 확인 불가

대시보드는 30분마다 자동 새로고침된다.

기술적 선택

In-memory Storage

선택 이유:

  • 외부 의존성 제거
  • 빠른 읽기/쓰기
  • 단일 바이너리 배포
  • MVP에 충분

Buf CLI vs HTTP API

HTTP API 문제점:

  • FileDescriptorSet 파싱 복잡
  • 의존성 해석 어려움
  • 에러 처리 불안정

Buf CLI 장점:

  • 완전한 proto 파일 제공
  • 검증된 안정성
  • 표준 도구임

트레이드오프:

  • 컨테이너에 buf CLI 설치 필요
  • 쓰기 가능한 /tmp 디렉토리 필요
  • 약간의 오버헤드

안정성이 더 중요했기 때문에 Buf CLI를 안쓸 이유가 없었다.

gRPC Reflection 의존성

장점:

  • proto 파일 접근 불필요
  • 모든 gRPC 서비스와 호환
  • 표준 프로토콜

요구사항:

  • 서비스에서 reflection 활성화 필수
  • Go: reflection.Register(server) 한 줄 추가
  • Java: server.addService(ProtoReflectionService.newInstance())

기존 서비스 코드 변경이 최소화된다.

향후 개선

고려 중인 기능:

  • 슬랙 알림 연동
  • 메트릭 수집 (Prometheus)
  • 이력 저장 (PostgreSQL)
  • BSR 캐시 레이어
  • 병렬 스캔 (goroutine pool)?
  • CRD 기반 설정 (진행 중)

정리

Kubernetes 환경 내 gRPC 스키마 불일치를 실시간으로 감지하는 Go 기반 모니터링 도구를 개발했다. client-go를 활용한 pod 추적 및 gRPC Reflection을 통해 라이브 스키마를 추출하고, 이를 Buf BSR 원본과 대조하는 파이프라인을 구축했습니다. 이때 교집합 기반의 비교 알고리즘을 적용해 오탐을 줄였으며, 웹 대시보드를 통해 변경 사항을 즉각 시각화했습니다.

핵심 성과:

  • K8s 기반 검증 자동화: 수동으로 수행하던 스키마 대조 작업을 Kubernetes Client-go 기반의 자동화 파이프라인으로 대체하여 리소스 투입 최소화
  • 배포 정합성 보장: 배포된 pod과 BSR 원본 간의 불일치를 사전에 감지하여, 버전 파편화로 인한 런타임 gRPC 통신 장애 예방
  • 검출 정확도 향상: Kubernetes 서비스 환경의 특성을 고려한 Intersection 비교 로직을 도입하여, 단순 스키마 비교 시 발생하는 오탐대폭 감소

오픈소스

Apache 2.0 라이센스로 오픈소스 공개했다. Kubernetes 환경에서 gRPC 기반의 마이크로서비스를 운영하는 팀이라면 바로 사용할 수 있다.

저장소:

0개의 댓글