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:
Phase 2 - Sessions + Cache:
Phase 3 - Full 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_SECRETfort :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.
| 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/).
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
/metricsPrometheus - 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]