Guía práctica para crear Dockerfiles desde cero

Guía práctica para crear Dockerfiles desde cero

¿Qué problema resuelve Docker?

Imagina que desarrollas una aplicación en tu portátil y funciona perfectamente. Cuando tu compañero intenta ejecutarla en su máquina, aparecen errores por versiones distintas de librerías, configuraciones faltantes o diferencias en el sistema operativo. Docker elimina este conflicto al empaquetar tu aplicación junto con todas sus dependencias en una unidad portable denominada contenedor.

Enfoque tradicional Con Docker
Instalar dependencias manualmente en cada equipo Todo viene incluido dentro del contenedor
Las máquinas virtuales consumen varios GB y tardan en arrancar Los contenedores pesan decenas de MB y se lanzan en segundos

Conceptos fundamentales

Antes de escribir un Dockerfile, es necesario entender cuatro elementos clave:

  • Imagen: Una plantilla de solo lectura que contiene el sistema operativo base, las herramientas instaladas y tu código. Piensa en ella como una fotografía congelada de un entorno funcional.
  • Contenedor: Una instancia en ejecución creada a partir de una imagen. Puedes tener múltiples contenedores basados en la misma imagen.
  • Dockerfile: Un archivo de texto con instrucciones secuenciales que describe paso a paso cómo construir una imagen personalizada.
  • Registro de imágenes: Un repositorio remoto (como Docker Hub) donde se almacenan y comparten imágenes.

La relación entre estos elementos es análoga a un receta de cocina: el Dockerfile es la receta, la imagen es el plato terminado listo para servir, y el contenedor es cada porción que se sirve individualmente.

Estructura básica de un Dockerfile

# Seleccionamos una imagen base ligera de Debian
FROM debian:bookworm-slim

# Metadatos opcionales del mantenedor
LABEL maintainer="equipo@midominio.com"

# Definimos variables de entorno accesibles durante la construcción y ejecución
ENV RUTA_APP=/opt/miapp
ENV DEBIAN_FRONTEND=noninteractive

# Establecemos el directorio de trabajo
WORKDIR ${RUTA_APP}

# Transferimos archivos desde la máquina host hacia la imagen
COPY configuracion.yml ./
COPY codigo/ ./codigo/

# Ejecutamos comandos dentro de la imagen (cada RUN genera una capa)
RUN apt-get update -qq && \
    apt-get install -y --no-install-recommends curl ca-certificates && \
    apt-get clean && \
    rm -rf /var/lib/apt/lists/*

# Declaramos qué puerto usará la aplicación internamente
EXPOSE 8080

# Definimos el comando de inicio
CMD ["./codigo/arrancar.sh"]

Instrucciones esenciales del Dockerfile

FROM — Seleccionar la imagen base

Toda imagen comienza con un FROM. Puedes elegir entre distintas variantes según tus necesidades:

# Variante ligera recomendada para producción
FROM node:20-bookworm-slim

# Variante aún más reducida usando Alpine Linux
FROM python:3.12-alpine

# Imagen genérica cuando necesitas un sistema completo
FROM ubuntu:24.04

RUN — Ejecutar comandos durante la construcción

Cada instrucción RUN genera una nueva capa en la imagen. Para minimizar el tamaño final, conviene agrupar operaciones relacionadas en una sola instrucción:

# Enfoque ineficiente: múltiples capas innecesarias
RUN apt-get update
RUN apt-get install -y nginx
RUN rm -rf /var/lib/apt/lists/*

# Enfoque recomendado: una sola capa optimizada
RUN apt-get update -qq && \
    apt-get install -y --no-install-recommends nginx && \
    rm -rf /var/lib/apt/lists/*

COPY y ADD — Transferir archivos

COPY es la opción preferida para copiar archivos del contexto de construcción hacia la imagen. ADD realiza lo mismo pero añade funcionalidades extra como descomprimir archivos tar automáticamente, lo cual puede generar comportamientos inespeardos y por ello se desaconseja su uso habitual.

# Copiar un archivo específico
COPY mi_configuracion.ini /etc/aplicacion/

# Copiar todo el contenido del directorio actual
COPY . /opt/servicio/

# ADD descomprime automáticamente (usar con precaución)
ADD recursos.tar.gz /opt/recursos/

CMD y ENTRYPOINT — Definir el proceso principal

CMD establece el comando por defecto que se ejecuta al iniciar el contenedor, pero puede ser sobreescrito al lanzar el contenedor con docker run. ENTRYPOINT fija un ejecutable que no se sobreescribe; los argumentos de CMD se le pasan como parámetros.

# Configuración combinada recomendada
ENTRYPOINT ["/usr/bin/python3"]
CMD ["servidor.py"]

# Al ejecutar: docker run mi-imagen → ejecuta python3 servidor.py
# Al ejecutar: docker run mi-imagen verificador.py → ejecuta python3 verificador.py

EXPOSE — Declarar puertos

EXPOSE es únicamente documentación. Indica qué puertos pretende usar la aplicación, pero no realiza ningún mapeo real. Para acceder al puerto desde fuera, debes usar el parámetro -p al ejecutar el contenedor.

EXPOSE 8080
# Mapeo real: docker run -p 8080:8080 mi-imagen

Ejemplo completo: aplicación web con FastAPI (Python)

Estructura del proyecto

api-fastapi/
├── main.py
├── dependencias.txt
└── Dockerfile

main.py

from fastapi import FastAPI

aplicacion = FastAPI()

@aplicacion.get("/")
async def inicio():
    return {"mensaje": "API funcionando correctamente"}

@aplicacion.get("/saludo/{nombre}")
async def saludar(nombre: str):
    return {"saludo": f"Hola, {nombre}!"}

if __name__ == "__main__":
    import uvicorn
    uvicorn.run(aplicacion, host="0.0.0.0", port=8000)

dependencias.txt

fastapi==0.111.0
uvicorn==0.30.1

Dockerfile

# Imagen base de Python optimizada
FROM python:3.12-slim

# Evitar archivos .pyc y forzar salida directa de logs
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1

# Crear y usar un directorio dedicado
WORKDIR /srv/app

# Instalar dependencias primero (mejor aprovechamiento de caché)
COPY dependencias.txt .
RUN pip install --no-cache-dir --quiet -r dependencias.txt

# Copiar el código fuente
COPY main.py .

# Declarar el puerto de escucha
EXPOSE 8000

# Lanzar la aplicación con uvicorn
CMD ["uvicorn", "main:aplicacion", "--host", "0.0.0.0", "--port", "8000"]

Construcción y ejecución

# Crear la imagen
docker build -t api-fastapi .

# Iniciar el contenedor
docker run -d -p 8000:8000 --nombre mi-api api-fastapi

# Verificar que funciona
curl http://localhost:8000/

Ejemplo completo: servidor web con Hono (TypeScript)

Dockerfile

# Fase 1: construir el proyecto TypeScript
FROM denoland/deno:2.1.4 AS compilador

WORKDIR /proyecto
COPY deno.json deno.lock* ./
COPY src/ ./src/

RUN deno cache src/index.ts

# Fase 2: imagen de ejecución reducida
FROM denoland/deno:2.1.4

WORKDIR /srv
COPY --from=compilador /proyecto .

EXPOSE 4000

USER deno

CMD ["run", "--allow-net", "--allow-env", "src/index.ts"]

Este ejemplo incluye un usuario no-root (USER deno) para mejorar la seguridad, y utiliza multi-etapa para separar la fase de compilación de la de ejecución.

Ejemplo completo: aplicación Go con compilación multi-etapa

servidor.go

package main

import (
	"fmt"
	"net/http"
)

func principal(w http.ResponseWriter, r *http.Request) {
	fmt.Fprintf(w, "Servidor Go activo!")
}

func main() {
	http.HandleFunc("/", principal)
	fmt.Println("Escuchando en puerto 5500...")
	http.ListenAndServe(":5500", nil)
}

Dockerfile

# === Etapa de compilación ===
FROM golang:1.23-alpine AS compilador

WORKDIR /fuente
COPY go.mod go.sum ./
RUN go mod download

COPY servidor.go .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o ejecutable servidor.go

# === Etapa de ejecución (imagen mínima) ===
FROM scratch

COPY --from=compilador /fuente/ejecutable /ejecutable

EXPOSE 5500

ENTRYPOINT ["/ejecutable"]

Al usar FROM scratch como base, la imagen final contiene únicamente el binario compilado, alcanzando un tamaño de apenas unos pocos megabytes.

$ docker images mi-servidor-go
REPOSITORY         TAG       SIZE
mi-servidor-go     latest    6.1MB

Recomendaciones de buenas prácticas

Ámbito Práctica recomendada Motivo
Imagen base Usar variantes -slim o -alpine Reduce drásticamente el tamaño de la imagen
Capas Colocar primero las instrucciones que cambian menos Aprovecha la caché de construcción y acelera los rebuilds
Seguridad Crear un usuario dedicado con RUN adduser y USER Evita ejecutar procesos como root dentro del contenedor
Depuración Borrar cachés de gestores de paquetes tras la instalación Elimina archivos innecesarios del producto final
Contexto Utilizar un archivo .dockerignore Evita copiar archivos innecesarios y posibles secretos

Archivo .dockerignore

Crea un fichero llamado .dockerignore en la raíz del proyecto para excluir archivos del contexto de construcción:

node_modules
__pycache__
*.pyc
.git
.github
.env
.env.local
dist
build
*.log
Dockerfile
.dockerignore
README.md
LICENSE

Este archivo cumple dos propósitos: acelera el proceso de construcción al reducir el tamaño del contexto enviado al demonio de Docker, y evita incluir secretos como variables de entorno o historiales de control de versiones.

Entorno de desarrollo con recarga automática

Durante el desarrollo, resulta útil que los cambios en el código se reflejen automáticamente sin reconstruir la imagen. Para una aplicación Node.js, puedes montar un volumen y usar un monitor de archivos:

FROM node:20-alpine

WORKDIR /desarrollo
COPY paquete.json paquete-lock.json ./
RUN npm install
RUN npm install -g nodemon

COPY . .

EXPOSE 3000

CMD ["nodemon", "--watch", "src", "--ext", "ts", "src/servidor.ts"]

Lanzar el contenedor con el volumen enlazado:

docker run --rm -v $(pwd)/src:/desarrollo/src -p 3000:3000 mi-app-dev

Cada modificación en la carpeta src del host provocará que nodemon reinicie la aplicación automáticamente dentro del contenedor.

Referencia rápida de comandos de Docker

Comando Descripción
docker build -t etiqueta . Construir una imagen a partir del Dockerfile actual
docker run -d -p externo:interno etiqueta Ejecutar un contenedor en segundo plano con mapeo de puertos
docker ps Listar contenedores en ejecución
docker ps -a Listar todos los contenedores (incluyendo detenidos)
docker images Mostrar imágenes almacenadas localmente
docker logs identificador Consultar la salida estándar de un contenedor
docker exec -it identificador sh Abrir una termnial interactiva dentro del contenedor
docker stop identificador Detener un contenedor en ejecución
docker rm identificador Eliminar un contenedor detenido
docker rmi etiqueta Eliminar una imagen local

Solución de problemas frecuentes

Síntoma Causa probable Solución
bind: address already in use El puerto asignado está ocupado por otro proceso Especificar un puerto alternativo: -p 8081:8080
permission denied al acceder a archivos El proceso corre como root y el host tiene permisos distintos Definir un usuario con USER y ajustar propietarios con chown
La imagen ocupa varios cientos de MB Se usó una imagen base completa sin limpiar cachés Alternar a -slim o -alpine y eliminar listas de paquetes tras instalar
Los cambios en el código no surten efecto Se ejecutó un contenedor con una imagen antigua Volver a ejecutar docker build antes de docker run
no such file or directory en COPY El archivo no está dentro del contexto de construcción o está en .dockerignore Verificar la ruta y revisar el archivo .dockerignore

Etiquetas: dockerfile Docker contenedores multi-stage-build DevOps

Publicado el 6-15 20:22