11 noviembre 2021

Cómo empaquetar aplicaciones Rust - Paquetes DEB


El lenguaje Rust es duro y áspero. Tu primer contacto con el compilador y con el borrow checker suele ser traumático hasta que te das cuenta de que en realidad están allí para protegerte de ti mismo. Una vez que tienes esa revelación empiezas a adorar a ese lenguaje.

Pero todo lo que está más allá del lenguaje en si mismo es amable (incluso diría que cómodo). Con cargo, compilar, probar, documentar, medir e incluso publicar en crates.io (el equivalente a Pypi en Rust) es una gozada. El empaquetado no es una excepción ya que se integra con cargo, una vez hecha la configuración que vamos a ver aquí.

Para empaquetar mi aplicación Rust en paqueted debian, utilizo cargo-deb. Para instalarlo sólo hay que teclear:

dante@Camelot:~~/Projects/cifra-rust/$ cargo install cargo-deb
Updating crates.io index
Downloaded cargo-deb v1.32.0
Downloaded 1 crate (63.2 KB) in 0.36s
Installing cargo-deb v1.32.0
Downloaded crc v1.8.1
Downloaded build_const v0.2.2
[...]
Compiling crossbeam-deque v0.8.1
Compiling xz2 v0.1.6
Compiling toml v0.5.8
Compiling cargo_toml v0.10.1
Finished release [optimized] target(s) in 53.21s
Installing /home/dante/.cargo/bin/cargo-deb
Installed package `cargo-deb v1.32.0` (executable `cargo-deb`)


dante@Camelot:~$

Hecho eso, ya podemos empezar a empaquetar aplicaciones sencillas. Por defecto, cargo deb saca la información de tu fichero Cargo.toml. De esa manera saca los siguientes campos:

  • name
  • version
  • license
  • license-file
  • description
  • readme
  • homepage
  • repository

Sin embargo, rara vez ocurre que tu aplicación no tiene dependencias en absoluto. Para configurar casos de uso más avanzados hay que crear una sección [package.metadata.deb] en tu Cargo.toml. En esa sección se pueden configurar los siguientes campos:

  • maintainer
  • copyright
  • changelog
  • depends
  • recommends
  • enhances
  • conflicts
  • breaks
  • replaces
  • provides
  • extended-description
  • extended-description-file
  • section
  • priority
  • assets
  • maintainer-scripts

Como ejemplo de todo esto, puedes consultar esta versión del fichero Cargo.toml de mi aplicación Cifra.

Ahí se puede leer la sección general de la que cargo-deb saca su información básica:

 

 

Cargo tiene una gran documentación donde se puede encontrar una explicación para todas las secciones y etiquetas posibles.

Ten en cuenta que cada ruta de ficheros que se incluya en el fichero Cargo.toml es relativa a dicho fichero Cargo.toml.

La sección específica para cargo-deb no necesita muchos parámetros para conseguir un paquete válido:


 

Las etiquetas para esta sección están documentadas en la página principal de cargo-deb.

Las etiquetas section y priority se usan para clasificar la aplicación en la jerarquía de Debian. Aunque en mi configuración las he fijado, creo que no son muy útiles los requisitos para publicar paquetes en los repositorios oficiales de Debian son superiores a los que se pueden conseguir con cargo-deb por el momento, por lo que cualquier paquete generado con cargo-deb acabará casi con total probabilidad en un repositorio personal al que no se le aplicará la jerarquía de debian para las aplicaciones.

En realidad, la etiqueta más importante es la de assets. Esta etiqueta te permite fijar que ficheros deben incluirse en el paquete y dónde deben dejarse al instalar. El formato del contenido de esa etiqueta es sencillo. Se trata de una lista de tuplas de tres elementos:

  • Ruta relativa al fichero a incluir en el paquete: Esa ruta es relativa a la ubicación del fichero Cargo.toml. 
  • Ruta absoluta donde ubicar el fichero en el ordenador del usuario.
  • Los permisos que debe tener el fichero en el ordenador del usuario.

Debería haber incluido una etiqueta "depends" para añadir las dependencias del paquete. Cifra depende de SQLlite3 y este no se encuentra en un paquete de Rust sino en un paquete de sistema, por lo que es una dependencia del paquete debian de Cifra. Si quisieras usar la etiqueta "depends" podrías hacerlo pero tendrías que usar el formato de dependencias de debian, aunque en realidad no es necesario porque cargo-deb es capaz de calcular las dependencias automáticamente aunque no uses esa etiqueta. Lo que hace es usar ldd contra el binario compilado de nuestra aplicación y luego busca con dpkg qué paquetes de sistema ofrecen las librerías detectadas por ldd.

Una vez que tienes tu configuración de cargo-deb en tu Cargo.toml, empaquetar tu paquete debian es tan sencillo como hacer:

dante@Camelot:~/Projects/cifra-rust/$ cargo deb
[...]
Finished release [optimized] target(s) in 0.17s
/home/dante/Projects/cifra-rust/target/debian/cifra_0.9.1_amd64.deb

dante@Camelot:~$

Como se puede ver en la salida del comando, se puede encontrar el paquete generado en una nueva carpeta dentro de la de tu proyecto denominada /target/debian/.

Cargo-deb es una herramienta muy útil cuya única pega es no ser capaz de cumplir los requisitos de Debian para empaquetar paquetes viables para ser incluidos en los repositorios oficiales.

Cómo escribir manpages con Markdown y Pandoc

Una aplicación de consola decente siempre cuenta con una manpage para documentar cómo usarla. Sin embargo, las interioridades de las manpages son bastantes arcanas, aunque para ser un formato de fichero de 1971 ha aguantado bastante bien.

Hoy en día hay dos manera estándar de aprender a usar un comando de consola (aparte de buscar en google ;) ): tecleando el nombre de la aplicación, seguido de "--help", para obtener un resumen de cómo usar la aplicación, o teclear "man" seguido del nombre de la aplicación para obtener información detallada de cómo usarla.

Para implementar "--help" en nuestra aplicación podemos incluir un parseado manual de "--help", aunque lo más recomendable es usar una librería como la de python ArgParse para parsear argumentos de usuario.

El enfoque de usar el comando "man" implica escribir una manpage de tu aplicación..

La manera estándar de crear manpages es usar el formato troff. Linux tiene su propio estándar de implementación de troff, denominado groff. Si quieres cacharrear para hacerte una idea de cómo es el formato con el que se escriben las manpages, puedes teclear lo siguiente como el contenido de un fichero que se llame, por ejemplo, corrupt.1:

.TH CORRUPT 1 .SH NAME corrupt \- modify files by randomly changing bits .SH SYNOPSIS .B corrupt [\fB\-n\fR \fIBITS\fR] [\fB\-\-bits\fR \fIBITS\fR] .IR file ... .SH DESCRIPTION .B corrupt modifies files by toggling a randomly chosen bit. .SH OPTIONS .TP .BR \-n ", " \-\-bits =\fIBITS\fR Set the number of bits to modify. Default is one bit.

Una vez salvado, ese fichero puede visualizarse conel comando man. Suponiendo que estemos en la misma carpeta que el fichero corrupt.1, podemos teclear:

dante@Camelot:~/$ man -l corrupt.1

La salida será: 

CORRUPT(1)                                                 General Commands Manual 

NAME
corrupt - modify files by randomly changing bits

SYNOPSIS
corrupt [-n BITS] [--bits BITS] file...

DESCRIPTION
corrupt modifies files by toggling a randomly chosen bit.

OPTIONS
-n, --bits=BITS
Set the number of bits to modify. Default is one bit.

CORRUPT(1)


 

Puedes eligir escribir directamente manpages para tu aplicación siguien, por ejemplo, esta pequeña chuleta

De todos modos, el formato troff/groff es bastante raro y en mi opinión mantener dos fuentes de información (tu README.md y tu manpage) no es sino fuente errores y un desperdicio de esfuerzos. Por eso sigo un enfoque diferente: escribo y mantengo actualizado mi README.md y luego lo convierto a una manpage. Es cierto que hay que mantener un formato estándar para que la conversión de README.md cuadre con lo que se espera de un manpage, pero al menos evitaremos teclear las mismas cosas dos veces, en dos ficheros diferentes, y con diferentes lenguajes de etiquetado.

La clave de la conversión es la herramienta denominada Pandoc. Esa herramienta es la navaja suiza de la conversión de documentos. La puedes usar para convertir entre muchos formatos documentales como word (docx), openoffice (odt), epub... o markdown (md) y groff man. Pandoc suele estar disponible en los repositorios estándar de la mayor parte de distribuciones de linux habituales, por lo que en un ubuntu no hay más que teclear:

dante@Camelot:~/$ sudo apt install pandoc

Una vez que Pandoc esté instalado, hay que respetar una serie de convenciones en nuestro README.md opara hacer que la conversión sea más sencilla. Para ilustrar las explicaciones de este artículo, vamos a seguir el ejemplo del fichero README.md de mi proyecto Cifra.

Como se puede ver, el README.md enlazado contiene medallas de GitHub justo al comienzo aunque las quitaremos antes de hacer la conversión, tal y como veremos más adelante. Todo lo demás está estructurado para cumplir con lo que se espera que tenga una manpage estándar.

Puede ser que lo más sospechoso sea la primera línea. No es normal encontrar una línea como esta en un README de GitHub:

En realidad, esa línea contiene metadatos para el visor de manpages. Tras el carácter "%" aparece el título del documento (generalmente el nombre de la aplicación), la sección del manual, una versión, un separador "|" y finalmente una cabecera. La sección del manual es 1 para los comandos de usuario, 2 para las llamadas de sistema y 3 para las funciones de C. Tus aplicaciones encajarán en la sección 1 el 99% de las veces. No he incluido una versión para Cifra en esa línea, pero lo podría haber hecho. Además, la cabecera indica a que categoría de documentación pertenece este manpage.

Tras esa línea, cada sección es de las habituales en un manpage, pero las únicas que deberían ser incluidas como obligatorias son: 

  • Name: El nombre del comando.
  • Synopsis: Un resumen de una línea explicando los argumentos y las opciones.
  • Description: Describe en detalle como usar el comando.

Otras secciones que se pueden añadir son:

  • Options: Opciones del comando.
  • Examples: Ejemplos de uso del comando.
  • Files: Útil si tu aplicación incluye ficheros de configuración.
  • Environment: Aquí se describe si la aplicaciónusa variables de entorno.
  • Bugs: ¿Se deben reportar los bugs detectados? Aquí se puede enlazar la página de issues de GitHub.
  • Authors: ¿Quién es el autor de esta pieza maestra?
  • See also: Referencias a otros manpages.
  • Copyright | License: Un buen lugar para incluir la licancia de tu aplicación.

Una vez decididas las secciones a incluir en el manpage, hay que escribirlas siguiendo un formato fácilmente convertible por pandoc en un manpage con una estructura estándar. Esa es la razón por la que en el README de ejemplo las secciones principales están todas marcadas con un nivle de indentación ("#"). Un problema con la indentación es que aunque en markdown se pueden conseguir múltiples niveles de indentación usando el carácter "#" ( "#" para los títulos principales, "##" para los subtítulos, "###" para las secciones, etc) esos subniveles sólo son reconocidos por Pandoc hasta el subnivel 2. Para conseguir más subniveles he tenido que tirar de las"pandoc lists", un formato que puedes usar en tu markdown y que luego lo reconoce Pandoc:

En las líneas 42 y 48 tenemos lo que Pandoc llama  "line blocks". Estos son líneas iniciadas por una barra vertical ( | ) seguidas por un espacio. Esos espacios entre la barra vertical y el comando se conservarán en el texto del manpage que genere Pandoc.

Todo lo demás del fichero README.md de ejemplo es formato markdown clásico.

Supongamos que hemos escrito todo nuestro README.md y que queremos hacer nuestra conversión. Para hacer eso podemos usar un script como este desde una carpeta temporal:

Las líneas 3 y 4 limpian cualquier fichero usado en una conversión anterior, mientras que la línea 5 es la que en realidad copia el README.md desde la carpeta de origen a la de destino.

La línea 6 borra cualquier medalla de GitHub que podamos tener en nuestro README.md.

La línea 7 es donde llamamos a Pandoc para realizar la conversión.

Hecho eso, ya tendremos un fichero que puede ser abierto con man:

dante@Camelot:~/$ man -l man/cifra.1

Aunque los manpages suelen estar comprimidos con gzip, como se puede ver con los manpages de nuestro sistema:

dante@Camelot:~/$ ls /usr/share/man/man1

Esa es la razón por la que la línea 8 del script comprime el manpage generado. A pesar de estar comprimido, el manpage sigue siendo leíble por man:

dante@Camelot:~/$ man -l man/cifra.1.gz

Aunque son unos cuantos pasos, se puede ver que son fácilmente automatizables mediante un script o como un paso más de tu flujo de integración y despliegue continuo.

Como se puede ver, con estos sencillos pasos sólo se necesita mantener actualizado el README.md, dado que el manpage puede ser generado a partir de él.

08 noviembre 2021

Cómo crear ejecutables de aplicaciones Python mediante PyOxidizer

Python es un gran lenguaje para desarrollar. El problema es que carece de un soporte adecuado para empaquetar y distribuir aplicaciones al usuario final. Pypi y los paquetes wheel están más enfocados a dar soporte a los desarrolladores para que estos instalen las dependencias de sus aplicaciones, pero para los usuarios finales es muy complicado usar pip y virtualenv para instalar una aplicación python. 

Hay algunos proyectos intentando solventar ese problema, como por ejemplo PyInstallerpy2exe o cx_Freeze. Yo mismo mantengo la aplicación vdist, que está muy enfocada a ese problema e intenta resolverlo creando paquetes debian, rpm y archlinux para aplicaciones Python. En este artículo vamos a analizar PyOxidizer. Esta herramienta está desarrollada en un lenguaje que me está gustando mucho (Rust) y sigue una aproximación similar a la de vdist al empaquetar tu aplicación junto a una distribuión de python, pero además compila el paquete entero un executable binario. 

Para estructurar este tutorial, vamos compilar mi proyecto Cifra. Lo puedes clonar en este punto point para seguir este tutorial paso a paso.

El primer punto a tener en cuenta es que PyOxidizer empaqueta tu aplicación con una distribución personalizada de python 3.8 o 3.9, por lo que la aplicación deberá ser compatible con esas versiones. Te hará falta un framework de compilación en C para compilar con PyOxidizer. Si no contases con dicho framework, PyOxidiczer sacaría instrucciones para instalar uno. PyOxidizer usa también el framework de Rust, pero lo descarga en segundo plano por tí, así que no es una dependencia de la que te tengas que preocupar.

Instalación de PyOxidizer

Hay varias maneras de instalar PyOxidizer (descargando una release compilada de su página de GitHub, compilando desde el código fuente) pero creo que la manera más limpia es instalarlo en el virtualenv del proyecto usando pip.

Supongamos que hemos clonado el proyecto de Cifra (en mi caso en la ruta ~/Project/cifra), que dentro de esta carpeta hemos creado un virtualenv en una carpeta venv y que hemos activado dicho virtualenv. En ese caso se puede instalar PyOxidizer dentro de ese virtualenv haciendo:

(venv)dante@Camelot:~/Projects/cifra$ pip install pyoxidizer
Collecting pyoxidizer
Downloading pyoxidizer-0.17.0-py3-none-manylinux2010_x86_64.whl (9.9 MB)
|████████████████████████████████| 9.9 MB 11.5 MB/s
Installing collected packages: pyoxidizer
Successfully installed pyoxidizer-0.17.0

(venv)dante@Camelot:~/Projects/cifra$

Se puede comprobar que hemos instalado PyOxidizer correctamente haciendo:

(venv)dante@Camelot:~/Projects/cifra$ pyoxidizer --help
PyOxidizer 0.17.0
Gregory Szorc <gregory.szorc@gmail.com>
Build and distribute Python applications

USAGE:
pyoxidizer [FLAGS] [SUBCOMMAND]

FLAGS:
-h, --help
Prints help information

--system-rust
Use a system install of Rust instead of a self-managed Rust installation

-V, --version
Prints version information

--verbose
Enable verbose output


SUBCOMMANDS:
add Add PyOxidizer to an existing Rust project. (EXPERIMENTAL)
analyze Analyze a built binary
build Build a PyOxidizer enabled project
cache-clear Clear PyOxidizer's user-specific cache
find-resources Find resources in a file or directory
help Prints this message or the help of the given subcommand(s)
init-config-file Create a new PyOxidizer configuration file.
init-rust-project Create a new Rust project embedding a Python interpreter
list-targets List targets available to resolve in a configuration file
python-distribution-extract Extract a Python distribution archive to a directory
python-distribution-info Show information about a Python distribution archive
python-distribution-licenses Show licenses for a given Python distribution
run Run a target in a PyOxidizer configuration file
run-build-script Run functionality that a build script would perform


(venv)dante@Camelot:~/Projects/cifra$


Configuración de PyOxidizer 

Ahora hay que crear un fichero de configuración inicial de PyOxidizer en la carpeta raiz del proyecto:

(venv)dante@Camelot:~/Projects/cifra$ cd ..
(venv) dante@Camelot:~/Projects$ pyoxidizer init-config-file cifra
writing cifra/pyoxidizer.bzl

A new PyOxidizer configuration file has been created.
This configuration file can be used by various `pyoxidizer`
commands

For example, to build and run the default Python application:

$ cd cifra
$ pyoxidizer run

The default configuration is to invoke a Python REPL. You can
edit the configuration file to change behavior.

(venv)dante@Camelot:~/Projects$

Se ha generado un fichero pyoxidizer.bzl que está escrito en Starlark, un dialecto de python para ficheros de configuración, así que nos sentiremos como en casa con ellos. Sin embargo, ese fichero de configuración es bastante largo y aunque está completamente comentado, al principio no está claro como hacer los ajustes necesarios. PyOxidizer es bastante completo pero puede resultar intimidante para alguien que se acerque a él por primera vez para hacer algo sencillo. Tras recorrer toda la web de documentación de PyOxidizer, creo que esta sección es la mñas útil para personalizar el fichero de configuración de PyOxidizer.

Hay muchas aproximaciones para compilar binarios con PyOxidizer. Vamos a suponer que tenemos un fichero setup.py de setuptools para nuestro proyecto, con eso podemos pedirle a PyOxidizer que ejecute ese setup.py dentro de su versión personalizada de python. Para hacer eso, hay que añadir la línea siguiente antes de la línea de return en la función make_exe() de pyoxidizer.bzl.

# Run cifra's own setup.py and include installed files in binary bundle.
exe.add_python_resources(exe.setup_py_install(package_path=CWD))

En el parámetro package_path hay que poner la ruta del fichero setup.py. Yo he incluido un argumento CWD porque estoy suponiendo que pyoxidizer.bzl y setup.py están en la misma carpeta.

Lo siguiente a personalizar en pyoxidizer.bzl es que por defecto intenta construir un ejecutable MSI para Windows, pero en Linux querremos un ejecutable ELF. Por eso, primero hay que comentar la línea cercana al final que registro un target para construir un instalador MSI.

#register_target("msi_installer", make_msi, depends=["exe"])

Necesitamos también un punto de entrada para nuestra aplicación. Estaría bien que PyOxidizer cogiese el punto de entrada del setup.py pero no es así. En vez de eso tenemos que facilitarlo manualmente a través del fichero de configuración pyoxidizer.bzl. En nuestro ejemplo sólo hay que encontrar la línea de la función make_exec() donde se crea la variable python_config y poner justo después:

python_config.run_command = "from cifra.cifra_launcher import main; main()"


Compilar a binarios ejecutables

Ahora ya se puede ejecutar "pyoxidizer build" y pyoxidizer comenzará a empaquetar y compilar nuestra aplicación.

Pero Cifra tiene un problema particular en este punto. Si intentas compilar Cifra con la configuración que hemos visto hasta ahora nos encontraremos con el siguiente error:

(venv)dante@Camelot:~/Projects/cifra$ pyoxidizer build
[...]
error[PYOXIDIZER_PYTHON_EXECUTABLE]: adding PythonExtensionModule<name=sqlalchemy.cimmutabledict>

Caused by:
extension module sqlalchemy.cimmutabledict cannot be loaded from memory but memory loading required
--> ./pyoxidizer.bzl:272:5
|
272 | exe.add_python_resources(exe.setup_py_install(package_path=CWD))
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ add_python_resources()


error: adding PythonExtensionModule<name=sqlalchemy.cimmutabledict>

Caused by:
extension module sqlalchemy.cimmutabledict cannot be loaded from memory but memory loading required

(venv)dante@Camelot:~/Projects$

PyOxidizer intenta incluir todas las dependencias de tu aplicación dentro del binalrio resultante (modo in-memory). Esto está muy bien porque acabas con un único binario a distribuir y se mejora el rendimiento al cargar esas dependencias. El problema es que no todos los paquetes que hay por ahí admiten ser incluidos de esa manera. En ese caso SQLAlchemy no se deja incluir así.

En ese caso, las dependencias deben almacenarse junto al binario producido (modo filesystem-relative). PyOxidizer enlazará esas dependencias dentro del binario usando sus ubicaciones relativas al fichero binario creado, por eso a la hora de distribuir debemos empaquetar el binario y sus dependencias manteniendo sus ubicaciones relativas.

Una manera de resolver el problema es pedirle a PyOxidizer que use el modo in-memory por defecto y recurra al modo filesystem-relative cuando no le quede más remedio. Para hacerlo hay que descomentar las dos líneas siguientes a la función make_exe() en el fichero de configuración:

# Use in-memory location for adding resources by default.
policy.resources_location = "in-memory"

# Use filesystem-relative location for adding resources by default.
# policy.resources_location = "filesystem-relative:prefix"

# Attempt to add resources relative to the built binary when
# `resources_location` fails.
policy.resources_location_fallback = "filesystem-relative:prefix"

Haciendo esto debería conseguir que tu aplicación tuviese este fallo, pero Cifra tiene otros más adelante en el proceso de compilación:

(venv)dante@Camelot:~/Projects/cifra$ pyoxidizer build
[...]
adding extra file prefix/sqlalchemy/cresultproxy.cpython-39-x86_64-linux-gnu.so to .
installing files to /home/dante/Projects/cifra/./build/x86_64-unknown-linux-gnu/debug/install
Traceback (most recent call last):
File "<string>", line 1, in <module>
File "cifra.cifra_launcher", line 31, in <module>
File "cifra.attack.vigenere", line 21, in <module>
File "cifra.tests.test_simple_attacks", line 2, in <module>
File "pytest", line 5, in <module>
File "_pytest.assertion", line 9, in <module>
File "_pytest.assertion.rewrite", line 34, in <module>
File "_pytest.assertion.util", line 13, in <module>
File "_pytest._code", line 2, in <module>
File "_pytest._code.code", line 1223, in <module>
AttributeError: module 'pluggy' has no attribute '__file__'
error: cargo run failed

(venv)dante@Camelot:~/Projects$

Este error parece algo relacionado con este matiz de PyOxidizer

Para resolver este proyecto no queda otro remedio que obligar al modo filesystem-related en todos los casos:

# Use in-memory location for adding resources by default.
# policy.resources_location = "in-memory"

# Use filesystem-relative location for adding resources by default.
policy.resources_location = "filesystem-relative:prefix"

# Attempt to add resources relative to the built binary when
# `resources_location` fails.
# policy.resources_location_fallback = "filesystem-relative:prefix"

Ahora la compilación funcionará de un tirón:

(venv)dante@Camelot:~/Projects/cifra$ pyoxidizer build
[...]
installing files to /home/dante/Projects/cifra/./build/x86_64-unknown-linux-gnu/debug/install
(venv)dante@Camelot:~/Projects/cifra$ls build/x86_64-unknown-linux-gnu/debug/install/
cifra prefix
(venv)dante@Camelot:~/Projects$

Como se puede ver, PyOxidizer generó un ejecutable ELF para nuestra aplicación y guardó todas sus dependencias en la carpeta prefix:

(venv)dante@Camelot:~/Projects/cifra$ ls build/x86_64-unknown-linux-gnu/debug/install/prefix
abc.py concurrent __future__.py _markupbase.py pty.py sndhdr.py tokenize.py
aifc.py config-3 genericpath.py mimetypes.py py socket.py token.py
_aix_support.py configparser.py getopt.py modulefinder.py _py_abc.py socketserver.py toml
antigravity.py contextlib.py getpass.py multiprocessing __pycache__ sqlalchemy traceback.py
argparse.py contextvars.py gettext.py netrc.py pyclbr.py sqlite3 tracemalloc.py
ast.py copy.py glob.py nntplib.py py_compile.py sre_compile.py trace.py
asynchat.py copyreg.py graphlib.py ntpath.py _pydecimal.py sre_constants.py tty.py
asyncio cProfile.py greenlet nturl2path.py pydoc_data sre_parse.py turtledemo
asyncore.py crypt.py gzip.py numbers.py pydoc.py ssl.py turtle.py
attr csv.py hashlib.py opcode.py _pyio.py statistics.py types.py
base64.py ctypes heapq.py operator.py pyparsing stat.py typing.py
bdb.py curses hmac.py optparse.py _pytest stringprep.py unittest
binhex.py dataclasses.py html os.py pytest string.py urllib
bisect.py datetime.py http _osx_support.py queue.py _strptime.py uuid.py
_bootlocale.py dbm idlelib packaging quopri.py struct.py uu.py
_bootsubprocess.py decimal.py imaplib.py pathlib.py random.py subprocess.py venv
bz2.py difflib.py imghdr.py pdb.py reprlib.py sunau.py warnings.py
calendar.py dis.py importlib __phello__ re.py symbol.py wave.py
cgi.py distutils imp.py pickle.py rlcompleter.py symtable.py weakref.py
cgitb.py _distutils_hack iniconfig pickletools.py runpy.py _sysconfigdata__linux_x86_64-linux-gnu.py _weakrefset.py
chunk.py doctest.py inspect.py pip sched.py sysconfig.py webbrowser.py
cifra email io.py pipes.py secrets.py tabnanny.py wsgiref
cmd.py encodings ipaddress.py pkg_resources selectors.py tarfile.py xdrlib.py
codecs.py ensurepip json pkgutil.py setuptools telnetlib.py xml
codeop.py enum.py keyword.py platform.py shelve.py tempfile.py xmlrpc
code.py filecmp.py lib2to3 plistlib.py shlex.py test_common zipapp.py
collections fileinput.py linecache.py pluggy shutil.py textwrap.py zipfile.py
_collections_abc.py fnmatch.py locale.py poplib.py signal.py this.py zipimport.py
colorsys.py formatter.py logging posixpath.py _sitebuiltins.py _threading_local.py zoneinfo
_compat_pickle.py fractions.py lzma.py pprint.py site.py threading.py
compileall.py ftplib.py mailbox.py profile.py smtpd.py timeit.py
_compression.py functools.py mailcap.py pstats.py smtplib.py tkinter

(venv)dante@Camelot:~/Projects$

Si quisieras nombrar a esa carpeta con otro nombre más autoexplicativo, sólo habría que cambiar "prefix" por el nombre que quisieras en el fichero de configuración. Por ejemplo, para llamarla "lib":

# Use in-memory location for adding resources by default.
# policy.resources_location = "in-memory"

# Use filesystem-relative location for adding resources by default.
policy.resources_location = "filesystem-relative:lib"

# Attempt to add resources relative to the built binary when
# `resources_location` fails.
# policy.resources_location_fallback = "filesystem-relative:prefix"

Podemos ver cómo ha cambiado el nombre de la carpeta de dependencias al recompilar:

(venv)dante@Camelot:~/Projects/cifra$ pyoxidizer build
[...]
installing files to /home/dante/Projects/cifra/./build/x86_64-unknown-linux-gnu/debug/install
(venv)dante@Camelot:~/Projects/cifra$ls build/x86_64-unknown-linux-gnu/debug/install/
cifra lib
(venv)dante@Camelot:~/Projects$


Compilando para múltiples distribuciones

Hasta ahora, hemos conseguido un binario que puede ser ejecutado en una distribución como la que hayamos usado para compilar. El problema es que el binario generado puede fallar si se ejecuta en otras distribuciones:

(venv)dante@Camelot:~/Projects/cifra$ ls build/x86_64-unknown-linux-gnu/debug/install/
cifra lib
(venv) dante@Camelot:~/Projects/cifra$ docker run -d -ti -v $(pwd)/build/x86_64-unknown-linux-gnu/debug/install/:/work ubuntu
36ad7e78c00f90172bd664f9a045f14acc2138b2ee5b8ac38e7158aa145d4965
(venv) dante@Camelot:~/Projects/cifra$ docker attach 36ad
root@36ad7e78c00f:/# ls /work
cifra lib
root@36ad7e78c00f:/# /work/cifra --help
usage: cifra [-h] {dictionary,cipher,decipher,attack} ...

Console command to crypt and decrypt texts using classic methods. It also performs crypto attacks against those methods.

positional arguments:
{dictionary,cipher,decipher,attack}
Available modes
dictionary Manage dictionaries to perform crypto attacks.
cipher Cipher a text using a key.
decipher Decipher a text using a key.
attack Attack a ciphered text to get its plain text

optional arguments:
-h, --help show this help message and exit

Follow cifra development at: <https://github.com/dante-signal31/cifra>
root@36ad7e78c00f:/# exit
exit

(venv)dante@Camelot:~/Projects$

Aquí nuestro binario ha funcionado en otro sistema Ubuntu porque lo he compilado en mi PC (Camelot) que es un Ubuntu (bueno, en realidad un Linux Mint). El binario generado se ejecutará correctamente en máquinas con la misma distribución de liinux que la que usé para compilar el binario.

Pero veamos que pasa si ejecutamos nuestro binario en una distribución diferente:

(venv)dante@Camelot:~/Projects/cifra$ docker run -d -ti -v $(pwd)/build/x86_64-unknown-linux-gnu/debug/install/:/work centos
Unable to find image 'centos:latest' locally
latest: Pulling from library/centos
a1d0c7532777: Pull complete
Digest: sha256:a27fd8080b517143cbbbab9dfb7c8571c40d67d534bbdee55bd6c473f432b177
Status: Downloaded newer image for centos:latest
376c6f0d085d9947453a2786a9a712146c471dfbeeb4c452280bd028a5f5126f
(venv) dante@Camelot:~/Projects/cifra$ docker attach 376
[root@376c6f0d085d /]# ls /work
cifra lib
[root@376c6f0d085d /]# /work/cifra --help
/work/cifra: /lib64/libm.so.6: version `GLIBC_2.29' not found (required by /work/cifra)
[root@376c6f0d085d /]# exit
exit

(venv)dante@Camelot:~/Projects$

Como puedes ver, nuestro binario falle en Centos. Eso ocurre porque las librerías no se llaman igual en las distintas distribuciones por lo que nuestro binario falla al cargar las dependencias que necesita para funcionar.

Para resolver esto necesitamos compilar un binario estáticamente enlazado para no tener dependencias externas en absoluto.

Para compilar ese tipo de binarios necesitamos actualizar el framework de Rust:


(venv)dante@Camelot:~/Projects/cifra$ rustup target add x86_64-unknown-linux-musl
info: downloading component 'rust-std' for 'x86_64-unknown-linux-musl'
info: installing component 'rust-std' for 'x86_64-unknown-linux-musl'
30.4 MiB / 30.4 MiB (100 %) 13.9 MiB/s in 2s ETA: 0s

(venv)dante@Camelot:~/Projects$

Se supone que para hacer que PyOxidizer compile un binario como ese debemos hacer:

(venv)dante@Camelot:~/Projects/cifra$ pyoxidizer build --target-triple x86_64-unknown-linux-musl
[...]
Processing greenlet-1.1.1.tar.gz
Writing /tmp/easy_install-futc9qp4/greenlet-1.1.1/setup.cfg
Running greenlet-1.1.1/setup.py -q bdist_egg --dist-dir /tmp/easy_install-futc9qp4/greenlet-1.1.1/egg-dist-tmp-zga041e7
no previously-included directories found matching 'docs/_build'
warning: no files found matching '*.py' under directory 'appveyor'
warning: no previously-included files matching '*.pyc' found anywhere in distribution
warning: no previously-included files matching '*.pyd' found anywhere in distribution
warning: no previously-included files matching '*.so' found anywhere in distribution
warning: no previously-included files matching '.coverage' found anywhere in distribution
error: Setup script exited with error: command 'musl-clang' failed: No such file or directory
thread 'main' panicked at 'called `Result::unwrap()` on an `Err` value: Custom { kind: Other, error: "command [\"/home/dante/.cache/pyoxidizer/python_distributions/python.70974f0c6874/python/install/bin/python3.9\", \"setup.py\", \"install\", \"--prefix\", \"/tmp/pyoxidizer-setup-py-installvKsIUS/install\", \"--no-compile\"] exited with code 1" }', pyoxidizer/src/py_packaging/packaging_tool.rs:336:38
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

(venv)dante@Camelot:~/Projects$

Como se puede ver, me sale un error que no he sido capaz de resolver. Debido a eso,  puse una incidencia en la página de GitHub de PyOxidizer. El autor de PyOxidizer me respondió amablemente que necesitaba el comando musl-clang en mi sistema. No he encontrado paquete alguno con el comando musl-clang por lo que supongo que es algo que hay que compilar desde el código fuente. He intentado encontrar en Google cómo hacerlo pero no he sido capaz (debo admitir que no soy un gurú de C/C++). Por eso, supongo que tendré que usar PyOxidizer sin la compilación estática hasta que la dependencia de musl-clang desaparezca o la documentación explique cómo conseguir ese comando.


Conclusión

Aunque no he conseguido crear binarios estáticamente enlazados, PyOxidizer parece una herramienta prometedora. Creo que puedo usarla con vdist para acelerar la velocidad del proceso de empaquetado. Como en vdist uso contenedores específicos para cada tipo de paquete la incapacidad para crear binarios estáticamente enlazados no parece ser bloqueante. Además, parece contar con un desarrollo activo, por lo que parece una buena oportunidad para ayudar a simplificar el empaquetado de aplicaciones python y su posterior distribución.

12 octubre 2021

Cómo procesar los comandos de consola de tus aplicaciones Python usando Argparse

Todas las aplicaciones de consola tienen algo en común: tienen que procesar los argumentos que introduce el usuario al teclear el comando que lanza la aplicación. Pocas aplicaciones de consola carecewn de argumentos, la mayoría los necesitan para funcionar adecuadamente. Piensa por ejemplo en el comando ping: necesita al menos un argumento, la dirección IP o la URL a la que se le hace ping.

dante@Camelot:~/$ ping 8.8.8.8
PING 8.8.8.8 (8.8.8.8) 56(84) bytes of data.
64 bytes from 8.8.8.8: icmp_seq=1 ttl=113 time=3.73 ms
64 bytes from 8.8.8.8: icmp_seq=2 ttl=113 time=3.83 ms
64 bytes from 8.8.8.8: icmp_seq=3 ttl=113 time=3.92 ms
^C
--- 8.8.8.8 ping statistics ---
3 packets transmitted, 3 received, 0% packet loss, time 2005ms
rtt min/avg/max/mdev = 3.733/3.828/3.920/0.076 ms

dante@Camelot:~$

Cuando ejecutas una aplicación, te llegan en la lista sys.argv todos los argumentos que usuario introdujo cuando llamó a tu aplicación desde la consola. Ese comando y sus argumentos se divide  en tokens, usando los espacios en blanco como separadores, siendo cada token un elemento de la lista sys.argv. El primer elemento de esa lista es el nombre de tu aplicación (el comando en si). Por eso, si por ejemplo hemos implementado nuestra propia versión del comando ping, el elemento 0 de sys.argv será "ping" y el elemento en la posición 1 será la IP, por ejemplo "8.8.8.8".

Podrías hacer tu propio análisis de los argumentos revisando por ti mismo el contenido de sys.argv. La mayor parte de los desarrolladores lo hacen así... al principio. Si lo haces así, pronto acabas dándote cuenta de que no es tan fácil gestionar de manera limpia los argumentos de usuario y encima acabas repitiendo muchísimo código en tus diferentes aplicaciones, por eso hay librerías pensadas para analizar por ti los argumentos de usuario. Una que me encanta es Argparse.

Argparse es un módulo incluido de serie en la distribución estándar de Python, así que no hay que descargarlo de Pypi. Una vez que se comprenden sus conceptos resulta fácil, muy flexible y gestiona por ti muchas casuísticas nada evidentes a priori. Es uno de esos módulos que echas de menos cuando programas en otros lenguajes.

El primer concepto a comprender de Argparser es precisamente el de Argument Parser. Para este módulo, un Argument Parser es una palabra fija que está seguida por argumentos de usuario posicionales u opcionales. El Argument Parser por defecto es precisamente el nombre de la aplicación que ejerce de comando. En nuestro ejemplo, el comando "ping" es un Argument Parser. Cada Argument Parser puede verse acompañado de Arguments, los cuales pueden ser de dos tipos:

  • Argumentos posicionales: Son aquellos obligatorios para el usuario. El usuario tiene que introducirlos o el comando asume que la orden es incorrecta y aborta la ejecución. Este tipo de argumentos tiene que introducirse en un orden específico. En nuestro ejemplo del comando ping "8.8.8.8" es un argumento posicional. Podemos encontrar otros ejemplos, el comando "cp" por ejemplo necesita dos argumentos posicionales: el fichero origen de la copia y el fichero de destino.
  • Argumentos opcionales: Pueden ser introducidos o no. Se marcan con etiquetas. Las etiqueta sabreviadas usan un guión y un carácter, mientras que las etiquetas largas usan un guión doble y una palabra. Algunos argumentos opcionales admiten un valor y otros no (son booleanos, true si se usan o false si no).
Aquí se pueden ver algunos de los argumentos que acepta el comando "cat":

dante@Camelot:~/$ cat --help
Usage: cat [OPTION]... [FILE]...
Concatenate FILE(s) to standard output.

With no FILE, or when FILE is -, read standard input.

-A, --show-all equivalent to -vET
-b, --number-nonblank number nonempty output lines, overrides -n
-e equivalent to -vE
-E, --show-ends display $ at end of each line
-n, --number number all output lines
-s, --squeeze-blank suppress repeated empty output lines
-t equivalent to -vT
-T, --show-tabs display TAB characters as ^I
-u (ignored)
-v, --show-nonprinting use ^ and M- notation, except for LFD and TAB
--help display this help and exit
--version output version information and exit

Examples:
cat f - g Output f's contents, then standard input, then g's contents.
cat Copy standard input to standard output.

GNU coreutils online help: <https://www.gnu.org/software/coreutils/>
Full documentation at: <https://www.gnu.org/software/coreutils/cat>
or available locally via: info '(coreutils) cat invocation'

dante@Camelot:~$

Hay comandos más complejos que incluyen lo que se denomina Subparsers. Los subaparsers aparecen cuando nuestro comando incluye otras palabras "fijas", como verbos. Por ejemplo, el comando "docker" tiene muchos subparser: "docker build", "docker run" o "docker ps". Cada subparser puede aceptar su propio conjunto de argumentos. Un subparser puede incluir a otros subparsers, formando subparser anidados y dando lugar a árboles de comandos.

Para mostrar como usar el módulo argparse, voy a usar como ejemplo my propia configuración para la aplicación Cifra. En concreto usaremos su fichero cifra_launcher.py file en GitHub.

En su sección __main__ se puede ver cómo se procesan los argumentos a alto nivel:

Como se puede ver esatamos usando sys.argv para obtener los argumentos facilitados por el usuario, descartando el primero ("[1:]") porque es el comando raiz mismo: "cifra".

Me gusta usar sys.argv como parámetro por defecto de main() porque así puedo llamar a main() desde mis test funcionales, usando una lista específica de argumentos.

Los argumentos se pasan a la función parse_arguments() que es la función que hace toda la magia de argparse. Allí se puede ver la configuración base de argparse:

Ahí se puede configurar el parser base, el que se enlaza al comando raiz, definiendo la descripción del comando y una nota final ("epilog") para el comando. Esos textos aparecerán cuando el usuario llame al comando con el argumento "--help".

Mi aplicación cifra tiene algunos subparsers, al igual que hace docker. Para permitir que un parser albergue subparsers, hay que hacer:

Una vez que has permitido que tu parser tenga subparsers, ya se puede empezar a crear esos subparser. Cifra tiene 4 subparsers en ese nivel: 

 

 

Luego veremos que argparse devuelve un objeto similar a un diccionario tras realizar su labor de parseo. Con el código anterior, si por ejemplo el usuario introdujo "cifra dictionary" entonces el objeto similar a un diccionario tendrá una clave "mode" con un valor "dictionary".

Cada subparser puede tener a su vez subparsers, en realidad en nuestro código de ejemplo el subparser "dictionary" tiene más subparsers que añaden más ramas al árbol del comando. De esa manera se puede llamar a "cifra dictionary create", "cifra dictionary update", "cifra ditionary delete", etc.

Una vez que llegas a la conclusión de que una rama ha llegado a su máxima profundidad y no necesita más subparsers, se pueden añadir sus respectivos argumentos. Para el subparser de "cipher" tenemos los siguientes argumentos:


En estos argumentos, "algorithm" y "key" son posicionales y por lo tanto obligatorios. Los argumentos que se sitúen en esas posiciones serán los valores de las claves "algorithm" y "key" del objeto devuelto por argparse.

El parámetro metavar es la cadena que queremos que se use para representar este parámetro cuando se llame a la ayuda con el argumento --help. Como te puedes imaginar, el parámetro help es la cadena de ayuda que sale para explicar este argumento cuando se llama a la ayuda con --help.

El parámetro type se usa para preprocesar el argumento facilitado por el susuario. Por defecto, los argumentos se interpretan como strings, pero si se usa type=int entonces se convertirán a int (o se lanzaría un error si la conversión resultase imposible). En realidad str o int son funciones, así que se pueden pasar los nombres de tus propias funciones de conversión a type. Por ejemplo, para el parámetro file_to_cipher yo he usado mi propia función _check_is_file().

Como se puede ver, esta función comprueba si los argumentos facilitados se corresponde con una ruta de ficheros correcta y de ser así devuelve la cadena de la ruta o un error si la ruta fuese incorrecta.

Recuerda que los parámetros opcionales siempre vienen precedidos de guiones. Si esos parámetros están seguidos de un argumento dado por el usuario, ese argumento será el valor para la clave, denominada como el nombre largo del parámetro, en el objeto tipo diccionario devuelto por argparser. En nuestro ejemplo, la línea 175 significa que cuando el usuario teclea "--ciphered_file myfile.txt", argparser devolverá una clave llamada "ciphered_file" con el valor  "myfile.txt".

A veces los parámetros opcionales no necesitarán valores. Pr ejemplo, podrían ser simples parámetros booleanos para activar una salida detallada:

parser.add_argument("--verbose", help="increase output verbosity", action="store_true", default=False)

Con action="store_true" el objeto tipo diccionario tendrá la clave "verbose" asociada con un valor booleano: verdadero si se usó --verbose o falso en caso contrario.

Antes de devolver de salir de parse_arguments() me gusta filtrar el contenido del diccionario a devolver:


En esta sección de código parsed_arguments() es el objeto tipo diccionario que devuelve argparse tras procesar los argumentos del usuario con arg_parser.parse_args(args). Este objeto incluye también, con valor None, todos los argumentos opcionales que se podrían haber introducido pero que al final no se metieron. Podría devolver directamente este objeto y luego ir comprobando parámetro a parámetro si es None o no, pero me parece más limpio quitar todas las claves que sean None y luego limitarme a comprbar si cada clave está presente o no. Por eso filtro el objeto diccionario en la línea 235.

De vuelta a la función main(), tras procesar los argumentos del usuario se puede desarrollar el resto de la lógica de la aplicación dependiendo del contenido del diccionario de argumentos. Por ejemplo, para cifra:

 De esta manera, argparser te permite procesar los argumentos del usuario de una manera limpia y que te ofrece también por el mismo "precio" (gratis) mensajes de ayuda y de error, dependiendo de si el usuario llamó a --help o introdujo un comando erroneo o equivocado.En realidad, los mensajes generados son tan limpios que los uso para el redme.md de mi repositorio y conforma el punto de partida para generar mis páginas de man (aunque eso ya es material para otro artículo).

04 septiembre 2021

Cómo usar contenedores de Docker

 
La virtualización se basa en el engaño.

Con la virtualización pesada (es decir, la de VMware, VirtualBox o Xen) a un sistema operativo "invitado" se le engaña para que crea que está funcionando en un hardware dedicado, cuando en realidad este es compartido.

Con virtualización ligera (es decir, la de Docker) a una aplicación se la engaña para que crea que está utilizando un kernel propio de sistema operativo, cuando en realidad este está compartido. La clave es que en Linux todo lo que no sea el kernel se considera una aplicación por lo que Docker puede hacer que varias destribuiones de linux compartan el mismo kernel (el del sistema anfitrión). Dado que el nivel de abstracción del engaño es mucho mayor que el practicado en la virtualización pesada el consumo de recursos es mucho menor, de ahí el nombre de virtualización ligera. Es tan ligera que muchas aplicaciones se distribuyen en paquetes de docker (denominados contenedores) en loas que la aplicación se empaqueta junto al sistema operativo y las dependencias que necesita para ser ejecutado de una vez en otro sistema, sin enredar con las dependencias de este último.

Es cierto que hay cosas que no se pueden hacer con virtualización ligera, pero no son muchas. Para desarrollar y ejecutar una aplicación estándar "de nivel 7" no encontraremos ninguna limitación usando virtualización de Docker.


Instalación

Antiguamente se podía instalar docker desde el repositorio de paquetes de tu distribución. Actualmente, aunque puedan encontrarse paquetes de docker en los repositorios estándar, lo normal es que ya no funcionen. Para hacer funcionar Docker lo suyo es ignorar los paquetes del repositorio estándar de tu distribución y tirar de los repositorios oficiales de Docker. 

Docker facilita repositorios de paquetes para muchas distribuciones. Por ejemplo, aquí se pueden leer las instrucciones para instalar docker en un equipo Ubuntu. Lo que sí hay que tener en cuenta es que en caso de que estemos usando algún derivado de Ubuntu, como Linux Mint, vamos a necesitar personalizar esas instrucciones para configurar la versión en las listas de fuentes de apt en nuestro sistema.

Una vez instalado docker en nuestro ordenador, podemos ejecutar la aplicación de Hello World, incluido en un contenedor de ejemplo, para comprobar que todo funcione bien:

dante@Camelot:~/$ sudo docker run hello-world
[sudo] password for dante:
Unable to find image 'hello-world:latest' locally
latest: Pulling from library/hello-world
b8dfde127a29: Pull complete
Digest: sha256:7d91b69e04a9029b99f3585aaaccae2baa80bcf318f4a5d2165a9898cd2dc0a1
Status: Downloaded newer image for hello-world:latest

Hello from Docker!
This message shows that your installation appears to be working correctly.

To generate this message, Docker took the following steps:
1. The Docker client contacted the Docker daemon.
2. The Docker daemon pulled the "hello-world" image from the Docker Hub.
(amd64)
3. The Docker daemon created a new container from that image which runs the
executable that produces the output you are currently reading.
4. The Docker daemon streamed that output to the Docker client, which sent it
to your terminal.

To try something more ambitious, you can run an Ubuntu container with:
$ docker run -it ubuntu bash

Share images, automate workflows, and more with a free Docker ID:
https://hub.docker.com/

For more examples and ideas, visit:
https://docs.docker.com/get-started/

dante@Camelot:~$

Ten en cuenta que aunque puedas ejecutar contenedores de docker puede ser que no puedas descargar nuevos e instalarlos sin ser usar sudo. Si no te resulta cómodo trabajar así, siempre puedes añadirte a tí mismos al grupo de docker:

dante@Camelot:~/$ sudo usermod -aG docker $USER
[sudo] password for dante:

dante@Camelot:~$

Puede ser que tengas que cerrar la sesión y volver a hacer login para que los cambios se activen. De esta manera podrás ejecutar comandos de docker como un usuario normal sin privilegios de administrador.


Uso

Podemos crear nuestros propios contenedores, pero eso es un tema para otro artículo. En este artículo vamos a usar contenedores personalizados por otros.

Para encontrar contenedores que sirvan de punto de partida, dirígete a  Docker Hub y teclea cualquier aplicación o distribución de Linux en su campo de búsqueda. La búsqueda devolverá muchas opciones. Si buscas una distribución monda y lironda lo mejor es usar una con la etiqueta "Official image". 

Supongamos que quieres probar una aplicación en Ubuntu Xenial, entonces elige Ubuntu en el listado de salida de la búsqueda y échale un ojo a la sección de "Supported tags...". Ahí se puede ver como se llaman las distintas versiones para ser descargadas. En nuestro caso tomaremos nota de las etiquetas "xenial" y "16.04".

Ahora que ya sabemos qué bajar, vamos a hacerlo con docker pull:

dante@Camelot:~/$ docker pull ubuntu:xenial
xenial: Pulling from library/ubuntu
528184910841: Pull complete
8a9df81d603d: Pull complete
636d9303bf66: Pull complete
672b5bdcef61: Pull complete
Digest: sha256:6a3ac136b6ca623d6a6fa20a7622f098b2fae1ac05f0114386ef439d8ca89a4a
Status: Downloaded newer image for ubuntu:xenial
docker.io/library/ubuntu:xenial


dante@Camelot:~$

Lo que hemos hecho ha sido bajar lo que se denomina una imagen. Una imagen es un paquete base con una distribución de linux específica con una serie de aplicaciones instaladas. Desde ese paquete base se pueden derivar tus propios paquetes personalizados o ejecutar instancias de esos paquetes base, esas instancias es lo que denominamos contenedores.

Si quieres comprobar cuantas imágenes tenemos disponibles sólo hay que usar docker images:

dante@Camelot:~/$ docker images
REPOSITORY TAG IMAGE ID CREATED SIZE
ubuntu xenial 38b3fa4640d4 4 weeks ago 135MB
hello-world latest d1165f221234 5 months ago 13.3kB



dante@Camelot:~$

Puedes iniciar instancias (es decir contenedores) desde esas imñagenes usando  docker run:

dante@Camelot:~/$ docker run --name ubuntu_container_1 ubuntu:xenial
dante@Camelot:~$

Usando el parámetro --name puedes asignar un nombre específico a un contenedor para identificarlo entre otros contenedores iniciados desde la misma imagen.

El problema de iniciar contenedores de esta manera es que se cierran inmediatamente. Si miramos el estatus de nuestros contenedores usando docker ps:

dante@Camelot:~/$ docker ps -a
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
c7baad2f7e56 ubuntu:xenial "/bin/bash" 12 seconds ago Exited (0) 11 seconds ago ubuntu_container_1
3ba89f1f37c6 hello-world "/hello" 9 hours ago Exited (0) 9 hours ago focused_zhukovsky

dante@Camelot:~$

He usado el parámetro -a para mostrar todos los contenedores, no sólo los activos. De esa manera se puede ver que el contenedor ubuntu_container_1 finalizó su actividad casi inmediatamene tras suzomienzo. Eso ocurre porque los contenedores de docker están diseñados para ejecutar una applicación específica y cerrarse en cuanto esa applicación acaba. Como nosotros no dijimos qué aplicación ejecutar, el contenedor se limitó a cerrarse en cuanto se inició.

Antes de intentar nada más, vamos a borrar el contenedor anterior usandodocker rm. De esa manera empezaremos desde cero:

dante@Camelot:~/$ docker rm ubuntu_container_1
ubuntu_container_1

dante@Camelot:~$ docker ps -a
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
3ba89f1f37c6 hello-world "/hello" 10 hours ago Exited (0) 10 hours ago focused_zhukovsky
dante@Camelot:~$

Ahora lo que queremos es mantener el contenedor vivo para poder acceder a su consola. Una manera de hacerlo es:

dante@Camelot:~/$ docker run -d -ti --name ubuntu_container_1 ubuntu:xenial
a14e6bcac57dd04cc777a4eac787a8465acd5b8d379591976c56de1d0acc2798

dante@Camelot:~$ docker ps -a
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
a14e6bcac57d ubuntu:xenial "/bin/bash" 8 seconds ago Up 7 seconds ubuntu_container_1
3ba89f1f37c6 hello-world "/hello" 10 hours ago Exited (0) 10 hours ago focused_zhukovsky
dante@Camelot:~$

En el comando anterior hemos usado el parámetro -d para ejecutar el contenedor en segundo plano y los parámetros -ti para iniciar una consola interactiva y mantenerla abierta. Haciéndolo de esa manera, podemos ver que esta vez el contenedor se mantiene arriba. Pero seguimos sin acceder a la consola del contenedor, para acceder a ella debemos hacer docker attach:

dante@Camelot:~/$ dante@Camelot:~$ docker attach ubuntu_container_1
root@a14e6bcac57d:/#

Se puede ver que la consola ha cambiado su identificador del lado izquierdo. Se puede salir del contenedor tecleando exit, pero eso para el contenedor. Para dejar el contenedor manteniéndolo activo debemos usar Ctrl+p seguido de Ctrl+q en su lugar. 

Se puede para un contenedor inactivo para ahorrar recursos y reanudarlo después usando docker stop y docker start:

dante@Camelot:~/$ docker ps -a
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
9727742a40bf ubuntu:xenial "/bin/bash" 4 minutes ago Up 4 minutes ubuntu_container_1
3ba89f1f37c6 hello-world "/hello" 10 hours ago Exited (0) 10 hours ago focused_zhukovsky
dante@Camelot:~$ docker stop ubuntu_container_1
ubuntu_container_1
dante@Camelot:~$ docker ps -a
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
9727742a40bf ubuntu:xenial "/bin/bash" 7 minutes ago Exited (127) 6 seconds ago ubuntu_container_1
3ba89f1f37c6 hello-world "/hello" 10 hours ago Exited (0) 10 hours ago focused_zhukovsky
dante@Camelot:~$ docker start ubuntu_container_1
ubuntu_container_1
dante@Camelot:~$ docker ps -a
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
9727742a40bf ubuntu:xenial "/bin/bash" 8 minutes ago Up 4 seconds ubuntu_container_1
3ba89f1f37c6 hello-world "/hello" 10 hours ago Exited (0) 10 hours ago focused_zhukovsky
dante@Camelot:~$


Salvar los cambios

Será habitual que quieras exportar los cambios realizados a un contenedor para poder iniciar uno nuevo con esos cambios aplicados.

La mejor manera es usar dockerfiles, pero eso lo dejaré para un artículo posterior. Una manera rápida (y chapucera) es registrar los cambios en una nueva imagen haciendo docker commit:

dante@Camelot:~/$ docker commit ubuntu_container_1 custom_ubuntu
sha256:e2005a0ec8302f8948958a90e2abc1e8957a15155c6e6bbf7300eb1709d4ae70
dante@Camelot:~$ docker images
REPOSITORY TAG IMAGE ID CREATED SIZE
custom_ubuntu latest e2005a0ec830 5 seconds ago 331MB
ubuntu xenial 38b3fa4640d4 4 weeks ago 135MB
ubuntu latest 1318b700e415 4 weeks ago 72.8MB
hello-world latest d1165f221234 5 months ago 13.3kB

dante@Camelot:~$

En este ejemplo hemos creado una nueva imagen llamada custom_ubuntu. Usando esa nueva imagen podemos crear nuevas instancias con los cambios hechos hasta el momento a ubuntu_container_1.

Ten en cuenta que al registrar los cambios de un contenedor en funcionamiento este se para con el fin de evitar la corrrupción de datos. Al finalizar el registro el contenedor reanuda su funcionamiento.


Ejecutar servicios desde contenedores

Hasta ahora hemos levantado una máquina virtual ligera y hemos accedido a su consola, pero eso no es suficiente si queremos ofrecer servicios desde ese contenedor.

Supongamos que hemos configurado un servidor de SSH en el contenedos y que queremos accedes a él a través de la LAN. En ese caso necesitaremos que determinados puertos del contenedor sean mapeados con otros del anfitrión que puedan ser accedidos por la LAN.

Una trampa importante a tener en cuenta aquí es que ese mapeo de puertos debe ser configurado cuando el contenedor se ejecuta por primera vez con docker run, usando el parámetro -p:
dante@Camelot:~/$ docker run -d -ti -p 8888:22 --name custom_ubuntu_container custom_ubuntu
5ea2604da8c696af397cd1b1f98d7426dd68182e5edd81a56a6b739849ba1e84

dante@Camelot:~$

Hecho eso se podrá acceder al servicio SSH del contenedor usando el puerto 8888 del anfitrión.


Compaartir ficheros con los contenedores 

Nuestros contenedores no serán islas aisladas. En ocasiones necesitarán que le facilitemos ficheros externos o nos devolverán ficheros.

Una manera de hacerlo es arrancar los contenedores montando una carpeta del anfitrión a modo de carpeta compartida:

dante@Camelot:~/$ docker run -d -ti -v $(pwd)/docker_share:/root/shared ubuntu:focal
ee64e7392a77b4d0b35adadad467fe5d6a105b84290945e346f82199116b7c56

dante@Camelot:~$

Aquí, la carpeta del anfitrión docker_share será accesible en la ruta del contenedor /root/shared. Ojo que las rutas deben ser absolutas. Utilizao $(pwd) como un atajo para poner el directorio actual.

Una vez que el contenedor esté arrancado, cualquier fichero que el contenedor sitúe en la carpeta /root/shared será visible desde el anfitrión, incluso después de que se pare al contenedor. En el otro sentido, es decir dejar un fichero del anfitrión para que lo vea el contenedor, es posible pero habrá que hacer sudo:

dante@Camelot:~/$ cp docker.png docker_share/.
cp: cannot create regular file 'docker_share/./docker.png': Permission denied

dante@Camelot:~/$ sudo cp docker.png docker_share/.
[sudo] password for dante:

dante@Camelot:~$

Otra manera es compartir los ficheros usando el comando de copia que tiene docker:

dante@Camelot:~/$ docker cp docker.png vigorous_kowalevski:/root/shared 
dante@Camelot:~$

Aquí, hemos copiado el fichero docker.png del anfitrión a la carpeta /root/shared del contenedor vigorous_kowalevski. Fíjate que no hemos necesitado hacer sudo para ejecutar el comando docker cp

En el otro sentido también es posible, sólo hay que cambiar el orden de los argumentos:

dante@Camelot:~/$ docker cp vigorous_kowalevski:/etc/apt/sources.list sources.list 
dante@Camelot:~$

Aquí, hemos copiado el fichero sources.list del contenedor al anfitrión.


A partir de aquí

Hasta ahora hemos visto como manejar contenedores de docker. El siguiente paso es crear tus propias imágenes usando dockerfiles y compartiendolos a través de Docker Hub. Explicaré todo eso en un artículo posterior.