Sistemas de compilação baseados em artefatos

Esta página aborda os 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 build baseados em tarefas sejam um bom passo acima dos scripts de build, eles dão muito poder a engenheiros individuais, permitindo 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 forma limitada. Os engenheiros ainda informam ao sistema o que criar, mas o sistema de build 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 a forma como eles são criados. Quando os engenheiros executam 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 executar, ele pode fazer garantias muito mais fortes que permitem que ele seja muito mais eficiente, garantindo 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 build baseados em tarefas permitem que os programadores definam uma série de etapas a serem executadas. As linguagens de programação funcional (como Haskell e ML), em contraste, são estruturadas mais como uma série de equações matemáticas. Em linguagens funcionais, o programador descreve uma computação a ser realizada, 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 consegue paralelizar trivialmente esses programas e fazer garantias fortes 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 envolvem simplesmente a transformação de um dado em outro usando uma série de regras ou funções. E é exatamente isso que um sistema de build é: todo o sistema é efetivamente uma função matemática que recebe arquivos de origem (e ferramentas como o compilador) como entradas e produz binários como saídas. Portanto, não é surpreendente que funcione bem para basear um sistema de build 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 a aparência de 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. 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 do 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ê realiza 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 todos os arquivos 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 cada destino de que esses destinos dependem, recursivamente.
  3. Cria cada uma dessas dependências, em ordem. O Bazel começa criando cada destino que não tem outras dependências e acompanha 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 criar esse destino. 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 vincula todas as dependências criadas na etapa 3.

Fundamentalmente, 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 há diferenças importantes. A primeira aparece na etapa 3: como o Bazel sabe que cada destino só produz 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 em relação à criação de destinos um de cada vez em uma máquina com vários núcleos e só é possível porque a abordagem baseada em artefatos deixa o sistema de build responsável pela própria estratégia de execução para que ele possa fazer garantias mais fortes sobre o paralelismo.

No entanto, os benefícios vão além do paralelismo. A próxima coisa que essa abordagem nos oferece fica aparente quando o desenvolvedor digita bazel build :MyBinary uma segunda vez sem fazer alterações: 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 de que falamos 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. Portanto, enquanto as entradas não mudarem, a saída poderá ser reutilizada. E essa análise funciona em todos os níveis. Se MyBinary.java mudar, o Bazel saberá como recriar MyBinary, mas reutilizar mylib. Se um arquivo de origem para //java/com/example/common mudar, o Bazel saberá como 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 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, paralelizando processos de build e reutilizando as saídas. Mas essa é apenas a primeira etapa, e esses blocos de construção de paralelismo e reutilização formam a base de um sistema de build distribuído e altamente escalonável.

Outros truques do Bazel

Os sistemas de build baseados em artefatos resolvem fundamentalmente os problemas de paralelismo e reutilização inerentes aos sistemas de build baseados em tarefas. Mas ainda há alguns problemas que surgiram anteriormente e que não abordamos. O Bazel tem maneiras inteligentes de resolver cada um deles, e precisamos discuti-los antes de prosseguir.

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 sistemas poderia ser difícil devido a versões ou locais de ferramentas diferentes. 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 e Linux), e cada uma dessas plataformas exige um conjunto ligeiramente diferente de ferramentas para fazer o mesmo trabalho.

O Bazel resolve a primeira parte desse problema tratando as ferramentas como dependências de cada destino. Cada java_library no espaço de trabalho depende implicitamente de um compilador Java, que é padrão para 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 da plataforma, definindo configurações de build. Em vez de destinos que dependem diretamente das ferramentas, eles dependem de tipos de configurações:

  • Configuração do host: ferramentas de build 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 vem com destinos para várias linguagens de programação populares, mas os engenheiros sempre querem fazer mais. Parte do benefício dos sistemas baseados em tarefas é a flexibilidade deles no suporte a qualquer tipo de processo de build, e seria melhor não desistir disso em um sistema de build baseado em artefatos. Felizmente, o Bazel permite que os tipos de destino com suporte sejam estendidos pela adição de regras personalizadas.

Para definir uma regra no Bazel, o autor da regra declara as entradas necessárias (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 pelas entradas e saídas. Isso significa que as ações são a unidade composicional 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 do agendamento de ações e do armazenamento em cache dos resultados, conforme apropriado.

O sistema não é à prova de falhas, já que não há como impedir que um desenvolvedor de ações faça algo como introduzir um processo não determinístico como parte da ação. Mas isso não acontece com muita frequência na prática, e aumentar as possibilidades de abuso até o nível de ação diminui muito as oportunidades de erros. As regras que oferecem suporte a 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 se preocupar com a implementação delas.

Como isolar o ambiente

As ações parecem ter os mesmos problemas que as tarefas em outros sistemas. Ainda é possível escrever ações que gravam no mesmo arquivo e acabam entrando em conflito umas com as outras? Na verdade, o Bazel torna esses conflitos impossíveis usando sandbox. Em sistemas com suporte, cada ação é isolada de todas as outras ações por um sandbox do sistema de arquivos. Efetivamente, cada ação só pode ver 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 por trás do Docker. Isso significa que é impossível que as ações entrem em conflito umas com as outras porque elas não podem ler arquivos que não declaram, e todos os arquivos que elas gravam, mas não declaram, serão descartados quando a ação terminar. O Bazel também usa sandboxes para restringir ações de comunicação via a rede.

Como tornar as dependências externas determinísticas

Ainda há 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 @com_google_common_guava_guava//jar dependência, que faz o download de um JAR arquivo do Maven.

Depender de arquivos fora do espaço de trabalho atual é arriscado. Esses arquivos podem mudar a qualquer momento, o que pode exigir que o sistema de build verifique constantemente se eles estão atualizados. Se um arquivo remoto mudar sem uma mudança 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 um dia e falhar no dia seguinte sem motivo óbvio devido a uma mudança de dependência não percebida. Por fim, uma dependência externa pode introduzir um grande risco de segurança quando pertence a terceiros: se um invasor conseguir se infiltrar nesse servidor de terceiros, ele poderá substituir o arquivo de dependência por algo do próprio design, o que pode dar a ele controle total sobre o ambiente de build e a saída.

O problema fundamental é que queremos que o sistema de build esteja ciente desses arquivos sem precisar fazer check-in no controle de origem. A atualização de uma dependência precisa ser uma escolha consciente, mas essa escolha precisa ser feita uma vez em um local central em vez de ser 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ê fizer o check-out de um commit da semana passada, as dependências serão exibidas como estavam na época, e não como estão agora.

O Bazel e alguns outros sistemas de build resolvem esse problema exigindo um arquivo de manifesto de 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 fazer check-in de todo o arquivo no controle de origem. Sempre que uma nova dependência externa é referenciada em um espaço de trabalho, o hash dessa dependência é 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 do arquivo novamente apenas se o hash for diferente.

Se o artefato que fazemos o download tiver um hash diferente do declarado no manifesto, o build vai 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 mudar sem uma mudança correspondente na origem do espaço de trabalho. Isso também significa que, ao fazer o check-out de uma versão mais antiga do código-fonte, o build tem a garantia de usar as mesmas dependências que estava usando no momento em que essa versão foi feita (ou vai falhar se essas dependências não estiverem mais disponíveis).

É claro que ainda pode ser um problema se um servidor remoto ficar indisponível ou começar a veicular dados corrompidos. Isso pode fazer com que todos os builds 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 dele em servidores ou serviços confiáveis e controlados. Caso contrário, você sempre estará à mercê de terceiros para a disponibilidade do sistema de build , mesmo que os hashes verificados garantam a segurança.