Sistemas de compilação baseados em artefatos

Nesta página, abordamos sistemas de compilação 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 build baseados em tarefas sejam uma boa opção acima dos scripts de build, eles dão muito poder aos engenheiros individuais, permitindo que eles definam as próprias tarefas.

Os sistemas de compilação 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 dizem ao sistema o que criar, mas o sistema determina como isso será feito. 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 serem um conjunto imperativo de comandos em uma linguagem de script de Turing completo 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 a criação deles. Quando os engenheiros executam bazel na linha de comando, eles especificam um conjunto de destinos a serem criados (o o quê). O Bazel é responsável por configurar, executar e programar as etapas de compilação (o como). Como o sistema de compilação agora tem controle total sobre quais ferramentas serão executadas e quando, ele pode oferecer garantias muito mais sólidas para que seja muito mais eficiente e, ao mesmo tempo, garantir a correção.

Uma perspectiva funcional

É fácil fazer uma analogia entre sistemas de compilação baseados em artefatos e programação funcional. As linguagens de programação imperativa tradicionais, como Java, C e Python, especificam listas de instruções a serem executadas uma após a outra, da mesma maneira que os sistemas de build 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 ela é executada para o compilador.

Isso é associado à 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 com a programação funcional, mas os que se beneficiam muito com ela: a linguagem muitas vezes é capaz de carregar esses programas em paralelo trivialmente e fazer garantias fortes sobre a correçã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 uma parte de dados em outra 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. Portanto, não é de se surpreender que funcione bem para basear um sistema de build com base nos princípios da programação funcional.

Noções básicas sobre sistemas de compilação baseados em artefatos

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

Veja como é um arquivo de build, normalmente 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, 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: 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. Cada destino tem:

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

As dependências podem estar no 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 todo destino de que esses destinos dependem, recursivamente.
  3. Cria cada uma dessas dependências, em ordem. Ele 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 compilar esse destino. Esse processo continua até que cada uma das dependências transitivas de MyBinary seja criada.
  4. Cria MyBinary para produzir um binário executável final que vincula todas as dependências criadas na etapa 3.

Pode não parecer que o que está acontecendo aqui é muito diferente do que aconteceu ao usar um sistema de compilação baseado em tarefas. De fato, o resultado final é o mesmo binário, e o processo de produção envolve a análise de várias etapas para encontrar dependências entre elas e a execução dessas 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. Assim, ele sabe que é seguro executar essas etapas em paralelo. Isso pode produzir uma ordem de magnitude de melhoria de desempenho na criação de destinos um de cada 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 ter garantias mais sólidas sobre paralelismo.

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

Reformular o processo de compilação em termos de artefatos em vez de tarefas é sutil, mas eficiente. 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 ao carregar os processos de compilação em paralelo e reutilizar as saídas deles. Mas esse é apenas o primeiro passo, e esses elementos básicos de paralelismo e reutilização formam a base para um sistema de build distribuído e altamente escalonável.

Outros truques do Bazel

Os sistemas de compilação baseados em artefatos resolvem basicamente os problemas com paralelismo e reutilização inerentes aos sistemas de compilação baseados em tarefas. Mas ainda há alguns problemas que surgiram anteriormente que não foram resolvidos. Ele tem maneiras inteligentes de resolver cada uma delas, e precisamos discuti-las antes de continuar.

Ferramentas como dependências

Um problema que encontramos anteriormente era que os builds dependiam das ferramentas instaladas na nossa máquina, e isso poderia ser difícil devido a diferentes versões de ferramentas ou locais. O problema fica 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 ou Linux), e cada uma dessas plataformas exige um conjunto de ferramentas um pouco diferente para fazer o mesmo trabalho.

Para resolver a primeira parte do problema, o Bazel trata as ferramentas como dependências de 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 mudar, todos os artefatos que dependem dele serão recriados.

O Bazel resolve a segunda parte do problema, a independência de plataforma, definindo configurações de build. Em vez de depender diretamente das ferramentas, eles dependem dos tipos de configuração:

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

Como estender o sistema de compilação

Ele vem com destinos para várias linguagens de programação conhecidas prontas, mas os engenheiros sempre vão querer fazer mais. Parte do benefício dos sistemas baseados em tarefas é a flexibilidade em oferecer suporte a qualquer tipo de processo de build, e é melhor não abrir esse sistema em um sistema de build baseado em artefatos. Felizmente, o Bazel permite que os tipos de destino com suporte sejam estendidos com a adição de 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 as 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 de entradas e saídas. Isso significa que as ações são a unidade de composição 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 de ações e do armazenamento em cache dos resultados 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. No entanto, 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 bastante as oportunidades de erros. As regras que oferecem suporte a muitas linguagens e ferramentas comuns estão disponíveis on-line, e a maioria dos projetos nunca precisará definir as próprias regras. Mesmo para aqueles que fazem, 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.

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 entre si? Na verdade, o Bazel impossibilita esses conflitos usando o sandbox. Em sistemas com suporte, cada ação é isolada de todas as outras por um sandbox do sistema de arquivos. Efetivamente, cada ação pode ver apenas uma visualização restrita do sistema de arquivos que inclui as entradas declaradas e todas as saídas produzidas por ela. Isso é aplicado por sistemas como LXC no Linux, a mesma tecnologia por trás do Docker. Isso significa que é impossível entrar em conflito entre as ações, porque elas não podem ler nenhum arquivo que não declare, e todos os arquivos que gravam, mas não declaram, serão descartados quando a ação for concluída. Ele também usa sandboxes para restringir a comunicação de ações pela rede.

Tornar as dependências externas determinísticas

Ainda resta um problema: os sistemas de compilação geralmente precisam fazer o download de dependências (seja ferramentas ou bibliotecas) de fontes externas em vez de criá-las diretamente. Isso pode ser visto no exemplo usando a 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 mudar a qualquer momento, exigindo que o sistema de build verifique constantemente se estão atualizados. Se um arquivo remoto for alterado sem uma alteração correspondente no código-fonte do espaço de trabalho, ele também poderá levar a builds não reproduzíveis. Um build pode funcionar em um dia e falhar no próximo sem nenhum motivo óbvio devido a uma alteração de dependência não percebida. Por fim, uma dependência externa pode apresentar um enorme risco de segurança quando é de terceiros. Se um invasor conseguir se infiltrar nesse servidor de terceiros, ele poderá substituir o arquivo de dependência por algo próprio, o que pode dar a ele controle total sobre o ambiente de build e a saída dele.

O problema fundamental é que queremos que o sistema de build conheça esses arquivos sem precisar verificá-los no controle de origem. A atualização de uma dependência precisa ser uma escolha consciente, mas é preciso fazer isso uma vez em um local central, em vez de ser gerenciada por engenheiros individuais ou automaticamente pelo sistema. Isso ocorre porque, mesmo em um modelo "Live at Head", ainda queremos que os builds sejam determinísticos, o que significa que, se você conferir uma confirmação da semana passada, verá suas dependências como estavam antes, e não como são agora.

O Bazel e alguns outros sistemas de build 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 todo o arquivo no controle de origem. Sempre que uma nova dependência externa é referenciada em um espaço de trabalho, o hash dela é adicionado ao manifesto de forma manual ou automática. Quando o Bazel executa um build, ele verifica o hash real da dependência 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, o 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 mudança correspondente na fonte do espaço de trabalho. Isso também significa que, ao verificar uma versão mais antiga do código-fonte, o build vai usar as mesmas dependências que estava sendo usado no momento em que a versão foi verificada. Caso contrário, vai falhar se essas dependências não estiverem mais disponíveis.

É claro que isso ainda pode ser um problema se um servidor remoto ficar indisponível ou começar a disponibilizar dados corrompidos. Isso pode fazer com que todas as versões comecem a falhar 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 que você confia e controla. Caso contrário, você sempre terá a responsabilidade de um terceiro pela disponibilidade do seu sistema de build, mesmo que os hashes com check-in garantam a segurança dele.