Blogs / Imágenes Docker de Python pequeñas, rápidas y reproducibles

Imágenes Docker de Python pequeñas, rápidas y reproducibles

Publicado
10 de octubre de 2026
Autor
Faizan Nadeem
Etiquetas
Python Docker DevOps
Shipping containers stacked and lined up at a busy port
Photo by Paul Lichtblau on Unsplash

La mayoría de las imágenes Docker de Python son malas de las mismas tres maneras al mismo tiempo: son enormes, se reconstruyen desde cero en cada cambio de código porque el layer de dependencias se invalida constantemente, y llevan a producción un toolchain completo de compilador C porque nadie separó “lo que se necesita para construir esto” de “lo que se necesita para ejecutar esto”. Si preguntas por qué, la respuesta suele ser un Dockerfile de stage único FROM python:3.12 que pip install los requirements y copia la app en un bloque indiferenciado — un patrón que estaba bien para un proyecto de fin de semana y nunca se revisó una vez que la imagen llegó a producción.

El arreglo no es exótico. Los multi-stage builds existen en Docker desde 2017, y uv ha hecho el paso de instalación de dependencias de Python lo suficientemente rápido como para que la vieja excusa — “compilar todo desde cero es simplemente lento, así es Python” — ya no aplique. Lo que todavía falta para la mayoría de los equipos es el modelo mental real: qué stage instala qué, por qué el orden de copia en tu Dockerfile determina si tu cache de CI te ahorra cinco minutos o cero, y cómo mantener funcionando las extensiones nativas (psycopg2, numpy, cryptography) cuando tu imagen de runtime no tiene el compilador que las construyó.

Aprenderás:

  • Por qué los Dockerfiles de Python de stage único terminan siendo simultáneamente hinchados y lentos de reconstruir, y qué causa específicamente cada problema
  • Cómo los multi-stage builds separan el tooling de “build-time” de lo que realmente va a producción
  • Cómo configurar uv para la instalación de dependencias que es genuinamente más rápida que pip, con cache mounts de BuildKit que persisten entre builds
  • El orden exacto de instrucciones en el Dockerfile que hace que el cache de capas de Docker realmente te ahorre tiempo en lugar de silenciosamente fallar
  • Cómo mantener funcionando las dependencias nativas/compiladas cuando el builder y la runtime stage usan imágenes base diferentes
  • Cómo el tamaño de la imagen realmente afecta el cold start time en Lambda, Cloud Run y Kubernetes, y cuándo no importa para nada

Tabla de contenidos

  1. Lo básico
  2. La arquitectura completa
  3. Explicación de las capas principales
  4. Walkthrough de extremo a extremo
  5. Casos especiales
  6. Desafíos de escala y producción
  7. Ejemplos de código
  8. Errores comunes
  9. Buenas prácticas para producción

Lo básico

Qué hace realmente un multi-stage build

Un Dockerfile multi-stage tiene más de una línea FROM, y cada una inicia una nueva stage de build aislada. Las stages posteriores pueden copiar selectivamente COPY --from=<stage anterior> archivos específicos del filesystem de una stage anterior, pero nada más de esa stage anterior — sus paquetes instalados, su caché de descargas, su toolchain del compilador — viene a menos que lo copies explícitamente. La imagen final se construye solo desde el último FROM en adelante, más lo que hayas traído con COPY --from.

Ese mecanismo soluciona ambos problemas mencionados arriba de una vez. Obtienes una stage “builder” que puede ser grande y lenta — instalar gcc, compilar wheels, resolver un árbol de dependencias completo — y una stage “runtime” que solo recibe los artefactos terminados y ya construidos: un entorno virtual poblado y el código de tu aplicación. El compilador nunca se envía. El caché de descargas del gestor de paquetes nunca se envía. Solo la salida.

Por qué esto importa incluso si no te preocupa el tamaño de la imagen

  • Superficie de ataque. Un contenedor de producción con gcc, make y listas de paquetes apt instaladas es un objetivo más grande que uno con solo un intérprete de Python y tu app. Eliminar el build tooling de la imagen de runtime es una mejora de seguridad real, no solo una dieta.
  • Tiempo de cold start. En Lambda, Cloud Run y cualquier setup de Kubernetes con autoescalado, la imagen del contenedor tiene que descargarse (y a veces descomprimirse) antes de que una nueva réplica pueda servir tráfico. Una imagen de 180MB arranca materialmente más rápido que una de 1.2GB en condiciones de scale-to-zero.
  • Los minutos de CI cuestan dinero. Un Dockerfile que reconstruye todo tu árbol de dependencias porque cambiaste una línea del código de la aplicación está quemando tiempo de CI y paciencia del desarrollador en cada push.

La arquitectura completa

┌─────────────────────┐        ┌──────────────────────┐
│   builder stage      │        │   runtime stage        │
│   FROM python:3.12    │        │   FROM python:3.12-slim │
│                       │  copy  │                        │
│  gcc, build-essential │ ─────► │  (no compiler)         │
│  uv sync → .venv/     │  .venv │  .venv/  (copied)       │
│  full source tree     │  app   │  app code only          │
└─────────────────────┘        └──────────────────────┘

El principio guía: la stage builder responde “¿qué se necesita para producir un entorno virtual funcional y un conjunto de dependencias compiladas?” y la stage runtime responde “¿qué se necesita para ejecutar ese entorno?” Son preguntas diferentes con respuestas diferentes, y colapsarlas en una línea FROM es la causa raíz tanto de la hinchazón como de los rebuilds lentos.

Explicación de las capas principales

1. La elección de imagen base

Qué es: python:3.12 (la imagen Debian por defecto), python:3.12-slim (un Debian recortado sin la mayoría de los headers de dev y docs) y python:3.12-alpine (basada en musl, la más pequeña, pero con una biblioteca C diferente) son los tres puntos de partida realistas.

Por qué importa: slim es el default correcto para la stage de runtime casi siempre. Alpine parece atractiva solo por el tamaño, pero musl libc rompe o ralentiza una cantidad sorprendente de paquetes Python compilados (numpy, pandas, cryptography han tenido todos problemas específicos de Alpine a lo largo de los años), y frecuentemente terminas compilando desde fuente en Alpine en lugar de instalar un wheel manylinux precompilado — lo que anula la ganancia de tamaño haciendo el build más lento y, en algunos casos, la imagen no es realmente más pequeña una vez que los artefactos compilados aterrizan.

Consejo de producción: Usa la imagen completa python:3.12 (o python:3.12-bookworm explícitamente, para reproducibilidad) como la stage builder donde de todas formas necesitas gcc, y python:3.12-slim-bookworm como la stage de runtime. Fija el nombre en clave de Debian, no solo la versión de Python — slim solo puede silenciosamente moverse a una versión más nueva de Debian por debajo de ti.

2. Instalar dependencias con uv en lugar de pip

Qué es: uv es un gestor y resolver de paquetes Python basado en Rust que es un reemplazo drop-in para pip y pip-tools en la mayoría de los flujos de trabajo, construido por Astral (el mismo equipo detrás de ruff).

Por qué importa: La resolución e instalación de dependencias con pip simple es genuinamente una de las partes más lentas de un build de imagen Python, especialmente para árboles de dependencias más grandes. uv paraleliza las descargas, usa un caché global con clave por hash de paquete para que las instalaciones repetidas entre proyectos e imágenes no vuelvan a descargar el mismo wheel, y resuelve lockfiles un orden de magnitud más rápido que pip-compile. En un build de Docker específicamente, esa velocidad se combina con los cache mounts de BuildKit (más abajo) para hacer que una instalación de dependencias desde cero pase de minutos a segundos con un caché caliente.

Consejo de producción: Instala con uv sync --frozen --no-install-project contra un archivo uv.lock committed, no uv pip install -r requirements.txt. --frozen se niega a actualizar silenciosamente el lockfile dentro del build del contenedor — si tu lockfile y pyproject.toml se desincronizaran, quieres que eso falle en voz alta en CI, no que resuelva un conjunto de dependencias diferente al que un desarrollador probó localmente.

3. Cache mounts de BuildKit para el caché de paquetes

Qué es: RUN --mount=type=cache,target=/root/.cache/uv le dice a BuildKit que persista un directorio entre builds, separado del caché de capas propio de la imagen — el contenido vive en un volumen de caché administrado por Docker, no en ninguna capa de imagen, así que nunca hincha la imagen final pero sí sobrevive entre builds en la misma máquina (y a través de un backend de caché BuildKit compartido en CI).

Por qué importa: Sin esto, cada docker build — incluso con un hit en el caché de capas regular de Docker — inicia el caché de descargas propio de uv vacío dentro de la ejecución de esa capa. Con esto, una dependencia que se descargó en el build #1 ya está sentada en el cache mount en el build #50, incluso si la capa de arriba cambió y forzó una re-ejecución del paso de instalación.

Consejo de producción: Usa ambos mecanismos de caché juntos, no uno en lugar del otro — el caché de capas de Docker para saltarse el paso de instalación completamente cuando nada cambió, y el cache mount de BuildKit para hacer el paso de instalación en sí mismo rápido en las raras ocasiones en que sí necesita re-ejecutarse.

4. Ordenar instrucciones para cache hits reales

Qué es: El caché de capas de Docker invalida una capa, y cada capa después de ella, en el momento en que cualquier COPY o RUN anterior en el Dockerfile cambia. Esto significa que el orden de instrucciones no es cosmético — determina directamente si editar app/routes.py activa una reinstalación completa de dependencias o no.

Por qué importa: Si haces COPY . . antes de ejecutar uv sync, entonces cualquier cambio de archivo — incluyendo un comentario de una línea en tu código de aplicación — invalida el layer de instalación de dependencias, y reconstruyes todo tu árbol de dependencias en cada cambio de código. Esa es la única causa más común de por qué los equipos creen que los builds de Docker para Python son inherentemente lentos.

Consejo de producción: Copia solo pyproject.toml y uv.lock primero, ejecuta la instalación, y solo entonces copia el resto del árbol de código fuente. Los cambios de dependencias son mucho menos frecuentes que los cambios de código, así que este orden significa que el layer costoso solo se re-ejecuta cuando realmente necesita hacerlo.

5. Usuarios no-root y superficie de runtime mínima

Qué es: Por defecto, un proceso de contenedor corre como root dentro del contenedor a menos que la imagen cree y cambie explícitamente a un usuario no-root con USER.

Por qué importa: Correr como root dentro de un contenedor no es equivalente a root en el host, pero sigue siendo una superficie de escalamiento de privilegios innecesaria si el proceso alguna vez se compromete — y varias plataformas administradas (Cloud Run con ciertas configuraciones, algunos Kubernetes Pod Security Standards) o advierten sobre o directamente rechazan contenedores con usuario root.

Consejo de producción: Crea el usuario en la stage de runtime, no en la builder stage, y chown el directorio de la app a él explícitamente — COPY --chown=appuser:appuser en la instrucción final COPY maneja esto en un paso sin una capa RUN chown adicional.

Walkthrough de extremo a extremo

Traza un cambio de código desde git push hasta un contenedor corriendo:

  1. El desarrollador edita app/main.py y hace push. pyproject.toml y uv.lock no cambiaron.
  2. CI ejecuta docker build. BuildKit hashea los inputs de cada instrucción. Los layers COPY pyproject.toml uv.lock ./ y RUN uv sync hashean idénticamente al último build — cache hit, ambos layers se saltan completamente.
  3. El layer COPY . . recoge el app/main.py cambiado, invalidando desde ese punto hacia adelante — pero ese es el único trabajo que necesitaba hacerse.
  4. La stage de runtime inicia fresca desde python:3.12-slim, y COPY --from=builder /app/.venv /app/.venv trae el entorno virtual ya construido — ninguna reinstalación ocurre en esta stage en absoluto, porque el builder ya hizo ese trabajo.
  5. La imagen final se tagea y pushea. Porque la stage de runtime nunca tuvo gcc ni el caché de uv, la imagen que se envía es solo el intérprete, el venv y el código de la app.
  6. El deploy activa un pull en la plataforma objetivo. Una imagen más pequeña se descarga más rápido, y si la plataforma es scale-to-zero (Cloud Run, imágenes de contenedor Lambda), ese tiempo de pull está directamente en el cold start que ve el usuario.

Casos especiales

Dependencias nativas/compiladas (psycopg2, numpy, cryptography, pillow). Estas necesitan bibliotecas C reales contra las cuales compilar (libpq-dev para psycopg2, por ejemplo) y a menudo un compilador. Instala los headers -dev y build-essential solo en la stage builder. Si la stage de runtime necesita la biblioteca compartida en tiempo de ejecución (no solo en tiempo de build — libpq es un ejemplo común), instala también el paquete de runtime no-dev (libpq5, no libpq-dev) en la stage de runtime; el wheel Python compilado enlaza dinámicamente contra él. Saltarse este paso produce un build que funciona y un ImportError inmediato o un segfault al arrancar el contenedor, que es una brecha frustrante de depurar la primera vez que la encuentras.

Índices de paquetes privados. Si uv sync necesita alcanzar un espejo PyPI privado o una dependencia basada en Git detrás de auth, pasa la credencial como un build secret con --mount=type=secret, nunca como ARG o ENV — los build args aterrizan en el historial de la imagen y son recuperables con docker history, incluso para stages intermedias que no se envían.

Hacer deploy de una app FastAPI específicamente. Si estás containerizando un servicio FastAPI, el patrón de Dockerfile aquí es idéntico, pero dónde pones main.py, tus routers y tu dependency-injection dentro de la imagen importa para qué tan limpiamente funciona este caching de orden de copia — una estructura de proyecto como la de esta guía para estructurar un proyecto FastAPI mantiene el código de la aplicación limpiamente separado de la configuración, que es exactamente la separación que este patrón de Dockerfile quiere aprovechar.

Builds multi-plataforma (linux/amd64 + linux/arm64). docker buildx build --platform linux/amd64,linux/arm64 construye ambos en un solo paso, pero las dependencias compiladas sin wheels manylinux_aarch64 precompilados compilarán desde fuente en el tramo arm64, lo que es significativamente más lento en CI. Verifica la disponibilidad de wheels de tu árbol de dependencias antes de asumir que los builds multi-plataforma son gratis.

Desafíos de escala y producción

El caché de CI no persiste por defecto entre runners efímeros. Los cache mounts de BuildKit son locales a la máquina que ejecuta el build. En GitHub Actions o cualquier CI con runner efímero, ese caché se va al inicio del siguiente run a menos que lo exportes explícitamente — docker buildx build --cache-to=type=registry,ref=<repo>:buildcache --cache-from=type=registry,ref=<repo>:buildcache pushea el caché a un registro para que el próximo run, incluso en un runner nuevo, pueda recuperarlo.

El tamaño de imagen vs el conteo de layers es un tradeoff real a escala. Combinar instrucciones RUN con && reduce el conteo de layers, lo que ayuda ligeramente al tiempo de pull, pero también significa que un cambio en cualquier parte de esa instrucción combinada invalida todo. Para un Dockerfile que se construye docenas de veces al día en CI, favorecer más layers pequeños y bien ordenados sobre menos combinados es usualmente el mejor tradeoff.

Reproducibilidad entre builds. Fija tu imagen base por digest (python:3.12-slim-bookworm@sha256:...), no solo por tag, para cualquier cosa que necesites que sea reproducible bit a bit — los tags son mutables y los parches de seguridad de Debian se integran en las imágenes slim sin un bump de versión que notarías.

Ejemplos de código

Un Dockerfile multi-stage completo para un servicio FastAPI, usando uv y cache mounts de BuildKit:

# syntax=docker/dockerfile:1

FROM python:3.12-bookworm AS builder

RUN apt-get update && apt-get install -y --no-install-recommends \
        build-essential \
        libpq-dev \
    && rm -rf /var/lib/apt/lists/*

COPY --from=ghcr.io/astral-sh/uv:0.5 /uv /usr/local/bin/uv

WORKDIR /app

# Copy only dependency manifests first, so this layer (and the
# expensive install below) only invalidates when they actually change.
COPY pyproject.toml uv.lock ./

RUN --mount=type=cache,target=/root/.cache/uv \
    uv sync --frozen --no-install-project --no-dev

# Now copy the rest of the source and finish the install (this
# installs the project itself as an editable/non-editable package).
COPY . .
RUN --mount=type=cache,target=/root/.cache/uv \
    uv sync --frozen --no-dev


FROM python:3.12-slim-bookworm AS runtime

RUN apt-get update && apt-get install -y --no-install-recommends \
        libpq5 \
    && rm -rf /var/lib/apt/lists/* \
    && useradd --create-home --uid 1000 appuser

WORKDIR /app

COPY --from=builder --chown=appuser:appuser /app/.venv /app/.venv
COPY --from=builder --chown=appuser:appuser /app /app

USER appuser
ENV PATH="/app/.venv/bin:$PATH"
ENV PYTHONUNBUFFERED=1

EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

Construirlo con el caché de registro conectado para CI:

docker buildx build \
    --platform linux/amd64 \
    --cache-from type=registry,ref=myregistry.example.com/myapp:buildcache \
    --cache-to type=registry,ref=myregistry.example.com/myapp:buildcache,mode=max \
    -t myregistry.example.com/myapp:latest \
    --push .

Verificar qué se envió realmente:

docker images myregistry.example.com/myapp:latest
docker history myregistry.example.com/myapp:latest --no-trunc

Errores comunes

Error: COPY . . antes de instalar dependencias. Esta es la causa única más común de “¿por qué mi build de Docker tarda cuatro minutos para un cambio de una línea?” Solución: copia pyproject.toml/uv.lock primero, instala, luego copia el resto.

Error: enviar el compilador y los headers de la stage builder a producción. Usar un solo FROM sin stage de runtime separada significa que gcc, build-essential y cada paquete -dev que necesitaste para compilar una extensión nativa terminan en la imagen deployada. Solución: dividir en una stage builder y una de runtime; solo copiar el .venv terminado y el código de la app.

Error: instalar dependencias de dev en la imagen de producción. uv sync sin --no-dev instala tu framework de testing, linter y debugger en la imagen de runtime. Solución: siempre pasar --no-dev (o la exclusión de extras equivalente) para la instalación final de producción.

Error: olvidar la biblioteca compartida de runtime para una dependencia compilada. El wheel compila e instala bien en la stage builder, luego la app crashea al importar en la stage de runtime porque libpq.so no está ahí. Solución: identifica qué archivos .so de runtime necesita una dependencia compilada e instala el paquete de sistema no-dev correspondiente también en la stage de runtime.

Error: taggear imágenes solo con :latest, sin digest pinning en ninguna parte. Esto hace que un incidente de “¿por qué producción se comporta de repente diferente?” sea imposible de bisectar. Solución: fija las imágenes base por digest para reproducibilidad, y tagea las imágenes construidas con el SHA de Git además de cualquier tag :latest en movimiento.

Buenas prácticas para producción

  • Separa siempre el build tooling del runtime. Si un paquete necesita gcc para instalarse, ese gcc pertenece a una stage que nunca se envía.
  • Ordena las instrucciones del Dockerfile de menos a más frecuentemente cambiadas. Manifiestos de dependencias antes que código fuente, código fuente antes que cualquier cosa específica del entorno.
  • Usa uv con --frozen y un lockfile committed. Las instalaciones reproducibles importan más en un build de contenedor que casi en cualquier otro lugar, ya que es el único artefacto que tiene que comportarse idénticamente en CI, staging y producción.
  • Configura un caché BuildKit respaldado por registro en CI. Los cache mounts locales no valen nada en runners efímeros sin esto.
  • Corre como usuario no-root y fija las imágenes base por digest. Ambas cosas son casi gratis y reducen significativamente la superficie de ataque de tu imagen y el riesgo de no-reproducibilidad.

Conclusión

Pequeño, rápido, reproducible no son tres objetivos separados que se intercambian entre sí — la mayoría salen de las mismas pocas decisiones: separar lo que construye la app de lo que la ejecuta, ordenar tu Dockerfile para que el caché realmente te proteja, y dejar que uv y los cache mounts de BuildKit hagan el trabajo de paralelización y caché que pip nunca fue diseñado para hacer rápidamente. Nada de esto requiere herramientas exóticas ni una reescritura; es una reestructuración del Dockerfile que puedes hacer en una tarde, y la ganancia aparece en cada build posterior, no solo en el primero.

Si tu imagen Python actual todavía es un solo FROM python:3.12 con pip install -r requirements.txt sentado en algún lugar en el medio de un bloque COPY . ., esa es la línea que vale la pena arreglar primero — todo lo demás en este artículo se desprende de separar eso.

Faizan Nadeem
Autor
Faizan Nadeem

AI/GenAI engineer specialising in agentic RAG, LangGraph, and production LLM systems with Python and FastAPI. 2 years building AI agents, RAG pipelines, and the backends behind them.

Más artículos