Builds distribuídos

Informar um problema Ver código-fonte

Quando você tem uma base de código grande, as cadeias de dependências podem se tornar muito profundas. Até mesmo binários simples podem depender de dezenas de milhares de destinos de compilação. Nessa escala, é impossível concluir um build em um tempo razoável em uma única máquina: nenhum sistema de compilação pode contornar as leis físicas impostas ao hardware de uma máquina. A única maneira de fazer esse trabalho é com um sistema de compilação compatível com builds distribuídos, em que as unidades de trabalho sendo realizadas pelo sistema são distribuídas por um número arbitrário e escalonável de máquinas. Supondo que o trabalho do sistema foi dividido em unidades pequenas o suficiente (vamos falar mais sobre isso adiante), isso nos permitiria concluir qualquer build de qualquer tamanho tão rápido quanto estamos dispostos a pagar. Essa escalabilidade é o segredo que estamos trabalhando para definir um sistema de compilação baseado em artefatos.

Armazenamento em cache remoto

O tipo mais simples de build distribuído é aquele que usa apenas o armazenamento em cache remoto, mostrado na Figura 1.

Compilação distribuída com armazenamento em cache remoto

Figura 1. Um build distribuído que mostra o armazenamento em cache remoto.

Todos os sistemas que executam versões, incluindo estações de trabalho do desenvolvedor e sistemas de integração contínua, compartilham uma referência a um serviço de cache remoto comum. Esse serviço pode ser um sistema de armazenamento rápido e local de curto prazo, como o Redis ou um serviço em nuvem, como o Google Cloud Storage. Sempre que um usuário precisa criar um artefato, seja diretamente ou como uma dependência, o sistema primeiro verifica o cache remoto para ver se esse artefato já existe lá. Nesse caso, ele pode fazer o download do artefato em vez de criá-lo. Caso contrário, o sistema vai criar o artefato e fazer upload do resultado de volta para o cache. Isso significa que dependências de baixo nível que não mudam com muita frequência podem ser criadas uma só vez e compartilhadas entre usuários, em vez de serem recriadas por cada usuário. No Google, muitos artefatos são exibidos de um cache em vez de criados do zero, reduzindo bastante o custo de execução do sistema de compilação.

Para que um sistema de armazenamento em cache remoto funcione, o sistema de compilação precisa garantir que as versões sejam completamente reproduzíveis. Ou seja, para qualquer destino de versão, é necessário determinar o conjunto de entradas para esse destino de modo que o mesmo conjunto de entradas produza exatamente a mesma saída em qualquer máquina. Essa é a única maneira de garantir que os resultados do download de um artefato sejam os mesmos da criação de um artefato. Observe que isso exige que cada artefato no cache seja codificado no destino e em um hash das entradas. Dessa forma, diferentes engenheiros podem fazer modificações diferentes no mesmo destino ao mesmo tempo, e o cache remoto armazenaria todos os artefatos resultantes e os exibiria adequadamente sem conflito.

É claro que, para que haja benefícios de um cache remoto, o download de um artefato precisa ser mais rápido que a criação dele. Isso nem sempre é o caso, especialmente se o servidor de cache estiver longe da máquina que está fazendo o build. A rede e o sistema de compilação do Google são cuidadosamente ajustados para compartilhar rapidamente os resultados das versões.

Execução remota

O armazenamento em cache remoto não é uma versão distribuída verdadeira. Se o cache for perdido ou se você fizer uma alteração de baixo nível que exija que tudo seja recriado, ainda será necessário executar toda a versão localmente na sua máquina. O objetivo real é oferecer suporte à execução remota, em que o trabalho real de fazer a compilação pode ser distribuído por qualquer número de workers. A Figura 2 representa um sistema de execução remota.

Sistema de execução remota

Figura 2. Um sistema de execução remota

A ferramenta de compilação em execução na máquina de cada usuário, em que os usuários são engenheiros humanos ou sistemas de compilação automatizados, envia solicitações para um mestre de compilação central. O mestre do build divide as solicitações nas ações do componente e programa a execução dessas ações em um pool escalonável de workers. Cada worker executa as ações solicitadas com as entradas especificadas pelo usuário e grava os artefatos resultantes. Esses artefatos são compartilhados entre as outras máquinas que executam ações que os exigem até que a saída final possa ser produzida e enviada ao usuário.

A parte mais complicada de implementar esse sistema é gerenciar a comunicação entre os workers, o mestre e a máquina local do usuário. Os workers podem depender de artefatos intermediários produzidos por outros workers, e a saída final precisa ser enviada de volta para a máquina local do usuário. Para fazer isso, podemos nos basear no cache distribuído descrito anteriormente, fazendo com que cada worker grave os resultados e leia as dependências do cache. O mestre impede que os workers continuem até que tudo o que dependam seja concluído. Nesse caso, eles poderão ler as entradas do cache. O produto final também é armazenado em cache, permitindo que a máquina local faça o download dele. Também precisamos de uma maneira separada de exportar as alterações locais na árvore de origem do usuário para que os workers possam aplicá-las antes de criar.

Para que isso funcione, todas as partes dos sistemas de compilação baseados em artefato descritas anteriormente precisam se unir. Os ambientes de build precisam ser totalmente descritivos para ativar os workers sem intervenção humana. Os processos de compilação precisam ser completamente autônomos, porque cada etapa pode ser executada em uma máquina diferente. As saídas precisam ser completamente determinísticas para que cada worker possa confiar nos resultados que recebe de outros workers. Essas garantias são extremamente difíceis de serem fornecidas por um sistema baseado em tarefas, o que torna impossível criar um sistema de execução remota confiável além de um.

Builds distribuídos no Google

Desde 2008, o Google usa um sistema de compilação distribuído que emprega cache e execução remota, como ilustrado na Figura 3.

Sistema de compilação de alto nível

Figura 3. Sistema de compilação distribuído do Google

O cache remoto do Google é chamado de ObjFS. Ele consiste em um back-end que armazena saídas de build em Bigtables distribuídos em toda nossa frota de máquinas de produção e um daemon de front-end do FUSE chamado objfsd, que é executado na máquina de cada desenvolvedor. O daemon do FUSE permite que os engenheiros procurem nas saídas de build como se fossem arquivos normais armazenados na estação de trabalho, mas com o conteúdo do arquivo salvo sob demanda apenas para os poucos arquivos solicitados diretamente pelo usuário. A disponibilização do conteúdo do arquivo sob demanda reduz bastante o uso da rede e do disco. Além disso, o sistema pode criar duas vezes mais rápido do que quando armazenamos toda a saída do build no disco local do desenvolvedor.

O sistema de execução remota do Google é chamado de Forge. Um cliente do Forge no Blaze (o equivalente interno do Bazel) chamado "Distribuidor" envia solicitações para cada ação a um job em execução em nossos data centers chamado de Scheduler. O programador mantém um cache dos resultados da ação, permitindo que ele retorne uma resposta imediatamente se a ação já tiver sido criada por qualquer outro usuário do sistema. Caso contrário, ela coloca a ação em uma fila. Um grande pool de jobs do Executor lê continuamente ações dessa fila, executa-as e armazena os resultados diretamente nos Bigtables ObjFS. Esses resultados estão disponíveis para os executores para ações futuras ou para serem baixados pelo usuário final via objfsd.

O resultado final é um sistema que se adapta para suportar de maneira eficiente todas as versões executadas no Google. E a escala dos builds do Google é realmente enorme: o Google executa milhões de builds executando milhões de casos de teste e produzindo petabytes de saídas de compilação com bilhões de linhas de código-fonte todos os dias. Esse sistema não só permite que nossos engenheiros criem bases de código complexas rapidamente, como também nos permite implementar um grande número de ferramentas e sistemas automatizados que dependem da nossa compilação.