K8s Lease 리더 선출 심화

K8s Lease 기반 리더 선출이란?

분산 시스템에서 단 하나의 인스턴스만 특정 작업을 수행해야 할 때 리더 선출(Leader Election)이 필요합니다. Kubernetes는 coordination.k8s.io/v1 API 그룹의 Lease 리소스를 네이티브 리더 선출 메커니즘으로 제공합니다. 외부 ZooKeeper나 etcd 클라이언트 없이 K8s API만으로 안전한 리더 선출이 가능합니다.

1. Lease 리소스 구조

Lease는 K8s 내부 컴포넌트(kube-controller-manager, kube-scheduler)가 이미 사용하는 검증된 메커니즘입니다.

apiVersion: coordination.k8s.io/v1
kind: Lease
metadata:
  name: my-app-leader
  namespace: default
spec:
  holderIdentity: pod-abc-123        # 현재 리더의 ID
  leaseDurationSeconds: 15           # 리스 유효 기간
  acquireTime: "2026-03-17T07:00:00Z"
  renewTime: "2026-03-17T07:00:10Z"  # 마지막 갱신 시각
  leaseTransitions: 3                # 리더 변경 횟수

핵심 필드:

  • holderIdentity: 현재 Lease를 보유한 Pod/프로세스 식별자
  • leaseDurationSeconds: 이 시간 내에 갱신하지 않으면 리더 자격 상실
  • renewTime: K8s API 서버의 낙관적 잠금(resourceVersion)으로 동시 갱신 충돌 방지

2. 리더 선출 동작 원리

리더 선출의 핵심은 Lease 리소스에 대한 원자적 업데이트입니다. K8s API 서버의 resourceVersion 기반 낙관적 동시성 제어가 분산 락 역할을 합니다.

단계 동작 실패 시
1. 획득 시도 Lease가 없거나 만료 → CREATE/UPDATE 409 Conflict → 재시도
2. 리더 갱신 renewTime을 주기적 UPDATE 갱신 실패 → 리더 포기
3. 팔로워 감시 renewTime + duration 만료 감시 만료 감지 → 1단계로
4. 리더 전환 새 리더가 holderIdentity 변경 leaseTransitions 증가
# 리더 선출 흐름 (의사코드)
loop:
  lease = GET /apis/coordination.k8s.io/v1/leases/{name}
  
  if lease.renewTime + lease.duration < now():
      # 리스 만료 → 획득 시도
      lease.holderIdentity = myPodName
      lease.renewTime = now()
      UPDATE lease  # resourceVersion 충돌 시 409 → 재시도
      → 리더 됨!
  
  elif lease.holderIdentity == myPodName:
      # 내가 리더 → 갱신
      lease.renewTime = now()
      UPDATE lease
  
  else:
      # 다른 Pod이 리더 → 대기
      sleep(retryPeriod)

3. Go client-go 구현

Go 애플리케이션에서는 client-goleaderelection 패키지를 사용합니다. K8s 컨트롤러의 표준 패턴입니다.

package main

import (
    "context"
    "fmt"
    "os"
    "time"

    metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
    "k8s.io/client-go/kubernetes"
    "k8s.io/client-go/rest"
    "k8s.io/client-go/tools/leaderelection"
    "k8s.io/client-go/tools/leaderelection/resourcelock"
)

func main() {
    config, _ := rest.InClusterConfig()
    clientset, _ := kubernetes.NewForConfig(config)

    // Pod 이름을 고유 식별자로 사용
    id := os.Getenv("POD_NAME")

    // Lease 기반 리소스 락
    lock := &resourcelock.LeaseLock{
        LeaseMeta: metav1.ObjectMeta{
            Name:      "my-scheduler-leader",
            Namespace: "default",
        },
        Client: clientset.CoordinationV1(),
        LockConfig: resourcelock.ResourceLockConfig{
            Identity: id,
        },
    }

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

    leaderelection.RunOrDie(ctx, leaderelection.LeaderElectionConfig{
        Lock:            lock,
        LeaseDuration:   15 * time.Second,
        RenewDeadline:   10 * time.Second,
        RetryPeriod:     2 * time.Second,
        Callbacks: leaderelection.LeaderCallbacks{
            OnStartedLeading: func(ctx context.Context) {
                fmt.Println("I am the leader!")
                runScheduler(ctx)  // 리더 전용 작업
            },
            OnStoppedLeading: func() {
                fmt.Println("Lost leadership")
                os.Exit(0)  // 안전하게 종료
            },
            OnNewLeader: func(identity string) {
                if identity == id {
                    return
                }
                fmt.Printf("New leader: %sn", identity)
            },
        },
    })
}

타이밍 파라미터 권장값:

  • LeaseDuration: 15s (리스 유효 기간)
  • RenewDeadline: 10s (갱신 시도 마감, LeaseDuration보다 짧아야 함)
  • RetryPeriod: 2s (팔로워의 획득 시도 간격)

4. Spring Boot 구현: fabric8 + Lease

Java/Spring 생태계에서는 fabric8 Kubernetes 클라이언트로 동일한 패턴을 구현합니다.

@Component
@RequiredArgsConstructor
@Slf4j
public class K8sLeaderElector implements SmartLifecycle {

    private final KubernetesClient k8sClient;
    private final LeaderTaskExecutor taskExecutor;
    
    @Value("${POD_NAME:unknown}")
    private String podName;
    
    private volatile boolean running = false;
    private volatile boolean isLeader = false;
    private ScheduledExecutorService scheduler;

    private static final String LEASE_NAME = "payment-processor-leader";
    private static final String NAMESPACE = "default";
    private static final int LEASE_DURATION = 15;
    private static final int RENEW_INTERVAL = 5;

    @Override
    public void start() {
        running = true;
        scheduler = Executors.newSingleThreadScheduledExecutor();
        scheduler.scheduleAtFixedRate(
            this::tryAcquireOrRenew, 0, RENEW_INTERVAL, TimeUnit.SECONDS
        );
    }

    private void tryAcquireOrRenew() {
        try {
            Lease lease = k8sClient.leases()
                .inNamespace(NAMESPACE)
                .withName(LEASE_NAME)
                .get();

            if (lease == null) {
                createLease();
                return;
            }

            Instant renewTime = Instant.parse(
                lease.getSpec().getRenewTime().toString()
            );
            boolean expired = renewTime
                .plusSeconds(LEASE_DURATION)
                .isBefore(Instant.now());

            String holder = lease.getSpec().getHolderIdentity();

            if (podName.equals(holder)) {
                renewLease(lease);
            } else if (expired) {
                acquireLease(lease);
            } else {
                if (isLeader) {
                    isLeader = false;
                    taskExecutor.onLostLeadership();
                }
            }
        } catch (KubernetesClientException e) {
            if (e.getCode() == 409) {
                log.debug("Conflict on lease update, will retry");
            } else {
                log.error("Leader election error", e);
            }
        }
    }

    private void createLease() {
        Lease lease = new LeaseBuilder()
            .withNewMetadata()
                .withName(LEASE_NAME)
                .withNamespace(NAMESPACE)
            .endMetadata()
            .withNewSpec()
                .withHolderIdentity(podName)
                .withLeaseDurationSeconds(LEASE_DURATION)
                .withAcquireTime(MicroTime.now())
                .withRenewTime(MicroTime.now())
                .withLeaseTransitions(0)
            .endSpec()
            .build();
        
        k8sClient.leases().inNamespace(NAMESPACE).create(lease);
        becomeLeader();
    }

    private void renewLease(Lease lease) {
        lease.getSpec().setRenewTime(MicroTime.now());
        k8sClient.leases().inNamespace(NAMESPACE)
            .withName(LEASE_NAME)
            .patch(lease);  // resourceVersion으로 낙관적 락
        
        if (!isLeader) becomeLeader();
    }

    private void becomeLeader() {
        isLeader = true;
        log.info("Became leader: {}", podName);
        taskExecutor.onBecameLeader();
    }

    @Override
    public void stop() {
        running = false;
        scheduler.shutdown();
    }

    @Override
    public boolean isRunning() { return running; }
}

5. Deployment 구성: Sidecar vs In-Process

리더 선출을 애플리케이션에 통합하는 두 가지 방식이 있습니다.

방식 장점 단점
In-Process (코드 내장) 지연 최소, 상태 즉시 반영 언어별 구현 필요
Sidecar (leader-elector) 언어 무관, 관심사 분리 HTTP 폴링 오버헤드
# Sidecar 방식 Deployment 예시
apiVersion: apps/v1
kind: Deployment
metadata:
  name: payment-processor
spec:
  replicas: 3
  template:
    spec:
      serviceAccountName: leader-election-sa
      containers:
      - name: app
        image: my-app:latest
        env:
        - name: LEADER_CHECK_URL
          value: "http://localhost:4040/leader"
        # 앱은 주기적으로 sidecar에 리더 여부 확인
      
      - name: leader-elector
        image: k8s.gcr.io/leader-elector:0.5
        args:
        - "--election=payment-leader"
        - "--http=0.0.0.0:4040"
        - "--election-namespace=default"
        - "--ttl=10s"
        ports:
        - containerPort: 4040
---
# 필수: ServiceAccount + RBAC
apiVersion: v1
kind: ServiceAccount
metadata:
  name: leader-election-sa
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
  name: leader-election-role
rules:
- apiGroups: ["coordination.k8s.io"]
  resources: ["leases"]
  verbs: ["get", "create", "update"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
  name: leader-election-binding
subjects:
- kind: ServiceAccount
  name: leader-election-sa
roleRef:
  kind: Role
  name: leader-election-role
  apiGroup: rbac.authorization.k8s.io

6. 장애 시나리오별 동작

리더 선출의 신뢰성은 장애 시나리오에서 어떻게 동작하는가로 결정됩니다.

장애 유형 동작 전환 시간
리더 Pod 크래시 Lease 만료 후 팔로워가 획득 ~LeaseDuration (15s)
리더 네트워크 격리 갱신 실패 → 리더 포기, 다른 Pod 획득 ~RenewDeadline + RetryPeriod
API 서버 일시 장애 모든 Pod 갱신/획득 불가 → 기존 리더 유지 API 복구 후 정상화
노드 Drain Pod 종료 → OnStoppedLeading 콜백 → 새 리더 Graceful: 즉시
Split Brain resourceVersion 충돌로 1개만 성공 발생 불가 (API 서버 보장)

7. 모니터링과 알림

리더 선출 상태를 Prometheus 메트릭으로 노출하여 모니터링합니다.

@Component
@RequiredArgsConstructor
public class LeaderMetrics {

    private final MeterRegistry registry;
    private final AtomicInteger isLeader = new AtomicInteger(0);
    private final AtomicInteger transitions = new AtomicInteger(0);

    @PostConstruct
    void init() {
        Gauge.builder("leader_election_is_leader", isLeader, AtomicInteger::get)
            .description("1 if this instance is the leader")
            .register(registry);
        
        Gauge.builder("leader_election_transitions_total", 
                transitions, AtomicInteger::get)
            .description("Total number of leader transitions")
            .register(registry);
    }

    public void onBecameLeader() { isLeader.set(1); transitions.incrementAndGet(); }
    public void onLostLeadership() { isLeader.set(0); }
}
# Prometheus AlertRule
groups:
- name: leader-election
  rules:
  - alert: LeaderElectionFrequentTransitions
    expr: increase(leader_election_transitions_total[10m]) > 5
    for: 2m
    labels:
      severity: warning
    annotations:
      summary: "리더 선출 빈번한 전환 감지 ({{ $value }}회/10분)"

  - alert: NoLeaderElected
    expr: sum(leader_election_is_leader) == 0
    for: 30s
    labels:
      severity: critical
    annotations:
      summary: "리더가 선출되지 않음 — 작업 중단 가능"

8. 실전 사용 사례

리더 선출이 필요한 대표적인 시나리오들입니다.

  • 스케줄러/배치 처리: 3개 replica 중 1개만 cron 작업 실행 (중복 실행 방지)
  • CDC Consumer: Debezium 커넥터처럼 단일 소비자만 변경 이벤트 처리
  • 캐시 워밍: 시작 시 1개 Pod만 캐시 사전 로드 수행
  • 외부 API 폴링: Rate Limit이 있는 외부 API를 1개 인스턴스만 호출
  • Singleton Service: 전역 상태를 관리하는 코디네이터 역할

마무리

K8s Lease 기반 리더 선출은 외부 의존성 없이 K8s API 서버의 낙관적 동시성 제어만으로 안전한 분산 리더 선출을 구현합니다. resourceVersion이 Split Brain을 원천 차단하고, LeaseDuration/RenewDeadline/RetryPeriod 세 파라미터로 장애 복구 속도를 제어할 수 있습니다. K8s RBAC 권한 관리로 Lease 접근 권한을 최소화하고, Prometheus 모니터링으로 리더 전환을 추적하는 것이 운영의 핵심입니다.

위로 스크롤
WordPress Appliance - Powered by TurnKey Linux