Buenas prácticas para Dockerfiles

Buenas prácticas para Dockerfiles

Aprender a crear imágenes para generar contenedores permite adaptar aplicaciones y entornos de desarrollo. Los contenidos y la estructura de un Dockerfile afectan directamente una serie de factores. Entre ellos podemos mencionar el tiempo del build, la seguridad, y la rapidez de las operaciones de push o pull hacia o desde el Dockerhub, respectivamente. Por esta razón, en esta guía aprenderás una serie de buenas prácticas para Dockerfiles que te ayudarán a mejorar y agilizar estos procesos.

Requisitos previos

Sugerencia 1: Emplear etiquetas específicas e imágenes oficiales

Omitir la etiqueta o utilizar :latest puede parecer conveniente porque nos garantiza que dispondremos de las últimas actualizaciones en cuanto a funcionalidad y seguridad. El problema surge debido a que el contenido de la imagen hoy puede diferir de otra con la misma etiqueta en algunos meses. Por ejemplo:

FROM python

es lo mismo que emplear python:3.10.2, python:3.10, python:3 o python:latest en este momento de acuerdo al Dockerhub:

buenas practicas para dockerfiles 1
Buenas prácticas para Dockerfiles

A pesar de que Python 4 no está en los planes del equipo de desarrollo, imagina lo que podría suceder si en un par de años python:latest apuntara a esa versión. Cualquier contenedor que la utilice dejaría de funcionar correctamente. Sin ir más lejos, el mismo inconveniente puede suceder cuando la última versión de Python sea la 3.20 (por nombrar un caso). Por este motivo, siempre es recomendable usar una etiqueta específica (FROM python:3.10.1) en vez de una general (como FROM python).

Además, siempre emplea imágenes oficiales como base en vez de intentar construir una similar por tu cuenta. En otras palabras, utilizar la imagen oficial de Node es mucho más confiable que instalar Node sobre una imagen de alguna distribución Linux.

Sugerencia 2: Preferir imágenes pequeñas como base

Además de utilizar una imagen oficial, es importante elegir una que sea lo más pequeña posible. El objetivo no es solamente reducir el tamaño del build final, sino evitar componentes innecesarios que aumenten la superficie de ataque.

Tomando como ejemplo la misma imagen python:3.10.2, vemos que su tamaño es 333.62 MB:

buenas practicas para dockerfiles 2
Buenas prácticas para Dockerfiles

y que su base es buildpack-deps:bullseye. Por ese motivo, incluye todas las herramientas necesarias para instalar gemas de Ruby así como paquetes de Node y Python. Además, provee una gran cantidad de paquetes de la versión estable de Debian (Bullseye en este caso). Si solamente necesitas emplear pip, puedes considerar otras alternativas como python:3.10.2-slim o python:3.10.2-alpine, de 42.33 y 17.65 MB, respectivamente.

Sugerencia 3: Aprovechar la caché en la construcción de imágenes

Cada línea de un Dockerfile genera una capa de sólo lectura en la imagen. De no haber cambios en el archivo o en el contexto, el proceso de creación de la imagen utiliza la caché de operaciones previas. Caso contrario, se invalida la caché correspondiente a la línea en cuestión y todas las siguientes. Por esta razón, siempre coloca las líneas donde es más probable que haya cambios después de aquellas que permanecen constantes.

Por ejemplo, en vez de

WORKDIR /app
COPY code/* .
RUN pip install -r requirements.txt 

es mejor colocar la última línea primero:

RUN pip install -r requirements.txt
WORKDIR /app
COPY code/* . 

En el primer caso, cualquier cambio en el contenido del directorio code invalida la caché de ese paso y del próximo. Como resultado, la instalación de los paquetes a partir del archivo requirements.txt siempre se lleva a cabo desde cero en la construcción de la imagen. Por otro lado, en el segundo ejemplo los cambios dentro de code no impactan los pasos anteriores. Esto significa que la capa de la imagen que creamos a partir de RUN pip install -r requirements.txt solamente se debe generar una vez.

Sugerencia 4: Usar builds de varias etapas

Cuando el proceso de construir una imagen involucra recursos que no son necesarios para correr el contenedor, lo mejor es no incluirlos en la imagen final. Por ejemplo, podemos considerar el caso de una aplicación de React con su correspondiente directorio node_modules que es necesario para correr npm run build. En la imagen nos interesa tener el resultado de este comando pero no la carpeta con los paquetes de Node que usamos. ¿Cómo podemos separar estas herramientas de nuestro producto final? Los builds de varias etapas son la respuesta.

Asumiendo que ya dispones de una aplicación React en el directorio actual, analicemos el Dockerfile que aparece a continuación. La primera etapa comienza con la línea FROM node:17.4.0-alpine y finaliza en RUN npm run build. Para identificar esta primera fase colocamos el nombre desarrollo (pero puedes elegir otro de tu preferencia) al final de la línea inicial. La segunda etapa utiliza una imagen distinta de base (nginx:1.21.6-alpine) y copia el resultado del build que generamos en la anterior en la raíz del servidor web.

FROM node:17.4.0-alpine AS desarrollo
WORKDIR /app
COPY package.json .
COPY package-lock.json . 
RUN npm install
RUN npm run build

FROM nginx:1.21.6-alpine
COPY --from=desarrollo /app/build/ /usr/share/nginx/html

Cuando generes la imagen a partir de este Dockerfile, esta no contendrá los recursos de desarrollo sino la aplicación propiamente dicha. Como consecuencia, el tamaño de la imagen será menor y contará con las ventajas que mencionamos previamente.

Además del uso de etapas múltiples en la construcción de una imagen, considera agregar el archivo .dockerignore en el directorio de tu proyecto. Esto te permitirá evitar que la imagen incluya datos sensibles (como claves de APIs) u otros archivos y directorios irrelevantes para esta. Para lograr este objetivo, cada línea en .dockerignore indica un patrón a excluir (o ignorar) en el contexto del build.

Sugerencia 5: Crear un usuario con permisos limitados

Si no especificas lo contrario, los contenedores correrán como root. En cualquier servidor Linux de producción, los procesos deben ejecutarse con un usuario dedicado y con privilegios limitados. Por ejemplo, Apache por lo general corre como www-data en distribuciones basadas en Debian como Ubuntu. Lo mismo sucede en los contenedores que utilices para el mismo propósito.

Para ver este concepto en la práctica, observa el detalle en la imagen oficial de Nginx:

buenas practicas para dockerfiles 3
Buenas prácticas para Dockerfiles

Como ilustra la imagen, luego de declarar variables de entorno lo primero que aparece es la creación del grupo y de la cuenta de usuario. De esta manera, el servidor web correrá como nginx (con los permisos limitados que eso implica) y no como root.

Conclusión

En esta guía aprendiste cinco buenas prácticas para Dockerfiles que te permitirán crear imágenes más robustas y seguras. Gracias a esto, la tarea de subirlas al Dockerhub y de descargarlas será más ágil. Adicionalmente, ocuparán menos espacio y serán más confiables.

Gabriel Cánepa
Gabriel Cánepa

Gabriel trabaja actualmente como desarrollador full-stack en Scalar, una firma que se dedica a hacer valuaciones de empresas. Es Administrador de Sistemas certificado por la Fundación Linux y previamente ha escrito un gran número de artículos y contenidos técnicos sobre el tema para: DigitalOcean, Linode, Carrera Linux Argentina y Tecmint.

Tiene una certificación en programación de la Universidad de Brigham Young-Idaho, y está completando las carreras de programador y analista de sistemas en la Universidad Nacional de Villa Mercedes (UNViMe).

En su tiempo libre, Gabriel disfruta leyendo libros de Stephen R. Covey, tocando piano y guitarra, y enseñando conocimientos de programación a su dos hijas.