Docker y contenedores
Contenedores con Docker: diferencia con las máquinas virtuales, imágenes y capas, Dockerfile, registros, ciclo de vida del contenedor, volúmenes, redes, Docker Compose, buenas prácticas de seguridad y tamaño, y una API de FastAPI empaquetada paso a paso.
Definición
Un contenedor es un proceso (o un grupo de procesos) que se ejecuta aislado del resto del sistema y que lleva consigo todo lo que necesita para funcionar: el código, el intérprete o las bibliotecas, las dependencias y la configuración base. Lo que se aísla no es una computadora completa, sino la vista que el proceso tiene del sistema: sus archivos, su red, sus procesos y sus recursos. Por eso el mismo contenedor se comporta igual en el equipo del desarrollador, en el de un colega y en un servidor de producción. Resuelve el problema clásico de «en mi computadora sí funcionaba».
Docker es la plataforma que popularizó los contenedores. Su pieza central es el Docker Engine, formado por tres partes:
- El demonio (daemon,
dockerd): el servicio que construye imágenes y crea, ejecuta y supervisa los contenedores. - La API del demonio: la interfaz mediante la cual otros programas se comunican con él.
- El cliente de línea de comandos (CLI, el comando
docker): traduce lo que escribes en peticiones al demonio.
Docker no es el único producto que trabaja con contenedores. Para que las herramientas sean intercambiables existe la OCI (Open Container Initiative), un proyecto de la Linux Foundation que define dos especificaciones abiertas: el formato de imagen (cómo se empaqueta una aplicación) y el entorno de ejecución (cómo se arranca ese paquete). Una imagen construida con Docker puede ejecutarse con otros motores compatibles con OCI, como Podman o containerd.
Contenedores frente a máquinas virtuales
Una máquina virtual (virtual machine, VM) emula un equipo completo: un hipervisor reparte el hardware entre varias VM y cada una arranca su propio sistema operativo con su propio kernel. Un contenedor, en cambio, comparte el kernel del sistema anfitrión y solo aísla el espacio de usuario. Dos mecanismos del kernel de Linux lo hacen posible:
- Namespaces (espacios de nombres): limitan lo que un proceso puede ver. Hay espacios separados para los identificadores de proceso (
pid), la red (net), los puntos de montaje del sistema de archivos (mnt), el nombre del equipo (uts) y otros. Dentro del contenedor, la aplicación cree ser el proceso número 1 de una máquina propia. - Control groups (cgroups, grupos de control): limitan lo que un proceso puede consumir: memoria, tiempo de procesador y operaciones de disco. Evitan que un contenedor agote los recursos de los demás.
| Criterio | Máquina virtual | Contenedor |
|---|---|---|
| Qué aísla | Un equipo completo emulado | Procesos con su propia vista del sistema |
| Kernel | Uno por máquina virtual | Compartido con el anfitrión |
| Tamaño típico | De gigabytes (incluye un sistema operativo completo) | De decenas a cientos de megabytes |
| Arranque | De decenas de segundos a minutos | Normalmente, fracciones de segundo a pocos segundos |
| Aislamiento | Más fuerte: el límite es el hipervisor | Más débil: el límite es el kernel compartido |
| Uso típico | Ejecutar sistemas operativos distintos, aislamiento estricto | Empaquetar y desplegar aplicaciones y servicios |
Como el contenedor usa el kernel del anfitrión, un contenedor de Linux necesita un kernel Linux. En Windows y macOS esto se resuelve ejecutando una máquina virtual Linux ligera en segundo plano; Docker Desktop la administra por ti. En Windows se apoya en WSL 2 (Windows Subsystem for Linux, versión 2), que ejecuta un kernel Linux real dentro de una VM optimizada. Los contenedores no sustituyen a las máquinas virtuales: se complementan, y es frecuente que los contenedores corran dentro de VM en la nube.
Imágenes y capas
Una imagen (image) es una plantilla de solo lectura que contiene el sistema de archivos de la aplicación y sus metadatos (comando de arranque, variables de entorno, puerto). Un contenedor es una instancia en ejecución de una imagen: de una misma imagen pueden salir muchos contenedores, igual que de una receta salen muchos platos.
Una imagen no es un bloque único, sino una pila de capas (layers). Cada instrucción del Dockerfile que modifica archivos genera una capa nueva que contiene solo las diferencias respecto a la anterior. Las capas se identifican por un hash de su contenido, por lo que se pueden compartir: si diez imágenes parten de la misma base, esa base se almacena y se descarga una sola vez.
Para presentar todas las capas como un único sistema de archivos se usa un sistema de archivos de unión (union filesystem, por ejemplo overlay2 en Linux). Las capas de la imagen son de solo lectura; al crear un contenedor, Docker agrega encima una capa de escritura propia. Si el contenedor modifica un archivo de la imagen, se copia primero a esa capa superior (copy-on-write). Al eliminar el contenedor, esa capa se descarta, y con ella todo lo que se escribió dentro.
Caché de capas
Al construir una imagen, Docker reutiliza una capa si la instrucción y los archivos que usa no cambiaron desde la construcción anterior. En cuanto una instrucción cambia, se invalidan esa capa y todas las posteriores. Por eso el orden importa: se colocan primero las instrucciones que cambian poco (instalar dependencias) y después las que cambian siempre (copiar el código fuente). Así, editar una línea de tu programa no vuelve a descargar todas las dependencias.
El Dockerfile
El Dockerfile es un archivo de texto con las instrucciones para construir una imagen, una por línea. Es la receta: declarativa, versionable junto al código y reproducible.
| Instrucción | Función |
|---|---|
FROM | Define la imagen base sobre la que se construye (por ejemplo python:3.12-slim). Toda construcción comienza con ella |
WORKDIR | Establece el directorio de trabajo dentro de la imagen; lo crea si no existe, y las instrucciones siguientes se ejecutan desde ahí |
COPY | Copia archivos desde el contexto de construcción (tu carpeta) hacia la imagen |
RUN | Ejecuta un comando durante la construcción y guarda el resultado como una capa nueva (instalar paquetes, crear usuarios) |
ENV | Define variables de entorno que persisten en la imagen y en los contenedores |
EXPOSE | Documenta el puerto en el que escucha la aplicación. No lo publica hacia el exterior: eso se hace al ejecutar |
USER | Fija el usuario con el que se ejecutarán las instrucciones posteriores y el contenedor |
CMD | Comando predeterminado al arrancar el contenedor; se puede sustituir desde la línea de comandos |
ENTRYPOINT | Programa fijo que se ejecuta siempre al arrancar; los argumentos de CMD o de la línea de comandos se le agregan |
Dos detalles de RUN frente a CMD: RUN ocurre una sola vez al construir la imagen; CMD ocurre cada vez que se arranca un contenedor. Y se recomienda la forma exec de CMD y ENTRYPOINT, con corchetes y comillas dobles (["uvicorn", "main:app"]), porque ejecuta el programa directamente y no a través de un intérprete de comandos, lo que permite que reciba correctamente las señales de apagado.
El contexto de construcción (build context) es el conjunto de archivos que Docker recibe al ejecutar docker build; el punto final del comando (.) indica que es la carpeta actual. COPY solo puede tomar archivos de ese contexto.
Registros, etiquetas y digest
Un registro (registry) es un servicio que almacena y distribuye imágenes. Docker Hub es el registro público predeterminado; también existen GitHub Container Registry, Amazon ECR, Google Artifact Registry o Azure Container Registry, además de registros privados. Los comandos docker pull (descargar) y docker push (publicar) interactúan con ellos.
El nombre completo de una imagen tiene esta forma:
registro/repositorio:etiqueta
docker.io/library/python:3.12-slim
- Repositorio: el conjunto de versiones de una misma imagen (
python). En Docker Hub, las imágenes oficiales viven bajolibrary/, que se omite al escribirlas. - Etiqueta (tag): un nombre legible que apunta a una versión (
3.12-slim,latest). Es mutable: el mantenedor puede mover la misma etiqueta a una imagen nueva, ylatestno significa «la más reciente probada», solo es la etiqueta que se aplica cuando no se indica otra. - Digest: el hash SHA-256 del contenido de la imagen (
python@sha256:...). Es inmutable: un digest identifica exactamente una imagen, byte por byte.
Para reproducibilidad estricta se fija el digest; para el trabajo cotidiano basta con una etiqueta de versión específica (3.12-slim) y nunca latest.
Ciclo de vida de un contenedor
Un contenedor pasa por estados: se crea a partir de una imagen, se ejecuta, se detiene y se elimina. Los comandos fundamentales:
| Comando | Acción |
|---|---|
docker build -t nombre . | Construye una imagen a partir del Dockerfile de la carpeta actual y le asigna el nombre indicado con -t (tag) |
docker run nombre | Crea y arranca un contenedor desde la imagen |
docker ps | Lista los contenedores en ejecución (docker ps -a incluye los detenidos) |
docker logs contenedor | Muestra lo que el proceso escribió en su salida estándar y de errores |
docker stop contenedor | Solicita el apagado ordenado (envía la señal SIGTERM y, pasado un plazo, SIGKILL) |
docker rm contenedor | Elimina un contenedor detenido, junto con su capa de escritura |
docker images / docker rmi imagen | Lista / elimina imágenes locales |
Opciones de docker run de uso frecuente: -d (detached, en segundo plano), -p (publicar puertos), --name (nombre propio), -e (variable de entorno), -v (volumen) y --rm (eliminar el contenedor automáticamente al terminar).
Un contenedor está pensado para ser efímero: debe poder destruirse y recrearse en cualquier momento sin pérdida. Esto lleva directo al siguiente tema.
Volúmenes y persistencia
Todo lo que un contenedor escribe en su capa de escritura desaparece con él. Para conservar datos (una base de datos, archivos subidos por usuarios) se usan mecanismos que guardan la información fuera del contenedor:
| Mecanismo | Qué es | Cuándo usarlo |
|---|---|---|
| Volumen (volume) | Almacenamiento administrado por Docker, independiente del ciclo de vida del contenedor | Datos que deben persistir: bases de datos, archivos de la aplicación |
| Montaje de enlace (bind mount) | Una carpeta de tu equipo montada dentro del contenedor | Desarrollo: editar el código en tu equipo y verlo reflejado en el contenedor |
| tmpfs | Almacenamiento en memoria, que se pierde al detener el contenedor | Datos temporales o sensibles que no deben tocar el disco |
docker volume create datos
docker run -d --name bd -v datos:/var/lib/postgresql/data postgres:16
Aquí datos:/var/lib/postgresql/data significa «monta el volumen datos en esa ruta del contenedor». Si eliminas el contenedor bd y creas otro con el mismo volumen, los datos siguen ahí.
Redes y puertos
Cada contenedor tiene su propia interfaz de red, aislada por un namespace. Por defecto, Docker conecta los contenedores a una red virtual interna (bridge). Dos ideas separan lo que es accesible desde dónde:
- Publicar un puerto con
-p PUERTO_ANFITRIÓN:PUERTO_CONTENEDORcrea una regla que redirige el tráfico del puerto del equipo al del contenedor. Con-p 8000:8000, lo que llega alocalhost:8000se entrega al puerto 8000 del contenedor. Sin-p, el servicio solo es alcanzable desde la red interna de Docker. - Redes definidas por el usuario: los contenedores conectados a la misma red pueden encontrarse entre sí por el nombre del contenedor o del servicio, que Docker resuelve mediante un DNS interno. Una API puede conectarse a
bd:5432sin conocer direcciones IP, que cambian en cada ejecución.
Un error muy común: la aplicación dentro del contenedor debe escuchar en 0.0.0.0 (todas las interfaces) y no en 127.0.0.1. En 127.0.0.1, solo el propio contenedor podría alcanzarla y el puerto publicado no recibiría nada. Por eso el comando de arranque de la práctica incluye --host 0.0.0.0.
Docker Compose
Una aplicación real rara vez es un solo contenedor: suele ser una API, una base de datos, una caché. Docker Compose es la herramienta para definir y ejecutar aplicaciones de varios contenedores con un único archivo declarativo en formato YAML, llamado compose.yaml. En él se describen los servicios (services), sus imágenes o su construcción, puertos, variables, volúmenes y redes. Compose crea automáticamente una red común donde cada servicio es accesible por su nombre.
services:
api:
build: .
ports:
- "8000:8000"
environment:
DATABASE_URL: postgresql://app:cambia_esto@bd:5432/app
depends_on:
- bd
bd:
image: postgres:16
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: cambia_esto
POSTGRES_DB: app
volumes:
- datos:/var/lib/postgresql/data
volumes:
datos:
Este ejemplo ilustra la estructura (un servicio construido localmente, otro descargado de un registro, un volumen con nombre y comunicación por nombre de servicio). Los valores cambia_esto son marcadores: en un proyecto real no se escriben contraseñas en el archivo, sino que se leen de un archivo .env fuera del control de versiones o de un gestor de secretos.
Los comandos principales: docker compose up (crea y arranca todos los servicios; -d en segundo plano, --build fuerza la reconstrucción de las imágenes), docker compose ps, docker compose logs y docker compose down (detiene y elimina los contenedores y la red; los volúmenes se conservan salvo que se agregue -v).
Buenas prácticas
- Usar una imagen base pequeña y de versión fija. Variantes como
-slim(oalpine) reducen el tamaño y la superficie de ataque. Elegirpython:3.12-slimy nopython:latestevita que una actualización inesperada rompa la construcción. - Compilaciones en varias etapas (multi-stage builds). Se usa una primera etapa con compiladores y herramientas pesadas para construir la aplicación y una segunda, mínima, a la que solo se copia el resultado (
COPY --from=construccion ...). La imagen final no arrastra lo que solo hacía falta para construir. - Un archivo
.dockerignore. Funciona como.gitignorepara el contexto de construcción: excluye.git, entornos virtuales, cachés y archivos.env. Reduce el contexto, acelera la construcción y evita copiar secretos a la imagen por accidente. - No ejecutar como administrador. Por defecto, el proceso del contenedor corre como
root. Crear un usuario sin privilegios y activarlo conUSERlimita el daño si la aplicación es vulnerada. - Fijar las versiones de las dependencias (
fastapi==0.115.14, nofastapi). Garantiza que la imagen de hoy y la de dentro de seis meses contengan lo mismo. - No incluir secretos en la imagen. Una contraseña escrita con
ENVo copiada conCOPYqueda grabada en las capas y cualquiera con acceso a la imagen puede extraerla. Los secretos se inyectan en tiempo de ejecución (variables de entorno,--env-file, secretos de Compose u orquestador). - Una responsabilidad por contenedor. Un contenedor, un proceso principal: la API en uno, la base de datos en otro. Se escalan, actualizan y reinician de forma independiente.
- Ordenar las instrucciones para aprovechar la caché: dependencias primero, código después. Y encadenar con
--no-cache-direnpipevita guardar dentro de la imagen una caché inútil.
Práctica: empaquetar una API de FastAPI
Se empaqueta la API mínima del día 2. La estructura final de la carpeta es:
saludo/
main.py
requirements.txt
Dockerfile
.dockerignore
compose.yaml
1. main.py: la aplicación con una sola ruta, GET /saludo.
from fastapi import FastAPI
app = FastAPI()
@app.get("/saludo")
def saludo():
return {"mensaje": "Hola desde un contenedor"}
2. requirements.txt: dependencias con versión fija.
fastapi==0.115.14
uvicorn==0.32.1
3. Dockerfile: la receta, con la imagen base python:3.12-slim y un usuario sin privilegios.
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY main.py .
RUN useradd --create-home appuser
USER appuser
EXPOSE 8000
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
Lectura línea por línea:
FROM python:3.12-slim: parte de una imagen oficial de Python 3.12 en su variante reducida.WORKDIR /app: el trabajo ocurre en/app.COPY requirements.txt .yRUN pip install ...: se copia solo la lista de dependencias y se instalan. Mientrasrequirements.txtno cambie, esta capa se reutiliza de la caché.COPY main.py .: el código se copia después, porque es lo que más cambia.RUN useradd --create-home appuseryUSER appuser: se crea un usuario sin privilegios y el contenedor se ejecuta con él.EXPOSE 8000: documenta el puerto de la aplicación.CMD [...]: arranca Uvicorn escuchando en0.0.0.0:8000, como se explicó en la sección de redes.
4. .dockerignore: lo que no debe entrar al contexto de construcción.
__pycache__/
*.pyc
.venv/
.git/
.pytest_cache/
.env
5. compose.yaml: la misma ejecución, declarada en un archivo.
services:
api:
build: .
ports:
- "8000:8000"
restart: unless-stopped
build: . indica construir la imagen con el Dockerfile de la carpeta actual; ports publica el puerto igual que -p; restart: unless-stopped reinicia el contenedor si falla, salvo que lo hayas detenido tú. Las versiones recientes de Compose no requieren el campo version: al inicio del archivo.
6. Construir y ejecutar con Docker, desde la carpeta saludo/:
docker build -t saludo .
docker run -p 8000:8000 saludo
La primera línea construye la imagen y la nombra saludo; la segunda crea un contenedor y publica el puerto 8000. Se espera que el terminal muestre el arranque de Uvicorn escuchando en 0.0.0.0:8000 y que quede ocupado mientras el contenedor corre (se detiene con Ctrl+C; con -d quedaría en segundo plano).
7. Comprobar desde otra terminal:
curl http://localhost:8000/saludo
Se espera la respuesta JSON {"mensaje":"Hola desde un contenedor"} con código 200. También puedes abrir http://localhost:8000/docs en el navegador para ver la documentación interactiva, igual que en el día 2.
8. Lo mismo con Compose:
docker compose up
Compose construye la imagen si no existe y arranca el servicio api. Con docker compose down se detiene y elimina. Para reconstruir tras cambiar el código: docker compose up --build.
Prueba automatizada (sin Docker). Antes de empaquetar conviene comprobar que la aplicación funciona por sí sola. Con pytest y el TestClient del día 2, en un archivo test_main.py junto a main.py:
from fastapi.testclient import TestClient
from main import app
cliente = TestClient(app)
def test_saludo():
r = cliente.get("/saludo")
assert r.status_code == 200
assert r.json() == {"mensaje": "Hola desde un contenedor"}
Se ejecuta con python -m pytest. Esta prueba y el arranque de la aplicación con Uvicorn se verificaron en un entorno con Python 3.12, FastAPI 0.115.14 y Uvicorn 0.32.1. El contenedor lo construyes y verificas tú: si curl responde lo esperado, la imagen contiene todo lo necesario y funcionará igual en cualquier otro equipo con Docker.
Errores frecuentes: el puerto 8000 ya está ocupado en tu equipo (usa -p 8080:8000 y consulta localhost:8080); la respuesta no llega porque la aplicación escucha en 127.0.0.1 en lugar de 0.0.0.0; o docker no responde porque Docker Desktop no está iniciado.
La skill gratuita dockeriza analiza tu proyecto, escribe su Dockerfile, su .dockerignore y su archivo de Compose, y los prueba construyendo y arrancando el contenedor antes de entregártelos.
Preguntas frecuentes
¿Qué es Docker en palabras simples?
Es una herramienta que empaqueta una aplicación con todo lo que necesita para correr (código, librerías y configuración) en un contenedor, para que funcione igual en cualquier computadora o servidor.
¿Qué diferencia hay entre un contenedor y una máquina virtual?
Una máquina virtual emula una computadora completa con su propio sistema operativo. Un contenedor comparte el núcleo del sistema anfitrión y aísla solo la aplicación, por eso es más ligero y suele arrancar en menos de un segundo.
¿Qué es una imagen de Docker?
Es la plantilla de solo lectura, construida a partir de un Dockerfile, de la que se crean los contenedores. Una imagen puede generar muchos contenedores iguales.
¿Para qué sirve Docker Compose?
Para definir en un solo archivo varios contenedores que trabajan juntos, por ejemplo una API y su base de datos, y levantarlos con un comando: docker compose up.
En el reel lo explicamos así
Imagina una lonchera sellada con la comida, los cubiertos y la servilleta: la abres en cualquier mesa y todo está ahí, sin pedir nada prestado. Un contenedor funciona igual: lleva dentro lo que la aplicación necesita, de modo que no importa en qué computadora se abra.
- El Dockerfile es la receta: la lista de pasos y de ingredientes, escrita en un archivo.
- La imagen es la receta ya preparada y empacada, lista para repartirse; no cambia aunque la copies mil veces.
- El contenedor es la lonchera en uso: una porción concreta, abierta y en funcionamiento. Cuando termina, se tira; la receta sigue ahí para preparar otra.
Y la frase que lo resume: ya no se dice «en mi computadora sí funcionaba», sino «funciona en la lonchera, y la lonchera viaja contigo».
Referencias
- Docker: Get started, descripción general: arquitectura, demonio, cliente, imágenes y contenedores.
- Referencia del Dockerfile: sintaxis completa de cada instrucción.
- Buenas prácticas para construir imágenes: etapas múltiples, caché, imágenes base y seguridad.
- Documentación de Docker Compose: el archivo
compose.yamly la línea de comandos. - Volúmenes en Docker y redes en Docker.
- Open Container Initiative: especificaciones: formato de imagen y entorno de ejecución.
Una lonchera sellada con la comida, los cubiertos y la servilleta: la abres en cualquier mesa y todo está ahí.
Glosario: del inglés al español
| Container | contenedor |
|---|---|
| Image | imagen (la receta ya preparada) |
| Dockerfile | archivo con la receta |