Sistemas de compilação baseados em artefatos

<ph-0-0>

Nesta página, falamos sobre sistemas de build baseados em artefatos e a filosofia por trás da criação deles. O Bazel é um sistema de build baseado em artefatos. Embora os sistemas de compilação baseados em tarefas sejam um bom passo acima dos scripts de build, eles dão muito poder aos engenheiros individuais ao permitir que eles definam as próprias tarefas.

Os sistemas de build baseados em artefatos têm um pequeno número de tarefas definidas pelo sistema que os engenheiros podem configurar de maneira limitada. Os engenheiros ainda informam ao sistema o que criar, mas o sistema determina como fazer isso. Assim como os sistemas de build baseados em tarefas, os sistemas de build baseados em artefatos, como o Bazel, ainda têm arquivos de build, mas o conteúdo deles é muito diferente. Em vez de ser um conjunto imperativo de comandos em uma linguagem de script completa de Turing que descreve como produzir uma saída, os arquivos de build no Bazel são um manifesto declarativo que descreve um conjunto de artefatos a serem criados, as dependências deles e um conjunto limitado de opções que afetam como eles são criados. Quando os engenheiros executam o bazel na linha de comando, eles especificam um conjunto de destinos a serem criados (o quê), e o Bazel é responsável por configurar, executar e programar as etapas de compilação (o como). Como o sistema de build agora tem controle total sobre quais ferramentas serão executadas e quando, ele pode oferecer garantias muito mais fortes que permitem ser muito mais eficiente e ainda garantir a correção.

Uma perspectiva funcional

É fácil fazer uma analogia entre sistemas de build baseados em artefatos e programação funcional. As linguagens de programação imperativas tradicionais (como Java, C e Python) especificam listas de instruções a serem executadas uma após a outra, da mesma forma que os sistemas de compilação baseados em tarefas permitem que os programadores definam uma série de etapas a serem executadas. As linguagens de programação funcionais (como Haskell e ML), por outro lado, são estruturadas mais como uma série de equações matemáticas. Em linguagens funcionais, o programador descreve uma computação a ser executada, mas deixa os detalhes de quando e exatamente como essa computação é executada para o compilador.

Isso é mapeado para a ideia de declarar um manifesto em um sistema de build baseado em artefatos e permitir que o sistema descubra como executar o build. Muitos problemas não podem ser facilmente expressos usando programação funcional, mas aqueles que se beneficiam muito dela: a linguagem geralmente é capaz de carregar esses programas em paralelo trivialmente e fazer fortes garantias sobre a correção deles, o que seria impossível em uma linguagem imperativa. Os problemas mais fáceis de expressar usando programação funcional são aqueles que simplesmente envolvem a transformação de um dado em outro, usando uma série de regras ou funções. Isso é exatamente o que é um sistema de build: todo o sistema é efetivamente uma função matemática que usa arquivos de origem (e ferramentas como o compilador) como entradas e produz binários como saídas. Por isso, não é de se surpreender que um sistema de build seja baseado nos princípios da programação funcional.

Noções básicas sobre sistemas de build baseados em artefatos

O sistema de build do Google, o Blaze, foi o primeiro sistema de build baseado em artefatos. O Bazel é a versão de código aberto do Blaze.

Confira como é um arquivo de build (normalmente chamado de BUILD) no Bazel:

java_binary(
    name = "MyBinary",
    srcs = ["MyBinary.java"],
    deps = [
        ":mylib",
    ],
)
java_library(
    name = "mylib",
    srcs = ["MyLibrary.java", "MyHelper.java"],
    visibility = ["//java/com/example/myproduct:__subpackages__"],
    deps = [
        "//java/com/example/common",
        "//java/com/example/myproduct/otherlib",
    ],
)

No Bazel, os arquivos BUILD definem destinos. Os dois tipos de destinos aqui são java_binary e java_library. Cada destino corresponde a um artefato que pode ser criado pelo sistema: os destinos binários produzem binários que podem ser executados diretamente, e os destinos de biblioteca produzem bibliotecas que podem ser usadas por binários ou outras bibliotecas. Toda meta tem:

  • name: como o destino é referenciado na linha de comando e por outros destinos.
  • srcs: os arquivos de origem que serão compilados para criar o artefato do destino.
  • deps: outros destinos que precisam ser criados antes desse destino e vinculados a ele.

As dependências podem estar dentro do mesmo pacote (como a dependência de MyBinary em :mylib) ou em um pacote diferente na mesma hierarquia de origem (como a dependência de mylib em //java/com/example/common).

Assim como nos sistemas de build baseados em tarefas, você executa builds usando a ferramenta de linha de comando do Bazel. Para criar o destino MyBinary, execute bazel build :MyBinary. Depois de inserir esse comando pela primeira vez em um repositório limpo, o Bazel:

  1. Analisa cada arquivo BUILD no espaço de trabalho para criar um gráfico de dependências entre artefatos.
  2. Usa o gráfico para determinar as dependências transitivas de MyBinary, ou seja, cada destino de que MyBinary depende e todos os destinos de que esses destinos dependem, de maneira recursiva.
  3. Cria cada uma dessas dependências, em ordem. O Bazel começa criando cada destino que não tem outras dependências e monitora quais dependências ainda precisam ser criadas para cada destino. Assim que todas as dependências de um destino são criadas, o Bazel começa a criá-lo. Esse processo continua até que todas as dependências transitivas de MyBinary sejam criadas.
  4. Cria MyBinary para produzir um binário executável final que se vincula a todas as dependências criadas na etapa 3.

Essencialmente, pode não parecer que o que está acontecendo aqui seja muito diferente do que aconteceu ao usar um sistema de build baseado em tarefas. De fato, o resultado final é o mesmo binário, e o processo de produção envolveu a análise de várias etapas para encontrar dependências entre elas e, em seguida, executar essas etapas em ordem. Mas existem diferenças críticas. O primeiro aparece na etapa 3: como o Bazel sabe que cada destino produz apenas uma biblioteca Java, ele sabe que tudo o que precisa fazer é executar o compilador Java em vez de um script arbitrário definido pelo usuário para saber que é seguro executar essas etapas em paralelo. Isso pode produzir uma melhoria de desempenho de ordem de magnitude em relação à criação de destinos, um por vez em uma máquina com vários núcleos. Isso só é possível porque a abordagem baseada em artefatos deixa o sistema de compilação no comando da própria estratégia de execução para que ele possa garantir garantias mais sólidas sobre o paralelismo.

No entanto, os benefícios vão além do paralelismo. O próximo aspecto que essa abordagem nos dá quando o desenvolvedor digita bazel build :MyBinary uma segunda vez sem fazer nenhuma mudança: o Bazel é encerrado em menos de um segundo com uma mensagem informando que o destino está atualizado. Isso é possível devido ao paradigma de programação funcional que abordamos anteriormente. O Bazel sabe que cada destino é o resultado apenas da execução de um compilador Java e sabe que a saída do compilador Java depende apenas das entradas. Assim, desde que as entradas não tenham sido alteradas, a saída pode ser reutilizada. E essa análise funciona em todos os níveis. Se MyBinary.java mudar, o Bazel saberá recriar MyBinary, mas reutilizar mylib. Se um arquivo de origem para //java/com/example/common mudar, o Bazel saberá recriar essa biblioteca, mylib e MyBinary, mas reutilizar //java/com/example/myproduct/otherlib. Como o Bazel conhece as propriedades das ferramentas executadas em cada etapa, ele pode recriar apenas o conjunto mínimo de artefatos a cada vez, garantindo que não produzirá builds desatualizados.

Reformular o processo de build em termos de artefatos em vez de tarefas é sutil, mas poderoso. Ao reduzir a flexibilidade exposta ao programador, o sistema de build pode saber mais sobre o que está sendo feito em cada etapa do build. Ele pode usar esse conhecimento para tornar o build muito mais eficiente, carregando em paralelo os processos de build e reutilizando as saídas deles. No entanto, essa é apenas a primeira etapa. Esses elementos básicos de paralelismo e reutilização formam a base de um sistema de compilação distribuído e altamente escalonável.

Outros truques incríveis do Bazel

Os sistemas de build baseados em artefatos resolvem fundamentalmente os problemas com paralelismo e reutilização inerentes aos sistemas de build baseados em tarefas. Mas ainda há alguns problemas que surgiram antes que não abordamos. O Bazel tem maneiras inteligentes de resolver cada um deles, e vamos falar sobre elas antes de continuar.

Ferramentas como dependências

Um problema que encontramos anteriormente foi que os builds dependiam das ferramentas instaladas na nossa máquina, e a reprodução de builds em vários sistemas poderia ser difícil devido a diferentes versões ou locais das ferramentas. O problema se torna ainda mais difícil quando o projeto usa linguagens que exigem ferramentas diferentes com base na plataforma em que estão sendo criadas ou compiladas (como Windows versus Linux), e cada uma dessas plataformas requer um conjunto de ferramentas um pouco diferente para fazer o mesmo trabalho.

Ele resolve a primeira parte desse problema tratando as ferramentas como dependências para cada destino. Cada java_library no espaço de trabalho depende implicitamente de um compilador Java, que tem como padrão um compilador conhecido. Sempre que o Bazel cria um java_library, ele verifica se o compilador especificado está disponível em um local conhecido. Assim como qualquer outra dependência, se o compilador Java for alterado, todos os artefatos que dependem dele serão recriados.

O Bazel resolve a segunda parte do problema, a independência da plataforma, definindo configurações de build (link em inglês). Em vez de destinos que dependem diretamente das ferramentas, eles dependem de tipos de configurações:

  • Configuração do host: criação de ferramentas que são executadas durante o build
  • Configuração de destino: criação do binário solicitado

Como estender o sistema de build

O Bazel oferece destinos para várias linguagens de programação conhecidas e prontas para uso, mas os engenheiros sempre vão querer fazer mais. Parte do benefício dos sistemas baseados em tarefas é a flexibilidade de oferecer suporte a qualquer tipo de processo de build, e é melhor não abrir mão disso em um sistema de build baseado em artefatos. Felizmente, o Bazel permite que os tipos de destino com suporte sejam estendidos adicionando regras personalizadas.

Para definir uma regra no Bazel, o autor da regra declara as entradas exigidas pela regra (na forma de atributos transmitidos no arquivo BUILD) e o conjunto fixo de saídas que a regra produz. O autor também define as ações que serão geradas por essa regra. Cada ação declara entradas e saídas, executa um executável específico ou grava uma string específica em um arquivo e pode ser conectada a outras ações por meio das entradas e saídas. Isso significa que as ações são a unidade combinável de nível mais baixo no sistema de build. Uma ação pode fazer o que quiser, desde que use apenas as entradas e saídas declaradas, e o Bazel cuida da programação das ações e armazena os resultados em cache conforme apropriado.

O sistema não é infalível, já que não há como impedir que um desenvolvedor de ação faça algo como introduzir um processo não determinista como parte da ação. Mas isso não acontece com muita frequência na prática, e levar as possibilidades de abuso até o nível de ação diminui muito as oportunidades de erros. As regras compatíveis com muitas linguagens e ferramentas comuns estão amplamente disponíveis on-line, e a maioria dos projetos nunca precisará definir as próprias regras. Mesmo para aqueles que fazem isso, as definições de regras só precisam ser definidas em um local central no repositório, o que significa que a maioria dos engenheiros poderá usar essas regras sem precisar se preocupar com a implementação delas.

Como isolar o ambiente

As ações parecem ter os mesmos problemas que tarefas em outros sistemas. Ainda não é possível gravar ações que gravam no mesmo arquivo e acabam em conflito? Na verdade, o Bazel impossibilita esses conflitos usando o sandbox. Em sistemas com suporte, todas as ações são isoladas de todas as outras por meio de um sandbox de sistema de arquivos. Efetivamente, cada ação pode ter apenas uma visualização restrita do sistema de arquivos que inclui as entradas declaradas e as saídas produzidas. Isso é aplicado por sistemas como o LXC no Linux, a mesma tecnologia do Docker. Isso significa que é impossível que as ações entrem em conflito umas com as outras porque não podem ler arquivos que não tenham sido declarados, e todos os arquivos gravados, mas não declarados, são descartados quando a ação termina. Ele também usa sandboxes para restringir as ações da comunicação pela rede.

Como tornar as dependências externas deterministas

Ainda resta um problema: os sistemas de build geralmente precisam fazer o download de dependências (ferramentas ou bibliotecas) de fontes externas, em vez de criá-las diretamente. Isso pode ser visto no exemplo pela dependência @com_google_common_guava_guava//jar, que faz o download de um arquivo JAR do Maven.

Depender de arquivos fora do espaço de trabalho atual é arriscado. Esses arquivos podem ser alterados a qualquer momento, possivelmente exigindo que o sistema de compilação verifique constantemente se são atualizados. Se um arquivo remoto for alterado sem uma alteração correspondente no código-fonte do espaço de trabalho, isso também poderá levar a builds não reproduzíveis. Um build pode funcionar em um dia e falhar no próximo sem motivo óbvio devido a uma mudança de dependência despercebida. Por fim, uma dependência externa pode introduzir um enorme risco de segurança quando é propriedade de terceiros: se um invasor conseguir se infiltrar nesse servidor de terceiros, ele pode substituir o arquivo de dependência por algo desenvolvido, potencialmente proporcionando controle total sobre o ambiente de build e a saída dele.

O problema fundamental é que queremos que o sistema de build esteja ciente desses arquivos sem precisar verificá-los no controle de origem. A atualização de uma dependência precisa ser uma escolha consciente, mas ela precisa ser feita uma vez em um local centralizado, e não gerenciada por engenheiros individuais ou automaticamente pelo sistema. Isso ocorre porque, mesmo com um modelo “Live at Head”, ainda queremos que os builds sejam determinísticos, o que implica que, se você conferir um commit da semana passada, poderá ver as dependências como estavam antes, e não como são agora.

O Bazel e alguns outros sistemas de compilação resolvem esse problema exigindo um arquivo de manifesto em todo o espaço de trabalho que liste um hash criptográfico para cada dependência externa no espaço de trabalho. O hash é uma maneira concisa de representar o arquivo de maneira exclusiva, sem verificar o arquivo inteiro no controle de origem. Sempre que uma nova dependência externa é referenciada em um espaço de trabalho, o hash dela é adicionado ao manifesto, manual ou automaticamente. Quando o Bazel executa um build, ele verifica o hash real da dependência armazenada em cache em relação ao hash esperado definido no manifesto e faz o download novamente do arquivo somente se o hash for diferente.

Se o artefato transferido por download tiver um hash diferente do declarado no manifesto, a build falhará, a menos que o hash no manifesto seja atualizado. Isso pode ser feito automaticamente, mas essa mudança precisa ser aprovada e verificada no controle de origem antes que o build aceite a nova dependência. Isso significa que sempre há um registro de quando uma dependência foi atualizada, e uma dependência externa não pode ser alterada sem uma alteração correspondente na origem do espaço de trabalho. Isso também significa que, ao verificar uma versão mais antiga do código-fonte, o build usa as mesmas dependências usadas no momento em que essa versão foi registrada. Caso contrário, ocorrerá uma falha se essas dependências não estiverem mais disponíveis.

Obviamente, isso ainda poderá ser um problema se um servidor remoto ficar indisponível ou começar a exibir dados corrompidos. Isso pode fazer com que todos os builds apresentem falha se você não tiver outra cópia dessa dependência disponível. Para evitar esse problema, recomendamos que, para qualquer projeto não trivial, você espelhe todas as dependências em servidores ou serviços confiáveis e controlados. Caso contrário, você sempre poderá depender de terceiros pela disponibilidade do seu sistema de build, mesmo que os hashes de check-in garantam a segurança.