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.
¡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:
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: