Aller au contenu

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 part de ce qui a été déployé pendant 14h de marathon — une vingtaine de services, GitOps complet, et pourquoi des dizaines de tokens ont été générés — et décrit l'infrastructure telle qu'elle tourne aujourd'hui, quatre mois et deux déménagements plus tard.

00 · Ce qui a changé depuis l'écriture

Cette page a été écrite le 2026-05-14, au sortir du marathon qui a mis AetherWX sur Kubernetes. L'architecture décrite plus bas (Kubernetes, GitOps, tunnel, tokens) est toujours la bonne, mais trois faits ont changé et la version que vous lisez en tient compte :

  • Fini le dual-cluster. La prod tournait sur deux clusters k3s en miroir (Big-Blue, la tour de dev sous WSL2 ; Mini-Blue, un mini-PC always-on). Depuis le 03/09/2026 elle tourne sur dark-blue, un serveur bare-metal dédié — un seul cluster, plus de dérive entre les deux.
  • GeoServer est en 3.0.1 (cutover du 08/09/2026), en cluster à 3 replicas, catalog et data dir en PostgreSQL, cache de tuiles sur object storage S3.
  • TimescaleDB n'a jamais été installée. Le schéma d'origine en dépendait ; l'extension n'a jamais réussi à cohabiter avec l'UID de CloudNativePG. Ce qui tourne, ce sont des stubs SQL no-op qui laissent le code d'origine s'exécuter, et la rétention (7 jours) est faite par un CronJob. Ce point était déjà faux dans la première version de cette page — corrigé ici.

Les sections « pièges » gardent leur date d'origine : ce sont des récits, pas des états.

01 · Le truc qui tourne ça

Au départ, une tour gaming RGB watercooled (et non pas un laptop comme je l'ai écrit par erreur) — c'est Big-Blue. Pendant 14h, un cluster k3s a tourné dans son WSL2 Ubuntu et servi publiquement l'atlas AetherWX ; un mini-PC always-on, Mini-Blue, a ensuite répliqué la même stack en miroir.

Aujourd'hui la prod a quitté les deux. Elle vit sur dark-blue, un serveur bare-metal assemblé pour ça : Ubuntu 26.04, Ryzen 9 9900X, 64 Go DDR5, NVMe 3,6 To, RTX 4080 (réservée au transcodage Plex, le GIS ne s'en sert pas). Big-Blue est redevenu ce qu'il aurait dû rester — un poste de développement qui pousse du code et des images.

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. Photo de mai 2026, quand la prod tournait encore dessus.

Spec Big-Blue : i9-14900KF, 32 GiB RAM, RTX SUPRIM, 2 To NVMe. Spec dark-blue : Ryzen 9 9900X, 64 Go DDR5, NVMe 3,6 To, RTX 4080. L'exposition passe par un Cloudflare Tunnel (1 token, 4 connexions QUIC sortantes) → 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 depuis septembre 2026 — un cluster k3s bare-metal, GitOps via ArgoCD + GHCR, expo publique via Cloudflare Tunnel. Le visiteur tape une URL, Cloudflare DNS résout vers le tunnel, et le tunnel sort du cluster (aucun port ouvert). Le poste de dev ne sert plus rien publiquement.

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 le cluster. C'est la chaîne complète.
  • Un seul endroit qui fait autorité — la prod est sur dark-blue, et nulle part ailleurs. La version « deux clusters en miroir » a tenu quelques mois avant de montrer son coût réel : deux états à garder synchrones, deux fois les incidents, et une prod qui dépendait d'un poste de travail. Le jour où le cluster sous WSL2 s'est mis à geler toutes les 48h, la redondance n'a rien sauvé — les deux moitiés partageaient le même point de défaillance, le connecteur de tunnel vivant dans le cluster. Symptôme côté visiteur : Cloudflare error 1033, trois semaines durant.

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 services qui tournent

Le namespace maritime fait tourner une vingtaine de 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)PostgreSQL + PostGIS · 2 clusters (pg-data, pg-catalog)1 primary chacun
geoserverGeoServer 3.0.1 · WMS + cascade · 3 plugins Java maison · cluster Hazelcast3 (HA)
rabbitmqBus alerts engine · RabbitMQ 4.3.01
seaweedfsObject storage S3 · cache de tuiles GWC partagé1
Frontend + expo
maritime-frontendAngular 19 · globe MapLibre + particules WebGL2
cloudflaredTunnel Cloudflare (4 connexions QUIC)1

S'y ajoutent les CronJobs qui ne tournent pas en permanence : le ménage de rétention (quotidien), le bootstrap des styles GeoServer (quotidien, parce qu'un bootstrap qui échoue une fois ne se rejoue jamais tout seul), et le canary WMS toutes les 6 heures qui rejoue la matrice complète des layers.

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. Cloudflare load-balance entre eux — utile quand plusieurs machines servaient le même site ; aujourd'hui il n'y a qu'un connecteur, sur dark-blue.
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 de tous les 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 le voit et synchronise le cluster (webhook GitHub en ~2 sec sur le dépôt de code ; polling 30 s sur le dépôt gitops depuis la remise à plat de septembre).

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 une quinzaine de sources externes (AIS, foudre, météo, vagues, SST, séismes, débits de fleuves, satellites, radars nationaux, feux…). Chacune a son propre rythme, et deux stratégies d'intégration : soit on ingère (fetcher dédié → Postgres ou GeoTIFF), soit on cascade (le provider expose déjà du WMS, GeoServer le relaie et le met derrière notre cache et notre paramètre TIME) :

Architecture
Loading diagram…
Flux d'ingestion data. Les fetchers cron déposent dans Postgres et/ou sur un volume partagé (rasters GeoTIFF). Les providers qui exposent déjà du WMS (satellites, radars nationaux) sont cascadés par GeoServer plutôt que ré-ingérés. Le frontend MapLibre consomme le tout.

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é :

dark-blue (always-on)

~60-90 W · 24h/24, prod + Plex

≈ 50 kWh/mois → ~€10

Big-Blue (dev, intermittent)

~80 W · ON quand sylvain code

ne sert plus rien publiquement

Coût récurrent : l'électricité, un domaine (~12$/an), et rien d'autre. Compare avec un cluster managé équivalent (3 nodes, cluster GeoServer, Postgres managé) : ~€80-120/mois. Le coût du bare-metal maison est en revanche un investissement initial et du temps d'exploitation — quand le serveur ne redémarre pas seul après une coupure de courant, personne ne vient le rallumer.

Plan B (si la machine meurt) : redéploiement chez un fournisseur managé via le même repo GitOps. Les Helm charts sont portables — il faut swapper la StorageClass et recréer les Secrets. C'est précisément ce qui a été exercé pour de vrai en septembre 2026, en migrant d'un cluster à l'autre : un fresh-start complet, données de rétention 7 jours re-remplies par les fetchers, et un catalog GeoServer reconstruit par des jobs idempotents plutôt que restauré.

10 · Quoi maintenant ?

Le marathon 2026-05-14 a livré le strict nécessaire. Le backlog d'alors, et ce qu'il est devenu quatre mois plus tard :

  • 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. Abandonné : au moment de le mettre en place, le dépôt upstream avait disparu et aucun SealedSecret réel n'existait dans le gitops. Les Secrets sont créés hors dépôt, à la main, et l'état de la machine est sauvegardé chiffré tous les jours (dump Postgres + données des apps, chiffrement age, rotation 14 jours, restauration testée octet à octet — pas seulement scriptée).
  • Rebrand maritime-atlas → aetherwx — fait. Y compris le renommage du workspace GeoServer maritime:* → aetherwx:*, qui touche à la fois les noms de layers et les chemins d'URL — deux substitutions distinctes, à faire de façon atomique sous peine de servir des tuiles introuvables pendant la bascule.
  • App Android AetherWX (Expo) — toujours au backlog.

Ce qui n'était pas au backlog et a occupé la suite : sortir la prod de WSL2 (serveur bare-metal, septembre), passer GeoServer en 3.0.1, et surtout construire le harnais qui empêche une correction de layer d'en casser une autre — raconté ici.

11 · Pour aller plus loin

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

  • k8s pour développeurs Java — la page glossaire complète, avec tous les concepts K8s mappés au monde Spring Boot / Tomcat / Docker Compose.
  • La nuit où on est passé de Swarm à K8s — le récit narratif de la migration initiale (13→14 mai), avec les 7 pièges détaillés.
  • AetherWX (ex Maritime Atlas) — fonctionnel — la case study orientée fonctionnalités : ce que voit l'utilisateur, les layers, les alerts, le slider temps.
  • Dépôt maritime-atlas : privé depuis septembre 2026, le temps d'étudier une cession. · Dépôt GitOps aetherwx-gitops : privé également

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, et mise à jour en septembre 2026 (prod bare-metal, GeoServer 3.0.1). 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.