18 junio 2017

Empaquetando aplicaciones Python - Paquetes DEB (y RPM)

La manera nativa de distribuir código Python es a través de PyPI, como expliqué en un artículo previo. Pero para ser sinceros PyPI tiene varias pegas que limitan su uso a entornos de desarrollo.

El problema con PyPI es que no implementa una gestión de paquetes completa de tal manera que la desinstalación de paquetes de pip deja que desear a menudo y no hay manera de revertir una desinstallación con errores. Además, los procedimientos de instalación de pip a menudo compilan paquetes de código fuente lo que puede ser terriblemente lento para un virtualenv completo.

Desplegar aplicaciones Python debería ser rápido y debería permitir hacerlo de manera que, llegado el caso, las desinstalaciones fuese siempre límpias.

Debian tiene una gestión de paquetes de sistema realmente sólida y estable. Puede chequear dependencias tanto al instalar como al desinstalar, así como ejecutar scripts previos y posteriores a la instalación. Esa es la razón por la que es uno de los sistemas de paquetería más usados en el ecosistema Linux.

Este artículo va repasar maneras para empaquetar aplicaciones Python en paquetes Debian que puedan ser fácilmente instalados en distribuciones Debian/Ubuntu.

El formato de paquetes de Debian

Aunque otros sistemas de paquetes de Linux se basan en formatos binarios, los paquetes Debian son simplemente ficheros de tipo ar que contienen un par de ficheros empaquetados mediante tar y comprimidos mediante gzip o bzip. Unos de estos ficheros contiene el árbol de ficheros con los ficheros de nuestra aplicación y el otro contiene los ficheros de configuración del paquete.

Los ficheros de configuración son meros ficheros de texto, por lo que la manera clásica de crear paquetes Debian implica sólo crar carpetas, editar unos ficheros de texto y ejecutar un comando que, en su forma más simple es:

dante@Camelot:~/project-directory$ debuild -us -uc


Se puede encontrar un buen tutorial sobre el tema aquí.

El tema no es complicado pero puede tener su intríngulis y requiere crear y editar muchos ficheros de texto. Por eso, no resulta sorprendente ver como los desarrolladores han crado muchas herramientas para automatizar esta tarea. Vamos a ver algunas de estas herremientas especializadas en el empaquetado de aplicaciones Python en paquetes  Debian.


STDEB

Si ya estás acostumbrado al empaquetado para PyPI, no debería resultarte complicado coger los conceptos de stdeb. Si no sabes a qué me refiero deberías echarle un ojo al artículo de este blog al que enlazaba al comienzo de este.

Dado que stdeb llama a algunas herramientas nativas de Debian/Ubuntu, es necesario usarlo desde una de estas distribuciones. Si estás en un Debian/Ubuntu, se puede instalar stdeb desde los repositorios estándar del sistema:

dante@Camelot:~$ sudo aptitude search stdeb
p   python-stdeb    - Python to Debian source package conversion utility                                 p   python3-stdeb   - Python to Debian source package conversion plugins for distutils                  
dante@Camelot:~$ sudo aptitude install python-stdeb python3-stdeb


También se puede instalar stdeb desde PyPI, como paquete python, lo que tiene la ventaja de que así se puede instalar una versión más reciente.

El mejor flujo de trabajo para usar stdeb implica crear los mismos ficheros que se usarían para crear un paquete para PyPI, de esta manera stdeb puede utilizar el fichero setup.py para conseguir la información necesaria para crear los fichero de configuración de un paquete debian.

Por eso, supongamos que nuestra aplicación estuviese lista para ser empaquetada para PyPI, con un setup.py ya creado. Para este ejemplo he clonado el siguiente repositorio de git.

Para hacer que stdeb genere un paquete fuente de debian sólo hay que hacer lo siguiente:

dante@Camelot:~/geolocate$ python3 setup.py --command-packages=stdeb.command sdist_dsc

La parte de --command-packages puede ser un poco complicada pero el comando no puede funcionar sin ella, por eso confía en mi e inclúyelo.

La ejecución de este comando tiene una salida de texto bastante profusa pero al final te darás cuenta que se habrán creado algunas carpetas en nuestro directorio de trabajo. Tienes que fijarte en una carpeta llamada deb_dist. Esa carpeta contiene principalmente tres ficheros: un fichero .dsc, un fichero .orig.tar.gz y un fichero .diff.gz. Esos tres ficheros juntos son lo que denominamos un paquete fuente.

Dentro de la carpeta deb_dist encontrarás otra llamada como tu proyecto pero con la versión principal de tu aplicación añadida al final. Esa carpeta contiene todos los datos generados para compilar un paquete debian binario. Por eso, entra en dicha carpeta y ejecuta lo siguiente:

dante@Camelot:~/geolocate/deb_dist/glocate-1.3-0$ dpkg-buildpackage -rfakeroot -uc -us

La parte de fakeroot es sólo un flag para poder construir un paquete debian sin estar logado como root. Ese es el comando recomendado en la documentación, pero por las pruebas que he realizado también se puede usar el comando debuild visto antes:

dante@Camelot:~/geolocate/deb_dist/glocate-1.3-0$ debuild -uc -us

Al final debuild no es más que un wrapper para dpkg-buildpackage que automatiza algunas cosas que de otra manera tendrían que hacerse manualmente.

Después de cualquiera de esos comandos, deberías encontrar un paquete fuente de debian en la carpeta deb_dist:

dante@Camelot:~/geolocate/deb_dist/glocate-1.3-0$ ls *.deb python3-glocate_1.3.0-1_all.deb

Lo anterior es el "método en dos pasos", aunque puedes reducirlo a un único paso haciendo:

dante@Camelot:~/geolocate$ python3 setup.py --command-packages=stdeb.command bdist_deb

Aunque ambos métodos acaban con un paquete .deb en la carpeta debian_dist, deberías ser consciente de que el paquete debian generado depende de la arquitectura por mucho que su nombre incluya la coletilla "_all". Eso significa que si generas en paquete en un Ubuntu de arquitectura amd64 lo más probable es que tengas problemas si intentas instalar dicho paquete en un Ubuntu con otra arquitectura. Para evitar este problema, hay dos opciones:


  • Usar máquinas virtuales para compilar un paquete debian para cada arquitectura objetivo.
  • Usar un repositorio PPA: Ubuntu ofrece espacio personal para alojar proyectos de empaquetado. Se sube un paquete fuente (el contenido de la carpeta deb_dist tras ejecutar "python3 setup.py --command-packages=stdeb.command sdist_dsc"), y el servidor lo compila a cada arquitectura objetivo (principalmente x86 y amd64). Tras la compilación, los paquetes creados aparecen en el PPA personal hasta que los borras o los reemplazas con versiones más nuevas. 
Si tus aplicaciones python sólo usan los paquetes que vienen por defecto con python entonces el empaquetado acaba aquí, pero si usas paquetes adicionales, por ejemplo descargados desde PyPI, lo más probable es que el paquete generado no los incluya correctamente.

Continuando con nuestro ejemplo, si miras en el fichero setup.py de geolocate podrás ver que depende de los siguientes paquetes:
install_requires=["geoip2>=2.1.0", "maxminddb>=1.1.1", "requests>=2.5.0", "wget>=2.2"]
Veamos si estas dependencias han sido incluidos en los metadatas generados en el paquete debian:

dante@Camelot:~/geolocate/deb_dist$ dpkg -I python3-glocate_1.3.0-1_all.deb [...] Depends: python3, python3-requests, python3:any (>= 3.3.2-2~) [...]


Obviamente no han sido incluidos. De hecho nuestro comando de compilación nos avisó de que el chequeo de dependencias había fallado al construir el paquete. Si nos fijamos en la salida del comando de compilación nos encontraremos esta parte:
[...]
I: dh_python3 pydist:184: Cannot find package that provides geoip2. Please add package that provides it to Build-Depends or add "geoip2 python3-geoip2-fixme"
line to debian/py3dist-overrides or add proper  dependency to Depends by hand and ignore this info.
I: dh_python3 pydist:184: Cannot find package that provides maxminddb. Please add package that provides it to Build-Depends or add "maxminddb python3-maxmindd
b-fixme" line to debian/py3dist-overrides or add proper  dependency to Depends by hand and ignore this info.
I: dh_python3 pydist:184: Cannot find package that provides wget. Please add package that provides it to Build-Depends or add "wget python3-wget-fixme" line t
o debian/py3dist-overrides or add proper  dependency to Depends by hand and ignore this info.
[...]
El problema es que stdeb no identifica qué paquete de Linux incluye a esos paquetes de PyPI. Por eso tenemos que fijarlos manualmente.

Para configurar manualmente a stdeb tienes que crear un fichero llamado stdeb.cfg en la misma carpeta que setup.py. Lo normal es crear una sección [DEFAULT] donde poner la configuración pero también se pueden crear secciones llamadas [nombre_de_paquete], donde el nombre del paquete coincide con el mismo argumento que el comando setup().

Por ejemplo, si averiguamos que la librería de PyPI llamada geoip2 está incluida dentro del paquete del repositorio de Ubuntu denominado python3-geoip, y la librería request de PyPI está incluida en el paquete python3-request podríamos crear un fichero stdeb.cfg con el siguiente contenido:
[DEFAULT]
X-Python3-Version: >= 3.4
Depends3: python3-geoip (>=1.3.1), python3-requests
Todas las etiquetas siguen el mismo formato que seguirían si estuvieran dentro de un fichero de configuración debian/control. Para etiquetas de dependencias, la especificación del formato es esta.

Con esta configuración, las dependencias fijadas se incluyen en el paquete debian generado:

dante@Camelot:~/geolocate/deb_dist$ dpkg -I python3-glocate_1.3.0-1_all.deb
[...]
Depends: python3, python3-requests, python3:any (>= 3.3.2-2~), python3-geoip (>= 1.3.1)
[...]

Lo que no he conseguido averiguar aún es una manera de cambiar la etiqueta de arquitectura de los paquetes generados de manera que no se generen con "Architecture: all".

A primera vista, stdeb parece una opción estupenda y verdaderamente lo es, pero tiene algunos inconvenientes dignos de consideración.

El problema es que stdeb te limita a usar sólo librerías y paquetes de python disponibles en el repositorio de paquetes estándar de Linux. Por eso si desarrollas tu aplicación usando paquetes y librerías de PyPI lo más probable es que cuando intentes identificar qué paquete de Linux incluye dichas librerías te encuentres con que dichos paquetes sólo contienen versiones más antiguas que aquellos disponibles en PyPI. Peor aún, muchas librerías de PyPI no han sido portadas a los repositorios estándar de Linux, por lo que no será posible encontrar un paquete que cubra esa dependencia. Por ejemplo, geolocate necesita usar geoip2 (v.2.1.0) el cual es fácilmente descargable de PyPI pero sólo Ubuntu 15.04 tiene un paquete disponible denominado python3-geoip, pero este viene con la versión 1.3.2 de geoip. ¿Funcionará geolocate con la versión de geoip facilitada por el paquete python3-geoip?, probablemente no. Otras dependencias de geolocate ni siquiera están en el repositorio estándar, como por ejemplo la librería de python wget (o al menos no lo había cuando escribí esta parte del artículo).

Está claro que si te gusta utilizar librerías PyPI, stdeb puede no ser tu mejor opción. Pero si desarrollas usando sólo librarías disponibles a través del gestor de paquetes estándar de Linux entonces stdeb probablemente te solucionará el problema.

FPM

FPM es una herramienta de Ruby similar a stdeb. Su principal ventaja es que puedes usar FPM para crear muchos tipos de paquetes de sistema, no sólo de Debian sino también paquetes RPM (pata distribuciones Red Hat). Y lo que es incluso más interesante de FPM es que permite conversiones de paquetes, por ejemplo de .rpm a .deb.

FPM no tiene paquete en el repositorio principal de Ubuntu, por lo que hay que descargarlo del equivalente Ruby a PyPI. Para ello primero hay que instalar los paquetes de Ruby:


dante@Camelot:~/geolocate$ sudo aptitude install ruby-dev gcc make

Después de eso puedes instalar FPM desde los repositorios de Ruby:


dante@Camelot:~/geolocate$ gem install fpm

Crear un paquete DEB es bastante simple, sólo fija el parámetro de origen de FPM como python y su objetivo como deb y dale la ruta al setup.py de tu paquete:


dante@Camelot:~/geolocate$ fpm -s python -t deb ./setup.py

FPM toma los nombres de las dependencias del setup.py y los prefija con la etiqueta que se fija con la bandera --python-package-name-prefix (si no está fijado entonces se usa python como prefijo):


dante@Camelot:~/geolocate$ dpkg -I python-glocate_1.3.0_all.deb
[...]
Depends: python-geoip2 (>= 2.1.0), python-maxminddb (>= 1.1.1), python-requests (>= 2.5.0), python-wget (>= 2.2)
[...]

El problema aquí es similar que con stdeb: esas dependencias no existen en el repositorio estándar de Ubuntu. En caso de la que las dependencias existiesen pero con nombres diferentes a los autogenereados por FPM, entonces puedes fijarlos manualmente:


dante@Camelot:~/geolocate$ fpm -s python -t deb --no-auto-depends -d "python3-geoip>=1.3.1, python3-wget" ./setup.py
[...]>
dante@Camelot:~/geolocate$ dpkg -I python-glocate_1.3.0_all.deb 
[...]
Depends: python3-geoip>=1.3.1, python3-wget
[...] 


Otra bandera útil es "-a native". Esta bandera fija la arquitectura del paquete a la de tu sistema, por eso ya no se fija a "_all" en el nombre de los paquetes.

FPM es una gran herramienta. Te permite crear paquetes RPM y es muy configurable pero en mi opinión tiene un inconveniente muy serio: tal y como pasaba con stdeb es inútil si tu aplicación importa una librería disponible en PyPI pero no en el repositorio estándar del sistema operativo.

DH-VIRTUALENV

Llegados a este punto debería estar claro que el problema principal para empaquetar una aplicación python es asegurar sus dependencias, porque el desarrollador puede haber usado librerías PyPI que no estén disponibles a través de los repositorios estándar en el lado del usuario.

Los chicos de Spotify desarrollaron un empaquetador para resolver este problema: dh-virtualenv.

Esta herramienta asume que si estás usando PyPI para desarrollar entonces lo más probable es que uses virtualenvs. Por eso dh-virtualenv incluye el virtualenv entero dentro del paquete de manera que no tengas que instalarlo en el extremo del usuario final.

Sin embargo, en mi humilde opinión dh-virtualenv tiene un inconveniente: no es más limpio que stdeb o que FPM (porque tienes que crear manualmente una carpeta debian y un fichero de reglas) acabas usando debuild como al comienzo del artículo.

VDIST

Los principales conceptos de vdist son similares a los vistos en dh-virtualenv pero vdist usa una conbinación de docker y FPM para crear un paquete estándar de sistema operativo. Esta herramienta te permite construir paquetes de tus aplicaciones creando entornos aislados para tu proyecto usando virtualenv. A primera vista vdist puede parecer complejo pero su documentación es realmente clara y en realidad es bastante simple de usar y de automatizar.

Si tu mayor problema al empaquetar aplicaciones python es asegurar que las dependencias estén presentes en el extremo del usuario final, vdist resuelve dicho problema haciendo que tu aplicación esté autocontenida y sea autosuficiente de tal manera que no dependa de los módulos de Python facilitados por los paquetes del sistema. Esto significa que los paquetes generados por vdist contienen tu aplicación, todas las dependencias de python requeridas por la misma y un interpreter de python para ejecutarla. De esa manera tu aplicación puedes ser ejecutada con el intérprete de tu elección y no con el que traiga el sistema operativo en el que estés desplegando tu aplicación.

Para asegurar que el equipo usado para construir el paquete conserve sus paquetes de sistema intactos. vdist usa docker para crear un sistema operativo limpio al crear el paquete donde instalar las dependencias necesarias antes de empaquetar la aplicación en él. Gracias a esto el equipo usado para la compilación será revertido a su estado original en cada compilación.Para cargar to aplicación en una imagen de docker, vdist descarga el código fuente de la aplicación desde un repositorio de git, por eso tener tu aplicación en BitBucket o Github es una buena idea. El código fuente se coloca en un virtualenv creado en una imagen de docker. Las dependencias de PyPI serán instalados en el virtualenv.

La primera dependencia para usar vdist es contar con vdist instalado y su demonio en ejecución. Para instalarlo en Ubuntu sólo necesitas hacer lo siguiente:


dante@Camelot:~/geolocate$ sudo aptitude install docker.io python-docker python3-docker


Tras instalar docker recuerda añadir tu usuario al grupo de docker:


dante@Camelot:~/geolocate$ sudo usermod -a -G docker dante


Puede que necesites reiniciar el sistema para estar seguro de que el grupo se actualice realmente.

La manera más fácil de instalar vdist es usar el gestor de paquetes estándar de Linux. Los paquetes de vdist están alojados en Bintray, por eso para instalarlos primero hay que incluir a Bintray en los repositorios de paquetes del sistema antes de nada más. Para hacerlo, hay que introducir los siguientes comandos:


dante@Camelot:~$ sudo apt-get update
dante@Camelot:~$ sudo apt-get install apt-transport-https
dante@Camelot:~$ sudo echo "deb [trusted=yes] https://dl.bintray.com/dante-signal31/deb generic main" | tee -a /etc/apt/sources.list
dante@Camelot:~$ sudo apt-key adv --keyserver pgp.mit.edu --recv-keys 379CE192D401AB61

Una vez que hayas añadido a Bintray en tus repositorios puedes instalar y actualizar vdist como cualquier otro paquete de sistema. Por ejemplo, en Ubuntu:


dante@Camelot:~$ sudo apt-get update
dante@Camelot:~$ sudo apt-get install vdist

Si estás en un sistema donde no tienes permisos para instalar paquetes de sistema puede que te resulte interesante instalarlo desde PyPI en un virtualenv creado ad-hoc para empaquetar tu aplicación:


(env) dante@Camelot:~/geolocate$ pip install vdist


Tras instalar vdist dispondrás de un comando de consola llamado... vdist. Sólo se consciente de que si has instalado vdist en un virtualenv, ese comando de consola sólo estará disponible dentro de ese virtualenv.

Hay muchas maneras de usar vdist, aunque creo que la manera más fácil es crear un fichero de configuración y hacer que vdist lo lea. El mismo vdist se utiliza para generar sus propios paquetes, así que su fichero de configuración es un buen ejemplo:

[DEFAULT]
app = vdist
version = 1.1.0
source_git = https://github.com/dante-signal31/${app}, master
fpm_args = --maintainer dante.signal31@gmail.com -a native --url
    https://github.com/dante-signal31/${app} --description
    "vdist (Virtualenv Distribute) is a tool that lets you build OS packages
     from your Python applications, while aiming to build an
     isolated environment for your Python project by utilizing virtualenv. This
     means that your application will not depend on OS provided packages of
     Python modules, including their versions."
    --license MIT --category net
requirements_path = /REQUIREMENTS.txt
compile_python = True
python_version = 3.5.3
output_folder = ./package_dist/
after_install = packaging/postinst.sh
after_remove = packaging/postuninst.sh

[Ubuntu-package]
profile = ubuntu-trusty
runtime_deps = libssl1.0.0, docker.io
build_deps =

[Centos7-package]
profile = centos7
runtime_deps = openssl, docker-ce

La documentación de vdist es lo suficientemente buena como para saber para qué sirve cada parámetro. Sólo hay que tener en cuenta que puedes tener un único fichero de configuración por cada paquete que quieras compilar. Sólo manten los parámetros comunes en una sección [DEFAULT] y pon los parámetros particulares de cada distribución en secciones separadas (que pueden llamarse como quieras, aunque lo mejor es usar nombres expresivos).

Una vez que tienes tu fichero puedes lanzar vdist de la siguiente manera (supongamos que el fichero de configuración se llama configuration_file):


dante@Camelot:~/$ vdist batch configuration_file

A partir de ahí comenzarás a ver un montón de texto de salida en la pantalla mientras vdist construye tus paquetes. Los paquetes generados serán colocados en la carpeta fijada en el parámetro del fichero de configuración output_folder.

El tamaño de paquete más pequeño generado por vdist es de aproximadamente 50 MB, debido a que hay que incluir una distribución completa de python  en el paquete. Ese tamaño es el precio que hay que pagar por la autosuficiencia de nuestra aplicación con respecto a las dependencias de python. Descontados esos 50 MB, el resto del tamaño se corresponderá con nuestra aplicación y sus dependencias. A primera vista puede parecer mucho, pero hoy en día ese tamaño es bastante habitual para cualquier aplicación compilada que nos encontremos por internet.

Creo que vdist es la solución de empaquetado más completa que se encuentra disponible para desplegar aplicaciones python en sistemas Linux. Con él puedes deplegar incluso en equipos linux sin ningún tipo de python instalado en absoluto, con lo que aporta un valioso aislamiento en el extremo del usuario, facilitándole la instalación además a tu usuario final.

Disclaimer: Empecé a usar vdist para escribir este artículo y he acabado siendo el nuevo desarrollador principal de la aplicación, por eso sentíos libres de comentar cualquier mejora o aportación que creais interesante.