DEV Community

Victor Campos
Victor Campos

Posted on

Mantendo seus dados segregados por Padrão

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:

  1. Mantendo os dados dos seus clientes separados: https://www.youtube.com/watch?v=5MLT-QP4S74&list=PLHFP2OPUpCeY9IX3Ht727dwu5ZJ2BBbZP&index=17

  2. 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
Enter fullscreen mode Exit fullscreen mode

2) Vamos gerar o nosso modelo com scafffold para facilitar a vida

rails g scaffold post body:text
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

4) Agora vamos criar as nossas tabelas principais

rails db:migrate
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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')
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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

  1. 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

  2. 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)

  3. 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

  4. 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 } }
  5. Você vai precisar fazer a mesma lógica do middleware no sidekiq, por sorte, o sidekiq também da suporte a middlewares

  6. Isso não resolve variáveis globais, cuidado com elas

  7. Provavelmente você vai ter que configurar um pgbouncer antes do necessário para gerenciar as conexões com o banco

  8. 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 (3)

Collapse
 
ff00 profile image
F

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!

Collapse
 
klawdyo profile image
Claudio Medeiros

Mais um da série "como complicar 100x o desenvolvimento de um sistema usando a desculpa de, eventualmente ,não esquecer um índice". 👏👏👏👏👏

Collapse
 
victorlcampos profile image
Victor Campos

Opa? Qual a parte complicada? Estou aberto para debates 🙂