Implementación de elección de líder con etcd en Go

En muchos sistemas distribuidos es necesario designar un nodo como líder que coordine tareas. Aunque ZooKeeper es la opción tradicional, su pila de dependencias y su modelo de znodes pueden resultar excesivos. etcd, al estar basado en Raft y ofrecer consistencia fuerte para operaciones de lectura/escritura, se convierte en una alternativa ligera para resolver este problema.

Fundamento

El paquete clientv3/concurrency de etcd abstrae el ciclo de elección:

import "go.etcd.io/etcd/client/v3/concurrency"

Detrás de la cortina, la elección se apoya en transacciones CAS (Compare-And-Swap) sobre un prefijo común. Cada candidato crea una clave con revisino monótonamente creciente; el nodo con la revision más baja se convierte en líder. Los demás observan (watch) el prefijo y se despiertan cuando el líder actual desaparecee.

Estructura de la solución

Envolvemos la sesión y la instancia de Election en una estructura reutilizable:

type liderEtcd struct {
	sesion *concurrency.Session
	votar  *concurrency.Election
}

func Postular(ctx context.Context, cli *clientv3.Client, prefijo, valor string) (*liderEtcd, error) {
	if valor == "" {
		valor = fmt.Sprintf("%s-%d", nombreNodo(), ahoraMs())
	}
	s, err := concurrency.NewSession(cli)
	if err != nil {
		return nil, fmt.Errorf("no se pudo crear sesión: %w", err)
	}
	prefijo = "/cluster/" + strings.TrimPrefix(prefijo, "/")

	eleccion := concurrency.NewElection(s, prefijo)
	l := &liderEtcd{sesion: s, votar: eleccion}
	return l, l.votar.Campaign(ctx, valor) // bloquea hasta ganar
}

El líder puede:

  • Refrescar su presencia con Proclamar.
  • Abandonar voluntariamente con Renunciar.
func (l *liderEtcd) Proclamar(ctx context.Context, valor string) error {
	if l.votar == nil {
		return fmt.Errorf("lider cerrado")
	}
	if valor == "" {
		valor = fmt.Sprintf("%s-%d", nombreNodo(), ahoraMs())
	}
	return l.votar.Proclaim(ctx, valor)
}

func (l *liderEtcd) Renunciar(ctx context.Context) error {
	if l.votar == nil {
		return nil
	}
	defer l.sesion.Close()
	l.votar, l.sesion = nil, nil
	return l.votar.Resign(ctx)
}

Simulación de varios procesos

Ejecutar varias instancias del siguiente código demuestra la elección en acción:

func tarea(id string) {
	cli, err := conectar("127.0.0.1:2379")
	if err != nil {
		log.Fatalf("proceso %s: %v", id, err)
	}
	defer cli.Close()

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

	lider, err := Postular(ctx, cli, "servicio/tarea", "")
	if err != nil {
		log.Printf("proceso %s no pudo postularse: %v", id, err)
		return
	}
	log.Printf("proceso %s es líder", id)

	tick := time.NewTicker(time.Second)
	defer tick.Stop()

	for i := 0; i < 5; i++ {
		select {
		case <-ctx.Done():
			_ = lider.Renunciar(context.Background())
			return
		case <-tick.C:
			if err := lider.Proclamar(ctx, ""); err != nil {
				log.Printf("proceso %s perdió liderazgo: %v", id, err)
				return
			}
			log.Printf("proceso %s tick %d", id, i+1)
		}
	}
	_ = lider.Renunciar(context.Background())
	log.Printf("proceso %s finaliza su mandato", id)
}

Lanzar varios go tarea("A"), go tarea("B"), … mostrará cómo uno gana la elección y los demás quedan en espera; cuando el líder muere o renuncia, el siguiente en la cola toma el mando sin intervención manual.

Gracias al watch interno, no hay sondeo activo (polling), lo que reduce latencia y carga de red repsecto a una implementación bloqueante clásica.

Etiquetas: etcd Raft leader-election Go Concurrency

Publicado el 9-18 08:00