Saltar al contenido

Capítulo 2 de 13

Imágenes que puedes reconstruir

Construir una aplicación pequeña, entender capas y caché y separar dependencias de construcción y ejecución.

#La imagen es el resultado, el Dockerfile es la receta

Ya puedes ejecutar una imagen publicada. Ahora necesitas incorporar tu programa. Un Dockerfile declara instrucciones para construir archivos y configuración de arranque. Una construcción no debería necesitar la base de datos de producción ni credenciales de usuarios: prepara un artefacto, no arranca el sistema completo.

Trabaja en una carpeta local de práctica. El contexto de construcción es el conjunto de archivos que permites usar al constructor; en docker build … ., el punto selecciona la carpeta actual. COPY lee de ese contexto, no de cualquier lugar del ordenador. Un archivo .dockerignore excluye lo que no debe enviarse.

#Primera imagen propia, con resultado visible

Guarda este app.py:

import os
from http.server import BaseHTTPRequestHandler, HTTPServer

class Handler(BaseHTTPRequestHandler):
    def do_GET(self):
        message = os.environ.get("SALUDO", "Hola desde una imagen")
        body = (message + "\n").encode("utf-8")
        self.send_response(200)
        self.send_header("Content-Type", "text/plain; charset=utf-8")
        self.send_header("Content-Length", str(len(body)))
        self.end_headers()
        self.wfile.write(body)

HTTPServer(("0.0.0.0", 8000), Handler).serve_forever()

http.server pertenece a Python: aquí evita instalar un framework para estudiar Docker. Handler hereda de una clase que sabe leer HTTP; redefinimos do_GET, el método que atiende peticiones GET. La respuesta se transforma en bytes porque la red transmite bytes. 0.0.0.0 significa escuchar en todas las interfaces del contenedor; no publica nada en el host. Este servidor mínimo es para experimentar, no un servidor de producción. El proyecto final usará Gunicorn y explicará su función.

Guarda un Dockerfile junto a ese archivo:

FROM python:3.13-slim
WORKDIR /app
ENV PYTHONUNBUFFERED=1 PYTHONDONTWRITEBYTECODE=1
RUN groupadd --gid 10001 app \
    && useradd --uid 10001 --gid app --no-create-home app
COPY app.py ./
USER 10001:10001
EXPOSE 8000
CMD ["python", "app.py"]

Y un .dockerignore:

.git
.venv
__pycache__
*.pyc
.env
secrets/
backups/

FROM incorpora Python y una base Debian reducida. WORKDIR fija /app como carpeta de trabajo dentro de la imagen. RUN ejecuta instrucciones durante la construcción; aquí crea un usuario con identificadores numéricos estables. COPY incorpora el programa. USER hace que se ejecute sin ser root. EXPOSE documenta el puerto, pero solo -p lo publica. CMD proporciona el comando que ejecutará cada contenedor.

PYTHONUNBUFFERED=1 evita que Python retenga mensajes de salida antes de escribirlos; ayuda a ver logs inmediatamente. PYTHONDONTWRITEBYTECODE=1 evita archivos .pyc al importar módulos; será útil cuando hagamos el sistema de archivos de solo lectura. No cambian la lógica del programa.

docker build -t saludo:1 .
docker run -d --name saludo -p 127.0.0.1:8000:8000 saludo:1
curl http://127.0.0.1:8000

Espera Hola desde una imagen. Si modificas app.py en el host, el contenedor no cambia: COPY hizo una copia durante la construcción. Construye saludo:2 y crea una nueva instancia para distribuir otra versión.

#Construcción y ejecución tienen configuraciones diferentes

docker run --rm -e SALUDO="Hola desde configuración" \
  -p 127.0.0.1:8001:8000 saludo:1

-e pasa una variable de entorno al proceso. La misma imagen puede servir mensajes diferentes sin reconstruirse. Al no usar -d, ves el proceso en primer plano. Puedes detenerlo desde otra terminal con docker stop usando su identificador.

Mecanismo Momento Uso razonable
ARG Construcción Elegir una opción de compilación o una versión base
ENV Imagen y valor predeterminado al ejecutar Valores no secretos como puerto o modo de logs
-e / environment Creación del contenedor Configuración específica del entorno
Archivo secreto montado Ejecución Una contraseña que lee el programa

No metas tokens en ARG, ENV ni COPY: pueden acabar en capas, metadatos o cachés. Borrar el archivo en una instrucción posterior no borra el contenido de una capa anterior. Los secretos de construcción permiten montarlo temporalmente durante un RUN si necesitas acceder a un registro privado de paquetes.

Un ejemplo específico sería RUN --mount=type=secret,id=pip_config,target=/etc/pip.conf pip install -r requirements.txt, pasando --secret id=pip_config,src=./pip.conf al construir. El archivo se monta solo durante esa instrucción. El programa que lo consume debe evitar imprimirlo o copiarlo al resultado; el montaje no controla lo que haga ese programa.

#Cómo funciona la caché y por qué el orden importa

Docker calcula si puede reutilizar el resultado de cada instrucción a partir de sus entradas. Si cambian los archivos de un COPY, ese paso y los pasos dependientes se reconstruyen. Esto explica por qué un Dockerfile puede tardar segundos o minutos sin que haya cambiado la velocidad del ordenador.

Flujo de caché: requisitos estables reutilizan instalación; cambios del código reconstruyen solo las capas posteriores.Ampliar diagrama ↗

Colocar dependencias antes que código limita la parte de la construcción que cambia en cada edición.

Cuando el programa use paquetes externos, requirements.txt es un archivo con nombres y versiones que entiende pip, el instalador de paquetes de Python. Usa este orden:

COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt
COPY app.py ./

El error habitual es COPY . . antes de instalar: cualquier cambio del README o del código invalida la instalación. .dockerignore reduce el contexto y la posibilidad de copiar secretos; no sustituye a elegir qué copiar.

--no-cache-dir evita conservar la caché de descargas de pip dentro de la capa. No desactiva la caché de Docker. Si quieres acelerar descargas sin incluirlas en la imagen, BuildKit —el constructor moderno de Docker— soporta un montaje de caché:

RUN --mount=type=cache,target=/root/.cache/pip \
    pip install -r requirements.txt

No combines esa variante con pip --no-cache-dir, porque pedirías a pip que no use la caché que acabas de montar. La caché es una optimización: una construcción con caché vacía también debe funcionar.

#Actualizar de forma deliberada

docker build --pull -t saludo:2 .

--pull comprueba la base en el registro. --no-cache fuerza rehacer pasos, pero no equivale por sí solo a descargar una base más nueva. Si fijas la base por digest, --pull seguirá obteniendo ese contenido concreto; actualizar implica cambiar el digest, probar y publicar una nueva imagen.

Un archivo de requisitos con versiones directas ayuda, pero no fija automáticamente todas las dependencias transitivas, el contenido de una etiqueta ni los paquetes del sistema. Para entregas reproducibles, registra también dependencias resueltas, hashes y digest de base. No confundas "puedo volver a construir" con "cada reconstrucción produce bytes idénticos".

#CMD, ENTRYPOINT y la ejecución de órdenes

CMD ["python", "app.py"] usa la forma de lista, también llamada exec. Docker inicia el ejecutable directamente. La forma CMD python app.py añade un shell intermedio; puede complicar el envío de señales de parada.

ENTRYPOINT fija el ejecutable y CMD puede fijar argumentos por defecto:

ENTRYPOINT ["python", "analizar.py"]
CMD ["--help"]

Entonces docker run imagen datos.csv sustituye --help por datos.csv. Para una aplicación web con comando que a veces cambiarás por una migración o un diagnóstico, un CMD suele ser más cómodo. No añadas ENTRYPOINT sin necesitar ese contrato de argumentos.

La lista exec no expande variables de shell. CMD ["echo", "$SALUDO"] imprime literalmente $SALUDO. Prefiere que la aplicación lea la variable; si necesitas un script de arranque, su última línea debería usar exec programa … para que el programa reemplace al shell.

#Varias etapas cuando aportan algo

Una construcción multistage separa herramientas de preparación de los archivos necesarios en ejecución. Por ejemplo, una biblioteca puede necesitar un compilador para instalarse, pero la aplicación no necesita ese compilador cada día.

Esta variante prepara un entorno virtual de Python y lo copia a una imagen final:

FROM python:3.13-slim AS dependencias
WORKDIR /build
RUN python -m venv /opt/venv
COPY requirements.txt ./
RUN /opt/venv/bin/pip install --no-cache-dir -r requirements.txt

FROM python:3.13-slim AS ejecucion
ENV PATH="/opt/venv/bin:$PATH" PYTHONUNBUFFERED=1
WORKDIR /app
RUN groupadd --gid 10001 app \
    && useradd --uid 10001 --gid app --no-create-home app
COPY --from=dependencias /opt/venv /opt/venv
COPY app.py ./
USER 10001:10001
CMD ["python", "app.py"]

Un entorno virtual agrupa los paquetes de una aplicación sin instalarlos globalmente. --from=dependencias copia desde la etapa anterior. Usar bases compatibles en ambas etapas importa: un paquete compilado contra otra versión de bibliotecas del sistema puede fallar al arrancar. Si necesita libpq u otra biblioteca compartida, esa dependencia de ejecución también debe estar en la etapa final.

No basta con añadir dos FROM para reducir el tamaño. En este ejemplo sin compilador, la diferencia puede ser modesta. La separación es útil si evita enviar herramientas o artefactos temporales; para un script sin dependencias, la primera imagen simple es más fácil de mantener.

#Revisar el resultado y elegir una base

docker image inspect saludo:1 --format '{{.Config.User}}'
docker image history saludo:1
docker run --rm saludo:1 python -c 'import os; print(os.getuid())'

El último comando reemplaza el CMD de la imagen para inspeccionar el usuario: debe mostrar 10001. history permite ver los pasos que produjeron las capas; no es un auditor completo de secretos.

slim reduce paquetes conservando un entorno Debian conocido. Alpine usa otra biblioteca C; algunos paquetes necesitan compilarse allí o no ofrecen las mismas ruedas binarias. Una base mínima sin shell reduce herramientas disponibles para un atacante y también para el diagnóstico. Decide según compatibilidad, actualizaciones, superficie de software y capacidad de operar, no solo por megabytes.

Pregunta: ¿por qué reconstruir una imagen no actualiza el contenedor?

El contenedor se creó a partir de un identificador de imagen concreto. Reasignar una etiqueta a una imagen nueva no reescribe esa ejecución. Necesitas crear un nuevo contenedor, preferiblemente con Compose, y conservar los datos fuera de su capa de escritura.

Limpia el ejemplo con docker stop saludo y docker rm saludo. La siguiente página explica lo que ocurre al proceso al recibir esa parada.

#Referencias para profundizar

    ↑ ↓ para recorrer · Intro para abrir · Ctrl K para buscar