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

  1. Las tres primitivas: namespaces, cgroups y capabilities
  2. Namespaces de usuario y el sysctl que dejaste comentado
  3. Instalar Docker, y por qué el grupo docker equivale a root
  4. Imágenes, contenedores y registros
  5. docker run y las opciones que importan
  6. Dockerfile: construir la imagen de Tramontana
  7. Construcción en varias etapas y elección de imagen base
  8. Datos: volúmenes, bind mounts y tmpfs
  9. Red y publicación de puertos
  10. Docker Compose
  11. Seguridad de contenedores
  12. 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/init

Todos 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 aux

Ahí 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
14

El 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:/# exit

Ni 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-tramontana

Un 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
64

Es 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:~# exit

Lé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 = 0

La 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 = 1

Un 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
enabled

Fí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

$ sudo usermod -aG docker operador   # <- LEE EL AVISO ANTES DE HACER ESTO

Ese comando aparece en todos los tutoriales como una comodidad, y su implicación real casi nunca se explica:

Pertenecer al grupo docker es equivalente a tener root sin contraseña, y sin dejar el rastro que deja sudo.

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:

  1. El grupo docker se trata como el grupo sudo, con el mismo control de quién entra y por qué. En srv-tramontana solo operador, y consta en la checklist de 06-06.
  2. Nunca se añade a una cuenta de servicio ni a un usuario de aplicación. Si svc-tramontana estuviera en docker, un compromiso de la aplicación sería root inmediato.
  3. La alternativa correcta cuando hay varias personas es sudo docker, con una regla en sudoers.d que registre quién ejecuta qué — coherente con lo que hiciste en 05-02 con desplegar-seguro.
  4. 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 OK

Imá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-nginx

Y 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
8912896

El 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 usado

docker 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/servidor

En 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 curl

El 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.4MB

Construcció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.1

Ese 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 fotos

Red 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       bd

Eso 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 nftables y se salta ufw. Un -p 8080:8080 inserta una regla en la cadena DOCKER que se evalúa antes que las de ufw, así que el puerto queda accesible desde Internet aunque ufw status diga 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 ufw

Las 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 docker

Cuidado: 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
200

depends_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 datos

Ese -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)/environ

Es 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_pass

Y 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      65536

podman 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, sshd y cron dentro, 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 docker sin entender lo que implica. Es root sin contraseña y sin traza. Trátalo como el grupo sudo.
  • 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 ENV o en una capa. Quedan en la imagen para siempre, aunque los borres después: las capas son inmutables. Usa secrets o 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.
  • rm en un RUN posterior para reducir tamaño. No funciona: la capa anterior ya contiene los ficheros. Encadena con && dentro del mismo RUN.
  • Olvidar .dockerignore. Un COPY . . puede meter .git entero, o un .env con 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:8080 y creer que ufw protege. Docker escribe reglas que se evalúan antes. Usa -p 127.0.0.1:8080:8080 y verifícalo desde fuera con nmap.
  • Dejar el controlador de registro por defecto. Los ficheros JSON crecen sin límite. "log-driver": "journald" los integra con journalctl y logrotate.
  • docker compose down -v sin pensarlo. Borra los volúmenes, es decir, los datos.
  • --privileged para «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.py

Ejercicio 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/bash

Antes 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  var

Y 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=alpine

Nombre 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/minicontenedor

La 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:

$ uname -r
6.8.0-41-generic
$ sudo nsenter -t "$pid" -a uname -r
6.8.0-41-generic

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:

.git
.gitignore
.env
secretos/
*.bak-*
__pycache__/
*.pyc
Dockerfile
compose.yaml
README.md

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   0B

684 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-tramontana añ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-tramontana tiene 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

  1. Despliegues reproducibles bit a bit. Hoy desplegar.sh mueve 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.
  2. Rollback aún más rápido. Volver a la imagen anterior es cambiar una etiqueta.
  3. 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.
  4. 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.
  5. 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

  1. 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.
  2. 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.
  3. Un riesgo de seguridad nuevo y concreto. El demonio de Docker corre como root, y pertenecer al grupo docker equivale a tener root sin dejar traza. Además, Docker escribe reglas de cortafuegos que se saltan ufw: un puerto publicado queda abierto aunque el firewall diga lo contrario. Lo he verificado en el laboratorio. Habría que gestionarlo explícitamente.
  4. 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.
  5. No resuelve nuestro problema real. Nuestro problema abierto es que srv-tramontana es 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

Módulo 2: Comandos Básicos de Linux

Módulo 3: Habilidades Avanzadas en la Línea de Comandos

Módulo 4: Scripting en Shell

Módulo 5: Administración del Sistema

Módulo 6: Redes y Seguridad

Módulo 7: Temas Avanzados

Módulo 8: Proyectos Prácticos

© Copyright 2026. Todos los derechos reservados