Sistemas de compilación basados en artefactos

Informar un problema Ver código fuente Nocturno · 8.4 · 8.3 · 8.2 · 8.1 · 8.0 · 7.6

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 gran avance en comparación con las secuencias de comandos de compilación, otorgan 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 de compilación determina cómo hacerlo. 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 el contenido de esos archivos es muy diferente. En lugar de ser un conjunto imperativo de comandos en un lenguaje de secuencias de comandos 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 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 es responsable de configurar, ejecutar y programar los pasos de compilación (el cómo). Dado 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 corrección.

Una perspectiva funcional

Es fácil establecer 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 ejecutan una tras otra, de la misma manera en 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 los lenguajes funcionales, el programador describe un cálculo que se debe realizar, pero deja los detalles de cuándo y cómo se ejecuta ese cálculo exactamente al compilador.

Esto se relaciona con la idea de declarar un manifiesto en un sistema de compilación basado en artefactos y dejar que el sistema determine cómo ejecutar la compilación. Muchos problemas no se pueden expresar fácilmente con la programación funcional, pero los que sí se benefician enormemente de ella: el lenguaje a menudo puede paralelizar trivialmente esos programas y hacer fuertes garantías sobre su corrección que serían imposibles en un lenguaje imperativo. Los problemas más fáciles de expresar con programación funcional son aquellos que simplemente implican transformar un fragmento de datos en otro usando una serie de reglas o funciones. Y 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 fuente (y herramientas como el compilador) como entradas y produce archivos binarios como salidas. Por lo tanto, no es sorprendente que funcione bien basar un sistema de compilación en 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. Bazel es la versión de código abierto de 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 destinos. Los dos tipos de destinos que se muestran aquí son java_binary y java_library. Cada destino corresponde a un artefacto que puede crear el sistema: los destinos binarios producen archivos binarios que se pueden ejecutar directamente, y los destinos de biblioteca producen bibliotecas que pueden usar los archivos binarios o las otras bibliotecas. Cada objetivo tiene lo siguiente:

  • name: Cómo se hace referencia al destino en la línea de comandos y en otros destinos
  • srcs: Son los archivos fuente que se compilan para crear el artefacto del destino.
  • deps: 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, realizas 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 del espacio 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 destino del que depende MyBinary y cada destino del que dependen esos destinos, de forma 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 se deben compilar para cada destino. Apenas se compilan todas las dependencias de un destino, Bazel comienza a compilar ese destino. Este proceso continúa hasta que se compilan todas las dependencias transitivas de MyBinary.
  4. Compila MyBinary para producir un archivo binario ejecutable final que vincula todas las dependencias que se compilaron en el paso 3.

Fundamentalmente, lo que sucede aquí no parece ser tan diferente de lo que sucedía cuando se usaba un sistema de compilación basado en tareas. De hecho, el resultado final es el mismo binario, y el proceso para producirlo implicó analizar varios pasos para encontrar dependencias entre ellos y, luego, ejecutar esos pasos en orden. Sin embargo, existen diferencias importantes. El primero aparece en el paso 3: Como Bazel sabe que cada destino solo produce una biblioteca de Java, sabe que lo único 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 una mejora del rendimiento de un orden de magnitud en comparación con la compilación de destinos uno a la vez en una máquina multinúcleo, 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 van 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 realizar ningún cambio: Bazel sale en menos de un segundo con un mensaje que indica que el destino está actualizado. Esto es posible gracias al paradigma de programación funcional del que hablamos antes: Bazel sabe que cada destino es el resultado de ejecutar un compilador de Java y sabe que el resultado del compilador de Java depende solo de sus entradas, por lo que, mientras las entradas no cambien, el resultado se puede reutilizar. Este análisis funciona en todos los niveles. Si cambia MyBinary.java, Bazel sabe que debe volver a compilar MyBinary, pero reutilizar mylib. Si cambia un archivo fuente para //java/com/example/common, Bazel sabe que debe volver a compilar esa biblioteca, mylib y MyBinary, pero reutilizar //java/com/example/myproduct/otherlib. Como 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 garantizar que no producirá compilaciones obsoletas.

Reformular el proceso de compilación en términos de artefactos en lugar de tareas es sutil, pero potente. Al reducir la flexibilidad expuesta al programador, el sistema de compilación puede saber más 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 forman la base de un sistema de compilación distribuido y altamente escalable.

Otros trucos útiles de Bazel

Los sistemas de compilación basados en artefactos resuelven fundamentalmente los problemas de paralelismo y reutilización inherentes a los sistemas de compilación basados en tareas. Sin embargo, aún quedan algunos problemas que surgieron antes y que no abordamos. Bazel tiene formas inteligentes de resolver cada uno de estos problemas, y deberíamos analizarlos 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 reproducir compilaciones en diferentes 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 (por ejemplo, Windows en comparación con Linux), y cada una de esas plataformas requiere un conjunto de herramientas ligeramente diferente para hacer el mismo trabajo.

Bazel resuelve la primera parte de este problema tratando las herramientas como dependencias de cada destino. Cada java_library del espacio de trabajo depende implícitamente 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 con cualquier otra dependencia, si cambia el compilador de Java, se vuelve a compilar cada artefacto que dependa de él.

Bazel resuelve la segunda parte del problema, la independencia de la plataforma, estableciendo configuraciones de compilación. En lugar de que los destinos dependan directamente de sus herramientas, dependen de tipos de configuraciones:

  • Configuración del host: Herramientas de compilación que se ejecutan durante la compilación
  • Configuración del destino: Compilación del binario que solicitaste

Extensión del sistema de compilación

Bazel incluye objetivos para varios lenguajes de programación populares de forma predeterminada, 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 sus tipos de destinos compatibles se extiendan 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 que se pasan en el archivo BUILD) y el conjunto fijo de salidas 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 archivo ejecutable en particular o escribe una cadena específica en un archivo, y se puede conectar a otras acciones a través de sus entradas y salidas. Esto significa que las acciones son la unidad componible de nivel más bajo en el sistema de compilación. Una acción puede hacer lo que quiera, siempre y cuando use solo sus 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, ya que no hay forma de impedir que un desarrollador de acciones haga algo como introducir un proceso no determinístico como parte de su acción. Sin embargo, esto no sucede muy a menudo en la práctica, y llevar las posibilidades de abuso hasta el nivel de la acción disminuye en gran medida las oportunidades de 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 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.

Aísla el entorno

Las acciones parecen tener los mismos problemas que las tareas en otros sistemas. ¿No es posible escribir acciones que escriban en el mismo archivo y terminen en conflicto entre sí? De hecho, Bazel hace que estos conflictos sean imposibles con el uso de zonas de pruebas. En los sistemas compatibles, cada acción se aísla de todas las demás a través de un sandbox del sistema de archivos. Efectivamente, cada acción solo puede ver una vista restringida del sistema de archivos que incluye las entradas que declaró y los resultados que produjo. Esto se aplica con 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í, ya que no pueden leer ningún archivo que no declaren, y los archivos que escriban pero no declaren se descartarán cuando finalice la acción. Bazel también usa zonas de pruebas para restringir las acciones que se comunican a través de la red.

Cómo hacer que las dependencias externas sean determinísticas

Sin embargo, aún queda un problema: 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.

Depender de archivos fuera del espacio de trabajo actual es riesgoso. Esos archivos podrían cambiar en cualquier momento, lo que podría requerir que el sistema de compilación compruebe constantemente si están actualizados. Si un archivo remoto cambia sin un cambio correspondiente en el código fuente del espacio de trabajo, también puede generar compilaciones no reproducibles: una compilación puede funcionar un día y fallar al día siguiente sin un motivo obvio debido a un cambio de dependencia inadvertido. Por último, una dependencia externa puede introducir un enorme riesgo de seguridad cuando es propiedad de un tercero: si un atacante puede infiltrarse en el servidor de ese tercero, puede reemplazar el archivo de dependencia por algo de su propio diseño, lo que podría darle el 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. Actualizar una dependencia debe ser una elección consciente, pero esa elección debe hacerse una vez en un lugar central en lugar de ser administrada por ingenieros individuales o automáticamente por el sistema. Esto se debe a que, incluso con un modelo “Live at Head”, queremos que las compilaciones sean determinísticas, lo que implica que, si extraes un commit de la semana pasada, deberías ver tus dependencias tal como eran entonces y no como son ahora.

Bazel y otros sistemas de compilación abordan este problema exigiendo un archivo de manifiesto para todo el espacio de trabajo que enumere un hash criptográfico para cada dependencia externa en el espacio de trabajo. El hash es una forma concisa de representar de forma única el archivo sin registrar todo el archivo en el control de origen. Cada vez que se hace referencia a una nueva dependencia externa desde un espacio 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 del que se declaró 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 debe aprobarse y registrarse 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 que una dependencia externa no puede cambiar sin un cambio correspondiente en la fuente del espacio de trabajo. También significa que, cuando se extrae 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, lo que 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 el caso de cualquier proyecto no trivial, dupliques todas sus dependencias en servidores o servicios en los que confíes y que controles. De lo contrario, siempre dependerás de un tercero para la disponibilidad de tu sistema de compilación, incluso si los hashes registrados garantizan su seguridad.