Tutorial de Docker. Parte 2

Esta segunda parte del tutorial está dedicada a la creación de imágenes y contenedores personalizados utilizando un archivo Dockerfile. Es muy recomendable haber seguido el artículo anterior para entender mejor esta parte. Además, algunos de los ejemplos aquí expuestos son adaptaciones de los ejemplos del artículo anterior, lo que permitirá apreciar las diferencias y ventajas que ofrece el uso de Dockerfile.

¿Qué es un Dockerfile?

Un Dockerfile es un archivo de texto que contiene un conjunto de instrucciones para construir una imagen Docker. Es una especie de receta que le indica a Docker cómo ensamblar una imagen, especificando el sistema operativo base, las dependencias, los archivos que se deben copiar, variables de entorno, los comandos que se deben ejecutar, y más.

Cada una de estas instrucciones se ejecuta de manera secuencial, y cada una representa una capa que añade características específicas a la imagen.

Una de las ventajas de Docker es poder tener diferentes archivos de configuración para un mismo tipo de contenedor. Por convención, el archivo Dockerfile principal se llama simplemente "Dockerfile", sin extensión. Para otros archivos secundarios de configuración, se recomienda nombrarlos siguiendo el formato "Dockerfile.loquesea". Ejemplo: Dockerfile.dev, Dockerfile.prod, etc.

Estructura Básica de un Dockerfile

La estructura básica de un Dockerfile consiste en una serie de comandos que se ejecutan de manera secuencial, formando la imagen en múltiples capas. Opcionalmente, se pueden añadir "parser directives" (directivas del analizador) al principio del archivo para indicarle al analizador cómo interpretar y ejecutar cada instrucción. Con estas directivas, es posible configurar detalles como la versión del analizador a utilizar, el carácter de escape para las rutas (en Linux generalmente es `/`, mientras que en Windows se utiliza `\`), entre otras características avanzadas. Si deseas profundizar más en este tema, puedes consultar la documentación oficial. Si se omiten las configuraciones de Parser Directives, el Dockerfile se ejecutará con los valores predeterminados, que son suficientes para los ejemplos y las necesidades tratadas en este artículo.

Un Dockerfile típico comienza indicando la imagen base que se utilizará, seguido de una serie de comandos adicionales, como la instalación de paquetes específicos, la definición de variables de entorno y la ejecución de comandos necesarios para inicializar servicios. El propósito principal de un Dockerfile es preparar la imagen de forma que, al crear un contenedor, las configuraciones más comunes ya estén automatizadas, evitando la necesidad de ejecutarlas manualmente, como vimos en el artículo anterior en algunos ejemplos. Si seguiste todo el proceso, recordarás que, una vez creado el contenedor, era necesario abrir sesión para instalar paquetes y realizar otras modificaciones, además de que la sentencia docker run se volvía muy extensa al incluir muchos parámetros. Con los Dockerfile, gran parte de ese trabajo de ajustes y configuraciones se automatiza, permitiéndonos tener una imagen o contenedor adaptado a las necesidades del proyecto, lo que simplifica enormemente el proceso de levantar un contenedor desde esa imagen.

Instrucciones comunes en Dockerfile

La lista completa de instrucciones está disponible, como siempre, en la documentación oficial. A continuación, revisaremos algunas de las más relevantes, que utilizaremos en los ejemplos.

  • FROM: Especifica la imagen base que se utilizará para construir la nueva imagen. Esta es la primera instrucción en cualquier Dockerfile.
    Ejemplo:
    # Usar la imagen base de Ubuntu 24.04
    FROM ubuntu:24.04
  • RUN: Ejecuta comandos en la imagen durante el proceso de construcción. Cada instrucción RUN crea una nueva capa en la imagen, permitiendo que el contenedor resultante esté actualizado con los paquetes y servicios deseados. Piensa en RUN como si estuvieras ejecutando comandos en una terminal dentro del contenedor.
    Ejemplo:
    RUN apt-get update && apt-get upgrade -y
  • WORKDIR: Establece el directorio de trabajo dentro del contenedor para las siguientes instrucciones.
    Ejemplo:
    WORKDIR /var/www/html/
  • COPY / ADD: Ambas instrucciones se utilizan para copiar archivos y directorios desde el host al contenedor. La instrucción ADD tiene funcionalidades adicionales, como la capacidad de descargar archivos desde URLs y extraer archivos comprimidos (por ejemplo, archivos .tar).
    Ejemplos:
    # Copia todos los archivos del directorio actual del host al directorio de trabajo del contenedor
    COPY . .
    
    # Copia todos los archivos del directorio actual del host al directorio /app del contenedor
    COPY . /app
    
    # Copia de archivos remotos(ejemplos extraidos de la documentación oficial)
    ADD https://example.com/archive.zip /usr/src/things/
    ADD git@github.com:user/repo.git /usr/src/things/
  • CMD / ENTRYPOINT: Ambas instrucciones definen el comando que se ejecutará por defecto cuando se inicie un contenedor. CMD proporciona los argumentos predeterminados y puede ser sobrescrito al ejecutar el contenedor, mientras que ENTRYPOINT establece el comando principal que no puede ser sobrescrito a menos que se use la opción --entrypoint en la línea de comandos de Docker. ENTRYPOINT es ideal para iniciar servicios, pero en la mayoría de los casos también se puede usar CMD si solo necesitas un comando predeterminado.
    Ejemplos:
    # Configura el punto de entrada para iniciar Apache en primer plano
    ENTRYPOINT ["apachectl", "-D", "FOREGROUND"]
    
    # (Alternativamente) Comando que se ejecutará al iniciar el contenedor
    CMD ["apachectl", "-D", "FOREGROUND"]
  • EXPOSE: Documenta el puerto en el que la aplicación dentro del contenedor está configurada para escuchar. Esta instrucción no abre ni expone el puerto por sí misma; simplemente sirve como una referencia para los usuarios y herramientas de Docker, indicando qué puertos deben ser accesibles para interactuar con el contenedor. Los puertos reales se mapean y exponen al ejecutar el contenedor utilizando la opción -p en el comando docker run.
    Ejemplo:
    EXPOSE 80
  • ENV: Establece variables de entorno en el contenedor. Estas variables están disponibles para los procesos en el contenedor y pueden ser utilizadas para configurar la aplicación o el entorno de ejecución. Puedes definir múltiples variables de entorno en una sola instrucción ENV separándolas con espacios o definir varias instrucciones ENV para mayor claridad.
    Ejemplo:
    # Establece una variable de entorno en el contenedor
    ENV MYSQL_DATABASE=ordenadoresretro
    
  • VOLUME: Define un punto de montaje en el contenedor para persistir datos. Esto permite que los datos almacenados en el contenedor sean mantenidos incluso si el contenedor se elimina. Al definir un volumen, Docker crea un directorio en el contenedor que puede ser mapeado a un directorio en el host o a un volumen gestionado por Docker. Esto es especialmente útil para almacenar datos que deben persistir entre las ejecuciones del contenedor.
    Ejemplo:
    # Define un punto de montaje para persistir datos dentro del contenedor
    VOLUME /mibasededatos
    
Ejemplo práctico: Dockerfile para una aplicación Apache Web

Creo que una buena manera de enseñar las ventajas del uso de un Dockerfile es repetir el ejemplo que vimos sobre cómo crear un contenedor web basado en la imagen "Ubuntu:2404" al que añadimos el servicio Apache. En aquella ocasión, fue necesario hacerlo en varios pasos: primero, creamos el contenedor base. Después, tuvimos que abrir una sesión dentro del contenedor para instalar el servicio **Apache HTTP**. Luego descubrimos que el servicio no estaba levantado, y además, nos dimos cuenta de que tampoco teníamos instalado el comando curl ni mi editor favorito, nano. En resumen, tuvimos que realizar manualmente una serie de operaciones para dejar todo a nuestro gusto. Ahora veremos que, con un Dockerfile, todo ese proceso se automatiza, y los contenedores que creemos a partir de esta imagen ya estarán configurados exactamente como necesitamos.

El Dockerfile necesario queda de la siguiente manera:

# Usamos la imagen base de Ubuntu 24.04
FROM ubuntu:24.04

# Actualizamos la lista de paquetes e instalamos Apache, curl y nano
RUN apt-get update && apt-get install -y apache2 curl nano

# Exponemos el puerto 80
EXPOSE 80

# Definimos el comando por defecto para iniciar Apache en primer plano
CMD ["apachectl", "-D", "FOREGROUND"]

Como ves, la extructura es bastante limpia y la sintaxis muy clara: elegir imagen base, instalar paquetes, exponer puerto y levantar servicio. Todo de una vez.

Construcción de una imagen y contenedor

A partir de un archivo Dockerfile, creamos una imagen personalizada usando el comando actualizado docker buildx build. También existe la forma tradicional, más antigua, que ahora no se recomienda utilizar, mediante el comando docker build. Buildx es más potente y optimizado, especialmente al trabajar con imágenes multiplataforma, cachés distribuidas y mejoras de rendimiento mediante el uso de BuildKit aunque docker build sigue siendo funcional.

Para conocer los múltiples parámetros del comando Docker Buildx podemos listarlos desde la terminal junto a una breve descripción. Para una explicación mucho más completa, extendida y actualizada, como siempre visita la documentación oficial

~$ docker buildx build --help
Start a build

Usage:  docker buildx build [OPTIONS] PATH | URL | -

Start a build

Aliases:
  docker build, docker builder build, docker image build, docker buildx b

Options:
      --add-host strings              Add a custom host-to-IP mapping (format: "host:ip")
      --allow strings                 Allow extra privileged entitlement (e.g., "network.host", "security.insecure")
      --annotation stringArray        Add annotation to the image
      --attest stringArray            Attestation parameters (format: "type=sbom,generator=image")
      --build-arg stringArray         Set build-time variables
      --build-context stringArray     Additional build contexts (e.g., name=path)
      --builder string                Override the configured builder instance
      --cache-from stringArray        External cache sources (e.g., "user/app:cache", "type=local,src=path/to/dir")
      --cache-to stringArray          Cache export destinations (e.g., "user/app:cache", "type=local,dest=path/to/dir")
      --call string                   Set method for evaluating build ("check", "outline", "targets") (default "build")
      --cgroup-parent string          Set the parent cgroup for the "RUN" instructions during build
      --check                         Shorthand for "--call=check" (default )
  -f, --file string                   Name of the Dockerfile (default: "PATH/Dockerfile")
      --iidfile string                Write the image ID to a file
      --label stringArray             Set metadata for an image
      --load                          Shorthand for "--output=type=docker"
      --metadata-file string          Write build result metadata to a file
      --network string                Set the networking mode for the "RUN" instructions during build (default "default")
      --no-cache                      Do not use cache when building the image
      --no-cache-filter stringArray   Do not cache specified stages
  -o, --output stringArray            Output destination (format: "type=local,dest=path")
      --platform stringArray          Set target platform for build
      --progress string               Set type of progress output ("auto", "plain", "tty", "rawjson"). Use plain to show container output (default "auto")
      --provenance string             Shorthand for "--attest=type=provenance"
      --pull                          Always attempt to pull all referenced images
      --push                          Shorthand for "--output=type=registry"
  -q, --quiet                         Suppress the build output and print image ID on success
      --sbom string                   Shorthand for "--attest=type=sbom"
      --secret stringArray            Secret to expose to the build (format: "id=mysecret[,src=/local/secret]")
      --shm-size bytes                Shared memory size for build containers
      --ssh stringArray               SSH agent socket or keys to expose to the build (format: "default|[=|[,]]")
  -t, --tag stringArray               Name and optionally a tag (format: "name:tag")
      --target string                 Set the target build stage to build
      --ulimit ulimit                 Ulimit options (default [])

Experimental commands and flags are hidden. Set BUILDX_EXPERIMENTAL=1 to show them.

A partir de la respuesta de este comando, se puede interpretar que ahora docker build es un alias de docker buildx build, lo que implica que, en la actualidad, posiblemente siempre se aplique la versión moderna de la construcción de imágenes. La informática está en constante evolución, y los cambios son tan rápidos y frecuentes que cualquier documentación o ejemplos que consultes pueden quedar desactualizados. Por eso es importante siempre contrastar y consultar todo con fuentes oficiales.

De esta lista de parámetros, uno de los más utilizados es -t, que permite definir un nombre y etiqueta para la nueva imagen resultante.

En una primera toma de contacto, también puede ser interesante el parámetro -f, que permite especificar el nombre del archivo Dockerfile. Si se omite, "Dockerfile" será el archivo predeterminado que se utilizará para crear la imagen.

Con toda esta información, ya somos capaces de crear nuestra primera imagen utilizando un Dockerfile. Tomaremos como ejemplo el archivo mencionado anteriormente, y desde la terminal debemos ubicarnos en el directorio donde se encuentre un archivo Dockerfile, llamado por ejemplo: Dockerfileapacheweb.dev.

La sintaxis sería la siguiente:

docker build -f Dockerfileapacheweb.dev -t apacheubuntu:v1.0 /ruta/al/directorio

La ruta al directorio si estamos en el propio directorio del Dockerfile se sustituye por un simple "."

Desde la terminal vamos a probar que ocurre:

$ docker build -f Dockerfileapacheweb.dev -t apacheubuntu:v1.0 .
[+] Building 16.0s (7/7) FINISHED                                                                                        docker:default
 => [internal] load build definition from Dockerfileapacheweb.dev                                                                  0.0s
 => => transferring dockerfile: 385B                                                                                               0.0s
 => [internal] load metadata for docker.io/library/ubuntu:24.04                                                                    1.3s
 => [auth] library/ubuntu:pull token for registry-1.docker.io                                                                      0.0s
 => [internal] load .dockerignore                                                                                                  0.0s
 => => transferring context: 2B                                                                                                    0.0s
 => [1/2] FROM docker.io/library/ubuntu:24.04@sha256:8a37d68f4f73ebf3d4efafbcf66379bf3728902a8038616808f04e34a9ab63ee              0.0s
 => => resolve docker.io/library/ubuntu:24.04@sha256:8a37d68f4f73ebf3d4efafbcf66379bf3728902a8038616808f04e34a9ab63ee              0.0s
 => => sha256:8a37d68f4f73ebf3d4efafbcf66379bf3728902a8038616808f04e34a9ab63ee 1.34kB / 1.34kB                                     0.0s
 => => sha256:820a8779863b9b666fd1585cd79b2d8e213b1193e4264c56239d90e9df3b0542 424B / 424B                                         0.0s
 => => sha256:1a799365aa63eed3c0ebb1c01aa5fd9d90320c46fe52938b03fb007d530d8b02 2.31kB / 2.31kB                                     0.0s
 => [2/2] RUN apt-get update && apt-get install -y apache2 curl nano                                                              13.9s
 => exporting to image                                                                                                             0.6s
 => => exporting layers                                                                                                            0.5s
 => => writing image sha256:bcdbb70aa8d924248db6d2355b68d1d3d689ab6a2ffce29f6dbe8e7f8b4f3ef5                                       0.0s
 => => naming to docker.io/library/apacheubuntu:v1.0

Tras unos segundos de operaciones, el comando nos devuelve el ID de la imagen y la confirmación de que ha sido creada con el nombre elegido. La nueva imagen se construyó superponiendo una serie de capas para cada uno de los comandos del Dockerfile.

Podemos listar las imágenes, como vimos anteriormente, y verificar que todo está correcto:

$ docker image ls
REPOSITORY               TAG               IMAGE ID       CREATED         SIZE
apacheubuntu             v1.0              bcdbb70aa8d9   5 minutes ago   262MB

El siguiente paso es ejecutar el contenedor o los contenedores basados en esta imagen.

$ docker run -d --name apache_container -p 8080:80 apacheubuntu:v1.0

Todos los parámetros los explicamos y practicamos anteriormente. La duda que puede surgir es por qué se necesita -p 8080:80 si ya estamos exponiendo el puerto 80 en el archivo Dockerfile. La razón es que, en el Dockerfile, solo definimos que el contenedor utiliza internamente el puerto 80, pero no podemos especificar que el host mapee su puerto 8080 hacia el contenedor. De hecho, se podría suprimir la línea EXPOSE 80 y también funcionaría, porque docker run permite, por sí solo, exponer los puertos de un contenedor. A pesar de esto, es una buena práctica incluir los puertos en el Dockerfile para proporcionar documentación útil a otros desarrolladores y para cuando se utilizan procesos automáticos, como Docker Compose, que aprovechan la información de EXPOSE para gestionar puertos automáticamente.

Ahora, al acceder desde el navegador a la IP y el puerto del host que aloja el contenedor, deberías ver la página de bienvenida de Apache, tal como hemos visto en otras ocasiones.

Las capas y buenas prácticas

En Docker, las capas son un concepto fundamental para comprender cómo se construyen las imágenes. Cada instrucción en un Dockerfile genera una nueva capa sobre la imagen base, y estas capas son inmutables y se almacenan en el sistema de archivos de Docker.

Cuando se agregan paquetes o archivos desde un Dockerfile utilizando comandos como RUN, ADD o COPY, Docker crea una capa sobre la imagen existente. Las capas son inmutables, lo que significa que, una vez creada una capa, esta no se modifica.

Esto se entenderá mejor si lo vemos con el ejemplo de Dockerfile anterior:

en los ejemplos.

  • FROM ubuntu:24.04: Esta primera capa usa una imagen base obtenida del repositorio, la cual ocupa una cantidad determinada de espacio para almacenarla. Aunque esta imagen base se creó a partir de otro archivo Dockerfile y no se considera realmente una capa por separado, a efectos prácticos la tomaremos como una sola capa ya que todo el conjunto viene así y es inmutable.
  • RUN apt-get update && apt-get upgrade -y apache2 curl nano: Esta segunda capa actualiza la lista de paquetes disponibles e instala tres paquetes: apache2, curl y nano. Vamos a tratar enseguida el motivo de encadenar varias instrucciones en una sola línea en lugar de hacerlo por separado para mayor legibilidad. El archivo de la imagen se ha agrandado para añadir esta nueva capa.
  • EXPOSE 80: Esta capa no modifica el tamaño de la imagen; simplemente informa que el contenedor expone el puerto 80.
  • CMD ["apachectl", "-D", "FOREGROUND"]: Esta última capa tampoco modifica el tamaño de la imagen. Su función es ordenar el inicio del servicio HTTP junto con el contenedor.

El uso de capas en Docker permite que, si necesitamos otra imagen con instrucciones comunes en el Dockerfile, no sea necesario descargar y crear esas capas nuevamente, sino que se reutilicen. Por ejemplo, si tenemos otro Dockerfile también basado en Ubuntu 24.04 que instala los mismos paquetes, Docker creará la nueva imagen reutilizando las capas comunes y solo generará nuevas capas donde haya diferencias. Esto permite un gran ahorro de espacio.

Por esta razón, es recomendable encadenar las instrucciones en una sola línea, especialmente cuando afectan al tamaño de la imagen. Si la actualización e instalación de paquetes se desglosa en varias líneas, Docker creará una capa nueva para cada instrucción. Del mismo modo, cuando se desinstalan o eliminan paquetes posteriormente, aunque Docker genere una nueva capa con el cambio, no eliminará las capas previas donde los paquetes fueron añadidos, lo que puede aumentar el tamaño de la imagen innecesariamente.

El comando docker history nombre_de_imagen se utiliza para mostrar el historial de capas de una imagen de Docker. Cada capa corresponde a una instrucción en el Dockerfile que se utilizó para crear la imagen. La salida incluye información como el identificador de la capa, cuándo fue creada, la instrucción del Dockerfile que la generó y su tamaño.

$ docker history apacheubuntu:v1.0
IMAGE          CREATED       CREATED BY                                      SIZE      COMMENT
bcdbb70aa8d9   4 days ago    CMD ["apachectl" "-D" "FOREGROUND"]             0B        buildkit.dockerfile.v0
<missing>      4 days ago    EXPOSE map[80/tcp:{}]                           0B        buildkit.dockerfile.v0
<missing>      4 days ago    RUN /bin/sh -c apt-get update && apt-get ins…   162MB     buildkit.dockerfile.v0
<missing>      6 weeks ago   /bin/sh -c #(nop)  CMD ["/bin/bash"]            0B
<missing>      6 weeks ago   /bin/sh -c #(nop) ADD file:154285ca3d49a142b…   101MB
<missing>      6 weeks ago   /bin/sh -c #(nop)  LABEL org.opencontainers.…   0B
<missing>      6 weeks ago   /bin/sh -c #(nop)  LABEL org.opencontainers.…   0B
<missing>      6 weeks ago   /bin/sh -c #(nop)  ARG LAUNCHPAD_BUILD_ARCH     0B
<missing>      6 weeks ago   /bin/sh -c #(nop)  ARG RELEASE                  0B

La lista se muestra en orden de más reciente a más antigua. Si empezamos por las capas más antiguas, encontramos 6 capas creadas hace 6 semanas, que corresponden a la creación de la imagen base. Estas capas se obtienen directamente de hub.docker.com/ y, en el momento de construir la nueva imagen, ya tenían 6 semanas de antigüedad.

Continuando hacia arriba, encontramos una capa creada hace 4 días, que corresponde al momento en que ejecuté el comando docker build con mi archivo Dockerfile. Esta capa añade 162 MB a la imagen al incorporar varios paquetes.

Las dos capas siguientes permiten la ejecución de determinados comandos al crear el contenedor, pero no modifican el tamaño de la imagen.

Toda esta información del historial de capas es muy útil para optimizar su número y facilita la comprensión de las ventajas del uso de capas en Docker.

Podríamos seguir practicando comandos para crear imágenes con Dockerfile, pero en cuanto tengamos la necesidad de crear un proyecto multicontenedor, descubriremos que debemos utilizar otra tecnología llamada Docker Compose. Esto lo veremos en el siguiente artículo, donde intentaremos crear proyectos multicontenedor de la misma forma que se hace en entornos más serios de desarrollo y producción.

Incluso en proyectos con un solo contenedor, podemos utilizar Docker Compose para levantarlo con un simple comando, evitando las largas líneas de parámetros que se requieren con docker run