Buenas prácticas construyendo imágenes Docker
En esta entrada voy a reunir un conjunto de buenas prácticas, para reunir toda la información dispersa por internet y, además, traducida a español ;). Al final, dejaré los links que he seguido para aprender sobre el tema y, ya que estamos, escribir esta entrada.
Antes de empezar, decir que principalmente voy a hablar de buenas prácticas a la hora de escribir ficheros Dockerfile. No obstante, si viene a cuento, puede que trate algún otro tema relacionado.
Una aplicación por contenedor
Es bastante común pensar en contenedores como si de una máquina virtual se tratase. La idea es que los contenedores, por un lado, tengan el mismo ciclo de vida de una aplicación y, por otro lado, que estos sean efímeros (podamos ejecutar o destruir uno en cualquier momento, así como, levantar varias instancias de un mismo contenedor).
Una pequeña aclaración que es necesaria para este apartado, y para los posteriores, es distinguir entre imagen y contenedor. A menudo, se utilizan indistintamente, pero en realidad una imagen es la definición de un servicio (aplicación) con idea de ser ejecutado. Una vez es ejecutado, hablamos de contenedor. El contenedor es una instancia de la imagen la cual se está ejecutando. Se pueden tener tantas instancias de la imagen (contenedores) como queramos. Además, un contenedor, con más detalle, consiste en una capa más sobre la imagen (la cual ya viene construida por capas, aunque no entraré en tanto detalle) y existe sólo mientras se ejecuta el contenedor.
Para finalizar este apartado, otro motivo por el que interesa tener una sola aplicación por contenedor es la monitorización de la salud del contenedor. Si existen varias aplicaciones en un contenedor, una de ellas podría dejar de funcionar y sería difícil conocer su estado por cómo trabaja el demonio de Docker (se dan pinceladas sobre esto más adelante).
Utilizar supervisor
A veces, existen imágenes oficiales que están creadas utilizando un sistema de gestión de procesos como, por ejemplo, un supervisor. O, incluso, usar un script bash como punto de entrada en el contenedor, el cual genera varias aplicaciones ejecutándose al mismo tiempo.
El por qué de esto se verá en un apartado posterior, donde se tratará el tema “manejo adecuado del PID1” y que está relacionado con cómo trata Linux los procesos.
Nótese que en muchas imágenes oficiales no siguen esta buena práctica porque necesitan que la aplicación tenga otro software para poder ser ejecutado y quieren que el usuario ejecute la aplicación con un solo comando.
Si bien esto puede ser util para probar, no se recomienda hacer esto si se desea llevar la imagen hasta producción.
Manejo adecuado de PID 1 y otras señales de los procesos
Linux maneja el ciclo de vide de los procesos en forma de envío de señales, por lo que vincular el ciclo de vida de la aplicación al contenedor y asegurar que ésta utiliza bien dichas señales es importante para tener un buen control del contenedor.
Y, aunque, se pueden instalar varios procesos sin supervisor en un contenedor, sólo se puede ejecutar un proceso al instanciar el contenedor, por lo que este detiene sólo el PID 1 mediante envío de una señal de terminación. Por tanto, los otros procesos no se detendrán adecuadamente.
Y ya que han salido decir que los PID (identificadores de procesos) son un id único que el núcleo de Linux asigna a cada proceso. Estos tienen un espacio de nombre (namespace), lo que significa que un contenedor tiene su propio conjunto de PIDs mapeados en el sistema huésped (host o localhost). De ellos, el primer proceso que se ejecuta en Linux recibe el nombre PID 1 (el primero en recibir un identificador). Docker utiliza las señales de Linux para comunicarse con los procesos dentro de un contenedor y sólo puede realizarse dicha comunicación con el PID 1 que se ejecuta dentro del contenedor.
Entender el contexto de construcción
Cuando se ejecuta el comando docker build para construir una imagen a partir de un Dockerfile el directorio donde se encuentra dicho fichero se conoce como contexto de construcción. Es este directorio el que se envía como contexto de construcción al demonio de Docker, es decir, los ficheros y directorios dentro de él.
Esto provoca que tener ficheros en dicho directorio que no son necesarios para la construcción de la imagen provocará contexto de construcción más grandes y, en consecuencia, imágenes de mayor tamaño.También, resulta en un tiempo de construcción mayor y en un contenedor (una vez ejecutado) con mayor uso de memoria.
En este caso, podemos aislar dentro de nuestro proyecto el fichero Dockerfile dentro de un subdirectorio que contendrá sólo aquello necesario. O bien, utilizar .dockerignore para indicar al demonio de Docker que no tenga en cuenta aquello que no necesitamos.
Optimización de la caché de construcción de Docker
Docker utiliza una memoria caché con la idea de agilizar la construcción de imágenes. Como ya se ha comentado previamente, las imágenes son construidas por capas, cada instrucción dentro de un fichero Dockerfile resulta en una capa de la imagen.
Durante la construcción, siempre que se pueda, Docker tiende a reutilizar las capas de una imagen de una construcción anterior, obviando un paso que podría resultar costoso.
Hay que considerar algunas cosas puesto que esto se puede volver problemático:
- Colocar las instrucciones del Dockerfile que tienden a cambiar en la parte final del fichero. De este modo, Docker podrá reutilizar las capas anteriores.
- Agrupar instrucciones en una misma capa (instrucción del fichero Dockerfile). Esto es por diversos motivos, desde reutilizar la capa, hasta mantener el mismo contexto, ya que puede ser necesario mantener un contexto entre dos capas. Para evitar esto, el ejemplo claro de esto es el comando apt o yum (instalador de paquetes), que normalmente requiere de una actualización de repositorios y de paquetes previos. A continuación, se muestra con un ejemplo:
FROM debian:9
RUN apt-get update
RUN apt-get install -y nginx
En su lugar, es mejor combinar todo en una misma instrucción RUN:
FROM debian:9
RUN apt-get update && \
apt-get install -y nginx
Eliminar herramientas innecesarias
Dejar el entorno sucio o con elementos innecesarios no es recomendable y, además, puede suponer un problema de seguridad. Por ejemplo, si durante la construcción se necesitan herramientas como unzip, netcat o wget, es recomendable dejar el paso inverso. No debemos olvidar seguir recomendaciones como la anterior, hacerlo en la propia instrucción RUN. Por ejemplo:
FROM debian:9
RUN apt-get update && \
apt-get install -y netcat && \
DO SOMETHING && \
apt-get remove -y netcat && \
Esto dificulta la maniobra a un hipotético atacante, el cual tendrá que buscar otro camino para realizar el ataque (ya que, no dispone de netcat). Además, también es recomendable para operaciones en contenedores (mientras estén ejecutándose, cualquier cambio en él podría provocar un agujero de seguridad).
En cuanto a las herramientas de depuración, pueden ser muy útiles, pero a menudo tienen privilegios que pueden derivar en un problema de seguridad. Por ello, hay que usarlas con precaución
Seguridad del sistema de fichero
En la medida de lo posible, debemos evitar ejecutar comandos como root dentro del contenedor. Esto es una primera medida de seguridad, y evita que un hipotético atacante, por ejemplo, utilice apt-get para instalar nuevos paquetes.
En este caso, se recomienda crear un usuario para realizar las operaciones que deben construir la imagen:
FROM debian:stretchRUN groupadd -g 999 appuser && \
useradd -r -u 999 -g appuser appuser
USER appuserCMD ["cat", "/tmp/secrets.txt"]
Iniciar contenedores en modo sólo lectura
Es una buena práctica iniciar el contenedor en modo sólo lectura. Para ello, utilizar junto al comando docker run con el flag read-only:
docker run -d --read-only nginx
Si el contenedor necesita escribir en el sistema de ficheros, se puede proveer un volumen para evitar errores y, además, hacer persistente los cambios una vez muera el contenedor. Si se trata de ficheros temporales, se recomienda emplear los volúmenes de tipo EmptyDir.
Minimizar el número de capas
Si bien las versiones recientes de Docker tienen la capacidad de optimizar la construcción reduciendo las capas y las instrucciones a nivel interno, siempre es recomendable reducir al mínimo las capas de una imagen.
Por tanto, si se tiene una versión antigua de Docker interesa tener en cuenta este aspecto.
Utilizar imágenes bases reducidas
En los ficheros Dockerfile se parte de una base a través de la instrucción FROM, la cual se encuentra al principio del fichero. El resto de instrucciones dependerán de dicha instrucción (se puede partir de una imagen vacía). Por ejemplo, si partimos de una imagen tipo CentOS el gestor de paquetes a utilizar en la instrucción RUN será yum en lugar de apt-get. Esto es porque en esa imagen base de la que partimos no tenemos disponible apt-get como gestor de paquetes.
Teniendo en cuenta lo dicho anteriormente, una imagen puede ser más ligera que otra. Por ejemplo, si necesitamos centos como imagen base, debemos saber que existe una alternativa más ligera como alpine, en este caso, pesa 71 MB menos.
Usar adecuadamente los tags de la imagen
Las imagenes de Docker se identifican de dos maneras: el nombre y el tag. El formato que sigue es el siguiente: nginx:1.15.5 donde “nginx” es el nombre y “1.15.5” el tag.
En Docker es común aprovechar los tags para versionar. De este modo, se facilita la liberación de código. Es, además, un método muy flexible, puesto que podemos utilizar las tags para obtener variantes muy diversas, no sólo indicando incrementalmente y con una política adecuada nuestra versión (esto en sí mismo es una buena practica ;) ), sino indicar diferentes variantes para una misma versión, por ejemplo: “openjdk-8alpine”, “openjdk-8”, etc.
Considerar cuidadosamente si utilizar una imagen pública
Docker es una gran ventaja, entre otras cosas, porque existen una gran cantidad de imágenes públicas. Esto permite ser rápidos si se quiere montar una arquitectura.
Pero, si se trata de implantar Docker en una orgnización, puede no ser posible usar imágenes públicas por diversos motivos:
- Necesitas controlar qué tiene exactamente la imagen dentro. Por ejemplo, sistema operativo y versión, por motivos de rendimiento, estabilidad, seguridad o licencia.
- No interesa depender de repositorios externos.
- Quieres homogeneidad en cuanto a qué imagen base contienen las imágenes y cómo se construyen.
Entender y conocer bien la tecnología
Con este ya terminamos. Trata de tener un poco de sentido común. Conocer la tecnología lo más al detalle que se pueda. Por ejemplo, entender la diferencia entre CMD y ENTRYPOINT en los Dockerfile, o entre ADD y COPY.
En muchas ocasiones podemos encontrar problemas (o evitarlos) si conocemos bien qué estamos haciendo.
Referencias
Finalmente, estas son las referencias utilizadas para realizar este artículo, las cuales recomiendo leer en caso de duda:
- https://docs.docker.com/develop/develop-images/dockerfile_best-practices/#understand-build-context
- https://cloud.google.com/solutions/best-practices-for-building-containers
- https://cloud.google.com/solutions/best-practices-for-operating-containers#avoid_running_as_root
- https://medium.com/@mccode/processes-in-containers-should-not-run-as-root-2feae3f0df3b
- https://vagga.readthedocs.io/en/latest/pid1mode.html
- https://stackoverflow.com/questions/33117068/use-of-supervisor-in-docker
- https://medium.com/@gchudnov/trapping-signals-in-docker-containers-7a57fdda7d86