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)으로 사용해 리더를 선출한다.
동작 원리는 간단하다.
- 여러 Pod가 동시에 특정 Lease 오브젝트를 "내가 갖겠다"고 시도한다.
- 이 시도는 Kubernetes API 서버의 원자적(atomic) 업데이트 연산을 이용하므로, 동시에 여러 Pod가 요청해도 단 하나만 성공한다.
- 락을 획득한 Pod가 리더가 되고, 주기적으로(RenewDeadline) lease를 갱신(update)해서 "나 아직 살아있다"를 알린다.
- 리더가 갱신에 실패하거나(네트워크 문제, 크래시 등) 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 워크로드에 동일하게 적용할 수 있다.
'프로그래밍 > k8s' 카테고리의 다른 글
| Kubernetes YAML 설정 파일 완벽 정리 — 구조와 주요 리소스 예제 (0) | 2026.09.10 |
|---|---|
| Kubernetes RWX(ReadWriteMany) 완벽 정리 — 개념부터 실전 예제까지 (0) | 2026.09.09 |
| kubectl 사용법 정리 — 자주 쓰는 명령어와 옵션 총정리 (0) | 2026.09.08 |
| Kubernetes 기본 개념 정리 — 그림으로 이해하는 K8s 아키텍처 (0) | 2026.09.07 |