Secrets à usage unique dans Docker Swarm avec des pipes FIFO
Docker Swarm monte les secrets dans /run/secrets/ sous forme de volumes tmpfs en lecture seule. Toute personne ayant accès à docker exec peut les lire à tout moment. La solution : utiliser des pipes nommés (FIFOs) pour une livraison unique des secrets au démarrage du conteneur.
Problèmes de l'approche standard
Les secrets sont stockés dans le journal Raft sur les nœuds managers, envoyés via mTLS aux workers, puis montés dans les conteneurs. Voici comment les définir dans 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
Lecture dans votre application :
with open("/run/secrets/db_password") as f:
db_password = f.read().strip()
Accès via docker exec :
$ docker exec -it <container_id> cat /run/secrets/db_password
SuperSecretPassword123
Accès sur l'hôte :
sudo cat /var/lib/docker/containers/<container_id>/mounts/secrets/ioz4pc6q8fjsfhem4pwsr35u6
SuperSecretPassword123
Pourquoi les solutions de contournement classiques échouent
- rm / tronquer / chmod : tmpfs en lecture seule
- umount : nécessite CAP_SYS_ADMIN
- Variables d'environnement : visibles dans
/proc/1/environ, héritées par les processus enfants - Gestionnaires externes (Vault) : ajoute de l'infrastructure supplémentaire
Fonctionnement des pipes FIFO
Les pipes nommés ont ces caractéristiques clés :
- Les écritures bloquent sans lecteur
- Les lectures bloquent sans écrivain
- Les données sont consommées à la première lecture
- Les lectures suivantes restent en attente
Démo :
$ mkfifo /tmp/my_pipe
echo "secret_value" > /tmp/my_pipe # bloque
cat /tmp/my_pipe # secret_value, données supprimées
cat /tmp/my_pipe # en attente
Architecture de la solution
Deux conteneurs partagent un volume tmpfs avec des FIFOs :
- secret-injector : crée les FIFOs, écrit les secrets, puis se termine
- app : lit les FIFOs une seule fois, garde les secrets uniquement en mémoire
Le conteneur app n'a pas de /run/secrets/.
Implémentation
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
Séquence d'exécution
- secret-injector :
- mkfifo /shared/db_password /shared/api_key
- cat /run/secrets/... > /shared/... (bloque)
- wait maintient le conteneur en vie
- app :
- DB_PASSWORD=$(cat /shared/db_password) → débloque l'écrivain
- Données consommées du FIFO
- secret-injector se termine (
restart_policy: none)
- app s'exécute avec les secrets en mémoire
Ce qu'un attaquant voit
Dans app :
$ docker exec -it <app_container> sh
$ ls /run/secrets/
ls: /run/secrets/: Aucun fichier ou dossier de ce type
$ cat /shared/db_password # en attente
^C
Dans secret-injector :
$ docker exec -it <injector_container> sh
Erreur : le conteneur ne tourne pas
Renforcement : utiliser des descripteurs de fichiers
Éviter les variables d'environnement. Passer via des descripteurs de fichiers (FD) :
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("descripteur de fichier invalide : %d", fd)
}
defer f.Close()
data, err := io.ReadAll(f)
if err != nil {
return "", err
}
return string(data), nil
}
Les secrets ne vivent qu'en mémoire du processus.
Points clés à retenir
- Secrets lus une seule fois via FIFO, données consommées
secret-injectorse termine, pas d'accèsdocker exec- L'app n'a pas de
/run/secrets/ - Préférer les descripteurs de fichiers aux variables d'environnement
- Aucun service externe requis
— Editorial Team
Aucun commentaire pour le moment.