Sistemas de compilação baseados em artefatos

Informar um problema Ver código-fonte

Nesta página, abordamos sistemas de compilação baseados em artefatos e a filosofia por trás da criação. O Bazel é um sistema de compilação baseado em artefatos. Embora os sistemas de compilação baseados em tarefas sejam uma boa etapa acima dos scripts de compilação, 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 informam ao sistema o que criar, mas o sistema determina como criá-lo. Assim como acontece com os sistemas de compilação baseados em tarefas, os sistemas de compilação baseados em artefatos, como o Bazel, ainda têm arquivos de compilação, mas o conteúdo deles é muito diferente. Em vez de ser um conjunto imperativo de comandos em uma linguagem de script completa em Turing que descreve como produzir uma saída, os arquivos de compilação no Bazel são um manifesto declarativo que descreve um conjunto de artefatos para criação, as dependências e um conjunto limitado de opções que afetam 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 que), e 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 executar, ele pode fazer garantias muito mais fortes que permitam ser muito mais eficiente e 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 tradicional, 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 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 ela é executada no compilador.

Isso mapeia para a ideia de declarar um manifesto em um sistema de compilação baseado em artefatos e permitir que o sistema descubra como executar o build. Muitos problemas não podem ser facilmente expressas usando uma programação funcional, mas os que se beneficiam muito disso: a linguagem geralmente é capaz de carregar esses programas paralelamente e fazer fortes garantias sobre a correção que seria impossível em uma linguagem imperativa. Os problemas mais fáceis de expressar usando a programação funcional são os que 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 compilação: o sistema inteiro é 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 é surpresa que ele funcione bem para basear um sistema de compilação em torno dos 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, Blaze, foi o primeiro sistema de compilação baseado em artefatos. O Bazel é a versão de código aberto do Blaze.

Veja a aparência de um arquivo de build (geralmente 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. Todos os destinos têm:

  • 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 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 compilação baseados em tarefas, você executa versões 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 forem criadas, ele vai começar a criar o 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.

Em essência, pode não parecer que o que está acontecendo aqui seja 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 dele envolve analisar várias etapas para encontrar dependências entre elas e, em seguida, executar essas etapas em ordem. Mas há 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, por isso sabe que é seguro executar essas etapas em paralelo. Isso pode produzir uma ordem de melhoria de desempenho de magnitude em relação aos destinos de compilação de cada vez em uma máquina de vários núcleos, e isso só é possível porque a abordagem baseada em artefatos deixa o sistema de compilação 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á fica aparente quando o desenvolvedor digita bazel build :MyBinary uma segunda vez sem fazer qualquer 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. 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, 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á como recriar MyBinary, mas reutilizar mylib. Se um arquivo de origem para //java/com/example/common mudar, o Bazel vai recriar essa biblioteca, mylib e MyBinary, mas reutilizar //java/com/example/myproduct/otherlib. Como o Bazel sabe sobre as propriedades das ferramentas que ele executa em todas as etapas, ele é capaz de recriar apenas o conjunto mínimo de artefatos por vez, garantindo que ele não produzirá versões desatualizadas.

Reenquadrar o processo de compilação em termos de artefatos em vez de tarefas é sutil, mas poderoso. Ao reduzir a flexibilidade exposta para o programador, o sistema de compilação pode saber mais sobre o que está sendo feito em cada etapa da compilação. Ele pode usar esse conhecimento para tornar o build muito mais eficiente carregando as processos de compilação em paralelo 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 compilação distribuído e altamente escalonável.

Outros truques interessantes do Bazel

Os sistemas de compilação baseados em artefatos resolvem os problemas com o paralelismo e a reutilização inerentes a esses sistemas. No entanto, ainda há alguns problemas que não foram resolvidos. O Bazel tem maneiras inteligentes de resolver cada um deles, e nós precisamos falar sobre elas antes de prosseguir.

Ferramentas como dependências

Um problema que tivemos anteriormente foi que as versões dependiam das ferramentas instaladas na nossa máquina, e a reprodução das versões entre sistemas pode ser difícil devido a diferentes versões ou locais de ferramentas. O problema se torna ainda mais difícil quando o projeto usa linguagens que exigem diferentes ferramentas com base na plataforma em que estão sendo criadas ou compiladas (como Windows ou Linux). Cada uma dessas plataformas requer um conjunto de ferramentas um pouco diferente para executar a mesma tarefa.

O Bazel 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 mudar, todos os artefatos que dependem dele serão recriados.

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

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

Como ampliar o sistema de compilação

O Bazel vem com destinos para várias linguagens de programação conhecidas, mas os engenheiros sempre vão querer fazer mais. Parte do benefício dos sistemas baseados em tarefas é a flexibilidade deles em oferecer suporte a qualquer tipo de processo de compilação, e seria melhor não desistir em um sistema de compilação 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 por ela (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 determinada string 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 de composição de nível mais baixo no sistema de compilação. 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, porque 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 dele. Mas isso não acontece com muita frequência na prática, e levar as possibilidades de abuso até o nível da ação diminui muito as oportunidades de erros. As regras que dão 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 as que têm, as definições de regras precisam ser definidas em um único local no repositório. Isso significa que a maioria dos engenheiros poderá usar essas regras sem se preocupar com a implementação delas.

Isolamento do ambiente

As ações parecem ter os mesmos problemas que as tarefas em outros sistemas. Ainda não é possível escrever 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, todas as ações são isoladas de outras por um sandbox do sistema de arquivos. 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 a LXC no Linux, a mesma tecnologia por trás do Docker. Isso significa que é impossível que as ações entrem em conflito porque elas não conseguem ler os arquivos que não declaram, e os arquivos que eles escrevem, mas não declaram, são descartados quando a ação é concluída. O Bazel também usa sandboxes para restringir ações de comunicação pela rede.

Como tornar dependências externas determinísticas

Ainda há um problema: os sistemas de compilação geralmente precisam fazer o download de dependências (ferramentas ou bibliotecas) de fontes externas em vez de compilá-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.

Dependendo dos arquivos que estão fora do espaço de trabalho atual, é arriscado. Esses arquivos podem mudar a qualquer momento, o que pode exigir que o sistema de compilação verifique constantemente se eles são recentes. Se um arquivo remoto mudar sem uma alteração correspondente no código-fonte do espaço de trabalho, ele também poderá gerar builds não reproduzíveis. Um build pode funcionar em um dia e falhar no outro por um 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 pertence a 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, possivelmente oferecendo controle total sobre o ambiente de build e a saída dele.

O problema fundamental é que queremos que o sistema de compilação esteja ciente desses arquivos sem precisar verificá-los no controle de origem. Atualizar uma dependência precisa ser uma escolha consciente, mas essa escolha precisa ser feita uma vez em um local central em vez de gerenciada por engenheiros individuais ou automaticamente pelo sistema. Mesmo porque, mesmo com 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 anterior, verá suas dependências como estavam, e não como estã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 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 maneira exclusiva sem verificar todo o arquivo no controle de origem. Sempre que uma nova dependência externa é referenciada de um espaço de trabalho, o hash dela é adicionado ao manifesto de forma manual ou automática. Quando o Bazel executa uma versão, 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 somente se ele for diferente.

Se o artefato transferido por download tiver um hash diferente do declarado no manifesto, a versão 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 a versão 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, a compilação tem a garantia de usar as mesmas dependências que estava usando no momento em que essa versão foi verificada. Caso contrário, ela falhará se essas dependências não estiverem mais disponíveis.

Obviamente, ainda pode ser um problema se um servidor remoto ficar indisponível ou começar a exibir dados corrompidos. Isso pode fazer com que todas as suas 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 dele em servidores ou serviços confiáveis e controlados. Caso contrário, você sempre terá a responsabilidade de um terceiro pela disponibilidade do sistema de build, mesmo que os hashes verificados garantam a segurança dele.