Cómo contenedorizar un sistema de compilación

Compilar una estructura repetible para mostrar aplicaciones como contenedores puede ser complicado. Aquí te mostraremos una forma de hacerlo de manera efectiva.

Un sistema de compilación se compone de las herramientas y procesos utilizados para la transición del código fuente a una aplicación en ejecución.

Esta transición también implica cambiar la audiencia del código del desarrollador de software al usuario final. Esto ya sea que el usuario final sea un colega en operaciones o un sistema de implementación.

Después de crear algunos sistemas de compilación utilizando contenedores, creo que tengo un enfoque decente y repetible que vale la pena compartir. Estos sistemas de compilación se utilizaron para generar imágenes de software cargables para hardware incorporado y compilar algoritmos de aprendizaje automático. Empero el enfoque es lo suficientemente abstracto como para ser utilizado en cualquier sistema de compilación basado en contenedores.

Este enfoque se trata de crear u organizar el sistema de compilación de manera que sea fácil de usar y mantener. No se trata de los trucos necesarios para lidiar con la compilación de herramientas o compiladores de software en particular.

Se aplica al caso de uso común de los desarrolladores de software que crean software para entrega una imagen mantenible a otros usuarios técnicos. Estos usuarios pueden ser administradores de sistemas, ingenieros de DevOps o algún otro título. El sistema de compilación se abstrae de los usuarios finales para que puedan centrarse en el software.

¿Por qué contenerizar un sistema de compilación?

Crear un sistema de compilación repetible basado en contenedores puede proporcionar una serie de beneficios a un equipo de software:

  • Enfoque. Quiero centrarme en escribir mi solicitud. Cuando llamo a una herramienta para “compilar”, quiero que el conjunto de herramientas entregue un binario listo para usar. No quiero pasar tiempo resolviendo problemas del sistema de compilación. De hecho, prefiero no saber o preocuparme por el sistema de compilación.
  • Comportamiento de compilación idéntico. Sea ​​cual sea el caso de uso, quiero asegurarme de que todo el equipo utilice las mismas versiones del conjunto de herramientas. Asimismo, que obtenga los mismos resultados al compilar. De lo contrario, estoy constantemente lidiando con el caso de “funciona en mi PC, pero no en la tuya”. Usar la misma versión del conjunto de herramientas y obtener una salida idéntica para un conjunto de archivos fuente de entrada dado es fundamental en un proyecto de equipo.
  • Configuración fácil y migración futura. Incluso si se proporciona un conjunto detallado de instrucciones a todos para instalar un conjunto de herramientas para un proyecto, probablemente alguien se equivoque. O podría haber problemas debido a cómo cada persona ha personalizado su entorno Linux. Esto puede agravarse aún más por el uso de diferentes distribuciones de Linux en todo el equipo (u otros sistemas operativos). Los problemas pueden ponerse más feos rápidamente cuando llega el momento de pasar a la próxima versión del conjunto de herramientas. El uso de contenedores y las pautas de este artículo facilitará la migración a versiones más nuevas.

Contenedorización

La contenedorización de los sistemas de compilación que utilizo sin duda ha sido valiosa en mi experiencia, ya que ha aliviado los problemas anteriores. Tiendo a usar Docker para mis herramientas de contenedor. Empero, aún puede haber problemas debido a que la instalación y la configuración de la red son únicas de un entorno a otro. Esto especialmente si trabajas en un entorno corporativo que involucra algunas configuraciones complejas de proxy. Pero al menos ahora tengo menos problemas de sistema de compilación con los que lidiar.

Caminando a través de un sistema de compilación en contenedores

Creé un repositorio tutorial que puedes clonar y examinar en otro momento o seguir a lo largo de este artículo. Recorreré todos los archivos del repositorio. El sistema de compilación es deliberadamente trivial (ejecuta gcc) para mantener el enfoque en la arquitectura del sistema de compilación.

Requisitos del sistema de compilación

Dos aspectos clave que creo que son deseables en un sistema de compilación son:

  • Invocación de compilación estándar. Quiero poder compilar código apuntando a algún directorio de trabajo cuya ruta sea /path/to/workdir. Quiero invocar la compilación como:

Para mantener la arquitectura de ejemplo simple (en aras de la explicación), asumiré que la salida también se genera en algún lugar dentro de /path/to/workdir. (De lo contrario, aumentaría la cantidad de volúmenes expuestos al contenedor, lo que no es difícil, pero es más engorroso de explicar).

  • Invocación de compilación personalizada a través de shell. A veces, el conjunto de herramientas debe utilizarse de manera imprevista. Además del build.sh estándar para invocar el conjunto de herramientas, algunos de estos podrían agregarse como opciones para build.sh, si es necesario. Pero siempre quiero poder llegar a una shell donde puedo invocar comandos del conjunto de herramientas directamente. En este ejemplo trivial, digamos que a veces quiero probar diferentes opciones de optimización de gcc para ver los efectos. Para lograr esto, quiero invocar:

Esto debería llevarme a una shell Bash dentro del contenedor con acceso al conjunto de herramientas y a mi workdir, para que puedas experimentar lo que quieras con el conjunto de herramientas.

Compilar la arquitectura del sistema

Para cumplir con los requisitos básicos anteriores, así es como diseño el sistema de compilación:

En la parte inferior, el workdir representa cualquier código fuente de software que los usuarios finales deben desarrollar. Típicamente, este workdir será un repositorio de código fuente.

Los usuarios finales pueden manipular este repositorio de código fuente de la forma que deseen antes de invocar una compilación. Por ejemplo, si están usando git para el control de versiones, podrían hacer git checkout a la rama de características en la que están trabajando y agregar o modificar archivos. Esto mantiene el sistema de compilación independiente del workdir.

Los tres bloques en la parte superior representan colectivamente el sistema de compilación en contenedores. El bloque más a la izquierda (amarillo) en la parte superior representa los scripts (build.sh y shell.sh). Estos son los scripts el usuario final usará para interactuar con el sistema de compilación.

En el medio (el bloque rojo) se encuentra el Dockerfile y el script asociado build_docker_image.sh. La gente de operaciones de desarrollo (yo, en este caso) normalmente ejecutará este script y generará la imagen del contenedor. (De hecho, ejecutaré esto muchas, muchas veces hasta que todo funcione correctamente, pero esa es otra historia). Y luego distribuiría la imagen a los usuarios finales, como a través de un registro confiable de contenedor.

Los usuarios finales necesitarán esta imagen. Además, clonarán el repositorio del sistema de compilación (es decir, uno que sea equivalente al repositorio del tutorial).

El script run_build.sh a la derecha se ejecuta dentro del contenedor cuando el usuario final invoca build.sh o shell.sh. Explicaré estos scripts en detalle a continuación. La clave aquí es que el usuario final no necesita saber nada sobre los bloques rojos o azules o cómo funciona un contenedor para poder usar todo esto.

Detalles del sistema de compilación

La estructura de archivos del repositorio tutorial se asigna a esta arquitectura. He usado esta estructura prototipo para sistemas de compilación relativamente complejos, por lo que su simplicidad no es una limitación de ninguna manera. A continuación, he enumerado la estructura de árbol de los archivos relevantes del repositorio. La carpeta dockerize-tutorial podría reemplazarse con cualquier otro nombre correspondiente a un sistema de compilación. Desde esta carpeta, invoco build.sh o shell.sh con el único argumento que es la ruta al workdir.

Tenga en cuenta que he excluido deliberadamente el example_workdir anterior, que encontrarás en el repositorio del tutorial. El código fuente real normalmente residiría en un repositorio separado y no sería parte del repositorio de la herramienta de compilación. Lo incluí en este repositorio, por lo que no tuve que lidiar con dos repositorios en el tutorial.

Hacer el tutorial no es necesario si solo te interesan los conceptos, ya que explicaré todos los archivos. Pero si deseas seguir (y tener Docker instalado), primero debes compilar la imagen del contenedor swbuilder:v1 con:

Luego debes invocar build.sh con:

El código para build.sh está debajo. Este script crea una instancia de un contenedor del generador de imágenes swbuilder:v1. Realiza dos asignaciones de volumen: una desde la carpeta example_workdir a un volumen dentro del contenedor en la ruta /workdir. La segunda desde dockerize-tutorial/swbuilder/scripts fuera del contenedor a /scripts dentro del contenedor.

Además, build.sh también invoca el contenedor para que se ejecute con tu nombre de usuario (y grupo, que el tutorial asume que es el mismo). Esto para que no tengas problemas con los permisos de archivo al acceder a la salida de compilación generada.

Shell.sh

Ten en cuenta que shell.sh es idéntico, excepto por dos cosas: build.sh crea un contenedor llamado build_swbuilder, mientras que shell.sh crea uno llamado shell_swbuilder. Esto es para que no haya conflictos si se invoca cualquier script mientras se ejecuta el otro.

La otra diferencia clave entre los dos scripts es el último argumento: build.sh pasa en el argumento build mientras shell.sh pasa en el argumento shell. Si observas el Dockerfile que se usa para crear la imagen del contenedor, la última línea contiene el siguiente ENTRYPOINT.

Esto significa que la invocación de ejecución del contenedor de docker anterior dará como resultado la ejecución del script run_build.sh con build o shell como único argumento de entrada.

run_build.sh usa este argumento de entrada para iniciar la shell Bash o invocar gcc para realizar la compilación del proyecto trivial helloworld.c. Un sistema de compilación real normalmente invocaría un Makefile y no ejecutaría gcc directamente.

Ciertamente, podrías pasar más de un argumento si tu caso de uso lo exige. Para los sistemas de compilación con los que me he ocupado, la compilación generalmente es para un proyecto determinado con una invocación de creación específica. En el caso de un sistema de compilación donde la invocación de compilación es compleja, puedes hacer que run_build.sh llame a un script específico. Este script debe estar dentro de workdir y el usuario final tiene que escribirlo.

Una nota sobre la carpeta de scripts

Tal vez te preguntes por qué la carpeta de scripts se encuentra en lo profundo de la estructura de árbol en lugar de en el nivel superior del repositorio. Cualquiera de los enfoques funcionaría, pero no quería alentar al usuario final a hurgar y cambiar las cosas allí.

Colocarlo más profundo es una forma de hacer que sea más difícil hurgar. Además, podría haber agregado un archivo .dockerignore para ignorar la carpeta de scripts, ya que no necesita ser parte del contexto del contenedor. Pero como es pequeño, no me molesté.

Simple pero flexible

Si bien el enfoque es simple, lo he usado para algunos sistemas de compilación bastante diferentes y me pareció bastante flexible. Los aspectos que van a ser relativamente estables (por ejemplo, un conjunto de herramientas determinado que cambia solo unas pocas veces al año) se fijan dentro de la imagen del contenedor.

Los aspectos que son más fluidos se mantienen fuera de la imagen del contenedor como scripts. Esto me permite modificar fácilmente cómo se invoca el conjunto de herramientas actualizando el script y enviando los cambios al repositorio del sistema de compilación.

Todo lo que el usuario necesita hacer es extraer los cambios a su repositorio del sistema de compilación local, que generalmente es bastante rápido. A diferencia de actualizar una imagen de Docker. La estructura se presta para tener tantos volúmenes y scripts como sea necesario mientras abstrae la complejidad del usuario final.