Retour à l'accueil

FIFO pour secrets Docker Swarm à usage unique

L'article décrit une méthode pour protéger les secrets Docker Swarm via des canaux FIFO nommés. Les secrets sont transférés une fois à l'application, excluant l'accès via docker exec. Implémentation basée sur deux conteneurs avec un volume tmpfs partagé.

Secrets Docker Swarm à usage unique : FIFO contre docker exec
Advertisement 728x90

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 :

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

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

Google AdInline article slot

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-injector se termine, pas d'accès docker 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

Advertisement 728x90

Lire ensuite