Fluxo de Execução de Requisições de Escrita no etcd

Este artigo descreve o fluxo de execução de uma requisição de escrita no etcd. Baseia-se em conteúdo de aperndizado próprio e, se houver alguma violação de direitos autorais, entre em contato para remoção.

Na seção anterior, abordamos o fluxo de requisições de leitura. Agora, vamos explorar como funciona a escrita de dados.

1. Visão Geral do Fluxo de Escrita

Qual é o caminho percorrido por uma requisição de escrita no etcd? Vamos considerar o comando abaixo:

etcdctl put hello world --endpoints 192.168.65.210:2379

O fluxo de execução é o seguinte:

    1. O cliente seleciona um nó etcd usando um algoritmo de balanceamento de carga e faz uma chamada gRPC.
    1. O nó etcd recebe a requisição, que passa pelos interceptadores gRPC e pelo módulo Quota antes de chegar ao módulo KVServer.
    1. O módulo KVServer submete uma proposta ao módulo Raft. O conteúdo da proposta é: "Olá a todos, por favor, executem o comando put com a chave 'hello' e o valor 'world'."
    1. Essa proposta é então encaminhada através do módulo de rede Raft HTTP. Após ser persistida pela maioria dos nós do cluster, seu estado muda para "confirmado" (committed).
    1. O etcdserver recupera as entradas de log confirmadas do módulo Raft e as passa para o módulo Apply.
    1. O módulo Apply executa o conteúdo da proposta através do módulo MVCC, atualizando a máquina de estados.

Diferentemente do fluxo de leitura, o fluxo de escrita envolve os módulos Quota, WAL e Apply. A tolerância a falhas (crash-safe) e a idempotência do etcd são implementadas com base no WAL e no índice consistente (consistent index) do fluxo Apply.

2. Detalhamento das Etapas

2.1. Módulo Quota

O módulo Quota verifica se o tamanho atual do banco de dados etcd somado ao tamanho da chave-valor da requisição ultrapassa a cota configurada (quota-backend-bytes). Se ultrapassar, ele gera uma requisição de alarme (Alarm) do tipo NO SPACE, que é sincronizada para outros nós através do log Raft, informando que o banco de dados está sem espaço. O alarme é persistido no banco de dados. Consequentemente, tanto o módulo gRPC da camada de API quanto o módulo Apply, que aplica as entradas de log do Raft confirmadas à máquina de estados, rejeitam escritas, tornando o cluster somente leitura.

O erro comum etcdserver: mvcc: database space exceeded é causado pelo módulo Quota ao detectar que o tamanho do banco de dados excedeu o limite. As situações que podem desencadear esse erro incluem:

  • A cota padrão do banco de dados é de apenas 2G. Com o aumento dos dados de negócio, da QPS de escrita ou da escala do cluster Kubernetes, o tamanho do banco de dados etcd pode ultrapassar 2G.
  • O etcd é um banco de dados MVCC que mantém versões históricas das chaves. Se a política de compactação não estiver configurada, o tamanho do banco de dados aumenta continuamente com as escritas, levando ao estouro de cota.

Soluções:

  1. Aumentar a cota. A comunidade etcd recomenda não ultrapassar 8G. Definir um valor menor que 0 desabilita a funcionalidade de cota, o que pode levar ao crescimento descontrolado do banco de dados e degradação de desempenho; não é recomendado.
  2. Verificar se a compactação (compact) do etcd está habilitada e se a configuração é adequada. A compactação apenas marca versões antigas de chaves como livres (Free). O espaço pode ser reutilizado por novas escritas, mas o tamanho do banco de dados não diminui. Para recuperar espaço e reduzir o tamanho do banco de dados, é necessário usar a desfragmentação (defrag), que percorre os dados do arquivo antigo e os escreve em um novo. No entanto, isso impacta significativamente o desempenho do serviço e não é recomendado em clusters de produção com alta frequência.

Após o ajuste, é necessário enviar manualmente um comando para cancelar o alarme (etcdctl alarm disarm) para eliminar todos os alarmes. Caso contrário, o cluster permanecerá sem capacidade de escrita devido à presença do alarme.

# Verificar status
etcdctl --endpoints=192.168.91.68:2379,192.168.91.68:12379,192.168.91.68:22379 endpoint status -w table

# Compactar
etcdctl --endpoints=192.168.91.68:2379,192.168.91.68:12379,192.168.91.68:22379 compact 1

# Desfragmentar
etcdctl --endpoints=192.168.91.68:2379,192.168.91.68:12379,192.168.91.68:22379 defrag

# Desarmar alarme
etcdctl alarm disarm

2.2. Módulo KVServer

Após passar pela verificação de cota, a requisição é encaminhada da camada de API para o método put do módulo KVServer.

As principais funções do módulo KVServer são:

  • Empacotar a proposta: transformar o conteúdo da requisição de escrita em uma mensagem de proposta e submetê-la ao módulo Raft.
  • Limitação de taxa e verificação: antes de submeter a proposta, há verificações de limitação de taxa, autenticação e tamanho do pacote.
2.2.1. Preflight Check

Para garantir a estabilidade do cluster e evitar avalanches, requisições submetidas ao módulo Raft passam por uma verificação simples de limitação de taxa.

Limitação de taxa:

  • Se o índice de log confirmado (committed index) do módulo Raft exceder o índice de log aplicado (applied index) à máquina de estados em mais de 5000, é retornado o erro etcdserver: too many requests ao cliente.

Autenticação:

  • Em seguida, tenta-se obter as informações de autenticação da requisição. Se a autenticação por senha estiver em uso e o token for inválido, é retornado o erro auth: invalid auth token ao cliente.

Verificação de tamanho do pacote:

  • Verifica-se se o tamanho do pacote de escrita excede o limite padrão de 1.5MB. Se sim, é retornado o erro etcdserver: request is too large ao cliente.

2.3. Propose

Após a verificação, é gerado um ID único que associa esta requisição a um canal de notificação de mensagens (channel) para receber o resultado. Em seguida, uma proposta (Proposal) é submetida ao módulo Raft. Após a submissão, o módulo KVServer aguarda o resultado da requisição put através do canal de notificação, ou até que ocorra um timeout.

O tempo limite padrão do etcd é de 7 segundos (5 segundos de latência de I/O de disco + 2 * 1 segundo de timeout de eleição). Se uma requisição expirar sem retornar resultado, pode ocorrer o erro etcdserver: request timed out.

2.4. Módulo WAL

Ao receber a proposta, se o nó atual for um Seguidor (Follower), ele a encaminha para o Líder (Leader). Apenas o Líder pode processar requisições de escrita.

O Líder, ao receber a proposta, usa o módulo Raft para gerar mensagens a serem enviadas aos nós Seguidores e entradas de log a serem persistidas. A entrada de log encapsula o conteúdo da proposta (nosso comando put).

O etcdserver obtém essas mensagens e entradas de log do módulo Raft. Como Líder, ele transmite a proposta para todos os nós do cluster. Simultaneamente, ele persiste as informações do mandato do Líder, informações de votação, índice confirmado e o conteúdo da proposta em um arquivo de log WAL (Write Ahead Log). Isso garante a consistência e a recuperabilidade do cluster, conforme ilustrado no fluxo.

2.4.1. Estrutura do Log WAL

O arquivo WAL é composto por registros WAL de vários tipos, acrescentados sequencialmente. Cada registro consiste em tipo, dados e código de redundância cíclica (CRC). O campo Type distingue os diferentes tipos de registro, Data contém o conteúdo correspondente e CRC é a informação de soma de verificação.

Atualmente, o WAL suporta 5 tipos de registro: registro de metadados de arquivo, registro de entrada de log, registro de informação de estado, registro CRC e registro de snapshot:

  • Registro de metadados de arquivo: contém o ID do nó e o ID do cluster, sendo escrito na criação do arquivo WAL.
  • Registro de entrada de log: contém as informações do log Raft, como o conteúdo da proposta put.
  • Registro de informação de estado: contém o mandato do cluster e as informações de votação do nó. Um arquivo de log pode ter vários, sendo o último registro o válido.
  • Registro CRC: contém o CRC do último arquivo WAL, escrito como primeira entrada em um novo arquivo WAL durante sua criação ou divisão, para verificar a integridade e precisão dos dados.
  • Registro de snapshot: contém o mandato e o índice do log do snapshot, usado para verificar a precisão do arquivo de snapshot.
2.4.2. Persistência WAL

Primeiro, a requisição put é encapsulada em uma entrada de log Raft. A estrutura de dados da entrada de log Raft é:

type Entry struct {
   Term             uint64    `protobuf:"varint,2,opt,name=Term" json:"Term"`
   Index            uint64    `protobuf:"varint,3,opt,name=Index" json:"Index"`
   Type             EntryType `protobuf:"varint,1,opt,name=Type,enum=Raftpb.EntryType" json:"Type"`
   Data             []byte    `protobuf:"bytes,4,opt,name=Data" json:"Data,omitempty"`
}

Ela é composta pelos seguintes campos:

  • Term: é o mandato do Líder, que aumenta a cada eleição.
  • Index: é o índice da entrada de log, que aumenta monotonicamente.
  • Type: é o tipo de log, como um comando normal (EntryNormal) ou uma mudança de configuração do cluster (EntryConfChange).
  • Data: contém o conteúdo da proposta put descrito acima.

O processo de persistência é o seguinte:

  1. O conteúdo da entrada de log Raft (incluindo mandato, índice e conteúdo da proposta) é serializado e salvo no campo Data do registro WAL. Em seguida, o valor CRC de Data é calculado, e o Type é definido como Entry Type. Isso forma um registro WAL completo.
  2. O comprimento do registro WAL é calculado, e primeiro o comprimento (Len Field) é escrito, seguido pelo conteúdo do registro. A chamada fsync persiste os dados no disco, completando o salvamento da entrada de log no armazenamento persistente.
  3. Quando mais da metade dos nós persistir esta entrada de log, o módulo Raft notifica o módulo etcdserver através de um canal que a proposta foi confirmada pela maioria dos nós. O estado da proposta é alterado para "confirmado", e a execução do seu conteúdo pode começar.
  4. Então, o módulo etcdserver retira o conteúdo da proposta do canal e o adiciona a uma fila de escalonamento FIFO (First-In, First-Out). Posteriormente, o módulo Apply executa as propostas de forma assíncrona e sequencial, de acordo com a ordem de chegada.

2.5. Módulo Apply

O módulo Apply é responsável por executar propostas no estado "confirmado", atualizando a máquina de estados.

Antes de executar o conteúdo da proposta, o módulo Apply verifica se a proposta já foi executada. Se sim, retorna imediatamente. Se não e não houver alarme de cota cheia, ele prossegue para o módulo MVCC, que interage com o módulo de armazenamento persistente.

Como encontrar e reexecutar propostas anômalas após uma falha (crash) durante a execução?

Isso depende principalmente do log WAL. Como as propostas submetidas ao módulo Apply foram confirmadas e persistidas pela maioria dos nós, o etcd, ao reiniciar, analisa o WAL para extrair o conteúdo das entradas de log Raft, anexa-as ao armazenamento de log do Raft e reproduz as propostsa de log confirmadas para o módulo Apply executar.

Como garantir a idempotência durante a recuperação da reinicialização, evitando a execução duplicada de propostas e a consequente corrupção de dados?

O etcd introduz um campo índice consistente (consistent index) para armazenar o índice da entrada de log que foi executada, garantindo a idempotência. Como o campo de índice (index) nas entradas de log Raft é globalmente monotônico e cada índice corresponde a uma proposta, se o índice da entrada de log executada for registrado no banco de dados, o problema de idempotência é resolvido. É necessário executar o comando e registrar o índice como uma única transação atômica para garantir a idempotência.

2.6. Módulo MVCC

O MVCC é composto principalmente por duas partes: o módulo de índice em memória treeIndex, que armazena as informações de versão histórica das chaves, e o módulo boltdb, que persiste os dados chave-valor.

Ao executar o comando put hello world, como o módulo MVCC constrói o índice em memória e quais dados são salvos no banco de dados?

2.6.1. treeIndex

A transação de escrita do MVCC, ao processar a requisição put hello world, gera uma nova revision baseada no incremento do currentRevision, como {2,0}. Em seguida, consulta o módulo treeIndex para obter informações como a versão de criação da chave e o número de modificações. Essas informações preenchem o valor no boltdb. Simultaneamente, a chave hello e a revisão são armazenadas na B-tree.

hello: revision{2, 0}

2.6.2. boltdb

Após o incremento da versão global pela transação de escrita do MVCC, a revisão {2, 0} gerada torna-se a chave do boltdb. Através dela, os dados podem ser escritos no boltdb.

Quais informações estão contidas no valor escrito no boltdb?

O valor escrito no boltdb não é simplesmente "world". Se apenas o valor do usuário fosse armazenado e o índice estivesse na memória volátil, após reiniciar o etcd, o nome da chave do usuário seria perdido, impossibilitando a reconstrução do módulo treeIndex.

Para construir o índice e suportar recursos como Lease (arrendamento), o etcd persiste as seguintes informações:

  • Nome da chave;
  • Versão de criação da chave (create_revision), versão da última modificação (mod_revision) e número de modificações da chave (version);
  • Valor;
  • Informações do lease.

O valor no boltdb é o resultado da serialização de uma estrutura que contém essas informações. Usando a interface put fornecida pelo boltdb, o etcd conclui rapidamente a escrita dos dados.

Nota: Durante o fluxo acima, o etcd ainda não confirmou a transação (commit). Os dados são atualizados apenas na estrutura de dados em memória gerenciada pelo boltdb. O processo de confirmação de transação inclui o balanceamento e a divisão da árvore B+, e a liberação dos dados sujos (dirty page) e metadados para o disco, sendo uma operação cara.

Se a transação fosse confirmada a cada atualização, o desempenho de escrita do etcd seria baixo.

A solução do etcd é combinar e consolidar:

  • Como a chave do boltdb é a versão, as operações put e delete geram novas versões baseadas no incremento da versão atual, caracterizando uma escrita sequencial. O parâmetro bucket.FillPercent do boltdb pode ser ajustado para que cada página armazene mais dados, reduzindo a divisão de páginas e o espaço em disco.
  • O etcd combina múltiplas requisições de transação de escrita. Normalmente, um mecanismo assíncrono confirma transações em lote periodicamente (a cada 100ms por padrão), aumentando significativamente a taxa de transferência. No entanto, essa otimização introduz outro problema: como a transação não foi confirmada, as requisições de leitura podem não obter os dados mais recentes do boltdb.
  • Para resolver isso, o etcd introduz um bucket buffer que mantém dados de transações ainda não confirmadas. Ao atualizar o boltdb, o etcd também sincroniza os dados com o bucket buffer. Assim, ao processar requisições de leitura, o etcd lê primeiro do bucket buffer e, em seguida, do boltdb. O bucket buffer melhora o desempenho de leitura e escrita, garantindo a consistência dos dados.

Este é o fluxo de execução de requisições de escrita no etcd. Ainda há muitos pontos que não compreendo completamente, mas o estudo contínuo traz novos insights.

Tags: etcd escrita Raft WAL MVCC

Publicado em 7-20 19:45