Contenido
Tutorial de fundamentos internos de Docker: experimentos con namespace, cgroup y overlayfs
Qué es Docker explica los contenedores con una analogía; en esta sección iremos directamente al host Docker para ver qué es realmente un contenedor en Linux. La respuesta cabe en una frase:
Un contenedor es un proceso Linux normal: los namespaces limitan «qué puede ver», los cgroups limitan «cuánto puede usar», overlayfs combina directorios para formar su directorio raíz y capabilities y seccomp limitan «qué puede hacer».
En este tutorial, «host» se refiere al entorno Docker de prueba (kernel Linux 5.4, cgroup v1). Al ejecutarlo en tu ordenador, los ID, las rutas y otros valores serán distintos; en los sistemas que usan cgroup v2 también cambian los nombres de los archivos, como se indica en el texto. Si usas Docker Desktop, puedes entrar en la máquina virtual Linux de Docker Desktop para observarla con
docker run -it --rm --privileged --pid=host alpine:3.23 nsenter -t 1 -m -u -i -n sh.
Un contenedor es un proceso del host
Inicia un contenedor nginx y limita la memoria a 256 MB y la CPU a 0.5 núcleos:
$ docker run -d --name demo --memory 256m --cpus 0.5 nginx:1.30-alpine
WARNING: Your kernel does not support swap limit capabilities or the cgroup is not mounted. Memory limited without swap.
57dcd17505d4e7c71e57df55edef3e137c18249e6ad402c7e7b3f215341c47b4
$ docker exec demo ps
PID USER TIME COMMAND
1 root 0:00 nginx: master process nginx -g daemon off;
29 nginx 0:00 nginx: worker process
30 root 0:00 ps
$ docker inspect -f "{{.State.Pid}}" demo
12458
- Dentro del contenedor, el PID del proceso principal de nginx es 1, y solo se ven sus propios procesos.
.State.Piddedocker inspectes el PID que tiene en el host: 12458.
Lista los procesos en el host:
$ ps -o pid,ppid,args | grep -v -E 'ps -o|grep|cut' | cut -c1-110
PID PPID COMMAND
1 0 docker-init -- dockerd --host=unix:///var/run/docker.sock --host=tcp://0.0.0.0:2375 --registry-mir
19 1 dockerd --host=unix:///var/run/docker.sock --host=tcp://0.0.0.0:2375 --registry-mirror=https://mir
12434 1 /usr/local/bin/containerd-shim-runc-v2 -namespace moby -id 57dcd17505d4e7c71e57df55edef3e137c18249
12458 12434 nginx: master process nginx -g daemon off;
12495 12458 nginx: worker process
En el host se ve todo el árbol de procesos: dockerd → containerd-shim-runc-v2 → nginx: master process (12458) → nginx: worker process.
- containerd-shim-runc-v2: hay uno por contenedor y se encarga de supervisar sus procesos. Si dockerd se reinicia, el contenedor no se detiene; eso se debe a este proceso.
- runc: es el programa que crea realmente el contenedor. Configura los namespaces, el cgroup y el directorio raíz; después ejecuta nginx y termina, por eso no aparece en el árbol de procesos.
El mismo proceso tiene PID 1 dentro del contenedor y PID 12458 en el host. Ese es el efecto del namespace PID.
namespace: decide qué puede ver un contenedor
lsns muestra el namespace al que pertenece un proceso:
$ lsns -p 12458 -o NS,TYPE,PID,COMMAND
NS TYPE PID COMMAND
4026531835 cgroup 1 docker-init -- dockerd --host=unix:///var/run/docker.sock --host=tcp://0.0.0.0:2375 --registry-mirror=https://mirror.gcr.io --dns=1.1.1.1 --dns=8.8.8.8
4026531837 user 1 docker-init -- dockerd --host=unix:///var/run/docker.sock --host=tcp://0.0.0.0:2375 --registry-mirror=https://mirror.gcr.io --dns=1.1.1.1 --dns=8.8.8.8
4026533161 mnt 12458 nginx: master process nginx -g daemon off;
4026533162 uts 12458 nginx: master process nginx -g daemon off;
4026533163 ipc 12458 nginx: master process nginx -g daemon off;
4026533164 pid 12458 nginx: master process nginx -g daemon off;
4026533166 net 12458 nginx: master process nginx -g daemon off;
El proceso nginx tiene 5 namespaces propios (con ID distintos de los del host), mientras que comparte otros 2 (cgroup y user) con el PID 1 del host:
| namespace | Elemento aislado | Efecto en el contenedor |
|---|---|---|
pid | identificadores de proceso | Dentro del contenedor solo se ven sus propios procesos; el proceso principal tiene el PID 1 |
net | interfaces de red, IP, puertos y rutas | El contenedor tiene su propio eth0 e IP, y puede escuchar en el puerto 80 de forma independiente |
mnt | puntos de montaje | El contenedor tiene su propio directorio raíz / |
uts | nombre del host | El hostname del contenedor es su ID |
ipc | memoria compartida y semáforos | Los contenedores no pueden interferir entre sí mediante IPC |
user | correspondencia de ID de usuario | De forma predeterminada, no está aislado: el usuario root del contenedor es el root del host. Solo se usa en el modo Rootless |
cgroup | vista del directorio cgroup | Este entorno (cgroup v1) no está aislado; los sistemas con cgroup v2 lo aíslan de forma predeterminada |
Efectos de los namespaces uts y net:
$ docker exec demo hostname
57dcd17505d4
$ docker exec demo ip -4 addr show eth0
2: eth0@if130: <BROADCAST,MULTICAST,UP,LOWER_UP,M-DOWN> mtu 1500 qdisc noqueue state UP
inet 172.18.0.2/16 brd 172.18.255.255 scope global eth0
valid_lft forever preferred_lft forever
El nombre del host del contenedor son los primeros 12 caracteres de su ID. eth0 tiene su propia IP (172.18.0.2), asignada por la red de Docker. Consulta Redes y comunicación entre contenedores.
cgroup: decide cuántos recursos puede usar un contenedor
--memory 256m --cpus 0.5 no lo calcula Docker; se escribe en la configuración de cgroup del kernel de Linux. Lee esta configuración desde el contenedor:
$ docker info --format "Cgroup Version: {{.CgroupVersion}}"
Cgroup Version: 1
$ docker exec demo cat /sys/fs/cgroup/memory/memory.limit_in_bytes
268435456
$ docker exec demo cat /sys/fs/cgroup/memory/memory.usage_in_bytes
10842112
$ docker exec demo cat /sys/fs/cgroup/cpu/cpu.cfs_quota_us /sys/fs/cgroup/cpu/cpu.cfs_period_us
50000
100000
$ docker stats --no-stream --format "{{.Name}} {{.MemUsage}} {{.CPUPerc}}" demo
demo 5.613MiB / 256MiB 0.00%
| Valor leído | Significado |
|---|---|
memory.limit_in_bytes = 268435456 | 268435456 bytes = 256 MiB, es decir, --memory 256m |
memory.usage_in_bytes = 10842112 | Se están usando unos 10 MB (estadística del kernel, incluye la caché de archivos, por eso es mayor que lo que muestra docker stats) |
cpu.cfs_quota_us = 50000, cpu.cfs_period_us = 100000 | Se puede usar un máximo de 50 ms de CPU cada 100 ms, es decir, 0,5 núcleos |
El límite de 256MiB que muestra docker stats también se lee de cgroup. Si la memoria usada por un proceso supera el límite, el kernel lo termina (OOM) y el código de salida es 137.
En los sistemas con cgroup v2 (versiones recientes de Ubuntu, Debian, Fedora y Docker Desktop), los nombres de archivo son distintos: el límite de memoria está en
/sys/fs/cgroup/memory.maxy la cuota de CPU en/sys/fs/cgroup/cpu.max(el contenido es50000 100000).
overlayfs: el sistema de archivos del contenedor
Capas de la imagen
Una imagen está compuesta por varias capas de solo lectura:
$ docker image inspect -f "{{range .RootFS.Layers}}{{println .}}{{end}}" nginx:1.30-alpine
sha256:74d97c428c51a828f9051a7a40a53ff1fc99e54fc30323ce36760701b0b7f711
sha256:2d3fbe0ca19dd389108f7e9a1d89ec5c1f1d7dc3c11fb2123bc425fed39c5f22
sha256:ed752e851ebe79a0e72be1fd48e90474d36ae2bb2ddccff2475f8e050c20a804
sha256:ec2b9a359ab960e2254545e79e9bca77edaeefb652d97b26855217fb8a9c80b3
sha256:267e83adf4c4e342b08cbe40814662493e030c88311c720fbce2f0d7c809f3f4
sha256:8fb5da5ad6fa936ba8c24999da33095b2b6c5fd2bcf4aa22acbf299b0e7294df
sha256:0d250d8db7c20b3c0bd3059b3431dd47f130cb81e7a4c7820950139daafb91d6
sha256:e46f4042ccf5f36d1605617ef6912bdaac437fa0bca12530e77529b191eb02fa
$ docker history --format "{{.Size}}\t{{.CreatedBy}}" nginx:1.30-alpine | cut -c1-90
51.9MB RUN /bin/sh -c set -x && apkArch="$(cat …
0B ENV ACME_VERSION=0.4.1
0B ENV NJS_RELEASE=1
0B ENV NJS_VERSION=1.0.1
0B CMD ["nginx" "-g" "daemon off;"]
0B STOPSIGNAL SIGQUIT
0B EXPOSE map[80/tcp:{}]
0B ENTRYPOINT ["/docker-entrypoint.sh"]
16.4kB COPY 30-tune-worker-processes.sh /docker-ent…
12.3kB COPY 20-envsubst-on-templates.sh /docker-ent…
12.3kB COPY 15-local-resolvers.envsh /docker-entryp…
12.3kB COPY 10-listen-on-ipv6-by-default.sh /docker…
8.19kB COPY docker-entrypoint.sh / # buildkit
5.62MB RUN /bin/sh -c set -x && addgroup -g 101…
0B ENV DYNPKG_RELEASE=1
0B ENV PKG_RELEASE=1
0B ENV NGINX_VERSION=1.30.5
0B LABEL maintainer=NGINX Docker Maintainers <d…
0B CMD ["/bin/sh"]
9.08MB ADD alpine-minirootfs-3.24.2-x86_64.tar.gz /…
RootFS.Layersmuestra los hashes de las 8 capas.docker historymuestra qué línea del Dockerfile creó cada capa. Las que tienen un tamaño de0B(ENV,CMD,EXPOSE) solo modificaron la configuración y no generaron archivos, así que no tienen una capa correspondiente. ElADD alpine-minirootfs...de la parte inferior es la imagen base de Alpine.
Cómo se combinan para formar el directorio raíz
Cuando se inicia el contenedor, Docker combina estas capas con overlayfs y añade una capa escribible. Consulta la información de montaje de este contenedor en el host:
$ grep overlay /proc/mounts | grep $(docker inspect -f '{{.Id}}' demo) | tr ',' '\n' | sed 's#/var/lib/docker/containerd/daemon/io.containerd.snapshotter.v1.overlayfs/snapshots#…/snapshots#g'
overlay /var/lib/docker/rootfs/overlayfs/57dcd17505d4e7c71e57df55edef3e137c18249e6ad402c7e7b3f215341c47b4 overlay rw
relatime
lowerdir=…/snapshots/260/fs:…/snapshots/21/fs:…/snapshots/20/fs:…/snapshots/19/fs:…/snapshots/18/fs:…/snapshots/17/fs:…/snapshots/16/fs:…/snapshots/15/fs:…/snapshots/1/fs
upperdir=…/snapshots/261/fs
workdir=…/snapshots/261/work
xino=off 0 0
(La ruta es demasiado larga, así que … sustituye a /var/lib/docker/containerd/daemon/io.containerd.snapshotter.v1.overlayfs.)
| Elemento | Significado |
|---|---|
lowerdir | Capas de imagen de solo lectura, apiladas de arriba abajo y compartidas por todos los contenedores que usan esta imagen |
upperdir | Capa escribible exclusiva de este contenedor |
workdir | Directorio temporal interno de overlayfs |
Punto de montaje /var/lib/docker/rootfs/overlayfs/ID del contenedor | El resultado combinado: el / que ve el contenedor |
Dónde van los archivos que escribe el contenedor
Crea un archivo dentro del contenedor y luego búscalo en el upperdir del host:
$ docker exec demo sh -c "echo hello > /usr/share/nginx/html/new.html"
$ find /var/lib/docker/containerd/daemon/io.containerd.snapshotter.v1.overlayfs/snapshots/261/fs -type f | sed 's#.*/fs/#upperdir/#'
upperdir/usr/share/nginx/html/new.html
upperdir/run/nginx.pid
upperdir/etc/nginx/conf.d/default.conf
$ docker diff demo
C /etc
C /etc/nginx
C /etc/nginx/conf.d
C /etc/nginx/conf.d/default.conf
C /usr
C /usr/share
C /usr/share/nginx
C /usr/share/nginx/html
A /usr/share/nginx/html/new.html
C /run
A /run/nginx.pid
C /var
C /var/cache
C /var/cache/nginx
A /var/cache/nginx/client_temp
A /var/cache/nginx/fastcgi_temp
A /var/cache/nginx/proxy_temp
A /var/cache/nginx/scgi_temp
A /var/cache/nginx/uwsgi_temp
- El archivo nuevo
new.htmlaparece en el upperdir. - nginx escribe
nginx.pidal iniciarse; el script de inicio de la imagen modificódefault.conf(añadió la configuración IPv6). Cuando se modifica un archivo que ya existe en la imagen, overlayfs primero lo copia al upperdir y luego lo modifica (copy-up). docker diffmuestra el contenido del upperdir:Aindica que se añadió,Cque se modificó (incluidas las carpetas de niveles superiores) yDque se eliminó.
Por tanto:
- Eliminar un contenedor = eliminar su upperdir; los datos escritos dentro del contenedor desaparecen. Por eso debes guardar los datos en un Volume, que es un directorio montado directamente y no pasa por overlayfs.
- Si una imagen inicia 100 contenedores, las capas de la imagen se almacenan una sola vez.
- En un Dockerfile, «primero COPY y luego RUN rm» no reduce el tamaño de la imagen, porque el archivo sigue en la capa de solo lectura inferior. Las prácticas recomendadas de seguridad incluso lo eliminan.
capabilities y seccomp: determinan qué puede hacer un contenedor
$ docker info --format "{{.Driver}} {{json .SecurityOptions}}"
overlayfs ["name=apparmor,profile=default","name=seccomp,profile=builtin"]
$ docker exec demo grep CapEff /proc/1/status
CapEff: 00000000a80425fb
$ docker run --rm --cap-drop ALL alpine:3.23 grep CapEff /proc/1/status
CapEff: 0000000000000000
docker infomuestra que este entorno tiene habilitadas las configuraciones predeterminadas de AppArmor y seccomp. seccomp bloquea algunas llamadas al sistema peligrosas que hacen los contenedores (por ejemplo, cargar módulos del kernel).CapEffrepresenta las capabilities que tiene actualmente el proceso, en forma de bits. De forma predeterminada, root dentro del contenedor solo tiene parte de los permisos (a80425fb), no todos; después de--cap-drop ALL, todos quedan a cero. Consulta el efecto real en las prácticas recomendadas de seguridad.--privilegedconcede al contenedor todas las capabilities y desactiva seccomp y AppArmor, lo que elimina esta capa de protección.
Comparación con las máquinas virtuales
| Contenedor | Máquina virtual | |
|---|---|---|
| Kernel | Comparte el kernel Linux del host | Cada máquina virtual tiene su propio kernel |
| Aislamiento | Funciones del kernel como namespace, cgroup y seccomp | Virtualización de hardware |
| Inicio | Inicia un proceso, normalmente en menos de 1 segundo | Arranca el sistema, de varios a decenas de segundos |
| Sobrecarga adicional | Prácticamente ninguna | Hay que asignar memoria y CPU a cada máquina virtual |
| Nivel de aislamiento | Menor; una vulnerabilidad del kernel puede afectar a todos los contenedores | Mayor |
Esto también explica varios comportamientos habituales:
- En un host Linux,
psmuestra los procesos del contenedor; en Windows y macOS no, porque el contenedor está dentro de la máquina virtual de Docker Desktop. - Un contenedor no puede ejecutar un sistema operativo con un kernel distinto. Por ejemplo, un host Linux no puede ejecutar directamente contenedores de Windows.
- El error
exec format errorde las imágenes multiplataforma: el contenedor no tiene su propia CPU, así que el programa debe estar en un formato que pueda ejecutar la CPU del host.
Resumen
- Un contenedor es un proceso Linux en el host, creado por runc y supervisado por containerd-shim.
- namespace aísla los procesos, la red, los montajes, el nombre del host y otros «elementos visibles»; de forma predeterminada, no aísla a los usuarios.
- cgroup implementa
--memory,--cpusy--pids-limit; puedes leer directamente los valores configurados en los archivos de cgroup. - overlayfs combina las capas de imagen de solo lectura con un upperdir escribible para formar el directorio raíz del contenedor; al eliminar el contenedor, se elimina el upperdir.
- capabilities, seccomp y AppArmor limitan lo que puede hacer root dentro del contenedor;
--privilegedelimina todas estas restricciones.
Ejercicios
Ejercicio 1: Ejecuta docker run -d --name t1 --pids-limit 50 alpine:3.23 sleep 600 y luego lee el límite de PID desde dentro del contenedor.
Respuesta
cgroup v1:
docker exec t1 cat /sys/fs/cgroup/pids/pids.max
cgroup v2:
docker exec t1 cat /sys/fs/cgroup/pids.max
Ambos deberían mostrar 50. Usa docker info --format "{{.CgroupVersion}}" para confirmar qué versión utiliza tu sistema.
Ejercicio 2: Elimina dentro del contenedor un archivo que ya existía en la imagen (por ejemplo, docker exec t1 rm /etc/motd) y luego ejecuta docker diff t1. ¿Qué verás? ¿Se ha eliminado realmente el archivo de la imagen?
Respuesta
docker diff mostrará D /etc/motd. Las capas de la imagen son de solo lectura, así que el archivo no se ha eliminado realmente: overlayfs coloca un «marcador de eliminación» (whiteout) en upperdir para que no aparezca en la vista combinada. Si inicias un contenedor nuevo con la misma imagen, /etc/motd seguirá existiendo.
Ejercicio 3: Inicia al mismo tiempo dos contenedores nginx:1.30-alpine y compara sus lowerdir y upperdir.
Pista
Ejecuta grep overlay /proc/mounts en el host Linux. Los lowerdir de los dos contenedores serán exactamente iguales (comparten las capas de la imagen), mientras que cada uno tendrá un upperdir diferente.
Preguntas frecuentes
¿Cuál es la diferencia fundamental entre un contenedor y una máquina virtual?
Una máquina virtual emula un ordenador completo y ejecuta su propio kernel de sistema operativo; un contenedor no tiene kernel propio: todos los contenedores usan directamente el kernel Linux del host y están aislados mediante namespaces y cgroups. Por eso, iniciar un contenedor solo requiere iniciar un proceso y prácticamente no lleva tiempo.
¿Por qué Windows y macOS también pueden ejecutar contenedores Linux?
Porque Docker Desktop inicia en segundo plano una pequeña máquina virtual Linux (en Windows es WSL 2), y los contenedores se ejecutan realmente sobre el kernel Linux de esa máquina virtual.
¿Qué diferencia hay entre cgroup v1 y v2?
Ambos son mecanismos del kernel Linux para limitar recursos; v2 es la versión más reciente y unifica la estructura de directorios. Los nombres de los archivos son distintos: por ejemplo, el límite de memoria se llama memory.limit_in_bytes en v1 y memory.max en v2; la cuota de CPU es cpu.max en v2. Las distribuciones recientes y Docker Desktop usan v2 de forma predeterminada; docker info muestra la versión en Cgroup Version.
Si una imagen inicia 100 contenedores, ¿ocuparán 100 veces más espacio?
No. Las capas de la imagen son el lowerdir de solo lectura y las comparten todos los contenedores; cada contenedor solo añade su propio upperdir, donde guarda los archivos que escribe o modifica.
Actualizado 14 de octubre de 2026