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.