Tal y como vimos en la teoría de Docker las imágenes y contenedores se pueden crear a través de comandos o a través de ficheros. Respecto a los ficheros de Docker existen dos tipos:
- Dockerfile, define las instrucciones necesarias para crear una imagen.
- docker-compose.yml, contiene los comandos para crear y ejecutar un conjunto de contenedores. Estos contenedores a su vez utilizarán las imágenes creadas a través del Dockerfile.
En este post nos centraremos en el primer fichero y veremos un ejemplo práctico y completo de Dockerfile. Tienes disponible el código del proyecto que utilizaremos como ejemplo en GitHub el cual tiene la siguiente estructura:
- Aplicación MVC .NET que muestra empleados y permite insertarlos y eliminarlos.
- Dicha aplicación se comunica con un API para realizar operaciones sobre empleados.
- A su vez el API se comunica con una base de datos SQL Server para persistir los datos.
- En el proyecto existen dos ramas, “WithoutDocker” que contiene el código de la aplicación sin nada de Docker y “WithDocker” que contiene el resultado final de implementar todos los tutoriales de este curso de Docker.
¿Qué es un fichero Dockerfile?
Como ya hemos comentado el fichero Dockerfile sirve para crear imágenes que después meteremos en contenedores. Es un fichero sin extensión que introducimos en la raíz de nuestro proyecto creado con tu editor de texto favorito. Las estructura básica del fichero Dockerfile es:
- FROM imagen:etiqueta. Indica la imagen base de nuestra imagen. Recuerda que una imagen no es más que un conjunto de capas y, por lo tanto, vamos a necesitar una base sobre la que instalar y ejecutar nuestra aplicación.
Establece que nuestra imagen utilizara como base la imagen del sdk de .NET que, a su vez, está instalado sobre el sistema operativo Alpine. Ejemplo:FROM mcr.microsoft.com/dotnet/sdk:8.0-alpine AS sdk - WORKDIR ruta. Permite cambiar el directorio de trabajo. Al igual que la terminal de cualquier sistema operativo, el directorio actual se cambia para que después afecte a los comandos que vienen a continuación.
Cambia el directorio actual a la ruta “/home/my-app-user/app/src/” del contenedor. Ejemplo:WORKDIR /home/my-app-user/app/src/ - COPY origen destino. Permite copiar archivos de la máquina anfitrión al contenedor.
Después de cambiar el directorio de trabajo a “/home/my-app-user/app/src/” se copiará el fichero “./Api.csproj” de la máquina anfitrión al directorio “home/my-app-user/app/src/” del contenedor. Ejemplo:WORKDIR /home/my-app-user/app/src/ COPY ./Api.csproj . - RUN comando. Sirve para ejecutar comandos (de Linux o Windows, depende de qué tipo de contenedor tengamos) durante la creación de la imagen.
Una vez que hemos copiado el archivo «Api.csproj» ejecutaremos el comando “restore” de .NET sobre ese archivo para instalar dependencias y paquetes. Ejemplo:RUN dotnet restore "Api.csproj" - ARG Variable=Valor. Utilizado para definir parámetros que utilizaremos mientras se crea la imagen, no después.
Estos argumentos pueden establecerse a la hora de ejecutar el comandodocker buildel cual nos permite crear una imagen a partir de un fichero Dockerfile (ya lo veremos más adelante). Ejemplo:ARG NODE_VERSION="20" ARG ALPINE_VERSION="3.20" FROM node:${NODE_VERSION}-alpine${ALPINE_VERSION} AS basedocker build --build-arg NODE_VERSION=current . - ENV Variable=Valor. Para establecer el valor de las variables de entorno.
La diferencia entre ARG y ENV en Docker es que ARG son variables que usaremos durante la creación de la imagen mientras que ENV son variables que usaremos durante la ejecución de la aplicación dentro del contenedor.
Si tienes dudas acerca de esta diferencia en Internet puedes encontrar algún esquema con la diferencia entre ARG y ENV. - EXPOSE puerto. Indica los puertos por los que podemos acceder a nuestra aplicación. Recuerda que en Docker existen puertos privados y públicos y que, por defecto, un contenedor no es accesible desde fuera.
Sin embargo ten en cuenta que este comando es solo a nivel informativo y no tiene ningún efecto, quien realmente expone puertos es el parámetro -p XX:YY en el comando run o su equivalente en el fichero docker-compose.yml como veremos más adelante.
Esta línea informa (pero no expone) que el contenedor que utilice esta imagen deberá exponerse por el puerto 8080. Ejemplo:EXPOSE 8080 - USER my-user. Indica el usuario con el que se ejecutarán los comandos que vienen a continuación. Solo se aplica a los comandos RUN, CMD o ENTRYPOINT. En el resto de líneas como COPY si queremos definir el usuario propietario deberemos indicarlo explícitamente con el argumento
--chown.
Esto provoca que todas las líneas que vengan a continuación, siempre que sean RUN, CMD o ENTRYPOINT, se ejecuten con el usuario my-app-user. Ejemplo:USER my-app-user - CMD [“comando1”, “comando2”]. Comando que se ejecutará cuando se inicie la imagen dentro de un contenedor, siempre va al final.
Estas líneas cambian el directorio de trabajo a la carpeta de publicación, informan de que se expondrá por el puerto 8080 y, finalmente, lanzan la aplicación con el comando “dotnet App.dll”. Ejemplo:WORKDIR /home/my-app-user/app/publish EXPOSE 8080 CMD ["dotnet","App.dll"]
Quizás no hayas entendido alguno de los comandos, no te preocupes, a continuación veremos ejemplos sencillos e iremos avanzando poco a poco y analizando cada una de las líneas del fichero Dockerfile. Este apartado está a modo resumen.
Pasos previos a crear fichero Dockerfile
Los pasos previos para crear un fichero Dockerfile de cualquier aplicación podemos resumirlos en los siguientes puntos:
- Conocer cómo publicar o generar la aplicación que vamos a introducir en un contenedor. Cada sistema tendrá su forma y depende del framework/lenguaje que estés utilizando.
- Saber cómo ejecutar la aplicación y qué necesita para ello de tal forma que sea accesible a usuarios y/o máquinas.
En el caso que nos ocupa, que es una aplicación de .NET MVC, los pasos necesarios para generar y ejecutar la aplicación son:
- El primer paso consiste en conocer qué necesitamos para convertir el código de la aplicación en un ejecutable. Date cuenta que lo que tenemos actualmente es el código pero, en la mayor parte de los casos, se necesita un proceso para convertirlo en un paquete ejecutable.
En el ejemplo de .NET necesitaremos hacer un build o restore de la aplicación para que se restauren sus paquetes y dependencias y, después, hacer un publish. En ambos casos es necesario utilizar el SKD de .NET adecuado. - En el segundo paso necesitaremos conocer qué base utilizar para que la aplicación se ejecute.
En el caso de .NET lo único que necesitamos es utilizar un sistema operativo Linux (por ejemplo Alpine porque es una distribución muy ligera) y sobre éste necesitamos instalar el runtime de .NET adecuado para ejecutar la aplicación publicada. Después solo será necesario ejecutar el comando “dotnet App.dll”. - Otros pasos necesarios serán, por ejemplo, cómo ejecutar la aplicación o exponer puertos para permitir el acceso a nuestra aplicación desde fuera.
En el ejemplo de .NET para ejecutar la aplicación solo es necesario el comando “dotnet App.dll” y exponerla a través del puerto 8080.
Ejemplo de Dockerfile básico y sencillo
Antes de comenzar con un ejemplo de Docker real y complejo, para evitarnos la complejidad de cualquier lenguaje compilado, vamos a seguir un ejemplo básico con Docker y HTML. El primer paso, como comenté anteriormente, es hacernos las siguientes preguntas:
- ¿Qué imagen o sistema base necesitamos?. Cualquiera que tenga un servidor web para poder servir los archivos como, por ejemplo, Apache, para ello podemos buscarla en Docker Hub. En nuestro caso utilizaremos esta imagen de Apache.
- ¿Qué necesitamos para convertir el código en un paquete ejecutable?. Absolutamente nada, lo único que necesitamos es copiar los ficheros HTML de nuestro proyecto a la carpeta correspondiente del servidor web.
- ¿Qué pasos adicionales necesitamos?. Simplemente mapear y exponer el puerto de Apache a un puerto público para poder acceder al contenedor y, como consecuencia, a nuestros ficheros HTML.
Una vez tenemos toda la información anterior es hora de pasar a la práctica con un ejemplo de Docker con los siguientes pasos:
- Crea una carpeta llamada “HTML App” en tu ordenador.
- Dentro de ésta crea un archivo llamado “index.html” con el siguiente contenido:
<h1> Hello Word !!! </h1> - En la línea de comandos muévete a la carpeta del proyecto con el fichero html.
- Crea también un fichero llamado Dockerfile sin extensión con el siguiente contenido.
FROM indica la imagen base (que es el servidor de Apache) y COPY copia todos los documentos del directorio actual (solo tenemos index.html) a la carpeta correspondiente de Apache.FROM httpd COPY ./ /usr/local/apache2/htdocs/ - Ejecuta el comando build para generar la imagen. Este comando nos permite generar la imagen no solo con Apache, sino también con nuestro fichero index.html copiado en la carpeta correspondiente del servidor. Podemos verlo como un comando para crear imágenes personalizadas.
El parámetro-tpermite darle un nombre a la imagen para luego referenciarla cuando creemos el contenedor y el “.” del final indica dónde se encuentra el fichero Dockerfile, en este caso se encuentra en el directorio actual.docker build -t my-app-image . - Crea un contenedor con la imagen anterior, para ello utiliza el comando run.
Tal y como vimos en otro artículo le damos un nombre al contenedor, indicamos que el puerto 8080 de nuestra máquina se corresponde con el 80 del servidor y, al final, indicamos la imagen a utilizar que es la que acabamos de crear.docker run --name MyContainer -p 8080:80 my-app-image - Finalmente accede a través del navegador a la ruta http://localhost:8080/ y podrás ver el resultado. ¡Enhorabuena, has dockerizado tu primera aplicación!.

Ejemplo basico de Dockerfile
¡Cuidado con las etiquetas en imágenes Docker!
Si has observado, en el anterior ejemplo, dentro del fichero Dockerfile hemos utilizado la imagen “FROM httpd”, sin etiqueta. Si recuerdas en apartados anteriores comentamos que si no se especifica una etiqueta Docker utilizará por defecto la etiqueta “latest” y esto es un error grave.
Vamos a suponer que, en vez de ser una simple página web HTML estamos creando el archivo para una aplicación .NET Core 3. Supongamos también que no utilizamos ninguna etiqueta y que la aplicación funciona correctamente al crear el contenedor.
Pasado un tiempo sale al mercado .NET Core 5 y seguimos todos los pasos de nuevo (crear imagen, contenedor, publicar aplicación en contenedor… etc) pero ¡Sorpresa!, nuestra aplicación no funciona…
Esto se debe a que la aplicación ha pasado de ejecutarse en .Net Core 3 a .Net Core 5 y todo ello sin darnos cuenta.
Por lo tanto siempre debemos utilizar etiquetas y, además, utilizar etiquetas lo más específicas posibles que permitan que nuestra aplicación funcione. Por ejemplo, es mejor utilizar la etiqueta para .NET core “3.5.18” que la etiqueta “3.5” porque, de nuevo, quizás la etiqueta “3.5” actualmente apunte a la “3.5.18” pero en un futuro puede cambiar a “3.5.24”.
Vamos a modificar nuestro ejemplo anterior para hacer uso de una versión concreta de Apache:
FROM httpd:2.4.62
COPY ./ /usr/local/apache2/htdocs/Y también vamos a darle la versión 1.0.0 a nuestra imagen en el comando build, así nos vamos acostumbrando a seguir buenas prácticas:
docker build -t my-app-image:1.0.0 .
docker run --name MyContainer -p 8080:80 my-app-image:1.0.0Finalmente ten en cuenta que uno de los objetivos de los contenedores es que nuestra aplicación funcione siempre y en cualquier lugar y esto lo conseguimos utilizando etiquetas lo más específicas posibles.
Cómo activar modo interactivo y terminal en contenedor Docker
Ahora vamos a ejecutar el modo interactivo de Docker y lanzar la terminal para poder ver qué hay dentro del contenedor tal y como si se tratase de una terminal de un servidor cualquiera.
Para ello utilizaremos el comando docker run con el parámetro -i, recuerda que antes debemos eliminar el contenedor que hemos creado:

docker run --name MyContainer -p 8080:80 -i my-app-image:1.0.0Como puedes observar aparece todo el output de nuestro contenedor pero, aunque introduzcamos comandos, éstos no se ejecutan, la terminal no nos hace caso… ¿Por qué? Porque es necesario indicar un comando Docker para lanzar la terminal, éste depende del sistema operativo que tenga el contenedor, en nuestro caso sería sh. Además al parámetro -i debemos añadirle el parámetro -t de tal manera que el comando final sería:
docker run --name MyContainer -p 8080:80 -it my-app-image:1.0.0 sh
Como puedes ver ahora si podemos ejecutar comandos dentro del contenedor y movernos dentro de éste como si de un servidor Linux cualquiera se tratara.
Esta funcionalidad es muy potente y útil porque nos permitirá depurar errores y realizar comprobaciones dentro del contenedor.
Seguridad y usuarios en contenedores Docker
Ahora que podemos acceder a la terminal vamos a ver con qué usuario se inicia el contenedor y los permisos de las carpetas:

Como podemos comprobar el usuario con el que se inicia el contenedor es root y todas las carpetas y ficheros, incluido nuestro index.html, son de él. Esto es un riesgo enorme de seguridad que vamos a solucionar en este apartado.
Supongamos que en vez de ser una carpeta con ficheros HTML nos encontramos en un contenedor con una aplicación MVC .NET. Supongamos también que un hacker consigue acceder a nuestro contenedor… Pues bien, éste iniciará sesión con root y, como consecuencia, podrá editar todos los ficheros como, por ejemplo, los del sistema operativo.
La solución consiste en indicar en el fichero Dockerfile que el usuario que va a iniciar sesión en el contenedor es uno específico y que, además, ese usuario es propietario solo de los ficheros de la aplicación pero no del resto como, por ejemplo, los del sistema operativo.
Vamos a ponernos manos a la obra con el fichero Dockerfile para mejorar la seguridad en contenedores Docker:
FROM httpd:2.4.62
RUN addgroup my-app-group && adduser my-app-user --no-create-home --disabled-password && adduser --gecos "" my-app-user my-app-group
USER my-app-user
COPY ./ /usr/local/apache2/htdocs/Como puedes observar, las líneas para cambiar el usuario de un contenedor con Dockerfile son:
- RUN comandos, crea un grupo y un usuario que serán los propietarios de la carpeta de nuestra aplicación y será con el que se iniciará sesión en el contenedor.
Además el usuario no tiene carpeta home ni contraseña y hemos activado el modo silencioso para que no nos pida los datos como nombre, email… etc.
Este comando dependerá del sistema operativo base que utilice tu contenedor, es por ello que en otros sitios de Internet lo puedes encontrar ligeramente distinto. - USER usuario, es la línea que realmente cambia el usuario con el que se ejecutarán las siguientes instrucciones en el fichero Dockerfile.
Si regeneramos la imagen de nuevo (build) y creamos e iniciamos el contenedor (run) de la misma manera de antes, podemos comprobar que hemos iniciado sesión con my-app-user:

Ahora bien… Aquí seguimos teniendo un error, la carpeta y ficheros de nuestra aplicación (index.html y Dockerfile) pertenecen al usuario root pero deberían pertenecer al usuario my-app-user, ¿Por qué ocurre esto?, porque el comando USER se aplica solo a todas las instrucciones siguientes que sean RUN, CMD o ENTRYPOINT y, por lo tanto, el COPY se está ejecutando como root.
Para que el comando COPY se ejecute como my-user-app debemos cambiar el Dockerfile de la siguiente manera:
FROM httpd:2.4.62
RUN addgroup my-app-group && adduser my-app-user --no-create-home --disabled-password && adduser --gecos "" my-app-user my-app-group
USER my-app-user
COPY --chown=my-app-user ./ /usr/local/apache2/htdocs/Y ahora sí, si repetimos las pruebas, el contenedor se inicia con my-user-app y los ficheros y carpetas de nuestro proyecto le pertenecen a él:

Esto tiene como consecuencia que, si alguien consigue entrar en nuestro contenedor, solo pueda acceder con el usuario my-app-user y, por lo tanto, solo podrá modificar los archivos de nuestra aplicación pero no los demás, reduciendo de esta manera el impacto o daño que nos pueden hacer.
¿Qué es y para qué sirve .dockerignore?
Te habrás dado cuenta que, en nuestro ejemplo, se está copiando el archivo Dockerfile y no es necesario que esté en nuestro contenedor.
Una manera de optimizar el tamaño de imágenes y contenedores es utilizar .dockerignore, permite ignorar archivos a la hora de crear la imagen con build y, por lo tanto, también se ignorarán a la hora de crear el contenedor con dicha imagen.
Podemos verlo como algo similar al .gitignore de Git. De hecho una muy buena práctica es utilizar lo mismo que en el .gitignore porque… ¿Qué sentido tiene en un proyecto de .NET copiar la carpeta obj a nuestra imagen y al contenedor? Veremos un ejemplo real de .NET más adelante.
Los pasos para utilizar .dockerignore en Docker son:
- Crea un fichero llamado “.dockerignore” en la carpeta del proyecto junto con Dockerfile. Asegúrate de que no se le añade ninguna extensión por error.
- Introduce en él el siguiente código:
#ignore all kind of files * #except html files !*.html - Elimina el contenedor y la imagen que hemos creado anteriormente con
docker container rm MyContainerydocker image rm my-app-image:1.0.0. - Crea de nuevo la imagen con el comando
docker build -t my-app-image:1.0.0 .desde el directorio HTML App. - Crea y ejecuta de nuevo el contenedor con el comando
docker run --name MyContainer -p 8080:80 -it my-app-image:1.0.0 sh. - Verifica que solo tenemos el index.html en la carpeta htdocs del contenedor y que ya no aparece el fichero Dockerfile:

Que es y para que sirve dockerignore
Ejemplo práctico y real de Dockerfile
Es el momento de utilizar Docker con un proyecto real, en nuestro caso vamos a contenerizar la aplicación MVC de .NET (carpeta APP) que podéis encontrar en GitHub, rama “WithoutDocker”.
El primer paso consiste en hacernos las siguientes preguntas:
- ¿Qué imagen o sistema base necesitamos?. En este caso la aplicación utiliza .NET 8.0, por lo tanto nos servirá cualquier imagen que tenga dicha versión instalada. Si acudimos al Docker Hub de Microsoft vamos a encontrarnos con la imagen del sdk en la versión .NET 8.
- ¿Qué necesitamos para convertir el código en un paquete ejecutable?:
- Por un lado necesitamos el sdk para hacer build y que las dependencias se restauren y descarguen y, después, para hacer publish de nuestra aplicación.
- También necesitamos el runtime de .NET para que nuestra aplicación se ejecute. Por ahora vamos a utilizar solo el sdk para mantener el fichero Dockerfile sencillo.
- ¿Qué pasos adicionales necesitamos?. Simplemente mapear y exponer el puerto del contenedor a uno de nuestro ordenador e iniciar la aplicación con el comando “dotnet App.dll”.
Dicho lo anterior, vamos a crear un fichero Dockerfile en nuestro proyecto, carpeta “Docker\App\App”, con el siguiente código:
FROM mcr.microsoft.com/dotnet/sdk:8.0
RUN addgroup my-app-group && adduser my-app-user --disabled-password && adduser --gecos "" my-app-user my-app-group
USER my-app-user
COPY --chown=my-app-user . /home/my-app-user/app/src
WORKDIR /home/my-app-user/app/src
RUN dotnet build "App.csproj" -c Release -o /home/my-app-user/app/build
RUN dotnet publish "App.csproj" -c Release -o /home/my-app-user/app/publish
WORKDIR /home/my-app-user/app/publish
CMD ["dotnet", "App.dll"]Y a continuación tenéis la explicación de los comandos de Dockerfile:
- FROM. Imagen base de la que partimos, utilizaremos el sdk para hacer build y publish y, posteriormente, para ejecutar la aplicación.
- RUN y USER. El comando RUN crea el usuario específico para la aplicación (recordad, por temas de seguridad) pero esta vez no utilizaremos el parámetro –no-create-home, necesitamos una carpeta home del usuario para después utilizarla. El comando USER hace que todo lo que venga a continuación (recordad, todo menos los COPY) se ejecute con nuestro nuevo usuario.
- COPY, copiamos todo el contenido de nuestra máquina en el directorio actual (.) en la ruta “/home/my-app-user/app/src” del contenedor con el usuario my-user-app como propietario del nuevo contenido.
- WORKDIR, cambiamos de directorio actual a la carpeta donde hemos copiado el código.
- RUN build, hacemos build del código para que se instalen y descarguen las dependencias necesarias en modo release y que los ficheros generados se almacenen en “/home/my-app-user/app/build”.
- RUN publish, publicamos la aplicación en el directorio “/home/my-app-user/app/publish”. Este comando es el que realmente convierte el código de la aplicación en ficheros binarios para ser ejecutados.
- WORKDIR, nos cambiamos a “/home/my-app-user/app/publish” que es donde se encuentra el fichero que debemos ejecutar para iniciar nuestra aplicación.
- CMD, ejecutamos el comando “dotnet App.dll” con la sintaxis de array de elementos. Este comando es el que, finalmente, levanta la aplicación escuchando en el puerto 8080.
Una vez creado el fichero Dockerfile iremos a la terminal de Docker Desktop e introduciremos los siguientes comandos:
cd Docker\App\Apppara cambiar al directorio donde se encuentra el fichero Dockerfile.docker build -t app-image:1.0.0 .para crear la imagen de nuestra aplicación. Guarda los logs en algún sitio, sobre todo el tiempo consumido la primera vez que se crea la imagen, después lo analizaremos y optimizaremos.docker run --name app-container -p 8080:8080 -it app-image:1.0.0para crear el contenedor basado en la imagen anterior. Mapeamos el puerto 8080 del contenedor con el puerto 8080 de nuestra máquina.
Si tenéis dudas sobre en qué puerto se inicia una aplicación en un contenedor utilizad, en el comandorun, la opción-itpara ver los logs, en el caso de .NET nos está indicando que se inicia en el puerto 8080, el cual mapearemos después a un puerto de nuestra máquina.
Docker logs para conocer puerto de inicio - Finalmente comprueba que todo ha salido bien accediendo a la dirección “http://localhost:8080/” desde tu navegador, deberías ver algo similar a la imagen siguiente.
Pero, ¿Por qué aparecen errores?, pues porque no hemos iniciado el API y, cuando la aplicación intenta obtener el listado de empleados, no los obtiene y salta el error de “Connection refused”. Más tarde meteremos el API en otro contenedor.
Docker error API connection refused
Finalmente vamos a dejar por aquí apuntado los tiempos y tamaños para después verificar la optimización de contenedores e imágenes en Docker:
- Tiempo de creación de imagen, 61 segundos.

Tiempo creacion imagen para optimizacion Docker - Tamaño imagen, 867 MB.

Tamano imagen para optimizacion Docker
¿Cómo optimizar imágenes Docker con .dockerignore?
El primer paso para optimizar una imagen Dockerfile es utilizar .dockerignore para reducir la cantidad de archivos que se copian a la imagen y al contenedor desde nuestra máquina.
El contenido de un .dockerignore es igual al que te puedes encontrar en cualquier .gitignore, en nuestro caso, al ser una aplicación .NET utilizaremos el .gitignore de visual studio, podéis encontrar todos los ficheros para cualquier lenguaje/IDE en el siguiente repositorio. Recuerda que debes ubicarlo en la misma carpeta que el Dockerfile con el nombre “.dockerignore”.
A continuación vamos a eliminar todo con el comando docker system prune -a, incluida la caché de docker:

Después creamos la imagen y el contenedor exactamente igual que en el apartado anterior.
Ahora pasaremos a comparar tiempos y tamaños generados con .dockerignore:
- Tiempo de creación de la imagen, 62.8 segundos, igual que antes.

Optimizar Docker con dockerignore tiempo creacion imagen - Tamaño de la imagen, 865 MB, menor que antes.

Optimizar Docker con dockerignore tamano imagen
Quizás una reducción de 2 MB en el tamaño de la imagen parecerá poco pero ten en cuenta que hemos partido de una plantilla .NET pequeña y solo hemos tocado 2 archivos. Un proyecto real es mucho mayor, tiene muchos paquetes Nuget, dependencias… etc, en ellos la diferencia será mucho mayor.
Podemos ver que antes de incluir el .dockerignore teníamos la carpeta Debug copiada tal cual de nuestro proyecto:

Y que al incluir el .dockerignore ya no la tenemos. Release se mantiene porque es la que se genera al hacer build y publish ya que es el modo seleccionado en el Dockerfile:

Finalmente comentar que, generalmente, incluir un fichero .dockerignore no tiene mucha repercusión porque lo que suele ocurrir es que el sistema de pipeline descarga el código de un repositorio git y, por lo tanto, las carpetas y ficheros ya han pasado el filtro del .gitignore. Pero en nuestro caso, como estamos ejecutando el Dockerfile desde el código directamente, si tiene repercusión y es un pequeño paso para optimizar imágenes Docker.
Optimizar imágenes Docker cambiando imagen base
Otra forma de reducir el tamaño de imágenes Docker y su tiempo de creación consiste en elegir imágenes base más pequeñas y/o optimizadas. No es lo mismo tener .NET sobre un Ubuntu con un montón de funcionalidades extra que sobre Alpine, una distribución hecha a propósito para Docker extremadamente ligera.
Veamos un ejemplo donde optimizaremos una imagen Docker cambiando únicamente la imagen base de la que partimos.
En el ejemplo anterior vamos a cambiar la línea FROM mcr.microsoft.com/dotnet/sdk:8.0 por FROM mcr.microsoft.com/dotnet/sdk:8.0-alpine.
Después vamos a borrar todo con el comando docker system prune -a y, a continuación, crearemos la imagen de nuevo y después el contenedor.

La primera vez nos da error en la creación de usuario, esto es porque Alpine tiene otra sintaxis para crearlos, cambiaremos la línea de crear usuario por addgroup my-app-group && adduser my-app-user --disabled-password --gecos "" --ingroup my-app-group.
Si que es cierto que la creación ha llevado algo más de tiempo (92 segundos) pero hemos conseguido reducir el tamaño a 718 MB desde 865 MB, un ahorro de 147 MB cambiando únicamente la imagen base que utilizamos:

No vamos a analizar cada una de las imágenes de .NET pero es conveniente que, para el tipo de proyecto que tengas como, por ejemplo, node o .NET investigues sus tag y el sistema operativo en el que se basan para elegir la imagen óptima que satisfaga las necesidades del proyecto.
¿Cómo funciona la caché y las capas de Docker?
En la teoría ya comentamos que una imagen es en realidad un conjunto de capas. Ahora que hemos visto un fichero Dockerfile es momento de saber que cada línea de éste es una capa (o casi todas ellas). Esto podemos observarlo a la hora de generar la imagen, en nuestro caso tenemos 7:
=> [1/7] FROM mcr.microsoft.com/dotnet/sdk:8.0-alpine@sha256:658c93223111638f9bb54746679e554b2cf0453d8fb7b9fed32c3c0726c210fe 149.8s
=> [2/7] RUN addgroup my-app-group && adduser my-app-user --disabled-password --gecos "" --ingroup my-app-group 1.3s
=> [3/7] COPY --chown=my-app-user . /home/my-app-user/app/src 0.1s
=> [4/7] WORKDIR /home/my-app-user/app/src 0.1s
=> [5/7] RUN dotnet build "App.csproj" -c Release -o /home/my-app-user/app/build 9.6s
=> [6/7] RUN dotnet publish "App.csproj" -c Release -o /home/my-app-user/app/publish 2.1s
=> [7/7] WORKDIR /home/my-app-user/app/publish 0.0sEn la teoría también comentamos que si una capa se encuentra ya en Docker (como por ejemplo la descarga del sdk de .NET, primera capa FROM) no es necesario ejecutarla al crear una imagen, la capa está cacheada. Esto podemos comprobarlo si eliminamos la imagen que acabamos de crear y la recreamos:
=> [1/7] FROM mcr.microsoft.com/dotnet/sdk:8.0-alpine@sha256:658c93223111638f9bb54746679e554b2cf0453d8fb7b9fed32c3c0726c210fe 0.0s
=> CACHED [2/7] RUN addgroup my-app-group && adduser my-app-user --disabled-password --gecos "" --ingroup my-app-group 0.0s
=> CACHED [3/7] COPY --chown=my-app-user . /home/my-app-user/app/src 0.0s
=> CACHED [4/7] WORKDIR /home/my-app-user/app/src 0.0s
=> CACHED [5/7] RUN dotnet build "App.csproj" -c Release -o /home/my-app-user/app/build 0.0s
=> CACHED [6/7] RUN dotnet publish "App.csproj" -c Release -o /home/my-app-user/app/publish 0.0s
=> CACHED [7/7] WORKDIR /home/my-app-user/app/publish 0.0sAquí observamos dos cosas:
- La descarga de la imagen base ha tardado 0 segundos porque Docker ya la tiene descargada.
- El resto de capas están cacheadas lo que significa que no tiene que ejecutarlas. Por ejemplo, no tiene que ejecutar el build ni el publish y, por lo tanto, todas las líneas tardan 0 segundos.
Ahora vamos a tocar el código de la aplicación mínimamente, lo único que haremos será meter un espacio en una vista y, a continuación, eliminamos la imagen y la volvemos a crear:
=> [1/7] FROM mcr.microsoft.com/dotnet/sdk:8.0-alpine@sha256:658c93223111638f9bb54746679e554b2cf0453d8fb7b9fed32c3c0726c210fe 0.0s
=> CACHED [2/7] RUN addgroup my-app-group && adduser my-app-user --disabled-password --gecos "" --ingroup my-app-group 0.0s
=> [3/7] COPY --chown=my-app-user . /home/my-app-user/app/src 0.1s
=> [4/7] WORKDIR /home/my-app-user/app/src 0.0s
=> [5/7] RUN dotnet build "App.csproj" -c Release -o /home/my-app-user/app/build 9.8s
=> [6/7] RUN dotnet publish "App.csproj" -c Release -o /home/my-app-user/app/publish 2.1s
=> [7/7] WORKDIR /home/my-app-user/app/publishAhora podemos ver que:
- La descarga de la imagen base tarda 0 segundos porque está cacheada.
- La instrucción RUN addgroup está cacheada y tarda 0 segundos.
- A partir de la instrucción COPY las capas no están cacheadas o, dicho de otra manera, se han eliminado de caché y tienen que volver a ejecutarse.
Lo realmente interesante de aquí es el último punto… ¿Por que se han eliminado de caché las capas a partir de la instrucción COPY? Porque cualquier cambio que realicemos en el código, si afecta a una línea o capa de Docker, hará que se elimine de caché esa capa y las de abajo. En nuestro ejemplo:
- FROM mcr.microsoft.com/dotnet/sdk:8.0-alpine. Si tocamos el código de nuestra aplicación esta instrucción no se ve afectada porque consiste en descargar la imagen base del sdk de Alpine.
- RUN addgroup my-app-group… Si tocamos el código este comando tampoco se ve afectado, será siempre igual independientemente de las modificaciones que hagamos.
- USER my-app-user, igual que antes, el cambio de usuario actual no cambia si tocamos código.
- COPY –chown=my-app-user . /home/my-app-user/app/src, ahora sí, este comando es dependiente de cambios en el código, es decir, si tocamos una vista o un controlador el comando copy tiene que volver a ejecutarse para copiar el fichero modificado, no puede utilizar la versión cacheada porque tiene el código antiguo.
Es por ello que esta capa y todas las que están por debajo de ella se eliminan de caché y se tienen que volver a ejecutar.
Vale pero… ¿Qué significa todo esto?, pues que el orden de las líneas en Docker importa porque afecta al tiempo de creación de la imagen, si conseguimos cachear la mayor cantidad de capas posibles cuando tocamos código la imagen se generará más rápido.
Veamos un ejemplo utilizando la caché de Docker. Para ello utilizaremos el siguiente fichero:
FROM mcr.microsoft.com/dotnet/sdk:8.0-alpine
RUN addgroup my-app-group && adduser my-app-user --disabled-password --gecos "" --ingroup my-app-group
#Commented for lab purposes USER my-app-user
COPY --chown=my-app-user . /home/my-app-user/app/src
# ¡¡¡ Nueva linea !!!
RUN apk add wget
WORKDIR /home/my-app-user/app/src
RUN dotnet build "App.csproj" -c Release -o /home/my-app-user/app/build
RUN dotnet publish "App.csproj" -c Release -o /home/my-app-user/app/publish
WORKDIR /home/my-app-user/app/publish
CMD ["dotnet", "App.dll"]En este fichero hemos hecho dos cambios:
- Hemos comentado la utilización del usuario my-app-user para después poder instalar un paquete. Si no somos root no podemos instalarlo.
- Hemos añadido “RUN apk add wget” después del copy, este comando instala la aplicación “wget” en nuestro contenedor.
Para comprender cómo funciona la caché de capas en Docker seguiremos los siguientes pasos:
- Crea la imagen 2 veces para que se cacheen las capas, el resultado de la segunda creación es el siguiente:
Como puedes observar en la segunda creación todas las capas están cacheadas.=> [1/8] FROM mcr.microsoft.com/dotnet/sdk:8.0-alpine@sha256:658c93223111638f9bb54746679e554b2cf0453d8fb7b9fed32c3c0726c210fe 0.0s => CACHED [2/8] RUN addgroup my-app-group && adduser my-app-user --disabled-password --gecos "" --ingroup my-app-group 0.0s => CACHED [3/8] COPY --chown=my-app-user . /home/my-app-user/app/src 0.0s => CACHED [4/8] RUN apk add wget # New line 0.0s => CACHED [5/8] WORKDIR /home/my-app-user/app/src 0.0s => CACHED [6/8] RUN dotnet build "App.csproj" -c Release -o /home/my-app-user/app/build 0.0s => CACHED [7/8] RUN dotnet publish "App.csproj" -c Release -o /home/my-app-user/app/publish 0.0s => CACHED [8/8] WORKDIR /home/my-app-user/app/publish - Ahora modifica el código de una vista o controlador del proyecto, borra la imagen y vuelve a generarla, el resultado es el siguiente.
¿Qué ha ocurrido? Tal y como hemos comentado anteriormente todas las capas a partir de COPY se han eliminado de caché y han tenido que volver a ejecutarse. Entre ellas se encuentra la instalación del paquete wget.=> [1/8] FROM mcr.microsoft.com/dotnet/sdk:8.0-alpine@sha256:658c93223111638f9bb54746679e554b2cf0453d8fb7b9fed32c3c0726c210fe 0.0s => CACHED [2/8] RUN addgroup my-app-group && adduser my-app-user --disabled-password --gecos "" --ingroup my-app-group 0.0s => [3/8] COPY --chown=my-app-user . /home/my-app-user/app/src 0.1s => [4/8] RUN apk add wget # New line 2.3s => [5/8] WORKDIR /home/my-app-user/app/src 0.0s => [6/8] RUN dotnet build "App.csproj" -c Release -o /home/my-app-user/app/build 9.3s => [7/8] RUN dotnet publish "App.csproj" -c Release -o /home/my-app-user/app/publish 2.1s => [8/8] WORKDIR /home/my-app-user/app/publish - Ahora vamos a cambiar el orden de las líneas de nuestro fichero Dockerfile, pondremos la línea de instalación de wget por encima de la línea de COPY, es decir:
RUN apk add wget # New line COPY --chown=my-app-user . /home/my-app-user/app/src - Elimina todo lo que tengas en Docker con el comando “docker system prune -a”.
- Vuelve a crear la imagen dos veces, el resultado de la segunda ejecución es el siguiente.
Como podemos observar y como era de esperar todas las capas están cacheadas.=> [1/8] FROM mcr.microsoft.com/dotnet/sdk:8.0-alpine@sha256:658c93223111638f9bb54746679e554b2cf0453d8fb7b9fed32c3c0726c210fe 0.0s => CACHED [2/8] RUN addgroup my-app-group && adduser my-app-user --disabled-password --gecos "" --ingroup my-app-group 0.0s => CACHED [3/8] RUN apk add wget # New line 0.0s => CACHED [4/8] COPY --chown=my-app-user . /home/my-app-user/app/src 0.0s => CACHED [5/8] WORKDIR /home/my-app-user/app/src 0.0s => CACHED [6/8] RUN dotnet build "App.csproj" -c Release -o /home/my-app-user/app/build 0.0s => CACHED [7/8] RUN dotnet publish "App.csproj" -c Release -o /home/my-app-user/app/publish 0.0s => CACHED [8/8] WORKDIR /home/my-app-user/app/publish - Modifica en el proyecto algo de código, como una vista o un controlador.
- Vuelve a borrar y generar la imagen, este es el resultado.
¿Qué ha ocurrido esta vez? Que ahora el comando RUN apk si se ha cacheado y ha tardado 0 segundos, mientras que en la prueba anterior no se cacheó y tardó 2.3 segundos para realizar la instalación.=> [1/8] FROM mcr.microsoft.com/dotnet/sdk:8.0-alpine@sha256:658c93223111638f9bb54746679e554b2cf0453d8fb7b9fed32c3c0726c210fe 0.0s => CACHED [2/8] RUN addgroup my-app-group && adduser my-app-user --disabled-password --gecos "" --ingroup my-app-group 0.0s => CACHED [3/8] RUN apk add wget # New line 0.0s => [4/8] COPY --chown=my-app-user . /home/my-app-user/app/src 0.1s => [5/8] WORKDIR /home/my-app-user/app/src 0.0s => [6/8] RUN dotnet build "App.csproj" -c Release -o /home/my-app-user/app/build 8.4s => [7/8] RUN dotnet publish "App.csproj" -c Release -o /home/my-app-user/app/publish 2.2s => [8/8] WORKDIR /home/my-app-user/app/publish
La conclusión que podemos sacar de este ejemplo es que con simplemente cambiar el orden de las líneas del Dockerfile podemos reducir el tiempo de creación de la imagen.
El objetivo es identificar los comandos que se pueden ver afectados por un cambio de código (como COPY) y poner encima de éstos todas las instrucciones que podamos para aprovecharnos de la caché de Docker.
Veamos otro ejemplo de capas y caché de Docker, esta vez con node:
- Partiremos del siguiente código, lo único que debes saber es que npm-install instala los paquetes/dependencias del proyecto:
FROM node:20.5 WORKDIR /my-app/ COPY . . RUN npm install - Vamos a pensar… ¿Qué líneas del fichero Dockerfile se eliminarán de caché si cambiamos el código de nuestro proyecto? Solo COPY y las que están debajo.
¿Esto qué significa? Que cada vez que toquemos código será necesario reinstalar los paquetes de node. - Veamos ahora la versión mejorada del código:
FROM node:20.5 WORKDIR /my-app/ COPY package*.json . RUN npm install COPY . . - Volvamos a pensar… ¿Qué líneas se ven afectadas si modificamos, por ejemplo, una vista? Solo el último COPY, el primero no se vería afectado porque solo copia los ficheros donde se indican las dependencias y paquetes.
Y esto… ¿Qué significa ahora? Pues que si modificamos una vista de nuestro proyecto de node esta vez no se tienen que instalar las dependencias de nuevo porque solo se borra de caché el último COPY.
Obviamente si en el proyecto añadimos un paquete/dependencia se eliminará de caché el primer COPY y el resto de líneas por debajo, lo que significa que sí se instalarán y descargarán las dependencias, pero esto es obvio y necesario.
Finalmente veamos un ejemplo de capas y caché Docker con nuestro proyecto de .NET:
FROM mcr.microsoft.com/dotnet/sdk:8.0-alpine
RUN addgroup my-app-group && adduser my-app-user --disabled-password --gecos "" --ingroup my-app-group
USER my-app-user
WORKDIR /home/my-app-user/app/src/
COPY --chown=my-app-user ./App.csproj .
RUN dotnet restore "App.csproj"
COPY --chown=my-app-user . .
RUN dotnet publish "App.csproj" --no-restore -c Release -o /home/my-app-user/app/publish
WORKDIR /home/my-app-user/app/publish
CMD ["dotnet", "App.dll"]Si te fijas, hemos dividido el COPY en dos partes, una que copia solo el .csproj para instalar dependencias y otra que copia todos los archivos. Con ello conseguimos que “dotnet restore” esté siempre cacheado (excepto si añaden un nuevo paquete o dependencia al proyecto) y, con ello, reducimos el tiempo de creación de la imagen en la mayor parte de los casos.
Observa también que, solo si utilizas el framework .NET, es importante utilizar –no-restore en el publish porque sino esta capa hará restore y no nos interesa, ya está hecho:
=> [1/8] FROM mcr.microsoft.com/dotnet/sdk:8.0-alpine@sha256:658c93223111638f9bb5474 0.0s
=> CACHED [2/8] RUN addgroup my-app-group && adduser my-app-user --disabled-password 0.0s
=> CACHED [3/8] WORKDIR /home/my-app-user/app/src/ 0.0s
=> CACHED [4/8] COPY --chown=my-app-user ./App.csproj . 0.0s
=> CACHED [5/8] RUN dotnet restore "App.csproj" 0.0s
=> [6/8] COPY --chown=my-app-user . . 0.1s
=> [7/8] RUN dotnet publish "App.csproj" --no-restore -c Release -o /home/my-app-use 5.1s
=> [8/8] WORKDIR /home/my-app-user/app/publish¿Te has dado cuenta de la importancia y potencia que tiene la caché de capas en Docker?. Simplemente con cambiar el orden de las líneas o pensar cinco minutos en cómo rehacer el fichero podemos conseguir optimizar la velocidad de creación de imágenes.
¿Qué es multi-stage en Docker? Ejemplo práctico
El primer paso para crear un fichero multi-stage es comprender qué es un fichero Dockerfile multi-stage, podemos verlo de dos formas:
- Son varios ficheros Dockerfile en uno solo. Pero… ¿Por qué necesitaríamos varios ficheros en uno?. Pues, por ejemplo, porque necesitamos utilizar más de una imagen base.
El ejemplo más claro lo tenemos en .NET, por una parte necesitamos utilizar elsdk (mcr.microsoft.com/dotnet/sdk:8.0-alpine)para hacer build y publish y, por otra, necesitamos utilizar elruntime (mcr.microsoft.com/dotnet/aspnet:8.0-alpine)para ejecutar nuestra aplicación. - Es cuando necesitamos utilizar varias imágenes base pero, al final, para lanzar la aplicación, solo nos quedamos con una de ellas.
Volvamos al ejemplo de .NET, necesitamos el sdk para el build y publish pero, al final, para lanzar la aplicación, solo necesitamos el runtime.
Esto significa que nuestra imagen final solo tendrá realmente instalado el runtime y no el sdk como hasta ahora y, dado que el runtime ocupa menos espacio, nuestra imagen final ocupará menos y por lo tanto la tendremos optimizada.
Debes tener en cuenta los siguientes puntos importantes sobre un Dockerfile multi-stage:
- Con multi-stage todo lo que no es necesario de etapas anteriores no estará presente en la imagen final.
Por ejemplo, si nuestra etapa final es el runtime y entre medias utilizamos el sdk, éste no estará instalado en nuestra imagen final ni en nuestro contenedor. - Cada etapa o stage es un paso completamente independiente, esto significa que tendremos que repetir líneas como la creación de usuario o tendremos que copiar carpetas entre etapas.
Una vez visto lo anterior podemos preguntarnos… ¿Qué ventajas aporta un Dockerfile multistage?:
- Menor tamaño de imagen si sabemos optimizarlo bien.
- Tiempo de ejecución de contenedor menor porque al tener menos cosas instaladas o una imagen menor se iniciará antes.
- Podemos seguir utilizando la caché de capas de imagen para reducir el tiempo de creación.
Vamos a partir del ejemplo siguiente, el que venimos utilizando hasta ahora:
FROM mcr.microsoft.com/dotnet/sdk:8.0-alpine
RUN addgroup my-app-group && adduser my-app-user --disabled-password --gecos "" --ingroup my-app-group
USER my-app-user
WORKDIR /home/my-app-user/app/src/
COPY --chown=my-app-user ./App.csproj .
RUN dotnet restore "App.csproj"
COPY --chown=my-app-user . .
RUN dotnet publish "App.csproj" --no-restore -c Release -o /home/my-app-user/app/publish
WORKDIR /home/my-app-user/app/publish
CMD ["dotnet", "App.dll"]Y vamos a apuntar cuánto ocupa la imagen y el tiempo de creación:
- Tiempo de creación sin caché, aproximadamente 11 segundos.
- Tiempo de creación con caché, aproximadamente 0.2 segundos.
- Tamaño de imagen 717 MB.
- Qué tenemos en nuestro contenedor, salida comprobación sdk y runtime:
~/app/publish $ dotnet --list-sdks 8.0.401 [/usr/share/dotnet/sdk] ~/app/publish $ dotnet --list-runtimes Microsoft.AspNetCore.App 8.0.8 [/usr/share/dotnet/shared/Microsoft.AspNetCore.App] Microsoft.NETCore.App 8.0.8 [/usr/share/dotnet/shared/Microsoft.NETCore.App]
Ahora vamos a modificar el Dockerfile para convertirlo en multi-stage de tal manera que:
- Con el sdk realizamos las tareas de restauración y publicación (build y publish).
- Con el runtime realizamos la tarea de ejecutar o iniciar nuestra aplicación (dotnet App.dll).
El código final de nuestro ejemplo Dockerfile multi-stage es el siguiente:
FROM mcr.microsoft.com/dotnet/sdk:8.0-alpine AS sdk
RUN addgroup my-app-group && adduser my-app-user --disabled-password --gecos "" --ingroup my-app-group
USER my-app-user
WORKDIR /home/my-app-user/app/src/
COPY --chown=my-app-user ./App.csproj .
RUN dotnet restore "App.csproj"
COPY --chown=my-app-user . .
RUN dotnet publish "App.csproj" --no-restore -c Release -o /home/my-app-user/app/publish
FROM mcr.microsoft.com/dotnet/aspnet:8.0-alpine AS runtime
RUN addgroup my-app-group && adduser my-app-user --disabled-password --gecos "" --ingroup my-app-group
USER my-app-user
COPY --chown=my-app-user --from=sdk /home/my-app-user/app/publish /home/my-app-user/app/publish
WORKDIR /home/my-app-user/app/publish
CMD ["dotnet","App.dll"]Algunos comentarios acerca del código:
- Podemos utilizar “AS” para nombrar una etapa y después referenciarla en otra.
Por ejemplo la primera etapa la hemos llamado “sdk” y la utilizamos en la etapa “runtime” con el parámetro--from=sdkdentro del último COPY. - Tanto la restauración como la publicación se hacen con el sdk.
- La ejecución se hace con el runtime, para lo cual es necesario copiar la carpeta “publish” entre las etapas ya que es la que contiene la aplicación compilada lista para ser iniciada.
- Hay que crear dos veces el usuario porque cada etapa es independiente de la otra, de hecho, en la primera etapa podríamos ahorrarnos el tema de seguridad y usuarios en Docker.
Y vamos comparar las métricas que obtuvimos antes:
- Tiempo de creación sin caché, aproximadamente 13 segundos. Tarda más porque ahora está descargando dos imágenes en vez de una.
- Tiempo de creación con caché, aproximadamente 0.2 segundos. De nuevo, aunque las capas están cacheadas puede tardar más porque son más pasos a realizar.
- Tamaño de imagen 117 MB, en comparación con el anterior que ocupaba 717 MB hemos ahorrado 600 MB.
- Qué tenemos instalado en el contenedor, salida de comprobación sdk y runtime, como puedes observar ahora no tenemos sdk y si tenemos los runtime:
~/app/publish $ dotnet --list-sdks ~/app/publish $ dotnet --list-runtimes Microsoft.AspNetCore.App 8.0.8 [/usr/share/dotnet/shared/Microsoft.AspNetCore.App] Microsoft.NETCore.App 8.0.8 [/usr/share/dotnet/shared/Microsoft.NETCore.App]












