Organizando un Monorepo de ML con Pants

Organizando Monorepo ML con Pants

¿Alguna vez has copiado y pegado fragmentos de código de utilidad entre proyectos, lo que ha resultado en varias versiones del mismo código en diferentes repositorios? ¿O tal vez has tenido que hacer solicitudes de extracción en decenas de proyectos después de que se actualizó el nombre del bucket de GCP en el que almacenas tus datos?

Las situaciones descritas anteriormente surgen con demasiada frecuencia en los equipos de ML, y sus consecuencias van desde la molestia de un solo desarrollador hasta la incapacidad del equipo para enviar su código según sea necesario. Afortunadamente, hay un remedio.

Sumergámonos en el mundo de los monorepos, una arquitectura ampliamente adoptada en grandes empresas de tecnología como Google, y cómo pueden mejorar tus flujos de trabajo de ML. Un monorepo ofrece una gran cantidad de ventajas que, a pesar de algunos inconvenientes, lo convierten en una opción convincente para administrar ecosistemas complejos de aprendizaje automático.

Debatiremos brevemente los méritos y deméritos de los monorepos, examinaremos por qué es una excelente elección de arquitectura para los equipos de aprendizaje automático y veremos cómo lo están utilizando las grandes empresas tecnológicas. Por último, veremos cómo aprovechar el poder del sistema de construcción Pants para organizar tu monorepo de aprendizaje automático en un sólido sistema de construcción de CI/CD.

Prepárate mientras nos embarcamos en este viaje para agilizar la gestión de tu proyecto de ML.

¿Qué es un monorepo?

Monorepo de aprendizaje automático | Fuente: Autor

Un monorepo (abreviatura de repositorio monolítico) es una estrategia de desarrollo de software en la que el código de muchos proyectos se almacena en el mismo repositorio. La idea puede ser tan amplia como todo el código de la empresa escrito en una variedad de lenguajes de programación almacenados juntos (¿alguien mencionó a Google?) o tan estrecha como un par de proyectos de Python desarrollados por un pequeño equipo arrojados en un único repositorio.

En esta publicación de blog, nos centraremos en los repositorios que almacenan código de aprendizaje automático.

Monorepos vs. polyrepos

Los monorepos contrastan fuertemente con el enfoque de los polyrepos, donde cada proyecto o componente individual tiene su propio repositorio separado. Se ha dicho mucho sobre las ventajas y desventajas de ambos enfoques, y no profundizaremos en este tema. Simplemente pongamos las bases sobre la mesa.

La arquitectura de monorepo ofrece las siguientes ventajas:

Arquitectura de monorepo | Fuente: Autor
  • Pipeline de CI/CD único, lo que significa que no hay conocimiento de implementación oculto repartido entre los colaboradores individuales en diferentes repositorios;
  • Commits atómicos, dado que todos los proyectos residen en el mismo repositorio, los desarrolladores pueden realizar cambios entre proyectos que abarcan varios proyectos pero se fusionan como un solo commit;
  • Fácil compartición de utilidades y plantillas entre proyectos;
  • Fácil unificación de estándares y enfoques de codificación;
  • Mejor descubrimiento de código.

Naturalmente, no hay almuerzos gratis. Tenemos que pagar por las ventajas mencionadas anteriormente, y el precio se presenta en forma de:

  • Desafíos de escalabilidad: A medida que el código base crece, administrar un monorepo puede volverse cada vez más difícil. A gran escala, necesitarás herramientas y servidores potentes para manejar operaciones como clonar, extraer y empujar cambios, lo que puede llevar una cantidad significativa de tiempo y recursos.
  • Complejidad: Un monorepo puede ser más complejo de administrar, especialmente en lo que respecta a dependencias y versiones. Un cambio en un componente compartido podría afectar potencialmente a muchos proyectos, por lo que se necesita precaución adicional para evitar cambios que rompan el código.
  • Visibilidad y control de acceso: Con todos trabajando desde el mismo repositorio, puede ser difícil controlar quién tiene acceso a qué. Si bien no es una desventaja en sí, podría generar problemas de naturaleza legal en casos en los que el código esté sujeto a un acuerdo de confidencialidad muy estricto.

La decisión de si las ventajas que ofrece un monorepo valen la pena pagar el precio debe ser determinada por cada organización o equipo individualmente. Sin embargo, a menos que estés operando a una escala prohibitivamente grande o estés lidiando con misiones ultrasecretas, argumentaría que, al menos en lo que respecta a mi área de experiencia, los proyectos de aprendizaje automático, un monorepo es una buena elección de arquitectura en la mayoría de los casos.

Hablemos sobre por qué eso es así.

Aprendizaje automático con monorepos

Hay al menos seis razones por las cuales los monorepos son particularmente adecuados para proyectos de aprendizaje automático.

  • 1
    Integración de canalización de datos
  • 2
    Coherencia en los experimentos
  • 3
    Simplificación de la versión del modelo
  • 4
    Colaboración interfuncional
  • 5
    Cambios atómicos
  • 6
    Unificación de los estándares de codificación

Integración de canalización de datos

Los proyectos de aprendizaje automático a menudo involucran canalizaciones de datos que preprocesan, transforman y alimentan los datos al modelo. Estas canalizaciones pueden estar estrechamente integradas con el código de ML. Mantener las canalizaciones de datos y el código de ML en el mismo repositorio ayuda a mantener esta integración estrecha y agilizar el flujo de trabajo.

Coherencia en los experimentos

El desarrollo de aprendizaje automático implica mucha experimentación. Tener todos los experimentos en un monorepo garantiza configuraciones de entorno consistentes y reduce el riesgo de discrepancias entre diferentes experimentos debido a versiones de código o datos variables.

Simplificación de la versión del modelo

En un monorepo, las versiones de código y modelo están sincronizadas porque se registran en el mismo repositorio. Esto facilita la gestión y el rastreo de las versiones del modelo, lo cual puede ser especialmente importante en proyectos donde la reproducibilidad de ML es crítica.

Solo toma el SHA de confirmación en cualquier momento dado y brinda información sobre el estado de todos los modelos y servicios.

Colaboración interfuncional

Los proyectos de aprendizaje automático a menudo implican colaboración entre científicos de datos, ingenieros de ML e ingenieros de software. Un monorepo facilita esta colaboración interfuncional al proporcionar una única fuente de verdad para todo el código y los recursos relacionados con el proyecto.

Cambios atómicos

En el contexto de ML, el rendimiento de un modelo puede depender de varios factores interconectados, como el preprocesamiento de datos, la extracción de características, la arquitectura del modelo y el postprocesamiento. Un monorepo permite cambios atómicos: un cambio en varios de estos componentes se puede confirmar como uno solo, asegurando que las interdependencias siempre estén sincronizadas.

Unificación de los estándares de codificación

Finalmente, los equipos de aprendizaje automático a menudo incluyen miembros sin formación en ingeniería de software. Estos matemáticos, estadísticos y econometristas son personas inteligentes con ideas brillantes y habilidades para entrenar modelos que resuelven problemas comerciales. Sin embargo, escribir código limpio, fácil de leer y mantener no siempre es su punto fuerte.

Un monorepo ayuda al verificar y hacer cumplir automáticamente los estándares de codificación en todos los proyectos, lo que no solo garantiza una alta calidad de código, sino que también ayuda a los miembros del equipo menos inclinados hacia la ingeniería a aprender y crecer.

Cómo lo hacen en la industria: monorepos famosos

En el panorama del desarrollo de software, algunas de las empresas más grandes y exitosas del mundo utilizan monorepos. Aquí hay algunos ejemplos destacados.

  • Google: Google ha sido un firme defensor del enfoque de monorepo. Todo su código, que se estima que contiene 2 mil millones de líneas de código, se encuentra en un solo repositorio masivo. Incluso publicaron un artículo al respecto.
  • Meta: Meta también emplea un monorepo para su vasto código base. Crearon un sistema de control de versiones llamado “Mercurial” para manejar el tamaño y la complejidad de su monorepo.
  • Twitter: Twitter ha estado gestionando su monorepo durante mucho tiempo utilizando Pants, ¡el sistema de compilación del que hablaremos a continuación!

Muchas otras empresas como Microsoft, Uber, Airbnb y Stripe también utilizan el enfoque de monorepo al menos para algunas partes de sus bases de código.

¡Basta de teoría! Veamos cómo construir realmente un monorepo de aprendizaje automático. Porque simplemente juntar lo que solían ser repositorios separados en una carpeta no hace el trabajo.

¿Cómo configurar un monorepo de ML con Python?

A lo largo de esta sección, basaremos nuestra discusión en un repositorio de aprendizaje automático de muestra que he creado para este artículo. Es un monorepo simple que contiene solo un proyecto o módulo: un clasificador de dígitos escritos a mano llamado mnist, en referencia al famoso conjunto de datos que utiliza.

Todo lo que necesitas saber en este momento es que en la raíz del monorepo hay un directorio llamado mnist y, en él, hay código Python para entrenar el modelo, las pruebas unitarias correspondientes y un Dockerfile para ejecutar el entrenamiento en un contenedor.

Utilizaremos este pequeño ejemplo para mantener las cosas simples, pero en un monorepo más grande, mnist sería solo una de las muchas carpetas de proyectos en la raíz del repositorio, cada una de las cuales contendrá código fuente, pruebas, archivos dockerfiles y archivos de requisitos como mínimo.

Sistema de construcción: ¿Por qué necesitas uno y cómo elegirlo?

¿Por qué?

Piensa en todas las acciones, aparte de escribir código, que los diferentes equipos que desarrollan diferentes proyectos dentro del monorepo realizan como parte de su flujo de trabajo de desarrollo. Ejecutarían analizadores estáticos en su código para asegurarse de cumplir con los estándares de estilo, ejecutarían pruebas unitarias, construirían artefactos como contenedores de Docker y ruedas de Python, los enviarían a repositorios de artefactos externos y los desplegarían en producción.

Toma las pruebas. 

Has realizado un cambio en una función de utilidad que mantienes, has ejecutado las pruebas y todo está correcto. Pero, ¿cómo puedes estar seguro de que tu cambio no está rompiendo el código para otros equipos que podrían estar importando tu utilidad? Deberías ejecutar también su conjunto de pruebas, por supuesto. 

Pero para hacer esto, necesitas saber exactamente dónde se está utilizando el código que has cambiado. A medida que el código base crece, descubrir esto manualmente no es escalable. Por supuesto, como alternativa, siempre puedes ejecutar todas las pruebas, pero nuevamente: ese enfoque no es muy escalable.

¿Por qué necesitas un sistema: pruebas | Fuente: Autor

Otro ejemplo, despliegue en producción

Ya sea que despliegues semanalmente, diariamente o de forma continua, cuando llegue el momento, construirías todos los servicios en el monorepo y los enviarías a producción. Pero, oye, ¿necesitas construir todos ellos en cada ocasión? Eso podría llevar mucho tiempo y ser costoso a gran escala. 

Algunos proyectos pueden no haberse actualizado durante semanas. Por otro lado, el código de utilidad compartido que utilizan podría haber recibido actualizaciones. ¿Cómo decidimos qué construir? Nuevamente, todo se trata de dependencias. Idealmente, solo construiríamos los servicios que se hayan visto afectados por los cambios recientes.

¿Por qué necesitas un sistema: despliegue | Fuente: Autor

Todo esto se puede manejar con un simple script de shell con una pequeña base de código, pero a medida que escala y los proyectos comienzan a compartir código, surgen desafíos, muchos de los cuales giran en torno a la gestión de dependencias. 

Elegir el sistema adecuado

Todo lo anterior ya no es un problema si inviertes en un sistema de construcción adecuado. La tarea principal de un sistema de construcción es construir código. Y debería hacerlo de manera inteligente: el desarrollador solo debería decirle qué construir (“construir imágenes de Docker afectadas por mi último commit” o “ejecutar solo esas pruebas que cubren el código que utiliza el método que he actualizado”), pero el cómo debería ser tarea del sistema.

Hay un par de excelentes sistemas de construcción de código abierto disponibles. Dado que la mayoría del aprendizaje automático se realiza en Python, centrémonos en aquellos con el mejor soporte para Python. Las dos opciones más populares en este sentido son Bazel y Pants. 

Bazel es una versión de código abierto del sistema de construcción interno de Google, Blaze. Pants también está fuertemente inspirado en Blaze y tiene objetivos de diseño técnico similares a Bazel. Un lector interesado encontrará una buena comparación de Pants vs. Bazel en esta publicación de blog (pero tenga en cuenta que proviene de los desarrolladores de Pants). La tabla en la parte inferior de monorepo.tools ofrece otra comparación.

Ambos sistemas son excelentes, y no es mi intención declarar una solución “mejor” aquí. Dicho esto, a menudo se describe a Pants como más fácil de configurar, más accesible y bien optimizado para Python, lo que lo convierte en una opción perfecta para monorepos de aprendizaje automático. 

En mi experiencia personal, el factor decisivo que me hizo elegir Pants fue su comunidad activa y servicial. Siempre que tengas preguntas o dudas, simplemente publica en el canal de Slack de la comunidad y un grupo de personas solidarias te ayudará pronto.

Presentando Pants

¡Bien, es hora de entrar en el meollo del asunto! Iremos paso a paso, presentando las diferentes funcionalidades de Pants y cómo implementarlas. Una vez más, puedes consultar el repositorio de muestra asociado aquí.

Configuración

Pants se puede instalar con pip. En este tutorial, usaremos la versión estable más reciente hasta la fecha de esta escritura, 2.15.1.

pip install pantsbuild.pants==2.15.1

Pants se puede configurar a través de un archivo de configuración maestro global llamado pants.toml. En él, podemos configurar el comportamiento propio de Pants, así como la configuración de las herramientas dependientes que utiliza, como pytest o mypy.

Empecemos con un pants.toml mínimo:

[GLOBAL]

pants_version = "2.15.1"

backend_packages = [

    "pants.backend.python",

]

[source]

root_patterns = ["/"]

[python]

interpreter_constraints = ["==3.9.*"]

En la sección global, definimos la versión de Pants y los paquetes de backend que necesitamos. Estos paquetes son los motores de Pants que admiten diferentes características. Para empezar, solo incluimos el backend de Python.

En la sección source, establecemos la fuente en la raíz del repositorio. A partir de la versión 2.15, para asegurarnos de que esto se detecte, también necesitamos agregar un archivo BUILD_ROOT vacío en la raíz del repositorio.

Por último, en la sección Python, elegimos la versión de Python a utilizar. Pants buscará en nuestro sistema una versión que cumpla con las condiciones especificadas aquí, así que asegúrate de tener instalada esta versión.

¡Eso es un comienzo! A continuación, echemos un vistazo al corazón de cualquier sistema de compilación: los archivos BUILD.

Archivos BUILD

Los archivos BUILD son archivos de configuración utilizados para definir objetivos (qué construir) y sus dependencias (lo que necesitan para funcionar) de manera declarativa.

Puedes tener varios archivos BUILD en diferentes niveles del árbol de directorios. Cuantos más haya, más granular será el control sobre la gestión de dependencias. De hecho, Google tiene un archivo BUILD en prácticamente cada directorio de su repositorio.

En nuestro ejemplo, usaremos tres archivos BUILD:

  • mnist/BUILD: en el directorio del proyecto, este archivo BUILD definirá los requisitos de Python para el proyecto y el contenedor de Docker a construir;
  • mnist/src/BUILD: en el directorio del código fuente, este archivo BUILD definirá las fuentes de Python, es decir, los archivos que serán cubiertos por comprobaciones específicas de Python;
  • mnist/tests/BUILD: en el directorio de pruebas, este archivo BUILD definirá qué archivos se ejecutarán con Pytest y qué dependencias son necesarias para que estas pruebas se ejecuten.

Echemos un vistazo al archivo mnist/src/BUILD:

python_sources(

    name="python",

    resolve="mnist",

    sources=["**/*.py"],

)

Al mismo tiempo, mnist/BUILD se ve así:

python_requirements(

    name="reqs",

    source="requirements.txt",

    resolve="mnist",

)

Las dos entradas en los archivos BUILD se denominan objetivos. Primero, tenemos un objetivo de fuentes de Python, que llamamos adecuadamente “python”, aunque el nombre podría ser cualquier otro. Definimos nuestras fuentes de Python como todos los archivos .py en el directorio. Esto es relativo a la ubicación del archivo BUILD, es decir: incluso si tuviéramos archivos Python fuera del directorio mnist/src, estas fuentes solo capturarían el contenido de la carpeta mnist/src. También hay un campo de resolución; hablaremos de ello en un momento.

A continuación, tenemos el objetivo de requisitos de Python. Le indica a Pants dónde encontrar los requisitos necesarios para ejecutar nuestro código Python (nuevamente, relativo a la ubicación del archivo BUILD, que está en la raíz del proyecto mnist en este caso).

Esto es todo lo que necesitamos para empezar. Para asegurarnos de que la definición del archivo BUILD sea correcta, ejecutemos:

pants tailor --check update-build-files --check ::

Como era de esperar, obtenemos: “No se encontraron cambios requeridos en los archivos BUILD”. ¡Bien!

Dediquemos un poco más de tiempo a este comando. En pocas palabras, un simple pants tailor puede crear automáticamente archivos BUILD. Sin embargo, a veces tiende a agregar demasiados para las necesidades de uno, por eso tiendo a agregarlos manualmente, seguido del comando anterior que verifica su corrección.

El doble punto y coma al final es una notación de Pants que le indica que ejecute el comando en todo el monorepo. Alternativamente, podríamos haberlo reemplazado por mnist: para ejecutarlo solo contra el módulo mnist.

Dependencias y archivos de bloqueo

Para llevar a cabo una gestión eficiente de las dependencias, Pants se basa en archivos de bloqueo. Los archivos de bloqueo registran las versiones y fuentes específicas de todas las dependencias utilizadas por cada proyecto. Esto incluye tanto las dependencias directas como las dependencias transitivas.

Al capturar esta información, los archivos de bloqueo garantizan que se utilicen las mismas versiones de las dependencias de manera consistente en diferentes entornos y compilaciones. En otras palabras, sirven como una instantánea del grafo de dependencias, asegurando la reproducibilidad y consistencia en las compilaciones.

Para generar un archivo de bloqueo para nuestro módulo mnist, necesitamos la siguiente adición a pants.toml:

[python]
interpreter_constraints = ["==3.9.*"]
enable_resolves = true
default_resolve = "mnist"

[python.resolves]
mnist = "mnist/mnist.lock"

Habilitamos las resoluciones (término de Pants para los entornos de los archivos de bloqueo) y definimos uno para mnist pasando una ruta de archivo. También lo elegimos como el predeterminado. Esta es la resolución que hemos pasado a las fuentes de Python y al objetivo de requisitos de Python antes: así es como saben qué dependencias se necesitan. Ahora podemos ejecutar:

pants generate-lockfiles

para obtener:

Completado: Generar archivo de bloqueo para mnist
Se escribió un archivo de bloqueo para la resolución `mnist` en mnist/mnist.lock

Esto ha creado un archivo en mnist/mnist.lock. Este archivo debe ser verificado con git si planea usar Pants para su CI/CD remoto. Y naturalmente, debe actualizarse cada vez que actualice el archivo requirements.txt.

Con más proyectos en el monorepo, preferiría generar los archivos de bloqueo selectivamente para el proyecto que lo necesita, por ejemplo, pants generate-lockfiles mnist: .

¡Eso es todo para la configuración! Ahora vamos a usar Pants para hacer algo útil para nosotros.

Unificando el estilo de código con Pants

Pants admite nativamente varios linters y herramientas de formato de código de Python, como Black, yapf, Docformatter, Autoflake, Flake8, isort, Pyupgrade o Bandit. Todos se utilizan de la misma manera; en nuestro ejemplo, implementemos Black y Docformatter.

Para hacerlo, agregamos dos backends apropiados a pants.toml:

[GLOBAL]
pants_version = "2.15.1"
colors = true
backend_packages = [
    "pants.backend.python",
    "pants.backend.python.lint.docformatter",
    "pants.backend.python.lint.black",
]

Podríamos configurar ambas herramientas si quisiéramos agregando secciones adicionales a continuación en el archivo toml, pero por ahora mantengamos los valores predeterminados.

Para usar los formateadores, debemos ejecutar lo que se llama una meta de Pants. En este caso, hay dos metas relevantes.

Primero, la meta lint ejecutará ambas herramientas (en el orden en que se enumeran en backend packages, por lo que Docformatter primero, Black segundo) en el modo de verificación.

pants lint ::

Completado: Formatear con docformatter - docformatter no realizó cambios.
Completado: Formatear con Black - black no realizó cambios.

✓ black tuvo éxito.
✓ docformatter tuvo éxito.

Parece que nuestro código se adhiere a los estándares de ambos formateadores. Sin embargo, si ese no fuera el caso, podríamos ejecutar la meta fmt (abreviatura de “formato”) que adapta el código adecuadamente:

pants fmt ::

En la práctica, es posible que desee usar más de estos dos formateadores. En este caso, es posible que necesite actualizar la configuración de cada formateador para asegurarse de que sea compatible con los demás. Por ejemplo, si está utilizando Black con su configuración predeterminada como lo hemos hecho aquí, esperará que las líneas de código no superen los 88 caracteres.

Pero si luego desea agregar isort para ordenar automáticamente sus importaciones, habrá conflictos: isort trunca las líneas después de 79 caracteres. Para hacer que isort sea compatible con Black, debería incluir la siguiente sección en el archivo toml:

[isort]
args = [
    "-l=88",
 ]

Todos los formateadores se pueden configurar de la misma manera en pants.toml pasando los argumentos a su herramienta subyacente.

Pruebas con Pants

¡Vamos a ejecutar algunas pruebas! Para ello, necesitamos dos pasos.

Primero, agregamos las secciones correspondientes a pants.toml:

[test]
output = "all"
report = false
use_coverage = true

[coverage-py]
global_report = true

[pytest]
args = ["-vv", "-s", "-W ignore::DeprecationWarning", "--no-header"]

Estas configuraciones se aseguran de que, al ejecutar las pruebas, se genere un informe de cobertura de pruebas. También pasamos un par de opciones personalizadas de pytest para adaptar su salida.

A continuación, debemos volver a nuestro archivo mnist/tests/BUILD y agregar un objetivo de pruebas de Python:

python_tests(
    name="tests",
    resolve="mnist",
    sources=["test_*.py"],
)

Lo llamamos “tests” y especificamos la resolución (es decir, el archivo de bloqueo) a utilizar. Las fuentes son las ubicaciones donde pytest buscará las pruebas a ejecutar; aquí, pasamos explícitamente todos los archivos .py con el prefijo “test_”.

Ahora podemos ejecutar:

pants test ::

para obtener:


✓ mnist/tests/test_data.py:../tests tuvo éxito en 3.83s.
✓ mnist/tests/test_model.py:../tests tuvo éxito en 2.26s.

Nombre                              Sentencias   Faltantes  Cobertura
------------------------------------------------------
__global_coverage__/no-op-exe.py       0          0         100%
mnist/src/data.py                     14         0         100%
mnist/src/model.py                    15         0         100%
mnist/tests/test_data.py              21         1         95%
mnist/tests/test_model.py             20         1         95%
------------------------------------------------------
TOTAL                                 70         2         97%

Como puedes ver, tomaron alrededor de tres segundos para ejecutar este conjunto de pruebas. Ahora, si lo volvemos a ejecutar, obtendremos los resultados de inmediato:

✓ mnist/tests/test_data.py:../tests tuvo éxito en 3.83s (memoizado).
✓ mnist/tests/test_model.py:../tests tuvo éxito en 2.26s (memoizado).

Observa cómo Pants nos dice que estos resultados están memoizados, o en caché. Como no se han realizado cambios en las pruebas, el código que se está probando o los requisitos, no es necesario volver a ejecutar las pruebas; sus resultados están garantizados que serán los mismos, por lo que se sirven directamente desde la caché.

Comprobación de tipado estático con Pants

Agreguemos una comprobación más de calidad de código. Pants permite utilizar mypy para verificar el tipado estático en Python. Todo lo que necesitamos hacer es agregar el backend de mypy en pants.toml: “pants.backend.python.typecheck.mypy”.

También es posible configurar mypy para que su salida sea más legible e informativa agregando la siguiente sección de configuración:

[mypy]
args = [
    "--ignore-missing-imports",
    "--local-partial-types",
    "--pretty",
    "--color-output",
    "--error-summary",
    "--show-error-codes",
    "--show-error-context",
]

Con esto, podemos ejecutar pants check :: para obtener:

Completado: Comprobación de tipos usando MyPy - mypy - mypy tuvo éxito.
Éxito: no se encontraron problemas en 6 archivos fuente

✓ mypy tuvo éxito.

Envío de modelos de aprendizaje automático con Pants

Hablemos del envío. La mayoría de los proyectos de aprendizaje automático involucran uno o más contenedores de Docker, por ejemplo, para procesar datos de entrenamiento, entrenar un modelo o servirlo a través de una API utilizando Flask o FastAPI. En nuestro proyecto de prueba, también tenemos un contenedor para el entrenamiento del modelo.

Pants admite la construcción y el envío automático de imágenes de Docker. Veamos cómo funciona.

Primero, agregamos el backend de Docker en pants.toml: pants.backend.docker. También configuramos nuestro Docker, pasándole varias variables de entorno y un argumento de compilación que será útil en un momento:

[docker]

build_args = ["SHORT_SHA"]

env_vars = ["DOCKER_CONFIG=%(env.HOME)s/.docker", "HOME", "USER", "PATH"]

Ahora, en el archivo mnist/BUILD, agregaremos otros dos objetivos: un objetivo de archivos y un objetivo de imagen de Docker.

files(

    name="module_files",

    sources=["**/*"],

)

docker_image(

    name="train_mnist",

    dependencies=["mnist:module_files"],

    registries=["docker.io"],

    repository="michaloleszak/mnist",

    image_tags=["latest", "{build_args.SHORT_SHA}"],

)

Llamamos al objetivo Docker “train_mnist”. Como dependencia, debemos pasarle la lista de archivos que se incluirán en el contenedor. La forma más conveniente de hacer esto es definir esta lista como un objetivo separado de archivos. Aquí, simplemente incluimos todos los archivos del proyecto mnist en un objetivo llamado “module_files” y lo pasamos como dependencia al objetivo de la imagen Docker.

Naturalmente, si sabes que solo se necesitará un subconjunto de archivos por el contenedor, es una buena idea pasar solo esos como dependencia. Esto es esencial porque estas dependencias son utilizadas por Pants para inferir si un contenedor se ha visto afectado por un cambio y necesita ser reconstruido. Aquí, con “module_files” incluyendo todos los archivos, si cambia cualquier archivo en la carpeta mnist (¡incluso un archivo readme!), Pants verá la imagen Docker “train_mnist” como afectada por este cambio.

Finalmente, también podemos configurar el registro externo y el repositorio al que se puede enviar la imagen, y las etiquetas con las que se enviará: aquí, enviaré la imagen a mi repositorio personal en DockerHub, siempre con dos etiquetas: “latest” y el SHA del commit abreviado que se pasará como argumento de construcción.

Con esto, podemos construir una imagen. Solo una cosa más: ya que Pants trabaja en sus entornos aislados, no puede leer las variables de entorno del host. Por lo tanto, para construir o enviar la imagen que requiere la variable “SHORT_SHA”, necesitamos pasarla junto con el comando Pants.

Podemos construir la imagen de esta manera:

SHORT_SHA=$(git rev-parse --short HEAD) pants package mnist:train_mnist 

para obtener:

Completado: Construyendo la imagen Docker docker.io/michaloleszak/mnist:latest +1 etiqueta adicional.
Imágenes Docker construidas: 
  * docker.io/michaloleszak/mnist:latest
  * docker.io/michaloleszak/mnist:0185754

Una rápida comprobación revela que las imágenes realmente se han construido:

docker images 


REPOSITORIO           ETIQUETA   ID DE IMAGEN        CREADO               TAMAÑO
michaloleszak/mnist   0185754   d86dca9fb037   Hace aproximadamente un minuto   3.71GB
michaloleszak/mnist   latest    d86dca9fb037   Hace aproximadamente un minuto   3.71GB

También podemos construir y enviar las imágenes de una vez usando Pants. Solo hace falta reemplazar el comando “package” con el comando “publish”.

SHORT_SHA=$(git rev-parse --short HEAD) pants publish mnist:train_mnist 

Esto construyó las imágenes y las envió a mi DockerHub, donde han llegado correctamente.

Pants en CI/CD

Los mismos comandos que acabamos de ejecutar manualmente localmente se pueden ejecutar como parte de una tubería de CI/CD. Puedes ejecutarlos a través de servicios como GitHub Actions o Google CloudBuild, por ejemplo, como una comprobación de PR antes de permitir que una rama de función se fusione con la rama principal, o después de la fusión, para validar que todo funcione correctamente y construir y enviar contenedores.

En nuestro repositorio de prueba, he implementado un gancho de pre-commit de empuje que ejecuta comandos de Pants en el empuje de git y solo lo permite si todos ellos pasan. En él, estamos ejecutando los siguientes comandos:

pants tailor --check update-build-files --check ::
pants lint ::
pants --changed-since=main --changed-dependees=transitive check
pants test ::

Puedes ver algunas banderas nuevas para el comando “pants check”, que es la comprobación de tipo con mypy. Se aseguran de que la comprobación solo se ejecute en archivos que han cambiado en comparación con la rama principal y sus dependencias transitivas. Esto es útil ya que mypy tiende a tomar algún tiempo para ejecutarse. Limitar su alcance a lo que realmente se necesita acelera el proceso.

¿Cómo se vería una construcción y envío de Docker en una tubería de CI/CD? Algo así:

pants --changed-since=HEAD^ --changed-dependees=transitive --filter-target-type=docker_image publish

Usamos el comando “publish” como antes, pero con tres argumentos adicionales:

  • –changed-since=HEAD^ y –changed-dependees=transitive se aseguran de que solo se construyan los contenedores afectados por los cambios en comparación con el commit anterior; esto es útil para ejecutarlo en la rama principal después de la fusión.
  • –filter-target-type=docker_image se asegura de que lo único que hace Pants es construir y enviar Docker; esto se debe a que el comando “pants publish” puede hacer referencia a objetivos distintos de Docker: por ejemplo, se puede utilizar para publicar gráficos de Helm en registros OCI.

Lo mismo ocurre con el paquete de Pants: además de construir imágenes de Docker, también puede crear un paquete de Python; por esa razón, es una buena práctica pasar la opción –filter-target-type.

Conclusión

Los monorepos son, en la mayoría de los casos, una excelente opción de arquitectura para equipos de aprendizaje automático. Sin embargo, administrarlos a gran escala requiere invertir en un sistema de compilación adecuado. Uno de esos sistemas es Pants: es fácil de configurar y usar, y ofrece soporte nativo para muchas características de Python y Docker que los equipos de aprendizaje automático suelen utilizar.

Además, es un proyecto de código abierto con una comunidad grande y útil. Espero que después de leer este artículo, decidas probarlo. ¡Incluso si actualmente no tienes un repositorio monolítico, Pants aún puede agilizar y facilitar muchos aspectos de tu trabajo diario!

Referencias

  • Documentación de Pants: https://www.pantsbuild.org/
  • Publicación de blog Pants vs. Bazel: https://blog.pantsbuild.org/pants-vs-bazel/
  • monorepo.tools: https://monorepo.tools/