--- title: "Cómo escribir un Dockerfile: imágenes pequeñas y seguras" description: "Escribe un Dockerfile para una app en Python, aprovecha la caché de capas, ejecútala sin root, añade un healthcheck y reduce su tamaño con builds multietapa." category: "Docker Kubernetes" language: es source: https://cubepath.com/es/docs/docker-kubernetes/creacion-de-dockerfiles-guia-completa updated: 2026-09-25 --- # Cómo escribir un Dockerfile: imágenes pequeñas y seguras Un Dockerfile es un archivo de texto con las instrucciones que Docker sigue para construir una imagen: la imagen base, los archivos que se copian, los paquetes que se instalan y el comando que arranca la aplicación. En este tutorial escribirás paso a paso el Dockerfile de una pequeña aplicación web en Python, optimizado para que se construya rápido, no se ejecute como root e informe de su estado. Al final verás cómo un build multietapa reduce una imagen compilada a unos pocos megabytes. ## Requisitos previos - Un servidor con Ubuntu 24.04 LTS y Docker Engine con el plugin Buildx instalados desde el repositorio oficial, por ejemplo un VPS de CubePath. - Un usuario no root con privilegios `sudo` y miembro del grupo `docker`. - Conocer los comandos básicos de Docker (`docker run`, `docker ps`, `docker logs`). ## Paso 1: Crear la aplicación de ejemplo Crea un directorio para el proyecto: ```bash mkdir -p ~/hola-docker && cd ~/hola-docker ``` Crea la aplicación, una API mínima con Flask: ```bash nano app.py ``` ```python import os import socket from flask import Flask, jsonify app = Flask(__name__) @app.get("/") def index(): return jsonify(message="Hola desde Docker", host=socket.gethostname(), env=os.environ.get("APP_ENV", "development")) @app.get("/health") def health(): return jsonify(status="ok") ``` Declara las dependencias con versiones fijas para que cada build instale exactamente lo mismo: ```bash nano requirements.txt ``` ```text flask==3.1.1 gunicorn==23.0.0 ``` En producción la aplicación se servirá con Gunicorn, no con el servidor de desarrollo de Flask. ## Paso 2: Escribir el Dockerfile Crea el archivo `Dockerfile` (sin extensión) en la raíz del proyecto: ```bash nano Dockerfile ``` ```dockerfile # syntax=docker/dockerfile:1 FROM python:3.12-slim ENV PYTHONDONTWRITEBYTECODE=1 \ PYTHONUNBUFFERED=1 WORKDIR /app # 1. Dependencias primero, para aprovechar la caché COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt # 2. Código de la aplicación COPY app.py . # 3. Usuario sin privilegios RUN groupadd --system app && useradd --system --gid app --no-create-home app USER app EXPOSE 8000 CMD ["gunicorn", "--bind", "0.0.0.0:8000", "--workers", "2", "app:app"] ``` Qué hace cada instrucción: | Instrucción | Función | |-------------|---------| | `# syntax=docker/dockerfile:1` | Usa la versión estable más reciente del intérprete de Dockerfile de BuildKit | | `FROM` | Imagen base. La variante `slim` de Debian ocupa mucho menos que la completa | | `ENV` | Variables de entorno disponibles en el build y en el contenedor. Aquí evitan archivos `.pyc` y el buffering de la salida, para que los logs aparezcan al instante | | `WORKDIR` | Directorio de trabajo para las instrucciones siguientes; se crea si no existe | | `COPY` | Copia archivos del contexto de build a la imagen | | `RUN` | Ejecuta un comando durante el build y guarda el resultado como una capa | | `USER` | Usuario con el que se ejecutan las instrucciones siguientes y el contenedor | | `EXPOSE` | Documenta el puerto en el que escucha la aplicación. No publica nada por sí mismo | | `CMD` | Comando por defecto al arrancar el contenedor | Usa siempre la forma JSON (exec) en `CMD` y `ENTRYPOINT`, como en el ejemplo. Así el proceso de la aplicación es el PID 1 y recibe la señal `SIGTERM` de `docker stop`, en lugar de quedar debajo de un `/bin/sh -c` que no la reenvía. > Nota: Usa `COPY` para copiar archivos locales. `ADD` también descomprime archivos tar y descarga URLs, un comportamiento implícito que rara vez necesitas. ## Paso 3: Excluir archivos con .dockerignore Al construir, Docker envía todo el directorio (el **contexto**) al motor de build. Un archivo `.dockerignore` evita que se copien a la imagen archivos innecesarios o secretos: ```bash nano .dockerignore ``` ```text .git .venv __pycache__/ *.pyc .env Dockerfile .dockerignore ``` Excluir `.env` es especialmente importante: sin esta línea, un `COPY . .` metería tus credenciales dentro de la imagen. ## Paso 4: Construir y ejecutar la imagen Construye la imagen y etiquétala con un nombre y una versión (`-t`). El punto final indica que el contexto es el directorio actual: ```bash docker build -t hola-docker:1.0 . ``` ```output [+] Building 18.4s (12/12) FINISHED => [internal] load build definition from Dockerfile => [1/6] FROM docker.io/library/python:3.12-slim => [2/6] WORKDIR /app => [3/6] COPY requirements.txt . => [4/6] RUN pip install --no-cache-dir -r requirements.txt => [5/6] COPY app.py . => [6/6] RUN groupadd --system app && useradd --system --gid app --no-create-home app => exporting to image => => naming to docker.io/library/hola-docker:1.0 ``` Ejecuta un contenedor publicando el puerto 8000 solo en localhost y pasando una variable de entorno: ```bash docker run -d --name hola -p 127.0.0.1:8000:8000 -e APP_ENV=production hola-docker:1.0 ``` Comprueba que responde: ```bash curl http://127.0.0.1:8000/ ``` ```output {"env":"production","host":"8d2c1f0e7a4b","message":"Hola desde Docker"} ``` Verifica también que el proceso no se ejecuta como root: ```bash docker exec hola id ``` ```output uid=999(app) gid=999(app) groups=999(app) ``` ## Paso 5: Aprovechar la caché de capas Cada instrucción genera una capa, y Docker reutiliza las capas cuyas entradas no han cambiado. Por eso el Dockerfile copia `requirements.txt` e instala las dependencias antes de copiar el código: al cambiar `app.py`, las dependencias no se vuelven a instalar. Compruébalo. Modifica el mensaje de `app.py` y vuelve a construir: ```bash sed -i 's/Hola desde Docker/Hola de nuevo/' app.py docker build -t hola-docker:1.1 . ``` ```output => CACHED [2/6] WORKDIR /app => CACHED [3/6] COPY requirements.txt . => CACHED [4/6] RUN pip install --no-cache-dir -r requirements.txt => [5/6] COPY app.py . => [6/6] RUN groupadd --system app && useradd --system --gid app --no-create-home app ``` El build tarda un par de segundos porque solo se rehacen las capas posteriores al cambio. Si hubieras escrito `COPY . .` antes de `pip install`, cualquier cambio en el código invalidaría la caché y reinstalaría todo. Sigue también estas pautas al escribir instrucciones `RUN`: - Encadena en un solo `RUN` los comandos que deben ir juntos, como `apt-get update` y `apt-get install`, para que no quede en caché un índice de paquetes antiguo. - Limpia en la misma instrucción lo que no necesitas. Borrarlo en una capa posterior no reduce el tamaño de la imagen. Por ejemplo, si tu aplicación necesitara `curl` dentro de la imagen: ```dockerfile RUN apt-get update \ && apt-get install -y --no-install-recommends curl \ && rm -rf /var/lib/apt/lists/* ``` ## Paso 6: Añadir un healthcheck `HEALTHCHECK` indica a Docker cómo comprobar que la aplicación funciona, no solo que el proceso existe. La imagen `slim` no incluye `curl`, así que la comprobación usa el propio Python. Añade estas líneas al `Dockerfile`, justo antes de `CMD`: ```dockerfile HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \ CMD ["python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=2)"] ``` Si la petición falla, Python termina con código distinto de cero y Docker cuenta un fallo. Tras tres fallos seguidos, el contenedor pasa a `unhealthy`. Reconstruye y sustituye el contenedor: ```bash docker build -t hola-docker:1.2 . docker rm -f hola docker run -d --name hola -p 127.0.0.1:8000:8000 hola-docker:1.2 ``` Espera unos segundos y consulta el estado: ```bash docker inspect --format '{{.State.Health.Status}}' hola ``` ```output healthy ``` La columna `STATUS` de `docker ps` también muestra `(healthy)`. Docker Compose puede usar este estado para arrancar un servicio solo cuando su dependencia está sana. ## Paso 7: Reducir el tamaño con un build multietapa En lenguajes compilados, la imagen final no necesita el compilador. Un build multietapa usa una imagen grande para compilar y copia solo el binario a una imagen mínima. Crea un proyecto aparte en Go: ```bash mkdir -p ~/hola-go && cd ~/hola-go nano main.go ``` ```go package main import ( "fmt" "log" "net/http" ) func main() { http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { fmt.Fprintln(w, "Hola desde Go") }) log.Fatal(http.ListenAndServe(":8080", nil)) } ``` Crea el archivo de módulo: ```bash nano go.mod ``` ```text module example.com/hola go 1.24 ``` Y el Dockerfile con dos etapas: ```bash nano Dockerfile ``` ```dockerfile # syntax=docker/dockerfile:1 FROM golang:1.24 AS build WORKDIR /src COPY go.mod ./ COPY main.go ./ RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /out/hola . FROM gcr.io/distroless/static-debian12:nonroot COPY --from=build /out/hola /hola EXPOSE 8080 ENTRYPOINT ["/hola"] ``` La primera etapa (`build`) tiene todo el toolchain de Go. La segunda parte de una imagen distroless, sin shell ni gestor de paquetes, que ya se ejecuta con un usuario no root. `COPY --from=build` trae únicamente el binario compilado estáticamente. Construye y compara tamaños: ```bash docker build -t hola-go:1.0 . docker image ls --format 'table {{.Repository}}:{{.Tag}}\t{{.Size}}' | grep -E 'hola|golang' ``` ```output hola-go:1.0 9.6MB hola-docker:1.2 161MB golang:1.24 853MB ``` Prueba la imagen: ```bash docker run --rm -d --name hola-go -p 127.0.0.1:8080:8080 hola-go:1.0 curl http://127.0.0.1:8080/ docker stop hola-go ``` ```output Hola desde Go ``` ## Solución de problemas **`COPY failed: file not found in build context`.** El archivo no está en el directorio del contexto o lo excluye `.dockerignore`. Las rutas de `COPY` son siempre relativas al contexto, nunca pueden salir de él con `../`. **El contenedor tarda 10 segundos en detenerse.** El proceso no recibe `SIGTERM`, normalmente porque `CMD` usa la forma shell (`CMD gunicorn ...`). Cámbiala a la forma JSON. **`Permission denied` al escribir archivos con `USER app`.** El usuario sin privilegios no puede escribir en directorios de root. Monta un volumen para los datos o asigna el directorio con `COPY --chown=app:app` o `RUN chown` antes de la instrucción `USER`. ## Conclusión Has escrito un Dockerfile que aprovecha la caché de capas, no incluye secretos gracias a `.dockerignore`, ejecuta la aplicación sin root, informa de su estado con `HEALTHCHECK`, y has visto cómo un build multietapa reduce una imagen a menos de 10 MB. Como siguientes pasos, define la aplicación junto a su base de datos con Docker Compose, guarda sus datos en volúmenes y sube la imagen a un registro privado para desplegarla en otros servidores.