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:
-
- O cliente seleciona um nó etcd usando um algoritmo de balanceamento de carga e faz uma chamada gRPC.
-
- O nó etcd recebe a requisição, que passa pelos interceptadores gRPC e pelo módulo Quota antes de chegar ao módulo
KVServer.
- O nó etcd recebe a requisição, que passa pelos interceptadores gRPC e pelo módulo Quota antes de chegar ao módulo
-
- O módulo
KVServersubmete 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'."
- O módulo
-
- 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).
- Essa proposta é então encaminhada através do módulo de rede
-
- O etcdserver recupera as entradas de log confirmadas do módulo Raft e as passa para o módulo Apply.
-
- 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:
- 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.
- 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 adesfragmentaçã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 delimitaçã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 de5000, é retornado o erroetcdserver: too many requestsao 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 tokenao 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 erroetcdserver: request is too largeao 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:
- O conteúdo da entrada de log Raft (incluindo mandato, índice e conteúdo da proposta) é serializado e salvo no campo
Datado registro WAL. Em seguida, o valor CRC deDataé calculado, e oTypeé definido comoEntry Type. Isso forma um registro WAL completo. - O comprimento do registro WAL é calculado, e primeiro o comprimento (
Len Field) é escrito, seguido pelo conteúdo do registro. A chamadafsyncpersiste os dados no disco, completando o salvamento da entrada de log no armazenamento persistente. - 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.
- 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
putedeletegeram novas versões baseadas no incremento da versão atual, caracterizando uma escrita sequencial. O parâmetrobucket.FillPercentdo 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 bufferque 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.