La lección anterior te dio aislamiento fuerte a un precio alto: cada máquina virtual lleva su propio kernel, tarda medio minuto en arrancar, ocupa gigabytes en disco y reserva memoria solo por existir. Para un entorno de pruebas es razonable. Para desplegar una aplicación y poder recrearla en segundos, es carísimo.
Esta lección construye la alternativa, y empieza por la idea que hay que tener clara antes de escribir el primer docker run, porque adquirirla mal condiciona todo lo que viene después:
Un contenedor no es una máquina pequeña. Es un proceso aislado.
No hay un sistema operativo dentro. No hay un kernel. No hay un arranque. Hay un proceso normal del anfitrión, ejecutándose con una vista restringida del sistema de ficheros, de la red y de la tabla de procesos, y con un límite de recursos. Todo lo que verás —las imágenes, los volúmenes, las redes, Compose— es maquinaria alrededor de esa idea.
Y las tres piezas del kernel que la hacen posible ya las conoces por separado: los cgroups son literalmente los del MemoryMax=512M que pusiste en 05-05, las capabilities son las de 05-02, y los namespaces de montaje son la evolución del chroot con el que reparaste GRUB en 07-01.
Contenido
- Las tres primitivas: namespaces, cgroups y capabilities
- Namespaces de usuario y el sysctl que dejaste comentado
- Instalar Docker, y por qué el grupo docker equivale a root
- Imágenes, contenedores y registros
- docker run y las opciones que importan
- Dockerfile: construir la imagen de Tramontana
- Construcción en varias etapas y elección de imagen base
- Datos: volúmenes, bind mounts y tmpfs
- Red y publicación de puertos
- Docker Compose
- Seguridad de contenedores
- Alternativas, y cuándo no usar contenedores
Las tres primitivas: namespaces, cgroups y capabilities
Un contenedor no es una función del kernel. No existe una llamada al sistema crear_contenedor(). Es una convención: un proceso lanzado con ciertas restricciones activadas a la vez. Verlo directamente, sin Docker de por medio, es lo que hace que todo lo demás encaje.
Namespaces: aislar lo que un proceso ve
Un namespace aísla una clase de recurso global del kernel, de modo que los procesos dentro de él vean su propia instancia. Hay siete:
| Namespace | Aísla | Efecto visible |
|---|---|---|
mnt |
Puntos de montaje | Su propio árbol de ficheros |
pid |
Identificadores de proceso | Su proceso principal es el PID 1 |
net |
Interfaces, rutas, puertos, firewall | Su propia pila de red |
uts |
Nombre de máquina y dominio | hostname propio |
ipc |
Memoria compartida, colas de mensajes | Sin comunicación con el exterior |
user |
Correspondencia de UID y GID | Ser root dentro sin serlo fuera |
cgroup |
Vista de la jerarquía de cgroups | No ve la del anfitrión |
$ lsns
NS TYPE NPROCS PID USER COMMAND
4026531834 time 184 1 root /sbin/init
4026531835 cgroup 184 1 root /sbin/init
4026531836 pid 184 1 root /sbin/init
4026531837 user 184 1 root /sbin/init
4026531838 uts 181 1 root /sbin/init
4026531839 ipc 184 1 root /sbin/init
4026531840 net 184 1 root /sbin/init
4026531841 mnt 172 1 root /sbin/initTodos los procesos comparten los mismos namespaces, porque no hay ningún contenedor en marcha. Vamos a crear uno a mano, sin Docker:
# Un proceso con su propio namespace de PID, UTS y montaje
$ sudo unshare --pid --fork --mount-proc --uts --mount bash
root@srv-tramontana:/# hostname contenedor-manual
root@contenedor-manual:/# hostname
contenedor-manual
root@contenedor-manual:/# ps aux
USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND
root 1 0.0 0.1 9788 5124 pts/0 S 19:04 0:00 bash
root 12 0.0 0.0 11492 3684 pts/0 R+ 19:04 0:00 ps auxAhí está el efecto: bash es el PID 1 y solo ve dos procesos. Desde fuera, el mismo proceso tiene un PID normal:
# En otra terminal del anfitrion
$ pgrep -a -f 'unshare --pid'
8412 sudo unshare --pid --fork --mount-proc --uts --mount bash
$ ps -ef | grep -c bash
14El proceso es el mismo. Lo único que cambia es lo que ve. Y esto explica por qué un contenedor arranca en milisegundos: no hay nada que arrancar, solo un clone() con unas banderas.
# Cada proceso expone sus namespaces como enlaces en /proc
$ ls -l /proc/1/ns/
lrwxrwxrwx 1 root root 0 ago 18 19:06 cgroup -> 'cgroup:[4026531835]'
lrwxrwxrwx 1 root root 0 ago 18 19:06 ipc -> 'ipc:[4026531839]'
lrwxrwxrwx 1 root root 0 ago 18 19:06 mnt -> 'mnt:[4026531841]'
lrwxrwxrwx 1 root root 0 ago 18 19:06 net -> 'net:[4026531840]'
lrwxrwxrwx 1 root root 0 ago 18 19:06 pid -> 'pid:[4026531836]'
lrwxrwxrwx 1 root root 0 ago 18 19:06 user -> 'user:[4026531837]'
lrwxrwxrwx 1 root root 0 ago 18 19:06 uts -> 'uts:[4026531838]'
# Comparar dos procesos: si los numeros coinciden, comparten namespace
$ sudo readlink /proc/8412/ns/pid /proc/1/ns/pid
pid:[4026532198]
pid:[4026531836]El namespace de red es especialmente ilustrativo, porque el aislamiento es total:
$ sudo unshare --net bash
root@srv-tramontana:/# ip addr
1: lo: <LOOPBACK> mtu 65536 qdisc noop state DOWN group default qlen 1000
link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
root@srv-tramontana:/# ss -tulpn
# (vacio)
root@srv-tramontana:/# exitNi enp0s3, ni IP, ni rutas, ni un solo socket. Y lo está DOWN. Ese es el punto de partida de cualquier contenedor de Docker antes de que se le conecte una interfaz virtual.
El chroot de 07-01, y por qué no basta
En 07-01 usaste chroot para entrar en un sistema instalado y reparar GRUB. chroot cambia la raíz del sistema de ficheros para un proceso — es decir, hace una parte de lo que hace un namespace de montaje.
Pero solo esa parte, y por eso chroot no es un mecanismo de seguridad:
$ sudo chroot /mnt /bin/bash
bash-5.2# ls /
bin boot dev etc home ... # otra raiz: eso si funciona
bash-5.2# ps aux | wc -l # PERO ve todos los procesos del anfitrion
185
bash-5.2# ip addr | grep enp0s3 # y toda la red del anfitrion
2: enp0s3: <BROADCAST,MULTICAST,UP,LOWER_UP> ...
bash-5.2# hostname # y su nombre
srv-tramontanaUn proceso con privilegios dentro de un chroot puede salir de él con relativa facilidad —hay técnicas conocidas y documentadas— y, aunque no saliera, tiene acceso completo a la red, a los procesos y a los dispositivos del anfitrión. El namespace de montaje resuelve el escape; los otros seis namespaces resuelven el resto.
cgroups: limitar lo que un proceso consume
Los namespaces controlan lo que un proceso ve; los cgroups controlan lo que consume. Y esto ya lo has usado:
$ systemctl show tramontana.service -p MemoryMax -p TasksMax -p CPUQuotaPerSecUSec
MemoryMax=536870912
TasksMax=64
CPUQuotaPerSecUSec=infinity
# Y por debajo, la jerarquia de cgroups v2 en /sys/fs/cgroup
$ cat /sys/fs/cgroup/system.slice/tramontana.service/memory.max
536870912
$ cat /sys/fs/cgroup/system.slice/tramontana.service/memory.current
187432960
$ cat /sys/fs/cgroup/system.slice/tramontana.service/pids.max
64Es exactamente la misma tecnología. Cuando en 05-05 pusiste MemoryMax=512M en la unidad de systemd, estabas creando un cgroup con memory.max. Docker hace lo mismo con --memory 512m. La diferencia es el envoltorio, no el mecanismo.
$ systemd-cgtop --iterations=1 -m | head -6
Control Group Tasks %CPU Memory Input/s Output/s
/ 184 2.1 1.2G - -
system.slice 102 1.4 842.1M - -
system.slice/postgresql@16-main.… 18 0.8 412.4M - -
system.slice/tramontana.service 12 0.4 178.7M - -capabilities: fragmentar los privilegios de root
De 05-02: en lugar de «root o no root», el kernel divide los privilegios en unas cuarenta capacidades independientes.
$ capsh --print | head -3
Current: =ep
Bounding set: cap_chown,cap_dac_override,cap_dac_read_search,cap_fowner,...Esto es lo que permite que el proceso principal de un contenedor pueda ser «root» y aun así no pueda hacer casi nada peligroso: se le retiran casi todas las capacidades. Docker, por defecto, deja unas catorce de las cuarenta.
Y con las tres primitivas juntas, ya puedes definir qué es un contenedor sin ninguna magia:
Un contenedor es un proceso lanzado con namespaces propios, dentro de un cgroup con límites, con un conjunto reducido de capabilities, y con la raíz del sistema de ficheros apuntando a la imagen.
Namespaces de usuario y el sysctl que dejaste comentado
El namespace de usuario es el más reciente y el que más consecuencias de seguridad tiene. Permite que un UID dentro se corresponda con un UID distinto fuera:
# Como usuario NORMAL, sin sudo
$ unshare --user --map-root-user bash
root@srv-tramontana:~# id
uid=0(root) gid=0(root) groups=0(root)
root@srv-tramontana:~# cat /proc/self/uid_map
0 1000 1
root@srv-tramontana:~# touch /etc/prueba
touch: no se puede efectuar `touch' sobre '/etc/prueba': Permiso denegado
root@srv-tramontana:~# exitLéelo despacio, porque es contraintuitivo: id dice uid=0(root), pero no puede escribir en /etc. El uid_map explica por qué — el UID 0 dentro es el UID 1000 fuera. Es root de su propio namespace y nadie más.
Eso es lo que hace posibles los contenedores sin privilegios (rootless): un usuario normal puede crear namespaces, montar sistemas de ficheros dentro y ser root dentro de su contenedor, sin ser root en el anfitrión.
Y ahora, el parámetro que dejaste comentado en 06-06:
$ grep -A3 unprivileged_userns /etc/sysctl.d/60-endurecimiento.conf
# No permitir a usuarios sin privilegios crear espacios de nombres de usuario.
# ATENCION: rompe los contenedores sin privilegios. Comentado porque en
# 07-05 vas a necesitarlo; descomentar solo en servidores sin contenedores.
#kernel.unprivileged_userns_clone = 0La tensión es real y merece entenderse en ambos sentidos:
A favor de desactivarlo (= 0) |
A favor de dejarlo activo |
|---|---|
| Los namespaces de usuario han sido la vía de numerosas escaladas de privilegios | Son la base de los contenedores sin privilegios y del sandboxing |
| Dan a un usuario normal acceso a superficie del kernel que antes era solo de root | Sin ellos, todo contenedor necesita un demonio con privilegios |
| En un servidor que no ejecuta contenedores, no se pierde nada | Los usan también Flatpak, Snap, bwrap y los navegadores |
La decisión para srv-tramontana: dejarlo activo, porque va a ejecutar contenedores. Y la nota que se añade al fichero, porque el criterio es lo que hay que documentar:
$ sudo tee -a /etc/sysctl.d/60-endurecimiento.conf >/dev/null <<'EOF'
# DECISION 2026-08-18: kernel.unprivileged_userns_clone se deja ACTIVO
# (valor por defecto 1). Motivo: es requisito de los contenedores sin
# privilegios (podman rootless) y del sandboxing de Snap. Desactivarlo
# reduciria superficie de ataque, pero impediria la contencion que
# aportan los contenedores, que es una proteccion mayor.
# Revisar si esta maquina deja de ejecutar contenedores.
EOF
$ sysctl kernel.unprivileged_userns_clone
kernel.unprivileged_userns_clone = 1Un endurecimiento descartado con su motivo escrito es conocimiento; descartado en silencio es una omisión.
Instalar Docker, y por qué el grupo docker equivale a root
Docker no está en los repositorios de Ubuntu con la versión actual, así que se instala desde el repositorio oficial aplicando lo de 05-03: clave en /etc/apt/keyrings/ y signed-by.
$ sudo apt install ca-certificates curl
$ sudo install -m 0755 -d /etc/apt/keyrings
$ sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg \
-o /etc/apt/keyrings/docker.asc
$ sudo chmod a+r /etc/apt/keyrings/docker.asc
$ echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] \
https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo "$VERSION_CODENAME") stable" \
| sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
$ sudo apt update
$ sudo apt install docker-ce docker-ce-cli containerd.io \
docker-buildx-plugin docker-compose-plugin
$ sudo docker version --format '{{.Server.Version}}'
27.1.2
$ sudo systemctl is-enabled docker
enabledFíjate en que añadir un repositorio de terceros es una decisión de seguridad, como se dijo en 05-03: a partir de ahora, quien controle ese repositorio puede instalar paquetes en tu servidor. signed-by limita la clave a ese repositorio concreto, que es lo mínimo exigible.
El aviso del grupo docker
Ese comando aparece en todos los tutoriales como una comodidad, y su implicación real casi nunca se explica:
Pertenecer al grupo
dockeres equivalente a tener root sin contraseña, y sin dejar el rastro que dejasudo.
No es una exageración. La demostración cabe en una línea:
# Como miembro del grupo docker, SIN sudo:
$ docker run --rm -v /:/anfitrion -it alpine \
cat /anfitrion/etc/shadow | head -2
root:$y$j9T$K2p...:20318:0:99999:7:::
daemon:*:20318:0:99999:7:::El demonio de Docker corre como root, y -v /:/anfitrion le pide montar la raíz del anfitrión dentro del contenedor. Cualquiera del grupo docker puede leer /etc/shadow, escribir en /etc/sudoers.d/, o modificar /etc/tramontana/secretos/. Y lo hace sin aparecer en auth.log.
Las consecuencias prácticas, que hay que aplicar:
- El grupo
dockerse trata como el gruposudo, con el mismo control de quién entra y por qué. Ensrv-tramontanasolooperador, y consta en la checklist de 06-06. - Nunca se añade a una cuenta de servicio ni a un usuario de aplicación. Si
svc-tramontanaestuviera endocker, un compromiso de la aplicación sería root inmediato. - La alternativa correcta cuando hay varias personas es
sudo docker, con una regla ensudoers.dque registre quién ejecuta qué — coherente con lo que hiciste en 05-02 condesplegar-seguro. - O usar
podman, que no tiene demonio con privilegios y resuelve el problema de raíz. Se ve al final de la lección.
$ sudo tee /etc/sudoers.d/docker-tramontana >/dev/null <<'EOF'
# Docker requiere privilegios efectivos de root. Se canaliza por sudo para
# que quede traza en auth.log de quien ejecuta que.
Cmnd_Alias TRAMO_DOCKER = /usr/bin/docker, /usr/bin/docker compose
operador ALL=(root) TRAMO_DOCKER
EOF
$ sudo visudo -c -f /etc/sudoers.d/docker-tramontana
/etc/sudoers.d/docker-tramontana: parsed OKImágenes, contenedores y registros
Tres conceptos que se confunden y que conviene separar de una vez:
| Concepto | Qué es | Analogía |
|---|---|---|
| Imagen | Plantilla inmutable de solo lectura, en capas | El fichero ejecutable |
| Contenedor | Una instancia en ejecución, con una capa de escritura encima | El proceso |
| Registro | Servidor donde se publican y descargan imágenes | El repositorio de paquetes |
La propiedad más importante de una imagen es que está formada por capas, cada una un conjunto de cambios sobre la anterior, identificada por su suma criptográfica:
$ sudo docker pull postgres:16-alpine
16-alpine: Pulling from library/postgres
c6a83fedfae6: Pull complete
a2e5cb2d5c74: Pull complete
...
Status: Downloaded newer image for postgres:16-alpine
$ sudo docker image ls
REPOSITORY TAG IMAGE ID CREATED SIZE
postgres 16-alpine 8b4c1f2a9e33 2 weeks ago 274MB
alpine 3.20 a606584aa9aa 3 weeks ago 7.8MB
$ sudo docker history postgres:16-alpine --format 'table {{.Size}}\t{{.CreatedBy}}' | head -6
SIZE CREATED BY
0B CMD ["postgres"]
0B EXPOSE map[5432/tcp:{}]
0B ENTRYPOINT ["docker-entrypoint.sh"]
12.4MB RUN /bin/sh -c set -eux; ...
188MB RUN /bin/sh -c set -eux; apk add --no-cache postgresql16 ...
7.8MB /bin/sh -c #(nop) ADD file:... in /Las capas se comparten entre imágenes: si diez imágenes se basan en alpine:3.20, esos 7,8 MB se almacenan una sola vez. Es la misma idea que los discos de respaldo de qcow2 en 07-04, aplicada al sistema de ficheros.
Y la implicación de seguridad de las capas, que hay que tener presente al escribir un Dockerfile: una capa nunca desaparece. Si en una capa copias un fichero con una contraseña y en la siguiente lo borras, el fichero sigue estando en la imagen y se puede extraer. Volveremos a ello.
# Las etiquetas son MUTABLES: latest hoy no es latest manana
$ sudo docker image inspect postgres:16-alpine --format '{{index .RepoDigests 0}}'
postgres@sha256:4f2b8c1e...
# El digest SI es inmutable, y es lo que se fija en produccion
$ sudo docker pull postgres@sha256:4f2b8c1e...docker run y las opciones que importan
$ sudo docker run --rm alpine:3.20 echo "hola desde el contenedor"
hola desde el contenedor
$ sudo docker run -d --name prueba-nginx -p 8081:80 nginx:1.27-alpine
a3f1c88e2d4b...
$ sudo docker ps
CONTAINER ID IMAGE COMMAND STATUS PORTS NAMES
a3f1c88e2d4b nginx:1.27-alpine "/docker-entrypoint.…" Up 4 seconds 0.0.0.0:8081->80/tcp prueba-nginxY ahora la comprobación que cierra el primer apartado. Desde el anfitrión:
$ pgrep -a nginx
9204 nginx: master process nginx -g daemon off;
9251 nginx: worker process
$ sudo ls -l /proc/9204/ns/ | awk '{print $9, $11}'
mnt mnt:[4026532412]
net net:[4026532475]
pid pid:[4026532413]
uts uts:[4026532410]
$ cat /sys/fs/cgroup/system.slice/docker-a3f1c88e2d4b*.scope/memory.current
8912896El proceso de nginx está en la tabla de procesos del anfitrión, con un PID normal, y pgrep lo encuentra. Solo tiene namespaces propios y un cgroup. No hay ninguna máquina, ningún kernel, ningún arranque. Es un proceso.
Las opciones de docker run que se usan de verdad:
| Opción | Qué hace | Nota |
|---|---|---|
-d |
En segundo plano | |
--name |
Nombre en vez de un identificador aleatorio | Necesario para la resolución por nombre |
-p 8081:80 |
Publica el puerto: anfitrión:contenedor | -p 127.0.0.1:8081:80 lo limita a local |
-v |
Monta un volumen o un directorio | Ver el apartado de datos |
-e |
Variable de entorno | No para secretos (06-05) |
--rm |
Borra el contenedor al terminar | Para pruebas |
--restart unless-stopped |
Rearranca tras un fallo o un reinicio | Producción |
--user 1000:1000 |
Ejecuta como ese UID | No como root dentro |
--read-only |
Sistema de ficheros de solo lectura | Con --tmpfs para lo escribible |
--cap-drop ALL |
Retira todas las capabilities | Y --cap-add solo las necesarias |
--security-opt no-new-privileges |
Impide escalar por SUID | Equivale a NoNewPrivileges de systemd |
--memory / --cpus |
Límites del cgroup | Los de 05-05, con otro nombre |
--health-cmd |
Comprobación de salud | Igual que revision_salud.sh |
Las de gestión del ciclo de vida:
$ sudo docker logs -f --tail 20 prueba-nginx
$ sudo docker exec -it prueba-nginx sh
$ sudo docker inspect prueba-nginx --format '{{.State.Status}} {{.NetworkSettings.IPAddress}}'
running 172.17.0.2
$ sudo docker stats --no-stream
$ sudo docker stop prueba-nginx && sudo docker rm prueba-nginx
$ sudo docker system df
$ sudo docker system prune -a --volumes # CUIDADO: borra lo no usadodocker stop envía SIGTERM, espera 10 segundos y luego SIGKILL — exactamente la disciplina de señales de 03-06. Si tu aplicación necesita más tiempo para cerrar limpiamente, --time 30.
Dockerfile: construir la imagen de Tramontana
Un Dockerfile es la receta de una imagen. Cada instrucción que modifica el sistema de ficheros crea una capa.
# Dockerfile — Tramontana Reservas
# Construccion en dos etapas: la primera compila, la segunda solo ejecuta.
# ---------- Etapa 1: compilacion ----------
FROM golang:1.23-alpine AS constructor
WORKDIR /origen
# Primero SOLO los ficheros de dependencias. Motivo: esta capa se
# reutiliza de la cache mientras no cambien, aunque cambie el codigo.
COPY go.mod go.sum ./
RUN go mod download
# Y despues el codigo, que cambia en cada commit.
COPY . .
# CGO_ENABLED=0 produce un binario estatico: no necesita bibliotecas
# del sistema, asi que la imagen final puede ser minima.
RUN CGO_ENABLED=0 GOOS=linux go build \
-ldflags='-s -w -X main.version=3.2.1' \
-o /tramontana ./cmd/servidor
# ---------- Etapa 2: ejecucion ----------
FROM alpine:3.20
# Actualizaciones de seguridad y certificados raiz para validar TLS.
# --no-cache evita dejar el indice de paquetes en la imagen.
RUN apk add --no-cache ca-certificates tzdata curl \
&& addgroup -g 1002 tramontana \
&& adduser -u 997 -G tramontana -s /sbin/nologin -D svc-tramontana
WORKDIR /opt/tramontana
# Solo el binario de la etapa anterior. El compilador de Go, el codigo
# fuente y las dependencias NO llegan a la imagen final.
COPY --from=constructor /tramontana /opt/tramontana/tramontana
COPY --chown=svc-tramontana:tramontana plantillas/ /opt/tramontana/plantillas/
# Los mismos identificadores numericos que en el servidor: los permisos
# de los volumenes solo son coherentes si coinciden.
USER svc-tramontana
ENV TRAMONTANA_PUERTO=8080 \
TRAMONTANA_LOG_NIVEL=info
EXPOSE 8080
# La comprobacion de salud, equivalente a revision_salud.sh
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
CMD curl -fsS "http://127.0.0.1:${TRAMONTANA_PUERTO}/salud" || exit 1
# ENTRYPOINT: el ejecutable, siempre. CMD: argumentos por defecto,
# sustituibles desde la linea de comandos.
ENTRYPOINT ["/opt/tramontana/tramontana"]
CMD ["--config", "/etc/tramontana/app.conf"]Las instrucciones y lo que hay que saber de cada una:
| Instrucción | Qué hace | Cuidado |
|---|---|---|
FROM |
Imagen base | Fija la versión, nunca latest |
WORKDIR |
Directorio de trabajo | Mejor que RUN cd, que no persiste |
COPY |
Copia ficheros | Preferible a ADD, que hace magia con URL y tar |
RUN |
Ejecuta en construcción | Encadena con &&: cada RUN es una capa |
ENV |
Variable de entorno | Queda en la imagen: nunca secretos |
USER |
Usuario de ejecución | Sin esto, el proceso corre como root |
EXPOSE |
Documenta el puerto | No publica nada: eso es -p |
HEALTHCHECK |
Comprobación periódica | La usa depends_on de Compose |
ENTRYPOINT |
El ejecutable | |
CMD |
Argumentos por defecto | Sustituible al ejecutar |
La caché de capas y cómo ordenar las instrucciones
Docker reutiliza una capa si la instrucción y su contexto no han cambiado. En cuanto una capa se invalida, todas las siguientes se reconstruyen. De ahí la regla:
Ordena las instrucciones de menos a más frecuentemente cambiante.
# MAL: cualquier cambio en el codigo invalida la descarga de dependencias
COPY . .
RUN go mod download
RUN go build -o /tramontana ./cmd/servidor
# BIEN: las dependencias solo se rebajan si cambian go.mod o go.sum
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN go build -o /tramontana ./cmd/servidorEn la primera versión, cambiar una línea de código obliga a volver a descargar todas las dependencias: minutos en cada construcción. En la segunda, segundos.
Y la otra regla de capas, la de encadenar:
# MAL: tres capas, y el indice de apk queda dentro de la imagen
RUN apk update
RUN apk add curl
RUN rm -rf /var/cache/apk/*
# BIEN: una capa, y el borrado ocurre ANTES de que la capa se cierre
RUN apk add --no-cache curlEl rm en un RUN posterior no reduce el tamaño: la capa anterior ya contiene los ficheros, y las capas son inmutables. Es el mismo motivo por el que un secreto copiado y luego borrado sigue siendo extraíble de la imagen.
# .dockerignore: lo que NO se envia al contexto de construccion
$ cat .dockerignore
.git
.gitignore
*.md
Dockerfile
compose.yaml
/datos
/pruebas
*.bak-*
.env
secretos/.dockerignore importa por dos motivos: velocidad —el contexto se envía entero al demonio— y seguridad, porque sin él un COPY . . puede meter en la imagen el directorio .git con todo el historial, o un .env con credenciales.
$ sudo docker build -t tramontana:3.2.1 .
[+] Building 24.1s (16/16) FINISHED
$ sudo docker image ls tramontana
REPOSITORY TAG IMAGE ID CREATED SIZE
tramontana 3.2.1 f4a1c88e2d4b 8 seconds ago 19.4MBConstrucción en varias etapas y elección de imagen base
19,4 MB. La comparación con una construcción de una sola etapa explica de dónde viene:
| Construcción | Contenido | Tamaño |
|---|---|---|
Una etapa sobre golang:1.23 |
Compilador, código fuente, dependencias, binario | ~850 MB |
Una etapa sobre golang:1.23-alpine |
Igual, con base menor | ~380 MB |
Dos etapas sobre alpine:3.20 |
Solo el binario y los certificados | 19,4 MB |
Dos etapas sobre scratch |
Solo el binario | ~11 MB |
Y el beneficio no es solo el disco. La imagen de 850 MB contiene el compilador de Go, git, make y decenas de bibliotecas: cada una es superficie de ataque y una fuente potencial de CVE. La de 19 MB no tiene nada de eso. Es el principio de superficie mínima de 06-06 aplicado a una imagen.
Las bases habituales:
| Base | Tamaño | Gestor | Cuándo |
|---|---|---|---|
ubuntu:24.04 |
~78 MB | apt |
Cuando necesitas el ecosistema completo |
debian:12-slim |
~29 MB | apt |
Compromiso razonable |
alpine:3.20 |
~7,8 MB | apk |
Binarios estáticos, herramientas |
gcr.io/distroless/static |
~2 MB | ninguno | Máxima seguridad; sin shell |
scratch |
0 B | ninguno | Binario estático puro |
Alpine, que se mencionó en 01-03 al hablar de distribuciones, tiene una peculiaridad que hay que conocer: usa musl en lugar de glibc. Un binario compilado contra glibc no funciona en Alpine, y algunas aplicaciones —notablemente en Python con extensiones nativas— tienen problemas sutiles de rendimiento con el asignador de memoria de musl. Con Go y CGO_ENABLED=0 no hay problema, porque el binario es estático.
Las imágenes distroless van un paso más allá: no tienen shell, ni ls, ni gestor de paquetes. Un atacante que consiga ejecución no tiene con qué operar. A cambio, depurar exige docker debug o una imagen paralela con herramientas.
Datos: volúmenes, bind mounts y tmpfs
El sistema de ficheros de un contenedor es efímero: al borrarlo, la capa de escritura desaparece. Los datos que deben sobrevivir se montan desde fuera, y hay tres formas:
| Mecanismo | Dónde vive | Cuándo |
|---|---|---|
| Volumen | Gestionado por Docker en /var/lib/docker/volumes/ |
Datos de la aplicación: bases de datos, ficheros subidos |
| Bind mount | Una ruta concreta del anfitrión | Configuración, desarrollo, integrarse con el sistema |
| tmpfs | Memoria del anfitrión | Temporales y secretos: no tocan disco |
# Volumen: Docker decide donde, y lo gestiona
$ sudo docker volume create tramontana-datos-bd
$ sudo docker volume inspect tramontana-datos-bd --format '{{.Mountpoint}}'
/var/lib/docker/volumes/tramontana-datos-bd/_data
$ sudo docker run -d --name bd \
-v tramontana-datos-bd:/var/lib/postgresql/data \
-e POSTGRES_PASSWORD_FILE=/run/secrets/bd_pass \
postgres:16-alpine
# Bind mount: una ruta concreta, en solo lectura
$ sudo docker run -d --name app \
-v /etc/tramontana/app.conf:/etc/tramontana/app.conf:ro \
-v /opt/tramontana/shared/uploads:/opt/tramontana/shared/uploads \
tramontana:3.2.1
# tmpfs: en memoria, no persiste, no toca disco
$ sudo docker run -d --name app-solo-lectura \
--read-only \
--tmpfs /tmp:rw,noexec,nosuid,size=64m \
tramontana:3.2.1Ese noexec,nosuid del tmpfs es el mismo criterio que aplicaste a /tmp en 06-06.
La preferencia por volúmenes frente a bind mounts para datos tiene motivos concretos: Docker los gestiona (creación, permisos, respaldo con docker run --volumes-from), son portables entre anfitriones, y no dependen de que exista una ruta concreta. Los bind mounts son mejores para configuración —porque quieres editarla desde el anfitrión con tus herramientas— y para desarrollo.
Y la advertencia de permisos, que es la fuente número uno de frustración con volúmenes: los UID son numéricos y no se traducen. Un fichero propiedad del UID 997 en el anfitrión pertenece al UID 997 dentro del contenedor, se llame como se llame ese usuario allí. Por eso el Dockerfile de Tramontana crea svc-tramontana con el UID 997 y el grupo con el GID 1002: para que coincidan con el servidor.
$ sudo docker run --rm -v /opt/tramontana/shared/uploads:/datos alpine:3.20 \
ls -ln /datos
total 4
drwxrws--- 2 997 1002 4096 Aug 18 17:12 fotosRed y publicación de puertos
$ sudo docker network ls
NETWORK ID NAME DRIVER SCOPE
f2a1c88e2d4b bridge bridge local
a3c1f88e2d4c host host local
b4d1e88e2d4d none null local| Modo | Aislamiento | Resolución por nombre | Cuándo |
|---|---|---|---|
bridge (por defecto) |
Red virtual propia con NAT | No en la red por defecto | Rara vez directamente |
| Red de usuario | Igual, pero con DNS interno | Sí | Lo correcto casi siempre |
host |
Ninguno: usa la del anfitrión | N/A | Rendimiento extremo; pierde aislamiento |
none |
Total: solo lo |
N/A | Procesos sin red |
La diferencia entre la red bridge por defecto y una red de usuario es la que decide el diseño:
$ sudo docker network create tramontana-red
$ sudo docker run -d --name bd --network tramontana-red postgres:16-alpine
$ sudo docker run -d --name app --network tramontana-red tramontana:3.2.1
# Resolucion por NOMBRE DE SERVICIO: no hacen falta IP en la configuracion
$ sudo docker exec app getent hosts bd
172.19.0.2 bdEso es lo que permite que app.conf diga db_host=bd en lugar de una IP que cambia en cada arranque. En la red bridge por defecto no funciona.
Y la publicación de puertos, con un detalle de seguridad importante:
# MAL: 0.0.0.0, accesible desde toda la red
$ sudo docker run -d -p 8080:8080 tramontana:3.2.1
# BIEN: solo desde el propio anfitrion, coherente con escucha=127.0.0.1
$ sudo docker run -d -p 127.0.0.1:8080:8080 tramontana:3.2.1
$ sudo ss -tulpn | grep 8080
tcp LISTEN 127.0.0.1:8080 users:(("docker-proxy",pid=9841,fd=4))Aviso serio: Docker escribe reglas en
nftablesy se saltaufw. Un-p 8080:8080inserta una regla en la cadenaDOCKERque se evalúa antes que las deufw, así que el puerto queda accesible desde Internet aunqueufw statusdiga que está bloqueado.
Se comprueba, y hay que comprobarlo:
$ sudo ufw status | grep 8080
# (nada: ufw cree que esta cerrado)
$ sudo nft list chain ip nat DOCKER 2>/dev/null | head -4
$ sudo nmap -p 8080 10.0.2.15 -Pn | grep 8080
8080/tcp open http-proxy # <- ABIERTO pese a ufwLas dos soluciones: publicar siempre con 127.0.0.1: delante —que es lo correcto cuando hay un proxy inverso delante, como habrá en 08-01—, o desactivar la manipulación de reglas de Docker:
$ sudo tee /etc/docker/daemon.json >/dev/null <<'EOF'
{
"iptables": false,
"log-driver": "journald",
"log-opts": { "tag": "{{.Name}}" },
"live-restore": true
}
EOF
$ sudo systemctl restart dockerCuidado: con "iptables": false los contenedores pierden la salida a Internet salvo que escribas tú las reglas de NAT. La opción práctica en la mayoría de los casos es la primera.
Fíjate también en "log-driver": "journald": envía la salida de los contenedores al journal, así que journalctl y el logrotate de 05-06 se aplican también a ellos. Por defecto, Docker escribe a ficheros JSON que crecen sin límite y son una causa habitual de discos llenos.
Docker Compose
Un compose.yaml describe el conjunto de servicios, redes y volúmenes en un fichero versionable:
# compose.yaml — Tramontana Reservas
name: tramontana
services:
bd:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_DB: tramontana_reservas
POSTGRES_USER: tramontana
# El secreto NO va aqui: se lee de un fichero montado.
POSTGRES_PASSWORD_FILE: /run/secrets/bd_pass
secrets:
- bd_pass
volumes:
- datos-bd:/var/lib/postgresql/data
networks:
- interna
# La comprobacion de salud es lo que permite el arranque ordenado
healthcheck:
test: ["CMD-SHELL", "pg_isready -U tramontana -d tramontana_reservas"]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
# Los mismos limites que la unidad de systemd de 05-05
deploy:
resources:
limits:
memory: 512M
security_opt:
- no-new-privileges:true
app:
build:
context: .
dockerfile: Dockerfile
image: tramontana:3.2.1
restart: unless-stopped
# Solo accesible desde el anfitrion: el proxy inverso de 08-01 ira delante
ports:
- "127.0.0.1:8080:8080"
environment:
TRAMONTANA_DB_HOST: bd # resolucion por nombre de servicio
TRAMONTANA_DB_PORT: "5432"
TRAMONTANA_LOG_NIVEL: info
secrets:
- bd_pass
volumes:
- ./config/app.conf:/etc/tramontana/app.conf:ro
- subidas:/opt/tramontana/shared/uploads
networks:
- interna
# NO arranca hasta que la base de datos responde de verdad.
# Sin 'condition', depends_on solo espera a que el contenedor exista.
depends_on:
bd:
condition: service_healthy
healthcheck:
test: ["CMD", "curl", "-fsS", "http://127.0.0.1:8080/salud"]
interval: 30s
timeout: 3s
retries: 3
start_period: 10s
read_only: true
tmpfs:
- /tmp:rw,noexec,nosuid,size=64m
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
deploy:
resources:
limits:
memory: 512M
volumes:
datos-bd:
subidas:
networks:
interna:
driver: bridge
secrets:
bd_pass:
file: ./secretos/bd_pass # fichero 0600, fuera de git$ sudo docker compose config --quiet && echo "sintaxis correcta"
sintaxis correcta
$ sudo docker compose up -d
[+] Running 4/4
✔ Network tramontana_interna Created
✔ Volume "tramontana_datos-bd" Created
✔ Container tramontana-bd-1 Healthy
✔ Container tramontana-app-1 Started
$ sudo docker compose ps
NAME IMAGE STATUS PORTS
tramontana-app-1 tramontana:3.2.1 Up 12 seconds (healthy) 127.0.0.1:8080->8080/tcp
tramontana-bd-1 postgres:16-alpine Up 45 seconds (healthy) 5432/tcp
$ curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8080/casas
200depends_on con condition: service_healthy es la pieza que resuelve un problema real: sin ella, depends_on solo espera a que el contenedor exista, no a que el servicio de dentro esté listo. La aplicación arrancaría, no encontraría la base de datos y moriría. Con la condición, Compose espera al healthcheck.
$ sudo docker compose logs -f app
$ sudo docker compose exec bd psql -U tramontana -d tramontana_reservas -c '\dt'
$ sudo docker compose down # para y borra contenedores y red
$ sudo docker compose down -v # ...Y LOS VOLUMENES: destruye los datosEse -v merece el aviso: docker compose down -v borra los volúmenes, es decir, la base de datos. Es un rm -rf con otro nombre, y la disciplina de 02-04 se aplica igual.
Seguridad de contenedores
El aislamiento de un contenedor es más débil que el de una VM, así que las medidas importan más. Las que hay que aplicar siempre:
| Medida | Por qué |
|---|---|
| No ejecutar como root dentro | USER en el Dockerfile o --user. Sin esto, un escape es root en el anfitrión |
--read-only + tmpfs |
Un atacante no puede escribir su herramienta en el sistema de ficheros |
--cap-drop=ALL y añadir solo lo necesario |
El proceso no necesita 14 capabilities |
no-new-privileges |
Impide escalar por un binario SUID |
| Fijar la versión de la imagen | latest cambia sin avisar: no hay reproducibilidad |
| Límites de memoria y CPU | Un contenedor sin límite puede tumbar el anfitrión |
| Escanear las imágenes | Las bases acumulan CVE con el tiempo |
No poner secretos en ENV ni en capas |
Quedan en la imagen para siempre |
Sobre los secretos, la demostración de por qué ENV es mala idea:
# MAL: visible para cualquiera que pueda inspeccionar la imagen o el contenedor
$ sudo docker inspect tramontana-app-1 --format '{{json .Config.Env}}' | tr ',' '\n'
"TRAMONTANA_DB_PASSWORD=Zx9K2pQ7vLm4RtWn"
# Y tambien en /proc, como ya viste en 06-05
$ sudo tr '\0' '\n' < /proc/$(sudo docker inspect -f '{{.State.Pid}}' tramontana-app-1)/environEs exactamente el problema que resolviste en 06-05 con LoadCredentialEncrypted. El equivalente en Docker es la sección secrets del compose.yaml, que monta el fichero en un tmpfs dentro del contenedor:
$ sudo docker compose exec app ls -l /run/secrets/
-r--r----- 1 svc-tramontana tramontana 17 ago 18 19:41 bd_passY el escaneo de imágenes:
$ sudo docker run --rm -v /var/run/docker.sock:/var/run/docker.sock \
aquasec/trivy:latest image --severity HIGH,CRITICAL tramontana:3.2.1
tramontana:3.2.1 (alpine 3.20.2)
Total: 0 (HIGH: 0, CRITICAL: 0)
$ sudo docker run --rm aquasec/trivy:latest image --severity HIGH,CRITICAL \
postgres:16-alpine | tail -4
Total: 2 (HIGH: 2, CRITICAL: 0)Fíjate en un detalle que ilustra la advertencia del grupo docker: ese comando monta /var/run/docker.sock dentro de un contenedor, lo que le da control completo del demonio de Docker y por tanto del anfitrión. Se hace con imágenes en las que confías, y con conciencia de lo que implica.
Dos medidas más que cierran el círculo con módulos anteriores:
# AppArmor: Docker aplica un perfil por defecto (06-06)
$ sudo docker inspect tramontana-app-1 --format '{{.AppArmorProfile}}'
docker-default
# seccomp: filtro de llamadas al sistema, como el SystemCallFilter de 05-05
$ sudo docker inspect tramontana-app-1 --format '{{.HostConfig.SecurityOpt}}'
[no-new-privileges:true]El perfil docker-default de AppArmor y el filtro seccomp por defecto bloquean unas cuarenta llamadas al sistema peligrosas. Desactivarlos con --privileged o --security-opt seccomp=unconfined es lo que convierte un contenedor en un proceso con acceso casi total al anfitrión, y --privileged no debe aparecer nunca en producción.
Alternativas, y cuándo no usar contenedores
| Herramienta | Modelo | Ventaja |
|---|---|---|
| Docker | Demonio con privilegios | Ecosistema, documentación, herramientas |
| Podman | Sin demonio, rootless | Sin proceso privilegiado; compatible con Docker |
| containerd | Motor de bajo nivel | Es lo que usa Kubernetes por debajo |
| LXC/LXD | Contenedores de sistema | Se parecen a una VM ligera: init completo dentro |
Podman merece atención por lo que resuelve:
$ sudo apt install podman
$ podman run --rm alpine:3.20 echo "sin demonio, sin sudo"
sin demonio, sin sudo
# Y sin root: gracias a los namespaces de usuario del segundo apartado
$ podman unshare cat /proc/self/uid_map
0 1000 1
1 100000 65536podman no tiene demonio: cada contenedor es un proceso hijo del que lo lanza. En modo rootless usa namespaces de usuario, así que no existe el problema del grupo docker. Sus comandos son compatibles (alias docker=podman funciona casi siempre) y se integra con systemd generando unidades, lo que encaja bien con todo lo del Módulo 5. Para un servidor donde varias personas ejecutan contenedores, es la opción más defendible.
LXC es distinto en concepto: contenedores de sistema, con init completo dentro, varios procesos y sesiones. Se parece más a una VM ligera que a un proceso aislado.
Cuándo NO usar contenedores
La honestidad que falta en la mayoría de las guías:
| Situación | Por qué no |
|---|---|
| Aislamiento de seguridad fuerte entre inquilinos | Comparten kernel. Para eso están las VM de 07-04 |
| Una base de datos con estado crítico | Se puede, pero el almacenamiento persistente añade complejidad sin beneficio claro en un solo servidor |
| Aplicaciones que necesitan acceso profundo al kernel | Módulos, systemd completo, hardware específico |
| Una aplicación en un único servidor que ya funciona | Contenerizar añade una capa que hay que aprender y mantener a cambio de poco |
| Cargas con requisitos de latencia extremos | La capa de red añade microsegundos |
| Sistemas que deben durar diez años sin tocarse | El ecosistema cambia rápido |
Ese cuarto punto se aplica a Tramontana ahora mismo: srv-tramontana funciona, está endurecido, monitorizado y documentado. Contenerizarlo hoy añadiría complejidad sin resolver ningún problema pendiente. Los contenedores brillan cuando hay varios servicios, varios entornos o varios servidores — que es justo el terreno de las dos lecciones siguientes.
Errores Comunes y Consejos
- Pensar en un contenedor como en una VM pequeña. Lleva a meter
systemd,sshdycrondentro, y a tratar el contenedor como una máquina que se administra. Un contenedor ejecuta un proceso y se reemplaza, no se administra. - Añadir a alguien al grupo
dockersin entender lo que implica. Es root sin contraseña y sin traza. Trátalo como el gruposudo. - Usar
latest. La imagen cambia sin avisar, y una reconstrucción de la semana que viene no produce lo mismo. Fija la versión, y en producción el digest. - Poner secretos en
ENVo en una capa. Quedan en la imagen para siempre, aunque los borres después: las capas son inmutables. Usasecretso un fichero montado. - Copiar el código antes de las dependencias en el
Dockerfile. Invalida la caché en cada cambio y convierte una construcción de segundos en una de minutos. rmen unRUNposterior para reducir tamaño. No funciona: la capa anterior ya contiene los ficheros. Encadena con&&dentro del mismoRUN.- Olvidar
.dockerignore. UnCOPY . .puede meter.gitentero, o un.envcon credenciales, en la imagen. - Ejecutar como root dentro del contenedor. Es el valor por defecto y es lo primero que hay que cambiar con
USER. - Publicar con
-p 8080:8080y creer queufwprotege. Docker escribe reglas que se evalúan antes. Usa-p 127.0.0.1:8080:8080y verifícalo desde fuera connmap. - Dejar el controlador de registro por defecto. Los ficheros JSON crecen sin límite.
"log-driver": "journald"los integra conjournalctlylogrotate. docker compose down -vsin pensarlo. Borra los volúmenes, es decir, los datos.--privilegedpara «que funcione». Desactiva seccomp, AppArmor y las capabilities de golde. Nunca en producción; busca la capability concreta que falta.- Consejo de método. Un contenedor debe poder morir y renacer sin que se pierda nada. Si el tuyo tiene estado dentro de su capa de escritura, algo está mal montado: todo lo que importa va en un volumen.
Ejercicios
Ejercicio 1
Demuestra, sin usar Docker, que un contenedor es un proceso aislado. Crea un «contenedor» a mano con unshare que tenga su propio nombre de máquina, su propia tabla de procesos y su propia red, y comprueba desde el anfitrión que sigue siendo un proceso normal. Explica qué le falta a tu construcción para ser un contenedor de verdad.
Ejercicio 2
Revisa este Dockerfile que ha escrito Luis y corrígelo, explicando cada problema:
FROM ubuntu:latest
RUN apt-get update
RUN apt-get install -y python3 python3-pip curl git
COPY . /app
WORKDIR /app
RUN pip3 install -r requirements.txt
ENV DB_PASSWORD=Zx9K2pQ7vLm4RtWn
EXPOSE 8080
CMD python3 servidor.pyEjercicio 3
Marta pregunta si conviene migrar Tramontana Reservas a contenedores. Redacta el análisis, teniendo en cuenta el estado real del servidor y lo que se ha visto en las dos últimas lecciones.
Soluciones
Solución 1
# --- El "contenedor" a mano ---
# Cada bandera de unshare crea un namespace. Juntas dan la mayor parte
# del aislamiento de un contenedor.
$ sudo unshare --pid --fork --mount-proc \
--uts --ipc --mount --net \
--root=/srv/minicontenedor \
/bin/bashAntes hace falta una raíz mínima, que es justo lo que hace una imagen:
$ sudo mkdir -p /srv/minicontenedor
$ sudo docker export $(sudo docker create alpine:3.20) \
| sudo tar -x -C /srv/minicontenedor
$ ls /srv/minicontenedor
bin dev etc home lib media mnt opt proc root run sbin srv sys tmp usr varY ahora, dentro:
$ sudo unshare --pid --fork --mount-proc --uts --ipc --mount --net \
chroot /srv/minicontenedor /bin/sh
/ # hostname minicontenedor
/ # hostname
minicontenedor
/ # ps aux
PID USER TIME COMMAND
1 root 0:00 /bin/sh
8 root 0:00 ps aux
/ # ip addr
1: lo: <LOOPBACK> mtu 65536 qdisc noop state DOWN qlen 1000
link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
/ # cat /etc/os-release | head -2
NAME="Alpine Linux"
ID=alpineNombre propio, PID 1, dos procesos visibles, sin red, y una distribución distinta a la del anfitrión — todo sin arrancar ningún kernel.
# --- Desde el anfitrion, la comprobacion que demuestra la tesis ---
$ pgrep -a -f 'unshare --pid'
10412 sudo unshare --pid --fork --mount-proc --uts --ipc --mount --net chroot ...
$ pid=$(pgrep -f 'chroot /srv/minicontenedor' | head -1)
$ ps -o pid,ppid,user,comm -p "$pid"
PID PPID USER COMMAND
10415 10412 root sh
$ sudo cat /proc/$pid/status | grep -E '^Name|^Pid|^NSpid'
Name: sh
Pid: 10415
NSpid: 10415 1
$ sudo readlink /proc/$pid/ns/pid /proc/1/ns/pid
pid:[4026532398]
pid:[4026531836]
$ sudo readlink /proc/$pid/root
/srv/minicontenedorLa línea NSpid: 10415 1 es la demostración exacta: el mismo proceso tiene el PID 10415 en el anfitrión y el PID 1 dentro de su namespace. Y uname -r da el mismo kernel en ambos lados:
Qué le falta para ser un contenedor de verdad:
| Falta | Por qué importa | Cómo lo hace Docker |
|---|---|---|
| cgroups | Sin límites, el proceso puede consumir toda la memoria y la CPU del anfitrión | Crea un cgroup con memory.max, cpu.max, pids.max |
| Capabilities reducidas | Aquí el proceso conserva las de root: podría cargar módulos o montar cualquier cosa | Retira ~26 de las 40 capabilities |
| seccomp | Sin filtro, puede invocar cualquier llamada al sistema, incluidas las vulnerables | Aplica un perfil que bloquea ~44 llamadas |
| AppArmor / MAC | Sin perfil, el DAC es la única barrera | Aplica docker-default |
pivot_root en vez de chroot |
chroot es evadible; pivot_root desmonta la raíz antiga |
Usa pivot_root |
| Red utilizable | El namespace de red está vacío: solo lo y caído |
Crea un veth, lo conecta a un puente y configura NAT |
| Sistema de ficheros en capas | Aquí la raíz es un directorio normal, y cada «contenedor» necesita su copia completa | Usa overlayfs: capas de solo lectura más una de escritura |
| Namespace de usuario | El root de dentro es el root de fuera | Opcional en Docker; por defecto en podman rootless |
| Gestión del ciclo de vida | No hay imágenes, ni versiones, ni forma de reproducir esto | Registro, etiquetas, digests |
La conclusión que se pide: Docker no aporta ninguna primitiva nueva. El aislamiento ya está en el kernel desde hace más de una década. Lo que aporta es el empaquetado: un formato de imagen con capas compartidas, un registro donde publicarlas, una forma reproducible de construirlas, y la aplicación coherente de las ocho restricciones de la tabla, que a mano serían un guion largo y fácil de equivocar. Entender esto explica por qué existen alternativas compatibles como podman: si las primitivas son del kernel, cualquiera puede orquestarlas.
Solución 2
Once problemas. La versión corregida, y después el porqué de cada uno:
# --- Etapa 1: dependencias ---
# 1. Version FIJA, no latest. 2. Base slim en vez de completa.
FROM python:3.12-slim-bookworm AS constructor
WORKDIR /app
# 3. Las dependencias ANTES del codigo: la cache se reutiliza mientras
# requirements.txt no cambie.
COPY requirements.txt .
# 4. Un solo RUN encadenado, sin dejar la cache de pip en la capa.
RUN pip install --no-cache-dir --prefix=/instalado -r requirements.txt
# --- Etapa 2: ejecucion ---
FROM python:3.12-slim-bookworm
# 5. Un solo RUN, con limpieza DENTRO de la misma capa.
# Sin git ni pip: son de construccion, no de ejecucion.
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl \
&& rm -rf /var/lib/apt/lists/* \
&& groupadd -g 1002 tramontana \
&& useradd -u 997 -g tramontana -s /usr/sbin/nologin -M svc-tramontana
WORKDIR /app
# 6. Solo las dependencias instaladas, sin el arbol de construccion
COPY --from=constructor /instalado /usr/local
# 7. Propiedad correcta desde el principio
COPY --chown=svc-tramontana:tramontana . /app
# 8. NO ejecutar como root
USER svc-tramontana
# 9. Sin secretos. Solo configuracion no sensible.
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
TRAMONTANA_PUERTO=8080
EXPOSE 8080
# 10. Comprobacion de salud, que ademas habilita depends_on en Compose
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
CMD curl -fsS "http://127.0.0.1:${TRAMONTANA_PUERTO}/salud" || exit 1
# 11. Forma exec, no shell: el proceso es PID 1 y recibe las senales
ENTRYPOINT ["python3", "servidor.py"]Y el .dockerignore que faltaba por completo:
Los once problemas, en orden de gravedad:
| # | Problema | Consecuencia |
|---|---|---|
| 1 | ENV DB_PASSWORD=... |
El más grave. La contraseña queda en la imagen para siempre, visible con docker history o docker inspect, y en cualquier registro donde se publique. Y aunque se borre en una capa posterior, la capa original la conserva |
| 2 | FROM ubuntu:latest |
Sin reproducibilidad: la construcción de hoy y la de dentro de un mes producen imágenes distintas. Y ubuntu completo son 78 MB frente a los 29 de slim |
| 3 | Ejecuta como root | Sin USER, el proceso es root dentro. Combinado con un escape de contenedor, es root en el anfitrión |
| 4 | git en la imagen final |
Herramienta de construcción que queda en producción: superficie de ataque y CVE. pip también sobra |
| 5 | COPY . /app antes de instalar dependencias |
Cualquier cambio de código invalida la caché y obliga a reinstalar todo: minutos en cada construcción |
| 6 | Tres RUN separados |
Tres capas, y el índice de apt (~40 MB) queda dentro de la imagen porque nunca se borra en la misma capa |
| 7 | apt-get update sin install en el mismo RUN |
La caché puede reutilizar el update viejo y el install traer paquetes desactualizados. Es el problema del cache busting |
| 8 | Sin --no-install-recommends |
Arrastra decenas de paquetes que nadie pidió |
| 9 | CMD python3 servidor.py en forma shell |
Se ejecuta como /bin/sh -c "python3 servidor.py", así que el PID 1 es la shell y no propaga SIGTERM a Python: docker stop acaba matando el proceso a los 10 segundos con SIGKILL, sin cierre limpio |
| 10 | Sin HEALTHCHECK |
Docker no sabe si el servicio funciona, y depends_on: condition: service_healthy no se puede usar |
| 11 | Sin .dockerignore |
COPY . /app mete .git entero —con todo el historial, incluidos secretos ya borrados— y cualquier .env presente |
El resultado medido:
$ sudo docker build -t tramontana-luis:malo -f Dockerfile.original .
$ sudo docker build -t tramontana:bueno .
$ sudo docker image ls | grep -E 'tramontana'
tramontana bueno f4a1c88e2d4b 14 seconds ago 142MB
tramontana-luis malo a3c1f88e2d4c 2 minutes ago 684MB
$ sudo docker history tramontana-luis:malo | grep -i password
<missing> 2 minutes ago ENV DB_PASSWORD=Zx9K2pQ7vLm4RtWn 0B684 MB frente a 142, y la contraseña recuperable con un solo comando por cualquiera que tenga acceso a la imagen.
La nota que hay que darle a Luis: la contraseña que aparece en ese Dockerfile es la de producción, así que —siguiendo la regla de 06-05— se considera comprometida desde el momento en que se escribió, y hay que rotarla con el procedimiento crear-aplicar-verificar-retirar. No basta con corregir el fichero.
Solución 3
Análisis: migrar Tramontana Reservas a contenedores Para: Marta Vidal · De: Operaciones de sistemas · 18 de agosto de 2026
Recomendación breve: no todavía, pero sí prepararlo. Contenerizar hoy
srv-tramontanaañadiría complejidad sin resolver ningún problema pendiente. Propongo un camino intermedio con beneficio inmediato y sin riesgo.
El punto de partida importa. No estamos ante una aplicación desplegada de cualquier manera.
srv-tramontanatiene despliegue atómico con rollback automático, servicio de systemd endurecido con la mejor puntuación de seguridad que da la herramienta, secretos cifrados, registros centralizados en el journal con rotación, copias con restauración probada, detección de cambios y un firewall de lista blanca. Funciona, está documentado y está medido. Esa es la referencia contra la que hay que comparar.Qué ganaríamos
- Despliegues reproducibles bit a bit. Hoy
desplegar.shmueve un enlace simbólico a un directorio nuevo, y el entorno —bibliotecas del sistema, versiones— es el que haya en la máquina. Con una imagen, lo que se prueba es exactamente lo que se despliega. Recuerda que el release 3.3.0 falló en julio y no supimos por qué hasta esta semana: un fallo de ese tipo es menos probable con imágenes, porque el entorno viaja con la aplicación.- Rollback aún más rápido. Volver a la imagen anterior es cambiar una etiqueta.
- Paridad entre entornos. La misma imagen en pruebas y en producción, sin la deriva que aparece cuando se configuran dos máquinas por separado.
- Aislamiento adicional de la aplicación. Un compromiso quedaría más contenido: sin shell útil, sin acceso al resto del sistema de ficheros, sin capabilities.
- Preparación para crecer. Si en algún momento hacen falta dos o tres instancias detrás de un balanceador, con contenedores es cuestión de minutos.
Qué costaría
- Rehacer trabajo que ya está hecho y validado. El endurecimiento de systemd, el perfil de AppArmor, la gestión de secretos y la integración con el journal habría que reconstruirlos con el equivalente de contenedores. No es imposible, pero es semanas de trabajo para llegar donde ya estamos.
- Una capa más que aprender y mantener. Imágenes, registros, redes virtuales, volúmenes, y sus modos de fallo propios — que son distintos y menos conocidos que los del sistema.
- Un riesgo de seguridad nuevo y concreto. El demonio de Docker corre como root, y pertenecer al grupo
dockerequivale a tener root sin dejar traza. Además, Docker escribe reglas de cortafuegos que se saltanufw: un puerto publicado queda abierto aunque el firewall diga lo contrario. Lo he verificado en el laboratorio. Habría que gestionarlo explícitamente.- La base de datos es el punto difícil. PostgreSQL en contenedor es posible, pero el almacenamiento persistente añade complejidad y no aporta nada en un único servidor. Mi recomendación sería dejarla fuera en cualquier caso.
- No resuelve nuestro problema real. Nuestro problema abierto es que
srv-tramontanaes un único punto de fallo. Los contenedores no lo resuelven: si la máquina cae, caen los contenedores. Eso es redundancia, y es el tema del que te traeré una propuesta.
Lo que propongo, en tres pasos
Paso 1 — Ahora: contenerizar solo el entorno de pruebas. Beneficio inmediato, riesgo nulo. Permite levantar la aplicación y una base de datos limpia en segundos para probar un cambio, y aprender la herramienta sin exponer producción. Ya está funcionando en
srv-tramontana-pruebas.Paso 2 — Después: construir la imagen en cada release, sin desplegarla. Cada versión produce además una imagen versionada y escaneada. Ganamos reproducibilidad y detección de vulnerabilidades en las dependencias, sin cambiar nada en producción. Es un paso reversible.
Paso 3 — Cuando se cumpla alguna condición: migrar. Las condiciones que lo justificarían:
Condición Por qué cambia la decisión Hacen falta dos o más instancias de la aplicación Es donde los contenedores empiezan a rendir de verdad Aparecen más servicios (una API, un procesador de tareas) Gestionar cinco servicios a mano es donde Compose gana Hay más de un entorno que mantener sincronizado La paridad deja de ser un lujo Volvemos a tener un fallo por diferencias de entorno Sería la segunda vez; una es casualidad Y una observación sobre la alternativa. Si damos el paso, propongo evaluar podman en lugar de Docker: no tiene demonio con privilegios, funciona sin root, resuelve de raíz el problema del grupo
docker, y se integra con systemd generando unidades — es decir, encaja con todo lo que ya tenemos montado en vez de sustituirlo. Sus comandos son compatibles, así que lo aprendido no se pierde.Resumen. Los contenedores son la respuesta correcta a un problema de escala y reproducibilidad entre entornos. Hoy tenemos un servidor, un entorno y una aplicación que funciona, así que el beneficio no compensa el coste. Empezamos por pruebas y por construir las imágenes, que es donde el beneficio es inmediato, y reevaluamos cuando cambie alguna de las condiciones de la tabla.
Conclusión
Tienes la idea correcta, que es lo que más vale de esta lección: un contenedor es un proceso aislado, no una máquina pequeña. Lo has visto sin intermediarios —un bash que es PID 1 dentro y tiene un PID normal fuera, con NSpid: 10415 1 en /proc como prueba— y sabes que Docker no inventa ninguna primitiva: los namespaces, los cgroups y las capabilities están en el kernel, y son literalmente los mismos que el MemoryMax=512M de 05-05 y el chroot de 07-01. Lo que Docker aporta es el empaquetado: imágenes en capas compartidas, un registro, construcción reproducible, y la aplicación coherente de ocho restricciones que a mano serían un guion largo y frágil.
Sabes construir una imagen que pese 19 MB en lugar de 850, ordenando las instrucciones para aprovechar la caché y usando dos etapas para que el compilador no llegue a producción — que es el principio de superficie mínima de 06-06 aplicado a una imagen. Sabes que un secreto en una capa no se borra nunca, que -p 8080:8080 se salta ufw y hay que verificarlo desde fuera con nmap, que USER no es opcional, y que docker compose down -v destruye los datos. Y has resuelto el kernel.unprivileged_userns_clone que dejaste comentado en 06-06: se queda activo, con la decisión y su motivo escritos, porque la contención que aportan los contenedores vale más que la superficie que cierra.
Fíjate ahora en dónde estás. Tienes srv-tramontana en producción, srv-tramontana-pruebas recreable con cloud-init, y contenedores que levantan la aplicación y su base de datos con un comando. Tres formas de crear máquinas y servicios. Y las tres comparten un problema: la configuración de producción sigue siendo un runbook en prosa y la memoria del administrador. Cada usuario, cada regla de sudoers, cada unidad de systemd, cada línea de ufw, cada sysctl y cada perfil de AppArmor de los módulos 5 y 6 se aplicó a mano. El RTO de 8 horas que Marta aprobó depende enteramente de que alguien recuerde el orden correcto, y la regla de 06-04 —«un servidor comprometido se reinstala, no se limpia»— es fácil de decir y cara de cumplir.
En la lección 07-06: Automatización con Ansible eso se convierte en código. Verás la diferencia entre un script imperativo como desplegar.sh y un estado deseado idempotente, entenderás por qué Ansible no necesita agente —trabaja sobre el SSH que endureciste en 06-02— y escribirás inventarios, playbooks con handlers y etiquetas, plantillas Jinja2 que generan app.conf con variables por entorno, y roles que organizan todo en piezas reutilizables. Usarás --check y --diff, que son el --dry-run del curso llevado a la configuración entera, guardarás los secretos con Vault junto a tu pass, y probarás cada cambio contra srv-tramontana-pruebas antes de tocar producción. Y al final harás la medición que importa: cuánto tarda en reconstruirse el servidor desde cero. Ese número —de ocho horas a menos de una— es lo que convierte una regla de seguridad en un procedimiento que de verdad se puede cumplir.
Curso de Linux: De Principiante a Administrador de Sistemas
Módulo 1: Introducción a Linux
- ¿Qué es Linux?
- Historia de Linux
- Distribuciones de Linux
- Instalando Linux
- Primer Contacto con el Sistema
- Estructura del Sistema de Archivos de Linux
Módulo 2: Comandos Básicos de Linux
- Introducción a la Línea de Comandos
- Obtener Ayuda y Documentación del Sistema
- Navegando el Sistema de Archivos
- Operaciones con Archivos y Directorios
- Visualización y Edición de Archivos
- Enlaces Duros y Simbólicos
- Permisos y Propiedad de Archivos
Módulo 3: Habilidades Avanzadas en la Línea de Comandos
- El Entorno del Shell: Variables, Alias e Historial
- Uso de Comodines y Expresiones Regulares
- Búsqueda de Archivos y Contenido: find, locate y grep
- Tuberías y Redirección
- Procesamiento de Texto: cut, sort, uniq, sed y awk
- Gestión de Procesos
- Programación de Tareas con Cron
- Comandos de Redes
Módulo 4: Scripting en Shell
- Introducción al Scripting en Shell
- Variables y Tipos de Datos
- Entrada, Salida y Argumentos de un Script
- Estructuras de Control
- Funciones y Librerías
- Depuración y Manejo de Errores
- Scripts de Producción: Buenas Prácticas
Módulo 5: Administración del Sistema
- Gestión de Usuarios y Grupos
- sudo y Permisos Especiales
- Gestión de Paquetes
- Gestión de Discos
- systemd y la Gestión de Servicios
- Registros del Sistema: journald y syslog
- Monitoreo del Sistema y Optimización del Rendimiento
- Respaldo y Restauración
Módulo 6: Redes y Seguridad
- Configuración de Redes
- SSH y Acceso Remoto
- Firewall y Seguridad Perimetral
- Sistemas de Detección de Intrusos
- Gestión de Secretos y Certificados TLS
- Asegurando Sistemas Linux
Módulo 7: Temas Avanzados
- El Proceso de Arranque y la Recuperación del Sistema
- Diagnóstico Avanzado: strace, perf y eBPF
- Optimización del Kernel de Linux
- Virtualización con Linux
- Contenedores de Linux y Docker
- Automatización con Ansible
- Alta Disponibilidad y Balanceo de Carga
