Migrando com zero downtime

Publicado por Jônatas Davi Paganini e Gabriel Mazetto no dia dev

Aqui na Resultados Digitais um dos principais valores é Customer First. Como reflexo disso, a equipe do RD Station tem o compromisso de manter a plataforma em operação com alto nível de disponibilidade.

Vamos explicar um pouco das estratégias utilizadas para não parar o RD Station, ao mesmo tempo que fazemos manutenção no backend ou colocamos novas atualizações no ar. Também entraremos em alguns detalhes sobre como lidamos com migrações de BigData e boas práticas para alta disponibilidade.

Disponibilidade

Um dos valores mais importantes que uma empresa que produza SaaS deve ter é transparência. Isso atende duas questões importantes:

  1. Confiança na nossa operação: Você sabe exatamente quando, porque e o que foi feito com relação a um problema.
  2. Reduzir atendimentos no suporte: Se você está passando por um problema que já foi identificado, pode acompanhar a resolução dele, sem a necessidade de gerar atendimento para um caso específico.

Existem diversos serviços que facilitam a disponibilização dessas informações. Nós utilizamos o Status.io para a nossa página de acompanhamento de status. Nela é possível ver métricas de qualidade do serviço e acompanhar a disponibilidade dos principais componentes da nossa aplicação.

Zero Downtime

Na nossa busca por manter o serviço sempre online, temos o desafio de introduzir mudanças da forma mais suave possível para os nossos clientes, sem nenhum downtime. Para entender melhor o que é disponibilidade, trouxemos os conceitos abaixo:

  • Disponibilidade geralmente é apresentada como um percentual de um determinado tempo (geralmente mensal) em que o sistema esteve disponível e acessível. Esse assunto por si só traz uma série de debates sobre como realizar a mensuração.
  • Uma aplicação está disponível se ela consegue responder requisições em um tempo aceitável. Para ela estar acessível, é necessário que pessoas de diferentes localizações geográficas consigam realizar as mesmas requisições e obter a resposta em um tempo aceitável.
  • Eventualmente, uma aplicação pode estar operacional, porém não acessível em uma determinada região geográfica. Isso pode acontecer por conta de problemas na infra-estrutura da própria internet.
  • Esse tipo de cenário, do qual não temos capacidade alguma de influenciar ou se responsabilizar, é geralmente desconsiderado de uma análise de disponibilidade. No entanto, se isso ocorrer por falta de capacidade da aplicação em atender a tantas requisições, isto é um problema de disponibilidade.

Com os conceitos explicados, podemos entender de forma genérica que disponibilidade se resume a seguinte fórmula:

1
disponibilidade = (tempo_total - tempo_fora/tempo_total) * 100

Esses valores ficam mais claros quando se observa a disponibilidade em comparação com o período de downtime de um mês:

Disponibilidade Tempo de downtime do mês
99.99% 4min 32s
99.9% 43min
99.5% 3h 36min
99.0% 7h 12min
98.6% 10h
98.0% 14h 24min
97.0% 21h 36min

Como é possível notar, o problema começa a partir das casas decimais de 99%. Atingir 100% de disponibilidade é uma tarefa que demanda uma quantidade grande de recursos e salvaguardas, e não é essencial para todos os tipos de aplicação. Você espera que um sistema que controla um avião durante o voo não deixe de funcionar por 4 minutos. No entanto, existem vários outros exemplos que mostram que ficar alguns minutos fora do ar não causa tantos problemas.

Considera-se um nível muito bom de disponibilidade qualquer sistema que atinga valores acima de 97%. Acima de 99% pode ser considerado um sistema de alta disponibilidade.

Cuidados com serviços externos

Um dos grandes desafios das migrações é interagir com serviços externos. Para migrar ou integrar novos serviços encaramos a latência da rede, a localidade e também a disponibilidade do serviço externo.

Outro ponto importante quando se está processando um grande volume de dados é evitar ao máximo bloqueios longos. Seja de objetos, threads, tabelas ou serviços. Fazer bloqueios menores e processos mais rápidos facilitam a migração, pois as tarefas se tornam mais simples e, consequentemente, os passos da migração se tornam menos perigosos.

Sempre fazemos um benchmarking antes de executar uma migração com dados de produção. Dessa forma garantimos que não vamos impactar a disponibilidade do sistema.

Um cuidado importante é garantir formas de acompanhar o progresso da migração e manter o log das atividades mais relevantes. Uma migração é transparente quando não atrapalha o uso do sistema. Ou seja, quando o usuário sequer percebe que algo está acontecendo.

O RD Station atualmente envia mais de 2 milhões de emails por dia. A todo momento ele recebe notificações sobre emails abertos e novos acessos a landing pages. Além disso, ele está integrado com uma série de serviços de terceiros que também não param de se comunicar com a plataforma.

Além de migrações, outro ponto crítico é o momento de deploy. Durante algum tempo, existirão duas versões distintas do sistema funcionando concorrentemente (a antiga e a nova). Utilizamos atualmente uma arquitetura baseada em 12 factors rodando no Heroku. Basicamente, antes de executar um deploy, duplicamos o número de dynos para garantir que, durante essa troca de versões, não tenhamos degradações ou downtime.

Premissas

O que precisamos levar em consideração para migrações em ambiente de alta disponibilidade:

  1. Evitar transações longas e bloqueantes no banco de dados.
  2. Evitar consumo excessivo de memória no processo que executa a migração.
  3. Evitar criar momentos de impasse - quebrar código ou causar bugs.
  4. Evitar desperdiçar tempo nos processos apenas esperando a resposta de serviços externos.

Temos tabelas do banco de dados com milhões de registros e é inviável trazer todos à memória com alta velocidade. Então, sempre que possível, optamos por trabalhar com dados fragmentados e de preferência selecionados. Por exemplo, se for necessário atualizar todos os registros de uma tabela com uma nova coluna, optamos por:

  • Pré-selecionar apenas os atributos necessários para a lógica do contexto.
  • Pré-carregar todas as relações interessantes para evitar lazy load de atributos ou relações.
  • Escolher pacotes de dados gerenciáveis e indexados para cada transação no banco.
  • Garantir que a migração é a prova de balas. Que mesmo que pare ou trave, quando o processo for retomado, deve seguir de onde parou.

Talk is cheap

Agora vamos usar um exemplo de migração simples de dados. Pretendemos explorar a parte prática de como realizá-la cuidando da performance.

Supomos uma tabela de pessoas com as colunas first_name e last_name. Nós vamos unir o nome e sobrenome em uma nova coluna full_name.

1
add_column :people, :full_name, :string

Em uma migração de dados tradicional e com poucos registros, executaríamos o seguinte código:

1
2
3
4
Person.all.each do |person|
  person.full_name = "#{person.first_name} #{person.last_name}"
  person.save
end

Agora, considerando o cenário acima, precisamos apenas do first_name, last_name e o ID de cada pessoa para executar atualização. Podemos usar o select para selecionar campos específicos e úteis na migração:

1
2
3
4
Person.select("id,first_name,last_name").each do |person|
  person.full_name = "#{person.first_name} #{person.last_name}"
  person.save
end

Outra melhoria é usar o update_attribute para evitar checks de validação e callbacks do ActiveRecord::Base:

1
2
3
Person.select("id,first_name,last_name").each do |person|
  person.update_attribute "full_name", "#{person.first_name} #{person.last_name}"
end

Uma terceira melhoria é usar batches para consultas em blocos. Isso também ajuda no gerenciamento de memória, pois não exige grandes carregamentos para memória:

1
2
3
4
5
Person.select("id,first_name,last_name").find_in_batches do |people|
  people.each do |person|
    person.update_attribute "full_name", "#{person.first_name} #{person.last_name}"
  end
end

Também vale a pena trabalhar com transactions e blocos maiores de transações. As transações permitem utilizar melhor o pool de conexão com o banco de dados. No entanto, em alguns casos, usar transações em um grupo grande de dados, pode causar locks.

1
2
3
4
5
6
7
Person.select("id,first_name,last_name").find_in_batches do |people|
  People.transaction do
    people.each do |person|
      person.update_attribute "full_name", "#{person.first_name} #{person.last_name}"
    end
  end
end

Por padrão o ActiveRecord#find_in_batches faz query com 1000 registros por vez. Desta forma os dados são carregados em blocos de 1000, ao invés de carregar todos de uma única vez. Usar a transação dentro desse bloco garante que a atualização também ocorrerá nesse mesmo bloco, ao invés de 1 a 1.

Subir um deploy intermediário, que já começa a preencher o modelo com os novos dados a partir dali, permite que façamos um único procedimento de migração incremental. Isso garante que não precisaremos reprocessar os dados que foram inseridos após o início da migração.

Realizar migrações incrementais também garante que, se o processo falhar, ele irá retomar apenas com os dados que não foram processados:

1
2
3
4
5
6
7
Person.where(full_name: nil).select("id,first_name,last_name").find_in_batches do |people|
  People.transaction do
    people.each do |person|
      person.update_attribute "full_name", "#{person.first_name} #{person.last_name}"
    end
  end
end

Mesmo com todas essas melhorias, ainda é possível conseguir um pouco mais de performance utilizando os demais núcleos disponíveis na máquina. Uma biblioteca que pode auxiliar esse código é a (Parallel Each)[https://github.com/schleyfox/peach], que traz ganhos mesmo não rodando em cima do jRuby (que possui threads nativas sem GIL):

1
2
3
4
5
6
7
Person.where(full_name: nil).select("id,first_name,last_name").find_in_batches do |people|
  People.transaction do
    people.peach(ActiveRecord::Base.connection_config[:pool]) do |person|
      person.update_attribute "full_name", "#{person.first_name} #{person.last_name}"
    end
  end
end

Também é possível explorar o pool e evitar o encapsulamento do active_record executando um sql puro de update diretamente no pool:

1
2
3
4
5
6
7
8
9
10
11
update_sql = "UPDATE people set full_name ="
Person.where(full_name: nil).select("id,first_name,last_name").find_in_batches do |people|
  People.transaction do
    people.peach(ActiveRecord::Base.connection_config[:pool]) do |person|
      ActiveRecord::Base.connection_pool.with_connection do |conn|
        set_full_name = conn.quote("#{person.first_name} #{person.last_name}")
        conn.execute("#{update_sql} #{set_full_name} where id = #{person.id}")
      end
    end
  end
end

O código acima está longe de ser elegante, mas tem uma performance superior aos anteriores. Em algumas situações, é necessário.

Rollout de funcionalidades

Supondo a nossa migração intermediária, comentada anteriormente, temos o seguinte hook no nosso model fictício:

1
2
3
4
5
6
7
8
class Person < ActiveRecord::Base

  before_save :update_full_name

  def update_full_name
    self.full_name = "#{first_name} #{last_name}"
  end
end

Este código, parece estar correto, porém só irá executar corretamente se existir a coluna no banco. Um passo anterior é programar a adição da coluna no banco em um deploy separado, mas podemos usar uma avaliação condicional para executar o hook somente depois da criação do campo na tabela:

1
  before_save :update_full_name, if: -> { Rollout.enabled? "started_migration", "global" }

O que deve ter chamado a atenção aqui é o “Rollout”. Utilizamos uma estrutura de controle baseada no Redis para ligar ou desligar funcionalidades em diversos contextos (sistema, por cliente, e outros). Um exemplo de como implementar uma estrutura semelhante:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Rollout
  def enabled?(feature, context)
    redis.sismember(feature, context)
  rescue Redis::BaseError
    false
  end

  def enable(feature, context)
    redis.sadd(feature)
  end

  def disable(feature, context)
    redis.srem(feature)
  end

  private
  def redis
    Redis::Namespace.new("rollout", redis: $redis)
  end
end

Utilizar algumas checagens de estado, utilizando Redis, é relativamente barato e garante que possamos trabalhar com o modelo novo e com o modelo antigo, sem precisar fazer um novo deploy. Outro ponto importante é que, caso algum imprevisto aconteça, ainda temos a possibilidade de reverter a alteração com segurança.

Descrição do modelo do Rollout

Os exemplos acima mostram apenas parte do problema. Segue um modelo mais correto e completo, em alguns passos:

Momento Situação Col. Antiga Coluna nova Rollout
Antes da migração apenas coluna antiga lê/escreve não existe
Adicionou coluna coluna nova existe, sem dados lê/escreve existe
Garantir escrita duplicar a escrita para ambas lê/escreve escreve +started
Migra dados coluna migração gradual em execução lê/escreve escreve started
Usa coluna nova lê dados apartir da coluna nova escreve lê/escreve started, +migrated
Limpeza do banco remove a coluna antiga não existe lê/escreve started, migrated
Limpeza do código remove as checagens e flagsda migração não existe lê/escreve -started, -migrated

Agendar manutenção ou não agendar?

Manutenções agendadas podem ter um custo mais barato, mas podem gerar o inconveniente de interrupção do serviço. A interrupção não é interessante para o RD Station, pois afeta a experiência de uso da ferramenta. Preferimos pagar o preço de executar migrações mais lentas e suaves à evitar manutenções agendadas.

Apesar de escolhermos esse modelo, esta não é uma solução para todo tipo de produto. Vale a pena analisar muito bem o custo e a maturidade em que o produto se encontra, alinhando com as expectativas dos seus clientes, para decidir o que mais vale a pena.

Jônatas Davi Paganini

Jônatas Davi Paganini

Full Stack Trainer

Gabriel Mazetto

Gabriel Mazetto

Full Stack Developer

Comentários