프로그래밍/k8s

Kubernetes 멀티 Pod 환경에서 Alert 중복 전송 문제 해결하기 (client-go Leader Election)

GONII 2026. 9. 4. 13:21

1. 문제 상황

Alertmanager(혹은 그 외 alert 소스)에서 수집한 데이터를 외부 웹으로 전달하는 서비스를 Kubernetes에 배포했다고 가정해보자.

Alertmanager → [내 서비스 Pod] → 외부 웹 API

가용성을 위해 Deployment의 replicas를 2 이상으로 설정하면, 각 Pod가 독립적으로 alert를 수신하고 동시에 동일한 데이터를 외부 웹으로 전송해버리는 문제가 발생한다.

  • Pod A → 외부 웹으로 alert 전송
  • Pod B → 동일한 alert를 또 전송
  • 결과: 외부 시스템에는 중복 알림이 두 번(또는 replica 수만큼) 쌓임

이는 서비스 자체는 이중화(HA)해야 하지만, 실제 "일"을 수행하는 주체는 단 하나여야 하는 전형적인 케이스다. 이럴 때 Kubernetes 진영에서 표준적으로 쓰는 패턴이 바로 **Leader Election(리더 선출)**이다.

2. 해결 방향: Leader Election 패턴

Leader Election은 여러 개의 Pod(인스턴스) 중 단 하나만 "리더"로 선출하고, 나머지는 대기(standby) 상태로 두는 방식이다.

  • 리더로 선출된 Pod만 실제 alert 전송 로직을 수행
  • 나머지 Pod는 리더가 될 때까지 대기
  • 리더가 죽거나(Pod 재시작, 크래시 등) lease를 갱신하지 못하면, 대기 중이던 Pod 중 하나가 새로운 리더로 자동 승격

이 패턴은 kube-controller-manager, kube-scheduler 같은 Kubernetes 자체 컨트롤 플레인 컴포넌트도 다중화(HA) 구성 시 사용하는 방식이며, k8s.io/client-go가 이를 라이브러리 형태로 제공한다.

참고: Deployment의 replica를 1로 줄이면 되지 않냐고 생각할 수 있지만, 그러면 Pod 재시작 중(이미지 pull, 초기화 등) 공백 시간 동안 alert 처리가 완전히 멈춘다. Leader Election을 쓰면 replica는 2 이상으로 유지하면서 "처리는 하나만" 하는 구조를 만들 수 있어 무중단에 가까운 장애 대응이 가능하다.

3. client-go의 leaderelection 패키지

k8s.io/client-go/tools/leaderelection 패키지는 Kubernetes API 오브젝트(주로 Lease 리소스, coordination.k8s.io/v1)를 락(lock)으로 사용해 리더를 선출한다.

동작 원리는 간단하다.

  1. 여러 Pod가 동시에 특정 Lease 오브젝트를 "내가 갖겠다"고 시도한다.
  2. 이 시도는 Kubernetes API 서버의 원자적(atomic) 업데이트 연산을 이용하므로, 동시에 여러 Pod가 요청해도 단 하나만 성공한다.
  3. 락을 획득한 Pod가 리더가 되고, 주기적으로(RenewDeadline) lease를 갱신(update)해서 "나 아직 살아있다"를 알린다.
  4. 리더가 갱신에 실패하거나(네트워크 문제, 크래시 등) LeaseDuration 시간 동안 갱신이 없으면, 다른 Pod가 락을 가져가 새 리더가 된다.

4. 필요한 RBAC 설정

Pod가 Lease 리소스를 생성/조회/수정할 수 있어야 하므로 아래와 같은 RBAC이 필요하다.

apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
  name: alert-forwarder-leader-election
  namespace: alert-system
rules:
  - apiGroups: ["coordination.k8s.io"]
    resources: ["leases"]
    verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
  name: alert-forwarder-leader-election
  namespace: alert-system
subjects:
  - kind: ServiceAccount
    name: alert-forwarder
    namespace: alert-system
roleRef:
  kind: Role
  name: alert-forwarder-leader-election
  apiGroup: rbac.authorization.k8s.io

5. Go 코드 구현

5.1 의존성 추가

go get k8s.io/client-go@latest
go get k8s.io/apimachinery@latest

5.2 Leader Election 적용 코드

package main

import (
	"context"
	"os"
	"time"

	"k8s.io/apimachinery/pkg/util/uuid"
	"k8s.io/client-go/kubernetes"
	"k8s.io/client-go/rest"
	"k8s.io/client-go/tools/leaderelection"
	"k8s.io/client-go/tools/leaderelection/resourcelock"
	"k8s.io/klog/v2"
)

func main() {
	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()

	// 클러스터 내부에서 실행 중이므로 in-cluster config 사용
	config, err := rest.InClusterConfig()
	if err != nil {
		klog.Fatalf("in-cluster config 생성 실패: %v", err)
	}

	client, err := kubernetes.NewForConfig(config)
	if err != nil {
		klog.Fatalf("kubernetes client 생성 실패: %v", err)
	}

	// Pod마다 고유한 식별자 (Pod 이름을 그대로 써도 무방)
	podName := os.Getenv("POD_NAME")
	if podName == "" {
		podName = string(uuid.NewUUID())
	}

	// Lease 오브젝트를 락으로 사용
	lock := &resourcelock.LeaseLock{
		LeaseMeta: metav1.ObjectMeta{
			Name:      "alert-forwarder-lock",
			Namespace: "alert-system",
		},
		Client: client.CoordinationV1(),
		LockConfig: resourcelock.ResourceLockConfig{
			Identity: podName,
		},
	}

	leaderelection.RunOrDie(ctx, leaderelection.LeaderElectionConfig{
		Lock:            lock,
		ReleaseOnCancel: true,

		// 리더 lease의 유효 시간 (이 시간 동안 갱신 없으면 리더 교체)
		LeaseDuration: 15 * time.Second,
		// 리더가 lease를 갱신하는 주기
		RenewDeadline: 10 * time.Second,
		// 새 리더가 되려는 Pod가 재시도하는 주기
		RetryPeriod: 2 * time.Second,

		Callbacks: leaderelection.LeaderCallbacks{
			OnStartedLeading: func(ctx context.Context) {
				klog.Infof("[%s] 리더로 선출됨. alert 처리 시작", podName)
				startAlertForwarding(ctx) // 실제 alert 수신/전송 로직
			},
			OnStoppedLeading: func() {
				klog.Infof("[%s] 리더 자리 상실. 처리 중단", podName)
				// 여기서 진행 중이던 작업을 안전하게 정리(cleanup)해야 함
			},
			OnNewLeader: func(identity string) {
				if identity == podName {
					return
				}
				klog.Infof("새로운 리더: %s", identity)
			},
		},
	})
}

func startAlertForwarding(ctx context.Context) {
	// 여기에 alert 수신 → 외부 web 전송 로직 구현
	// ctx가 취소되면(OnStoppedLeading 트리거 시점) 반드시 goroutine을 종료해야 함
	for {
		select {
		case <-ctx.Done():
			return
		default:
			// alert 폴링 or webhook 수신 처리
		}
	}
}

metav1 import(k8s.io/apimachinery/pkg/apis/meta/v1)를 빠뜨리지 않도록 주의한다.

5.3 Deployment 예시

apiVersion: apps/v1
kind: Deployment
metadata:
  name: alert-forwarder
  namespace: alert-system
spec:
  replicas: 3   # 여러 개 유지해도 실제 처리는 1개만 수행됨
  selector:
    matchLabels:
      app: alert-forwarder
  template:
    metadata:
      labels:
        app: alert-forwarder
    spec:
      serviceAccountName: alert-forwarder
      containers:
        - name: alert-forwarder
          image: myregistry/alert-forwarder:latest
          env:
            - name: POD_NAME
              valueFrom:
                fieldRef:
                  fieldPath: metadata.name

POD_NAME을 downward API로 주입해 각 Pod의 identity로 사용하는 것이 핵심이다.

6. 동작 확인 방법

kubectl get lease -n alert-system alert-forwarder-lock -o yaml

holderIdentity 필드를 확인하면 현재 어떤 Pod가 리더인지 알 수 있다.

spec:
  holderIdentity: alert-forwarder-7d9f9c6b8-abcde
  leaseDurationSeconds: 15
  renewTime: "2026-09-04T09:12:33.000000Z"

리더 Pod를 강제로 삭제(kubectl delete pod)해보면, LeaseDuration 시간 내에 holderIdentity가 다른 Pod로 바뀌는 것을 확인할 수 있다.

7. 현재 리더(Active) 상태 모니터링하기

운영 중에는 "지금 어떤 Pod가 Active인지"를 외부에서 확인할 수 있어야 장애 대응이나 디버깅이 쉬워진다. 별도의 조회 없이도 kubectl get lease로 확인할 수 있지만, 애플리케이션 자체에 /healthz나 /metrics 엔드포인트로 리더 여부를 노출해두면 모니터링 도구(Prometheus, Grafana 등)와 바로 연동할 수 있다.

7.1 리더 상태를 저장하는 공유 변수

package main

import (
	"context"
	"net/http"
	"sync/atomic"
)

// 0 = standby, 1 = leader
var isLeader atomic.Bool

func main() {
	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()

	// ... client, lock 생성 코드는 기존과 동일 ...

	// 상태 조회용 HTTP 서버 (별도 goroutine)
	go startStatusServer()

	leaderelection.RunOrDie(ctx, leaderelection.LeaderElectionConfig{
		Lock:            lock,
		ReleaseOnCancel: true,
		LeaseDuration:   15 * time.Second,
		RenewDeadline:   10 * time.Second,
		RetryPeriod:     2 * time.Second,

		Callbacks: leaderelection.LeaderCallbacks{
			OnStartedLeading: func(ctx context.Context) {
				isLeader.Store(true)
				klog.Infof("[%s] 리더로 선출됨. alert 처리 시작", podName)
				startAlertForwarding(ctx)
			},
			OnStoppedLeading: func() {
				isLeader.Store(false)
				klog.Infof("[%s] 리더 자리 상실. 처리 중단", podName)
			},
			OnNewLeader: func(identity string) {
				if identity == podName {
					return
				}
				klog.Infof("새로운 리더: %s", identity)
			},
		},
	})
}

func startStatusServer() {
	mux := http.NewServeMux()

	// liveness/readiness와는 별개로, 현재 role을 그대로 노출
	mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
		if isLeader.Load() {
			w.Write([]byte("active"))
		} else {
			w.Write([]byte("standby"))
		}
	})

	// Prometheus 스타일 metric으로도 노출
	mux.HandleFunc("/metrics", func(w http.ResponseWriter, r *http.Request) {
		val := 0
		if isLeader.Load() {
			val = 1
		}
		w.Header().Set("Content-Type", "text/plain")
		w.Write([]byte("alert_forwarder_is_leader " + strconv.Itoa(val) + "\n"))
	})

	http.ListenAndServe(":8080", mux)
}

프로덕션에서는 strconv import를 잊지 말고, 실제로는 client_golang/prometheus의 Gauge 타입을 써서 /metrics를 표준 Prometheus exposition format으로 노출하는 것을 권장한다.

7.2 Grafana / Prometheus 연동 예시

alert_forwarder_is_leader 메트릭을 Pod 라벨과 함께 수집하면, 어떤 Pod가 현재 Active인지 시계열로 추적할 수 있다.

alert_forwarder_is_leader{pod="alert-forwarder-7d9f9c6b8-abcde"} 1
alert_forwarder_is_leader{pod="alert-forwarder-7d9f9c6b8-xyz12"} 0

이 값이 예상치 못하게 자주 바뀌면(flapping) LeaseDuration/RenewDeadline 설정이 너무 타이트하거나, 리더 Pod의 리소스(CPU/메모리) 부족으로 lease 갱신이 지연되고 있다는 신호일 수 있다.

8. 운영 시 주의할 점

  • OnStoppedLeading 처리 필수: 리더 자격을 잃었을 때 진행 중이던 작업(예: 이미 읽었지만 아직 전송하지 않은 alert)을 제대로 정리하지 않으면, 새 리더와 함께 여전히 중복 전송이 발생할 수 있다. context 취소 시 즉시 작업을 중단하도록 구현해야 한다.
  • LeaseDuration / RenewDeadline 튜닝: 값이 너무 짧으면 네트워크 지연만으로도 불필요한 리더 교체(flapping)가 발생하고, 너무 길면 장애 발생 시 페일오버가 느려진다. 서비스 특성에 맞게 조정한다.
  • 멱등성(idempotency) 확보 권장: Leader Election만으로 100% 중복을 막을 수는 없다(리더 교체 시점의 극히 짧은 race window 존재 가능). 가능하다면 외부 웹 API 쪽에 alert ID 기반 중복 제거 로직을 함께 두는 것이 안전하다.
  • RBAC 누락 주의: Lease 리소스에 대한 권한이 없으면 RunOrDie가 계속 에러를 뱉으며 리더 선출 자체가 안 되므로, 배포 전 RBAC을 반드시 확인한다.

9. 정리

  • 문제: 다중 Pod 환경에서 동일한 alert를 각 Pod가 중복 전송
  • 원인: 모든 Pod가 동등하게 동작을 수행하는 구조 (Active-Active)
  • 해결: client-go의 leaderelection 패키지로 Active-Standby 구조 전환, 실제 전송 로직은 리더 Pod만 수행
  • 핵심 리소스: coordination.k8s.io/v1 Lease
  • 추가 권장: 리더 전환 시 안전한 cleanup, 외부 시스템 측 멱등성 처리

이 패턴은 alert 전송뿐 아니라 cron성 배치 작업, 특정 이벤트를 단일 인스턴스에서만 처리해야 하는 모든 Kubernetes 워크로드에 동일하게 적용할 수 있다.

반응형