Case study · encyclopédie aetherwx

L'encyclopédie AetherWX

« 12 ans que je fais du GeoServer et de l'OpenLayers, je ne comprends rien aux technos que tu utilises. » — Sylvain, 2026-05-14, 22h, juste avant que la tour Big-Blue arrête de clignoter. Cette page raconte ce qui a été déployé pendant 14h de marathon : 17 services par cluster, 2 clusters HA, GitOps complet, et pourquoi des dizaines de tokens ont été générés — un par un.

01 · Le truc qui tourne ça

Une tour gaming RGB watercooled (et non pas un laptop comme je l'ai écrit par erreur) — c'est Big-Blue. Au-dessus, depuis 14h, un cluster k3s tourne dans WSL2 Ubuntu et sert publiquement l'atlas maritime AetherWX. À côté, sur un bureau au calme, le Mini-Blue — un GEEKOM IT13 mini-PC always-on qui réplique la même stack en miroir.

Big-Blue — tour gaming RGB watercooled qui héberge le cluster k3s AetherWX
Big-Blue — RTX SUPRIM + AIO Liquid (CPU @31°C en idle), 6 fans RGB, WSL2 Ubuntu + Docker Desktop + k3s. Watercooled parce qu'à chaque cycle GFS (4×/jour) le pod weather-fetcher mouline 65 GeoTIFFs en quelques secondes.

Spec : i9-14900KF, 32 GiB RAM, RTX SUPRIM, 2 To NVMe. Spec Mini-Blue : Intel i7-1360P, 32 GiB, 1 To NVMe, ~12 W idle. Les deux exposent leurs services via un Cloudflare Tunnel (1 token, 2 connectors, 8 connexions QUIC HA) → le visiteur ne voit qu'un nom de domaine, jamais une IP maison.

02 · Vue d'oiseau

Quand un recruteur clique sur aetherwx.sladoire.dev, voilà ce qui se passe en moins de 200 ms :

Architecture
Loading diagram…
Architecture globale AetherWX — 2 clusters k3s HA, GitOps via ArgoCD + GHCR, expo publique via Cloudflare Tunnel. Le visiteur tape une URL, Cloudflare DNS résout vers le tunnel, et le tunnel route vers l'un des deux connecteurs (Big-Blue ou Mini-Blue). Si l'un tombe, l'autre encaisse seul.

Trois principes qui pilotent toute l'archi :

  • Aucun port ouvert sur la box — Cloudflare initie la connexion vers le tunnel, pas l'inverse. Pas de NAT, pas de DDNS, pas de port 443 exposé. Le pare-feu domestique reste fermé.
  • Aucune action manuelle pour déployer — tu push du code → GH Actions build + push GHCR → tu bump le tag GitOps → ArgoCD sync les 2 clusters en ~2 sec. C'est la chaîne complète.
  • Tout doublé — Big-Blue ET Mini-Blue tournent les mêmes 17 pods. Si Big-Blue plante ou que Sylvain ferme l'ordi pour partir, Mini-Blue prend tout le trafic.

03 · Le glossaire 5-minutes pour un dev Java

Tu connais Spring Boot, Tomcat, peut-être Kafka. K8s parle un autre dialecte. Voilà la table de traduction (cf aussi la page dédiée k8s pour devs Java) :

Côté Java/Docker Côté K8s Ce que ça fait
java -jar app.jarPod1 instance qui tourne
docker-compose serviceDeploymentMaintient N pods identiques
application.ymlConfigMapVariables non-sensibles
vault secretsSecretVariables sensibles (en base64)
localhost:8080ServiceDNS interne entre pods
nginx reverse-proxyIngressRoute HTTP externe → Service
/var/data volumePVC + LonghornStockage persistant cluster
Maven build + scpGH Actions + GHCRCI build + image privée
ssh prod && deploy.shArgoCD GitOpsSync auto depuis Git
@Scheduled cronCronJobTâche périodique (ex: GFS 4×/j)
Helm Maven pluginHelm chartTemplate paramétrable

Une fois ces 11 mots intégrés, 80% du jargon K8s devient respirable. Le reste (StatefulSet, DaemonSet, Operator, CRD…) sont des variations qu'on rencontre quand on tape sur des cas spécifiques.

04 · Les 17 services qui tournent

Chaque cluster fait tourner 17 pods. Voici le découpage par rôle :

Service Rôle Réplicas
Ingestion data temps réel
ais-ingesterReçoit le flux AIS TCP brut (11k msg/min)1
ais-decoderDécode NMEA → JSON → Postgres1
lightning-fetcherWebSocket Blitzortung (foudre live)1
Ingestion data périodique (cron)
weather-fetcher (GFS)NOAA GFS 4×/jour → 65 GeoTIFF1
weather-fetcher-aromeMétéo-France AROME (haute résolution)1
weather-fetcher-arpegeMétéo-France ARPEGE (Europe élargie)1
sst-fetcherNOAA OISST quotidien (température mer)1
buoy-fetcherEMODnet WFS 1×/jour (612 plateformes)1
track-builderAgrège trajets AIS journaliers1
API + orchestration
maritime-apiNestJS · endpoints REST + orchestrator USGS/METAR/Hubeau/FIRMS1
alerts-engineCroise positions + vent + foudre → alertes RMQ1
Stockage + rendering
postgres (CNPG)TimescaleDB hypertables · 5 GiB1 primary
geoserverWMS multi-projection · plugin IDW custom Java3 (HA)
rabbitmqBus alerts engine1
Frontend + expo
maritime-frontendAngular 19 · OpenLayers 10 multi-projection2
cloudflaredTunnel Cloudflare (4 connexions QUIC)1 par cluster

17 pods × 2 clusters = 34 instances qui tournent en permanence, pour un coût électrique total estimé de ~15 W (Mini-Blue idle) + ~80 W (Big-Blue actif quand sylvain code), soit ~€4/mois si on facture l'électricité.

05 · L'inventaire des tokens (le vrai sujet)

Sylvain : « j'ai généré des dizaines de tokens donné à plein de monde sans trop savoir pourquoi ». Voici la vérité — il y en a 6, et chacun a un rôle précis et borné.

Architecture
Loading diagram…
Inventaire des secrets utilisés par AetherWX. Aucun n'est dupliqué — chaque token a une mission unique.

Token #1 · GHCR_TOKEN

Type : GitHub Personal Access Token (classic, scope write:packages).
Pourquoi : GitHub Actions ne peut pas pousser sur le registry GHCR au nom de l'utilisateur sylad avec le GITHUB_TOKEN par défaut (403). Un PAT user-scoped est obligatoire.
Où vit-il : repo secret GHCR_TOKEN sur chaque repo qui build (visible dans Settings → Secrets). Et en clone dans regcred Secret K8s pour le pull privé côté cluster.
Rotation : tu peux le régénérer sur github.com/settings/tokens à tout moment.

Token #2 · ArgoCD webhook secret

Type : chaîne hex 32 bytes (openssl rand -hex 32).
Pourquoi : partagé entre GitHub (qui signe les webhooks HMAC) et ArgoCD (qui vérifie la signature). Sans ce secret, ArgoCD pollerait le repo toutes les 3 minutes au lieu de réagir en 2 secondes.
Où vit-il : dans kubectl get secret argocd-secret -n argocd en stringData, ET dans GitHub repo Settings → Webhooks → Secret.
Rotation : recette dans la section rotation ci-dessous (jamais en arg shell, toujours via stdin read -s).

Token #3 · Cloudflare tunnel token

Type : JWT long-lived émis par Cloudflare.
Pourquoi : un seul token permet d'attacher N connecteurs cloudflared (sur Big-Blue ET Mini-Blue). Cloudflare load-balance entre eux. Si l'un tombe, l'autre encaisse.
Où vit-il : Secret K8s cloudflared-token, monté en env var TUNNEL_TOKEN dans le Deployment cloudflared.
Rotation : dashboard Cloudflare Zero Trust → Networks → Tunnels → AetherWX → Refresh.

Token #4 · Google OAuth client_secret

Type : string fournie par Google Cloud Console quand on crée une app OAuth 2.0.
Pourquoi : permettre le login « Sign in with Google » dans l'interface AetherWX (utilisateurs invités pour la démo). Le client_id est public, le secret reste serveur-side.
Où vit-il : Secret K8s maritime-google-oauth, monté dans maritime-api comme GOOGLE_CLIENT_SECRET.
Rotation : Google Cloud Console → Credentials → ton OAuth Client → ⟳ Reset secret. (Note : ce token a été rotaté le 2026-05-14 après une fuite accidentelle dans un terminal.)

Token #5 · Resend API key

Type : API key SaaS Resend (envoi d'emails transactionnels).
Pourquoi : envoyer le mail de reset password dans le flux « Mot de passe oublié ». Plan gratuit suffit (3000 mails/mois).
Où vit-il : Secret K8s maritime-shared-env, key RESEND_API_KEY.
Rotation : dashboard Resend → API Keys → Revoke + Create.

Token #6 · Anthropic API key (côté apps perso)

Type : sk-ant-XXX clé API Anthropic.
Pourquoi : les 3 backends NestJS (finance-tracker, warhammer40k, ol-companion) appellent Claude via SDK pour analyser les PDF bancaires, chercher le lore Warhammer, etc. Pas utilisé dans AetherWX — mais dans la même famille de secrets.
Où vit-il : env var ANTHROPIC_API_KEY dans le .env de chaque app, ou en Secret K8s.
Rotation : console Anthropic → API Keys.

Plus quelques tokens internes auto-gérés par les operators : password Postgres (CNPG le génère et fait tourner), user RabbitMQ (operator-managed), ArgoCD admin (initial bootstrap, à changer au premier login). Pas besoin de les rotater à la main.

06 · Le flux GitOps · « tu push, ça déploie »

C'est le moment où K8s arrête de faire peur et commence à devenir agréable. Une fois cette chaîne en place, il n'y a plus rien à faire à la main. Tu modifies du code en local, tu push, et 30 secondes plus tard, l'URL publique affiche le fix.

Architecture
Loading diagram…
Le pipeline complet entre un git push et l'utilisateur qui voit le fix. ~30 secondes end-to-end. Pas un seul kubectl manuel.

Deux repos GitHub orchestrent ça :

  • maritime-atlas · le code source des 17 services. Quand tu push, GH Actions lit les fichiers modifiés (path filter), build seulement les images concernées, et push sur GHCR avec un tag sha-XXXXXXX (les 7 premiers chars du commit).
  • developpeur-gitops · les charts Helm + les values.yaml. Quand tu bumpes un tag dans ce repo et que tu push, ArgoCD reçoit le webhook GitHub en ~2 sec et sync les 2 clusters en parallèle.

Le découpage en 2 repos est volontaire : le code source est ce qui change tous les jours, les charts Helm changent rarement. Séparer les deux évite que chaque commit code re-déclenche un sync ArgoCD.

07 · Le flux d'ingestion data

AetherWX agrège 11 sources externes (AIS, foudre, météo, vagues, SST, séismes, débits fleuves, etc.). Chacune a son propre rythme et son propre service dédié :

Architecture
Loading diagram…
Flux d'ingestion data. Les fetchers cron déposent dans Postgres (hypertables Timescale) et/ou sur Longhorn RWX (rasters GeoTIFF). GeoServer lit les rasters depuis le volume partagé et expose en WMS multi-projection. Le frontend OpenLayers consomme.

Un détail qui a coûté 90 minutes de debug : le fetcher SST tombe régulièrement sur des données _preliminary.nc au lieu du fichier final (NOAA publie le preliminary 1 jour après l'observation, le final 14-21 jours après). On itère maintenant sur les deux URLs candidates et prend la première qui répond.

Autre détail : skipIfMissing dans la config orchestrator. Quand l'API Hubeau (débits de fleuves) renvoie un record sans code_station (data quality moisie côté amont), on saute ce record au lieu de planter le pg_insert. Le compteur recordsSkipped remonte en métriques pour garder la trace.

08 · Les 5 leçons qui ont coûté 6 heures

Cette journée n'a pas été un long fleuve tranquille. Voici les 5 pièges qui ont consommé le plus de temps — documentés pour qu'un autre dev Java qui débarque sur K8s ne les refasse pas :

Piège #1 · WSL2 mount propagation (45 min)

Longhorn (le stockage distribué K8s) a besoin que la racine / soit en mode rshared. Sur WSL2 par défaut, c'est private. Symptôme : longhorn-manager en CrashLoopBackOff avec CreateContainerError.
Fix : sudo mount --make-rshared / + persistance dans /etc/wsl.conf section [boot].

Piège #2 · undici + Alpine musl libc (60 min)

Node 22 sur Alpine déclenche un bug undici (fetch côté client) où les requêtes externes timeout systématiquement. https.request classique fonctionne, fetch() non. Issue upstream undici référencée.
Fix : basculer la base image de node:22-alpine à node:22-bookworm-slim. Coût : +30 MB par image, mais plus de timeouts mystérieux.

Piège #3 · OpenLayers multi-projection (2 h)

L'option projection sur une source WMS OpenLayers désigne la projection source (ce que le serveur sait servir), pas celle de la View. Je l'ai compris à l'envers et perdu 2 heures à itérer entre viewProj, undefined, et 'EPSG:3857' avant que Sylvain me dise « tu veux pas lire la doc ? ». La doc disait noir sur blanc : « the source projection is the projection it serves, reprojection happens automatically ».
Fix : projection: 'EPSG:3857' sur toutes les sources WMS (stable, GS sait servir natif), et la View peut être en EPSG:3035 Lambert ou 4326 sans souci. OL reprojete côté client via proj4js.
Leçon meta : sur une API spécialisée que je ne maîtrise pas, lire la doc officielle avant de deviner. Une mémoire dédiée existe pour ça maintenant.

Piège #4 · GeoServer JDBCConfig SLD disk drift (1 h)

Quand GeoServer tourne en mode JDBCConfig (catalog stocké dans Postgres pour cluster HA), la REST API qui met à jour un SLD écrit dans la DB mais pas sur le disque. Le rendu lit depuis le disque (workspaces/.../*.sld) → tu vois la nouvelle config en GUI mais le rendu reste stale.
Fix : docker cp direct dans le pod + reload manuel. Ou mieux : monter /opt/geoserver_data/workspaces en PVC partagé Longhorn (déjà fait).

Piège #5 · Plugin Java IDW + raster origin (90 min)

Le plugin custom Java IDW (interpolation inverse distance weighting) crashait avec ArrayIndexOutOfBoundsException: Invalid coordinates quand la View était en EPSG:3035 Lambert. Cause : après reprojection EPSG:4326 → EPSG:3857 côté serveur, le raster résultant a un minX non-nul. Mon code appelait raster.getSamples(0, 0, width, height, ...) qui sort des bornes.
Fix : raster.getSamples(raster.getMinX(), raster.getMinY(), width, height, ...). Une ligne. 90 minutes de stacktrace lecture.

09 · Combien ça coûte ?

AetherWX tourne en home-prod — l'infra est entièrement chez moi. Le seul coût récurrent est l'électricité :

Big-Blue (intermittent)

~80 W · ON quand sylvain code (8-12h/j)

≈ 8 kWh/mois → ~€2

Mini-Blue (always-on)

~12 W · 24h/24

≈ 9 kWh/mois → ~€2

Total : ~€4/mois. Compare avec un cluster Kapsule Scaleway équivalent (3 nodes, GeoServer cluster, Postgres managé) : ~€80-120/mois. Le ROI de la stratégie home-prod est de l'ordre de 20× moins cher, en échange d'une dépendance à mon LAN + box. Cloudflare tunnel gère la partie réseau, donc même si l'IP publique change, le service reste up.

Plan B (si la box meurt) : redéploiement sur Scaleway Kapsule en ~2 heures via le même repo GitOps. Les Helm charts sont portables — il faut juste swapper la StorageClass Longhorn → scw-bssd. Doc dans le repo developpeur-gitops.

10 · Quoi maintenant ?

Le marathon 2026-05-14 a livré le strict nécessaire. Le backlog post-ship :

  • ArgoCD Image Updater — pour aller vraiment zero-touch : ne plus bumper le tag à la main dans values.yaml. Image Updater scan GHCR et commit le bump tout seul. ~1h d'install.
  • SealedSecrets — chiffrer les Secrets dans le repo gitops (regcred, maritime-google-oauth, maritime-shared-env). Aujourd'hui ils sont stockés en clair dans GitHub (repo privé), demain ils seront publics par défaut. kubeseal CLI déjà installé.
  • Rebrand maritime-atlasaetherwx — couche 3 (rename repo, mise à jour des imports, redirect 301 sur les anciennes URLs).
  • App Android AetherWX (Expo) — MVP weekend, écrans Map + Alerts, push notifications via Cloudflare Worker → APNs/FCM.

11 · Pour aller plus loin

Cette page est un récit-tour. Pour les détails techniques par thème :

Encyclopédie écrite en pair-programming avec Claude Opus 4.7 le 2026-05-14, en fin de journée d'un marathon de 14 heures. Si quelque chose ne te paraît pas clair, ouvre une issue sur le repo claude-code-codex — c'est probablement que j'ai sauté une étape.