Aller au contenu

Docker - Configuration Stateless-Ready

Date: 2026-01-04 Status: ✅ Production-Ready

Mise à jour 2026-08-14

Durcissement conteneur ANSSI (cap_drop, security_opt) — voir Isolation Conteneur ci-dessous.


Vue d'ensemble

L'application est désormais stateless-ready, c'est-à-dire prête pour un déploiement multi-instances derrière un load balancer, sans modification obligatoire de l'infrastructure actuelle.

Principe: Activation opt-in via variables d'environnement.


Architecture Actuelle (Par défaut)

┌─────────────────────┐
│   Load Balancer     │  (optionnel)
│    (au choix)       │
└──────────┬──────────┘
           │
┌──────────▼──────────┐
│   Container Web     │  PHP 8.2 + Nginx
│   - Sessions files  │
│   - Uploads local   │
│   - Queue sync      │
└──────────┬──────────┘
           │
┌──────────▼──────────┐
│   Container DB      │  MariaDB 10.11
│                     │
└─────────────────────┘

État: ✅ Fonctionne exactement comme avant la refactorisation.


Architecture Scalable (Opt-in Redis)

┌─────────────────────┐
│   Load Balancer     │
│    (au choix)       │
└──────────┬──────────┘
           │
     ┌─────┴─────┬─────────┐
     │           │         │
┌────▼────┐ ┌───▼────┐ ┌──▼─────┐
│  Web 1  │ │ Web 2  │ │ Web 3  │  N instances
│ Stateless│ │Stateless│ │Stateless│
└────┬────┘ └───┬────┘ └──┬─────┘
     │          │          │
     └─────┬────┴────┬─────┘
           │         │
     ┌─────▼─────┐   │
     │   Redis   │   │  Sessions + Cache + Queue
     └───────────┘   │
                     │
              ┌──────▼──────┐
              │  MariaDB    │
              └─────────────┘

Activation:

# .env
SESSION_DRIVER=redis
CACHE_DRIVER=redis
QUEUE_DRIVER=redis

# Lancer Redis
docker-compose --profile redis up -d


Configuration PHP Sécurisée

Le Dockerfile injecte un fichier security.ini dans PHP :

# /usr/local/etc/php/conf.d/security.ini
display_errors         = Off     # Pas d'erreurs exposées au client
display_startup_errors = Off
expose_php             = Off     # Masque la version PHP dans les headers HTTP
log_errors             = On
error_reporting        = E_ALL   # Tout logger côté serveur

session.cookie_httponly = 1      # Inaccessible à JavaScript
session.use_only_cookies = 1
session.use_strict_mode = 1      # Rejette les IDs de session non générés serveur
session.cookie_samesite = Strict # Anti-CSRF

allow_url_fopen        = Off     # Bloque les wrappers de fichiers distants
allow_url_include      = Off     # Bloque les includes distants

Rebuild requis

Toute modification de la config PHP nécessite docker-compose up -d --build.


Isolation Conteneur (ANSSI)

Contexte : audit du risque d'évasion de conteneur suite à la publication zerone-cyber.org/docker-container-evasion, qui documente l'exploitation de --privileged, du montage docker.sock, de capabilities Linux excessives (CAP_SYS_ADMIN, CAP_SYS_MODULE) et de CVE runc/containerd (CVE-2019-5736, CVE-2019-14271, CVE-2020-15257).

Constat : aucun de ces vecteurs n'était présent dans docker-compose.yml (pas de --privileged, pas de docker.sock, runc 1.3.0 / Docker 28.5.1 non vulnérables aux CVE citées). Durcissement complémentaire appliqué en défense en profondeur :

web:
  cap_drop:
    - ALL
  cap_add:
    - CHOWN            # entrypoint.sh : chown -R www-data:www-data uploads/
    - FOWNER            # entrypoint.sh : chmod -R sur fichiers non détenus par le process
    - SETUID            # nginx/php-fpm master (root) → workers (www-data)
    - SETGID
    - NET_BIND_SERVICE  # bind port 80 sans être root "plein pouvoir"
  security_opt:
    - no-new-privileges:true

db:      # + redis, phpmyadmin
  security_opt:
    - no-new-privileges:true

Méthode de validation : les 5 capabilities ci-dessus ont été déterminées empiriquement (pas par supposition) en testant cap_drop: ALL sur une pile isolée (réseau/volume dédiés, port alternatif) et en ajoutant une capability à la fois jusqu'à disparition des erreurs Operation not permitted dans les logs (chown, chmod) et bind() ... Permission denied (nginx).

Code applicatif en lecture seule

web:
  volumes:
    - ./:/var/www/html:ro
    - ./uploads:/var/www/html/uploads                                # photos incidents (citoyens)
    - ./public/backend/uploads:/var/www/html/public/backend/uploads  # + tasks/ et logs/
    - ./public/uploads:/var/www/html/public/uploads                  # incidents (agents), vidéos, alertes

Une compromission applicative (upload malveillant, RCE) ne peut plus écrire dans le code source — seuls les trois volumes explicites ci-dessus restent en écriture.

Migration préalable (2026-08-14) : tasks.php écrivait tasks.csv/tasks_history.csv/tasks_admin_config.json directement dans public/, et cron/cleanup_alertes_audio.php écrivait cleanup_alertes.log dans public/cron/ — deux emplacements incompatibles avec :ro.

Avant Après
public/tasks.csv, public/tasks_history.csv public/backend/uploads/tasks/
public/tasks_admin_config.json public/backend/uploads/tasks/
public/cron/cleanup_alertes.log public/backend/uploads/logs/

public/backend/uploads/ était déjà un volume RW — aucun nouveau mount n'a été nécessaire pour ces deux fichiers. Les données historiques (tasks.csv, tasks_history.csv) ont été déplacées sans perte. cron/send_scheduled_notifications.php écrit dans /var/log/urbafix_notifications.log, hors de /var/www/html : non affecté par le :ro.

Validation : testé sur pile isolée puis en production — touch sur un fichier de code retourne Read-only file system, écriture uploads/tasks/logs fonctionnelle, tasks.php charge les données migrées, health.php reste healthy.


Services Docker

1. Service Web (Obligatoire)

Image: PHP 8.2-FPM + Nginx État: Stateless-ready Port: 8080 (configurable via WEB_PORT)

Volumes: - ./:/var/www/html - Code application (read-only en production) - ./public/backend/uploads - Uploads locaux (à supprimer si STORAGE_DRIVER=s3)

Health Check:

healthcheck:
  test: ["CMD", "curl", "-f", "http://localhost/health.php"]
  interval: 30s
  timeout: 10s
  retries: 3
  start_period: 40s

Endpoint: GET /health.php - Vérifie DB, Redis, Stockage, Config, Extensions PHP - Retourne HTTP 200 si OK, HTTP 503 sinon - JSON avec détails de chaque check


2. Service DB (Obligatoire)

Image: MariaDB 10.11 État: Production-ready Port: 3306 (ne pas exposer en production)

Volumes: - db_data:/var/lib/mysql - Données persistantes - ./database:/docker-entrypoint-initdb.d - Scripts d'initialisation

Health Check: Built-in MariaDB healthcheck

Configuration: - character-set-server=utf8mb4 - collation-server=utf8mb4_unicode_ci - max_allowed_packet=64M


3. Service Redis (Optionnel)

Image: Redis 7 Alpine État: Production-ready Port: 6379 (ne pas exposer en production) Activation: --profile redis

Volumes: - redis_data:/data - Persistance AOF (Append-Only File)

Configuration:

redis-server
  --requirepass ${REDIS_PASSWORD}
  --maxmemory 256mb
  --maxmemory-policy allkeys-lru
  --appendonly yes
  --appendfsync everysec

Stratégie de persistance: - AOF (Append-Only File) pour durabilité - Fsync toutes les secondes (compromis performance/durabilité) - LRU eviction si mémoire pleine

Health Check: redis-cli --raw incr ping

Bases de données (isolation par DB number): - DB 0: Sessions (SESSION_REDIS_DB=0) - DB 1: Cache (CACHE_REDIS_DB=1) - DB 2: Queue (QUEUE_REDIS_DB=2)


4. Service phpMyAdmin (Optionnel)

Image: phpMyAdmin latest État: Development only Port: 8081 Activation: --profile admin

⚠️ SÉCURITÉ: Ne JAMAIS activer en production.


Variables d'Environnement

Configuration Minimale (Default)

# .env
DB_HOST=db
DB_NAME=urbafix
DB_USER=urbafix_user
DB_PASSWORD=changez_ce_mot_de_passe

SESSION_DRIVER=file
CACHE_DRIVER=null
STORAGE_DRIVER=local
QUEUE_DRIVER=sync

Configuration Scalable (Redis)

# .env
SESSION_DRIVER=redis
CACHE_DRIVER=redis
QUEUE_DRIVER=redis

REDIS_PASSWORD=changez_ce_mot_de_passe_redis

SESSION_REDIS_HOST=redis
SESSION_REDIS_PORT=6379
SESSION_REDIS_PASSWORD=${REDIS_PASSWORD}
SESSION_REDIS_DB=0

CACHE_REDIS_HOST=redis
CACHE_REDIS_PORT=6379
CACHE_REDIS_PASSWORD=${REDIS_PASSWORD}
CACHE_REDIS_DB=1

QUEUE_REDIS_HOST=redis
QUEUE_REDIS_PORT=6379
QUEUE_REDIS_PASSWORD=${REDIS_PASSWORD}
QUEUE_REDIS_DB=2

Configuration S3 (Stockage externalisé)

STORAGE_DRIVER=s3
STORAGE_S3_BUCKET=urbafix-prod-uploads
STORAGE_S3_REGION=eu-west-3
STORAGE_S3_KEY=AKIAIOSFODNN7EXAMPLE
STORAGE_S3_SECRET=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY
STORAGE_S3_ENDPOINT=  # Laisser vide pour AWS S3, ou URL MinIO

⚠️ SÉCURITÉ: Utiliser Docker Secrets en production pour les clés AWS.


Commandes Docker

Démarrage Standard

# Mode single-instance (default)
docker-compose up -d

# Avec Redis
docker-compose --profile redis up -d

# Avec phpMyAdmin (dev seulement)
docker-compose --profile admin up -d

# Tout activer
docker-compose --profile redis --profile admin up -d

Health Checks

# Vérifier l'état des containers
docker-compose ps

# Tester le health endpoint
curl http://localhost:8080/health.php | jq

# Exemple de réponse
{
  "status": "healthy",
  "timestamp": 1704398400,
  "checks": {
    "database": {
      "status": "healthy",
      "message": "Database connection OK"
    },
    "redis": {
      "status": "healthy",
      "message": "Redis connection OK",
      "host": "redis",
      "port": 6379
    },
    "storage": {
      "status": "healthy",
      "message": "Storage writable",
      "driver": "local"
    },
    "config": {
      "status": "healthy",
      "message": "Configuration valid",
      "environment": "production"
    },
    "php_extensions": {
      "status": "healthy",
      "message": "All required extensions loaded"
    }
  },
  "system": {
    "php_version": "8.2.15",
    "memory_usage": "12.45 MB",
    "memory_limit": "256M",
    "uptime": "3600s"
  }
}

Scaling Horizontal

# Lancer 3 instances web (nécessite Redis)
docker-compose --profile redis up -d --scale web=3

# Vérifier
docker-compose ps

# Les instances sont accessibles via:
# - web_1: port 8080
# - web_2: port assigné dynamiquement
# - web_3: port assigné dynamiquement

# ⚠️ Pour production: utiliser un load balancer externe

Load Balancer - Configuration

Le choix du load balancer est libre. Quel que soit l'outil, il doit :

  • Terminer le TLS (redirection HTTP → HTTPS)
  • Répartir en round-robin sur les instances web (127.0.0.1:8080, :8081, :8082, ...)
  • Vérifier la santé de chaque instance via GET /health.php (attendu : HTTP 200)
  • Transmettre les en-têtes de sécurité standards (HSTS, X-Frame-Options, X-Content-Type-Options)

Lancer les instances:

# Terminal 1
WEB_PORT=8080 docker-compose --profile redis up -d

# Terminal 2
WEB_PORT=8081 docker-compose --profile redis up -d

# Terminal 3
WEB_PORT=8082 docker-compose --profile redis up -d

# Redémarrer le load balancer (commande spécifique à l'outil choisi)


Monitoring Prometheus

Endpoint métrics (à créer): GET /metrics

# Exemple de métriques à exposer
urbafix_http_requests_total{method="GET",endpoint="/api/get_incidents",status="200"} 1234
urbafix_http_request_duration_seconds_bucket{endpoint="/api/submit_incident",le="0.5"} 987
urbafix_database_connections_active 5
urbafix_redis_connections_active 3
urbafix_uploads_total 567
urbafix_incidents_created_total 890

Configuration Prometheus:

scrape_configs:
  - job_name: 'urbafix'
    static_configs:
      - targets: ['web1:80', 'web2:80', 'web3:80']
    metrics_path: '/metrics'
    scrape_interval: 15s


Backup & Restore

Base de données

# Backup
docker-compose exec db mysqldump -u root -p${MYSQL_ROOT_PASSWORD} urbafix | gzip > backup_$(date +%Y%m%d_%H%M%S).sql.gz

# Restore
gunzip < backup_20260104_120000.sql.gz | docker-compose exec -T db mysql -u root -p${MYSQL_ROOT_PASSWORD} urbafix

Redis (si utilisé)

# Backup AOF (automatique via volume redis_data)
docker-compose exec redis redis-cli --raw BGSAVE

# Copier le dump
docker cp urbafix_redis:/data/appendonly.aof ./backup_redis_$(date +%Y%m%d).aof

# Restore (arrêter Redis, copier AOF, redémarrer)
docker-compose stop redis
docker cp backup_redis_20260104.aof urbafix_redis:/data/appendonly.aof
docker-compose start redis

Uploads

# Backup local
tar -czf uploads_$(date +%Y%m%d).tar.gz public/backend/uploads/

# Si S3: automatique via versioning S3

Migration vers Redis

Étape 1: Activer Redis

# docker-compose.yml déjà configuré avec profile redis
docker-compose --profile redis up -d redis

# Vérifier
docker-compose exec redis redis-cli -a ${REDIS_PASSWORD} PING
# Réponse: PONG

Étape 2: Configuration progressive

Phase 1 - Sessions uniquement:

# .env
SESSION_DRIVER=redis
CACHE_DRIVER=null
QUEUE_DRIVER=sync

Phase 2 - Sessions + Cache:

SESSION_DRIVER=redis
CACHE_DRIVER=redis
QUEUE_DRIVER=sync

Phase 3 - Full Redis:

SESSION_DRIVER=redis
CACHE_DRIVER=redis
QUEUE_DRIVER=redis

Étape 3: Lancer Workers (si QUEUE_DRIVER=redis)

Fichier: bin/worker.php (à créer)

<?php
require_once __DIR__ . '/../src/Queue/QueueFactory.php';

$queue = QueueFactory::create();

while (true) {
    $job = $queue->pop('default');

    if ($job) {
        echo "Processing job: {$job['job']}\n";

        $jobClass = $job['job'];
        require_once __DIR__ . "/../src/Jobs/$jobClass.php";

        $instance = new $jobClass();
        $instance->handle($job['data']);
    }

    sleep(1);
}

Supervisord (/etc/supervisor/conf.d/urbafix-workers.conf):

[program:urbafix-worker]
process_name=%(program_name)s_%(process_num)02d
command=php /var/www/html/bin/worker.php
autostart=true
autorestart=true
numprocs=3
user=www-data
stdout_logfile=/var/log/urbafix-worker.log
stderr_logfile=/var/log/urbafix-worker-error.log


Checklist Production

Avant déploiement

  • Changer tous les mots de passe dans .env
  • Générer JWT_SECRET fort : openssl rand -hex 32
  • APP_DEBUG=false
  • APP_ENV=production (exclut localhost de la whitelist CORS)
  • Désactiver phpMyAdmin (supprimer --profile admin)
  • Ne pas exposer MySQL port (commenter DB_EXTERNAL_PORT)
  • Ne pas exposer Redis port (commenter REDIS_PORT)
  • Configurer SSL/HTTPS via reverse proxy
  • Tester health endpoint: curl https://urbafix.fr/health.php
  • Configurer firewall (UFW)
  • Activer logs structurés
  • Configurer backups automatiques (cron)
  • Rebuild Docker après tout changement de config : docker-compose up -d --build

Performance

  • Activer Redis pour sessions si multi-instances
  • Activer cache Redis pour réduire DB load
  • Migrer uploads vers S3 si volume > 10 GB
  • Activer queue async si > 1000 incidents/jour
  • Monitoring Prometheus + Grafana

Sécurité ANSSI

  • TLS 1.3 uniquement
  • JWT_SECRET défini (≥ 32 octets aléatoires)
  • Redis password fort (32+ caractères)
  • Secrets Docker (pas env vars pour S3)
  • Logs masquage GPS précis
  • Audit logs cross-tenant access

Troubleshooting

Container web ne démarre pas

# Vérifier les logs
docker-compose logs -f web

# Erreurs courantes:
# - DB not ready → attendre healthcheck DB
# - Permission denied uploads → chmod 755
# - Missing .env → copier .env.example

Erreurs Operation not permitted / Permission denied après durcissement cap_drop

Voir Isolation Conteneur. Causé par un cap_add incomplet suite à une modification de docker-compose.yml.

docker-compose logs web | grep -E "Operation not permitted|Permission denied"
Message Capability manquante
chown: changing ownership of '...': Operation not permitted CHOWN
chmod: changing permissions of '...': Operation not permitted FOWNER
bind() to 0.0.0.0:80 failed (13: Permission denied) NET_BIND_SERVICE
setgroups/initgroups failed SETUID / SETGID

Solution : ajouter la capability manquante dans cap_add, puis docker-compose up -d --build web. En dernier recours, restaurer docker-compose.yml.bak-20260814_114232.

Read-only file system après passage du code en :ro

Un endpoint ou un script tente d'écrire un fichier hors des volumes uploads explicites (uploads/, public/uploads/, public/backend/uploads/).

docker-compose logs web | grep "Read-only file system"

Solution : rediriger l'écriture vers un sous-répertoire de public/backend/uploads/ (déjà monté en RW, comme tasks/ et logs/) ou vers la base de données — ne pas retirer le :ro du mount principal. En dernier recours, restaurer l'état pré-:ro depuis .backups/pre-readonly-20260814/.

Redis connection failed

# Tester connexion
docker-compose exec redis redis-cli -a ${REDIS_PASSWORD} PING

# Vérifier password
docker-compose exec web env | grep REDIS_PASSWORD

# Logs Redis
docker-compose logs -f redis

Health endpoint retourne 503

# Identifier le check qui échoue
curl http://localhost:8080/health.php | jq '.checks'

# Exemples:
# - database unhealthy → vérifier DB credentials
# - redis unhealthy → vérifier Redis password
# - storage unhealthy → vérifier permissions uploads

Sessions perdues après redémarrage

# Si SESSION_DRIVER=file (default)
# → Normal, sessions stockées dans container

# Solution: Migrer vers Redis
SESSION_DRIVER=redis
docker-compose --profile redis up -d

Roadmap Future

Court terme (Q1 2026)

  • Endpoint /metrics Prometheus
  • Worker queue Redis avec Supervisord
  • Tests automatisés health checks

Moyen terme (Q2 2026)

  • Support Kubernetes (Helm charts)
  • Auto-scaling horizontal (HPA)
  • Distributed tracing (Jaeger)

Long terme (Q3-Q4 2026)

  • Migration S3 progressive
  • CDN CloudFront pour uploads
  • Read replicas MariaDB

Support

Documentation: - /docs/ARCHITECTURE.md - Architecture globale - /docs/CONFIGURATION.md - Variables d'environnement - /docs/SCALABILITY.md - Scénarios de scaling - /docs/SECURITY_AUDIT_MULTI_TENANT.md - Audit sécurité

Health Check: GET /health.php Logs: docker-compose logs -f [service]