Sistemas de compilación basados en artefactos

Informar un problema Ver fuente

En esta página, se describen los sistemas de compilación basados en artefactos y la filosofía detrás de su creación. Bazel es un sistema de compilación basado en artefactos. Si bien los sistemas de compilación basados en tareas son un buen paso por sobre las secuencias de comandos de compilación, les dan demasiada potencia a los ingenieros individuales porque les permiten definir sus propias tareas.

Los sistemas de compilación basados en artefactos tienen una pequeña cantidad de tareas definidas por el sistema que los ingenieros pueden configurar de forma limitada. Los ingenieros aún le dicen al sistema qué compilar, pero el sistema de compilación determina cómo compilarlo. Al igual que sucede con los sistemas de compilación basados en tareas, los sistemas de compilación basados en artefactos, como Bazel, aún tienen archivos de compilación, pero su contenido es muy diferente. En lugar de ser un conjunto imperativo de comandos en un lenguaje de secuencias de comandos de Turing completo que describe cómo producir un resultado, los archivos de compilación de Bazel son un manifiesto declarativo que describe un conjunto de artefactos para compilar, sus dependencias y un conjunto limitado de opciones que afectan la forma en que se compilan. Cuando los ingenieros ejecutan bazel en la línea de comandos, especifican un conjunto de destinos para compilar (el qué), y Bazel se encarga de configurar, ejecutar y programar los pasos de compilación (el cómo). Debido a que el sistema de compilación ahora tiene control total sobre qué herramientas ejecutar y cuándo, puede ofrecer garantías mucho más sólidas que le permiten ser mucho más eficiente y, al mismo tiempo, garantizar la precisión.

Una perspectiva funcional

Es fácil crear una analogía entre los sistemas de compilación basados en artefactos y la programación funcional. Los lenguajes de programación imperativos tradicionales (como Java, C y Python) especifican listas de instrucciones que se ejecutarán una tras otra, de la misma manera que los sistemas de compilación basados en tareas permiten a los programadores definir una serie de pasos para ejecutar. En cambio, los lenguajes de programación funcionales (como Haskell y ML) se estructuran más como una serie de ecuaciones matemáticas. En lenguajes funcionales, el programador describe un cálculo que debe realizar, pero le deja al compilador los detalles de cuándo y exactamente cómo se ejecuta ese cálculo.

Esto se relaciona con la idea de declarar un manifiesto en un sistema de compilación basado en artefactos y permitir que el sistema determine cómo ejecutar la compilación. Muchos problemas no se pueden expresar con facilidad mediante la programación funcional, pero los que sí se benefician mucho: el lenguaje a menudo puede paralelizar trivialmente dichos programas y ofrecer garantías sólidas sobre su precisión que sería imposible en un lenguaje imperativo. Los problemas más fáciles de expresar mediante la programación funcional son los que implican la transformación de un dato en otro mediante una serie de reglas o funciones. Eso es exactamente lo que es un sistema de compilación: todo el sistema es, en efecto, una función matemática que toma archivos de origen (y herramientas como el compilador) como entradas y produce objetos binarios como salidas. Por lo tanto, no es de extrañar que funcione bien basar un sistema de compilación en torno a los principios de la programación funcional.

Información sobre los sistemas de compilación basados en artefactos

El sistema de compilación de Google, Blaze, fue el primer sistema de compilación basado en artefactos. Es la versión de código abierto de Blaze.

Así se ve un archivo de compilación (que normalmente se llama BUILD) en 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",
    ],
)

En Bazel, los archivos BUILD definen los destinos. Estos dos tipos de destinos son java_binary y java_library. Cada destino corresponde a un artefacto que el sistema puede crear: los objetivos binarios producen objetos binarios que pueden ejecutarse directamente, mientras que los destinos de la biblioteca producen bibliotecas que pueden usar los objetos binarios o las otras bibliotecas. Cada objetivo tiene:

  • name: Indica cómo se hace referencia al destino en la línea de comandos y en otros destinos.
  • srcs: Son los archivos de origen que se compilarán para crear el artefacto del destino.
  • deps: Otros destinos que se deben compilar antes que este y vincularse a él

Las dependencias pueden estar dentro del mismo paquete (como la dependencia de MyBinary en :mylib) o en un paquete diferente en la misma jerarquía de fuentes (como la dependencia de mylib en //java/com/example/common).

Al igual que con los sistemas de compilación basados en tareas, puedes realizar compilaciones con la herramienta de línea de comandos de Bazel. Para compilar el destino MyBinary, ejecuta bazel build :MyBinary. Después de ingresar ese comando por primera vez en un repositorio limpio, Bazel hace lo siguiente:

  1. Analiza cada archivo BUILD en el lugar de trabajo para crear un gráfico de dependencias entre los artefactos.
  2. Usa el gráfico para determinar las dependencias transitivas de MyBinary, es decir, cada objetivo del que depende MyBinary y de los que dependen esos destinos, de manera recursiva.
  3. Compila cada una de esas dependencias, en orden. Bazel comienza por compilar cada destino que no tiene otras dependencias y realiza un seguimiento de las dependencias que aún deben compilarse para cada destino. En cuanto se compilan todas las dependencias de un objetivo, Bazel comienza a compilar ese objetivo. Este proceso continúa hasta que se hayan compilado todas las dependencias transitivas de MyBinary.
  4. Compila MyBinary para producir un objeto binario ejecutable final que se vincula en todas las dependencias que se compilaron en el paso 3.

En esencia, puede que no parezca que lo que sucede aquí es muy diferente de lo que sucede cuando se usa un sistema de compilación basado en tareas. De hecho, el resultado final es el mismo objeto binario, y el proceso para producirlo implica analizar varios pasos a fin de encontrar dependencias entre ellos y, luego, ejecutar esos pasos en orden. Pero existen diferencias críticas. El primero aparece en el paso 3: como Bazel sabe que cada destino solo produce una biblioteca Java, sabe que lo único que debe hacer es ejecutar el compilador de Java en lugar de una secuencia de comandos arbitraria definida por el usuario, por lo que es seguro ejecutar estos pasos en paralelo. Esto puede producir una mejora en el rendimiento de orden de magnitud en comparación con compilar objetivos uno a la vez en una máquina de varios núcleos y solo es posible porque el enfoque basado en artefactos deja al sistema de compilación a cargo de su propia estrategia de ejecución para que pueda ofrecer garantías más sólidas sobre el paralelismo.

Sin embargo, los beneficios se extienden más allá del paralelismo. Lo siguiente que nos brinda este enfoque se hace evidente cuando el desarrollador escribe bazel build :MyBinary por segunda vez sin hacer ningún cambio: Bazel sale en menos de un segundo con un mensaje que dice que el destino está actualizado. Esto es posible debido al paradigma de programación funcional del que hablamos antes: Bazel sabe que cada destino es el resultado solo de la ejecución de un compilador de Java y que el resultado del compilador de Java depende solo de sus entradas, de modo que, mientras estas no hayan cambiado, el resultado se puede volver a usar. Este análisis funciona en todos los niveles. Si MyBinary.java cambia, Bazel sabe volver a compilar MyBinary, pero reutilizar mylib. Si cambia un archivo de origen para //java/com/example/common, Bazel sabe que debe volver a compilar esa biblioteca, mylib y MyBinary, pero volver a usar //java/com/example/myproduct/otherlib. Debido a que Bazel conoce las propiedades de las herramientas que ejecuta en cada paso, puede volver a compilar solo el conjunto mínimo de artefactos cada vez y, al mismo tiempo, garantizar que no producirá compilaciones inactivas.

Reencuadrar el proceso de compilación en términos de artefactos en lugar de tareas es sutil pero poderoso. Al reducir la flexibilidad expuesta al programador, el sistema de compilación puede obtener más información sobre lo que se hace en cada paso de la compilación. Puede usar este conocimiento para hacer que la compilación sea mucho más eficiente, ya que paraleliza los procesos de compilación y reutiliza sus resultados. Sin embargo, este es solo el primer paso, y estos componentes básicos de paralelismo y reutilización son la base de un sistema de compilación distribuido y altamente escalable.

Otros trucos ingeniosos de Bazel

Los sistemas de compilación basados en artefactos resuelven fundamentalmente los problemas de paralelismo y reutilización que son inherentes a los sistemas de compilación basados en tareas. Sin embargo, todavía hay algunos problemas que surgieron antes y que no abordamos. Bazel tiene formas inteligentes de resolver cada una de ellas, y deberíamos hablarlas antes de continuar.

Herramientas como dependencias

Un problema con el que nos encontramos antes fue que las compilaciones dependían de las herramientas instaladas en nuestra máquina, y la reproducción de compilaciones en todos los sistemas podría ser difícil debido a las diferentes versiones o ubicaciones de las herramientas. El problema se vuelve aún más difícil cuando tu proyecto usa lenguajes que requieren diferentes herramientas según la plataforma en la que se compilan o compilan (como Windows frente a Linux), y cada una de esas plataformas requiere un conjunto de herramientas un poco diferente para realizar el mismo trabajo.

Para resolver la primera parte de este problema, Bazel trata las herramientas como dependencias de cada destino. Cada java_library en el espacio de trabajo depende de forma implícita de un compilador de Java, que, de forma predeterminada, es un compilador conocido. Cada vez que Bazel compila un java_library, verifica que el compilador especificado esté disponible en una ubicación conocida. Al igual que cualquier otra dependencia, si el compilador de Java cambia, se reconstruye cada artefacto que dependa de él.

Bazel resuelve la segunda parte del problema, la independencia de la plataforma, mediante la configuración de configuraciones de compilación. En lugar de depender directamente de sus herramientas, los objetivos dependen de los tipos de configuración:

  • Configuración del host: herramientas de compilación que se ejecutan durante la compilación
  • Target configuration: Compila el objeto binario que solicitaste en última instancia.

Cómo extender el sistema de compilación

Bazel incluye objetivos para varios lenguajes de programación populares desde el primer momento, pero los ingenieros siempre querrán hacer más. Parte del beneficio de los sistemas basados en tareas es su flexibilidad para admitir cualquier tipo de proceso de compilación, y sería mejor no renunciar a eso en un sistema de compilación basado en artefactos. Afortunadamente, Bazel permite que se extiendan sus tipos de destinos compatibles agregando reglas personalizadas.

Para definir una regla en Bazel, el autor de la regla declara las entradas que requiere la regla (en forma de atributos pasados en el archivo BUILD) y el conjunto fijo de salidas que produce la regla. El autor también define las acciones que se generarán con esa regla. Cada acción declara sus entradas y salidas, ejecuta un ejecutable o escribe una cadena específica en un archivo, y puede conectarse a otras acciones a través de sus entradas y salidas. Esto significa que las acciones son la unidad de componibilidad de nivel más bajo del sistema de compilación: una acción puede hacer lo que quiera, siempre que use solo las entradas y salidas declaradas, y Bazel se encarga de programar las acciones y almacenar en caché sus resultados según corresponda.

El sistema no es infalible, dado que no hay manera de impedir que un desarrollador de acciones realice acciones como introducir un proceso no determinista como parte de su acción. Sin embargo, esto no ocurre con mucha frecuencia en la práctica y reducir las posibilidades de abuso al nivel de acción disminuye en gran medida las oportunidades de errores. Las reglas compatibles con muchos lenguajes y herramientas comunes están disponibles en línea y la mayoría de los proyectos nunca tendrán que definir sus propias reglas. Incluso para aquellos que sí lo hacen, las definiciones de reglas solo deben definirse en un lugar central del repositorio, lo que significa que la mayoría de los ingenieros podrán usar esas reglas sin tener que preocuparse por su implementación.

Aislar el entorno

Parece que las acciones podrían tener los mismos problemas que las tareas de otros sistemas. ¿Acaso no es posible escribir acciones que ambas escriban en el mismo archivo y terminen en conflicto entre sí? En realidad, Bazel hace que estos conflictos sean imposibles con la zona de pruebas. En los sistemas compatibles, cada acción se aísla de las demás a través de la zona de pruebas del sistema de archivos. De hecho, cada acción solo puede ver una vista restringida del sistema de archivos que incluye las entradas que declaró y las salidas que produjo. Esto se aplica mediante sistemas como LXC en Linux, la misma tecnología detrás de Docker. Esto significa que es imposible que las acciones entren en conflicto entre sí porque no pueden leer ningún archivo que no declaren y los archivos que escriban, pero no declaren, se descartarán cuando la acción finalice. Bazel también usa zonas de pruebas para evitar que las acciones se comuniquen a través de la red.

Haz que las dependencias externas sean deterministas

Aún queda un problema por resolver: los sistemas de compilación a menudo necesitan descargar dependencias (ya sean herramientas o bibliotecas) de fuentes externas en lugar de compilarlas directamente. Esto se puede ver en el ejemplo a través de la dependencia @com_google_common_guava_guava//jar, que descarga un archivo JAR de Maven.

El uso de archivos fuera del espacio de trabajo actual es riesgoso. Esos archivos pueden cambiar en cualquier momento, lo que podría requerir que el sistema de compilación verifique constantemente si están actualizados. Si un archivo remoto cambia sin el cambio correspondiente en el código fuente del lugar de trabajo, también puede generar compilaciones no reproducibles: una compilación puede funcionar un día y fallar al siguiente sin motivo aparente debido a un cambio de dependencia no notado. Por último, una dependencia externa puede generar un gran riesgo de seguridad cuando es propiedad de un tercero: si un atacante puede infiltrarse en ese servidor de terceros, puede reemplazar el archivo de dependencia con algo de su propio diseño, lo que le da control total sobre tu entorno de compilación y su resultado.

El problema fundamental es que queremos que el sistema de compilación tenga en cuenta estos archivos sin tener que incluirlos en el control de código fuente. La actualización de una dependencia debería ser una decisión consciente, pero debe hacerse una vez en un lugar central, en lugar de que los ingenieros individuales o el sistema la administren automáticamente. Esto se debe a que, incluso con un modelo “Live at Head”, todavía queremos que las compilaciones sean deterministas, lo que implica que si consultas una confirmación de la semana pasada, deberías ver tus dependencias como estaban en ese momento y no como están ahora.

Bazel y algunos otros sistemas de compilación solucionan este problema, ya que requieren un archivo de manifiesto para todo el espacio de trabajo que enumere un hash criptográfico para cada dependencia externa del lugar de trabajo. El hash es una forma concisa de representar el archivo de forma única sin verificar todo el archivo en el control de código fuente. Cada vez que se hace referencia a una dependencia externa nueva desde un lugar de trabajo, el hash de esa dependencia se agrega al manifiesto, ya sea de forma manual o automática. Cuando Bazel ejecuta una compilación, verifica el hash real de su dependencia almacenada en caché con el hash esperado definido en el manifiesto y vuelve a descargar el archivo solo si el hash difiere.

Si el artefacto que descargamos tiene un hash diferente al declarado en el manifiesto, la compilación fallará a menos que se actualice el hash del manifiesto. Esto se puede hacer automáticamente, pero ese cambio se debe aprobar y registrar en el control de código fuente para que la compilación acepte la dependencia nueva. Esto significa que siempre hay un registro de cuándo se actualizó una dependencia y una dependencia externa no puede cambiar sin un cambio correspondiente en la fuente del lugar de trabajo. También significa que, cuando se verifica una versión anterior del código fuente, se garantiza que la compilación usará las mismas dependencias que usaba en el momento en que se registró esa versión (de lo contrario, fallará si esas dependencias ya no están disponibles).

Por supuesto, puede seguir siendo un problema si un servidor remoto deja de estar disponible o comienza a entregar datos dañados. Esto puede provocar que todas tus compilaciones comiencen a fallar si no tienes otra copia de esa dependencia disponible. Para evitar este problema, te recomendamos que, en cualquier proyecto no trivial, dupliques todas sus dependencias en servidores o servicios que sean de confianza y controles. De lo contrario, la disponibilidad de tu sistema de compilación siempre estará a merced de un tercero, incluso si los hashes registrados garantizan su seguridad.