Builds distribuídos

Quando você tem uma base de código grande, as cadeias de dependências podem ficar muito profundas. Até mesmo binários simples podem depender de dezenas de milhares de destinos de compilação. Nessa escala, é simplesmente impossível concluir uma criação em um período razoável em uma única máquina: nenhum sistema de compilação 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 dividimos o trabalho do sistema em unidades pequenas o suficiente (falaremos mais sobre isso depois), isso nos permitiria concluir qualquer build de qualquer tamanho com a rapidez que quisermos pagar. Essa escalonabilidade é o principal 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 só usa o armazenamento em cache remoto, mostrado na Figura 1.

Build distribuído 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 comum de cache remoto. Ele 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, diretamente ou como uma dependência, o sistema primeiro verifica com o cache remoto se esse artefato já existe no local. 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 precisar ser recriada por cada um deles. No Google, muitos artefatos são disponibilizados a partir de um cache em vez de criados do zero, reduzindo bastante o custo de execução do sistema de build.

Para que um sistema de armazenamento em cache remoto funcione, ele precisa garantir que as versões sejam completamente reproduzíveis. Ou seja, para qualquer build de destino, é preciso que seja 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 iguais aos da criação dele. Observe que isso exige que cada artefato no cache seja codificado no destino e no hash das entradas. Dessa forma, engenheiros diferentes podem fazer modificações diferentes no mesmo destino ao mesmo tempo, e o cache remoto armazenaria todos os artefatos resultantes e os disponibilizaria adequadamente sem conflito.

Obviamente, para haver benefícios de um cache remoto, o download de um artefato precisa ser mais rápido do que a criação dele. Isso nem sempre é o caso, especialmente se o servidor de cache estiver longe da máquina que executa a criação. A rede e o sistema de build do Google são cuidadosamente ajustados para poder compartilhar rapidamente os resultados de build.

Execução remota

O armazenamento em cache remoto não é um build realmente distribuído. 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 a compilação inteira localmente na máquina. O verdadeiro objetivo é oferecer suporte à execução remota, em que o trabalho real de criação pode ser distribuído por qualquer número de workers. A Figura 2 ilustra um sistema de execução remota.

Sistema de execução remota

Figura 2. Sistema de execução remota

A ferramenta de criação executada 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 criação central. O mestre da compilação divide as solicitações em 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 da implementação desse 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 fazer isso, podemos usar o cache distribuído descrito anteriormente fazendo com que cada worker grave os resultados e leia as dependências dele no cache. A mestre impede que os workers prossigam 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 dele. Também precisamos de uma forma separada de exportar as alterações 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 compilação baseados em artefatos descritos anteriormente precisam se unir. Os ambientes de criação precisam ser completamente autodescritivos para que possamos ativar os workers sem intervenção humana. Os processos de compilaçã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 confie nos resultados que recebe de outros workers. Essas garantias são extremamente difíceis 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 em cache remoto e execução remota, 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 build em Bigtables distribuídos em nossa 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 do FUSE permite que os engenheiros naveguem pelas saídas de compilação como se fossem arquivos normais armazenados na estação de trabalho, mas com o conteúdo do arquivo transferido por download sob demanda apenas para os poucos arquivos solicitados diretamente pelo usuário. A disponibilização do conteúdo do arquivo sob demanda reduz muito o uso da rede e do disco, e o sistema pode criar duas vezes mais rápido em comparação com 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 Forge no Blaze (equivalente interno do Bazel) chamado de Distribuidor envia solicitações para cada ação para um job em execução nos nossos data centers, chamado Programador. 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, ele coloca a ação em uma fila. Um grande pool de jobs do Executor lê continuamente as 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 download pelo usuário final via objfsd.

O resultado final é um sistema escalonável para oferecer suporte eficiente a todos os builds executados 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 resultados de versões a partir de bilhões de linhas de código-fonte todos os dias. Esse sistema permite que nossos engenheiros criem rapidamente bases de código complexas, mas também implemente um grande número de ferramentas e sistemas automatizados que dependem do nosso build.