Secretos de un solo uso en Docker Swarm con tuberías FIFO
Docker Swarm monta los secretos en /run/secrets/ como volúmenes tmpfs de solo lectura. Cualquiera con acceso a docker exec puede leerlos en cualquier momento. La solución: usar tuberías con nombre (FIFOs) para entregar secretos de un solo uso al iniciar el contenedor.
Problemas con el enfoque estándar
Los secretos se almacenan en el registro Raft en los nodos manager, se envían vía mTLS a los workers y se montan en los contenedores. Así se define en docker-compose.yml:
version: "3.8"
services:
app:
image: myapp:latest
secrets:
- db_password
- api_key
secrets:
db_password:
external: true
api_key:
external: true
Lectura en tu aplicación:
with open("/run/secrets/db_password") as f:
db_password = f.read().strip()
Acceso vía docker exec:
$ docker exec -it <container_id> cat /run/secrets/db_password
SuperSecretPassword123
Acceso en el host:
sudo cat /var/lib/docker/containers/<container_id>/mounts/secrets/ioz4pc6q8fjsfhem4pwsr35u6
SuperSecretPassword123
Por qué fallan las soluciones estándar
- rm /truncate/chmod: tmpfs de solo lectura
- umount: requiere CAP_SYS_ADMIN
- Variables de entorno: visibles en
/proc/1/environ, heredadas por procesos hijos - Gestores externos (Vault): añade infraestructura extra
Cómo funcionan las tuberías FIFO
Las tuberías con nombre tienen estas características clave:
- Las escrituras se bloquean sin lector
- Las lecturas se bloquean sin escritor
- Los datos se consumen en la primera lectura
- Las lecturas posteriores se cuelgan
Demostración:
$ mkfifo /tmp/my_pipe
echo "secret_value" > /tmp/my_pipe # se bloquea
cat /tmp/my_pipe # secret_value, datos eliminados
cat /tmp/my_pipe # se cuelga
Arquitectura de la solución
Dos contenedores comparten un volumen tmpfs con FIFOs:
- secret-injector: crea FIFOs, escribe secretos y termina
- app: lee de los FIFOs una vez, mantiene secretos solo en memoria
El contenedor app no tiene /run/secrets/.
Implementación
docker-compose.yml
version: "3.8"
services:
secret-injector:
image: alpine:3.19
secrets:
- db_password
- api_key
volumes:
- secrets_pipe:/shared
entrypoint: ["sh", "-c", "
mkfifo /shared/db_password /shared/api_key &&
cat /run/secrets/db_password > /shared/db_password &
cat /run/secrets/api_key > /shared/api_key &
wait
"]
deploy:
restart_policy:
condition: none
resources:
limits:
memory: 16M
app:
image: myapp:latest
volumes:
- secrets_pipe:/shared:ro
entrypoint: ["sh", "-c", "
DB_PASSWORD=$(cat /shared/db_password) &&
API_KEY=$(cat /shared/api_key) &&
exec myapp
"]
depends_on:
- secret-injector
volumes:
secrets_pipe:
driver: local
driver_opts:
type: tmpfs
device: tmpfs
o: size=1m,noexec,nosuid
secrets:
db_password:
external: true
api_key:
external: true
Secuencia de ejecución
- secret-injector:
- mkfifo /shared/db_password /shared/api_key
- cat /run/secrets/... > /shared/... (se bloquea)
- wait mantiene el contenedor vivo
- app:
- DB_PASSWORD=$(cat /shared/db_password) → desbloquea al escritor
- Datos consumidos del FIFO
- secret-injector termina (
restart_policy: none)
- app se ejecuta con secretos en memoria
Lo que ve un atacante
En app:
$ docker exec -it <app_container> sh
$ ls /run/secrets/
ls: /run/secrets/: No existe ese archivo o directorio
$ cat /shared/db_password # se cuelga
^C
En secret-injector:
$ docker exec -it <injector_container> sh
Error: el contenedor no está ejecutándose
Refuerzo: Usa descriptores de archivo
Evita variables de entorno. Pásalos vía descriptores de archivo (FDs):
Entrypoint:
#!/bin/sh
exec 3< /shared/db_password
exec 4< /shared/api_key
exec myapp --db-password-fd=3 --api-key-fd=4
Python:
import os
def read_secret_from_fd(fd_num: int) -> str:
with os.fdopen(fd_num, 'r') as f:
return f.read().strip()
db_password = read_secret_from_fd(3)
api_key = read_secret_from_fd(4)
Go:
package main
import (
"fmt"
"io"
"os"
)
func readSecretFromFD(fd int) (string, error) {
f := os.NewFile(uintptr(fd), fmt.Sprintf("fd-%d", fd))
if f == nil {
return "", fmt.Errorf("descriptor de archivo inválido: %d", fd)
}
defer f.Close()
data, err := io.ReadAll(f)
if err != nil {
return "", err
}
return string(data), nil
}
Los secretos viven solo en la memoria del proceso.
Conclusiones clave
- Secretos leídos una sola vez vía FIFO, datos consumidos
secret-injectortermina, no posibledocker exec- App no tiene
/run/secrets/ - Prefiere descriptores de archivo sobre variables de entorno
- No se necesitan servicios externos
— Editorial Team
Aún no hay comentarios.