Builds distribuídos

Quando você tem uma grande base de código, as cadeias de dependências podem se tornar muito profundas. Até os binários simples podem depender de dezenas de milhares de destinos de compilação. Nessa escala, é simplesmente impossível concluir um build em uma quantidade razoável de tempo em uma única máquina: nenhum sistema de build consegue 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 compilação compatível com versões distribuídas, 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 o trabalho do sistema tenha sido dividido em unidades pequenas o suficiente (falaremos mais sobre isso posteriormente), isso nos permitiria concluir qualquer build de qualquer tamanho o mais rápido que estivermos dispostos a pagar. 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 aproveita apenas o armazenamento em cache remoto, mostrado na Figura 1.

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

Figura 1. Uma versão distribuída mostrando armazenamento em cache remoto

Todo sistema que executa builds, incluindo estações de trabalho do desenvolvedor e sistemas de integração contínua, compartilha 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 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 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 próprio 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, reduzindo muito o custo de execução do nosso sistema de build.

Para que um sistema de armazenamento em cache remoto funcione, o sistema de build precisa garantir que as versões sejam completamente reproduzíveis. Ou seja, para qualquer destino de compilação, precisa ser possível 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 dele por conta própria. Observe que isso exige que cada artefato no cache seja codificado no destino e em um hash das entradas. Dessa forma, engenheiros diferentes podem fazer modificações diferentes no mesmo destino ao mesmo tempo, e o cache remoto armazena todos os artefatos resultantes e os veicula de maneira adequada 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, especialmente se o servidor de cache estiver longe da máquina que faz a compilação. A rede e o sistema de criação do Google são cuidadosamente ajustados para permitir o compartilhamento rápido dos 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 alteração de baixo nível que exija reconstrução de tudo, ainda será necessário executar toda a compilação localmente na máquina. O verdadeiro objetivo é oferecer suporte à execução remota, na qual o trabalho real de fazer a compilação pode ser espalhado por qualquer número de workers. A Figura 2 ilustra 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 de compilação divide as solicitações em ações 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 precisam deles 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 à máquina local do usuário. Para isso, podemos criar com base no cache distribuído descrito anteriormente, fazendo com que cada worker grave os resultados e leia as dependências dele no cache. O mestre impede que os workers prossigam até que tudo de 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 um meio separado de exportar as mudanças locais na árvore de origem do usuário para que os workers possam aplicar essas alterações antes da criação.

Para que isso funcione, todas as partes dos sistemas de build baseados em artefatos descritos anteriormente precisam se unir. Os ambientes de build precisam ser completamente autodescritivos para que possamos ativar 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 deterministas para que cada worker confie nos resultados que recebe de outros. Essas garantias são extremamente difíceis de fornecer para um sistema baseado em tarefas, o que torna quase impossível criar um sistema de execução remota confiável sobre um.

Builds distribuídos no Google

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

Sistema de build de alto nível

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 saídas de compilação em Bigtables distribuídos por toda a nossa frota de máquinas de produção e um daemon do FUSE de front-end chamado objfsd que é executado na máquina de cada desenvolvedor. O daemon do FUSE permite que os engenheiros procurem saídas de build como se fossem arquivos normais armazenados na estação de trabalho, mas com o download do conteúdo do arquivo sob demanda apenas para os poucos arquivos solicitados diretamente pelo usuário. A disponibilização de conteúdo de arquivos sob demanda reduz muito o uso da rede e do disco. Além disso, o sistema pode criar duas vezes mais rápido se comparado ao armazenamento de toda a saída de build no disco local do desenvolvedor.

O sistema de execução remota do Google se chama Forge. Um cliente Forge no Blaze (equivalente interno do Bazel) chamado de distribuidor envia solicitações de cada ação para um job em execução em nossos data centers, chamado de Programador. O Programador mantém um cache de 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, ele coloca a ação em uma fila. Um grande pool de jobs de executor lê continuamente as ações dessa fila, as executa e armazena os resultados diretamente nos Bigtables do ObjFS. Esses resultados estão disponíveis para os executores para 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 de forma 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 rapidamente bases de código complexas, mas também permite implementar um grande número de ferramentas e sistemas automatizados que dependem do nosso build.