Menú de navegaciónMenú
Categorías

La mejor forma de Aprender Programación online y en español www.campusmvp.es

?id=ba2d3095-c43e-429d-89ea-996a7f3d1773

Mejores prácticas para crear Dockerfiles excelentes

Nota: este artículo es una traducción del artículo original, de Jakub Skalecki, y ha sido traducido con su permiso expreso. Puedes seguir a Jakub en Twitter y en LinkedIn. Debes saber también que tenemos disponible un magnífico curso de Docker y Kubernetes en nuestro catálogo, por si te interesa aprender la herramienta a fondo.

Imagen ornamental - Operando con contenedores en un puerto - (c) José M. Alarcón 2018

¡Hola! Hace tiempo que trabajo con Docker. La creación de Dockerfiles es una parte esencial del proceso, y quería compartir algunos consejos sobre cómo mejorarlo.

Los objetivos:

  • Queremos minimizar el tamaño de la imagen, el tiempo de compilación y el número de capas.
  • Queremos maximizar el uso de la caché de compilación y la legibilidad del Dockerfile.
  • Queremos que trabajar con nuestro contenedor sea lo más agradable posible.

TL;DR - Para los que tienen prisa

Este artículo está lleno de ejemplos y descripciones detalladas, así que, aquí tienes un resumen rápido:

  • Escribir archivo .dockerignore
  • El contenedor debe hacer una sola cosa
  • ¡Debemos entender el almacenamiento en caché de Docker! Usa los comandos COPY y RUN en el orden correcto a la hora de utilizarlo.
  • Fusionar varios comandos RUN en uno solo.
  • Eliminar archivos innecesarios después de cada paso.
  • Usar una imagen base adecuada (las versiones Alpine deberían ser suficientes)
  • Configurar WORKDIR y CMD
  • Utilizar ENTRYPOINT cuando se tenga más de un comando y/o se necesite actualizar archivos usando datos en tiempo de ejecución.
  • Usar exec dentro del script del punto de entrada.
  • Mejor usar COPY que ADD
  • Especificar variables de entorno, puertos y volúmenes predeterminados dentro de Dockerfile

Ejemplo práctico

Así que, acabas de leer mis consejos. ¡Todo genial...! Pero te preguntarás, ¿cómo introducirlos en mis Dockerfiles y en qué se notará el hacerlo?

He preparado un pequeño archivo Dockerfile, con casi todos los posibles errores que se me ocurren. Ahora lo arreglaremos. Supongamos que queremos dockerizar una pequeña aplicación web Node.js. Aquí está (es complicada y probablemente no funcione, pero es tan sólo un ejemplo):

FROM ubuntu 

ADD . /app 

RUN apt-get update RUN apt-get upgrade -y 
RUN apt-get install -y nodejs ssh mysql 
RUN cd /app && npm install 

# esto debería iniciar tres procesos, mysql y ssh 
# en segundo plano y node app en primer plano 
# no es tremendamente bonito? <3 
CMD mysql & sshd & npm start

Podríamos construirlo usando docker build -t wtf.

¿Puedes detectar todos los errores que tiene? ¿No? Vamos a arreglarlos juntos, uno por uno.

1. Escribir .dockerignore

Al crear una imagen, Docker debe preparar el context antes de nada: recopilar todos los archivos que se pueden utilizar en un proceso. El contexto predeterminado contiene todos los archivos de un directorio Dockerfile. Normalmente no queremos incluir ahí el directorio .git, las bibliotecas descargadas ni los archivos compilados. El archivo .dockerignore es exactamente igual a .gitignore, por ejemplo:

.git/ 
node_modules/ 
dist/

2. El contenedor debe hacer una sola cosa

Técnicamente, PUEDES iniciar múltiples procesos dentro de un contenedor Docker. PUEDES poner aplicaciones de base de datos, frontend y backend, ssh, y supervisor en una imagen de docker. Pero muchas cosas no te irán bien:

  • Los tiempos de compilación serán largos (un cambio en, por ejemplo, el frontend te obligará a volver a compilar todo el backend)
  • Las imágenes serán muy grandes
  • Tendrás un registro duro de datos desde muchas aplicaciones (no un simple stdout)
  • Escalado horizontal innecesario
  • Problemas con los procesos "zombies" - Tendrás que acordarte de cuál es el proceso init adecuado

Mi consejo es que prepares una imagen Docker separada para cada componente y que utilices Docker Compose para iniciar fácilmente varios contenedores al mismo tiempo.

Eliminemos los paquetes innecesarios de nuestro Dockerfile. SSH puede ser reemplazado por docker exec.

FROM ubuntu 

ADD . /app 

RUN apt-get update
RUN apt-get upgrade -y
  
# deberíamos quitar ssh y mysql, y usar 
# contenedor separado para la base de datos  
RUN apt-get install -y nodejs # ssh mysql 
RUN cd /app && npm install 

CMD npm start

3. Fusionar varios comandos RUN en uno solo

Docker está totalmente basado en capas. El conocimiento de cómo funcionan es esencial.

  • Cada comando en Dockerfile crea una capa.
  • Las capas se almacenan en caché y se reutilizan.
  • Invalidar la caché de una sola capa invalida todas las capas subsiguientes.
  • La invalidación ocurre después de un cambio de comando, si los archivos copiados son diferentes, o si la variable de compilación es diferente a la anterior.
  • Las capas son inmutables, así que, si añadimos un archivo a una capa y lo eliminamos en la siguiente, la imagen TODAVÍA contiene ese archivo (¡simplemente no estará disponible en el contenedor!)

Me gusta comparar las imágenes de Docker con una cebolla:

Imagen de una cebolla - Dominio Público

Las dos te hacen llorar... esto no :) Ambas tienen capas. Para acceder y modificar la capa interna, debes eliminar todas las anteriores. Recuerda esto y todo irá bien.

Vamos a optimizar nuestro ejemplo. Estamos fusionando todos los comandos RUN en uno, y eliminando la actualización apt-get, ya que hace que nuestra compilación no sea determinista (dependemos de las actualizaciones de nuestra imagen base):

FROM ubuntu 

ADD . /app 

RUN apt-get update \ 
    && apt-get install -y nodejs \ 
    && cd /app \ 
	&& npm install 
	
CMD npm start

Se debe tener en cuenta que se deben fusionar comandos que tienen una probabilidad similar de ser modificados o de sufrir cambios. Actualmente, cada vez que nuestro código fuente se modifique, necesitamos reinstalar Node.js por completo. Así que, una opción mejor es:

FROM ubuntu 

RUN apt-get update && apt-get install -y nodejs 
ADD . /app 
RUN cd /app && npm install 

CMD npm start

4. No utilices la etiqueta de imagen base latest

La etiqueta latest es la que se utiliza por defecto, cuando no se especifica ninguna otra etiqueta. Así que nuestra instrucción FROM ubuntu en realidad hace exactamente lo mismo que FROM ubuntu:latest. Pero la etiqueta latest apuntará a una imagen diferente cuando se publique una nueva versión, y tu build puede romperse. Por lo tanto, a menos que estés creando un Dockerfile genérico que tengas que estar actualizado con la imagen base, deberás proporcionar una etiqueta específica.

En nuestro ejemplo, usemos la etiqueta 16.04:

FROM ubuntu:16.04 # ¡es así de fácil! 

RUN apt-get update && apt-get install -y nodejs 
ADD . /app 
RUN cd /app && npm install 

CMD npm start

5. Eliminar archivos innecesarios después de cada paso de RUN

Por lo tanto, supongamos que hemos actualizado las fuentes apt-get, instalado algunos paquetes necesarios para compilar otros, y hemos descargado y extraído los archivos. Obviamente no los necesitamos en nuestras imágenes finales, así que, mejor hagamos una limpieza. ¡El tamaño importa!

En nuestro ejemplo podemos eliminar las listas apt-get (creadas por apt-get update):

FROM ubuntu:16.04
 
RUN apt-get update \ 
	&& apt-get install -y nodejs \ 
    # líneas añadidas 
    && rm -rf /var/lib/apt/lists/* 
    
ADD . /app 
RUN cd /app && npm install

CMD npm start

6. Usar una imagen base adecuada

En nuestro ejemplo estamos usando ubuntu. Pero, ¿por qué? ¿Realmente necesitamos una imagen de base de propósito general, cuando sólo queremos ejecutar una aplicación de Node.js? Una mejor opción es usar una imagen especializada con Node.js ya instalado:

FROM node 

ADD . /app 

# ya no se necesita instalar
# node ni usar apt-get 
RUN cd /app && npm install 

CMD npm start

O mejor aún, podemos elegir la versión Alpine (Alpine es una distribución Linux muy pequeña, de unos 4 MB de tamaño. Esto lo convierte en el candidato perfecto para una imagen base)

FROM node:7-alpine 

ADD . /app 
RUN cd /app  && npm install 

CMD npm start

Alpine tiene un gestor de paquetes, llamado apk. Es un poco diferente a apt-get, pero aún así es bastante fácil de aprender. Además, tiene algunas características realmente útiles, como las opciones --o-cache y --virtual. De esta manera, elegimos exactamente lo que queremos en nuestra imagen, nada más. Tu disco te va a amar :)

7. Configurar WORKDIR y CMD

El comando WORKDIR cambia el directorio por defecto, donde ejecutamos nuestros comandos RUN / CMD / ENTRYPOINT.

CMD es una ejecución de comandos por defecto después de crear un contenedor sin otro comando especificado. Por lo general, es la acción que se realiza con más frecuencia. Añadámoslos a nuestro Dockerfile:

FROM node:7-alpine 

WORKDIR /app 
ADD . /app 
RUN npm install 

CMD ["npm", "start"]

Debes poner tu comando dentro de la matriz, una palabra por elemento (más en la documentación oficial)

8. Usa ENTRYPOINT (opcional)

No siempre es necesario, es opcional, ya que Entrypoint, o punto de entrada, añade complejidad. ¿Cómo funciona este sistema?

Entrypoint es un script, que se ejecutará en lugar de un determinado comando, y recibirá éste como un argumento. Es una excelente forma de crear imágenes ejecutables Docker:

#!/usr/bin/env sh
# $0 is a script name, 
# $1, $2, $3 etc are passed arguments  # $1 is our command 
CMD=$1
  
case "$CMD" in  
    "dev" ) 
		npm install 
		export NODE_ENV=development 
		exec npm run dev 
		;;
		
	"start" ) 
	# we can modify files here, using ENV variables passed in 
	# "docker create" command. It can't be done during build process.  
	echo  "db: $DATABASE_ADDRESS" >> /app/config.yml 
	export NODE_ENV=production 
	exec npm start 
	;;

	* ) 
	 # Run custom command. Thanks to this line we can still use 
	 # "docker run our_image /bin/bash" and it will work  
	 exec $CMD ${@:2} 
	 ;; 
esac

Guárdalo en tu directorio raíz, con el nombre entrypoint.sh. Así es su uso en Dockerfile:

FROM node:7-alpine 

WORKDIR /app 
ADD . /app 
RUN npm install 

ENTRYPOINT ["./entrypoint.sh"] 
CMD ["start"]

Ahora, valga la redundancia, podemos ejecutar esta imagen en un formato ejecutable:

docker run nuestra-app dev
docker run nuestra-app start

docker run -it nuestra-app /bin/bash` # Este también funcionará.

9. Usar exec dentro del script entrypoint

Como puedes ver en el ejemplo de entrypoint, estamos usando exec. Sin él, no podríamos detener nuestra aplicación de forma elegante (SIGTERM es engullido por el script bash). Exec básicamente reemplaza el proceso de script con uno nuevo, por lo que todas las señales y códigos de salida funcionan como se había previsto.

10. Mejor COPY que ADD

COPY es más sencillo. ADD tiene cierta lógica para descargar archivos remotos y extraer archivos (más en la documentación oficial). Sólo tienes que quedarte con COPY.

Nota: este punto necesita una explicación adicional. ADD puede ser útil si la compilación depende de recursos externos y quieres que la invalidación de la caché de compilación sea la adecuada en caso de cambio. No es una buena práctica, pero a veces es la única manera de hacerlo.

Vamos a AÑADIR... uups, COPIAR esto en nuestro ejemplo:

FROM node:7-alpine 

WORKDIR /app 

COPY . /app 
RUN npm install 

ENTRYPOINT ["./entrypoint.sh"] 
CMD ["start"]

11. Optimizar COPY y RUN

Deberíamos poner los cambios que se producen con menor frecuencia en la parte superior de nuestros Dockerfiles para aprovechar el almacenamiento en caché.

En nuestro ejemplo, el código cambiará a menudo, y no queremos reinstalar paquetes cada vez. Podemos copiar el package.json antes del resto del código, instalar dependencias y luego añadir otros archivos. Apliquemos esa mejora a nuestro Dockerfile:

FROM node:7-alpine 

WORKDIR /app 

COPY package.json /app 
RUN npm install 
COPY . /app 

ENTRYPOINT ["./entrypoint.sh"] 
CMD ["start"]

12. Especificar variables de entorno, puertos y volúmenes predeterminados

Probablemente necesitemos algunas variables de entorno para ejecutar nuestro contenedor. Es una buena práctica establecer valores predeterminados en Dockerfile. Además, debemos mostrar todos los puertos utilizados y definir los volúmenes.

Veamos la siguiente mejora aplicada a nuestro ejemplo:

FROM node:7-alpine 

# env variables required during build 
ENV PROJECT_DIR=/app 

WORKDIR $PROJECT_DIR 

COPY package.json $PROJECT_DIR 
RUN npm install 
COPY . $PROJECT_DIR
 
# env variables that can change  
# volume and port settings  
# and defaults for our application 
ENV MEDIA_DIR=/media \ 
	NODE_ENV=production \ 
	APP_PORT=3000 

VOLUME $MEDIA_DIR 
EXPOSE $APP_PORT 

ENTRYPOINT ["./entrypoint.sh"] 
CMD ["start"]

Estas variables estarán disponibles en el contenedor. Si necesitas variables de compilación solamente, usa build args en su lugar.

13. Añadir metadatos a la imagen usando LABEL

Hay una opción para añadir metadatos a la imagen, como información sobre quién es el encargado de mantenerla o una descripción ampliada. Necesitamos la instrucción LABEL para ello (antes podíamos usar la opción MAINTAINER, pero ahora está obsoleta). Los metadatos son utilizados a veces por programas externos, por ejemplo nvidia-docker requiere la etiqueta com.nvidia.volumes.needed para funcionar correctamente.

Ejemplo de un metadato en nuestro Dockerfile:

FROM node:7-alpine 
LABEL maintainer "[email protected]" 
...

14. Añadir HEALTHCHECK

Podemos iniciar el contenedor docker con la opción --restart always (reiniciar siempre). Después de un fallo del contenedor, el "demonio" de Docker intentará reiniciarlo. Es muy útil si tu contenedor tiene que estar operativo todo el tiempo. Pero, ¿qué pasa si el contenedor se está ejecutando, pero no está disponible (bucle infinito, configuración no válida, etc.)? Con la instrucción HEALTHCHECK podemos decirle a Docker que compruebe periódicamente el estado de salud de nuestro contenedor. Puede ser cualquier comando, devolviendo 0 como código de salida si todo está bien, y 1 en el caso contrario.

Último cambio a nuestro ejemplo:

FROM node:7-alpine 
LABEL maintainer "[email protected]" 

ENV PROJECT_DIR=/app 
WORKDIR $PROJECT_DIR 

COPY package.json $PROJECT_DIR 
RUN npm install 
COPY . $PROJECT_DIR 
ENV MEDIA_DIR=/media \ 
		 NODE_ENV=production \ 
		 APP_PORT=3000 

VOLUME $MEDIA_DIR 
EXPOSE $APP_PORT 
HEALTHCHECK CMD curl --fail http://localhost:$APP_PORT || exit 

ENTRYPOINT ["./entrypoint.sh"] 
CMD ["start"]

curl --fail devuelve el código de salida que no es cero si la petición falla.

Para usuarios avanzados

Este post se está haciendo muy largo, así que, aunque tenga algunas ideas más, no las expondré aquí. Si quieres saber más, echa un vistazo a las instrucciones de STOPSIGNAL, ONBUILD y SHELL. Además, algunas opciones muy útiles durante la compilación son --no-cache (especialmente en un servidor de integración continua, si quiere estar seguro de que la compilación se puede hacer en una nueva instalación de Docker), y --squash (más aquí).

Diviértete :)

Conclusión

Esto es todo. Un post largo, pero creo que contiene información útil. Si tienes tus propios consejos que dar sobre Dockerfiles, compártelos en comentarios. ¡Todos los comentarios son bienvenidos!

Fecha de publicación:
campusMVP campusMVP es la mejor forma de aprender a programar online y en español. En nuestros cursos solamente encontrarás contenidos propios de alta calidad (teoría+vídeos+prácticas) creados y tutelados por los principales expertos del sector. Nosotros vamos mucho más allá de una simple colección de vídeos colgados en Internet porque nuestro principal objetivo es que tú aprendas. Ver todos los posts de campusMVP
Archivado en: Herramientas

Boletín campusMVP.es

Solo cosas útiles. Una vez al mes.

🚀 Únete a miles de desarrolladores

DATE DE ALTA

x No me interesa | x Ya soy suscriptor

La mejor formación online para desarrolladores como tú

Comentarios (1) -

Gracias de antemano por tu aporte, quizás en la línea del archivo Dockerfile donde tocas HEALTHTCHECK  y pone algo como esto:
HEALTHCHECK CMD curl --fail http://localhost:$APP_PORT || exit

no querías decir mejor, esto otro

HEALTHCHECK CMD curl --fail http://localhost:$APP_PORT || exit 1

lo vi en la documentación de docker, pero no me hagas mucho caso recién estoy aprendiendo

Otra cuestión podría ser que se pudiera variar la variable APP_PORT dependiendo de si estamos en  production o en development, algo como:
"dev" )
    export APP_PORT=3333
...
"start" )
    export APP_PORT=6666

y por último, para que sirve el archivo config.yml y de dónde sale la variable $DATABASE_ADRESS

Responder

Agregar comentario

Los datos anteriores se utilizarán exclusivamente para permitirte hacer el comentario y, si lo seleccionas, notificarte de nuevos comentarios en este artículo, pero no se procesarán ni se utilizarán para ningún otro propósito. Lee nuestra política de privacidad.