Volver al inicio

FIFO para secretos de Docker Swarm de un solo uso

El artículo describe un método para proteger secretos de Docker Swarm vía canales FIFO con nombre. Los secretos se transfieren una vez a la aplicación, excluyendo el acceso vía docker exec. Implementación basada en dos contenedores con un volumen tmpfs compartido.

Secretos de Docker Swarm de un solo uso: FIFO contra docker exec
Advertisement 728x90

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:

Google AdInline article slot
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:

Google AdInline article slot
  • 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/.

Google AdInline article slot

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-injector termina, no posible docker exec
  • App no tiene /run/secrets/
  • Prefiere descriptores de archivo sobre variables de entorno
  • No se necesitan servicios externos

— Editorial Team

Advertisement 728x90

Leer después