Quando você tem uma base de código grande, as cadeias de dependências podem se tornar 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 de tempo em uma única máquina: nenhum sistema de build pode contornar as leis fundamentais da física impostas no hardware de uma máquina. A única maneira de fazer isso é com um sistema de build que ofereça suporte a builds distribuídos, em que as unidades de trabalho feitas pelo sistema sejam distribuídas em um número arbitrário e escalonável de máquinas. Supondo que tenhamos dividido o trabalho do sistema em unidades pequenas (mais detalhes sobre isso mais tarde), isso nos permitiria concluir qualquer build de qualquer tamanho o mais rápido possível. Essa escalonabilidade é o segredo de que estamos trabalhando para 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 armazenamento em cache remoto, mostrado na Figura 1.
Figura 1. Uma versão distribuída mostrando armazenamento em cache remoto
Todos os sistemas que executam 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 local e rápido de curto prazo, 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 cria o artefato e faz 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 precisar ser recriadas por cada usuário. No Google, muitos artefatos são disponibilizados a partir de um cache em vez de criados do zero, o que reduz significativamente o custo de execução do 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, é 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 que os resultados de criá-lo. Isso exige que cada artefato no cache seja codificado com o destino e um hash das entradas. Dessa forma, diferentes engenheiros podem fazer modificações diferentes no mesmo destino ao mesmo tempo, e o cache remoto armazena todos os artefatos resultantes e os serve adequadamente sem conflito.
Obviamente, para que haja 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 ajustados com cuidado para compartilhar rapidamente os resultados do build.
Execução remota
O armazenamento em cache remoto não é um build distribuído de verdade. 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 realizar todo o build localmente na máquina. O objetivo real é oferecer suporte à execução remota, em que o trabalho real de fazer o build pode ser distribuído para 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 (em que os usuários são engenheiros humanos ou sistemas de build automatizados) envia solicitações para um mestre de build central. O mestre de build divide as solicitações nas ações dos componentes 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 criar em cima do 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 prossigam até que tudo o que 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 dele. 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 aplicá-las antes da criação.
Para que isso funcione, todas as partes dos sistemas de build baseadas em artefatos descritas anteriormente precisam se unir. Os ambientes de build precisam ser completamente autodescritivos para que possamos iniciar workers sem intervenção humana. Os processos de criação precisam ser completamente autossuficientes, 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 quase impossível criar um sistema de execução remota confiável em cima dele.
Builds distribuídos no Google
Desde 2008, o Google usa um sistema de compilação distribuída que emprega armazenamento em cache e execução remota, conforme ilustrado na Figura 3.
Figura 3. Sistema de build distribuído do Google
O cache remoto do Google é chamado de ObjFS. Ele consiste em um back-end que armazena as saídas de build em Bigtables distribuídos em toda a frota de máquinas de produção e um daemon FUSE de front-end chamado objfsd que é executado na máquina de cada 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 só sendo transferido por demanda para os poucos arquivos que são solicitados diretamente pelo usuário. O fornecimento de conteúdo de arquivo sob demanda reduz bastante o uso da rede e do disco, e o sistema consegue criar duas vezes mais rápido em comparação com quando armazenamos 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 Distribuidor envia solicitações para cada ação a um job em execução nos data centers chamado Programador. O Programador 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 qualquer outro usuário do sistema. Caso contrário, a ação é colocada em uma fila. Um grande conjunto de jobs do executor lê continuamente as ações dessa fila, as executa e armazena os resultados diretamente nas Bigtables do ObjFS. Esses resultados ficam disponíveis para os executores para ações futuras ou para download pelo usuário final pelo objfsd.
O resultado final é um sistema que é dimensionado para oferecer suporte eficiente a todos os builds realizados no Google. 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 resultados com bilhões de linhas de código-fonte todos os dias. Esse sistema não apenas permite que nossos engenheiros criem bases de código complexas rapidamente, mas também permite implementar um grande número de ferramentas e sistemas automatizados que dependem do nosso build.