Skip to content
Published on

etcd Watch와 Lease 메커니즘 분석

Authors

etcd Watch와 Lease 메커니즘 분석

etcd의 Watch와 Lease는 분산 시스템에서 핵심적인 기능을 제공합니다. Watch는 키 변경 사항의 실시간 감시를, Lease는 키의 자동 만료를 가능하게 합니다. 이 두 메커니즘의 내부 동작을 상세히 분석합니다.


1. Watch 내부 구조

1.1 Watchable Store 아키텍처

Watchable Store는 MVCC Store를 래핑하여 Watch 기능을 추가합니다:

// watchableStore 구조 (간략화)
type watchableStore struct {
    *store                        // MVCC store
    synced   watcherGroup         // 현재 revision에 동기화된 watcher
    unsynced watcherGroup         // 아직 따라잡지 못한 watcher
    victims  []watcherBatch       // 전송 실패한 이벤트 대기열
}

1.2 Watcher 생명주기

  1. 클라이언트가 Watch 요청 전송
  2. Watcher 생성 및 시작 revision 결정
  3. 시작 revision이 현재 revision보다 오래되면 unsynced 그룹에 배치
  4. 히스토리에서 이벤트를 재생하여 따라잡기
  5. 현재 revision까지 따라잡으면 synced 그룹으로 이동
  6. 이후 새 이벤트를 실시간으로 수신

1.3 synced vs unsynced Watcher

synced watcher:

  • 현재 store revision까지 모든 이벤트를 수신 완료
  • 새 쓰기 작업 발생 시 즉시 이벤트 수신
  • 성능 오버헤드가 적음

unsynced watcher:

  • 아직 과거 이벤트를 재생 중
  • 별도의 goroutine이 히스토리를 순회하며 이벤트 생성
  • 따라잡기 완료 후 synced로 전환

2. 이벤트 생성과 전달

2.1 이벤트 생성 과정

MVCC Store에 쓰기가 발생하면:

// 트랜잭션 커밋 시 이벤트 생성 (간략화)
func (tw *storeTxnWrite) End() {
    // 1. 변경사항을 BoltDB에 기록
    tw.s.b.BatchTx().Commit()

    // 2. 이벤트 생성
    evs := make([]mvccpb.Event, len(tw.changes))
    for i, change := range tw.changes {
        evs[i] = mvccpb.Event{
            Type: change.Type,  // PUT or DELETE
            Kv:   change.Kv,
        }
    }

    // 3. watchable store에 이벤트 전달
    tw.s.notify(tw.rev, evs)
}

2.2 이벤트 필터링과 매칭

각 Watcher는 감시 대상을 정의합니다:

  • 단일 키 Watch: 정확히 하나의 키만 감시
  • 범위 Watch: 키 범위(prefix 포함)를 감시
  • 필터: PUT 또는 DELETE 이벤트만 필터링 가능

이벤트 발생 시 watchable store는 영향받는 모든 watcher를 찾아 이벤트를 전달합니다. 효율적인 매칭을 위해 interval tree와 같은 자료구조를 사용합니다.

2.3 이벤트 순서 보장

etcd Watch는 이벤트의 순서를 보장합니다:

  • 같은 키에 대한 이벤트는 revision 순서대로 전달
  • Watch ID별로 이벤트가 순서대로 전달
  • 클라이언트는 수신한 이벤트의 revision을 확인하여 누락을 감지

3. gRPC Watch 스트리밍

3.1 Watch 스트림 구조

클라이언트와 서버 간 Watch는 양방향 gRPC 스트리밍을 사용합니다:

service Watch {
    rpc Watch(stream WatchRequest) returns (stream WatchResponse);
}

하나의 gRPC 연결에서 여러 Watch를 다중화(multiplexing)할 수 있습니다. 각 Watch는 고유한 Watch ID를 가집니다.

3.2 Watch 요청 타입

  • WatchCreateRequest: 새 Watch 생성 (키, 범위, 시작 revision 등 지정)
  • WatchCancelRequest: 기존 Watch 취소
  • WatchProgressRequest: 현재 진행 상태 요청

3.3 Compacted Revision 처리

Watch의 시작 revision이 이미 compaction된 경우:

  1. 서버가 ErrCompacted 에러와 함께 compactRevision을 반환
  2. 클라이언트는 현재 데이터를 다시 로드 (Range 요청)
  3. 로드 완료 후 반환된 revision부터 새 Watch 시작
// 클라이언트 측 compaction 처리 예시
wch := client.Watch(ctx, "key", clientv3.WithRev(oldRev))
for resp := range wch {
    if resp.CompactRevision > 0 {
        // compaction 발생 - 데이터 재로드 필요
        reloadData()
        // 새 Watch 시작
        wch = client.Watch(ctx, "key", clientv3.WithRev(resp.CompactRevision))
    }
}

3.4 Watch 진행(Progress) 알림

클라이언트가 현재 Watch 진행 상태를 알고 싶을 때:

  • WatchProgressRequest를 전송
  • 서버가 현재 revision을 포함한 빈 WatchResponse를 반환
  • 일정 간격으로 자동 progress 알림도 설정 가능

4. Lease 메커니즘

4.1 Lease 개요

Lease는 키에 TTL(Time-To-Live)을 부여하여 자동 만료를 가능하게 합니다:

  • Lease를 생성하면 고유 ID와 TTL이 할당
  • 키를 Lease에 연결하면 Lease 만료 시 키도 삭제
  • 하나의 Lease에 여러 키를 연결 가능

4.2 Lease Grant

# Lease 생성 (TTL 300초)
etcdctl lease grant 300
# lease 694d71ddafb1e01a granted with TTL(300s)

# 키에 Lease 연결
etcdctl put --lease=694d71ddafb1e01a mykey myvalue

4.3 Lease 내부 구조

// Lessor 구조 (간략화)
type lessor struct {
    leaseMap       map[LeaseID]*Lease   // 활성 lease 목록
    leaseExpiredNotifier *LeaseExpiredNotifier
    itemMap        map[LeaseItem]LeaseID // 키 -> lease 매핑
    b              backend.Backend       // 영속 저장소
}

type Lease struct {
    ID           LeaseID
    ttl          int64        // 원래 TTL (초)
    remainingTTL int64        // 남은 TTL
    expiryTime   time.Time    // 만료 시각
    itemSet      map[LeaseItem]struct{} // 연결된 키 목록
}

4.4 TTL 구현과 만료 처리

Lease의 만료는 다음과 같이 처리됩니다:

  1. Lessor가 주기적으로 만료된 Lease를 확인 (500ms 간격)
  2. 만료된 Lease 발견 시 Revoke 요청을 Raft에 제안
  3. Raft 합의 후 Lease와 연결된 모든 키를 삭제
  4. Lease 자체도 제거

4.5 Lease KeepAlive

클라이언트가 Lease를 갱신하려면 KeepAlive를 전송합니다:

// KeepAlive 사용 예시
resp, _ := client.Grant(ctx, 30) // 30초 TTL
_, _ = client.Put(ctx, "key", "value", clientv3.WithLease(resp.ID))

// 주기적으로 KeepAlive 전송
ch, _ := client.KeepAlive(ctx, resp.ID)
for ka := range ch {
    // ka.TTL은 갱신된 TTL
    fmt.Println("TTL renewed:", ka.TTL)
}

KeepAlive의 특성:

  • 클라이언트 라이브러리가 자동으로 TTL/3 간격으로 전송
  • 서버에서 TTL을 원래 값으로 리셋
  • gRPC 스트리밍으로 효율적인 갱신

4.6 Lease Revoke

Lease를 명시적으로 취소:

etcdctl lease revoke 694d71ddafb1e01a

Revoke 시 해당 Lease에 연결된 모든 키가 즉시 삭제됩니다. Raft 합의를 거치므로 모든 멤버에서 일관적으로 처리됩니다.


5. Lease와 Kubernetes

5.1 리더 선출(Leader Election)

Kubernetes의 리더 선출은 etcd Lease를 활용합니다:

  1. 후보가 특정 키에 자신의 ID를 Lease와 함께 기록 (IfNotExists 조건)
  2. 성공하면 리더가 됨
  3. 리더는 KeepAlive로 Lease를 유지
  4. 리더 장애 시 KeepAlive가 중단되어 Lease 만료
  5. Lease 만료 후 다른 후보가 새 리더로 선출

5.2 노드 하트비트

Kubernetes에서 kubelet은 etcd의 Lease를 사용하여 노드 상태를 보고합니다:

  • 각 노드에 대한 Lease 오브젝트가 kube-node-lease 네임스페이스에 존재
  • kubelet이 주기적으로 Lease를 갱신하여 노드 활성 상태를 표시
  • Lease 갱신 실패 시 노드가 NotReady 상태로 전환

5.3 API Server의 Lease 활용

kube-apiserver도 etcd Lease를 활용합니다:

  • API server 인스턴스 등록
  • 서비스 디스커버리
  • 분산 락 구현

6. Watch와 Lease 결합 패턴

6.1 분산 구성 관리

Watch와 Lease를 결합한 일반적인 패턴:

  1. 설정값을 etcd에 저장
  2. 클라이언트가 설정 키를 Watch
  3. 설정 변경 시 이벤트를 수신하여 즉시 반영
  4. Lease로 임시 설정의 자동 정리

6.2 서비스 등록/발견

  1. 서비스 인스턴스가 Lease와 함께 자신을 등록
  2. 다른 서비스가 등록 키를 Watch하여 변화 감지
  3. 인스턴스 장애 시 Lease 만료로 자동 등록 해제
  4. Watch를 통해 다른 서비스가 즉시 인지

7. 정리

etcd의 Watch와 Lease는 분산 시스템의 핵심 프리미티브를 제공합니다. Watch는 실시간 변경 감지를, Lease는 TTL 기반 자동 만료를 가능하게 하며, 이 둘의 조합으로 리더 선출, 서비스 디스커버리 등 다양한 분산 패턴을 구현할 수 있습니다. 다음 글에서는 etcd와 Kubernetes API Server의 통합을 분석합니다.