Suas reconstruções levam uma eternidade com milhões de uploads? Tente adicionar este template!

parallel-fs-ops.template.yml (2,8 KB)

Vamos falar sobre um problema de escala que se torna dolorosamente óbvio assim que um site do Discourse acumula uma grande biblioteca de uploads.

O que antes levava minutos para o comando chown ser executado em um diretório de uploads enorme agora leva segundos!

Contexto

Reconstruções do Discourse podem executar operações recursivas como:

chown -R ...
chmod -R ...

Mais especificamente, esta linha em templates/web.template.yml:

- chown -R discourse:www-data /shared/log/rails /shared/uploads /shared/backups /shared/tmp

Estes comandos percorrem o sistema de arquivos de forma serial, um por um.

Isso é perfeitamente razoável para uma instalação pequena. Mas quando shared/uploads contém centenas de milhares — ou milhões — de arquivos, operações recursivas de propriedade e permissão podem dominar o tempo de implantação. Pode haver capacidade disponível de CPU, armazenamento e rede, mas um único processo percorre toda a árvore um inode de cada vez.

Para comunidades com muitos uploads, o resultado pode ser:

  • Reconstruções extremamente longas
  • Janelas de manutenção mais longas
  • Atrasos em implantações e atualizações de segurança
  • Pobre utilização de armazenamento rápido ou distribuído
  • Uma implantação parecendo travada enquanto processa uma árvore de arquivos enorme
  • Desempenho particularmente ruim em NFS, JuiceFS, CephFS e outros sistemas de arquivos remotos

A parte frustrante é que muitos desses arquivos são independentes. Suas permissões podem ser processadas em paralelo.

A Solução: Modelo de Operações Paralelas de Sistema de Arquivos

Criei um modelo de pups que substitui transparentemente as operações recursivas de chmod e chown por pipelines paralelos de find e xargs.

Os wrappers se anunciam sempre que interceptam uma operação recursiva:

echo "[parallel-fs-ops] chmod -R override active: $*" >&2

e:

echo "[parallel-fs-ops] chown -R override active: $*" >&2

Esse prefixo entre colchetes torna a otimização fácil de identificar em um log de implantação.

Como uma implantação parece

No início da implantação, o modelo confirma quais binários lidarão com as operações de sistema de arquivos posteriores:

[parallel-fs-ops] chmod -> /usr/local/bin/chmod
[parallel-fs-ops] chown -> /usr/local/bin/chown

Quando um modelo upstream executa posteriormente uma mudança de permissão recursiva, a saída da implantação inclui uma linha semelhante a:

[parallel-fs-ops] chmod -R override active: -R 0755 /var/www/discourse/public

Uma mudança de propriedade recursiva produz:

[parallel-fs-ops] chown -R override active: -R discourse:www-data /shared/log/rails /shared/uploads /shared/backups /shared/tmp

Para uma instalação com muitos uploads, você pode ver algo semelhante a:

[parallel-fs-ops] chown -R override active: -R discourse:www-data /shared/log/rails /shared/uploads /shared/backups /shared/tmp

Os caminhos exatos e os argumentos dependem dos modelos em uso, mas a parte importante é o marcador visível:

[parallel-fs-ops]

Sem o modelo, a implantação pode parecer pausada por um longo tempo durante uma operação recursiva de sistema de arquivos. Com o modelo, o log informa que:

  1. O wrapper foi instalado corretamente.
  2. Uma operação recursiva foi detectada.
  3. A implementação paralela está ativa.
  4. Os argumentos originais sendo processados são visíveis.

Isso é particularmente valioso durante a solução de problemas, pois distingue uma travessia paralela lenta do sistema de arquivos de uma construção travada.

Após a conclusão da operação, a implantação continua com sua saída normal do pups. O próprio wrapper não imprime uma linha por arquivo, portanto, mesmo uma árvore contendo milhões de uploads não inunda o log de implantação.

O modelo

run:
  - file:
      path: /usr/local/bin/chmod
      chmod: "+x"
      contents: |
        #!/bin/bash
        if [[ "$*" =~ (^|[[:space:]])-R([[:space:]]|$) ]]; then
          echo "[parallel-fs-ops] chmod -R override active: $*" >&2
          args=()
          for arg in "$@"; do
            [[ "$arg" != "-R" ]] && args+=("$arg")
          done
          mode="${args[0]}"
          targets=("${args[@]:1}")
          [[ ${#targets[@]} -eq 0 ]] && targets=(".")
          find "${targets[@]}" -print0 |
            xargs -0 -n 32 -P 128 /bin/chmod "$mode"
        else
          exec /bin/chmod "$@"
        fi

  - file:
      path: /usr/local/bin/chown
      chmod: "+x"
      contents: |
        #!/bin/bash
        if [[ "$*" =~ (^|[[:space:]])-R([[:space:]]|$) ]]; then
          echo "[parallel-fs-ops] chown -R override active: $*" >&2
          args=()
          for arg in "$@"; do
            [[ "$arg" != "-R" ]] && args+=("$arg")
          done
          owner="${args[0]}"
          targets=("${args[@]:1}")
          [[ ${#targets[@]} -eq 0 ]] && targets=(".")
          find "${targets[@]}" -print0 |
            xargs -0 -n 32 -P 128 /bin/chown "$owner"
        else
          exec /bin/chown "$@"
        fi

  - exec:
      cmd: |
        echo "[parallel-fs-ops] chmod -> $(command -v chmod)"
        echo "[parallel-fs-ops] chown -> $(command -v chown)"

O modelo instala wrappers em /usr/local/bin, que normalmente aparece antes de /bin no PATH.

Quando uma operação normal, não recursiva, é solicitada, o wrapper delega diretamente para a utilidade padrão:

exec /bin/chmod "$@"

Quando -R está presente, ele remove a flag recursiva, enumera os alvos com segurança usando delimitadores nulos e processa lotes em paralelo:

find "${targets[@]}" -print0 |
  xargs -0 -n 32 -P 128 /bin/chmod "$mode"

Isso também funciona quando o pups invoca comandos através de /bin/sh. O shebang Bash do wrapper é respeitado quando o executável é iniciado, mesmo que o shell chamador seja Dash.

Por que isso é mais importante quando você tem muitos uploads

Comunidades com muitos uploads são exatamente onde o comportamento da implantação precisa escalar de forma graciosa.

Um fórum de longa duração pode conter:

  • Imagens incorporadas em anos de publicações
  • Avatares e fundos de perfil
  • Variantes de imagem originais e otimizadas
  • Anexos de vídeo e áudio
  • Documentos e arquivos compactados
  • Uploads seguros
  • Mídia gerenciada por plugins
  • Árvores de upload de multisite

A quantidade de código da aplicação pode permanecer relativamente estável, enquanto o número de objetos de sistema de arquivos enviados continua a crescer. A travessia do sistema de arquivos — e não a compilação ou a criação de contêineres — pode eventualmente se tornar o custo dominante da implantação.

Este é um problema de escala incomum: quanto mais bem-sucedida e rica em conteúdo a comunidade se torna, mais caro o trabalho operacional de rotina pode se tornar.

Por que um modelo é necessário

Alterar o .bashrc ou definir BASH_ENV não resolve isso de forma confiável. O pups executa comandos run através de /bin/sh, e o Dash nem carrega a configuração do Bash nem entende funções específicas do Bash.

Um modelo fornece uma maneira repetível de instalar os wrappers cedo o suficiente para que as operações recursivas subsequentes — incluindo aquelas de modelos upstream — sejam resolvidas através da implementação paralela:

templates:
  - "templates/postgres.template.yml"
  - "templates/redis.template.yml"
  - "templates/web.template.yml"
  - "containers/parallel-fs-ops.template.yml"

Opções Configuráveis

Opções de processamento paralelo

O modelo usa:

find "${targets[@]}" -print0 |
  xargs -0 -n 32 -P 128 /bin/chmod "$mode"

Os parâmetros relevantes do xargs são:

Opção Propósito
-0 Lê caminhos delimitados por nulo produzidos por find -print0. Isso lida com segurança com nomes de arquivos contendo espaços, aspas, abas ou quebras de linha.
-n 32 Passa no máximo 32 caminhos para cada invocação de chmod ou chown. Este é o tamanho do lote.
-P 128 Permite que até 128 processos de chmod ou chown sejam executados em paralelo. Este é o nível de paralelismo.

Juntos, -n 32 -P 128 significam que até 128 processos podem ser executados simultaneamente, com cada processo processando um lote de até 32 caminhos. Aproximadamente 4.096 caminhos podem, portanto, estar ativamente distribuídos entre lotes de comandos de uma só vez.

Escolhendo -n

-n controla quanta trabalho é atribuído a cada comando:

  • Valores menores fornecem uma distribuição de trabalho mais fina, mas iniciam mais processos.
  • Valores maiores reduzem a sobrecarga de lançamento de processos, mas criam lotes maiores e menos uniformemente distribuídos.
  • -n 1 executa um comando chmod ou chown por caminho.
  • -n 32 é um ponto de partida razoável para equilibrar lote e paralelismo.
  • Valores muito grandes podem reduzir a eficácia de -P porque menos lotes totais são criados.

Escolhendo -P

-P controla quantos comandos podem ser executados ao mesmo tempo:

  • Valores menores reduzem a carga na CPU e no sistema de arquivos.
  • Valores maiores podem melhorar o desempenho em armazenamento rápido ou distribuído.
  • Paralelismo excessivo pode sobrecarregar discos, saturar um servidor de metadados ou piorar o desempenho.
  • -P 1 é efetivamente execução serial.
  • -P 8 ou -P 16 é um ponto de partida conservador.
  • -P 32 pode ser adequado para armazenamento baseado em SSD rápido.
  • -P 128 deve ser usado apenas quando o sistema de arquivos e o host podem sustentar essa concorrência.

Os melhores valores dependem da latência do sistema de arquivos, do desempenho de metadados, da capacidade da CPU e do número de arquivos. Idealmente, ambos devem ser configuráveis e benchmarkados para a instalação específica.

Paralelismo excessivo pode sobrecarregar um sistema de arquivos, saturar servidores de metadados ou degradar o desempenho da implantação. O tamanho do lote e a concorrência devem, portanto, ser configuráveis.

Este modelo é uma solução prática, mas a proposta maior é mais ampla:

O Discourse poderia suportar oficialmente paralelismo configurável para grandes operações recursivas de sistema de arquivos durante implantações?

Uma implementação upstream poderia:

  • Paralelizar apenas árvores de diretórios grandes conhecidas
  • Evitar percorrer árvores de uploads inalteradas desnecessariamente
  • Tornar a concorrência configurável
  • Detectar sistemas de arquivos locais versus baseados em rede
  • Preservar a semântica completa dos argumentos de chmod e chown
  • Emitir progresso periódico para árvores muito grandes
  • Registrar tempos para que administradores possam identificar gargalos de implantação

Importante advertência

O wrapper acima está focado nas formas recursivas de comando usadas pelo nosso processo de build. Ele não é uma reimplementação completa de todas as possíveis combinações de opções de chmod ou chown.

Ele deve ser testado contra os comandos exatos gerados pelos modelos de um site antes do uso em produção. Operadores devem começar com paralelismo conservador e medir o efeito em seu armazenamento.

Mas o problema subjacente é real: operações recursivas de metadados serializadas não escalam bem quando uma comunidade acumulou uma árvore de uploads massiva.

Boa sorte, e agradeço qualquer comentário ou sugestão (mesmo que eu tenha duplicado os esforços de outra pessoa, apreciaria ponteiros para isso também)!

Abraços!

2 curtidas