Service discovery com Consul ou etcd

1. Introdução ao Service Discovery em Microsserviços Go

1.1. Por que service discovery é essencial em sistemas distribuídos

Em arquiteturas de microsserviços, instâncias são criadas e destruídas dinamicamente devido a escalonamento horizontal, falhas ou deploys. Endereços IP e portas mudam constantemente, tornando inviável a configuração estática. O service discovery resolve esse problema mantendo um catálogo atualizado de serviços disponíveis, permitindo que consumidores encontrem produtores sem acoplamento rígido.

1.2. Abordagens: cliente-servidor vs. peer-to-peer

Existem duas abordagens principais:
- Cliente-servidor: Um servidor central (como Consul ou etcd) armazena o estado. Clientes registram e consultam serviços. Oferece consistência forte, mas introduz um ponto único de falha que pode ser mitigado com clusters.
- Peer-to-peer: Cada nó conhece seus pares (ex: Serf, memberlist). Escalável e tolerante a falhas, mas com consistência eventual.

1.3. Visão geral do Consul e etcd como soluções de descoberta

  • Consul: Plataforma completa com service discovery, health checking, segmentação por datacenter e suporte a DNS. Ideal para ambientes híbridos (on-premise + cloud).
  • etcd: Key-value store consistente e altamente disponível, usado como backbone do Kubernetes. Excelente para cenários que exigem forte consistência e integração com sistemas containerizados.

Ambos possuem bibliotecas Go maduras e são escolhas populares na comunidade.

2. Configuração e Integração Básica com Consul

2.1. Instalação local do Consul e configuração mínima

Para testar localmente, inicie o Consul em modo dev:

consul agent -dev -node=local -bind=127.0.0.1

2.2. Registro de serviço usando a biblioteca hashicorp/consul/api

package main

import (
    "fmt"
    "log"

    consulapi "github.com/hashicorp/consul/api"
)

func registerService(client *consulapi.Client, name, address string, port int) error {
    registration := &consulapi.AgentServiceRegistration{
        ID:      fmt.Sprintf("%s-%s", name, address),
        Name:    name,
        Address: address,
        Port:    port,
        Check: &consulapi.AgentServiceCheck{
            HTTP:     fmt.Sprintf("http://%s:%d/health", address, port),
            Interval: "10s",
            Timeout:  "5s",
        },
    }
    return client.Agent().ServiceRegister(registration)
}

func main() {
    config := consulapi.DefaultConfig()
    config.Address = "localhost:8500"
    client, err := consulapi.NewClient(config)
    if err != nil {
        log.Fatal(err)
    }

    err = registerService(client, "user-service", "192.168.1.10", 8080)
    if err != nil {
        log.Fatal(err)
    }
    fmt.Println("Serviço registrado com sucesso no Consul")
}

2.3. Descoberta de serviços via consulta DNS e API HTTP

func discoverService(client *consulapi.Client, serviceName string) ([]string, error) {
    services, _, err := client.Health().Service(serviceName, "", true, nil)
    if err != nil {
        return nil, err
    }

    var endpoints []string
    for _, entry := range services {
        endpoints = append(endpoints, fmt.Sprintf("%s:%d", entry.Service.Address, entry.Service.Port))
    }
    return endpoints, nil
}

3. Configuração e Integração Básica com etcd

3.1. Inicialização de um cluster etcd local (modo single-node)

etcd --listen-client-urls http://localhost:2379 --advertise-client-urls http://localhost:2379

3.2. Registro de serviço com leases usando clientv3 do etcd

package main

import (
    "context"
    "fmt"
    "log"
    "time"

    clientv3 "go.etcd.io/etcd/client/v3"
)

func registerServiceEtcd(cli *clientv3.Client, key, value string, ttl int64) error {
    lease, err := cli.Grant(context.Background(), ttl)
    if err != nil {
        return err
    }

    _, err = cli.Put(context.Background(), key, value, clientv3.WithLease(lease.ID))
    if err != nil {
        return err
    }

    // Keep-alive da lease
    keepAlive, err := cli.KeepAlive(context.Background(), lease.ID)
    if err != nil {
        return err
    }

    go func() {
        for range keepAlive {
            // Mantém a lease ativa
        }
    }()

    return nil
}

func main() {
    cli, err := clientv3.New(clientv3.Config{
        Endpoints:   []string{"localhost:2379"},
        DialTimeout: 5 * time.Second,
    })
    if err != nil {
        log.Fatal(err)
    }
    defer cli.Close()

    key := "/services/user-service/192.168.1.10:8080"
    value := `{"address":"192.168.1.10","port":8080,"status":"active"}`
    err = registerServiceEtcd(cli, key, value, 10)
    if err != nil {
        log.Fatal(err)
    }
    fmt.Println("Serviço registrado no etcd com lease")
}

3.3. Watchers para descoberta dinâmica de endpoints

func watchService(cli *clientv3.Client, prefix string) {
    watchChan := cli.Watch(context.Background(), prefix, clientv3.WithPrefix())
    for wresp := range watchChan {
        for _, ev := range wresp.Events {
            fmt.Printf("Tipo: %s, Chave: %s, Valor: %s\n", ev.Type, ev.Kv.Key, ev.Kv.Value)
        }
    }
}

4. Estratégias de Health Checking e Heartbeat

4.1. Health checks customizados no Consul (scripts, HTTP, TCP)

O Consul suporta múltiplos tipos de health check. O exemplo anterior usou HTTP. Para checks TCP:

Check: &consulapi.AgentServiceCheck{
    TCP:      fmt.Sprintf("%s:%d", address, port),
    Interval: "10s",
}

4.2. Implementação de heartbeats com etcd (keep-alive de leases)

O mecanismo de leases do etcd já funciona como heartbeat. Se o serviço falhar ao renovar a lease dentro do TTL, a chave expira automaticamente.

4.3. Tratamento de falhas: remoção automática de serviços inativos

No Consul, checks com falha marcam o serviço como crítico. Após um número configurável de falhas, o serviço é removido da consulta de serviços saudáveis. No etcd, a expiração da lease remove automaticamente a chave.

5. Padrões Avançados de Descoberta em Go

5.1. Cache local de endpoints com atualização por watch/event

type ServiceCache struct {
    mu       sync.RWMutex
    endpoints map[string][]string
    consulClient *consulapi.Client
}

func (c *ServiceCache) Watch(serviceName string) {
    go func() {
        for {
            endpoints, err := discoverService(c.consulClient, serviceName)
            if err == nil {
                c.mu.Lock()
                c.endpoints[serviceName] = endpoints
                c.mu.Unlock()
            }
            time.Sleep(5 * time.Second)
        }
    }()
}

5.2. Estratégias de resolução: round-robin, weighted, least-connections

type RoundRobin struct {
    mu     sync.Mutex
    index  int
    endpoints []string
}

func (rr *RoundRobin) Next() string {
    rr.mu.Lock()
    defer rr.mu.Unlock()
    if len(rr.endpoints) == 0 {
        return ""
    }
    endpoint := rr.endpoints[rr.index]
    rr.index = (rr.index + 1) % len(rr.endpoints)
    return endpoint
}

5.3. Integração com gRPC resolvers e load balancers personalizados

O pacote google.golang.org/grpc/resolver permite criar resolvers customizados que consultam Consul ou etcd dinamicamente.

6. Comparação Prática: Consul vs. etcd para Service Discovery

6.1. Diferenças de consistência, latência e tolerância a falhas

Característica Consul etcd
Consistência Eventual (AP) Forte (CP)
Latência Menor (cache local) Maior (consenso Raft)
Health checking Nativo (HTTP, TCP, script) Requer implementação externa
DNS integrado Sim Não
Interface web Sim Não (apenas API)

6.2. Casos de uso recomendados

  • Consul: Ambientes híbridos, múltiplos datacenters, necessidade de DNS, health checks sofisticados.
  • etcd: Kubernetes, sistemas que já usam etcd, cenários que exigem forte consistência.

6.3. Exemplo de código: migração de Consul para etcd

A migração envolve substituir chamadas à API do Consul por operações equivalentes no etcd. O conceito de chave-valor com leases substitui o registro de serviço com TTL.

7. Boas Práticas e Troubleshooting

7.1. Gerenciamento de conexões e timeouts com retry e backoff

func connectWithRetry(endpoints []string, maxRetries int) (*clientv3.Client, error) {
    var cli *clientv3.Client
    var err error
    for i := 0; i < maxRetries; i++ {
        cli, err = clientv3.New(clientv3.Config{
            Endpoints:   endpoints,
            DialTimeout: 5 * time.Second,
        })
        if err == nil {
            return cli, nil
        }
        time.Sleep(time.Duration(i+1) * time.Second)
    }
    return nil, fmt.Errorf("falha ao conectar após %d tentativas: %v", maxRetries, err)
}

7.2. Monitoramento do service discovery (métricas e logs)

Registre métricas como:
- Número de serviços registrados
- Latência de consulta
- Taxa de expiração de leases
- Número de falhas de health check

7.3. Problemas comuns: split-brain, churn de registros, TTL mal configurado

  • Split-brain: Em clusters etcd, garantir número ímpar de nós.
  • Churn de registros: TTL muito curto causa registros constantes. Ajuste para 30-60 segundos.
  • TTL mal configurado: TTL muito longo mantém serviços mortos; muito curto causa instabilidade.

Referências