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.
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.
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.
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.
# Usar la imagen base de Ubuntu 24.04
FROM ubuntu:24.04
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.
RUN apt-get update && apt-get upgrade -y
WORKDIR /var/www/html/
ADD
tiene funcionalidades adicionales, como la capacidad de descargar archivos desde URLs y extraer archivos comprimidos (por ejemplo, archivos .tar).
# 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
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.
# 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"]
-p
en el comando docker run
.
EXPOSE 80
ENV
separándolas con espacios o definir varias instrucciones ENV
para mayor claridad.
# Establece una variable de entorno en el contenedor
ENV MYSQL_DATABASE=ordenadoresretro
# Define un punto de montaje para persistir datos dentro del contenedor
VOLUME /mibasededatos
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.
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.
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.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