¡Esta es una revisión vieja del documento!
Tabla de Contenidos
Creación de imágenes
Notas pertenecientes al curso Docker a fondo e Introducción a Kubernetes: aplicaciones basadas en contenedores
Hasta ahora has aprendido qué es Docker y cómo gestionar tus imágenes y contenedores usando la CLI. Es importante que te sientas cómodo con los comandos de Docker que hemos visto ya que los usaremos constantemente a lo largo de los módulos restantes.
En este módulo vamos a ver cómo crear nuestras propias imágenes de Docker, algunos aspectos importantes a tener en cuenta al hacerlo y cómo trabajar con un repositorio de imágenes. En concreto hablaremos de:
- Las características de una imagen de Docker
- El fichero
Dockerfiley sus comandos más importantes - Trabajar con volúmenes
- Qué son las etiquetas (tags) de las imágenes y cómo funcionan
- Los comandos
pullypushpara trabajar con repositorios
Creación de imágenes: el fichero Dockerfile
Para crear imágenes Docker se necesita básicamente un solo fichero: el Dockerfile.
Grosso modo podemos decir que el archivo Dockerfile contiene el conjunto de instrucciones que Docker sigue para crear una imagen. Es un fichero de texto donde cada línea del Dockerfile es una instrucción que Docker ejecuta para crear la imagen. Su nombre suele ser simplemente Dockerfile, sin extensión.
¿Qué contiene una imagen?
Una imagen de Docker contiene básicamente cuatro elementos:
- Un conjunto de archivos. Realmente, un “sistema de archivos” (filesystem) completo: tendremos archivos y directorios con sus correspondientes permisos de lectura y escritura.
- Un conjunto de puertos expuestos. La imagen declara que los contenedores abren unos determinados puertos al exterior. Recuerda que son “sus” puertos, no los puertos del host.
- Una configuración en forma de variables de entorno que van a estar en todos los contenedores creados a partir de dicha imagen.
- Un comando inicial que es el que ejecutará por defecto todos los contenedores creados a partir de esa imagen.
Todos los comandos de Dockerfile van orientados a especificar esos cuatro conceptos.
Imágenes base
Lo primero a tener presente cuando creamos una imagen es que muchas veces (la gran mayoría de veces) no queremos partir de cero. Imagina que tienes un programa desarrollado en Node.js y quieres empaquetarlo en una imagen Docker. Para que tu imagen sea funcional no te basta con tener tu código y los correspondientes paquetes npm instalados. También necesitas:
- El runtime de Node.js
- Cualquier dependencia del runtime de Node.js
- Cualquier dependencia de cualquier dependencia del runtime de Node.js…
- … Y así sucesivamente
No tiene sentido que tengas que configurar todo eso en cada imagen que crees; además, de que eso sería muy complejo. Es por ello que existe la idea de “imagen base”: tu imagen hereda de una imagen base, lo que significa que, partiendo de lo que hay en esta, la modificas para añadirle nuevos elementos.
Así, si tienes un programa escrito en Node.js y lo quieres empaquetar en Docker, lo suyo es partir de una imagen que tenga el runtime de Node.js: así ya sabes que Node.js está en la imagen, junto a todas sus dependencias.
El comando del Dockerfile que nos permite definir una imagen base es el comando FROM. Así, nuestro Dockerfile podría empezar de la siguiente manera:
FROM node
La sintaxis de FROM es muy sencilla: FROM nombre_imagen_base. En este caso la imagen base es la llamada node. Pero… ¿de dónde sacamos estas imágenes base?
Bueno, lo habitual es usar el “repositorio oficial de imágenes, DockerHub”. Este repositorio contiene muchas imágenes, verificadas por la propia Docker, que nos sirven de base para gran cantidad de casos. Puedes buscar una imagen y determinar si se trata de una versión oficial, verificada o de la comunidad, con las franjas que tienen a la derecha:
Como se puede apreciar en la imagen, las 3 imágenes que se ven son oficiales, lo cual quiere decir que han sido publicadas por Docker y se responsabilizan de ellas.
Desde el hub también puedes filtrar las imágenes por categoría. Puedes marcar las opciones del lateral para que tan solo te aparezcan imágenes oficiales o de publicadores verificados (en los que confía Docker), para mayor seguridad.
Por lo general preferirás imágenes que, o bien son oficiales o de terceras partes verificadas por Docker, así que las puedes considerar oficiales. Por otro lado, si encuentras una imagen “de la comunidad”, o sea, no verificada por Docker, no significa que no puedas usarla, pero no están verificadas por Docker y debes tener cuidado y ver cuán confiable es, porque podría ser cualquier cosa
Construyendo una imagen
La salida por pantalla de estos comandos depende de si tu instalación de Docker utiliza “BuildKit” (el sistema moderno de construcción de imágenes) o, por el contrario, utiliza el antiguo. Las versiones recientes de Docker tienen BuildKit habilitado por defecto, pero es posible deshabilitarlo, editando el fichero ~/.docker/daemon.json (%USERPROFILE%\.docker\daemon.json en Windows) y estableciendo la opción buildKit a false dentro de la sección features. Luego debes reiniciar Docker. Aunque lo normal es que estés usando BuildKit, quiero explicar ambos casos porque el comportamiento antiguo revela un concepto importante del funcionamiento de Docker, y creo que será positivo para el aprendizaje.
Una vez tienes un archivo Dockerfile puedes construir una imagen usando el comando build. Haz una prueba. Guarda el Dockerfile que tenemos (hasta el momento una sola línea). Ahora, desde línea de comandos, navega hacia donde tienes dicho Dockerfile y teclea:
docker build . -t myfirstimage
Con ese comando le indicamos a Docker que cree una imagen llamada myfirstimage a partir del contenido del Dockerfile y usando el directorio actual como “contexto de build” (hablaremos de dicho contexto más adelante).
Verás una serie de cadenas de hash y mensajes tales como “Transferring” y “Exporting”… Como parte de construcción de la imagen, Docker se descarga la imagen base y, a su vez, las imágenes base de esta. Al final, tendrás una salida parecida a la siguiente (algunos mensajes pueden cambiar, no le des importancia):
La captura de pantalla anterior muestra la salida de docker build cuando se usa una versión de Docker que tenga habilitado BuildKit (recuerda que cualquier versión reciente lo tiene ya activado). Si usas una versión más antigua de Docker, o bien tienes BuildKit deshabilitado, la salida será parecida a la siguiente:
El aviso de seguridad (SECURITY WARNING) que aparece al final se da solo si construyes imágenes Linux usando un ordenador Windows y lo puedes obviar.
Bien, una vez construida la imagen, si ahora obtienes un listado de las imágenes que tienes en tu sistema (recuerda: docker images) obtendrás una salida distinta en función de si usas BuildKit o no.
Si usas BuildKit (que es lo habitual) la salida será parecida a:
REPOSITORY TAG IMAGE ID CREATED SIZE myfirstimage latest 7e6991800b08 16 hours ago 936MB
Por supuesto, puedes tener más imágenes además de las mostradas en estos listados. Pero te muestro las resultantes del build anterior.
Pero, si no usas BuildKit (porque estás en una versión antigua de Docker o porque, por cualquier motivo, lo has deshabilitado), entonces la salida será parecida a la siguiente:
REPOSITORY TAG IMAGE ID CREATED SIZE myfirstimage latest 6e72986b1b6e 16 hours ago 936MB node latest 6e72986b1b6e 16 hours ago 936MB
En el primer caso vemos que solo hay una imagen, myfirstimage. Pero en el segundo observamos que está también la imagen base (node) y, además, con el mismo identificador
Vamos a ver qué significa esto…
Imágenes "por capas" (layered images)
En el último listado anterior, el que no está hecho con BuildKit, verás que el id de ambas imágenes es el mismo, tanto para la imagen node como para la imagen myfirstimage. Recuerda que el id es el identificador que usamos para, entre otras cosas, borrar una imagen. El motivo es que, para Docker, la imagen de node y la imagen myfirstimage son la misma imagen.
Dado que BuildKit, por simplicidad, nos oculta ciertos aspectos que considero importante entender, esta parte de la lección va a mostrar la salida que se genera con BuildKit desactivado. Una vez tengamos claros los conceptos que deseo introducir, mostraremos qué cambios introduce BuildKit al respecto.
¿Cómo es esto posible si nosotros hemos creado una imagen nueva a partir de un Dockerfile?
Bien, nuestro Dockerfile tan solo tenía una instrucción FROM que indicaba que su imagen base era node. Dado que no hacemos nada más con la imagen, en la práctica, nuestra imagen es la misma que node y por eso, el resultado es que tenemos dos imágenes con el mismo id… o dicho más correctamente: una misma imagen con dos nombres distintos.
Es absolutamente posible y normal tener una misma imagen con nombres distintos (más adelante veremos que, de hecho, eso es a veces imprescindible).
Añadamos ahora algo nuevo a nuestro Dockerfile y observemos qué es lo que ocurre.
Vamos a usar la instrucción ENV en el Dockerfile. Dicha instrucción sirve para definir variables de entorno. La sintaxis de ENV es muy sencilla: ENV variable=valor.
También es válido utilizar ENV variable valor, sin el igual. Esto es así por compatibilidad con versiones antiguas de Docker, y te lo cuento porque es muy probable que lo veas utilizado por ahí, pero es mejor evitarlo.
Definamos, pues, una variable de entorno en nuestro archivo Dockerfile y a ver qué ocurre. Para ello añade una nueva línea para que quede así:
FROM node ENV foo=bar
Lo que hemos hecho es declarar una variable de entorno llamada foo que tendrá el valor de bar. Eso ocurrirá para todos los contenedores que se creen a partir de dicha imagen.
Una vez tengas el nuevo Dockerfile, crea de nuevo la imagen, con el mismo nombre, usando la instrucción docker build . -t myfirstimage. En este caso la salida que verás puede ser algo parecido a lo siguiente:
Y si ahora miras las imágenes que tienes, verás que ahora los ids son distintos:
REPOSITORY TAG IMAGE ID CREATED SIZE myfirstimage latest d60fdb5b4dc4 About a minute ago 936MB node latest 6e72986b1b6e 16 hours ago 936MB
¿Cuánto ocupa realmente en disco una imagen?
Esta pregunta no es baladí y su respuesta nos llevará a entender mejor cómo funcionan las imágenes en Docker.
Observa que en el listado anterior ambas imágenes tienen el mismo tamaño (936MB). Es algo lógico, ya que la imagen myfirstimage parte de la imagen node, así que ocupará como mínimo lo mismo.
Ahora bien, ¿eso significa que esas dos imágenes ocupan 1872MB (936×2) en disco? La realidad es que no. En disco tenemos ocupados aproximadamente… 936MB.
La razón es que Docker usa un sistema de “capas” para montar una imagen. Tenemos que entender que cada instrucción del Dockerfile añade una capa adicional sobre la imagen base, y que la imagen final es la “superposición” de todas esas capas. Docker intenta reaprovechar las capas siempre que puede.
¡Este es un concepto muy importante!
La instrucción FROM define la imagen base o la primera capa. En nuestro caso, dicha capa es la imagen node (id 6e72986b1b6e) y tiene un tamaño de 936MB. La segunda capa es la creada por la instrucción ENV y esta capa se monta encima de la primera. El tamaño de esta capa es de 0 bytes (lo único que hace es definir una variable de entorno).
Por lo tanto, en lo que respecta a Docker lo que tenemos es:
- Una imagen
6e72986b1b6e(node) con un tamaño de 936MB - Una imagen
d60fdb5b4dc4(myfirstimage) que consta de dos capas:- Capa 1: la imagen
6e72986b1b6e - Capa 2: el resultado de
ENV
Cuando Docker crea contenedores a partir de la imagen d60fdb5b4dc4 monta las nuevas capas una encima de otra, así que, no se necesita que la capa 6e72986b1b6e esté dos veces físicamente en disco. Docker es muy eficiente en cuanto al uso de tamaño de disco.
De hecho, cuando hemos dicho que la imagen d60fdb5b4dc4 constaba de dos capas, eso no es del todo cierto. Podemos ver todas las capas que tiene una imagen a partir del comando docker history.
Así, si tecleamos docker history myfirstimage la salida será parecida a la siguiente:
IMAGE CREATED CREATED BY SIZE COMMENT d60fdb5b4dc4 3 minutes ago /bin/sh -c #(nop) ENV foo=bar 0B 6e72986b1b6e 16 hours ago /bin/sh -c #(nop) CMD ["node"] 0B <missing> 16 hours ago /bin/sh -c #(nop) ENTRYPOINT ["docker-entry… 0B <missing> 16 hours ago /bin/sh -c #(nop) COPY file:238737301d473041… 116B <missing> 16 hours ago /bin/sh -c set -ex && for key in 6A010… 7.76MB <missing> 16 hours ago /bin/sh -c #(nop) ENV YARN_VERSION=1.22.5 0B <missing> 16 hours ago /bin/sh -c ARCH= && dpkgArch="$(dpkg --print… 93MB <missing> 16 hours ago /bin/sh -c #(nop) ENV NODE_VERSION=15.14.0 0B <missing> 8 days ago /bin/sh -c groupadd --gid 1000 node && use… 333kB <missing> 8 days ago /bin/sh -c set -ex; apt-get update; apt-ge… 561MB <missing> 8 days ago /bin/sh -c apt-get update && apt-get install… 141MB <missing> 8 days ago /bin/sh -c set -ex; if ! command -v gpg > /… 7.82MB <missing> 8 days ago /bin/sh -c set -eux; apt-get update; apt-g… 24.1MB <missing> 8 days ago /bin/sh -c #(nop) CMD ["bash"] 0B <missing> 8 days ago /bin/sh -c #(nop) ADD file:e52290391b221e1a4… 101MB
La columna IMAGE nos muestra cada una de las capas de la imagen y su tamaño asociado.
Observa que la columna que nos muestra las capas se llama realmente IMAGE. Eso es porque históricamente en Docker una imagen y una capa eran conceptos sinónimos. Pero, a partir de Docker 1.10 se introdujo un cambio fundamental y actualmente imágenes y capas no son exactamente lo mismo. Así que, a partir de Docker 1.10 dicha columna solo muestra el id de las imágenes (no de las capas) y dicho id se muestra asociado a la última capa de cada imagen que tengamos. El resto aparece como <missing>.
Así, es importante que entendamos que la fila superior, cuyo id en nuestro ejemplo es d60fdb5b4dc4, nos indica que tenemos una imagen cuya última capa es la indicada en esta fila. Pero este id no es el id de la capa. Es el de la imagen.
Exportando imágenes en disco
Es posible exportar una imagen de Docker en un fichero (en formato tar) y al abrirlo nos quedará todavía más claro el concepto de capas. Para exportar una imagen de Docker a un fichero el comando a usar es docker save:
docker save myfirstimage -o myfirstimage.tar
Este comando exporta la imagen myfirstimage al fichero myfirstimage.tar, ahora podemos abrir ese fichero con cualquier editor que soporte el formato tar y ver sus contenidos. En mi caso yo he usado esa pequeña maravilla llamada 7-Zip:
Esta imagen nos muestra el contenido del fichero tar, que es el resultado de exportar la imagen myfirstimage. Lo interesante es que tienes un montón de carpetas con varios identificadores: esas carpetas contienen las capas reales de la imagen.
Vamos a ver qué tenemos si exportamos la imagen base de node. Para ello ejecuta el comando:
docker save node -o node.tar
Y al igual que antes lo abrimos con 7-Zip para ver su contenido:
Observa ¡como casi todas las carpetas son idénticas! Esto tiene sentido porque el contenido del sistema de ficheros de ambas imágenes es el mismo (en nuestro Dockerfile solo hemos declarado una variable de entorno). Se ha resaltado en ambas capturas la única carpeta que es distinta. Esta carpeta resaltada se corresponde a la última capa de la imagen y contiene, entre otras cosas, las variables de entorno y otros metadatos, de ahí que sea diferente.
Luego existen otras diferencias en los ficheros .json de la raíz del fichero tar, que contienen metadatos adicionales de la imagen (observa como el nombre y el tamaño de dicho fichero son distintos en ambos casos).
Pero, volviendo al espacio real ocupado en disco, lo que Docker guarda son esas carpetas con las distintas capas de ficheros, por lo que si una misma capa se utiliza en varias imágenes, en realidad se encuentra físicamente una sola vez en disco.
Imágenes por capas con BuildKit y dangling images
Bien, lo que hemos visto en la lección anterior era con BuildKit deshabilitado. Si usas BuildKit los conceptos son los mismos, pero como BuildKit nos oculta cierta información, se hace más complejo de explicar. Pero, ahora que ya sabes lo que hay por debajo, vamos a ver cómo es la salida en cada caso usando BuildKit.
Recuerda que, a diferencia del caso anterior, con BuildKit la imagen base (node) no aparece en el listado de imágenes:
REPOSITORY TAG IMAGE ID CREATED SIZE myfirstimage latest 7e6991800b08 16 hours ago 936MB
Vamos a hacer el mismo cambio que en la lección anterior y añadimos la sentencia ENV al Dockerfile:
FROM node ENV foo=bar
Si ahora lanzamos el comando docker build para construir la imagen, la salida será parecida a la siguiente:
Y ¿qué imágenes tenemos ahora? Pues bien, el comando docker images nos da una salida como la siguiente:
REPOSITORY TAG IMAGE ID CREATED SIZE <none> <none> 7e6991800b08 16 hours ago 936MB myfirstimage latest 38bb7313054c 16 hours ago 936MB
La imagen base (node) sigue sin aparecer, pero ahora nos muestra otra imagen llamada <none>. ¿Qué está ocurriendo aquí?
Imágenes colgantes (//dangling images//)
Si el comando docker images te muestra una imagen <none>:<none>, generalmente eso significa que tenemos una imagen “colgante”, o como verás más a menudo, en inglés, una dangling image. Se trata de imágenes que ya no están referenciadas (utilizadas) por ninguna otra imagen, por lo que pueden (y deben) ser eliminadas, básicamente porque ocupan espacio en disco.
Estas imágenes colgantes aparecen habitualmente al construir nuevas versiones de una imagen propia. Por ejemplo, tienes un Dockerfile y construyes una versión (usando docker build). Posteriormente realizas cualquier modificación y vuelves a usar el mismo comando docker build sin cambiar el nombre o el tag. En este caso, la nueva imagen generada reemplazará a la antigua (pues tienen el mismo nombre y tag) y la antigua quedará como dangling image.
¡Ojo!: no todas las imágenes <none>:<none> son imágenes “colgantes”. Luego veremos otro caso en el que, aun estando marcadas así, no lo son. De hecho, lo son solo aquellas que te aparecen cuando usas docker images sin ningún otro parámetro. Pero, si quieres tener la seguridad, las puedes listar explícitamente con el filtro docker images -f dangling=true:
Es recomendable eliminar las imágenes colgantes cada cierto tiempo, ya que Docker no lo hará por ti.
Para eliminarlas lo más sencillo es usar bash o PowerShell (no cmd.exe) si estás en Windows, con el siguiente comando:
docker rmi $(docker images -f "dangling=true" -q)
O utilizar el comando docker image prune, que hace exactamente lo mismo.
El comando docker image prune -a borraría además todas las imágenes que tengamos que no tengan contenedores asociados. Consulta https://docs.docker.com/config/pruning/1la documentación de prune.
El motivo de que haya aparecido esa imagen colgante es que hemos modificado el Dockerfile y hemos reconstruido la imagen, pero hemos utilizado el mismo nombre y la misma etiqueta de la imagen previa. Sin embargo, la imagen previa sigue existiendo, ya que BuildKit no la elimina, solo que ahora no tiene nombre ni etiqueta: es una imagen colgante.
Este comportamiento es nuevo de BuildKit. El “antiguo” docker build borraba la imagen anterior. Personalmente me parece un buen comportamiento: permite volver a la imagen anterior en cualquier momento si así se desea. Claro que, lo ideal sería que Docker tuviese un sistema de recolección de imágenes colgantes cada cierto tiempo, pero por ahora no es así: debes ir borrándolas tú.
Eso nos deja claro una de las características fundamentales de las imágenes en Docker: son inmutables. Una vez creada una imagen no se puede modificar. Puedes crear otra que tenga el mismo nombre y etiqueta, pero será otra imagen, con otro ID.
Imágenes intermedias
Como hemos dicho antes, no todas las imágenes <none>:<none> son imágenes colgantes, algunas de ellas son imágenes intermedias. Esas no aparecen listadas con docker images y debe usarse docker images -a para verlas.
Dependiendo de la versión de Docker que uses, puedes tener más o menos de esas imágenes, pero no te preocupes por ellas: en ningún caso suponen un problema, ya que contienen capas intermedias que son usadas por otras imágenes, así que no debes eliminarlas.
Capas en BuildKit
A todo eso, estábamos en que habíamos construido la imagen myfirstimage con el Dockerfile que tenía la sentencia ENV, lo que nos ha generado la imagen con el id 38bb7313054c. Vamos a exportar, con docker save dicha imagen a un fichero tar para analizarla:
docker save myfirstimage -o myfirstimage_buildkit.tar
Si comparas esa captura del contenido del fichero myfirstimage_buildkit.tar con el del fichero myfirstimage.tar que generamos con la versión anterior, verás que las carpetas son idénticas. Esto tiene sentido: BuildKit sigue usando las capas y esas capas siguen siendo las mismas. Lo único que no hace BuildKit es guardar la imagen base (node), cosa que sí hacía el antiguo docker build. Pero, en lo que respecta al contenido en disco, pocos cambios hay: Docker guarda las capas del mismo modo.
Ese cambio se refleja en la salida del comando docker history que es ligeramente distinto:
IMAGE CREATED CREATED BY SIZE COMMENT 38bb7313054c 17 hours ago ENV foo=bar 0B buildkit.dockerfile.v0 <missing> 17 hours ago /bin/sh -c #(nop) CMD ["node"] 0B <missing> 17 hours ago /bin/sh -c #(nop) ENTRYPOINT ["docker-entry… 0B <missing> 17 hours ago /bin/sh -c #(nop) COPY file:238737301d473041… 116B <missing> 17 hours ago /bin/sh -c set -ex && for key in 6A010… 7.76MB <missing> 17 hours ago /bin/sh -c #(nop) ENV YARN_VERSION=1.22.5 0B <missing> 17 hours ago /bin/sh -c ARCH= && dpkgArch="$(dpkg --print… 93MB <missing> 17 hours ago /bin/sh -c #(nop) ENV NODE_VERSION=15.14.0 0B <missing> 8 days ago /bin/sh -c groupadd --gid 1000 node && use… 333kB <missing> 8 days ago /bin/sh -c set -ex; apt-get update; apt-ge… 561MB <missing> 8 days ago /bin/sh -c apt-get update && apt-get install… 141MB <missing> 8 days ago /bin/sh -c set -ex; if ! command -v gpg > /… 7.82MB <missing> 8 days ago /bin/sh -c set -eux; apt-get update; apt-g… 24.1MB <missing> 8 days ago /bin/sh -c #(nop) CMD ["bash"] 0B <missing> 8 days ago /bin/sh -c #(nop) ADD file:e52290391b221e1a4… 101MB
La segunda capa (primera <missing> del listado), en el caso anterior tenía un ID en IMAGE, ya que efectivamente esa es la capa superior de la imagen de node y antes teníamos esa imagen en el disco. Pero ahora no la tenemos, ya que BuildKit no la guarda, de ahí que ahora nos aparezca como <missing>.
Capas e imágenes
Como se ha comentado, a partir de Docker 1.10, imágenes y capas ya no son sinónimos. Las capas se identifican mediante un id que es realmente un hash del contenido de la capa calculado mediante el algoritmo SHA256. Usar el hash como identificador garantiza que una capa siempre tendrá el mismo id mientras su contenido no cambie. Al cambiar su contenido, el hash también se modificará.
Así, ahora en Docker una imagen es básicamente un objeto de configuración que, entre otros datos, contiene todos los ids de todas las capas que conforman dicha imagen. Y el id de una imagen no es nada más que el hash (de nuevo SHA256) de dicho objeto. Por lo tanto:
- Cada capa tiene un id.
- El id de una capa es un hash que depende del contenido de dicha capa.
- Una imagen se compone de varias capas.
- Para Docker una imagen es un objeto de configuración que contiene, entre otras cosas, todos los ids de las capas que conforman esta imagen.
- El id de la imagen es un hash calculado a partir de los datos de dicho objeto de configuración.
Por lo tanto, el id de una imagen depende (entre otras cosas) de los ids de sus capas.
Cómo obtener los ids de las capas
Es posible obtener los ids de todas las capas de una imagen. Para ello debemos usar el comando inspect. Dicho comando devuelve información sobre un objeto de Docker (puede ser una imagen, un contenedor u otro tipo de objeto). La salida es un JSON que puede ser relativamente grande. Por suerte podemos quedarnos solo con la parte que nos interesa especificándola con el parámetro --format:
PS> docker inspect --format @"
{{range .RootFS.Layers}}
{{.}}{{end}}
"@ myfirstimage
El parámetro --format en el fragmento anterior ocupa varias líneas por claridad. Si estás en Windows, usa PowerShell y cambia de línea usando Shift + Enter.
Este comando nos mostrará todos los ids de las distintas capas que conforman la imagen:
sha256:18f9b4e2e1bcd5abe381a557c44cba379884c88f6049564f58fd8c10ab5733df sha256:d70ce8b0dad684a9e2294b64afa06b8848db950c109cde60cb543bf16d5093c9 sha256:ecd70829ec3d4a56a3eca79cec39d5ab3e4d404bf057ea74cf82682bb965e119 sha256:7381522c58b0db7134590fdcbc3b648865325f638427f69a474adc22e6b918af sha256:bfa30f97c0f427f1cda8f3192cc25c7a4729ec28269ae8f5241c7366738e3ca5 sha256:1d13cfbbfeb2fae5b521d38447ba69ba61c36b9b07f37ded51be7856e127c9a7 sha256:dd258e4bb89d7385af12f3b0ec0fed35992ed467c5b2f5684a95a9cab4b1d06d sha256:9c9366531e84e3ac9db451bda6c240ea82e52c3bb71eb0d59e49d18a6089703f
Ahora bien, si comparas el número de capas que nos aparecen usando docker inspect con el número que nos aparece usando docker history, verás que es distinto. En nuestro caso, history nos muestra 15 capas, mientras que inspect tan solo nos muestra 8 capas. ¿Por qué esa diferencia?
La razón viene por cómo se calcula el id de una capa: es un hash del contenido (ficheros) de dicha capa. Por lo tanto, si tenemos una capa que no modifica el sistema de ficheros, su id será el mismo que el de la capa anterior.
Usando inspect vemos solo los distintos ids, mientras que usando history tenemos todas las capas. Por lo tanto, de las 15 capas reales que conforman la imagen myfirstimage tenemos 8 capas que modifican el sistema de archivos del contenedor y 7 capas que realizan tareas de configuración (por ejemplo, añadir variables de entorno).
De hecho, si vuelves a mirar la salida de history, verás que hay 7 capas cuyo campo SIZE vale 0B: esas son las 7 capas que no modifican el sistema de ficheros y, por lo tanto, el id de cada una de esas capas es igual al id de la capa anterior. Por otro lado, las capas que tienen un tamaño mayor a 0 (y si las cuentas verás que son ocho) son las que modifican el sistema de ficheros de la imagen y, por lo tanto, tienen un id propio.
Obtener el id completo de la imagen
Hemos comentado que el id de una imagen es un hash (SHA256) calculado a partir del contenido del objeto de configuración que describe la imagen. Pero, hasta ahora todos los ids de imágenes que hemos visto son mucho más cortos. Eso es, simplemente, porque Docker no nos muestra el id completo. Así, cuando hacemos docker images o cualquier otro comando que nos muestre ids de imágenes vemos los ids abreviados.
Para obtenerlo, disponemos de dos opciones sencillas. La primera es usar docker images --no-trunc. El modificador --no-trunc le indica a Docker que queremos ver los ids completos:
REPOSITORY TAG IMAGE ID CREATED SIZE myfirstimage latest sha256:f7c89b3c7fa9884ac3e77715899ba7d9089ebbd76990a76a3538228b782b0096 4 hours ago 673MB node latest sha256:9ea1c3e33a0b643018428df8b675d623dd6b67315bc60c69c7fe43efea9a177d 15 hours ago 673MB
Otra es usar el comando inspect y extraer el id de la imagen:
docker inspect --format "{{.Id}}" myfirstimage
La salida de este comando es el id completo de la imagen.
Añadir contenido a una imagen
Vamos a continuar con nuestra imagen y vamos a añadirle contenido propio.
Hasta ahora hemos partido de una imagen base (usando FROM) y hemos añadido una configuración (usando ENV). Veamos ahora cómo podemos añadir contenido a la imagen. En aras de la sencillez vamos a usar un ejemplo en Node.js. Luego comentaremos otros escenarios.
Creando una imagen de Node.js
Descarga desde el lateral de esta lección (o desde las descargas del curso) el fichero nodejs-sample.zip y descomprímelo en un directorio vacío. Este fichero contiene una sencilla aplicación en Node.js que levanta un servidor que escucha por un puerto (configurable) y que devuelve el contenido de una URL (dirección web) cualquiera que se le mande por parámetro.
Para ejecutar esta aplicación en local (olvídate de Docker de momento) necesitas lo siguiente:
- Tener Node.js instalado
- Tener nuestro código (obviamente)
- Ejecutar el comando npm install para instalar todas las dependencias (paquetes npm) que requiere la aplicación
- Establecer la variable de entorno
PORTque indica por qué puerto debe escuchar la aplicación (opcional) - Ejecutar
node server.jspara iniciar la aplicación
Si tienes Node.js y quieres probar el ejemplo en local, una vez hayas hecho npm install, ponlo en marcha mediante node server.js. Por defecto se usa el puerto 3000, aunque puedes establecer la variable de entorno PORT para elegir el puerto por el cual escuche la aplicación. Puedes llamar a /ping para obtener un pong como respuesta, o bien llamar a /get?url=xxx para mostrar el contenido de la url xxx por pantalla.
Por supuesto, puedes probar todas esas tareas en local si quieres, pero no es necesario. Lo importante es entender qué debes hacer porque es lo que debemos replicar en el Dockerfile.
Para empezar, crea un Dockerfile que parta de la imagen de Node.js:
FROM node
El siguiente punto es establecer un directorio en el que vamos a desplegar nuestra aplicación. Para ello se usa el comando WORKDIR. Asume que este comando es el equivalente a hacer cd dentro de la imagen. Su sintaxis es muy sencilla: WORKDIR directorio-imagen. Vamos a desplegar nuestra aplicación en el directorio /app:
FROM node WORKDIR /app
Ahora, el siguiente punto es copiar todos los ficheros que conforman nuestra aplicación al sistema de ficheros de la imagen:
FROM node WORKDIR /app COPY . .
El comando COPY copia un directorio local (de la máquina que está creando la imagen) a un directorio del contenedor. La sintaxis es COPY directorio-local directorio-contenedor. El “directorio-local” es relativo a la ubicación del Dockerfile y el “directorio-contenedor” es relativo al directorio especificado en el comando WORKDIR. Por lo tanto, con el comando COPY . . estamos copiando el contenido del directorio (y subdirectorios) donde está el Dockerfile al directorio /app del contenedor.
Existe una alternativa al comando COPY que es el comando ADD. El comando ADD usa la misma sintaxis que COPY (ADD origen directorio-contenedor), pero, a diferencia de COPY, el origen puede ser no solo un directorio local sino también una URL o un fichero comprimido (y lo descomprime en la imagen). La recomendación es usar COPY antes que ADD siempre que sea posible.
Una vez tenemos nuestro código en el sistema de archivos de la imagen podemos ejecutar npm install en la imagen. Quizá te suene raro eso de “ejecutar algo en la imagen”, pero ten presente que el proceso de construcción de una imagen se realiza a través de un contenedor, por lo que se pueden ejecutar comandos dentro de este. Y para eso tenemos la instrucción RUN del Dockerfile.
La sintaxis de RUN es muy sencilla: RUN comando para que (el contenedor que construye) la imagen ejecute un comando. En nuestro caso queremos ejecutar un npm install:
FROM node WORKDIR /app COPY . . RUN npm install
El comando npm install requiere el fichero package.json, que en este caso ya tenemos en el sistema de ficheros de la imagen porque lo hemos copiado en el punto anterior. Y podemos ejecutar npm porque la imagen base de la que partimos (la de node) contiene npm.
Finalmente, solo nos queda indicar cuál es el comando que debe ejecutarse cuando se inicie el contenedor (el mismo que debe ejecutarse en local). Para indicar el comando que se ejecuta al iniciar un contenedor existe la instrucción CMD. Como te puedes imaginar su sintaxis es muy sencilla: CMD comando_a_ajecutar:
FROM node WORKDIR /app COPY . . RUN npm install CMD node server.js
La diferencia entre RUN y CMD es que el primero se ejecuta durante la fase de construcción del contenedor a partir de la imagen, es decir, se ejecuta como parte del proceso que construye el nuevo contenedor. CMD, por el contrario, se ejecuta ya dentro del nuevo contenedor y es, generalmente, la última instrucción, destinada a lanzar el proceso que nos interese dentro del nuevo contenedor que se acaba de crear.
ENTRYPOINT o CMD
Hemos visto que para especificar el proceso inicial de un contenedor utilizamos CMD. Así, para que nuestro contenedor lance node server.js cuando se inicie, podemos usar:
CMD node server.js
Existe otra sentencia alternativa en Dockerfile que también te permite especificar un comando inicial. Se trata de ENTRYPOINT. Toda imagen necesita definir, o bien un valor para CMD, o bien para ENTRYPOINT; pero ¡es posible definir ambos!
Entender las diferencias entre CMD y ENTRYPOINT es fundamental.
Shell vs Exec
Antes de hablar de las diferencias entre ENTRYPOINT y CMD es interesante conocer que ambas instrucciones pueden aparecer con dos sintaxis distintas: la sintaxis que conocemos como shell y la que conocemos como exec.
Hasta ahora siempre hemos usado CMD en su forma shell (CMD node server.js). Pero también podríamos usar CMD en su forma exec:
CMD ["node", "server.js"]
Entre ambos formatos hay una diferencia importante: usando la forma shell el comando que especificamos se ejecuta dentro de un shell (de ahí su nombre); mientras que la forma exec lanza un nuevo proceso. En el caso de Linux se ejecuta el shell /bin/sh -c, y luego el comando que hayamos indicado nosotros.
Veámoslo con un ejemplo. Crea un Dockerfile con el siguiente contenido:
FROM alpine:3.7 CMD ping localhost
Partimos de la imagen base alpine:3.7 y el comando inicial es ping localhost (es un comando que nunca finaliza en Linux, así que nuestro contenedor estará corriendo hasta que lo paremos). Ahora crea una imagen con docker build -t test-cmd .
Una vez tengas la imagen creada, usa docker run -d test-cmd para crear un contenedor (en modo desatendido). Esto te imprimirá el id del contenedor y te devolverá el control. Ahora con docker ps deberías ver tu contenedor ejecutándose.
Ahora vamos a usar el comando docker exec. El comando docker exec ejecuta un proceso nuevo en un contenedor que esté en marcha. La sintaxis es:
docker exec <id-contenedor> comando
En nuestro caso queremos ejecutar el comando ps (usado en Linux para ver los procesos ejecutándose). Así pues teclea lo siguiente:
docker exec <id-contenedor> ps
(siendo <id-contenedor> el id de tu contenedor)
La salida será algo parecido a:
PID USER TIME COMMAND
1 root 0:00 /bin/sh -c ping localhost
6 root 0:00 ping localhost
7 root 0:00 ps
Observa como el comando inicial (PID 1) es /bin/sh -c ping localhost. Esto es porque hemos usado la forma shell.
En el caso de que uses contenedores Windows, al usar la forma shell se usa cmd /S /C en lugar de /bin/sh. No confundas el cmd de cmd /S /C con la instrucción CMD del Dockerfile. Este cmd se refiere a cmd.exe el intérprete de comandos de Windows.
Vamos a modificar el Dockerfile y cambiemos la última línea, para usar la forma exec de CMD:
FROM alpine:3.7 CMD ["ping","localhost"]
Y reconstruimos la imagen (para ello, antes, detén y borra el contenedor que acabas de crear, porque sino, Docker no podrá recrear la imagen). Una vez construida la imagen, pon en marcha otro contenedor y usa docker exec de nuevo para lanzar el comando ps en el contenedor. Y la salida será parecida a:
PID USER TIME COMMAND
1 root 0:00 ping localhost
7 root 0:00 ps
Como puedes ver ahora no se ha ejecutado /bin/sh -c para lanzar el comando indicado.
¿Cuál es la forma recomendada? Pues, por lo general la recomendación es usar la forma exec, no la shell. Hay dos razones fundamentales:
/bin/sh -cno propaga señales POSIX al proceso hijo. Eso tiene implicaciones en cómo Docker detiene nuestro contenedor, ya que la señalSIGTERMque Docker envía para detenerlo, no llegará nunca a nuestro proceso.- No todas las distros Linux tienen por qué tener un shell. Por lo que
/bin/shpuede no estar disponible. Es cierto que esto todavía no es muy habitual pero, cada vez más, se crean imágenes llamadas “shell-less” que no tienen shell. Los motivos son, tanto por seguridad como por tamaño de la imagen.
Puedes usar la instrucción SHELL del Dockerfile para indicar cuál debe ser el shell a usar por defecto. El shell especificado usando SHELL se usará tanto para las instrucciones CMD y ENTRYPOINT (si se usan en su forma shell), y también para la instrucción RUN. Eso es especialmente interesante en contenedores Windows, en los que podemos querer usar Powershell como intérprete de comandos en lugar de cmd.exe. En este caso podemos hacer:
SHELL ["powershell", "-command"] RUN Write-Host "¡Puedo usar PS!"
La instrucción SHELL establece que el shell a usar será powershell -command, lo que permite usar instrucciones Powershell en el Dockerfile. Así mismo, si utilizases CMD o ENTRYPOINT en su forma shell, el comando se ejecutaría mediante Powershell.
En Linux no se suele usar tanto esta posibilidad, aunque también tiene su utilidad en aquellos casos en los que quieras emplear un shell alternativo a sh, como bash o zsh. Por supuesto, como es evidente, el shell que quieras utilizar (tanto en Windows como en Linux) debe existir previamente en la imagen.
La instrucción ENTRYPOINT
Hemos visto cómo funciona CMD y las diferencias entre usarla en su forma shell o en su forma exec. Es el momento de presentar ENTRYPOINT y ver en qué se diferencia de CMD.
Lo primero a tener presente es que si solo utilizas CMD o ENTRYPOINT (uno de los dos únicamente) no existen demasiadas diferencias entre ellos. El comportamiento será muy parecido.
De hecho, si modificas el Dockerfile para usar ENTRYPOINT en lugar de CMD observarás idéntico comportamiento:
- Usando
ENTRYPOINT ping localhostverás que el proceso inicial es/bin/sh -c ping localhost - Usando
ENTRYPOINT [“ping, “localhost”]verás que el proceso inicial esping localhost
Es decir, el mismo comportamiento que CMD.
La principal diferencia entre usar CMD o ENTRYPOINT estriba en la facilidad para modificar este valor que le proporcionamos a quien crea el contenedor (es decir, lo fácil que le resulta modificarlo a quien ejecuta docker run). Esto es porque la sintaxis de docker run permite redefinir tanto CMD como ENTRYPOINT:
docker run <imagen> comando docker run --entrypoint comando <imagen>
La primera sentencia redefine el valor de CMD, mientras que la segunda redefine el valor de ENTRYPOINT y requiere un parámetro adicional, que va antes del nombre de la imagen. Un poco más complicado.
En general la idea es que uses ENTRYPOINT para aquellas imágenes en las que no esperas que se vaya a modificar su proceso inicial. Mientras que CMD se suele utilizar más en aquellas imágenes en las que es relativamente normal que quien lanza el contenedor correspondiente modifique el proceso inicial.
ENTRYPOINT y CMD a la vez
Toda imagen debe definir o bien ENTRYPOINT o bien CMD, pero no son exclusivos: es posible definir ambos de forma simultánea. En ese caso, cuando ambos están definidos, el comportamiento es el siguiente:
ENTRYPOINT: es el que determina el proceso inicialCMD: el contenido deCMDse emplea como si fueran los parámetros paraENTRYPOINT… aunque solo siENTRYPOINTse usa en su forma exec. SiENTRYPOINTse usa en su forma shell entonces se hace caso omiso deCMD.
Vamos a verlo en acción. No es necesario que crees una imagen con CMD y ENTRYPOINT, vamos a pasar sus valores mediante docker run. Partiremos de la última imagen test-cmd que teníamos. Recuerda que el Dockerfile era:
FROM alpine:3.7 ENTRYPOINT ["ping", "localhost"]
La imagen pues no define CMD pero sí ENTRYPOINT. Ahora imagina que queremos usar esta imagen para lanzar cualquier otro comando en lugar de ping, como por ejemplo ifconfig. La sentencia más lógica sería:
docker run test-cmd ifconfig
Observa que estamos ejecutando el comando ifconfig existente en Linux. No lo confundas con el comando ipconfig (con p en lugar de la primera f) que existe en Windows
Pero esto no te funcionará. No te funcionará porque este docker run redefine el valor de CMD (que no teníamos), así que ahora este contenedor tiene:
ENTRYPOINTigual a “ping localhost” (le viene de la imagen)CMDigual a “ifconfig” (le viene del comandodocker run)
Por lo tanto, nuestro contenedor intentará ejecutar ping localhost ifconfig, lo que te dará un error.
Ahora bien, si usamos el comando docker run –entrypoint ifconfig test-cmd lo que el contenedor recibe es:
ENTRYPOINTigual a “ifconfig” (del comandodocker run)CMDninguno
Por lo que este contenedor ejecutará el comando ifconfig:
Observa que ejecutar docker run --entrypoint ifconfig test-cmd es bastante menos intuitivo que usar docker run test-cmd ifconfig. Este es el comando que nos hubiera funcionado si en el Dockerfile hubiéramos usado CMD en lugar de ENTRYPOINT. De ahí la afirmación anterior donde indicaba que ENTRYPOINT se usa en aquellos casos en que “no es habitual” que se redefina el proceso de entrada de un contenedor.
Problemas al usar la forma shell
Hemos visto cómo combinar ENTRYPOINT y CMD. Pero ten presente que usar ENTRYPOINT y CMD a la vez solo te funcionará si usas la forma exec. Si usas la forma shell tendrás problemas. La razón es muy sencilla y es que el comando final generado puede ser inválido.
| ENTRYPOINT | CMD | Resultado final |
|---|---|---|
["ping, "localhost"] | -4 | ping localhost /bin/sh -4 |
ping localhost | -4 | /bin/sh -c 'ping localhost' |
ping localhost | ["-4"] | /bin/sh -c 'ping localhost' |
["ping, "localhost"] | ["-4"] | ping localhost -4 |
Observa como la única sentencia correcta es la última (ping localhost -4) que se da cuando tanto ENTRYPOINT como CMD están especificados usando la forma exec. Las filas segunda y tercera son también correctas, pero se ha hecho caso omiso del valor de CMD porque ENTRYPOINT está definido en su forma shell.
Cuando redefines ENTRYPOINT o CMD usando docker run es como si los definieses en el archivo en su forma exec.
Resumiendo:
- Utiliza preferiblemente
ENTRYPOINTantes queCMD(a no ser que pretendas que el usuario redefina fácilmente el comando) - Si
ENTRYPOINTyCMDaparecen a la vez, el segundo se toma como parámetros del primero - Usa mejor la forma exec antes que la forma shell. Especialmente usa siempre la forma exec de
ENTRYPOINT(a no ser que quieras expresamente que no se haga caso deCMD)
Exponer puertos
En el ejemplo que estamos usando hemos generado un Dockerfile para crear una imagen que nos permita levantar contenedores que ejecutan un servicio de Node.js. Dicho servicio escucha a través del puerto 3000, lo que significa que el contenedor abre su puerto 3000.
Sin embargo, si pones en marcha el contenedor y usas docker ps, verás que la columna PORTS está vacía:
Eso es debido a que no hemos indicado a Docker que los contenedores levantados a partir de dicha imagen pretenden abrir su puerto 3000. Para indicarle dicha intención a Docker, se puede usar la sentencia EXPOSE del Dockerfile. Su uso es muy simple: EXPOSE puerto, donde puerto es el número de puerto que nuestro contenedor pretende abrir (en nuestro ejemplo el 3000).
Si modificas el Dockerfile para añadir EXPOSE 3000 (p. ej. antes de la sentencia CMD), reconstruyes la imagen y lanzas de nuevo un contenedor, verás como, ahora, en la salida de docker ps la columna PORTS te indica que el contenedor expone el puerto 3000:
Es importante que tengas presente que EXPOSE es puramente informativo: indica la intención del contenedor de escuchar a través de dicho puerto. Pero, no significa forzosamente que este sea el puerto que de verdad abra el contenedor.
El uso de EXPOSE no tiene ningún impacto sobre la forma en la que Docker gestiona la red: no hace que dicho puerto esté disponible y el resto no, no abre dicho puerto por defecto (esto debe hacerlo el contenedor), ni tampoco mapea dicho puerto al host (eso debes seguir haciéndolo tú, usando el modificador -p de docker run).
Pero, es importante su uso porque documenta cuál (o cuáles si usas múltiples sentencias EXPOSE) son los puertos por los que se supone que el contenedor escucha.
Y, por supuesto, dado que EXPOSE es puramente informativo, el mapeo de puertos funciona, incluso, aunque no uses EXPOSE. Es decir, si la imagen no usa EXPOSE, pero tu contenedor abre el puerto 3000, si al usar docker run, mapeas el puerto 3000 del contenedor al puerto 8080 (o cualquier otro) del host, podrás acceder a tu contenedor usando dicho puerto.
Insisto: EXPOSE es solo con propósito informativo.
Un consejo: utiliza siempre EXPOSE en las imágenes que crees. La razón principal es para documentar y porque hay otras herramientas en el ecosistema de contenedores que pueden sacar partido a esta información.
El proceso de construcción de una imagen
En la sección anterior hemos comentado que “el proceso de construcción de una imagen se realiza a través de un contenedor”. Es importante que entiendas este concepto, así que vamos a desarrollarlo un poco.
Todo Dockerfile debe empezar por una sentencia FROM que especifique la imagen base a usar. Lo primero que Docker hace es crear un contenedor a partir de esa imagen. Cuando en nuestro Dockerfile teníamos FROM node, lo primero que hace Docker es descargarse la imagen node (si no la tenemos en local) y crear un contenedor a partir de esta imagen.
Será este contenedor recién creado el que ejecutará la siguiente instrucción del Dockerfile.
Veamos la salida que nos genera la instrucción docker build:
E:\nodejs-sample>docker build . -t nodejs-sample Sending build context to Docker daemon 28.16kB Step 1/5 : FROM node ---> 9ea1c3e33a0b Step 2/5 : WORKDIR /app ---> 88a4a86fa250 Removing intermediate container 73428d030cf1 Step 3/5 : COPY . . ---> dfae33d74efb Step 4/5 : RUN npm install ---> Running in 6138c2f87253 npm info it worked if it ends with ok **.... varias líneas de LOG de npm que se omiten ....** added 93 packages in 2.48s npm info ok ---> cf59b841c646 Removing intermediate container 6138c2f87253 Step 5/5 : CMD node server.js ---> Running in 1e6d20825950 ---> c69b88bc66c3 Removing intermediate container 1e6d20825950 Successfully built c69b88bc66c3 Successfully tagged nodejs-sample:latest
El proceso que sigue Docker para construir una imagen es el siguiente:
- Se crea una capa a partir de la imagen base (definida en el
FROM). En nuestro caso es la9ea. - Se crea un contenedor a partir de esa capa (que equivale a la imagen base). Dicho contenedor en nuestro caso es el
734. - Sobre ese contenedor ejecuta la siguiente sentencia del
Dockerfile(en nuestro ejemplo esWORKDIR app). El resultado se guarda como una capa (88a). - Si la siguiente instrucción del
Dockerfilees un ejecutable (por ejemplo,RUNoCMD) el contenedor anterior es desechado y se crea uno nuevo. Si no es el caso, se continúa usando el contenedor anterior.
De hecho, en este caso concreto se crean los siguientes contenedores:
- Contenedor
734. Creado a partir de la capa9ea(sentenciaFROM). Ejecuta también elWORKDIR(y genera la capa88a) y elCOPY(generando la capadfa). - Contenedor
613. Se crea para ejecutar elRUN npm install, generando la capacf5. - Contenedor
1ed. Se crea para ejecutar elCMD node server.jsgenerando la capac69.
Cada vez que se crea un nuevo contenedor durante el proceso, se desecha el anterior (observa los mensajes Removing intermediate container).
Recuerda que si ejecutamos docker history nodejs-sample podremos ver cada una de esas capas creadas.
En resumen: cuando ejecutamos un Dockerfile (para crear una imagen) Docker crea un contenedor temporal para ejecutar las sentencias del Dockerfile. Cada sentencia del Dockerfile genera una capa y es ejecutada en este contenedor creado por Docker. Algunas sentencias implican la creación de otro contenedor temporal, siendo el anterior desechado.
Variables de construcción
Es posible utilizar variables en el fichero Dockerfile. Para ello debes “declarar” la variable en el fichero Dockerfile usando la instrucción ARG:
ARG tag
El código anterior declara una variable llamada tag. Las variables declaradas con ARG son variables en tiempo de construcción de la imagen, es decir, la imagen construida no contendrá ninguna variable de entorno llamada tag (debes usar ENV para ello), sino que podemos usar esta variable tag a la hora de construir y su valor será sustituido:
ARG tag
FROM node:${tag}
Este ejemplo utiliza la variable de construcción tag para permitir decidir qué imagen base usar (en concreto qué tag de la imagen base node). Si guardas el código anterior en un fichero Dockerfile e intentas construirlo con el comando docker build -t testvars . la salida será parecida a:
Sending build context to Docker daemon 2.048kB
Step 1/2 : ARG tag
Step 2/2 : FROM node:${tag}
invalid reference format
Docker se queja de que la imagen node:${tag} no es una imagen válida. El error es debido a que la variable de construcción tag no tenía valor todavía. Para proporcionarle un valor usamos el modificador --build-arg:
docker build -t testvars --build-arg tag=8.11-alpine .
Ahora la imagen se construirá correctamente, tomando como imagen base node:8.11-alpine
Debes pasar explícitamente los valores de las variables de construcción usando --build-arg. Tener una variable de entorno con el mismo nombre declarada en tu sistema no funcionará.
Valores por defecto
Las variables de construcción pueden tener un valor por defecto. Este valor se establece en cada uso de la variable, es decir, el valor por defecto no se asigna a la variable, sino en cada sitio en el que se usa. Para ello se emplea la sintaxis $:{variable:-valor}:
ARG tag
FROM node:${tag:-8.11-alpine}
Si ahora no usas --build-arg para especificar un valor para la variable tag, la sentencia FROM usará el valor 8.11-alpine en su lugar. De este modo, aunque el que haga uso del archivo de construcción se olvide de establecerla, al menos tendrá un valor predeterminado que le permita funcionar.
Capas de escritura (writable layer)
Recuerda que una imagen de Docker está formada por un conjunto de capas. Esas capas son de solo lectura. Es decir, no puede modificarse el contenido de una capa, debe crearse una capa nueva (de hecho, recuerda que el id de una capa es un hash de los contenidos de esta). Eso permite a Docker reaprovechar capas entre imágenes (y por lo tanto contenedores) y es un aspecto fundamental de la eficiencia de la herramienta en cuanto a tamaño de disco requerido se refiere.
A continuación, descárgate la imagen ubuntu y crea un contenedor a partir de esa imagen iniciando una sesión interactiva (ejecutando bash). Si quieres refrescar la memoria sobre los comandos a usar, despliega el código a continuación:
docker pull ubuntu docker run -it ubuntu /bin/bash
Ahora debes tener una sesión interactiva con un contenedor. Vamos a crear un fichero en el contenedor, tecleando los siguientes comandos en la sesión interactiva que tenemos:
mkdir test cd test echo test file > test.txt
Eso debe crear un fichero test.txt en el contenedor con el contenido test file.
Ahora termina la sesión interactiva tecleando exit. Con eso deberías salir del contenedor y estar otra vez en tu sesión de terminal o línea de comandos. Además, si usas docker ps deberías ver que no hay ningún contenedor en marcha: al terminar con exit el proceso bash (que era el proceso inicial del contenedor) ha terminado también. Si tecleas docker ps -a, sí que verás el contenedor con el estado exited:
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES df90d8320dcf ubuntu "/bin/bash" About a minute ago Exited (130) About a minute ago festive_noyce
Si ahora pones en marcha otro contenedor de la misma imagen, verás que el fichero test.txt que hemos creado antes no existe. Es lógico, puesto que el fichero lo hemos creado en el contenedor, no en la imagen.
Ahora bien, si reinicias el contenedor anterior sí que verás el fichero. Para verificarlo puedes teclear docker start -i <id> donde <id> es el id del contenedor (por ejemplo, en este caso sería df). Usamos el modificador -i para tener una sesión interactiva. Dado que el contenedor ejecutaba el comando bash vas a tener un shell interactivo. Si te vas al directorio test (cd test) y listas los ficheros (ls) allí verás el fichero test.txt.
El comando docker start no crea un contenedor nuevo, sino que reinicia uno que estaba parado.
Por lo tanto, la pregunta obvia es: ¿dónde está guardado este fichero? La respuesta, quizá como ya puedes intuir es: en una capa adicional.
Cuando se crea un contenedor, Docker monta sobre la última capa una capa adicional de escritura. Esa capa es única para el contenedor y contiene todos aquellos datos del sistema de ficheros que el contenedor modifique (creación de nuevos ficheros, modificaciones, borrados). Cuando el contenedor se borra, esta capa se borra también.
Tamaño ocupado por la capa de escritura
Si usas docker ps con el modificador -s puedes ver el tamaño que ocupa esta última capa:
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES SIZE df90d8320dcf ubuntu "/bin/bash" 12 minutes ago Up 8 minutes festive_noyce 63B (virtual 122MB)
Observa que en la columna SIZE se indica que el contenedor ocupa 63B para un tamaño virtual de 122MB. Eso significa que el tamaño de la capa de escritura es de 63B (63 bytes). Y, que el tamaño del resto de capas suma 122MB. Pero, Docker interpreta que es un tamaño virtual porque esas capas son de solo lectura y, por lo tanto, pueden ser usadas en ese contenedor y en muchos otros más que estén en el sistema.
Así pues, el uso de capas de escritura le permite a Docker mantener su eficiencia en el uso de disco y, a la vez, posibilita que un contenedor pueda modificar sus datos. Pero, recuerda: los datos almacenados en la capa de escritura (que son todas las modificaciones al sistema de ficheros que dicho contenedor realice) existen solo para el contenedor al cual pertenece la capa de escritura. Las capas de escritura nunca se comparten.
Volúmenes
Básicamente, un volumen es una carpeta del contenedor que reside fuera de él (habitualmente en el host). Eso permite que los contenedores lean (y escriban) en una carpeta que realmente se encuentra en otro lugar. Por lo tanto, sus contenidos exceden el tiempo de vida del contenedor y pueden ser leídos o escritos por otros contenedores.
El escenario más habitual es que la carpeta resida físicamente en el sistema de ficheros del host.
Para indicar que una imagen usa uno o varios volúmenes podemos utilizar la sentencia VOLUME del Dockerfile. Su sintaxis es muy sencilla:
VOLUME path VOLUME ["path1", "path2",..., "pathN"] VOLUME path1 path2 ... pathN
La sentencia VOLUME solo indica que el directorio (o directorios) especificados serán volúmenes (es decir su contenido estará mapeado a partir de una carpeta del host), pero no indica en ningún caso qué carpeta del host es la que contiene los datos del volumen. Ya que eso añadiría una dependencia del Dockerfile hacia el host y los Dockerfile deben ser independientes del host pues describen imágenes.
Vamos a ver un ejemplo sencillo para entenderlo mejor. Crea un Dockerfile como el que sigue:
FROM ubuntu RUN mkdir /test-volume VOLUME /test-volume CMD tail -f /dev/null
La sentencia CMD final es para que el contenedor se quede corriendo “eternamente”. Su proceso inicial es tail que está escuchando continuamente el “fichero” /dev/null. Es una técnica habitual que suele usarse para contenedores que queremos que se queden en marcha hasta que sean explícitamente parados.
A continuación, construye una imagen (llámala ”test-volume“) y luego pon en marcha un contenedor. Para que no nos bloquee la línea de comandos, haz que Docker ejecute el contenedor en segundo plano. Finalmente, verifica que el contenedor está corriendo:
docker build . -t test-volume docker run -d test-volume docker ps
Ahora es posible que te estés preguntando: no hemos especificado dónde reside el volumen. ¿Qué directorio se está usando? Para verlo, lo mejor es recurrir a un viejo amigo: el comando inspect. Vimos este comando cuando hablamos de ver las capas de una imagen. Recuerda que dicho comando nos da la descripción de cualquier objeto de Docker (contenedor, imagen, red, …). Si tecleas docker inspect <id-contenedor> te debería salir un JSON bastante grande por la pantalla: esa es toda la información que Docker maneja de este contenedor. A nosotros ahora nos interesa la sección Mounts, así que podemos filtrar la salida:
docker inspect --format "{{json .Mounts}}" <id-contenedor>
Eso debería sacarnos una cadena similar a la siguiente (los números pueden cambiar):
[{
"Type":"volume",
"Name":"d0fc0c35960af5cdd583af476d40e8da58ab70437a1774fe396d6335d7b2e8f7",
"Source":"/var/lib/docker/volumes/d0fc0c35960af5cdd583af476d40e8da58ab70437a1774fe396d6335d7b2e8f7/_data",
"Destination":"/test-volume",
"Driver":"local",
"Mode":"",
"RW":true,
"Propagation":""
}]
Vemos que es un array formado por un solo objeto con las propiedades:
Type: tipo del volumenName: nombre (id) del volumenSource: el origen del volumen (donde residen los datos físicamente). En nuestro caso es una carpeta del host.Destination: la carpeta del contenedor que representa el volumen (nuestratest-volume)Driver: driver usado para el volumen. En este caso nos indica que es un volumen local.
Vamos a verificar que esto es realmente una carpeta. Para ello “tan solo” debemos ir al directorio indicado en Source y guardar allí un fichero. Si estás ejecutando Docker en Linux esto no tiene más complicación: crea un fichero en la carpeta indicada en Source de tu máquina y listo.
Nota para sistemas Windows (con contenedores Linux)
Recuerda que si ejecutas Docker en Windows tus contenedores se están ejecutando usando WSL o bien una máquina virtual (a no ser que uses contenedores Windows como veremos más adelante). Si ejecutas Docker Desktop usando la MV, no hay ninguna manera sencilla de entrar a la máquina virtual que, como hemos visto, ejecuta los contenedores.
Pero si usas Docker Desktop con WSL2, entonces sí puedes acceder fácilmente a los volúmenes. Abre un explorador de Windows y teclea la siguiente dirección en la barra de tareas: \\wsl$\docker-desktop-data\version-pack-data\community\docker\volumes. Eso te debería abrir una carpeta donde residen todos los volúmenes de tus contenedores.
Comprobando el volumen
Desde el terminal (o línea de comandos) abre una sesión interactiva con el contenedor (docker exec -it <id-contenedor> /bin/bash) y navega al directorio /test-volume. Allí deberías ver el fichero que hemos creado desde el host en el paso anterior.
Crear una sesión interactiva para ejecutar un shell mediante docker exec solo funcionará si la imagen tiene shell. Lógicamente. Si la imagen es “shell-less” (es decir no tiene ningún shell), el comando docker exec producirá un error. Que una imagen tenga shell abre la puerta a varios posibles ataques y, por ello, cada vez más se crean imágenes sin ningún shell.
En el ejemplo he usado /bin/bash como shell. En muchas imágenes este shell puede no estar disponible, pero esto no significa que la imagen sea “shell-less”. Simplemente puede tener otro shell que no sea bash. Por lo general, la apuesta segura es usar /bin/sh que es el shell “básico” de Linux.
Si ahora paras el contenedor, lo eliminas y ejecutas de nuevo otro contenedor: ¿qué ha ocurrido con el volumen? Compruébalo tú mismo:
docker stop <id-contenedor> docker rm <id-contenedor> docker run -d test-volume docker exec -it <nuevo-id-contenedor> /bin/bash cd /test-volume ls
Si lo compruebas verás que los datos del volumen ya no están: eso es porque el nuevo contenedor usa otro volumen. Si usas docker inspect verás que el id del volumen (y el directorio del host) son distintos del anterior. Esto es porque estamos usando “volúmenes anónimos”. En el siguiente apartado veremos los distintos tipos de volúmenes existentes y cuándo usar cada uno de ellos.
Tipos de volúmenes
Hemos visto cómo definir un volumen en el Dockerfile y cómo el uso de VOLUME implica que se monta un directorio del contenedor a partir de un origen de datos fuera del contenedor. Cuando trabajemos con volúmenes debemos diferenciar entre dos casos:
- Cuando nos interesa simplemente tener datos persistentes en algún lugar.
- Cuando queremos mapear una carpeta determinada del host a un contenedor.
Docker ofrece una solución específica para cada ocasión. Y aunque en ambos casos se suele hablar de “volúmenes”, debemos distinguir entre lo que Docker entiende realmente por volumen (que usaremos para el primer caso) y lo que se llama un bind mount que usaremos en el segundo caso.
En este curso vamos a usar la nomenclatura oficial de Docker y vamos a distinguir entre volumen y bind mount. Pero, ten presente que, en muchos artículos no se hace esta distinción.
Alguna literatura nombra a los bind mounts como host volumes. Conviene que conozcas también este término.
Bind Mounts
Un bind mount consiste en montar una carpeta del host en un directorio del contenedor. Eso permite a un contenedor acceder a ficheros que residen en el sistema de archivos del host. Este acceso puede ser de solo lectura o el contenedor puede tener permisos de escritura.
Veamos un ejemplo de creación de un bind mount. Para ello vamos a usar, sobre la misma imagen anterior, el comando docker run con el modificador -v (o --volume). Este modificador permite crear tanto volúmenes como bind mounts.
Para crear un bind mount la sintaxis es muy sencilla: -v <dir-host>:<dir-contenedor>. Así, en nuestro ejemplo anterior podríamos lanzar el contenedor con:
docker run -v /etc/tmp:/test-volume test-volume
¡Nota importante!: el directorio del host debe ser un directorio absoluto. No se admiten directorios relativos con el parámetro -v al crear bind mounts.
Si utilizas Docker Desktop for Windows, el directorio del host sigue la sintaxis de las rutas de Windows, es decir, por ejemplo: docker run -v d:\localfolder:/test-volume. Si el directorio en Windows tiene espacios, debes entrecomillar todo el valor del parámetro -v: docker run -v "d:\local folder:/test-volume".
Si ahora usas inspect para ver el volumen montado, verás algo como:
[{
"Type":"bind",
"Source":"/etc/tmp",
"Destination":"/test-volume",
"Mode":"",
"RW":true,
"Propagation":"rprivate"
}]
Observa como el valor de Source es el directorio del host que nosotros hemos especificado. Puedes seguir los puntos del apartado anterior para comprobar cómo, efectivamente, los datos del directorio del host /etc/tmp se ven en el contenedor en el directorio /test-volume
Ahora bien, algunas diferencias respecto al volumen del punto anterior:
Typetiene el valor debinden lugar devolume, indicando que es un bind mount.- No tiene el atributo
Name
Volúmenes
A diferencia de los bind mounts, que simplemente montan una carpeta del host en un contenedor, un volumen es un objeto gestionado por Docker. En contraste con los bind mounts, los volúmenes pueden gestionarse usando la CLI:
- Puedes listar los volúmenes mediante
docker volume ls - Obtener los datos de cada uno mediante
docker inspect <nombre-volumen> - Y borrar los que no sean usados por ningún contenedor con
docker volume prune.
La recomendación actual pasa por usar siempre que sea posible volúmenes en lugar de bind mounts, ya que tienen varias ventajas:
- Los volúmenes son manejados completamente por Docker. Los bind mounts son simplemente una carpeta del host mapeada en una carpeta del contenedor.
- Se puede usar la CLI (
docker volume) para gestionar volúmenes. No es posible gestionar bind mounts. - Los bind mounts siempre son una carpeta del host que se mapea a un directorio del contenedor. Los volúmenes pueden residir en otros lugares (por ejemplo, un proveedor de cloud) y ofrecer funcionalidades adicionales (como encriptación). Eso es porque Docker puede usar distintos drivers para acceder a los volúmenes.
- Los volúmenes se pueden compartir con total seguridad entre distintos contenedores.
- El contenido inicial de un volumen puede ser rellenado por un contenedor.
Los bind mounts están pensados para compartir carpetas del host, generalmente que contengan ficheros de configuración. En cambio, los volúmenes están pensados para ofrecer una zona de almacenamiento de datos persistente para ser compartida entre varios contenedores.
Volúmenes anónimos
Docker soporta volúmenes anónimos. No es que no tengan nombre, lo tienen, sino que el nombre lo genera Docker automáticamente (y consiste en un id largo). De hecho, el volumen que creamos en el apartado anterior era un volumen nombrado anónimo (recuerda que su nombre era un id largo).
Los volúmenes anónimos se gestionan exactamente igual que los volúmenes, ya que es lo que realmente son.
Crear un volumen
Vamos a ver qué tenemos que hacer para que nuestro contenedor use un volumen en lugar de un bind mount para ejecutarse. Para ello, lo primero es crear el volumen:
docker volume create my-vol
Con eso hemos creado el volumen con el nombre my-vol. Ahora podemos asociarlo a un contenedor:
docker run -v my-vol:/test-volume test-volume
Observa como la sintaxis es muy parecida a la del caso anterior (donde usábamos un bind mount), pero ahora en lugar de -v <path-host>:<path-contenedor> empleamos -v <nombre-volumen>:<path-contenedor>.
Puedes usar docker inspect sobre un volumen para ver su información. Así, si tecleas docker inspect my-vol verás algo parecido a:
[
{
"CreatedAt": "2017-10-09T16:56:52Z",
"Driver": "local",
"Labels": {},
"Mountpoint": "/var/lib/docker/volumes/my-vol/_data",
"Name": "my-vol",
"Options": {},
"Scope": "local"
}
]
El directorio del host donde está este volumen es el valor del atributo Mountpoint. Este directorio está gestionado por Docker y no puedes especificarlo.
Si ahora ejecutas docker inspect --format "{{json .Mounts}}" <id-contenedor> verás algo parecido a lo siguiente:
[{
"Type":"volume",
"Name":"my-vol",
"Source":"/var/lib/docker/volumes/my-vol/_data",
"Destination":"/test-volume",
"Driver":"local",
"Mode":"z",
"RW":true,
"Propagation":""
}]
Se puede observar como ahora el valor de Type es volume y el valor de Name es el nombre que hemos usado al crear el volumen.
Crear y usar volúmenes anónimos
Para crear un volumen anónimo lo más sencillo es dejar que Docker lo haga automáticamente cuando iniciemos un contenedor:
docker run -v /test-volume test-volume
Observa que usamos de nuevo el modificador -v pero omitimos el nombre del volumen (-v <path-contenedor>). De este modo Docker nos va a crear un volumen para ser usado en este contenedor. Si ahora usamos docker volume ls para listar los volúmenes veremos algo parecido a:
DRIVER VOLUME NAME local dc6ce70d7e549b923ae38b2866ba082a7ce1f3150268236592f6ab98deb9d775 local my-vol
El primer volumen es el volumen anónimo, recién creado por Docker, mientras que el segundo es el volumen my-vol que hemos creado antes. Al margen de que el nombre del volumen anónimo es un id largo, no tiene ninguna otra diferencia con un volumen nombrado tradicionalmente. De hecho, se podría lanzar otro contenedor y usar el mismo volumen:
docker run -v dc6ce70d7e549b923ae38b2866ba082a7ce1f3150268236592f6ab98deb9d775:/test-volume test-volume
Observa como usamos la sintaxis -v <nombre-volumen>:<path-contenedor> que hemos visto anteriormente: un volumen anónimo es, simplemente, un volumen con un nombre autogenerado.
Puedes crear un volumen anónimo tecleando simplemente docker volume create. La salida del comando es el id (nombre) del volumen creado.
Los volúmenes (anónimos o no) persisten incluso cuando todos los contenedores que los usan han sido borrados. Es tu responsabilidad eliminarlos cuando ya no los necesites. Para ello puedes usar docker volume rm para borrar un volumen en concreto, o bien docker volume prune para borrar todos aquellos volúmenes que no se estén usando en ningún contenedor.
Volúmenes en memoria
Existe la posibilidad de crear un volumen en memoria. En la terminología de Docker se les conoce como tmpfs mounts y básicamente guardan su contenido en memoria. Como su nombre deja entrever, los tmpfs mounts montan un sistema de ficheros temporal que reside en la memoria del host. Por lo tanto, son para ser usados para datos no persistentes. La ventaja que tienen es la de ofrecer un gran rendimiento.
Esos volúmenes no se pueden compartir entre contenedores.
¿Volúmenes o capa de escritura?
Observa que si deseas guardar datos en un contenedor tienes dos opciones:
- Usar la capa de escritura
- Usar un volumen
Las diferencias fundamentales son que un volumen puede ser compartido por varios contenedores (a diferencia de la capa de escritura que es única y propia de cada contenedor) y que, al estar los datos físicamente en un lugar externo, los datos de los volúmenes persisten con independencia del ciclo de vida de los contenedores.
Una buena práctica cuando se usan contenedores es que estos sean stateless (sin estado). Eso implica que en cualquier momento debería ser posible parar un contenedor y levantar otro con la misma imagen y que este segundo contenedor pueda seguir procesando las tareas y peticiones que procesaba el primero. Eso permite una alta escalabilidad y una gran disponibilidad.
Pero que un contenedor sea stateless significa que todo su estado debe ser persistido fuera del contenedor, es decir, usando volúmenes o bind mounts. Eso descarta el uso de la capa de escritura propia de cada contenedor: los datos que allí se escriben se pierden cuando el contenedor es eliminado. La capa de escritura se usa para aquellos datos que no son imprescindibles como ficheros temporales, cachés y datos de índole similar.
Por lo tanto la idea general es:
- Datos temporales, cachés, ficheros autogenerados: usar la capa de escritura
- Datos temporales, cachés donde se requiera gran rendimiento: usar un volumen en memoria (tmpfs mount)
- Compartición de ficheros entre el host y un contenedor: usar un bind mount
- Datos de contenedores que deben persistir: usar un volumen
- Datos de configuración que contengan secretos: usar un volumen en memoria (tmpfs mount)
Etiquetas (tags)
Una etiqueta (tag) es un identificador adicional al nombre de una imagen. Las etiquetas nos permiten versionar imágenes y, por lo tanto, disponer de más de una versión de la misma imagen. De hecho, toda imagen tiene siempre una etiqueta. Si no la especificamos, Docker asume la etiqueta latest.
El nombre completo de una imagen es nombre:etiqueta.
Vamos a partir de la imagen test-volume que hemos creado en el punto anterior. Si usas docker images la salida será similar a:
REPOSITORY TAG IMAGE ID CREATED SIZE test-volume latest ca13dd99cd29 21 hours ago 122MB ubuntu latest 2d696327ab2e 3 weeks ago 122MB
La columna TAG nos muestra la etiqueta de cada imagen.
Etiquetar una imagen
Para etiquetar una imagen se usa el comando docker tag. Este toma una imagen que tengamos y la etiqueta a utilizar con ella. Vamos a etiquetar la imagen test-volume con la etiqueta v1:
docker tag test-volume test-volume:v1
Si ahora usamos docker images de nuevo:
REPOSITORY TAG IMAGE ID CREATED SIZE test-volume latest ca13dd99cd29 21 hours ago 122MB test-volume v1 ca13dd99cd29 21 hours ago 122MB ubuntu latest 2d696327ab2e 3 weeks ago 122MB
Observa que ha aparecido una nueva imagen test-volume con la etiqueta v1. Pero fíjate que su id es el mismo que test-volume con etiqueta latest. Eso es porque, realmente, son la misma imagen, no hay ninguna diferencia entre test-volume:latest y test-volume:v1 (recuerda la nomenclatura imagen:etiqueta que usaremos a partir de ahora).
Vamos a modificar el Dockerfile que usamos para crear test-volume y le agregaremos una instrucción ENV para que la imagen sea distinta:
FROM ubuntu RUN mkdir /test-volume VOLUME /test-volume ENV version v2 CMD tail -f /dev/null
Si construyes de nuevo la imagen (docker build . -t test-volume) y ejecutas de nuevo un docker images la salida ahora será parecida a:
REPOSITORY TAG IMAGE ID CREATED SIZE test-volume latest f0c2e88e809a 21 seconds ago 122MB test-volume v1 ca13dd99cd29 21 hours ago 122MB ubuntu latest 2d696327ab2e 3 weeks ago 122MB
Observa como ahora los ids de test-volume:v1 y test-volume:latest son distintos, ya que ahora sí que la imagen es distinta.
Para ejecutar un contenedor de una etiqueta concreta simplemente especifica la etiqueta: docker run test-volume:v1.
Por supuesto podemos especificar una etiqueta al construir una imagen (observa cómo usamos de nuevo la sintaxis nombre:etiqueta):
docker build . -t test-volume:v2
Y ahora la salida de docker images será parecida a:
REPOSITORY TAG IMAGE ID CREATED SIZE test-volume latest f0c2e88e809a 2 minutes ago 122MB test-volume v2 f0c2e88e809a 2 minutes ago 122MB test-volume v1 ca13dd99cd29 21 hours ago 122MB ubuntu latest 2d696327ab2e 3 weeks ago 122MB
El id de test-volume:latest y test-volume:v2 es el mismo ya que, a pesar de que la hemos construido dos veces mediante dos llamadas a docker build, el Dockerfile era idéntico, por lo que la imagen generada también lo es.
Una etiqueta puede “moverse” de imagen, simplemente reetiquetando otra imagen (con otro id) con la etiqueta anterior. Por ejemplo, si hiciéramos docker tag test-volume:latest test-volume:v1 moveríamos la etiqueta v1 de la imagen ca13 a la imagen f0c2.
Borrar una etiqueta
Para borrar una etiqueta se usa docker rmi. Si recuerdas, usábamos rmi para borrar una imagen (usábamos el id como parámetro). Pero recuerda que rmi puede usarse también con el nombre de la imagen:
docker rmi test-volume:v2
Si ahora usamos docker images la salida será parecida a:
REPOSITORY TAG IMAGE ID CREATED SIZE test-volume latest f0c2e88e809a 6 minutes ago 122MB test-volume v1 ca13dd99cd29 21 hours ago 122MB ubuntu latest 2d696327ab2e 3 weeks ago 122MB
Observa como físicamente la imagen f0c2 no se ha borrado. La razón es porque esta imagen tenía dos etiquetas (latest y v2). Al usar docker rmi hemos borrado una etiqueta, pero como quedaba otra la imagen se mantiene. Si ahora usamos rmi, pero borrando test-volume:v1, la imagen ca13 sí que se borra.
Si utilizas docker -f rmi <id-imagen> se borrará la imagen con el id indicado, independientemente del número de etiquetas que tenga. Es importante que uses -f ya que, en caso contrario, si la imagen tiene más de una etiqueta, Docker te dará un error: conflict: unable to delete <id-imagen> (must be forced) - image is referenced in multiple repositories. El parámetro -f precisamente le indica a Docker que borre la imagen (y todas sus etiquetas) aunque haya más de una. Si sólo hubiera una, no es necesario el uso de -f.
Usos de las etiquetas
Las etiquetas se usan para:
- Tener varias versiones de la misma imagen
- Distinguir entre arquitecturas de la misma imagen (veremos eso más adelante)
Buenas prácticas con las etiquetas
Cualquier comando de Docker que acepte un nombre de imagen acepta la sintaxis nombre:etiqueta. Y si no utilizas etiqueta, el comando asume automáticamente que estás indicando la etiqueta latest.
Por lo general, la etiqueta latest contiene la última versión de una imagen y es, por lo tanto, una etiqueta “volátil”, ya que con el tiempo va cambiando la imagen a la que “apunta”. A esas etiquetas que, a medida que pasa el tiempo van cambiando la imagen a la que apuntan, se las conoce como “etiquetas flotantes”.
La etiqueta latest no es la única etiqueta flotante que existe, pero sí la más evidente. De hecho, cualquier etiqueta puede serlo: depende del publicador de la imagen. Aunque no hay “estándar” definido para ello, y depende de las preferencias de cada publicador, en muchos casos se sigue un patrón como el del siguiente ejemplo:
latestapunta a la última versiónv2apunta a la última versión2.x.x.v2.1apunta a la última versión2.1.x.v2.1.3apunta a la versión2.1.3.
En este caso, todas las etiquetas salvo v2.1.3 son técnicamente “etiquetas flotantes” ya que en cualquier momento la imagen subyacente puede cambiar. Así, imagina que la 2.1.3 es la última versión, pero que al cabo de unos días se lanza la 2.1.4. Entonces:
- La etiqueta
v2pasará a apuntar a la imagen de la versión2.1.4 - La etiqueta
v2.1pasará a apuntar a la imagen de la versión2.1.4 - La etiqueta
v2.1.3seguirá apuntando a la imagen de la versión2.1.3 - Se creará una etiqueta nueva
v2.1.4.
Si más adelante, apareciera la versión 2.2.0, entonces la etiqueta v2 pasaría a apuntar a esa nueva imagen.
Pensando en contenedores pensados para producción, te aconsejo que:
- Evites utilizar la etiqueta
latest, ya que esa cambia sin previo aviso y puedes saltar de una versión a otra más nueva que fácilmente podría incluir breaking changes o ¡hasta ser una versión preview!. - Selecciones la “etiqueta flotante” que mejor se adapte a tus necesidades. Por ejemplo, si no te preocupa que la imagen que tienes en producción se actualice de la
2.1.1a la2.1.2puedes usar la etiquetav2.1. Si el publicador utiliza versionamiento semántico, entonces no deberías tener problemas al cambiar a una versión menor posterior. - En muchos casos se suele preferir no usar “etiquetas flotantes” y usar una etiqueta fija, para evitar cualquier posible cambio de versión.
Actualización de una imagen
En el caso de que tengas una imagen referenciada por una “etiqueta flotante” y esa cambie, debes tener presente que Docker no actualizará la imagen de tu máquina automáticamente, debes hacer un docker pull para forzar a Docker a actualizar la imagen local.
Trabajar con repositorios
En esta sección vamos a ver los fundamentos necesarios para trabajar con repositorios de imágenes. Vamos a centrarnos en Docker Hub. Más adelante veremos cómo trabajar con otros repositorios existentes.
Para esta sección es necesario que te registres en Docker Hub. Su uso es gratuito (aunque existe la posibilidad de planes de pago que soportan repositorios privados), pero es necesario tener un usuario para poder subir imágenes.
Subir una imagen a Docker Hub
Antes de subir una imagen vas a necesitar introducir tus credenciales en Docker. Para ello usa el comando docker login, sin ningún parámetro. Este comando te pedirá tu usuario de Docker Hub y tu contraseña.
Para subir una imagen a un repositorio (sea Docker Hub o cualquier otro) se usa el comando docker push. Su sintaxis es muy sencilla:
docker push <nombre-imagen>
Por supuesto, para el nombre de la imagen puedes usar la sintaxis nombre:etiqueta. Si omites la etiqueta, Docker subirá al repositorio la etiqueta latest.
Por ejemplo, podríamos intentar subir la imagen test-volume:latest con el comando docker push test-volume, pero si lo pruebas verás que te da un error:
The push refers to a repository [docker.io/library/test-volume] b34d128dfc84: Preparing 7f7a065d245a: Preparing f96e6b25195f: Preparing c56153825175: Preparing ae620432889d: Preparing a2022691bf95: Waiting denied: requested access to the resource is denied
El error se debe a que el nombre de la imagen juega un papel crucial cuando se quiere subir dicha imagen a un repositorio. En este caso la imagen se llama test-volume y las imágenes sin prefijo solo puede subirlas Docker, ya que son imágenes oficiales. Debemos prefijar la imagen con nuestro nombre de usuario (o la organización a usar).
Para ello usamos el comando docker tag tal como sigue:
docker tag test-volume eiximenis/test-volume
Importante: sustituye eiximenis (que es mi usuario) por tu usuario de DockerHub.
Si ahora miras las imágenes que tienes, verás algo parecido a:
REPOSITORY TAG IMAGE ID CREATED SIZE eiximenis/test-volume latest 6e87f7565f31 26 minutes ago 122MB test-volume latest 6e87f7565f31 26 minutes ago 122MB
La imagen eiximenis/test-volume es realmente la misma imagen (tiene el mismo id), pero ahora tiene el prefijo eiximenis. Eso le indica a Docker que debe subir la imagen en el repositorio de este usuario en Docker Hub. Prueba de nuevo de hacer un docker push, pero recuerda: debes hacer push de la imagen xxxxx/test-volume (donde xxxxx es tu nombre de usuario). La salida ahora debería ser parecida a:
The push refers to a repository [docker.io/eiximenis/test-volume] b34d128dfc84: Pushed 7f7a065d245a: Pushed f96e6b25195f: Pushed c56153825175: Pushed ae620432889d: Pushed a2022691bf95: Pushed latest: digest: sha256:d6f183f04661861d769726986725d74eff4b5ed9632852cc9434e14b1969b3bd size: 1564
¡Felicidades! Has subido tu imagen a Docker Hub.
Descargar una imagen de Docker Hub
Para descargar una imagen simplemente debes hacer docker pull. Por ejemplo si borras la imagen 6e8 (test-volume), la puedes volver a descargar con:
docker pull eiximenis/test-volume
Y si ahora miras las imágenes de nuevo:
REPOSITORY TAG IMAGE ID CREATED SIZE eiximenis/test-volume latest 6e87f7565f31 32 minutes ago 122MB
Observa que la imagen que solo se llamaba test-volume sin el prefijo, no la tienes en este momento. ¿Cómo crees que puedes tener otra vez la imagen con el nombre test-volume sin el prefijo?
docker tag eiximenis/test-volume test-volume
Organizaciones
En DockerHub puedes crear organizaciones. Una organización permite que varios usuarios suban imágenes a ella.
Para crear una organización debes ir a la página de añadir organización en Docker Hub y rellenar los datos necesarios. El primer campo (namespace) es el prefijo de la organización: Para subir imágenes a una organización las imágenes deben tener como prefijo el nombre de la organización (en lugar del nombre de tu usuario de Docker Hub).
La ventaja de usar organizaciones es que es posible colaborar en la gestión de las imágenes en el repositorio.
En Docker Hub los usuarios se tratan como organizaciones con un único miembro.
Usar otros repositorios de imágenes
Para usar otros repositorios, el primer paso es lanzar el comando docker login contra el repositorio deseado:
docker login login-server
El parámetro login-server es la dirección de autenticación del repositorio. Por ejemplo, queremos autenticarnos contra el repositorio Quay el comando que usaremos será docker login quay.io. Docker nos preguntará por nuestras credenciales y si son correctas estaremos autenticado en él.
Luego, al igual que el nombre de la imagen define qué usuario u organización es la responsable de dicha imagen, el repositorio también se incluye como parte del nombre de la imagen. De hecho el nombre de una imagen se compone de 3 partes (dos de ellas opcionales) separadas por /.
Esas 3 partes son:
- El nombre del registro. Es siempre un DNS (por ejemplo,
grc.iooquay.ioomcr.microsoft.com, por poner tres ejemplos). Es opcional y si NO existe se asume “Docker Hub” - El nombre de la organización (por ejemplo,
dockercampusmvp). Es opcional y si NO existe se asume la organización “raíz” del registro. - El nombre de la imagen y OPCIONALMENTE la etiqueta (formato
nombre:etiqueta). Es obligatorio. Si no hay etiqueta se asumelatest.
Algunos ejemplos:
openzipkin/zipkin: El registro es Docker Hub. La organización esopenzipkin. La imagen se llamazipkin:latesteiximenis/testaf: EL registro es Docker Hub. La organización eseiximenis(este soy yo). La imagen se llama
testaf:latestpostgres:14.1: El registro es Docker Hub. La organización es la raíz. La imagen se llamapostgres:14.1quay.io/prometheus/node-exporter: El registro esquay.io. La organización esprometheus. La imagen se llamanode-exporter:latestmcr.microsoft.com/dotnet/runtime:6.0: El registro esmcr.microsoft.com. La organización esdotnet. La imagen se llamaruntime:6.0devadfeda.azurecr.io/rocket: El registro esdevadfeda.azurecr.io. La organización es la raíz. La imagen se llamarocket:latest
Así, simplemente debes usar los comando docker pull o docker push con el nombre de la imagen correspondiente. Por supuesto puedes usar el comando docker tag para asignar el nombre correcto (incluyendo el repositorio) a cualquier imagen que tengas en tu máquina.
Copiar imágenes entre repositorios
Para copiar una imagen de un repositorio a otro repositorio distinto, los pasos a seguir son los siguientes:
- Autenticarse contra ambos repositorios (con
docker login) - Obtener (si no la tienes) la imagen del primer repositorio (
docker pull) - Usar
docker tagpara añadir otro nombre a la misma imagen - Subir la imagen usando el nuevo nombre (con
docker push)
Más allá de Docker
¡No te asustes en ver una lección titulada “más allá de Docker” en el tercer módulo del curso!
Tranquilidad… En este curso nos centramos en Docker (primero) y Kubernetes (después), pero no está de más que conozcas algunas de las alternativas a Docker que se manejan en la actualidad.
Recuerda que a lo que llamamos “Docker” es una combinación de una herramienta CLI (el comando docker), un daemon (o parte servidora) que usualmente se ejecuta en la misma máquina, un motor de contenedores (containerd) y un sistema de construcción de imágenes (BuildKit). El estándar OCI permite una interoperabilidad total entre todas esas piezas de forma que una imagen OCI puede ser ejecutada por cualquier motor de contenedores OCI.
Tanto containerd como BuildKit cumplen con OCI, lo que significa que:
- Cualquier imagen construida con BuildKit se puede usar con cualquier otro motor de contenedores OCI.
- Cualquier imagen OCI se puede ejecutar con containerd (y por lo tanto con
docker run).
En fin, que Docker no es el único “chaval” del barrio. Hay muchos otros proyectos por ahí que cumplen con el estándar OCI, lo cual es estupendo porque esa competencia ayuda a la innovación constante.
Recuerda que cuando hablamos de BuildKit nos referimos a la parte del producto de Docker que se encarga de construir las imágenes. El comando docker build usa BuildKit internamente.
nerdctl
Nerdctl es una herramienta CLI que actúa como cliente de containerd. Por ello, con nerdctl puedes interaccionar con containerd de forma directa, haciendo uso una interfaz con comandos muy parecidos a los que ofrece el comando docker de Docker.
Con nerdctl puedes:
- Construir imágenes OCI, aunque requiere BuildKit para ello.
- Gestionar contenedores
- Gestionar redes
- Ejecutar ficheros de Docker Compose
Además ofrece características adicionales que no existen actualmente en Docker como:
- Lazy pulling, que permite poner en marcha contenedores antes de bajarse toda la imagen.
- Soporte para imágenes encriptadas.
- Soporte para usar protocolos P2P (IPFS) para descargar imágenes.
La motivación de nerdctl es ofrecer una experiencia de uso para aquellas características adicionales de containerd a las que que Docker no saca partido (aunque nada impida que puedan adoptarlo tarde o temprano), manteniendo al mismo tiempo una gran compatibilidad con este.
podman
Podman es otro motor de ejecución de contenedores OCI con una CLI que es muy compatible con Docker. Cuando se dice que la CLI es “compatible” con Docker, nos referimos a que los comandos se llaman igual y aceptan los mismos parámetros (tal y como dicen en su página principal, puedes usar un alias docker=podman y trabajar con podman al igual que lo haces con Docker).
A diferencia de Docker, Podman es capaz de ejecutar directamente ficheros de Kubernetes, aunque eso no significa que Podman sea un orquestador. Simplemente, que nos permite reutilizar (algunos) de los ficheros que se usan para Kubernetes en nuestro ordenador de desarrollo local. También soporta ficheros de Docker Compose para manejar varios contenedores a la vez de forma sencilla.
También es capaz de construir imágenes OCI, aunque en este caso no usa BuildKit sino que usa Buildah, que se trata de un proyecto (totalmente independiente de Docker) para generar imágenes OCI.
Ejercicios propuestos
En este módulo hemos visto cómo crear imágenes usando el archivo Dockerfile. Hemos explorado su sintaxis base y hemos visto algunas de sus sentencias más importantes. También hemos analizado qué es realmente una imagen para Docker y por qué Docker es tan eficiente usando el espacio en disco.
También hemos estudiado cómo crear volúmenes y bind mounts y cuándo usar unos u otros.
Finalmente, hemos visto cómo subir nuestras imágenes a Docker Hub y descargarnos imágenes desde allí.
Con esto finalizamos la primera parte del curso. En esta primera parte hemos aprendido lo básico de Docker y hemos trabajado en escenarios con un solo contenedor. En la segunda parte continuaremos aprendiendo conceptos de Docker a la vez que veremos cómo gestionar escenarios con más de un contenedor.
A continuación tienes algunos ejemplos para practicar:
Preparación inicial del ejercicio
- Crea un directorio vacío en tu máquina
- Añade un fichero
hello.txten este directorio
Todos los ejercicios se realizarán en este directorio que acabas de crear (este será el “directorio local del host”).
Ejercicio 1
En este directorio crea una imagen de Docker que haga lo siguiente
- Herede de la imagen base de Ubuntu
- Cree un directorio en el contenedor que se llame
/test - Copie el contenido del directorio local del host al directorio
/testdel contenedor - El contenedor se quede esperando “eternamente”
Ejercicio 2
Crea un contenedor de esta imagen:
- Abre una sesión interactiva con el contenedor
- Lista el contenido de
/test. Debería aparecer el archivohello.txt - Crea un fichero adicional en
/test - Detén el contenedor, no lo borres
- Crea otro contenedor de la misma imagen y abre una sesión interactiva con él
- Ve al directorio
/test. ¿Aparece el fichero adicional que has creado? ¿Por qué? - Para el contenedor y bórralo
- Vuelve a iniciar el contenedor anterior y abre una sesión interactiva con él
- Ve al directorio
/test. ¿Aparece el fichero adicional que has creado? ¿Por qué?
Ejercicio 3
- Etiqueta la imagen con “v1”
- Modifica el
Dockerfilepara usar un volumen en lugar de copiar el contenido de la carpeta del host y recrea la imagen con la etiqueta “v2”- Crea un volumen con un nombre (por ejemplo
my-vol) - Averigua cuál es el directorio del host que usa este volumen
- Pon en marcha un contenedor usando este volumen (móntalo en el directorio
/testdel contenedor) - Abre una sesión interactiva en este contenedor
- Crea un fichero en el directorio
/test - Pon en marcha otro contenedor con la misma imagen y el mismo volumen y abre una sesión interactiva
- Ve al directorio
/test. ¿Aparece el fichero adicional que has creado? ¿Por qué? - Detén ambos contenedores y bórralos
- Pon en marcha otro contenedor usando este mismo volumen (montado también contra
/test) y abre una sesión interactiva - Ve al directorio
/test. ¿Aparece el fichero adicional que has creado? ¿Por qué? - Para el contenedor y bórralo
- ¿Cómo mirarías cuál es el directorio del host al cual está mapeado este volumen?
- [Si estás en Linux]: navega a este directorio del host. ¿Aparece el fichero que has creado desde el contenedor? ¿Por qué?
- Borra el volumen
A continuación encontrarás sendos vídeos con la realización paso a paso de lo que se te pide en los ejercicios anteriores. Te la dejamos por si te atascas en algún paso o tienes alguna dificultad, pero deberías poder realizar los ejercicios por tu cuenta sin necesidad de mirarlos.
Repasa lo necesario de este módulo para ser capaz de realizar todas esas tareas con fluidez. No pases al siguiente tema hasta que lo consigas sin tener que pensarlo.
Imágenes multiplataforma
Esta lección y la siguiente son un poco complejas, e incluso albergaba ciertas dudas sobre si debía incorporarlas al curso y en cuál de los módulos hacerlo. Finalmente, creo que son lo suficientemente interesantes como para incluirlas y, dado que tienen que ver con la construcción de imágenes, este me parece el mejor punto para hacerlo. Si te resultan demasiado complejas las puedes dejar para más adelante cuando tengas los conceptos de Docker más claros. No te preocupes: no habrá preguntas sobre el tema en ninguna de las evaluaciones.
Recuerda: Docker no virtualiza. Eso significa que sólo podemos ejecutar binarios de la plataforma (o arquitectura) de la cual sea nuestra máquina (en Windows y Mac, Docker Desktop ejecuta los contenedores en un sistema virtualizado que ejecuta Linux. Es gracias a ese sistema virtualizado que podemos ejecutar contenedores Linux en estos sistemas).
Por lo tanto eso significa que, cuando se crea una imagen de contenedor, ésta tiene asociada una plataforma. Es decir, tendremos distintas imágenes en función de qué plataforma se utilizó para crear la imagen.
El comando docker build crea la imagen en la plataforma correspondiente a la del host. De nuevo recuerda que si usas Docker Desktop en Windows o Mac el host es realmente Linux, debido al entorno virtualizado.
La plataforma más común para contenedores es Linux ejecutándose en procesadores con arquitectura x64, pero obviamente no es la única.
Un listado de las más comunes sería:
amd64: Linux funcionando en procesadores x64arm32v6: Linux funcionando en procesadores ARMv6 32 bitarm32v7: Linux funcionando en procesadores ARMv7 32 bitarm64v8: Linux funcionando en procesadores ARMv8 64 bitwindows-amd64: Windows funcionando en procesadores x64
Imágenes multi-arquitectura (o multi-plataforma)
Una imagen de Docker (o imagen OCI que es lo mismo) está asociada a una única plataforma, ya que en el fondo es poco más que un sistema de ficheros y metadatos de configuración. Eso significa que una misma imagen no puede ser de la plataforma amd64 y arm32v6 a la vez: debe haber dos imágenes distintas.
Aprovecho para recalcar que a pesar de que en el curso (y en mucha literatura que puedes encontrar) se hable de “imágenes Docker”, realmente nos estamos refiriendo a imágenes OCI.
Inicialmente para distinguir entre plataformas para una “misma” imagen, se creaban imágenes distintas y se usaban etiquetas (tags) específicas para cada plataforma. Así podías tener myimagen:amd64 para la arquitectura amd64 y myimagen:arm32v6 para la arquitectura arm32v6, etc. Por supuesto, no había obligación de que el nombre de la etiqueta coincidiera con el nombre de la plataforma específica, aunque todo publicador responsable lo hacía así.
El problema de esa aproximación es que el nombre de la plataforma se filtraba al nombre de la imagen. Es decir, ya no se podía ejecutar simplemente el comando docker run myimagen, ya que myimagen, a secas, no existía.
Una solución era que la etiqueta latest apuntase a una de las dos imágenes en concreto. Por ejemplo, se podía establecer que myimagen:latest apuntase a myimagen:amd64 que se considera la arquitectura “habitual”. Eso permite ejecutar docker run myimagen desde un Linux ejecutándose en x64 y todo funcionaría correctamente. Pero si intentáramos ejecutar el comando docker run myimagen desde un Linux ejecutándose en un ARM recibiríamos un error.
Si intentas crear contenedores a partir de una imagen con arquitectura distinta a la del host recibirás, como es lógico, un error:
En la anterior captura puedes ver qué ocurre cuando se intenta ejecutar un contenedor de una arquitectura distinta a la del host: la imagen se descarga, pero el contenedor no se puede crear.
Al menos eso es lo que ocurre si usas Docker Engine. Si usas Docker Desktop (y eso incluye a Windows y macOS) las cosas son un poco distintas: en este caso recibirás el aviso, pero de todos modos vas a poder ejecutar el contenedor, ya que Docker Desktop es capaz de virtualizarlo de modo transparente (usa QEMU para ello).
Cuando se empezó a popularizar el uso de otras plataformas más allá de amd64, Docker buscó una solución para evitar que las imágenes tuvieran que tener nombres distintos por plataforma. Así nació el concepto de “imagen multi-arquitectura”, que no es más que un mecanismo mediante el cual distintas imágenes de distintas plataformas pueden compartir nombre (y tag). En este caso Docker elige siempre, de manera automática, la imagen adecuada para la plataforma bajo la que se ejcuta el host.
Muchas de las imágenes actuales son multi-arquitectura y el propio Docker Hub te da información de que plataformas soporta cada una de ellas:
En la captura anterior puedes ver cómo Docker Hub nos lista todas las plataformas para las que está disponible la imagen con la etiqueta latest.
Eso significa que en cualquiera de esas plataformas, podemos ejecutar el comando docker run node:latest y Docker eligirá la imagen correspondiente a nuestra plataforma (observa que tienen valores distintos de DIGEST ya que son a todos los efectos imágenes distintas).
Puedes usar el comando docker manifest inspect <nombre-imagen> para ver todas las plataformas disponibles de una imagen desde la CLI.
Construcción de imágenes multi-arquitectura
Inicialmente, la construcción de imágenes multi-arquitectura era un proceso bastante complejo que requería ejecutar varios comandos docker build en distintos hosts (uno por cada arquitectura) y luego usar el comando experimental docker manifest para subir esas distintas imágenes como una sola imagen multi-arquitectura en Docker Hub.
Por suerte, en la actualidad existe un comando específico que simplifica muchísimo la creación de imágenes multi-arquitectura. No sólo permite construir imágenes para otras plataformas (es decir, por ejemplo, construir una imagen de ARM en un host ejecutando x64) sino también publicarlas de forma sencilla en Docker Hub.
Este comando es docker buildx, y vamos a estudiarlo a continuación…
Uso de Docker buildx
El comando docker buildx es el comando que abre las puertas a las características avanzadas de BuildKit. Así, en lugar de añadir complejidad al comando docker build, el equipo de Docker prefirió crear un comando nuevo y específico que permite usar todas las características avanzadas de de BuildKit. De este modo el comando docker build no se complica y además mantiene total compatibilidad hacia atrás.
El comando docker buildx ofrece multitud de características avanzadas, algunas de las cuales se discuten en esta lección.
Crear imágenes para otras plataformas
El comando docker buildx nos permite crear imágenes para otras plataformas. Por supuesto, para que ello sea posible es necesario que las imágenes base que usemos existan en estas otras plataformas.
Para poder construir para una plataforma específica, el host debe soportarlo. Este soporte se añade instalando componentes específicos llamados builders. Así el comando docker buildx podrá construir imágenes para una plataforma en concreto siempre y cuando exista en el host un builder para la plataforma concreta que nos interese.
Un builder de docker buildx representa un entorno aislado en el cual podemos construir imágenes para una o más plataformas. Yendo un poco más al detalle de cómo trabajan, un builder hace uso de una o más instancias de BuildKit (cada instancia se denomina “nodo”). Cuando creamos un builder podemos indicar qué instancia de BuildKit se debe usar:
- El BuildKit que viene por defecto con Docker (driver
docker) - Un BuildKit ejecutándose en remoto (driver
remote) - Un contenedor ejecutando BuildKit (driver
docker-container) - Un contenedor de BuildKit pero manejado por Kubernetes (driver
kubernetes)
No podemos crear manualmente builders que usen el driver docker. Esos se crean automáticamente por el sistema. Además son los que tienen menos funcionalidades. Para cada contexto de Docker que tengas (más adelante en el curso hay un módulo dedicado a ver bien estos contextos) existirá un builder de driver docker que usará el BuildKit asociado a ese contexto.
El comando docker buildx ls te muestra los builders instalados en tu sistema y las instancias de BuildKit asociada a cada builder:
El comando nos indica que tenemos dos builders:
- El builder llamado
defaultcuyo driver esdocker(columna DRIVER). Este builder tiene una sola instancia de BuildKit:- La instancia se llama
defaulty este BuildKit está en el endpoint (columna ENDPOINT)default. Es decir se trata del BuildKit que viene por defecto con Docker.
- El builder llamado
docker-linuxcuyo driver es tambiéndocker. Este builder tiene también una sola instancia de BuildKit:- La instancia se llama
desktop-linuxy el endpoint esdesktop-linux. Se trata del BuildKit que incorpora Docker Desktop.
El valor de ENDPOINT indica dónde está ubicado el BuildKit asociado. Puede ser, o bien el nombre de un contexto de Docker (tal como desktop-linux o default) y entonces significa “usar el BuildKit asociado a ese entorno”, o puede ser una ubicación remota.
La construcción de una imagen de contenedor implica la creación de contenedores temporales que van ejecutando las sentencias del Dockerfile. Por lo tanto para crear una imagen de ARM, pongamos de ejemplo, Docker deberá crear contenedores temporales de ARM. ¿Cómo los puede crear en una plataforma x64, como es el caso de este ejemplo? Pues la respuesta a esa pregunta es que Docker utiliza internamente QEMU para emular las distintas arquitecturas. Observa que, de los dos builders que hay, sólo el de Docker Desktop puede construir para plataformas ARM, ya que usa técnicas de virtualización.
Para seleccionar una plataforma de entre todas las admitidas, usamos el modificador --platform del comando docker buildx build. El comando docker buildx build espera un Dockerfile y es equivalente al comando docker build.
Recuerda que el comando docker build también usa BuildKit (a no ser que uses una versión antigua de Docker o bien contenedores Windows). La diferencia es que el comando docker build no te da acceso a las características avanzadas de BuildKit como la creación de imágenes para otras plataformas
Builders de docker buildx
Un builder de docker buildx no es más que una instancia de BuildKit. Por defecto Docker Engine (recuerda, sólo disponible en Linux) tiene su propia instancia de BuildKit, por lo que es automáticamente un builder válido para docker buildx. Lo mismo ocurre con Docker Desktop, que incluye su propia instancia de BuildKit. Por lo tanto, como hemos visto, en un sistema Linux que tenga instalados a la vez Docker Engine y Docker Desktop, tendrás dos builders disponibles.
Una de las grandes ventajas de BuildKit es que está “desacoplado” del resto de Docker, lo que permite ejecutarlo en otros contextos. Así, por ejemplo, sería posible crear un contenedor que ejecutase una instancia adicional de BuildKit y tendríamos otro builder válido. Más adelante vas a ver como podemos crear builders adicionales.
Ejemplo de creación de imágenes multi-plataforma
Para hacer este ejercicio desde un ordenador Linux debes tener Docker Desktop instalado en él. No vas a poder realizarlo si sólo tienes Docker Engine. El motivo es que el builder que viene con Docker Engine no puede construir para otras plataformas que no sean la del host.
Vamos a ver un ejemplo paso a paso de crear una imagen multi-plataforma usando docker buildx. Recuerda que des muy importante que exista la imagen base para la plataforma de destino. Lo más sencillo es usar imágenes base que ya sean multiplataforma así, se elegirá la correcta de manera automática.
Para ello crea una carpeta vacía en tu ordenador y crea un fichero app.mjs con el siguiente código de Node.js:
import os from 'os' import process from 'process' console.log(`${os.type()} on ${process.arch}`)
Luego añade un Dockerfile con ese contenido:
FROM node:20 WORKDIR /app COPY . . CMD node /app/app.mjs
Ahora vamos a construir esa imagen, pero no usaremos docker build sino docker buildx build. Y vamos a construirla para dos plataformas: x64 y ARM.
Primero vamos a generar la imagen para x64:
docker buildx build --platform linux/arm/v7 -t demo:arm .
Una vez hayas construido la imagen puedes probar a ejecutarla usando docker run demo:arm. En este punto, en función de si usas Docker Engine o Docker Desktop podrás ejecutar o no un contenedor de esa imagen. Si usas Docker Engine recibirás un error:
Mientras que si usas Docker Desktop, como soporta virtualización, podrás ejecutar el contenedor:
En realidad, en Linux es posible conseguir el mismo resultado (ejecutar la imagen de otra arquitectura), sin necesidad de usar Docker Desktop (con tan solo Docker Engine). Pero se trata de un aspecto avanzado que se escapa de este curso. Si quieres tener una idea de cómo lo hace, lo principal es saber que Docker usa QEMU para emular y binfmt_misc para el soporte de multi-arquitectura (Docker Desktop hace lo mismo, pero lo configura todo de forma automática y transparente para nosotros). Para más información sobre binfmt_misc puedes consultar esta página y sobre cómo Docker Engine hace uso de él y de QEMU para poder ejecutar aplicaciones para otras plataformas tienes información en este post y en la documentación de Docker.
Vamos a construir ahora para la plataforma del host:
docker buildx build -t demo:x64 .
La salida con Docker Engine es:
Además puedes comparar la salida del comando docker images de ambas imágenes y verás que el id y el tamaño de ambas imágenes son distintas, a pesar de que las has generado con el mismo Dockerfile (lo que es lógico ya que la imagen base es distinta por cada plataforma):
Ejecutar imágenes de una plataforma en concreto
Las reglas que sigue Docker son:
- Si la imagen NO la tienes en local, se descargará la imagen de la misma plataforma que el host.
- Si la imagen la tienes en local, usará la imagen que tengas en local. Esta puede ser o no de la misma plataforma que el host.
- Si la imagen es de la misma plataforma que el host, se ejecuta sin más
- Si la imagen es de otra plataforma y tu docker soporta virtualización (Docker Desktop), docker te va a emitir un aviso, pero podrás ejecutar la imagen (si la arquitectura de ésta está soportada por el sistema de virtualización).
Un ejemplo rápido: utilizando Docker Desktop, ejecuto node:20:
El comando docker run ha ejecutado node:20 de la plataforma x64, ya que mi host es de esa plataforma y la imagen node:20 existe para esa plataforma. Pero si recuerdas de antes, el comando docker run demo:arm ejecutaba la versión de arm, ya que la imagen demo:arm solo existe en ARM (la versión x64 es demo:x64 que es un nombre distinto).
Ahora bien puedes forzar a docker a intentar ejecutar la versión de la imagen de una plataforma en concreto usando el modificador --platform de docker run:
Este mismo comando docker run dará error si usas Docker Engine en lugar de Docker Desktop:
Para cambiar entre Docker Desktop y Docker Engine, puedes usar el comando docker context use default para usar Docker Engine y docker context use desktop-linux para usar Docker Desktop. Por supuesto esto es solo en el caso que tengas ambos instalados. Recuerda que Docker Engine y Docker Desktop no comparten las imágenes ni los contenedores. Docker Engine y Docker Desktop se exponen como dos contextos separados. En el módulo “Contextos en Docker” se habla con más detalle sobre el concepto de “contexto”.
Publicar imágenes multi-arquitectura
Antes hemos construido dos imágenes demo:x64 y demo:arm para dos arquitecturas distintas. Está bien, pero no es todavía una imagen multi-arquitectura. Vamos a ver, ahora sí, cómo construirla.
Para ello vamos a usar el mismo Dockerfile y fichero app.mjs que teníamos. El comando es el siguiente:
docker buildx build --platform linux/amd64,linux/arm/v7 -t demo:multiarch .
¡Ojo!: no pongas un espacio entre la coma que separa las plataformas.
Pero este comando no te funcionará directamente:
Eso es porque el builder por defecto no se puede ejecutar para más de una plataforma a la vez. Por ello debemos crear un builder nuevo:
docker buildx create --name multibuilder --driver docker-container --bootstrap
Este comando crea un builder nuevo. La opción --driver docker-container lo que hace es indicar que este builder no será un deamon de Docker tradicional, sino un contenedor de buildkit. Eso es requerido para construir imágenes multiplataforma.
Una vez creado te debería aparecer con el comando docker buildx builder ls:
Perfecto, ahora vamos a seleccionar este nuevo builder que hemos llamado multibuilder:
docker buildx use multibuilder
El comando docker buildx use <builder> te permite seleccionar el builder a usar. No te confundas con el comando docker context use que te permite seleccionar qué contexto de Docker (es decir qué daemon de Docker) se usa al ejecutar contenedores.
Y ahora ya puedes repetir el comando docker build. El problema es que como BuildKit se ejecuta en un contenedor, el resultado de las imágenes se quedará en el contenedor. Para evitar eso podemos usar el modificador --push que hará un docker push al final y subirá las imágenes a un registro de imágenes. Pero recuerda que para ello las imágenes deben tener el nombre correcto. En mi caso las subiré a dockercampusmvp:
docker buildx build --load --platform linux/amd64,linux/arm/v7 -t dockercampusmvp/demo:multiarch . --push
El modificador --push hace que la imagen se suba al registro de Docker. Asegúrate de haber hecho docker login antes y de que tengas los permisos correspondientes. Recuerda que el nombre de la imagen indica en qué usuario/organización se sube la imagen.
Al cabo de un rato la imagen estará en Docker Hub:
Ya has construido una imagen multi-arquitectura, de forma sencilla gracias a docker buildx. Créeme si te digo que antes de docker buildx el proceso era mucho más complejo y manual…
El comando docker system
El comando docker system es útil para manejar ciertos aspectos de Docker relacionados en como éste usa nuestro sistema. No es un comando que vayas a usar continuamente, ni mucho menos, pero está bien aprender sus usos más habituales.
Espacio ocupado en disco
Si usas docker system df, Docker te indicará cuánto espacio en disco se está ocupando. Por ejemplo, este es el aspecto de la salida de este comando en mi máquina:
TYPE TOTAL ACTIVE SIZE RECLAIMABLE Images 143 8 78.56GB 72.1GB (91%) Containers 13 6 3.483MB 3.153MB (90%) Local Volumes 400 4 75.49GB 30.87GB (40%) Build Cache 307 0 14.04GB 14.04GB
Me indica el espacio que tengo ocupado en imágenes, contenedores, volúmenes y la cache de build (un concepto que veremos más adelante).
La columna RECLAIMABLE me dice la cantidad de espacio que podría recuperar. Eso significa que es espacio ocupado pero que no estoy utilizando ya (por ejemplo, con volúmenes que ya no están siendo utilizados por ningún contenedor, imágenes sin contenedores, contenedores parados, etc).
Si usas el modificador -v te sacará más información (nombres de imágenes, contenedores, etc)
En mi caso puedes ver que tengo bastante espacio ocupado… ¡quizá ha llegado la hora de hacer limpieza!
Hacer limpieza
El comando docker system prune es el encargado de hacer la “limpieza general”. Está bien usarlo de vez en cuando, pero ten en cuenta las implicaciones:
- Borrará las dangling images
- Eliminará los contenedores parados
- Eliminará las redes no utilizadas (más adelante hablamos del redes en Docker y como crearlas)
Después de ejecutarlo, vuelvo a obtener la información con docker system df, y me devuelve estos resultados:
TYPE TOTAL ACTIVE SIZE RECLAIMABLE Images 82 3 51.8GB 47.12GB (90%) Containers 6 6 330.2kB 0B (0%) Local Volumes 400 1 75.49GB 32.46GB (42%) Build Cache 82 0 0B 0B
Puedes ver que no he recuperado todo el espacio marcado como RECLAIMABLE. Eso es porque system prune es bastante conservador. Por ejemplo, de las imágenes sólo me borra las dangling images, no todas las que ya no están en uso.
Si quieres recuperar más espacio:
- Puedes emplear el modificador
-apara eliminar todas las imágenes utilizadas (no sólo las dangling). - Con el modificador
--volumes, eliminarás también los volúmenes no utilizados.
¡MUCHO OJO!: este último modificador puede implicar la pérdida de datos, ya que es muy habitual tener un volumen con datos persistentes pero no tener un contenedor que lo esté usando en un momento dado). Tenlo muy en cuenta antes de usarlo.
