Sistemas de compilación basados en artefactos

En esta página, se abordan 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 encima de las secuencias de comandos de compilación, les dan demasiado poder a los ingenieros individuales, ya que 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 indican al sistema qué compilar, pero el sistema determina cómo compilarlo. Al igual que 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 programación Turing completo que describe cómo producir un resultado, los archivos de compilación en Bazel son un manifiesto declarativo que describe un conjunto de artefactos para compilar, sus dependencias y un conjunto limitado de opciones que afectan cómo se compilan. Cuando los ingenieros ejecutan bazel en la línea de comandos, especifican un conjunto de destinos para compilar (el qué), y Bazel es responsable de configurar, ejecutar y programar los pasos de compilación (el cómo). Como el sistema de compilación ahora tiene control total sobre las herramientas que se ejecutan, puede hacer 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 hacer 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 declaraciones que se ejecutarán una después de la 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 AA), están estructurados más como una serie de ecuaciones matemáticas. En lenguajes funcionales, el programador describe un cálculo para realizar, pero deja los detalles sobre cuándo y exactamente cómo se ejecuta ese cálculo al compilador.

Esto se mapea a la idea de declarar un manifiesto en un sistema de compilación basado en artefactos y permitir que el sistema decida 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 de él: el lenguaje suele ser capaz de paralelizar trivialmente esos programas y garantizar de forma sólida que no se podrá corregir en un lenguaje imperativo. Los problemas más fáciles de expresar mediante la programación funcional son los que implican simplemente transformar un dato en otro con una serie de reglas o funciones. Eso es exactamente lo que es un sistema de compilación: todo el sistema es una función matemática que toma archivos de origen (y herramientas como el compilador) como entradas y produce objetos binarios como resultados. Por lo tanto, no es de extrañarse que funcione bien para establecer 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

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

Así se ve un archivo de compilación (normalmente llamado 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 objetivos. Los dos tipos de destinos son java_binary y java_library. Cada destino corresponde a un artefacto que puede crear el sistema: los objetos binarios producen objetos binarios que se pueden ejecutar directamente, y los objetivos de la biblioteca producen bibliotecas que los objetos binarios y otras bibliotecas pueden usar. Cada objetivo tiene lo siguiente:

  • name: cómo se hace referencia al objetivo en la línea de comandos y mediante otros destinos
  • srcs: Son los archivos de origen que se compilarán para crear el artefacto para el destino.
  • deps: Son otros destinos que se deben compilar antes de este destino 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 origen (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:

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

En principio, puede parecer que lo que sucede aquí no es lo mismo que sucede cuando se usa un sistema de compilación basado en tareas. En efecto, el resultado final es el mismo objeto binario, y el proceso para producirlo implicaba el análisis de varios pasos a fin de encontrar dependencias entre ellos y, luego, ejecutar esos pasos en orden. Sin embargo, existen diferencias fundamentales. El primero aparece en el paso 3: dado que Bazel sabe que cada destino solo produce una biblioteca de Java, sabe que todo lo que tiene que hacer es ejecutar el compilador de Java en lugar de una secuencia de comandos arbitraria definida por el usuario, por lo que sabe que es seguro ejecutar estos pasos en paralelo. Esto puede producir un orden de mejora del rendimiento de magnitud en comparación con los objetivos de compilación uno a la vez en una máquina de varios núcleos, y solo es posible porque el enfoque basado en artefactos deja el sistema de compilación a cargo de su propia estrategia de ejecución para que pueda garantizar de forma más sólida el paralelismo.

Sin embargo, los beneficios van más allá del paralelismo. Lo siguiente que nos brinda este enfoque es evidente cuando el desarrollador escribe bazel build :MyBinary por segunda vez sin realizar ningún cambio: Bazel sale en menos de un segundo con un mensaje que dice que el objetivo está actualizado. Esto es posible debido al paradigma de programación funcional que mencionamos antes. Bazel sabe que cada objetivo es solo el resultado de ejecutar un compilador de Java y sabe que el resultado del compilador de Java solo depende de sus entradas, de manera que, si las entradas no cambiaron, el resultado se puede volver a usar. Y este análisis funciona en todos los niveles; si cambia MyBinary.java, Bazel sabe volver a compilar MyBinary, pero reutiliza mylib. Si cambia un archivo de origen para //java/com/example/common, Bazel sabe volver a compilar esa biblioteca, mylib y MyBinary, pero reutiliza //java/com/example/myproduct/otherlib. Debido a que Bazel conoce las propiedades de las herramientas que ejecuta en cada paso, es capaz de reconstruir solo el conjunto mínimo de artefactos cada vez y garantiza que no producirá compilaciones inactivas.

Reenmarcar 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 mediante la paralelización de los procesos de compilación y la reutilización de sus resultados. Sin embargo, este es solo el primer paso, y estos componentes fundamentales del paralelismo y la 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 solucionan de forma fundamental los problemas del paralelismo y la reutilización que son inherentes a los sistemas de compilación basados en tareas. Sin embargo, todavía surgieron algunos problemas que surgieron antes y que no abordamos. Bazel tiene maneras inteligentes de resolver cada una de ellas, y debemos analizarlas antes de continuar.

Herramientas como dependencias

Uno de los problemas que encontramos antes fue que las compilaciones dependían de las herramientas instaladas en nuestra máquina, y reproducir versiones en todos los sistemas podí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 ligeramente diferente para hacer el mismo trabajo.

Para solucionar la primera parte de este problema, Bazel trata las herramientas como dependencias de cada destino. Cada java_library del lugar de trabajo depende de forma implícita de un compilador de Java, que usa un compilador conocido de forma predeterminada. Cada vez que Bazel compila un elemento java_library, se asegura de que el compilador especificado esté disponible en una ubicación conocida. Al igual que con cualquier otra dependencia, si cambia el compilador de Java, se reconstruyen todos los artefactos que dependen 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 objetivos, según directamente sus herramientas, dependen de los distintos tipos de configuración:

  • Configuración del host: herramientas de compilación que se ejecutan durante la compilación
  • Configuración de destino: 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 de los beneficios 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 los tipos de destino admitidos 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 resultados que produce la regla. El autor también define las acciones que generará esa regla. Cada acción declara sus entradas y salidas, ejecuta un ejecutable determinado o escribe una string en particular en un archivo, y se puede conectar a otras acciones a través de sus entradas y salidas. Eso significa que las acciones son la unidad que admite composición de nivel inferior en el sistema de compilación: una acción puede hacer lo que quiera, siempre que use solo sus entradas y salidas declaradas, y Bazel se encarga de programar acciones y almacenar en caché sus resultados según corresponda.

El sistema no es infalible, ya que no hay forma de evitar que un desarrollador de acciones realice acciones como introducir un proceso no determinista como parte de su acción. Sin embargo, esto no sucede con mucha frecuencia en la práctica, y reducir las posibilidades de abuso hasta el nivel de acción disminuye enormemente las oportunidades de cometer errores. Las reglas que admiten muchos lenguajes y herramientas comunes están disponibles en línea, y la mayoría de los proyectos nunca necesitarán definir sus propias reglas. Incluso para aquellos que lo hacen, las definiciones de reglas solo deben definirse en un lugar central en el repositorio, lo que significa que la mayoría de los ingenieros podrán usarlas sin tener que preocuparse nunca por su implementación.

Aísla el entorno

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

Cómo hacer que las dependencias externas sean deterministas

Queda un problema pendiente: 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.

La dependencia de archivos fuera del lugar de trabajo actual es un proceso riesgoso. Esos archivos pueden cambiar en cualquier momento, por lo que es posible que el sistema de compilación deba verificar constantemente si están actualizados. Si un archivo remoto cambia sin un cambio correspondiente en el código fuente del lugar de trabajo, también puede dar lugar a compilaciones no reproducibles: una compilación puede funcionar un día y fallar la siguiente sin un motivo evidente debido a un cambio imprevisto de la dependencia. Por último, una dependencia externa puede generar un enorme riesgo de seguridad cuando pertenece a un tercero: si un atacante puede infiltrarse en ese servidor de terceros, puede reemplazar el archivo de dependencia por 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 conozca estos archivos sin tener que registrarlos en el control de código fuente. La actualización de una dependencia debe ser una opción consciente, pero esa opción debe tomarse una vez en un lugar central, en lugar de administrarla de manera individual por parte de ingenieros individuales o de forma automática por el sistema. Esto se debe a que, incluso con un modelo de "Live at Head", aún 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 en lugar de como están ahora.

Bazel y algunos otros sistemas de compilación abordan este problema, ya que requieren un archivo de manifiesto para todo el lugar de trabajo que enumere un hash criptográfico por cada dependencia externa en el 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. Cuando se hace referencia a una dependencia externa nueva desde un lugar de trabajo, se agrega el hash de esa dependencia 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 es diferente.

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 en el manifiesto. Esto se puede hacer automáticamente, pero ese cambio se debe aprobar y registrar en el control de código fuente antes de que la compilación acepte la nueva dependencia. 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 revisa una versión anterior del código fuente, se garantiza que la compilación use 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 ser un problema si un servidor remoto deja de estar disponible o comienza a entregar datos corruptos; esto podría causar 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 en los que confíes y controles. De lo contrario, siempre estarás a cargo de un tercero para la disponibilidad de tu sistema de compilación, incluso si los hash registrados garantizan su seguridad.