Le domaine connaissait deja CAMERA_OFFLINE, classe CRITICAL, mais rien ne l'emettait. CameraLivenessProbe interroge l'API du serveur media et comble ce trou. Elle passe par le meme port IngestSensorEventUseCase que l'endpoint REST et le simulateur : trois producteurs de natures tres differentes, un seul contrat d'entree, et le meme trajet Kafka -> agregat ensuite. Si l'alarme est desarmee, une coupure video ne reveille personne - c'est l'agregat qui tranche, pas la sonde. Le seuil est la partie qui compte. Sur un lien 4G le flux tombe regulierement sans que personne n'ait touche au telephone : tunnel, bascule d'antenne, congestion. Alerter des la premiere sonde ratee produirait plusieurs CRITICAL par jour, et on apprend vite a ignorer des alertes qui crient pour rien - y compris le jour ou elles ont raison. Trois sondes consecutives, soit une minute d'absence reelle. Six tests couvrent ce comportement, dont la remise a zero du compteur au retour du flux. Utilisateur d'API dedie cote MediaMTX, restreint au reseau Docker interne. 41 tests au vert. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
475 lines
21 KiB
Markdown
475 lines
21 KiB
Markdown
# iot-alarm-system
|
||
|
||
Système d'alarme événementiel pour capteurs IoT.
|
||
**Java 21 · Spring Boot 3 · Apache Kafka · Architecture hexagonale · CQRS · DDD**
|
||
|
||
🔗 **Démonstration en ligne : https://video-seurveillance.belkacemi.me**
|
||
|
||
---
|
||
|
||
## Le contexte
|
||
|
||
Mon studio a été cambriolé.
|
||
|
||
Les offres de vidéosurveillance du marché se ressemblent toutes : une caméra à
|
||
80 €, puis un abonnement mensuel pour avoir le droit d'accéder à ses propres
|
||
images. J'avais un vieil iPhone dans un tiroir et une bibliothèque bien remplie.
|
||
L'iPhone est désormais calé entre deux livres, objectif vers la porte d'entrée.
|
||
|
||
Ce dépôt est le backend qui écoute ce capteur : il ingère les détections de
|
||
mouvement, décide lesquelles constituent une intrusion, et pousse l'alerte en
|
||
temps réel sur une console de supervision.
|
||
|
||
Le problème est volontairement simple. Ce qui est travaillé ici, c'est la
|
||
**structure** : un domaine métier isolé du framework, un bus d'événements, une
|
||
séparation lecture/écriture, et des règles d'architecture vérifiées par les
|
||
tests plutôt que par la discipline.
|
||
|
||
---
|
||
|
||
## Le flux
|
||
|
||
### Deux flux, deux chemins
|
||
|
||
Le système transporte deux choses de nature très différente, et elles ne
|
||
suivent volontairement pas le même chemin :
|
||
|
||
```
|
||
┌─────────────────────┐
|
||
│ iPhone (capteur) │
|
||
└──────────┬──────────┘
|
||
événements │ vidéo
|
||
(quelques octets) │ (800 kbps continu)
|
||
┌──────────┴──────────┐
|
||
▼ ▼
|
||
┌───────────────┐ ┌────────────────┐
|
||
│ Spring Boot │ │ MediaMTX │
|
||
│ Kafka · JPA │ │ SRT → WebRTC │
|
||
│ domaine │ │ transport pur │
|
||
└───────┬───────┘ └────────┬───────┘
|
||
└──────────┬───────────┘
|
||
▼
|
||
Console web (une seule page)
|
||
```
|
||
|
||
**La vidéo ne traverse jamais la JVM.** Faire transiter 800 kbps continus par
|
||
Spring Boot n'apporterait rien et coûterait un thread par spectateur. Le média
|
||
est un problème de transport, résolu par un service dédié de 83 Mo ; la JVM
|
||
garde le domaine métier. Les deux se rejoignent seulement dans le navigateur.
|
||
|
||
Corollaire pratique : MediaMTX peut tomber sans que l'alarme cesse de
|
||
fonctionner, et inversement.
|
||
|
||
### Le flux d'événements en détail
|
||
|
||
```
|
||
┌──────────────────┐
|
||
│ iPhone 6s │ Détection de mouvement
|
||
│ "iphone-biblio" │ {"deviceId":"iphone-biblio","type":"MOTION_DETECTED"}
|
||
└────────┬─────────┘
|
||
│ POST /api/v1/sensor-events
|
||
▼
|
||
┌────────────────────────────┐
|
||
│ SensorIngestController │ ADAPTER ENTRANT
|
||
│ → 202 Accepted │ Répond avant tout traitement métier :
|
||
└────────┬───────────────────┘ le capteur n'attend pas la base.
|
||
│
|
||
▼
|
||
┌────────────────────────────┐
|
||
│ SensorEventKafkaPublisher │ ADAPTER SORTANT
|
||
└────────┬───────────────────┘ Clé de partition = deviceId → ordre garanti
|
||
│ par capteur.
|
||
▼
|
||
╔═══════════════════════╗
|
||
║ KAFKA sensor-events ║ 3 partitions · acks=all · idempotent
|
||
╚═══════════┬═══════════╝ (poison pill → sensor-events-dlq)
|
||
│
|
||
▼
|
||
┌────────────────────────────┐
|
||
│ SensorEventKafkaConsumer │ ADAPTER ENTRANT
|
||
└────────┬───────────────────┘ Désérialise, délègue, instrumente. Zéro règle.
|
||
│
|
||
▼
|
||
┌────────────────────────────────────────────────────────┐
|
||
│ AlarmSystem.evaluate() ◄── LE CŒUR DU SYSTÈME│
|
||
│ │
|
||
│ 1. alarme désarmée ? → Ignored(ALARM_DISARMED)
|
||
│ 2. heartbeat ? → Ignored(NOT_INTRUSION_RELEVANT)
|
||
│ 3. délai de sortie en cours ? → Ignored(WITHIN_EXIT_DELAY)
|
||
│ 4. confiance < seuil ? → Ignored(CONFIDENCE_TOO_LOW)
|
||
│ 5. alerte récente ? → Ignored(WITHIN_COOLDOWN)
|
||
│ │
|
||
│ sinon → TRIGGERED + Alert + IntrusionDetected │
|
||
└────────┬───────────────────────────────────────────────┘
|
||
│
|
||
┌─────┴─────────────────────────┐
|
||
▼ ▼
|
||
┌──────────────────┐ ┌──────────────────────────┐
|
||
│ PostgreSQL │ │ WebSocket STOMP │ après commit
|
||
│ (write model) │ │ /topic/alerts │ uniquement
|
||
└──────────────────┘ └────────────┬─────────────┘
|
||
▼
|
||
┌──────────────────────────┐
|
||
│ Console : fond rouge, │
|
||
│ "INTRUSION DÉTECTÉE" │
|
||
└──────────────────────────┘
|
||
```
|
||
|
||
---
|
||
|
||
## L'architecture
|
||
|
||
### Hexagonale (ports & adapters)
|
||
|
||
```
|
||
domain/ Le métier. Zéro import de framework.
|
||
model/ AlarmSystem (agrégat racine), Alert, SensorEvent,
|
||
AlarmPolicy, AlarmDecision (sealed)
|
||
event/ DomainEvent (sealed) : IntrusionDetected, AlarmArmed…
|
||
port/in/ Ports primaires — ce que le système sait faire
|
||
port/out/ Ports secondaires — ce dont il a besoin (SPI)
|
||
|
||
application/ Orchestration. Charge, appelle, persiste, notifie.
|
||
command/ Côté écriture du CQRS
|
||
query/ Côté lecture — son propre port, son propre modèle
|
||
|
||
infrastructure/ Les adapters. C'est ici, et seulement ici, qu'on trouve
|
||
les mots « Kafka », « JPA », « WebSocket ».
|
||
|
||
presentation/ REST + WebSocket. Traduit HTTP en commandes.
|
||
```
|
||
|
||
Les dépendances pointent **vers l'intérieur**. Le domaine ne connaît ni Spring,
|
||
ni Kafka, ni PostgreSQL — il déclare des interfaces (`SensorEventPublisher`,
|
||
`AlertRepository`) que l'infrastructure implémente.
|
||
|
||
Conséquence mesurable : les 13 tests de `AlarmSystemTest` couvrent l'intégralité
|
||
de la logique de détection et s'exécutent en **48 millisecondes**, sans
|
||
conteneur, sans base, sans `Thread.sleep`.
|
||
|
||
### Ces règles sont testées, pas documentées
|
||
|
||
Un schéma dans un README dérive en trois sprints. Celles-ci cassent le build :
|
||
|
||
```java
|
||
@Test
|
||
void domainHasNoFrameworkDependency() {
|
||
noClasses().that().resideInAPackage("..domain..")
|
||
.should().dependOnClassesThat().resideInAnyPackage(
|
||
"org.springframework..", "jakarta.persistence..",
|
||
"org.apache.kafka..", "io.micrometer..")
|
||
.check(classes);
|
||
}
|
||
```
|
||
|
||
7 règles ArchUnit s'exécutent à chaque `mvn test` — **et à chaque `docker build`**.
|
||
Une violation de l'architecture hexagonale ne franchit pas l'étape de packaging.
|
||
|
||
### CQRS
|
||
|
||
La séparation n'est pas cosmétique : les deux côtés ne partagent aucun modèle.
|
||
|
||
| | Écriture | Lecture |
|
||
|---|---|---|
|
||
| Entrée | `ArmAlarmCommand`, `IngestSensorEventCommand` | `AlarmQueryService` |
|
||
| Chemin | passe par l'agrégat, applique les invariants | requête directe, ne charge jamais l'agrégat |
|
||
| Modèle | `Alert` (entité, comportement) | `AlertView` (record, libellés pré-calculés) |
|
||
| Port | `domain.port.out` | `application.query.port` |
|
||
| Transaction | `@Transactional` | `@Transactional(readOnly = true)` |
|
||
|
||
Compter les alertes non acquittées se fait par un `COUNT(*)`, pas en
|
||
réhydratant un agrégat dont on n'appellerait aucune méthode. Brancher un cache
|
||
Redis ou un index Elasticsearch sur la lecture ne demanderait qu'une nouvelle
|
||
implémentation de `AlarmReadModelPort`.
|
||
|
||
### DDD
|
||
|
||
- **Agrégat racine** — `AlarmSystem` est le seul point d'entrée pour modifier
|
||
l'état. Aucun service ne décide à sa place.
|
||
- **Value Objects** — `DeviceId` valide son format à la construction ;
|
||
au-delà de la frontière, aucune `String` non validée ne circule.
|
||
- **Entité** — `Alert` a une identité (`AlertId`, générée par le domaine et non
|
||
par la base) et un cycle de vie (acquittement à usage unique).
|
||
- **Interfaces scellées** — `AlarmDecision` est `Triggered | Ignored(reason)`.
|
||
Le `switch` qui la consomme est vérifié exhaustivement par le compilateur :
|
||
ajouter un troisième cas casse la compilation au lieu de tomber dans un `else`.
|
||
- **Un rejet dit pourquoi** — `IgnoreReason` alimente à la fois les logs et un
|
||
tag Micrometer. « Pourquoi l'alarme n'a pas sonné cette nuit ? » se répond par
|
||
une requête PromQL, pas par une lecture de logs.
|
||
|
||
---
|
||
|
||
## Le flux vidéo
|
||
|
||
### Le téléphone pousse, on ne l'interroge pas
|
||
|
||
C'est la contrainte qui a dicté toute cette partie. Un iPhone n'a **aucune
|
||
adresse joignable depuis Internet** : en 4G il est derrière le CGNAT de
|
||
l'opérateur, sur le WiFi du studio derrière le NAT de la box, et l'application
|
||
signale `UPnP unavailable`. Le serveur RTSP intégré à l'application caméra est
|
||
donc inutilisable de l'extérieur, quelle que soit la configuration.
|
||
|
||
La seule direction qui traverse ces deux NAT est la sortante. Le téléphone
|
||
**pousse** son flux vers le serveur :
|
||
|
||
```
|
||
iPhone ──SRT (UDP 8890)──▶ MediaMTX ──┬──▶ WebRTC < 1 s
|
||
└─RTMP (TCP 1935)──▶ └──▶ HLS ~ 6 s
|
||
```
|
||
|
||
**SRT plutôt que RTMP** quand le choix est possible : RTMP est du TCP, et sur
|
||
un lien mobile un paquet perdu déclenche une retransmission qui bloque tout le
|
||
reste. SRT transporte en UDP avec sa propre retransmission sélective — c'est
|
||
exactement le protocole conçu pour ce cas.
|
||
|
||
**WebRTC plutôt que HLS** côté lecture : voir quelqu'un six secondes après son
|
||
passage n'a pas le même intérêt. La console tente WebRTC (WHEP) d'abord et
|
||
bascule sur HLS si l'UDP est filtré — ce qui arrive précisément sur les réseaux
|
||
d'entreprise.
|
||
|
||
### Ce qui est protégé, ce qui ne l'est pas
|
||
|
||
| | |
|
||
|---|---|
|
||
| **Lecture** | publique — le flux est une vitrine, c'est assumé |
|
||
| **Publication** | authentifiée — sans ça, n'importe qui sur Internet pousserait sa propre vidéo sur le flux `biblio` |
|
||
|
||
### La caméra muette est un événement comme un autre
|
||
|
||
Le domaine connaissait déjà `CAMERA_OFFLINE`, classé `CRITICAL` — un capteur
|
||
qui se tait alors que l'alarme est armée, c'est soit une batterie morte, soit
|
||
quelqu'un qui l'a trouvé derrière les livres. Rien ne l'émettait jamais.
|
||
`CameraLivenessProbe` comble ce trou : elle interroge l'API du serveur média et
|
||
traduit une absence prolongée en événement capteur.
|
||
|
||
Elle passe par le **même port** `IngestSensorEventUseCase` que l'endpoint REST
|
||
et le simulateur. Trois producteurs d'événements de natures totalement
|
||
différentes — un téléphone, un simulateur, une sonde de supervision — un seul
|
||
contrat d'entrée, et le même trajet Kafka → agrégat ensuite. Si l'alarme est
|
||
désarmée, une coupure vidéo ne réveille personne : c'est l'agrégat qui tranche,
|
||
pas la sonde.
|
||
|
||
**Le seuil est la partie qui compte.** Sur un lien 4G, le flux tombe
|
||
régulièrement sans que personne n'ait touché au téléphone : tunnel, bascule
|
||
d'antenne, congestion. Alerter dès la première sonde ratée produirait plusieurs
|
||
`CRITICAL` par jour — et la seule chose qu'on apprend à vivre avec des alertes
|
||
qui crient pour rien, c'est à les ignorer, y compris le jour où elles ont
|
||
raison. Il faut donc trois sondes consécutives, soit une minute d'absence
|
||
réelle.
|
||
|
||
### Brancher un vrai téléphone
|
||
|
||
**Larix Broadcaster** (iOS/Android, gratuite, sans filigrane) : elle gère SRT,
|
||
la reconnexion automatique et le débit adaptatif. C'est ce qu'il faut sur un
|
||
lien mobile. IP Camera Lite sait aussi pousser, mais elle est conçue pour le
|
||
mode *pull* — lequel ne peut pas fonctionner depuis l'extérieur.
|
||
|
||
```
|
||
SRT srt://<serveur>:8890 · streamid = publish:biblio:iphone:<mot-de-passe>
|
||
RTMP rtmp://<serveur>:1935/biblio?user=iphone&pass=<mot-de-passe>
|
||
```
|
||
|
||
Réglages pour la 4G : **H.264, 640×360, 15 fps, 400–600 kbps, latence SRT
|
||
2000 ms, audio coupé**. À 500 kbps, compter **~225 Mo par heure** — un flux
|
||
permanent dépasse 5 Go par jour, ce qui n'a de sens que sur le WiFi du studio.
|
||
|
||
Le mot de passe est généré au déploiement dans le `.env` du serveur
|
||
(`STREAM_PUBLISH_PASSWORD`) et n'existe nulle part dans ce dépôt.
|
||
|
||
Pour valider la chaîne sans téléphone, une mire suffit :
|
||
|
||
```bash
|
||
docker run --rm --network iot-alarm-internal jrottenberg/ffmpeg:6.1-alpine \
|
||
-re -f lavfi -i testsrc=size=640x360:rate=25 \
|
||
-c:v libx264 -preset ultrafast -tune zerolatency -b:v 800k -pix_fmt yuv420p \
|
||
-f flv "rtmp://mediamtx:1935/biblio?user=iphone&pass=$PASS"
|
||
```
|
||
|
||
### Le piège du reverse proxy
|
||
|
||
MediaMTX répond d'abord une redirection vers un chemin **absolu**
|
||
(`/biblio/index.m3u8?cookieCheck=1`). Derrière un `handle_path /hls/*` qui
|
||
retire le préfixe, le navigateur suivait cette redirection jusqu'à
|
||
l'application Spring Boot et recevait du JSON là où il attendait une playlist.
|
||
Le `header_down Location` du [snippet Caddy](deploy/Caddyfile.snippet) réécrit
|
||
le préfixe. Symptôme trompeur : `curl -I` renvoyait un 404 sans rapport, le
|
||
serveur HLS ne gérant simplement pas la méthode HEAD.
|
||
|
||
---
|
||
|
||
## Les décisions non évidentes
|
||
|
||
Les choix ci-dessous ne sont pas les plus courts. Ils sont justifiés ici parce
|
||
que c'est précisément ce qui distingue un projet de démonstration d'un système
|
||
qu'on accepte de laisser tourner.
|
||
|
||
**`202 Accepted` et non `201 Created`** — à cet instant, rien n'a été décidé.
|
||
L'événement est sur le bus ; le traitement suivra. Le code de statut dit la
|
||
vérité sur ce qui s'est passé.
|
||
|
||
**Notification WebSocket après commit** — pousser l'alerte dans la transaction
|
||
signifierait afficher « INTRUSION » sur une écriture qui peut encore partir en
|
||
rollback. `AfterCommit` diffère l'effet de bord jusqu'au commit effectif.
|
||
|
||
**Entité JPA ≠ entité du domaine** — annoter directement `Alert` aurait imposé
|
||
un constructeur sans argument, des setters et des champs mutables : exactement
|
||
ce que l'encapsulation cherche à empêcher. Le prix est un mapper ; le bénéfice
|
||
est un domaine testable sans base.
|
||
|
||
**`SensorEventMessage` ≠ `SensorEvent`** — le format Kafka est un contrat public
|
||
partagé avec un producteur qu'on ne maîtrise pas (l'app iPhone). Sérialiser
|
||
l'objet du domaine ferait qu'un renommage de champ casse le capteur en
|
||
production.
|
||
|
||
**Anti-rafale dans l'agrégat** — sans ce filtre, un intrus qui traverse la pièce
|
||
génère quarante alertes en dix secondes. C'est une règle métier, elle est donc
|
||
dans le domaine, pas dans le consumer.
|
||
|
||
**Délai de sortie** — la différence entre un système utilisable et un système
|
||
qu'on finit par laisser désarmé en permanence.
|
||
|
||
**`ErrorHandlingDeserializer` + DLQ** — sans lui, *un seul* message malformé met
|
||
le consumer dans une boucle de replay infinie et bloque tous les événements
|
||
suivants, y compris une vraie intrusion.
|
||
|
||
**`Clock` injectée** — `Instant.now()` en dur rendrait le délai de sortie et
|
||
l'anti-rafale intestables autrement qu'avec des `Thread.sleep`.
|
||
|
||
**KRaft, pas Zookeeper** — Zookeeper est déprécié depuis Kafka 3.5 et supprimé
|
||
en 4.0. Le conserver ajouterait un conteneur, ~400 Mo de RAM et une pièce à
|
||
maintenir, pour une fonction que le broker assure désormais lui-même.
|
||
|
||
**`ddl-auto: validate`** — le schéma appartient à Flyway. L'application refuse
|
||
de démarrer si une entité et sa table ont divergé, plutôt que de tomber en
|
||
marche sur la première requête.
|
||
|
||
---
|
||
|
||
## Démarrage
|
||
|
||
### Tout en conteneurs
|
||
|
||
```bash
|
||
grep -v '^DB_PASSWORD=' .env.example > .env
|
||
printf 'DB_PASSWORD=%s\n' "$(openssl rand -hex 24)" >> .env && chmod 600 .env
|
||
|
||
docker compose up -d --build
|
||
docker compose logs -f app
|
||
```
|
||
|
||
Console sur http://localhost:8080 — le simulateur de capteur produit du trafic
|
||
dès le démarrage.
|
||
|
||
Pour un frontal Nginx en plus (http://localhost:8081) :
|
||
`docker compose --profile standalone up -d`
|
||
|
||
### En développement
|
||
|
||
```bash
|
||
docker compose up -d kafka # le broker seul
|
||
mvn spring-boot:run -Dspring-boot.run.profiles=local # H2 en mémoire
|
||
```
|
||
|
||
### Tests
|
||
|
||
```bash
|
||
mvn test # unitaires + ArchUnit — aucune infra requise
|
||
mvn verify -Pintegration # Testcontainers (Kafka + PostgreSQL réels)
|
||
```
|
||
|
||
---
|
||
|
||
## API
|
||
|
||
| Méthode | Route | Rôle |
|
||
|---|---|---|
|
||
| `POST` | `/api/v1/sensor-events` | Ingestion capteur → Kafka (202) |
|
||
| `POST` | `/api/v1/alarm/arm` | **Commande** — armer |
|
||
| `POST` | `/api/v1/alarm/disarm` | **Commande** — désarmer |
|
||
| `POST` | `/api/v1/alerts/{id}/acknowledge` | **Commande** — acquitter |
|
||
| `GET` | `/api/v1/alarm/status` | **Requête** — état courant |
|
||
| `GET` | `/api/v1/alerts?limit=50` | **Requête** — historique |
|
||
| `WS` | `/ws` → `/topic/alerts` | Push temps réel (STOMP) |
|
||
| `POST` | `/webrtc/biblio/whep` | Négociation WebRTC (servi par MediaMTX) |
|
||
| `GET` | `/hls/biblio/index.m3u8` | Repli HLS (servi par MediaMTX) |
|
||
|
||
Documentation OpenAPI : `/swagger-ui.html`
|
||
|
||
Déclencher une intrusion à la main :
|
||
|
||
```bash
|
||
curl -X POST http://localhost:8080/api/v1/alarm/arm
|
||
sleep 31 # laisser passer le délai de sortie
|
||
|
||
curl -X POST http://localhost:8080/api/v1/sensor-events \
|
||
-H 'Content-Type: application/json' \
|
||
-d '{"deviceId":"iphone-biblio","type":"MOTION_DETECTED","confidence":95}'
|
||
```
|
||
|
||
Les erreurs suivent la RFC 7807 (`ProblemDetail`). Une règle métier violée
|
||
renvoie **409** et non 400 : la requête est bien formée, c'est l'état qui s'y
|
||
oppose.
|
||
|
||
---
|
||
|
||
## Production
|
||
|
||
Déployé sur un serveur Hetzner (Ubuntu, 2 vCPU, 4 Go) mutualisé avec d'autres
|
||
services.
|
||
|
||
- **TLS** — Caddy, déjà en place sur la machine et détenteur des ports 80/443,
|
||
obtient et renouvelle les certificats Let's Encrypt automatiquement.
|
||
Y démarrer un second frontal les aurait mis en conflit et coupé les services
|
||
existants. Le bloc à ajouter est dans [`deploy/Caddyfile.snippet`](deploy/Caddyfile.snippet).
|
||
- **Deux pare-feux à traverser, pas un** — le serveur a `ufw`, et le projet
|
||
Hetzner Cloud a son propre pare-feu *en amont de la machine*. Ouvrir un port
|
||
dans `ufw` ne suffit pas : le paquet n'arrive jamais jusqu'à `ufw`.
|
||
Diagnostic sans ambiguïté — `tcpdump` sur l'hôte ne voit **aucun paquet**
|
||
alors qu'un port autorisé, lui, apparaît. Les trois ports d'ingestion média
|
||
(1935/tcp, 8890/udp, 8189/udp) doivent être ouverts **des deux côtés** :
|
||
|
||
```bash
|
||
# Sur la machine
|
||
ufw allow 1935/tcp && ufw allow 8890/udp && ufw allow 8189/udp
|
||
|
||
# Puis dans la console Hetzner Cloud > Firewalls > règles entrantes,
|
||
# ou via l'API :
|
||
hcloud firewall add-rule <nom> --direction in --protocol tcp --port 1935 --source-ips 0.0.0.0/0
|
||
hcloud firewall add-rule <nom> --direction in --protocol udp --port 8890 --source-ips 0.0.0.0/0
|
||
hcloud firewall add-rule <nom> --direction in --protocol udp --port 8189 --source-ips 0.0.0.0/0
|
||
```
|
||
|
||
Sans 8189/udp, la signalisation WebRTC aboutit mais aucun média ne circule :
|
||
la console bascule silencieusement en HLS. Panne d'autant plus trompeuse que
|
||
l'interface continue d'afficher une image.
|
||
|
||
- **Isolation réseau** — Kafka et PostgreSQL vivent sur un réseau Docker privé
|
||
sans aucun port publié sur l'hôte. Seule l'application rejoint le réseau
|
||
partagé du reverse proxy.
|
||
- **Empreinte mémoire** — limites de conteneur explicites (app 512 Mo, Kafka
|
||
800 Mo, PostgreSQL 256 Mo). La JVM utilise `MaxRAMPercentage` plutôt que
|
||
`-Xmx` : le réglage suit la limite du cgroup sans être défini à deux endroits.
|
||
`SerialGC` sur 2 vCPU partagés, où G1 coûte plus en threads qu'il ne rapporte.
|
||
- **Supervision** — `/actuator/prometheus` exposé au Prometheus de la machine,
|
||
bloqué côté public.
|
||
- **Conteneur non-root**, healthchecks sur les trois services, arrêt gracieux.
|
||
|
||
---
|
||
|
||
## Stack
|
||
|
||
| | |
|
||
|---|---|
|
||
| Langage | Java 21 (records, sealed interfaces, pattern matching, virtual threads) |
|
||
| Framework | Spring Boot 3.4 |
|
||
| Messagerie | Apache Kafka 3.9 (KRaft) |
|
||
| Persistance | PostgreSQL 16 · Spring Data JPA · Flyway |
|
||
| Temps réel | WebSocket / STOMP / SockJS |
|
||
| Vidéo | MediaMTX · SRT / RTMP en entrée · WebRTC (WHEP) / HLS en sortie |
|
||
| Build | Maven · Docker multi-stage |
|
||
| Tests | JUnit 5 · AssertJ · **ArchUnit** · Testcontainers |
|
||
| Observabilité | Actuator · Micrometer / Prometheus |
|
||
|
||
---
|
||
|
||
*Adam Belkacemi — le studio est toujours debout.*
|