Sistemas de compilação baseados em artefatos

Informar um problema Ver a fonte Nightly · 8.0 · 7.4 · 7.3 · 7.2 · 7.1 · 7.0  · 6.5

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 uma boa etapa acima dos scripts de build, eles dão muito poder aos 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 maneira limitada. Os engenheiros ainda informam ao sistema o que construir, 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 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 maneira como eles são criados. Quando os engenheiros executam bazel na linha de comando, eles especificam um conjunto de destinos a serem criados (o 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 e quando, ele pode oferecer garantias muito mais fortes que permitem ser muito mais eficiente, além de garantir a correção.

Uma perspectiva funcional

É fácil fazer uma analogia entre sistemas de build baseados em artefatos e programação funcional. 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 para execução. As linguagens de programação funcional (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 realizada, mas deixa os detalhes de quando e exatamente como essa computação é executada para o compilador.

Isso corresponde à 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 a programação funcional, mas aqueles que se beneficiam muito dela: a linguagem geralmente é capaz de paralelizar esses programas e fazer garantias fortes sobre a correção deles que seriam impossíveis em uma linguagem imperativa. Os problemas mais fáceis de expressar usando programação funcional são aqueles que simplesmente envolvem transformar um pedaço de dados em outro usando uma série de regras ou funções. E é exatamente isso que um sistema de build é: todo o sistema é, na verdade, 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 é surpreendente que funcione bem para basear um sistema de build em torno dos princípios da programação funcional.

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

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

Confira como é um buildfile (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 destino 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 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 deste 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, todos os destinos de que MyBinary depende e todos os destinos de que esses destinos dependem, de forma 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 rastreia 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.

Basicamente, pode não parecer que o que está acontecendo aqui é muito diferente do que acontece ao usar um sistema de build baseado em tarefas. O resultado final é o mesmo binário, e o processo de produção envolve analisar 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 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 em relação ao build de destinos um por vez em uma máquina multicore, 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 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 dá se torna aparente quando o desenvolvedor digita bazel build :MyBinary uma segunda vez sem fazer nenhuma mudança: 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 que mencionamos 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, desde que as entradas não tenham mudado, a saída poderá ser reutilizada. E essa análise funciona em todos os níveis. Se MyBinary.java mudar, o Bazel saberá como reconstruir MyBinary, mas reutilizar mylib. Se um arquivo de origem para //java/com/example/common mudar, o Bazel vai saber reconstruir 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 reconstruir apenas o conjunto mínimo de artefatos a cada vez, garantindo que não produz builds desatualizados.

Reformular o processo de build em termos de artefatos, e não 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 elementos básicos de paralelismo e reutilização formam a base de um sistema de build distribuído e altamente escalonável.

Outros truques úteis do Bazel

Os sistemas de build baseados em artefatos resolvem fundamentalmente os problemas de paralelismo e reutilização que são inerentes aos sistemas de build baseados em tarefas. Mas ainda há alguns problemas que surgiram anteriormente e que não foram resolvidos. O Bazel tem maneiras inteligentes de resolver cada um deles, e precisamos discutir isso antes de continuar.

Ferramentas como dependências

Um problema que encontramos anteriormente era que os builds dependiam das ferramentas instaladas na máquina, e a reprodução de builds em sistemas poderia ser difícil devido a versões ou locais diferentes de ferramentas. 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 versus Linux), e cada uma dessas plataformas exige um conjunto ligeiramente diferente de ferramentas para fazer a mesma tarefa.

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 é definido como 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 depender diretamente das ferramentas, os destinos dependem de tipos de configurações:

  • Configuração do host: ferramentas de build que são executadas durante o build
  • Configuração de destino: build do binário que você solicitou.

Como estender o sistema de build

O Bazel vem com metas para várias linguagens de programação conhecidas fora da caixa, mas os engenheiros sempre vão querer fazer mais. Parte do benefício dos sistemas baseados em tarefas é a flexibilidade no suporte a qualquer tipo de processo de build. Seria 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 declara as entradas que a regra requer (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 pelas 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 de programar ações e armazenar em cache os 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 determinístico como parte da ação. Mas isso não acontece com frequência na prática, e aumentar as possibilidades de abuso até o nível de ação diminui significativamente 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 precisa definir as próprias regras. Mesmo para aqueles que têm, 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

Parece que as ações podem ter os mesmos problemas que as tarefas em outros sistemas. Ainda é possível gravar ações que gravam no mesmo arquivo e acabam entrando em conflito uma com a outra? Na verdade, o Bazel torna esses conflitos impossíveis usando o sandbox. Em sistemas com suporte, cada ação é isolada das outras por um sandbox de sistema de arquivos. Na prática, cada ação pode acessar 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 por trás do Docker. Isso significa que é impossível que as ações entrem em conflito uma com a outra, 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 comunicação de ações pela 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 (sejam 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 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 aparente devido a uma mudança de dependência que passou despercebida. Por fim, uma dependência externa pode apresentar um grande risco de segurança quando é de propriedade de terceiros: se um invasor conseguir se infiltrar no servidor de terceiros, ele poderá substituir o arquivo de dependência por algo do próprio design, potencialmente a ele 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 verificar no controle de origem. Atualizar uma dependência deve ser uma escolha consciente, mas essa escolha precisa ser feita uma vez em um local centralizado, 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ê conferir um commit da semana passada, vai ver as dependências como elas eram, e não como estã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 lista um hash criptográfico para cada dependência externa no espaço de trabalho. O hash é uma maneira concisa de representar o arquivo de forma 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 dessa dependência é adicionado ao manifesto, manualmente ou automaticamente. 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 do arquivo novamente apenas se o hash for diferente.

Se o artefato que você fizer 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 conferir uma versão mais antiga do código-fonte, o build tem garantia de usar as mesmas dependências que estava usando no momento em que a versão foi verificada. Caso contrário, ele vai falhar se essas dependências não estiverem mais disponíveis.

É claro que ainda pode haver um problema se um servidor remoto ficar indisponível ou começar a exibir dados corrompidos. Isso pode fazer com que todos os seus 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 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.