Quando você tem uma base de código grande, as cadeias de dependências podem ficar muito profundas. Mesmo binários simples podem depender de dezenas de milhares de destinos de build. Nessa escala, é simplesmente impossível concluir um build em um período razoável em uma única máquina: nenhum sistema de build pode contornar as leis fundamentais da física impostas ao hardware de uma máquina. A única maneira de fazer isso funcionar é com um sistema de build que ofereça suporte a builds distribuídos, em que as unidades de trabalho realizadas pelo sistema são distribuídas por um número arbitrário e escalonável de máquinas. Supondo que tenhamos dividido o trabalho do sistema em unidades pequenas o suficiente (mais sobre isso depois), isso nos permitiria concluir qualquer build de qualquer tamanho tão rápido quanto estamos dispostos a pagar. Essa escalonabilidade é o objetivo que buscamos ao definir um sistema de build baseado em artefatos.
Armazenamento em cache remoto
O tipo mais simples de build distribuído é aquele que usa apenas o cache remoto, mostrado na Figura 1.
Figura 1. Um build distribuído mostrando o armazenamento em cache remoto
Todos os sistemas que realizam builds, incluindo estações de trabalho de desenvolvedores 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 de curto prazo rápido e local, como o Redis, ou um serviço de 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 com o cache remoto 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 vez e compartilhadas entre os usuários, em vez de serem recriadas por cada um deles. No Google, muitos artefatos são veiculados de um cache em vez de serem criados do zero, reduzindo muito o custo de execução do nosso sistema de build.
Para que um sistema de cache remoto funcione, o sistema de build precisa garantir que os builds sejam totalmente reproduzíveis. Ou seja, para qualquer destino de build, é preciso determinar o conjunto de entradas para esse destino de forma 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 dele. Isso exige que cada artefato no cache seja identificado com base no destino e em um hash das entradas. Assim, diferentes engenheiros podem fazer modificações diferentes no mesmo destino ao mesmo tempo, e o cache remoto armazena todos os artefatos resultantes e os fornece adequadamente sem conflitos.
É claro que, para haver algum benefício de um cache remoto, o download de um artefato precisa ser mais rápido do que a criação dele. Isso nem sempre acontece, principalmente se o servidor de cache estiver longe da máquina que está fazendo o build. A rede e o sistema de build do Google são cuidadosamente ajustados para compartilhar rapidamente os resultados do build.
Execução remota
O cache remoto não é uma versão distribuída verdadeira. Se o cache for perdido ou se você fizer uma mudança de baixo nível que exija a reconstrução de tudo, ainda será necessário executar toda a build localmente na sua máquina. O objetivo real é oferecer suporte à execução remota, em que o trabalho real de fazer o build pode ser distribuído por qualquer número de workers. A Figura 2 mostra um sistema de execução remota.
Figura 2. Um sistema de execução remota
A ferramenta de build em execução na máquina de cada usuário (engenheiros humanos ou sistemas de build automatizados) envia solicitações a um mestre de build central. O build master divide as solicitações em ações de componentes e agenda 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 difícil de implementar um sistema assim é gerenciar a comunicação entre os trabalhadores, 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 criar um cache distribuído, como descrito anteriormente, em que cada worker grava os resultados e lê as dependências do cache. O master impede que os workers continuem até que tudo de que eles dependem 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. Também precisamos de uma maneira separada de exportar as mudanças locais na árvore de origem do usuário para que os trabalhadores possam aplicar essas mudanças antes de criar.
Para que isso funcione, todas as partes dos sistemas de build baseados em artefatos descritos anteriormente precisam ser reunidas. Os ambientes de build precisam ser completamente autodescritivos para que possamos ativar workers sem intervenção humana. Os processos de build precisam ser completamente independentes, porque cada etapa pode ser executada em uma máquina diferente. As saídas precisam ser completamente deterministas para que cada worker possa confiar nos resultados recebidos de outros workers. Essas garantias são extremamente difíceis de serem fornecidas por um sistema baseado em tarefas, o que torna quase impossível criar um sistema de execução remota confiável com base em um deles.
Compilações distribuídas no Google
Desde 2008, o Google usa um sistema de build distribuído que emprega cache e execução remotos, conforme ilustrado na Figura 3.
Figura 3. Sistema de build distribuído do Google
O cache remoto do Google se chama ObjFS. Ele consiste em um back-end que armazena saídas de build em Bigtables distribuídas por toda a nossa frota de máquinas de produção e um daemon FUSE de front-end chamado objfsd que é executado em cada máquina do desenvolvedor. O daemon FUSE permite que os engenheiros naveguem pelas saídas de build como se fossem arquivos normais armazenados na estação de trabalho, mas com o conteúdo do arquivo baixado sob demanda apenas para os poucos arquivos solicitados diretamente pelo usuário. Servir o conteúdo do arquivo sob demanda reduz muito o uso da rede e do disco, e o sistema consegue criar duas vezes mais rápido em comparação com o armazenamento de toda a saída de build no disco local do desenvolvedor.
O sistema de execução remota do Google é chamado de Forge. Um cliente do Forge no Blaze (equivalente interno do Bazel) chamado Distributor envia solicitações para cada ação a um job em execução nos nossos data centers chamado Scheduler. O Scheduler mantém um cache de resultados de ações, permitindo que ele retorne uma resposta imediatamente se a ação já tiver sido criada por 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 e armazena os resultados diretamente nas Bigtables do ObjFS. Esses resultados ficam disponíveis para os executores em ações futuras ou podem ser baixados pelo usuário final via objfsd.
O resultado final é um sistema que pode ser escalonado para oferecer suporte eficiente a todos os builds realizados no Google. E a escala das builds do Google é realmente enorme: o Google executa milhões de builds, milhões de casos de teste e produz petabytes de saídas de build de bilhões de linhas de código-fonte todos os dias. Além de permitir que nossos engenheiros criem bases de código complexas rapidamente, esse sistema também nos permite implementar um grande número de ferramentas e sistemas automatizados que dependem da nossa build.