Python en Docker: imágenes eficientes y seguras

Contenerizar una aplicación Python es fácil. Hacerlo bien —imágenes pequeñas, capas cacheadas correctamente, sin root, sin basura de desarrollo— requiere entender un puñado de decisiones que se toman en el Dockerfile y que después son casi imposibles de cambiar sin reconstruir todo.

Empecemos por la decisión más frecuentemente mal tomada: la imagen base.

Elegir la imagen base correcta

python:3.12-slim y python:3.12-alpine parecen intercambiables —ambas son “pequeñas”— pero tienen naturalezas muy distintas. Alpine usa musl libc en lugar de glibc. Eso significa que cualquier paquete con extensiones C compiladas (numpy, pandas, psycopg2, cryptography, Pillow…) o bien no tiene wheel precompilado para musl, o tiene uno pero se comporta de forma sutil y diferente. El resultado es que pip install termina compilando desde fuente dentro del contenedor, lo que alarga el build y a veces falla con errores crípticos de linking.

python:3.12-slim es Debian sin los paquetes de documentación y man pages. Usa glibc, así que los wheels de PyPI funcionan directamente. Para la mayoría de aplicaciones de producción —APIs, workers, scripts— es la elección pragmática.

Alpine tiene sentido cuando controlas el conjunto de dependencias y sabes que ninguna compila C. En cuanto tienes una sola dependencia con extensiones nativas, el dolor no vale la diferencia de unos pocos MB.

Multi-stage builds: separar build de runtime

El patrón central para imágenes de producción pequeñas es el multi-stage build. La idea: un stage builder instala compiladores, headers y dependencias de desarrollo; un stage final copia solo lo que necesita el proceso en runtime. La imagen publicada nunca contiene gcc, pip, ni archivos de test.

Si copias el entorno virtual completo entre stages, obtienes un runtime limpio sin reconstruir la lógica de instalación.

# ── Stage 1: builder ─────────────────────────────────────────────────────────
FROM python:3.12-slim AS builder

# Evita archivos .pyc en el stage builder y fuerza salida sin buffer
ENV PYTHONDONTWRITEBYTECODE=1 \
    PYTHONUNBUFFERED=1

WORKDIR /app

# Instala dependencias del sistema necesarias para compilar extensiones C.
# --no-install-recommends recorta ~30 MB de paquetes no necesarios.
RUN apt-get update && apt-get install -y --no-install-recommends \
        build-essential \
        libpq-dev \
    && rm -rf /var/lib/apt/lists/*

# Copia solo los archivos de dependencias primero.
# Docker cachea esta capa: si no cambian requirements, no reinstala nada.
COPY requirements.txt .

RUN python -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"

RUN pip install --upgrade pip \
    && pip install --no-cache-dir -r requirements.txt


# ── Stage 2: final (runtime) ──────────────────────────────────────────────────
FROM python:3.12-slim AS final

ENV PYTHONDONTWRITEBYTECODE=1 \
    PYTHONUNBUFFERED=1 \
    PATH="/opt/venv/bin:$PATH"

# Crea un usuario sin privilegios antes de copiar archivos.
# UID/GID explícitos evitan conflictos si el host mapea permisos.
RUN groupadd --gid 1001 python \
    && useradd --uid 1001 --gid python --no-create-home python

WORKDIR /app

# Copia el venv compilado desde el builder. Sin pip, sin gcc, sin headers.
COPY --from=builder /opt/venv /opt/venv

# --chown asigna propiedad al usuario python en el mismo paso que copia.
# Hacerlo en un RUN chmod separado crearía una capa extra con los inodos duplicados.
COPY --chown=python:python src/ ./src/

USER python

# HEALTHCHECK evalúa si el proceso responde, no solo si está vivo.
# --interval: cada cuánto se lanza. --retries: intentos antes de marcar unhealthy.
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
    CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')" \
    || exit 1

# ENTRYPOINT define el ejecutable fijo. CMD provee argumentos por defecto
# sobreescribibles en docker run. Juntos permiten: docker run myimage --workers 4
ENTRYPOINT ["python", "-m", "uvicorn"]
CMD ["src.main:app", "--host", "0.0.0.0", "--port", "8000"]

Y el .dockerignore correspondiente:

.git
.gitignore
.github
__pycache__
**/__pycache__
*.pyc
*.pyo
.venv
venv
.env
*.env
.pytest_cache
tests/
docs/
*.md
.mypy_cache
.ruff_cache
dist/
build/

Lo que hace cada decisión

Las variables de entorno en el stage builder (PYTHONDONTWRITEBYTECODE, PYTHONUNBUFFERED) evitan que pip y el intérprete generen archivos que después se copiarían al stage final. PYTHONUNBUFFERED=1 es crítico en contenedores: sin él, los logs de Python se bufferean y cuando el proceso muere abruptamente, pierdes las últimas líneas justo cuando más las necesitas.

La secuencia de COPY en el builder no es arbitraria. Copiar requirements.txt antes que el código fuente es un patrón de cache de capas. Docker rehace una capa y todas las siguientes cuando detecta un cambio. Si copias todo el código primero, cualquier modificación a un .py invalida la capa de pip install, que puede tardar minutos. Con la secuencia correcta, cambiar código no reinstala nada.

El virtual environment (/opt/venv) como unidad de copia entre stages es más limpio que usar pip install --target o manipular PYTHONPATH. El venv es autónomo: tiene su propio intérprete (symlink al de la imagen base), sus paquetes, y sus scripts de consola. Al sobreescribir PATH en el stage final apuntas al venv sin activate.

USER python es la línea que más se omite y más importa. Correr como root dentro de un contenedor no es equivalente a root en el host (gracias a los namespaces), pero si hay una vulnerabilidad de escape de contenedor, o si el proceso escribe archivos en un volumen montado, la diferencia es enorme. El UID explícito (1001) permite que operaciones de Kubernetes con runAsUser sean predecibles.

COPY --chown=python:python hace la asignación de permisos en la misma instrucción que la copia. Si lo hicieras con un RUN chown -R posterior, Docker crearía una nueva capa donde todos los inodos del directorio existen duplicados —la capa vieja con root como dueño y la nueva con python— inflando la imagen sin razón.

ENTRYPOINT + CMD juntos te dan flexibilidad real. ENTRYPOINT ["python", "-m", "uvicorn"] fija el ejecutable; CMD ["src.main:app", "--host", "0.0.0.0", "--port", "8000"] da los argumentos por defecto. En desarrollo o CI puedes hacer docker run myimage src.main:app --reload y sobreescribir solo los argumentos sin tocar el entrypoint. Si usaras solo CMD ["python", "-m", "uvicorn", "src.main:app"], sobreescribir con docker run myimage bash te metería en un shell, lo que en producción es exactamente lo que quieres evitar.

El HEALTHCHECK con urllib.request funciona sin dependencias externas —curl no está en slim—. Docker marca el contenedor como unhealthy tras tres fallos consecutivos, lo que permite que orquestadores como Compose o ECS tomen decisiones de restart o tráfico basadas en estado real, no en si el PID existe.

Errores que debes conocer

Error: Instalar dependencias antes de crear el usuario y luego hacer RUN chown -R en una capa separada.

# ❌ Wrong
COPY src/ ./src/
RUN chown -R python:python /app

# ✅ Right
COPY --chown=python:python src/ ./src/

--chown fusiona copia y permisos en una sola capa; el RUN chown posterior duplica todos los inodos en el layer store de Docker, pudiendo añadir decenas de MB a la imagen final.


Error: Poner USER python antes de instrucciones que necesitan escribir en directorios del sistema.

# ❌ Wrong
USER python
RUN apt-get install -y libpq5   # falla: sin permisos para apt

# ✅ Right
RUN apt-get install -y --no-install-recommends libpq5 \
    && rm -rf /var/lib/apt/lists/*
USER python   # root solo hasta aquí, luego sin privilegios

Todas las instalaciones de sistema van antes de USER python; después de esa línea el proceso nunca vuelve a tener permisos de root.


Error: Confiar en que .dockerignore excluye archivos que ya están en capas anteriores del build context.

# ❌ Wrong (el .venv local entra en el build context si no está en .dockerignore,
#           y COPY . . lo copia entero —cientos de MB de site-packages locales)
COPY . .

# ✅ Right: .dockerignore con .venv y el COPY explícito
COPY --chown=python:python src/ ./src/
COPY --chown=python:python pyproject.toml .

.dockerignore actúa en el build context enviado al daemon, no en capas ya construidas. Un .venv local sin excluir puede multiplicar por diez el tamaño del contexto y romper la reproducibilidad si el venv del host tiene versiones distintas a las del requirements.txt.


Control Block

Dejar un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *

Scroll al inicio