Contexto: Imagina que você teve uma ideia genial, criar um microblog empresarial, onde cada empresa vai poder ter seus colaboradores postando besteira e criando flamewar somente entre eles.
Você bateu na porta de 2 clientes diferentes, clientes grandes e eles adoraram a ideia, mas trouxeram para você a preocupação de algum colaborador postar algo confidencial e essa informação vazar.
Também pediram, para caso o contrato acabe, você garanta que deletou TODOS os dados deles.
Você prometeu para esses 2 clientes que isso seria possível e agora só precisa entregar.
Arquitetura de MultiTenancy e Segurança:
O jeito tradicional de criar multi tenancy, adicionando uma coluna account_id traz junto consigo uma complexidade em segurança e desempenho, como garantir que estou colocando account_id em todas as tabelas que eu realmente preciso? Como garantir que um Dev não esqueceu de adicionar essa coluna nos wheres necessários ? Como garantir índice correto nessa colunas para as queries não ficarem extremamente lentas?
No ultimo Rails World, tivemos 2 talks dedicadas a esses problemas:
Mantendo os dados dos seus clientes separados: https://www.youtube.com/watch?v=5MLT-QP4S74&list=PLHFP2OPUpCeY9IX3Ht727dwu5ZJ2BBbZP&index=17
Implementando índices compostos no Rails: https://www.youtube.com/watch?v=aOD0bZfQvoY&list=PLHFP2OPUpCeY9IX3Ht727dwu5ZJ2BBbZP&index=9
O próprio DHH tem um artigo falando que Multi Tenancy é o que torna aplicações web difíceis: https://world.hey.com/dhh/multi-tenancy-is-what-s-hard-about-scaling-web-services-dd1e0e81
Mas, e se fosse possível programar sua aplicação como se ela não fosse Multi Tenancy, se você não tivesse que se preocupar com todas essas questões?
Simples: Vamos ter 1 banco de dados para cada cliente.
Sim, você leu certo, a ideia por trás de ter segurança por padrão é que cada cliente seu tenha seu próprio banco de dados.
Com isso seu desenvolvedor não tem como simplesmente esquecer um filtro e disponibilizar os dados de um cliente para o outro.
Você também não tem que se preocupar em particionar o seu banco em vários shards separados e garantir indicies complicados quando uma tabela estiver completamente lotada.
Escalar o seu banco de dados? Bom, você vai conseguir escalar verticalmente normalmente, todos os bancos podem viver dentro do mesmo servidor sem aumentar custos, mas também pode escalar horizontalmente se for o caso.
Imagina que você tem um cliente muito, muito grande e ele sozinho deixa os outros clientes mais lentos? Simples, cria um servidor de banco só para ele, não importa, sua aplicação não precisa saber disso.
E o melhor, o Rails já te da hoje todas as ferramentas que você precisa:
——
1) Vamos iniciar o nossa aplicação genial com o bom e velho rails new
rails new enterprise_blog
2) Vamos gerar o nosso modelo com scafffold para facilitar a vida
rails g scaffold post body:text
3) Agora vem a magia, vamos usar a funcionalidade que o rails já dá para definir múltiplos databases:
Vamos separar as nossas migrações em 2, vamos ter os modelos da nossa aplicação e um modelo tenant que vai controlar os nossos diferentes clientes, esse modelo vai ficar um banco específico para ele, com migrações específicas para ele. O migrations_path diz para o rails que as migrações para esse banco ficam em uma pasta separada.
development:
tenancy:
<<: *default
database: storage/tenancy.sqlite3
migrations_paths: db/tenancy
localhost:
<<: *default
database: storage/localhost.sqlite3
localhost_2:
<<: *default
database: storage/localhost_2.sqlite3
4) Agora vamos criar as nossas tabelas principais
rails db:migrate
Pronto, nesse momento você vai reparar que o rails vai rodar a migração de criação da tabela de posts para os 2 bancos (lindo não?)
5) Agora, vamos criar o nosso modelo tenant, que vai ter a configuração do banco de dados e o domínio
rails g model tenant name:string custom_domain:string --database tenancy
Apontando que ele fica no —database tenancy o rails já cria a migração no lugar certo
6) Rodando a migração novamente, vamos ver que somente um tenancy foi criado
rails db:migrate
7) Agora vamos criar os dados dos nossos grandes clientes, o localhost e o seu grande concorrente locahost_2
rails c
=> Tenant.create(name: 'localhost', custom_domain: 'localhost')
=> Tenant.create(name: 'localhost_2', custom_domain: '127.0.0.1')
8) Agora, queremos sempre que a gente acessar localhost os dados do cliente localhost sejam mostrados, e quando a gente acessar o 127.0.0.1, os dados do localhost_2 sejam mostrados, para isso, vamos adicionar um middleware, um código que roda antes de cada requisição no rails:
# lib/middleware/tenant_selector.rb
class TenantSelector
def initialize(app)
@app = app
end
def call(env)
if tenant = Tenant.tenant_from_env(env)
tenant.switch do # aqui vem a magia, a requisição vai continuar dentro do tenant, não vai ser possível acessar o banco de dados de outros clientes
@app.call(env)
end
else
[404, { 'Content-Type' => 'text/html' }, ['Not Found']]
end
end
end
9) Agora um pouco de configuração para fazer o rails usar o nosso middleware e fazer o ActiveRecord::Base saber se conectar com cada banco configurado
No application.rb vamos adicionar
require_relative '../lib/middleware/tenant_selector'
module EnterpriseBlog
class Application < Rails::Application
(...)
config.middleware.use TenantSelector # Se usar o Devise `config.middleware.insert_before Warden::Manager, TenantSelector`
# aqui vem o segredo, para cada banco no database.yml, vamos criar um shard e o nome desse shard vai ser igual ao nome do tenant
config.after_initialize do
ActiveRecord::Base.connects_to(shards: ActiveRecord::Base.configurations.configs_for.select { |c| c.env_name == Rails.env }.reject { |c| c.name == 'tenancy' }.map do |c|
[c.name.to_sym, { writing: c.name.to_sym }]
end.to_h)
end
(...)
end
end
10) E por fim, vamos implementar o nosso método de busca e switch do tenant
class Tenant < TenancyRecord
def self.tenant_from_env(env)
Tenant.find_by(custom_domain: env['SERVER_NAME']) # Aqui recomendo colocar um cache em memória por questões de desempenho
end
def switch
ActiveRecord::Base.connected_to(role: :writing, shard: name.to_sym) do
yield
end
end
end
11) Pronto,
Agora ao acessar o localhost ou 127.0.0.1, você vai ver posts diferentes, com menos de 20 linhas de código.
——————————
Obvio, nem tudo são flores, vou colocar aqui alguns tradeoffs
Atualmente aqui na empresa temos algumas centenas de banco de dados, não tem se mostrado um problema de escala para o rails/postgres gerenciar todas essas conexões, mas não sei se essa arquitetura escala para milhares de clientes, quando eu tiver esse problema eu trago aqui a solução
No nosso contexto, não temos uma feature que o cliente faz um signup e isso gera um database novo, o processo de assinatura de contrato demora alguns dias, o que é tempo suficiente para o nosso time de infra alterar o database.yml e fazer um deploy, temos planos para automatizar essa etapa, mas provavelmente não esse ano (se você pensar como fazer antes, só avisar)
Agregar os dados de todos os clientes para ter métricas gerenciais é um pouco trick e deu trabalho para o nosso time de dados, posso escrever mais sobre isso no futuro
-
Provavelmente você vai querer complicar um pouco e ter um current attribute para o tenant e usar ele como namespace do seu cache
- config.cache_store = :redis_cache_store, { url: ENV['REDIS_CACHE_STORE_URL'], namespace: proc { Current.tenant&.id } }
Você vai precisar fazer a mesma lógica do middleware no sidekiq, por sorte, o sidekiq também da suporte a middlewares
Isso não resolve variáveis globais, cuidado com elas
Provavelmente você vai ter que configurar um pgbouncer antes do necessário para gerenciar as conexões com o banco
Gerar um dump do schema para o rails não ter que ir verificar para cada banco que você tem: https://stackoverflow.com/questions/38778689/how-to-fix-a-slow-implicit-query-on-pg-attribute-table-in-rails
Mas, depois de 3 anos usando essa arquitetura, não temos o que reclamar, conseguimos no nosso dia a dia programar sem se preocupar com isso, no nosso nível de escala não temos problema de desempenho por conta da arquitetura e com certeza não temos as dores de cabeça de ter um tenant_id espalhado por centenas de tabelas no nosso banco de dados
Top comments (4)
Muito bom artigo, parabéns.
De fato, tem trade-offs (como quase tudo). Em muitas situações o simples tenant_id pode atender bem. Mas, pra casos onde o nível de isolamento ou independência pra escalar os tenants precisa ser maior, essa organização pode ser bastante interessante (como tem se mostrado pra sua empresa).
Obrigado por compartilhar!
Valeu por compartilhar, @victorlcampos !
Mais um da série "como complicar 100x o desenvolvimento de um sistema usando a desculpa de, eventualmente ,não esquecer um índice". 👏👏👏👏👏
Opa? Qual a parte complicada? Estou aberto para debates 🙂