Esta página aborda 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 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 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 dizem 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 baseados em artefatos, como o Bazel, ainda têm arquivos de build, mas o conteúdo deles é muito diferente. Em vez de um conjunto imperativo de comandos em uma linguagem de programação Turing-completa 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 são criados. Quando os engenheiros executam bazel
na linha de comando, eles especificam um conjunto de destinos para criar (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, o que permite ser muito mais eficiente sem deixar de 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 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 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 deixar 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 transformar um dado em outro usando uma série de regras ou funções. E é exatamente isso que um sistema de build faz: 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 basear um sistema de build nos princípios da programação funcional.
Entender 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.
Veja 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: 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 destinodeps
: outros destinos que precisam ser criados antes deste 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:
- Analisa todos os arquivos
BUILD
no espaço de trabalho para criar um gráfico de dependências entre artefatos. - Usa o gráfico para determinar as dependências transitivas de
MyBinary
, ou seja, todos os destinos de queMyBinary
depende e todos os destinos de que esses destinos dependem, de forma recursiva. - 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. - Cria
MyBinary
para produzir um binário executável final que vincula todas as dependências criadas na etapa 3.
Basicamente, o que está acontecendo aqui não parece muito diferente do que acontecia ao usar um sistema de build baseado em tarefas. O resultado final é o mesmo binário, e o processo para produzi-lo 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. O primeiro 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 melhoria de desempenho de uma ordem de magnitude em relação à criação de destinos um de cada vez em uma máquina multicore. Isso 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 dá fica evidente 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 de que falamos antes. O Bazel sabe que cada destino é o resultado apenas da execução de um compilador Java e que a saída do compilador Java depende apenas das entradas. Portanto, enquanto as entradas não mudarem, a saída poderá ser reutilizada.
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 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 consegue recriar apenas o conjunto mínimo de artefatos a cada vez, garantindo que não vai produzir builds desatualizados.
Refazer 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 os processos de build e reutilizando as saídas deles. Mas esse é apenas o primeiro passo, e esses blocos de paralelismo e reutilização formam a base de um sistema de build distribuído e altamente escalonável.
Outras dicas úteis 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 antes 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 antes era que os builds dependiam das ferramentas instaladas na nossa máquina, e reproduzir builds em sistemas podia ser difícil devido a versões ou locais diferentes das 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 para a qual estão sendo compiladas (como Windows x 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. Todo java_library
no espaço de trabalho depende implicitamente de um compilador
Java, que usa um compilador conhecido por padrão. 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 dependerem 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: criação do binário que você solicitou
Como estender o sistema de build
O Bazel vem com destinos para várias linguagens de programação conhecidas, mas os engenheiros sempre querem fazer mais. Parte do benefício dos sistemas baseados em tarefas é a flexibilidade para oferecer suporte a qualquer tipo de processo de build. Seria melhor não desistir disso em um sistema de build baseado em artefatos. Felizmente, o Bazel permite que os tipos de destino compatíveis sejam estendidos adicionando regras personalizadas.
Para definir uma regra no Bazel, o autor 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 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 do agendamento de ações e do armazenamento em cache dos resultados conforme necessário.
O sistema não é infalível, 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 reduzir as possibilidades de abuso até o nível da 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 precisa definir as próprias regras. Mesmo para aqueles que usam, as definições de regra só precisam ser definidas em um local central no repositório. Isso 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 as tarefas em outros sistemas. Ainda é possível escrever ações que gravam no mesmo arquivo e acabam entrando em conflito? Na verdade, o Bazel torna esses conflitos impossíveis usando o sandbox. Em sistemas compatíveis, cada ação é isolada de todas as outras por um sandbox do sistema de arquivos. Na prática, 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 os arquivos que elas gravam, mas não declaram, são descartados quando a ação termina. O Bazel também usa sandboxes para restringir a comunicação de ações pela rede.
Como tornar as dependências externas deterministas
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 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 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 pode 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 não percebida. Por fim, uma dependência externa pode introduzir 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 de design próprio, 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 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 feita uma vez em um lugar central, em vez de ser gerenciada por engenheiros individuais ou automaticamente pelo sistema. Isso porque, mesmo com um modelo "Live at Head", ainda queremos que os builds sejam deterministas. Isso implica que, se você extrair um commit da semana passada, as dependências vão aparecer como eram naquela época, 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 lista um hash criptográfico para cada dependência externa no espaço de trabalho. O hash é uma maneira concisa de representar exclusivamente o arquivo 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, de forma manual ou automática. Quando o Bazel executa um build, ele verifica o hash real da dependência armazenada em cache com o hash esperado definido no manifesto e baixa novamente o arquivo somente se o hash for diferente.
Se o artefato baixado 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 a 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 garantia de usar as mesmas dependências que usava no momento em que essa versão foi verificada (caso contrário, 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 todas as suas builds falhem se você não tiver outra cópia dessa dependência disponível. Para evitar esse problema, recomendamos que, em 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 vai depender de terceiros para a disponibilidade do sistema de build, mesmo que os hashes confirmados garantam a segurança.