27 abril 2023

Cómo crear tus propias imágenes de Docker

 


En un artículo anterior ya vimos las bases de cómo usar imágenes de Docker. Pero en aquel caso usamos imágenes construidas por otros. Eso está muy bien si encuentras la imagen que estás buscando, pero qué pasa si ninguna cubre tus necesidades?

En este artículo te voy a explicar cómo crear tus propias imágenes y cómo subirlas a Docker Hub de manera que puedas descargarlas fácilmente para tus proyectos, así como compartirlas con otros desarrolladores. 


Para cocinar necesitas una receta

Partiendo de que hayas seguido el tutorial que enlazaba al principio, ya tendrás Docker instalado en tu Linux. Con eso, necesitarás una receta para decirle a Docker los pasos para "cocinar" tu imagen. Esa receta es un fichero llamado Dockerfile que hay que crear en la carpeta que más te convenga en la que habrás de incluir cualquier fichero que quiera meter en tu imagen.

¿Qué cocinas con esa receta? una imagen, ¿pero qué es una imagen de Docker?. Una imagen de docker es un sistema operativo virtual con una configuración específica y un conjunto concreto de dependencias instaladas. Cómo utilices esa imagen depende de ti. Algunas imágenes están pensadas para ser usadas como base en las que instalar nuestra aplicación para que se ejecute dentro. Otras imágenes son más específicas y ya traen una aplicación instalada para ser ejecutada en cuanto se arranque la imagen. Haciendo una analogía con la Programación Orientada a Objetos (OOP en inglés), una imagen de Docker sería una clase, mientras que un contenedor sería una instancia de esa clase (también denominada "objeto" en OOP). No es raro encontrar múltiples contenedores ejecutándose simultáneamente después de ser iniciados de la misma imagen.

Para entender cómo funciona un Dockerfile, vamos a analizar algunos ejemplos.

Vamos a empezar con este Dockerfile del  proyecto vdist. La imagen que se crea con ese Dockerfile está pensada para compilar una distribución de Python y ejecutar una aplicación llamada fpm sobre una carpeta creada sobre la marcha cl arrancar el contenedor basado en la imagen. Por lo tanto, este Dockerfile se encarga de instalar todas las dependencias necesarias para que la imagen quede preparada para que sus contenedores puedan compilar la distribución de Python y ejecutar fpm.

El primer paso en todos los Dockerfile es definir qué imagen vamos a usar como punto de partida. Continuando la metáfora de la OOP, si nuestra imagen es una clase, en este punto definimos de qué otra clase hereda la nuestra.

En este caso, nuestra imagen se deriva de la imagen ubuntu:latest, pero para tus imágenes puedes usar cualquier otra imagen disponible en Docker Hub. Sólo asegúrate de echarle un ojo a la página de la imagen en Docker Hub para averiguar cual es la etiqueta que más se ajuste a lo que necesites. Una vez que subas tu imagen a Docker Hub otros podrán usarla como punto de partida para sus respectivas imágenes.

Todos las obras de arte deberían firmarse. A tu imagen le pasa lo mismo, por lo que debemos definir metadatos para hacerlo.

La miga empieza realmente con los comandos RUN. Esos son lo que usas para configurar tu imagen.

Algunas personas que crean una imagen por primera vez confunden lo que hacen realmente los comandos RUN. Estos comandos no se ejecutan cuando se crea un contenedor de una imagen, con el comando "docker run". En vez de eso, se ejecutan sólo una vez por parte del comando "docker build", para crear una imagen de un Dockerfile.

El conjunto exacto de comandos RUN para una imagen dependerá de los que necesite el proyecto respectivo. En este ejemplo, los comando RUN comprueban que la lista de fuentes de apt son las correctas, actualizan la base de datos de apt e instalan un  conjunto de dependencias usando tanto "apt-get" como "gem install". 

Te recomiendo que comiences el apartado de los comandos RUN con un "RUN set -e" de manera que el proceso de construcción finalice con un error si cualquiera de los comandos RUN devuelve un error. Esto puede parecer una medida extrema, pero es la mejor para asegurar que no subas una imagen con un error inadvertido.

Además de eso, cuando revises Dockerfiles de otros proyectos te darás cuenta que muchos de ellos incluyen varios comandos de shell dentro del mismo comando RUN, como pasa en las líneas 14 a 16 de nuestro ejemplo. La gente de Docker recomienda juntar dentro de un mismo comando RUN aquellos comandos de shell que están relacionados entre si (es decir, si dos comandos no tienen sentido separados, siendo ejecutados sin el otro, deberían ser incluidos dentro del mismo comando RUN). Esto se debe a la estructura en capas con la que se construyen las imágenes de Docker, en las que cada comando RUN conforma una capa separada. Si sigues el consejo de la gente de Docker, tus imágenes serán más rápidas de reconstruir cuando realices un cambio sobre su Dockerfile. Para incluir varios comandos de shell en el mismo comando RUN, recuerda separar esos comandos en una línea por cada comando de shell y pon al final de cada línea un "&& \" para encadenar las sucesivas líneas (excepto en la última, como se puede ver en el ejemplo).

Aparte de los comandos RUN, hay otros que deberías conocer. Vamos a analizar el Dockerfile de mi proyecto markdown2man. El propósito de esa imagen es ejecutar un script de Python para hacer que Pandoc realice la conversión de un fichero usando los argumentos pasados por el usuario al arrancar el contenedor con "docker run". Para ello, verás que algunos de los comandos empleados en el Dockerfile ya resultan familiares:


Pero a partir de ahí, aparecen nuevos comando que vuelven interesante la cosa.

Con los comandos ENV puedes crear variables de entorno para ser usadas en los comandos del Dockerfile, de manera que no tengas que repetir una y otra vez las mismas cadenas y además facilites las modificaciones.


Sin embargo, ten en cuenta que las variables de entorno creadas con los comandos ENV van más allá de la fase de construcción y persisten en el contenedor que se cree de esa imagen. Eso puede provocar colisiones con otras variables de entorno creadas al ejecutar el contenedor de la imagen. Si sólo necesitas una variable de entorno durante la fase de construcción y quieres que se borre cuando se ejecute el contenedor, entonces usa el comando ARG en vez de los comandos ENV.

Para incluir los ficheros de tu aplicación dentro de la imagen, usa el comando COPY:


Estos comandos copian un fichero de origen, relativos a la ubicación del Dockerfile, desde el equipo donde estés construyendo la imagen, a una ruta dentro de esa imagen. En este ejemplo, copiamos un fichero requirements.txt, que está ubicado en la misma carpeta que el Dockerfile, hacia una carpeta llamada /script (tal y como se define en la variable de entorno SCRIPT_PATH) dentro de la imagen.

Por último, pero no por ello menos importante, tenemos otro comando nuevo: ENTRYPOINT.


Un ENTRYPOINT define qué comando ejecutar cuando se inicie una contenedor a partir de esta imagen, de manera que los comandos que se pasan a "docker run" en realidad se pasan al comando definido en ENTRYPOINT. El contenedor se mantendrá en marcha hasta que el comando ejecutado en el ENTRYPOINT finalice.

Los ENTRYPOINT son la clave para poder usar contenedores de docker como medio para ejecutar programas sin necesitar de instalarlos en nuestro equipos y "contaminar" nuestro sistema de paquetes con las dependencias de ese programa.

Hora de cocinar

Una vez que nuestra receta esté lista, será hora de cocinar algo con ella.

Cuando nuestro Dockerfile esté listo, será el momento de usar el comando "docker build" para creau una imagen. Partiendo de que estemos en la misma carpeta que nuestro Dockerfile:

dante@Camelot:~/Projects/markdown2man$ docker build -t dantesignal31/markdown2man:latest .
Sending build context to Docker daemon  20.42MB
Step 1/14 : FROM python:3.8
 ---> 67ec76d9f73b
Step 2/14 : LABEL maintainer="dante-signal31 (dante.signal31@gmail.com)"
 ---> Using cache
 ---> ca94c01e56af
Step 3/14 : LABEL description="Image to run markdown2man GitHub Action."
 ---> Using cache
 ---> b749bd5d4bab
Step 4/14 : LABEL homepage="https://github.com/dante-signal31/markdown2man"
 ---> Using cache
 ---> 0869d30775e0
Step 5/14 : RUN set -e
 ---> Using cache
 ---> 381750ae4a4f
Step 6/14 : RUN apt-get update     && apt-get install pandoc -y
 ---> Using cache
 ---> 8538fe6f0c06
Step 7/14 : ENV SCRIPT_PATH /script
 ---> Using cache
 ---> 25b4b27451c6
Step 8/14 : COPY requirements.txt $SCRIPT_PATH/
 ---> Using cache
 ---> 03c97cc6fce4
Step 9/14 : RUN pip install --no-cache-dir -r $SCRIPT_PATH/requirements.txt
 ---> Using cache
 ---> ccb0ee22664d
Step 10/14 : COPY src/lib/* $SCRIPT_PATH/lib/
 ---> d447ceaa00db
Step 11/14 : COPY src/markdown2man.py $SCRIPT_PATH/
 ---> 923dd9c2c1d0
Step 12/14 : RUN chmod 755 $SCRIPT_PATH/markdown2man.py
 ---> Running in 30d8cf7e0586
Removing intermediate container 30d8cf7e0586
 ---> f8386844eab5
Step 13/14 : RUN ln -s $SCRIPT_PATH/markdown2man.py /usr/bin/markdown2man
 ---> Running in aa612bf91a2a
Removing intermediate container aa612bf91a2a
 ---> 40da567a99b9
Step 14/14 : ENTRYPOINT ["markdown2man"]
 ---> Running in aaa4774f9a1a
Removing intermediate container aaa4774f9a1a
 ---> 16baba45e7aa
Successfully built 16baba45e7aa
Successfully tagged dantesignal31/markdown2man:latest

dante@Camelot:~$

Si no estuviésemos en la misma carpeta que en el Dockerfile, deberíamos reemplazar ese "." al final del comando con una ruta hacia la carpeta del Dockerfile.

El  parámetro "-t" se usa para darle un nombre adecuado (también llamado "tag") a nuestra imagen. Si quieres subir tu imagen a Docker Hub te aconsejo que intentes seguir sus convenciones de nombrado. Para que una imagen pueda ser subida a Docker su nombre se debe estructurar de la siguiente forma: <docker-hub-user>/<repository>:<version>. Como puedes ver en el último ejemplo, el parámetro docker-hub-user era dantesignal31 mientras que el repositorio era markdown2man y la versión era latest.

Subir tu imagen a Docker Hub

Si el proceso de construcción finaliza correctamente debería ver cómo se registra tu imagen en el sistema:

dante@Camelot:~/Projects/markdown2man$ docker images
REPOSITORY                   TAG                 IMAGE ID       CREATED          SIZE
dantesignal31/markdown2man   latest              16baba45e7aa   15 minutes ago   1.11GB


dante@Camelot:~$

Pero una imagen que sólo esté disponible localmente tiene una utilidad limitada. Para que esté globalmente disponible deberíassubirla a Docker Hub, pero para hacerlo primero necesitas una cuenta en Docker Hub. El proceso para registrar una nueva cuenta es similar al de cualquier otro servicio online.

Hecho el registro, accede con tu nueva cuenta a Docker Hub. Una vez dentro, crea un nuevo repositorio. Recuerda que, sea cual sea el nombre que le des al repositorio, se le pondrá un prefijo con tu nombre de usuario para conformar el nombre completo del repositorio.


En este ejemplo, el nombre completo del repositorio sería mobythewhale/my-private-repo. A menos que estés usando una cuenta de pago, probablemente seleccionarás un repositorio "Public".

Recuerda nombrar a tu imagen con un nombre de repositorio que concuerde con el que creaste en Docker Hub.

Antes de que puedas subir tu imagen, tendrás que hacer login a Docker Hub desde la consola con "docker login":

dante@Camelot:~/Projects/markdown2man$ docker login
Authenticating with existing credentials...
WARNING! Your password will be stored unencrypted in /home/dante/.docker/config.json.
Configure a credential helper to remove this warning. See
https://docs.docker.com/engine/reference/commandline/login/#credentials-store

Login Succeeded
dante@Camelot:~$

La primera vez que hagas login se te pedirá tu usuario y contraseña.

Una vez logado ya puedes subir tu imagen con "docker push":

dante@Camelot:~/Projects/markdown2man$ docker push dantesignal31/markdown2man:latest
The push refers to repository [docker.io/dantesignal31/markdown2man]
05271d7777b6: Pushed 
67b7e520b6c7: Pushed 
90ccec97ca8c: Pushed 
f8ffd19ea9bf: Pushed 
d637246b9604: Pushed 
16c591e22029: Pushed 
1a4ca4e12765: Pushed 
e9df9d3bdd45: Mounted from library/python 
1271cc224a6b: Mounted from library/python 
740ef99eafe1: Mounted from library/python 
b7b662b31e70: Mounted from library/python 
6f5234c0aacd: Mounted from library/python 
8a5844586fdb: Mounted from library/python 
a4aba4e59b40: Mounted from library/python 
5499f2905579: Mounted from library/python 
a36ba9e322f7: Mounted from library/debian 
latest: digest: sha256:3e2a65f043306cc33e4504a5acb2728f675713c2c24de0b1a4bc970ae79b9ec8 size: 3680

dante@Camelot:~$

A partir de ahí tu imagen estará disponible en Docker Hub y lista para ser usada por cualquiera.

Conclusión

Hemos revisado los fundamentos básicos de como construir una imagen de Docker. A partir de aquí, ya sólo te queda practicar creando imágenes cada vez más complejas, a partir de otras más sencillas. Afortunadamente Docker tiene una gran documentación por lo que debería resultarte fácil resolver cualquier problema con el que puedas encontrarte.

Comprendido lo básico, puedes sacarle mucho partido a herramientas como Docker Desktop que pueden simplificarte enormemente el proceso.