Ir al contenido

Laboratorio 06 - Contenerización de GroceryListApp con Dockerfile optimizado

En el Laboratorio 05 construyó una imagen funcional de GroceryListApp a partir de su código fuente. En este laboratorio partirá de esa base y modificará el Dockerfile para incorporar decisiones orientadas a seguridad, reproducibilidad y operación.

El ejercicio introduce una construcción multietapa, un usuario sin privilegios de root, Tini como proceso de inicialización, una comprobación de salud definida en la imagen, metadatos OCI y restricciones aplicadas al ejecutar el contenedor.

El objetivo no es únicamente producir una imagen diferente. Cada cambio permite observar una responsabilidad concreta del empaquetado o de la ejecución del contenedor.

Al finalizar podrá

  • separar la construcción de dependencias de la imagen utilizada durante la ejecución;
  • utilizar una versión específica de la imagen base para hacer más explícita la dependencia del entorno;
  • ejecutar la aplicación con un usuario sin privilegios de root;
  • utilizar Tini para manejar señales y procesos hijos;
  • definir una comprobación de salud mediante HEALTHCHECK;
  • incorporar metadatos OCI a la imagen;
  • aplicar límites de CPU y memoria, un sistema de archivos de solo lectura y restricciones de privilegios;
  • comprobar que los controles configurados se aplican durante la ejecución;
  • comparar el Dockerfile del Laboratorio 05 con la nueva versión.

Compruebe que dispone de los siguientes elementos.

Requisito Comprobación
Docker instalado docker --version
Docker en ejecución docker info
Conexión a internet Necesaria para descargar imágenes y paquetes
Puerto 8080 disponible lsof -i :8080 no debería mostrar procesos escuchando en ese puerto
Navegador web Necesario para acceder a la aplicación

Verifique que Docker esté disponible.

Ventana de terminal
docker --version

Importante

Si el comando docker no es reconocido o devuelve errores de permisos, resuelva primero la instalación o configuración de Docker antes de continuar.

Omita esta sección si ya tiene el repositorio utilizado en el Laboratorio 05.

Ventana de terminal
git clone https://github.com/jpadillaa/grocerylistapp.git
cd grocerylistapp

Cree el archivo Dockerfile en la raíz del proyecto.

Ventana de terminal
touch Dockerfile

Reemplace su contenido por la siguiente versión.

# ============================================================================
# ETAPA 1: Build - Instalación de dependencias
# ============================================================================
FROM python:3.11.9-slim-bookworm AS builder
# Evitar prompts interactivos y configurar Python
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1
WORKDIR /build
# Instalar dependencias en un virtualenv para multi-stage limpio
RUN python -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
# Copiar solo requirements para aprovechar caché de capas
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# ============================================================================
# ETAPA 2: Runtime - Imagen final optimizada
# ============================================================================
FROM python:3.11.9-slim-bookworm AS runtime
# ------------------------------------------------------------------------------
# Metadatos de la imagen (OCI Image Spec)
# ------------------------------------------------------------------------------
LABEL org.opencontainers.image.title="GroceryListApp" \
org.opencontainers.image.description="Aplicación web para gestionar listas de compras" \
org.opencontainers.image.version="1.0.0" \
org.opencontainers.image.vendor="jpadillaa" \
org.opencontainers.image.source="https://github.com/jpadillaa/grocerylistapp" \
org.opencontainers.image.licenses="GPL-3.0"
# ------------------------------------------------------------------------------
# Variables de entorno
# ------------------------------------------------------------------------------
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
# Configuración de la aplicación
PORT=8080 \
DATA_DIR=/data \
DB_PATH=/data/shop.db \
FLASK_CONFIG=app.config.Config \
# Ruta del virtualenv
PATH="/opt/venv/bin:$PATH"
# ------------------------------------------------------------------------------
# Instalación de dependencias del sistema y configuración de seguridad
# ------------------------------------------------------------------------------
RUN set -eux; \
# Instalar tini para manejo correcto de señales
apt-get update; \
apt-get install -y --no-install-recommends \
tini \
curl \
; \
# Limpiar caché de apt para reducir tamaño
apt-get purge -y --auto-remove -o APT::AutoRemove::RecommendsImportant=false; \
rm -rf /var/lib/apt/lists/*; \
# Crear usuario no-root para seguridad
groupadd --gid 1000 appgroup; \
useradd --uid 1000 --gid appgroup --shell /bin/bash --create-home appuser; \
# Crear directorio de datos con permisos correctos
mkdir -p /data; \
chown -R appuser:appgroup /data
# ------------------------------------------------------------------------------
# Copiar dependencias desde la etapa de build
# ------------------------------------------------------------------------------
COPY --from=builder /opt/venv /opt/venv
# ------------------------------------------------------------------------------
# Configurar directorio de trabajo y copiar código fuente
# ------------------------------------------------------------------------------
WORKDIR /app
# Copiar código con ownership del usuario no-root
COPY --chown=appuser:appgroup . .
# ------------------------------------------------------------------------------
# Configuración de seguridad final
# ------------------------------------------------------------------------------
# Cambiar a usuario no-root
USER appuser
# Declarar volumen para persistencia
VOLUME ["/data"]
# Exponer puerto (documentación)
EXPOSE 8080
# Health check para orquestadores
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD curl --fail http://localhost:${PORT}/health || exit 1
# ------------------------------------------------------------------------------
# Punto de entrada y comando
# ------------------------------------------------------------------------------
# Usar tini como init system para manejo correcto de señales y procesos zombie
ENTRYPOINT ["/usr/bin/tini", "--"]
# Comando de arranque con gunicorn
CMD ["sh", "-c", "gunicorn --bind 0.0.0.0:${PORT} --workers 2 --threads 4 --worker-class gthread --access-logfile - --error-logfile - \"app.main:app\""]

La nueva versión incorpora decisiones que pertenecen a momentos diferentes del ciclo de vida.

Construcción
│
├── versión explícita de la imagen base
├── etapa builder
└── instalación de dependencias
│
▼
Imagen de ejecución
│
├── usuario no root
├── Tini
├── metadatos OCI
├── HEALTHCHECK
└── configuración de Gunicorn
│
▼
docker run
│
├── límites de CPU y memoria
├── sistema de archivos de solo lectura
├── no-new-privileges
└── volumen persistente

Esta separación será útil durante las siguientes secciones porque permite distinguir qué controles quedan incorporados en la imagen y cuáles se aplican cuando se crea el contenedor.

El Dockerfile utiliza dos instrucciones FROM con nombres diferentes.

FROM python:3.11.9-slim-bookworm AS builder
# ... instalación de dependencias ...
FROM python:3.11.9-slim-bookworm AS runtime
COPY --from=builder /opt/venv /opt/venv

La etapa builder prepara el entorno virtual e instala las dependencias. La etapa runtime parte nuevamente de la imagen base y recibe únicamente el entorno virtual resultante.

Aspecto Aporte
Separación de etapas Distingue los artefactos necesarios durante la construcción de los necesarios durante la ejecución
Imagen final Solo incorpora desde builder el entorno virtual preparado
Superficie disponible Evita trasladar automáticamente a la etapa final todo lo que pudiera haberse utilizado durante la construcción

En este ejercicio ambas etapas utilizan la misma imagen base y el ahorro de tamaño puede ser moderado. La propia comparación del laboratorio muestra que el aporte principal de la construcción multietapa está en la separación de responsabilidades.

Utilizar una versión específica de la imagen base

Sección titulada «Utilizar una versión específica de la imagen base»

El Dockerfile anterior utilizaba

FROM python:3.11-slim

La nueva versión utiliza

FROM python:3.11.9-slim-bookworm

La referencia más específica hace explícitas tanto la versión de Python como la variante de Debian utilizada como base.

Elemento Propósito
3.11.9 Identifica una versión concreta de Python
bookworm Identifica la variante de Debian utilizada por la imagen

Esta decisión reduce la ambigüedad respecto de una referencia más general como python:3.11-slim.

La etapa de construcción define las siguientes variables.

ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1
Variable Efecto esperado en este ejercicio
PYTHONDONTWRITEBYTECODE=1 Evita generar archivos .pyc
PYTHONUNBUFFERED=1 Hace que la salida de Python se escriba sin búfer y aparezca oportunamente en los registros
PIP_NO_CACHE_DIR=1 Evita conservar la caché de pip
PIP_DISABLE_PIP_VERSION_CHECK=1 Evita la comprobación de nuevas versiones de pip durante la construcción

La intención es reducir archivos que no necesita la imagen y hacer más directa la salida de la aplicación hacia los registros del contenedor.

El laboratorio contrasta varias instrucciones independientes

RUN apt-get update
RUN apt-get install -y tini
RUN rm -rf /var/lib/apt/lists/*

con una única instrucción.

RUN set -eux; \
apt-get update; \
apt-get install -y --no-install-recommends tini curl; \
rm -rf /var/lib/apt/lists/*

La limpieza ocurre dentro de la misma instrucción que instala los paquetes. De este modo, los archivos temporales eliminados no quedan conservados en una capa anterior de esa misma secuencia.

set -eux modifica el comportamiento del shell utilizado durante la construcción.

Opción Efecto
-e Detiene la ejecución cuando un comando falla
-u Trata como error el uso de variables no definidas
-x Muestra los comandos que se ejecutan

Ejecutar con un usuario sin privilegios de root

Sección titulada «Ejecutar con un usuario sin privilegios de root»

El Dockerfile crea un grupo y un usuario dedicados a la aplicación.

RUN groupadd --gid 1000 appgroup; \
useradd --uid 1000 --gid appgroup --shell /bin/bash --create-home appuser
USER appuser

La instrucción USER appuser establece qué usuario ejecutará las instrucciones posteriores y el proceso principal del contenedor.

El propósito es aplicar el principio de mínimo privilegio. Si la aplicación no necesita permisos administrativos para funcionar, ejecutarla con un usuario dedicado reduce las operaciones que puede realizar dentro del contenedor.

El directorio /data se prepara antes del cambio de usuario.

mkdir -p /data; \
chown -R appuser:appgroup /data

Esto permite que appuser escriba en la ubicación donde SQLite almacena la base de datos.

Utilizar Tini para manejar señales y procesos hijos

Sección titulada «Utilizar Tini para manejar señales y procesos hijos»

La imagen instala Tini y lo define como punto de entrada.

RUN apt-get install -y --no-install-recommends tini
ENTRYPOINT ["/usr/bin/tini", "--"]

Tini se convierte en el proceso que ocupa el PID 1 y ejecuta el comando definido posteriormente mediante CMD.

Situación Función que cumple Tini en el ejercicio
Recepción de señales Reenvía señales como SIGTERM y SIGINT al proceso hijo
Procesos hijos finalizados Puede recolectar procesos que de otro modo quedarían como zombis
Detención del contenedor Facilita que Gunicorn reciba la señal de terminación y cierre de forma ordenada

La relación entre los procesos queda aproximadamente así.

PID 1
Tini
│
▼
sh -c
│
▼
Gunicorn
│
└── workers

El Dockerfile incorpora

HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD curl --fail http://localhost:${PORT}/health || exit 1

Docker ejecutará periódicamente la solicitud indicada y mantendrá un estado de salud asociado con el contenedor.

Parámetro Valor Función
--interval 30s Intervalo entre comprobaciones
--timeout 10s Tiempo máximo permitido para una comprobación
--start-period 5s Periodo inicial antes de contabilizar fallos
--retries 3 Número de fallos consecutivos antes de marcar el contenedor como unhealthy

La comprobación utiliza la ruta /health de GroceryListApp. Por esta razón, su utilidad depende también de que esa ruta represente adecuadamente el estado que se desea observar.

El laboratorio menciona que otros sistemas de ejecución y orquestación disponen de mecanismos propios para trabajar con comprobaciones de salud. La configuración concreta depende de cada plataforma.

La imagen incluye etiquetas bajo el espacio de nombres org.opencontainers.image.*.

LABEL org.opencontainers.image.title="GroceryListApp" \
org.opencontainers.image.description="Aplicación web para gestionar listas de compras" \
org.opencontainers.image.version="1.0.0" \
org.opencontainers.image.vendor="jpadillaa" \
org.opencontainers.image.source="https://github.com/jpadillaa/grocerylistapp" \
org.opencontainers.image.licenses="GPL-3.0"

Estas etiquetas almacenan metadatos junto con la imagen y pueden consultarse posteriormente mediante docker inspect.

En este caso documentan el nombre, la descripción, la versión, el proveedor, el repositorio de origen y la licencia declarada por la imagen.

El comando final utiliza una configuración explícita de Gunicorn.

CMD ["sh", "-c", "gunicorn --bind 0.0.0.0:${PORT} --workers 2 --threads 4 --worker-class gthread --access-logfile - --error-logfile - \"app.main:app\""]
Opción Valor Función en esta configuración
--workers 2 Inicia dos procesos de trabajo
--threads 4 Permite hasta cuatro hilos por worker
--worker-class gthread Utiliza el worker basado en hilos de Gunicorn
--access-logfile - stdout Envía los registros de acceso a la salida estándar
--error-logfile - stderr Envía los errores a la salida de error

Los valores elegidos forman parte de la configuración de este ejercicio. Su ajuste en otros entornos depende de la carga, los recursos disponibles y el comportamiento de la aplicación.

Construya la nueva imagen con dos etiquetas.

Ventana de terminal
docker build \
--tag grocerylistapp:1.0.0 \
--tag grocerylistapp:latest \
--build-arg BUILDKIT_INLINE_CACHE=1 \
.
Opción Propósito dentro del laboratorio
--tag grocerylistapp:1.0.0 Asigna una etiqueta de versión
--tag grocerylistapp:latest Asigna una segunda etiqueta de conveniencia
--build-arg BUILDKIT_INLINE_CACHE=1 Configura el argumento utilizado en el ejercicio para caché de construcción

Compruebe el tamaño de la imagen.

Ventana de terminal
docker images grocerylistapp:1.0.0 --format "table {{.Repository}}\t{{.Tag}}\t{{.Size}}"

Ejecutar el contenedor con restricciones adicionales

Sección titulada «Ejecutar el contenedor con restricciones adicionales»

Ejecute la imagen mediante el siguiente comando.

Ventana de terminal
docker run -d \
--name grocerylist \
--restart unless-stopped \
--memory 256m \
--cpus 0.5 \
--read-only \
--tmpfs /tmp:rw,noexec,nosuid,size=64m \
--security-opt no-new-privileges:true \
-p 8080:8080 \
-v grocerylist-data:/data \
grocerylistapp:1.0.0

La construcción de la imagen y la ejecución del contenedor resuelven responsabilidades diferentes. Las siguientes opciones no quedan almacenadas en el Dockerfile, sino que se aplican a esta instancia durante docker run.

Opción Efecto esperado
--restart unless-stopped Configura una política de reinicio automático salvo detención manual
--memory 256m Limita la memoria disponible para el contenedor
--cpus 0.5 Limita la capacidad de CPU disponible
--read-only Monta el sistema de archivos raíz del contenedor como solo lectura
--tmpfs /tmp:rw,noexec,nosuid,size=64m Proporciona un espacio temporal escribible en memoria
--security-opt no-new-privileges:true Impide que los procesos adquieran privilegios adicionales
-p 8080:8080 Publica el puerto utilizado por la aplicación
-v grocerylist-data:/data Proporciona almacenamiento escribible y persistente para SQLite

--read-only cambia un supuesto importante de la ejecución. Las rutas que deban aceptar escritura tienen que proporcionarse explícitamente. En este ejercicio /data se monta mediante un volumen y /tmp mediante tmpfs.

Sistema de archivos raíz
└── solo lectura
/data
└── volumen grocerylist-data
/tmp
└── tmpfs

La verificación no se limita a comprobar que la aplicación responde. Cada prueba siguiente busca evidencia de que una decisión concreta quedó aplicada.

Espere aproximadamente 30 segundos después del inicio y ejecute

Ventana de terminal
docker inspect grocerylist --format='{{.State.Health.Status}}'

La salida esperada es

healthy

Puede consultar el historial completo con

Ventana de terminal
docker inspect grocerylist --format='{{json .State.Health}}' | jq
Ventana de terminal
docker exec grocerylist whoami

La salida esperada es

appuser

Esto comprueba que el proceso ejecutado dentro del contenedor utiliza el usuario definido en el Dockerfile.

Comprobar el sistema de archivos de solo lectura

Sección titulada «Comprobar el sistema de archivos de solo lectura»

Intente crear un archivo en /app.

Ventana de terminal
docker exec grocerylist touch /app/test.txt

La operación debería fallar con un mensaje similar al siguiente.

touch: cannot touch '/app/test.txt': Read-only file system

El fallo es la evidencia esperada en esta prueba.

Ventana de terminal
curl -s http://localhost:8080/health | jq

La respuesta esperada es

{
"status": "ok"
}
Ventana de terminal
docker inspect grocerylistapp:1.0.0 --format='{{json .Config.Labels}}' | jq

La salida debería incluir las etiquetas org.opencontainers.image.* declaradas en el Dockerfile.

El cambio entre ambos laboratorios no consiste en una sola optimización. Se incorporan controles y responsabilidades que antes no estaban explícitos.

Aspecto Laboratorio 05 Laboratorio 06
Construcción multietapa No builder y runtime
Usuario root appuser con UID 1000
Imagen base python:3.11-slim python:3.11.9-slim-bookworm
Proceso de inicialización Ninguno explícito Tini
Comprobación de salud No HEALTHCHECK
Metadatos OCI No Etiquetas estándar
Configuración de Gunicorn Básica Workers, hilos y registros explícitos
Destino de registros Configuración por defecto stdout y stderr

La ejecución también incorpora controles adicionales.

Control Laboratorio 05 Laboratorio 06
Usuario sin privilegios de root No Sí
Sistema de archivos raíz de solo lectura No Sí
Límites de recursos No Sí
no-new-privileges No Sí
Política de reinicio No Sí

El laboratorio propone además la siguiente comparación aproximada de tamaño.

Versión Tamaño aproximado
Dockerfile del Laboratorio 05 ~180–200 MB
Dockerfile del Laboratorio 06 ~150–170 MB

Estos valores son estimaciones del ejercicio y pueden variar según las versiones de las imágenes y las dependencias utilizadas.

En este caso la construcción multietapa no introduce herramientas de compilación pesadas que luego desaparezcan de la imagen final. Por eso, según el propio laboratorio, su aporte principal está en la separación de responsabilidades y no necesariamente en una reducción drástica del tamaño.

Consulte la última ejecución registrada.

Ventana de terminal
docker inspect grocerylist --format='{{json .State.Health}}' | jq '.Log[-1]'

El laboratorio propone revisar los siguientes casos.

Síntoma Posible causa Acción propuesta
connection refused La aplicación todavía no acepta conexiones Aumentar --start-period
curl not found curl no está disponible en la imagen Revisar su instalación en el Dockerfile
404 en /health La ruta no existe o no está disponible Revisar la definición de la ruta en la aplicación

Compruebe los permisos almacenados en el volumen.

Ventana de terminal
docker run --rm -v grocerylist-data:/data alpine ls -la /data

El laboratorio propone corregir la propiedad de los archivos con

Ventana de terminal
docker run --rm -v grocerylist-data:/data alpine chown -R 1000:1000 /data

Este comando modifica los propietarios de los archivos contenidos en el volumen y debe utilizarse únicamente cuando esa sea la corrección requerida.

El contenedor fue terminado por falta de memoria

Sección titulada «El contenedor fue terminado por falta de memoria»

Compruebe el indicador correspondiente.

Ventana de terminal
docker inspect grocerylist --format='{{.State.OOMKilled}}'

El laboratorio propone aumentar el límite de memoria con

Ventana de terminal
docker update --memory 512m grocerylist

Compruebe los procesos del contenedor.

Ventana de terminal
docker exec grocerylist ps aux

La salida esperada en el laboratorio es similar a

PID USER COMMAND
1 appuser /usr/bin/tini -- sh -c gunicorn ...
7 appuser gunicorn: master [app.main:app]

La comprobación busca confirmar que Tini ocupa el PID 1 y que Gunicorn se ejecuta como proceso descendiente.

El Dockerfile de este laboratorio incorpora controles en tres niveles diferentes.

El primer nivel corresponde a la construcción de la imagen. Aquí aparecen la separación entre builder y runtime, la versión explícita de la imagen base, la caché de capas y la copia selectiva de dependencias.

El segundo corresponde a la configuración de la imagen de ejecución. Aquí aparecen el usuario appuser, Tini, HEALTHCHECK, los metadatos OCI y la configuración de Gunicorn.

El tercero corresponde a la creación del contenedor. Aquí se aplican los límites de CPU y memoria, el sistema de archivos de solo lectura, tmpfs, no-new-privileges, la publicación de puertos y el volumen persistente.

Distinguir estos niveles permite razonar con mayor precisión sobre dónde debe implementarse cada control y qué evidencia permite comprobarlo.

Acción Comando
Consultar el estado de salud docker inspect grocerylist --format='{{.State.Health.Status}}'
Consultar el uso de recursos docker stats grocerylist --no-stream
Seguir los registros docker logs -f grocerylist
Abrir un shell docker exec -it grocerylist /bin/bash
Escanear vulnerabilidades docker scout cves grocerylistapp:1.0.0
Consultar las capas de la imagen docker history grocerylistapp:1.0.0

Fuente original

Laboratorio 06 - Contenerización de GroceryListApp con Dockerfile optimizado