
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.
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_dumpcompleto 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_dumpfiltra tabelas, não linhas.--tablee--exclude-table-datadecidem quais tabelas vêm, mas não quais linhas. Pegue 1% deorderse osorder_items,paymentseusersligados 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,nilgrava string vazia. Use a funçãonulldo template para gravar NULL. - A
spatial_ref_sysdo PostGIS vai inteira no dump e colide com as linhas que oCREATE EXTENSIONinsere. 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: 1ou uma instância maior resolveram.
O pipeline
Um script roda tudo de ponta a ponta:
- Checagens: versão do
pg_dump, destino vazio e quantidade de usuários semente. - Lê as FKs da origem para arquivos de regras e remove a FK autorreferente da origem descartável.
greenmask dump, depois recoloca a FK.- Restaura pre-data e data, roda o
repair.sql, restaura post-data e recria a FK autorreferente. - 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. - 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.