Saltar al contenido
sudoquest@root
Docker

El Dockerfile

Construir tus propias imágenes: el Dockerfile y docker build, las capas y la caché, el contexto y .dockerignore, ARG y ENV, y una base que no pese de más.

30 min de lectura

Hasta ahora las imágenes venían hechas (nginx, postgres, alpine). Las de tu propio programa las construyes tú, con un Dockerfile: un archivo de texto con los pasos para montar la imagen, uno por línea. docker build lo lee y hace la imagen, capa a capa.

Los ejercicios pasan en el servidor de pruebas de un estudio de videojuegos indie: el servidor de las partidas está escrito en Python y vive en ~/juego.

Un Dockerfile por dentro

tecnico@srv-pruebas:~/juego$ cat Dockerfile
FROM python:3.12-slim
WORKDIR /app
COPY requisitos.txt .
RUN pip install -r requisitos.txt
COPY . .
CMD ["python", "servidor.py"]
Instrucción Lo que hace
FROM la imagen de la que se parte (aquí, Python 3.12 en su versión ligera)
WORKDIR la carpeta de trabajo dentro de la imagen (se crea si no existe)
COPY origen destino copia archivos de tu carpeta a la imagen
RUN orden ejecuta una orden al construir (instalar dependencias, por ejemplo)
CMD [...] lo que arranca el contenedor al ejecutarlo

docker build

-t le pone nombre y versión a la imagen; el punto del final es la carpeta con lo que se manda a construir (el contexto), que es donde está el Dockerfile:

tecnico@srv-pruebas:~/juego$ docker build -t estudio/partidas:1.0 .
[+] Building 3.5s (10/10) FINISHED                               docker:default
=> [internal] load build definition from Dockerfile                       0.0s
=> => transferring dockerfile: 169B                                       0.0s
=> [internal] load metadata for docker.io/library/python:3.12-slim        0.0s
=> [internal] load .dockerignore                                          0.0s
=> => transferring context: 2B                                            0.0s
=> [1/5] FROM docker.io/library/python:3.12-slim                          0.0s
=> [internal] load build context                                          0.0s
=> => transferring context: 434B                                          0.0s
=> [2/5] WORKDIR /app                                                     0.0s
=> [3/5] COPY requisitos.txt .                                            0.0s
=> [4/5] RUN pip install -r requisitos.txt                                3.0s
=> [5/5] COPY . .                                                         0.0s
=> exporting to image                                                     0.5s
=> => exporting layers                                                    0.5s
=> => writing image sha256:81c4c772e7176a1e6fd892e8a6d13b82bcadb9e9d6da5  0.0s
=> => naming to docker.io/estudio/partidas:1.0                            0.0s

Cada [n/5] es un paso del Dockerfile. Con la imagen hecha, se arranca como cualquier otra:

tecnico@srv-pruebas:~/juego$ docker run --rm estudio/partidas:1.0
Servidor de partidas 1.0
Esperando jugadores en el puerto 7777

Ejercicio

La primera imagen

El servidor de partidas del estudio está en ~/juego, con su Dockerfile. Construye con él la imagen estudio/partidas:1.0 y arráncala (con --rm) para ver que dice lo suyo.

Preparando la máquina…

Escribir uno

Un Dockerfile no necesita más que lo que el programa necesita. La web del juego son páginas sueltas: basta con nginx y copiar la página donde nginx las busca.

tecnico@srv-pruebas:~/web$ printf 'FROM nginx:1.27-alpine\nCOPY index.html /usr/share/nginx/html/\n' > Dockerfile
tecnico@srv-pruebas:~/web$ docker build -t estudio/web:1.0 .

Si el destino de COPY acaba en / (o es .), es una carpeta y el archivo entra con su nombre; si no, es el nombre que tendrá dentro.

Ejercicio

Tu propio Dockerfile

La web del juego está en ~/web (solo index.html). Escribe ~/web/Dockerfile para servirla con nginx:1.27-alpine (copiando index.html donde nginx tiene sus páginas, /usr/share/nginx/html/) y construye estudio/web:1.0.

Preparando la máquina…

CMD: lo que arranca

CMD va en forma exec: una lista JSON con el programa y sus argumentos. Si el programa no existe, la imagen se construye igual (Docker no lo comprueba), pero el contenedor no arranca:

tecnico@srv-pruebas:~/juego$ docker run --rm estudio/partidas:errata
docker: Error response from daemon: failed to create task for container: failed to create shim task: OCI runtime create failed: runc create failed: unable to start container process: error during container init: exec: "pyhton": executable file not found in $PATH

Run 'docker run --help' for more information

Escrito sin corchetes (CMD python servidor.py, la forma shell) también funciona, pero el programa arranca dentro de un /bin/sh -c y no le llegan bien las señales (docker stop acaba matándolo a los 10 segundos). BuildKit avisa:

tecnico@srv-pruebas:~/juego$ docker build -f Dockerfile.shell -t estudio/partidas:shell .
…
1 warning found (use docker --debug to expand):
- JSONArgsRecommended: JSON arguments recommended for CMD to prevent unintended behavior related to OS signals (line 6)

Ejercicio

La imagen no arranca

La versión 1.1 del servidor (estudio/partidas:1.1, construida con el Dockerfile de ~/juego) no arranca. Mira qué dice, arregla el Dockerfile y vuelve a construir la 1.1.

Preparando la máquina…

Las capas y la caché

Cada RUN, COPY y WORKDIR deja una capa. Docker apunta en una caché qué capa salió de cada paso; si al reconstruir el paso y lo de antes no han cambiado, la reutiliza (CACHED) en lugar de repetirlo. Tras cambiar solo servidor.py:

tecnico@srv-pruebas:~/juego$ docker build -t estudio/partidas:1.1 .
[+] Building 0.5s (10/10) FINISHED                               docker:default
…
=> CACHED [2/5] WORKDIR /app                                              0.0s
=> CACHED [3/5] COPY requisitos.txt .                                     0.0s
=> CACHED [4/5] RUN pip install -r requisitos.txt                         0.0s
=> [5/5] COPY . .                                                         0.0s
…

Pero en cuanto un paso cambia, todos los de después se repiten. Con COPY . . antes de instalar, cualquier cambio en el código vuelve a instalar las dependencias:

tecnico@srv-pruebas:~/juego$ docker build -f Dockerfile.malo -t estudio/partidas:malo .
[+] Building 3.5s (9/9) FINISHED                                 docker:default
…
=> CACHED [2/4] WORKDIR /app                                              0.0s
=> [3/4] COPY . .                                                         0.0s
=> [4/4] RUN pip install -r requisitos.txt                                3.0s
…

La regla: primero lo que cambia poco (las dependencias), después lo que cambia mucho (el código). docker history enseña las capas de una imagen, con lo que ocupa cada una:

tecnico@srv-pruebas:~/juego$ docker history estudio/partidas:1.1
IMAGE          CREATED          CREATED BY                                      SIZE      COMMENT
281e6f6dcd37   8 seconds ago    CMD ["python" "servidor.py"]                    0B        buildkit.dockerfile.v0
<missing>      8 seconds ago    COPY . . # buildkit                             283B      buildkit.dockerfile.v0
<missing>      43 seconds ago   RUN /bin/sh -c pip install -r requisitos.txt…   512kB     buildkit.dockerfile.v0
<missing>      43 seconds ago   COPY requisitos.txt . # buildkit                17B       buildkit.dockerfile.v0
<missing>      43 seconds ago   WORKDIR /app                                    0B        buildkit.dockerfile.v0
<missing>      3 weeks ago      CMD ["python3"]                                 0B        buildkit.dockerfile.v0
…

docker build --no-cache construye sin mirar la caché (para cuando hace falta, por ejemplo, bajar otra vez lo que instala un RUN).

Ejercicio

Que la caché ayude

Con el Dockerfile de ~/juego, cualquier cambio en servidor.py vuelve a instalar las dependencias (y tarda). Cámbialo para que solo se instalen cuando cambie requisitos.txt y construye estudio/partidas:1.2; luego cambia servidor.py y vuelve a construirla: la instalación tiene que salir de la caché.

Preparando la máquina…

El contexto y .dockerignore

El punto de docker build . manda toda la carpeta a construir, y COPY . . la copia entera a la imagen. También lo que no debería ir, como el .env con las contraseñas:

tecnico@srv-pruebas:~/juego$ docker run --rm estudio/partidas:1.1 ls -a /app
.
..
.env
Dockerfile
requisitos.txt
servidor.py

Un archivo .dockerignore junto al Dockerfile dice lo que no se manda, con patrones como los de .gitignore (*.log, secretos/, **/*.tmp; con ! delante, lo que sí):

tecnico@srv-pruebas:~/juego$ echo .env > .dockerignore
tecnico@srv-pruebas:~/juego$ docker run --rm estudio/partidas:1.2 ls -a /app
.
..
.dockerignore
Dockerfile
requisitos.txt
servidor.py

Ejercicio

Se coló el .env

La imagen estudio/partidas:1.3 lleva dentro el .env de ~/juego, con la clave de la base de datos. Haz que no entre en la imagen (sin borrarlo de la carpeta: el equipo lo usa) y vuelve a construir la 1.3.

Preparando la máquina…

ARG y ENV

ENV deja una variable de entorno en la imagen (la ve el programa al arrancar). ARG es una variable de la construcción, con su valor por defecto, que se cambia con --build-arg. Juntas, la versión se elige al construir:

tecnico@srv-pruebas:~/juego$ cat Dockerfile.arg
FROM python:3.12-slim
ARG VERSION=1.0
ENV VERSION=$VERSION
WORKDIR /app
COPY servidor.py .
CMD ["python", "servidor.py"]
tecnico@srv-pruebas:~/juego$ docker build -f Dockerfile.arg --build-arg VERSION=2.0 -t estudio/partidas:2.0 .
tecnico@srv-pruebas:~/juego$ docker run --rm estudio/partidas:2.0
Servidor de partidas 2.0
Esperando jugadores en el puerto 7777

Sin --build-arg, vale lo de ARG (1.0). Y al arrancar, docker run -e VERSION=3.0 cambia la del ENV.

Ejercicio

La versión, al construir

En el Dockerfile de ~/juego la versión está escrita a fuego (ENV VERSION=1.0). Haz que se elija al construir con un ARG (que siga siendo 1.0 si no se dice otra) y construye estudio/partidas:2.0 con la versión 2.0.

Preparando la máquina…

Una base que no pese de más

El FROM decide casi todo lo que ocupa la imagen. python:3.12 trae compiladores y bibliotecas para construir cosas; python:3.12-slim, lo justo para ejecutar:

tecnico@srv-pruebas:~/juego$ docker images estudio/partidas
REPOSITORY         TAG       IMAGE ID       CREATED              SIZE
estudio/partidas   entera    34dbff8e4508   11 seconds ago       1.11GB
estudio/partidas   2.0       3f980dfc8281   35 seconds ago       128MB
estudio/partidas   1.2       f18339447f42   About a minute ago   128MB

El mismo programa, ocho veces más grande: más que bajar en cada servidor y más cosas dentro que pueden tener fallos de seguridad.

Cuando falla

Docker dice la línea del Dockerfile y la enseña con las de alrededor:

tecnico@srv-pruebas:~/juego$ docker build -f Dockerfile.mal .
…
Dockerfile.mal:2
--------------------
 1 |     FROM python:3.12-slim
 2 | >>> COPPY . .
 3 |     
--------------------
ERROR: failed to build: failed to solve: dockerfile parse error on line 2: unknown instruction: COPPY (did you mean COPY?)

Si falla un RUN, sale lo que escribió y su código (did not complete successfully: exit code: 1); si un COPY no encuentra su archivo, "/archivo": not found.

Y si la imagen se construye pero el contenedor nuevo no arranca (un CMD mal escrito, por ejemplo), la imagen de antes sigue ahí, con su versión (docker images): lo rápido es crear otra vez el contenedor con ella, para que el servicio vuelva, y arreglar el Dockerfile con calma.

Resumen

Orden o instrucción Para qué
docker build -t nombre:versión . construir con el Dockerfile de la carpeta
docker build -f otro.Dockerfile … con otro Dockerfile
docker build --build-arg X=valor … dar valor a un ARG
docker build --no-cache … sin la caché
docker history imagen sus capas y lo que ocupa cada una
FROM · WORKDIR · COPY · RUN la base, la carpeta, copiar y ejecutar al construir
CMD ["prog", "arg"] · ENTRYPOINT lo que arranca (en forma exec)
ENV · ARG variables de la imagen y de la construcción
.dockerignore lo que no se manda a construir