Skip to content
Reduzindo um Postgres de 130 GB a uma cópia local com Greenmask: o que funcionou e o que quebrou

Reduzindo um Postgres de 130 GB a uma cópia local com Greenmask: o que funcionou e o que quebrou

Dump completo era grande demais, seed era artificial demais e os dados reais tinham PII. Veja como montei um subconjunto pequeno, consistente nas foreign keys e mascarado do banco de uma app Rails com Greenmask, e os seis problemas que encontrei no caminho, com a correção de cada um.

Também disponível em English

Compartilhar

O problema

Nosso banco de staging passou de 130 GB. Só duas tabelas, uma de pontos de GPS e outra de pontos de rota, tinham 70 GB e 52 GB. O time precisava de dados realistas localmente, mas:

  • Um pg_dump completo estava fora de questão. Horas para gerar, horas para restaurar e mais disco do que um notebook tem.
  • Seeds não refletiam a realidade. Bugs que só aparecem com dados reais (históricos longos, coordenadas estranhas, usuários em muitos grupos) nunca reproduziam localmente.
  • O pg_dump filtra tabelas, não linhas. --table e --exclude-table-data decidem quais tabelas vêm, mas não quais linhas. Pegue 1% de orders e os order_items, payments e users ligados a elas não vêm juntos, então o restore falha nas foreign keys.
  • Staging tinha dados pessoais. Nomes, e-mails, push tokens, mensagens privadas. Nada disso deveria parar num notebook.

O que eu queria: escolher alguns usuários e trazer tudo que pertence a eles, mais as tabelas de referência que a app precisa, com todas as foreign keys íntegras e todos os identificadores mascarados.

A abordagem: subsetting com Greenmask

Isso se chama database subsetting. O Greenmask faz isso para PostgreSQL: você dá uma condição a uma tabela, e ele percorre o grafo de foreign keys para que toda tabela que a referencia mantenha só as linhas correspondentes. Ele gera um dump compatível com pg_restore e mascara os dados no caminho.

A raiz é users, filtrada por uma variável de ambiente:

dump:
  pg_dump_options:
    dbname: "${GM_SOURCE_URL}"
    jobs: 1
  transformation:
    - schema: public
      name: users
      subset_conds:
        - "public.users.id IN (${GM_USERS})"

GM_USERS pode ser uma lista (42,108,311) ou uma subquery, como "um usuário e os amigos dele".

Associações Rails sem FK no banco

O Greenmask só segue foreign keys reais. Numa app Rails, muitas associações nunca ganharam foreign_key: true, então essas tabelas viriam inteiras. Declare-as como referências virtuais:

  virtual_references:
    - schema: public
      name: trips
      references:
        - {schema: public, name: users, columns: [{name: user_id}]}
    - schema: public
      name: track_points
      references:
        - {schema: public, name: trips, columns: [{name: trip_id}]}

Só declarei assim os vínculos de posse (a linha pertence ao pai). Vínculos secundários, como uma viagem apontando para o barco de um amigo, ficaram de fora. Declará-los descartaria a viagem do próprio usuário semente. Em vez disso, esses vínculos são anulados depois do restore se apontarem para fora do subconjunto.

Mascaramento

Os transformers reescrevem os dados durante o dump. Todo mundo vira user<ID>@example.test com uma senha de desenvolvimento conhecida, então dá para logar localmente como qualquer usuário semente:

      transformers:
        - name: TemplateRecord
          params:
            columns: [email, uid, push_token]
            template: >-
              {{- $email := printf "user%v@example.test" (.GetColumnValue "id") -}}
              {{- .SetColumnValue "email" $email -}}
              {{- .SetColumnValue "uid" $email -}}
              {{- .SetColumnValue "push_token" null -}}
        - name: HashedPassword
          resolve_env: true
          params:
            column: encrypted_password
            password: "${GM_DEV_PASSWORD}"

Logs, sessões, push tokens e tabelas parecidas vão para exclude-table-data: o schema vem, as linhas não.

O que quebrou e como resolvi

A configuração acima é cerca de um décimo do trabalho. A maior parte do tempo foi com os problemas abaixo. Eles apareceram no Greenmask 0.2.25; confira se versões mais novas ainda os têm.

1. Linhas com dono NULL escapam do filtro

O Greenmask trata uma FK anulável como "pai é NULL ou pai está no subconjunto". Uma viagem com user_id IS NULL passa, e com ela milhões de pontos de GPS. Correção: uma condição explícita em cada tabela que pertence a um usuário.

    - {schema: public, name: posts, subset_conds: ["public.posts.user_id IS NOT NULL"]}

2. Uma FK autorreferente derruba o planner

Uma tabela com FK para ela mesma (por exemplo original_id) que também chega em users por dois caminhos fez o dump entrar em panic com get one group cycle group is not allowed for multy cycles. Não existe opção de configuração para ignorar uma FK.

A solução: o script remove essa FK na origem descartável antes do dump, recoloca como NOT VALID logo depois (mesmo se o dump falhar, via trap) e recria no destino. Ele se recusa a rodar sem você confirmar o host de origem. Use só contra staging ou um snapshot restaurado, nunca produção.

3. Referências polimórficas geram SQL inválido

polymorphic_exprs existe para colunas no estilo commentable_type/commentable_id. Quando a tabela alvo também é alcançada por outro caminho, o Greenmask gera argument of AND must be type boolean. Tirei isso da configuração e tratei os polimorfismos depois do restore: cada valor de tipo vira o nome da tabela (convenção Rails, ReportPin para report_pins), e linhas cujo alvo não existe são apagadas ou anuladas.

4. Linhas órfãs passam

Mesmo com tudo declarado, algumas linhas chegavam com o pai já filtrado. Isso quebrou o restore numa FK real (likes.post_id). A causa é como o Greenmask monta subqueries aninhadas quando uma tabela chega no mesmo pai por dois caminhos.

A correção que deixou o processo confiável: restaurar por seções e reparar no meio.

greenmask --config greenmask.yml restore latest --section pre-data   # só as tabelas
greenmask --config greenmask.yml restore latest --section data       # linhas, ainda sem PKs e FKs
psql "$TARGET" -f fk_rules.sql -f repair.sql                         # deixa consistente
greenmask --config greenmask.yml restore latest --section post-data  # PKs, índices, FKs

O repair.sql aplica uma regra até nada mudar: uma linha fica só se toda referência não nula aponta para uma linha que ficou. Ele lê as FKs reais do catálogo da origem, soma as referências virtuais e polimórficas, e apaga ou anula o que não passa. Quando a seção post-data cria as FKs, elas validam.

5. A origem ficou sem espaço temporário

A tabela de 70 GB falhou com could not write to file "base/pgsql_tmp/...": No space left on device, erro do banco de origem, não da máquina que fazia o dump. A query gerada pelo Greenmask fazia join na tabela inteira e despejava em arquivos temporários.

A correção foi dar às tabelas grandes uma condição que o planner consegue resolver pelo índice da FK:

    - schema: public
      name: track_points
      subset_conds:
        - "public.track_points.trip_id IN (SELECT t.id FROM public.trips t WHERE t.user_id IN (${GM_USERS}))"

Confira com EXPLAIN antes de rodar: o esperado é um index scan na tabela grande, não um sequential scan.

Quando uma execução morre, a query dela pode continuar rodando no servidor, segurando locks e espaço temporário. Antes de tentar de novo, procure sessões da sua máquina em pg_stat_activity e encerre-as.

6. Um NOTICE aborta o restore

Uma linha tinha longitude -227. No restore, uma coluna geography gerada corrigiu o valor, e o PostGIS enviou um NOTICE. O COPY do Greenmask não espera mensagens no meio do fluxo, e o restore falhou com unknown message ... Coordinate values were coerced. Uma linha no destino resolve:

ALTER DATABASE dev_copy SET client_min_messages = warning;

Armadilhas menores

  • Parâmetros de transformer não expandem variáveis de ambiente sem resolve_env: true. Sem isso, a senha de todo usuário virou o bcrypt da string literal ${GM_DEV_PASSWORD}.
  • No TemplateRecord, nil grava string vazia. Use a função null do template para gravar NULL.
  • A spatial_ref_sys do PostGIS vai inteira no dump e colide com as linhas que o CREATE EXTENSION insere. Exclua os dados dela.
  • A máquina importa. Um bastion com 450 MB de RAM teve o dump morto por falta de memória. Swap, jobs: 1 ou uma instância maior resolveram.

O pipeline

Um script roda tudo de ponta a ponta:

  1. Checagens: versão do pg_dump, destino vazio e quantidade de usuários semente.
  2. Lê as FKs da origem para arquivos de regras e remove a FK autorreferente da origem descartável.
  3. greenmask dump, depois recoloca a FK.
  4. Restaura pre-data e data, roda o repair.sql, restaura post-data e recria a FK autorreferente.
  5. Roda o verify.sql. Ele falha se houver referência pendente, tabela excluída com linhas ou e-mail que não termine em @example.test.
  6. Apaga os arquivos do dump. Eles ainda guardam as linhas que o reparo removeu.

A partir daí, um pg_dump -Fc da cópia local pequena gera um único arquivo que qualquer pessoa do time restaura com pg_restore.

Resultado

Alguns usuários semente geram um banco que cabe num notebook. As tabelas de 70 GB e 52 GB ficam só com as linhas desses usuários, todas as foreign keys valem e qualquer pessoa loga como user<ID>@example.test. O grosso do que sobra são as tabelas de referência, que vêm inteiras; filtre-as por bounding box se ficarem grandes demais.

Lições

  • Subsetting é um problema de grafo. Vale gastar tempo mapeando as associações, principalmente as que o Rails conhece e o Postgres não.
  • Não confie numa única ferramenta para consistência. Um passo de reparo antes de criar as FKs, e um de verificação depois, transformaram um processo frágil em algo repetível.
  • Mascaramento precisa de teste. Duas regras minhas não faziam nada, em silêncio, até a verificação pegar.
  • Localização continua sendo dado pessoal. Os nomes foram mascarados, os trajetos de GPS não. Treat a cópia como confidencial.

Comentários

Faça login com Google ou GitHub para comentar.