Health Sync — Runbook de déploiement
Ce runbook détaille exactement comment se fait un déploiement Health Sync, commande par commande, pour qu’un ingénieur puisse le reproduire de bout en bout. Tout ce qui suit est la procédure réelle utilisée sur les neuf sites d’établissements en production au Rwanda ; seuls les identifiants, noms d’hôtes et adresses IP ont été remplacés par des variables. Pour comprendre ce qu’est le système et pourquoi il est conçu ainsi, commencez par l’aperçu de Health Sync.
Conventions utilisées dans tout le document :
| Variable | Signification |
|---|---|
<site_id> | Identifiant de l’établissement, en minuscules, p. ex. gisenyi — devient le nom du schéma PostgreSQL |
<central-host> | Le point d’accès HTTPS public de votre API centrale |
<strong-password> | Générez le vôtre ; ne réutilisez jamais les exemples |
openmrs | Le nom de la base DME de l’établissement (adaptez au vôtre) |
Le déploiement comporte deux moitiés :
- Côté central (fait une fois, plus un préchargement par site) : stack PostgreSQL + API, génération du jeton de site, préchargement des données historiques.
- Côté établissement (fait par site) : évaluation du serveur, préparation sûre de MySQL/Docker, indexation de la base source, installation du runner.
Partie A — Mise en place du serveur central (une fois)
A.1 Stack Compose : PostgreSQL + API
La stack centrale est un fichier compose local au serveur (délibérément non committé dans le dépôt — les runners de site s’auto-mettent à jour depuis le dépôt, et l’API centrale ne doit jamais redémarrer parce qu’un correctif de runner a été poussé).
docker-compose.central.yml :
services:
db:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_USER: healthsync
POSTGRES_PASSWORD: <strong-password> # évitez @ : / # — ils cassent les URL de connexion
POSTGRES_DB: healthsync
volumes:
- pg-data:/var/lib/postgresql/data
api:
build: { context: ./api }
restart: unless-stopped
depends_on: [db]
deploy:
replicas: 4 # déclaratif — survit à un simple `up -d`
environment:
DATABASE_URL: postgres://healthsync:<strong-password>@db:5432/healthsync
TOKEN_SIGNING_SECRET: <64-hex-chars> # voir ci-dessous ; min 32 caractères, l'API refuse de démarrer sans
PORT: 3000
volumes:
pg-data:Générez le secret de signature des jetons (c’est le secret maître — chaque jeton de site est un HMAC qui en dépend ; sa rotation invalide tous les jetons de site d’un coup) :
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"Démarrez la stack et vérifiez :
docker compose -f docker-compose.central.yml up -d --build
curl -s http://localhost:3000/status | python3 -m json.toolRègle apprise à la dure : limitez toujours les commandes compose au service que vous déployez (
up -d --build api, pas unup -dnu). Sur un serveur partagé, unup -dnon ciblé recrée tous les services du projet — nous avons un jour redémarré ainsi les conteneurs d’une autre équipe.
A.2 Reverse proxy HTTPS
Les runners parlent à l’API en HTTPS. Sur l’hôte central, nginx termine le TLS (Let’s Encrypt via certbot) et répartit la charge entre les réplicas de l’API :
sudo apt update
sudo apt install -y --no-install-recommends certbot python3-certbot-nginx
sudo certbot --nginx -d <central-host>server {
listen 443 ssl; # ou un port alternatif si le 443 est pris — vérifiez d'abord !
server_name <central-host>;
ssl_certificate /etc/letsencrypt/live/<central-host>/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/<central-host>/privkey.pem;
client_max_body_size 50m; # les lots de synchronisation peuvent être gros — évitez les 413 silencieux
location / {
resolver 127.0.0.11 valid=10s; # DNS embarqué de Docker — résout les réplicas à la requête
set $upstream http://api:3000; # proxy_pass en variable = résolution différée
proxy_pass $upstream;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}sudo nginx -t && sudo systemctl reload nginx
curl -s https://<central-host>/statusAnecdote de terrain : avant de conclure à une interception TLS au niveau du réseau quand un port sert le mauvais certificat, vérifiez qui possède le port localement —
sudo ss -tlnp | grep ':443'. Dans notre cas, « l’intercepteur » était le conteneur d’une autre équipe déjà lié au port 443 du serveur partagé. Nous sommes passés sur un port alternatif en vingt minutes, après des heures perdues en théories d’interception.
A.3 Réglage de PostgreSQL pour un hôte partagé
Sur un serveur gouvernemental partagé, réglez PostgreSQL pour cohabiter avec les autres
locataires. Deux pièges : ALTER SYSTEM ne peut pas s’exécuter dans une transaction
(utilisez un heredoc, pas psql -c "a; b;"), et shared_buffers exige un redémarrage
complet, pas un rechargement.
docker compose -f docker-compose.central.yml exec -T db \
psql -U healthsync -d healthsync <<'SQL'
ALTER SYSTEM SET shared_buffers = '3GB';
ALTER SYSTEM SET effective_cache_size = '8GB';
ALTER SYSTEM SET work_mem = '32MB';
ALTER SYSTEM SET maintenance_work_mem = '512MB';
ALTER SYSTEM SET random_page_cost = 1.1;
ALTER SYSTEM SET wal_compression = on;
ALTER SYSTEM SET checkpoint_timeout = '15min';
ALTER SYSTEM SET max_wal_size = '4GB';
ALTER SYSTEM SET autovacuum_naptime = '30s';
ALTER SYSTEM SET autovacuum_vacuum_scale_factor = 0.05;
SELECT pg_reload_conf();
SQL
docker compose -f docker-compose.central.yml restart db # shared_buffers exige un redémarrageAdaptez les valeurs à votre hôte — celles-ci sont pour une machine de 15 Go partagée avec ~25 autres conteneurs.
Partie B — Génération du jeton de site (par site, sur le serveur central)
Chaque site s’authentifie avec un jeton aléatoire de 32 octets. Le jeton brut n’est affiché qu’une seule fois ; seuls son haché HMAC et un préfixe de recherche sont stockés. L’identité du site est dérivée du jeton au moment de la requête — jamais du corps de la requête — de sorte qu’un runner mal configuré ne peut pas écrire dans le schéma d’un autre site.
node scripts/gen-token.js --site <site_id>
# → affiche le jeton brut UNE FOIS. Collez-le dans l'assistant du runner à l'établissement. Il n'est jamais stocké.Si la base centrale n’est joignable que depuis l’intérieur du conteneur API (recommandé), exécutez le générateur dedans :
docker compose -f docker-compose.central.yml exec \
-e NODE_PATH=/app/node_modules api \
node scripts/gen-token.js --site <site_id>Vérifiez (cette requête ne révèle aucun secret) :
SELECT site_id, token_prefix, revoked, created_at FROM health_sync.site_tokens;Partie C — Préchargement historique (par site, sur le serveur central)
Principe : précharger l’historique hors ligne ; ne synchroniser que le delta. Laisser le runner rattraper des années d’historique martèlerait à la fois le serveur de l’établissement et l’API centrale. À la place, un dump de base est chargé au centre, et le runner démarre depuis un watermark placé juste avant la date de coupure du dump.
C.1 La méthode en une commande
Tout ce qui suit est automatisé dans un seul script reprenable :
# Nouveau site — préchargement complet depuis un dump
./scripts/preload-site.sh --site <site_id> --dump ~/dumps/<site_id>/<dump>.sql.gz
# Options utiles :
# --prune-before 2020-01-01 supprime les lignes obs/encounter antérieures à une date
# --no-prune conserve l'historique complet
# --dry-run montre ce que ferait chaque phase, ne change rien
# --status montre lesquelles des 14 phases sont terminées
# --reset oublie l'état, repart de la phase 1
# --only t1,t2,... ajoute des tables à un site DÉJÀ en production (voir C.4)Le script encode chaque leçon des exécutions manuelles : un plafond mémoire dur de 4 Go
sur le conteneur MySQL jetable (un conteneur sans plafond a un jour affamé tout le serveur
partagé), des vérifications mémoire préalables avec abandon automatique, des fichiers d’état
par phase conservés dans le répertoire personnel (pas dans /tmp — un redémarrage a un
jour effacé des heures de progression), et des comptages de vérification par table.
Lancez toujours les préchargements dans
tmux. Une session SSH interrompue a un jour coûté une heure de progression d’import.
tmux new -s preload
./scripts/preload-site.sh --site <site_id> --dump <dump>
# se détacher : Ctrl-b d · se rattacher : tmux attach -t preloadC.2 Ce que fait le script sous le capot
Comprendre les phases importe plus que mémoriser le script — c’est la partie à adapter à votre propre DME.
Phase 1 — Amincir le dump. Les dumps DME de plusieurs Go contiennent ~500 tables ; nous en synchronisons 18. Un filtre Python en streaming extrait uniquement les tables cibles (60 à 80 % plus petit, et il ne charge jamais le fichier en mémoire) :
import re
TABLES = {'person', 'patient', 'person_name', 'patient_identifier',
'encounter', 'encounter_type', 'concept_name', 'obs', ...} # votre jeu de tables
in_target = False
with open(dump, 'r', errors='replace') as src, open(out, 'w') as dst: # errors='replace' : les vrais dumps contiennent des octets non UTF-8
for line in src:
m = re.match(r"-- Table structure for table `([^`]+)`", line)
if m: in_target = m.group(1) in TABLES
if in_target: dst.write(line)Phase 2 — Conteneur MySQL jetable, plafonné, réglé pour la vitesse. Les fonctions de durabilité sont inutiles pour une cible d’import jetable — les désactiver rend l’import 2 à 4× plus rapide et annulable sans risque. Le plafond mémoire est non négociable sur un hôte partagé :
docker run -d --name mysql-tmp \
--memory=4g --memory-swap=4g \
-e MYSQL_ROOT_PASSWORD=<strong-password> \
-e MYSQL_DATABASE=openmrs \
mysql:8.0 \
--innodb-buffer-pool-size=2G \
--innodb-flush-log-at-trx-commit=2 \
--innodb-doublewrite=OFF \
--skip-log-bin \
--max-allowed-packet=1G
until docker exec mysql-tmp mysqladmin ping -uroot -p<strong-password> --silent 2>/dev/null; do sleep 1; donePhase 3 — Importer le dump aminci avec les options de vitesse autour :
{
echo "SET sql_mode='';" # le mode strict de MySQL 8 rejette les zéro-dates héritées
echo "SET autocommit=0; SET unique_checks=0; SET foreign_key_checks=0;"
echo "ALTER INSTANCE DISABLE INNODB REDO_LOG;"
cat <site>-slim.sql
echo "COMMIT;"
echo "ALTER INSTANCE ENABLE INNODB REDO_LOG;"
} | docker exec -i mysql-tmp mysql -uroot -p<strong-password> --max-allowed-packet=1G openmrsVérifiez avec des estimations instantanées, pas avec COUNT(*) :
SELECT TABLE_NAME, TABLE_ROWS FROM information_schema.TABLES
WHERE TABLE_SCHEMA='openmrs' ORDER BY TABLE_ROWS DESC;Phase 4 (optionnelle) — Élaguer l’historique ancien. Seules obs et encounter sont
élaguées (les autres tables restent complètes pour l’intégrité référentielle). Indexez
d’abord la colonne d’horodatage, puis supprimez par plages de clés primaires — jamais un
seul DELETE géant :
START=<min_pk>; END=<max_pk>; STEP=100000
while [ $START -le $END ]; do
docker exec mysql-tmp mysql -uroot -p<strong-password> -e "
SET autocommit=1; SET foreign_key_checks=0;
DELETE FROM openmrs.obs
WHERE obs_id BETWEEN $START AND $((START+STEP-1))
AND date_created < '2020-01-01';"
START=$((START+STEP))
done(foreign_key_checks=0 doit être défini dans la même instruction — chaque
docker exec mysql -e est une session neuve. obs a des clés étrangères
auto-référentielles qui rejettent silencieusement les suppressions sinon.)
Phase 5 — Transfert vers PostgreSQL. Streaming MySQL → PostgreSQL, en renommant le
schéma source en <site_id>, en convertissant les zéro-dates en NULL, en chargeant les
données seules (pas d’index, pas de clés étrangères — ajoutés après). Le projet fournit un
chargeur Python en streaming conçu sur mesure (pymysql → COPY PostgreSQL) qui crée les
tables à partir des types de colonnes déclarés du runner, après que l’outil de migration du
commerce s’est révélé peu fiable à l’échelle (blocages silencieux, un mode de faux succès,
incompatibilité d’authentification MySQL 8).
Phase 6 — Corrections post-chargement (indispensables au fonctionnement du runner).
L’upsert de l’API est INSERT … ON CONFLICT (<pk>) DO UPDATE — chaque table doit avoir sa
clé primaire, et l’API marque chaque ligne avec _synced_at :
-- chaque table du schéma du site reçoit _synced_at
DO $$
DECLARE r record;
BEGIN
FOR r IN SELECT tablename FROM pg_tables WHERE schemaname='<site_id>' LOOP
EXECUTE format(
'ALTER TABLE <site_id>.%I ADD COLUMN IF NOT EXISTS _synced_at TIMESTAMPTZ DEFAULT now()',
r.tablename);
END LOOP;
END$$;
-- clés primaires (vérifiez le vrai nom de PK par table — les conventions ne sont pas universelles !)
ALTER TABLE <site_id>.person ADD PRIMARY KEY (person_id);
ALTER TABLE <site_id>.obs ADD PRIMARY KEY (obs_id);
-- ... etc. Exemple de rupture de convention rencontrée :
ALTER TABLE <site_id>.moh_bill_insurance_policy ADD PRIMARY KEY (insurance_policy_id);Phase 7 — Index analytiques, construits avec CONCURRENTLY (aucun verrou de table),
les plus petites tables d’abord, les index géants d’obs un par un avec vérification de
validité entre chaque :
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_obs_concept_dt
ON <site_id>.obs (concept_id, obs_datetime);
-- vérifiez avant de lancer le suivant :
SELECT ic.relname, i.indisvalid
FROM pg_index i JOIN pg_class c ON c.oid=i.indrelid
JOIN pg_class ic ON ic.oid=i.indexrelid
JOIN pg_namespace n ON n.oid=c.relnamespace
WHERE n.nspname='<site_id>' AND c.relname='obs';
-- indisvalid = f → DROP INDEX CONCURRENTLY puis réessayezPhase 8 — Démontage et note de watermark. docker rm -f mysql-tmp libère la mémoire,
et le script affiche le start_from recommandé par table : horodatage maximal chargé
moins un jour. Le chevauchement est délibérément généreux — les doublons sont gratuits
(upserts), les trous perdent des données.
C.3 Vérifier un préchargement
docker compose -f docker-compose.central.yml exec db psql -U healthsync -d healthsync\dn -- schémas de sites
\dt <site_id>.* -- tables du schéma du site
SELECT count(*) FROM <site_id>.obs; -- comparez aux comptages de la sourceC.4 Ajouter des tables à un site en production plus tard
Ne relancez jamais un préchargement complet contre un schéma en production. Le mode incrémental est chirurgical : son propre espace d’état, une vérification par table, les phases d’élagage/d’indexation sautées, et — point critique — il ne régénère jamais le jeton du site (ce qui casserait le runner en production) :
./scripts/preload-site.sh --site <site_id> --dump <fresh-dump> \
--only orders,concept_class,moh_bill_global_billPartie D — Préparation du serveur de l’établissement (par site)
D.1 Reconnaissance d’abord — ne touchez à rien avant de connaître la machine
# OS + ressources
lsb_release -a && uname -a && uptime && free -h && df -h / && nproc
# Ce qui est déjà installé et en écoute
dpkg -l | grep -iE "mysql|mariadb|docker"
sudo ss -tlnp
# Politique de mises à jour automatiques — critique sur un serveur DME de production
systemctl is-enabled unattended-upgrades
cat /etc/apt/apt.conf.d/20auto-upgrades
apt-mark showhold
tail -50 /var/log/apt/history.logConsignez les réponses. Elles déterminent tout ce qui suit — et elles sont votre preuve si quelque chose change plus tard sur la machine.
D.2 Durcissement de stabilité — ne pas nuire au DME
# Empêcher les montées de version d'OS accidentelles
sudo sed -i 's/^Prompt=.*/Prompt=never/' /etc/update-manager/release-upgradesTout ce que vous installez est épinglé en version, pour que les mises à jour automatiques ne puissent jamais redémarrer la base du DME dans votre dos :
sudo apt-mark hold docker-ce docker-ce-cli containerd.io docker-compose-pluginLes problèmes qui relèvent de l’établissement (p. ex. MySQL lié à toutes les interfaces, absence d’épinglage sur le MySQL du DME lui-même) sont signalés par écrit à l’administrateur de l’établissement — pas modifiés unilatéralement. Vous héritez d’un système clinique qui fonctionne ; il doit continuer à fonctionner.
D.3 Utilisateur de base en lecture seule pour le runner
Le runner a besoin de SELECT et de rien d’autre. L’hôte '%' compte — le runner se
connecte depuis une IP de conteneur, pas depuis localhost :
CREATE USER 'syncer'@'%' IDENTIFIED BY '<strong-password>';
GRANT SELECT ON openmrs.* TO 'syncer'@'%';
FLUSH PRIVILEGES;Si MySQL n’est lié qu’à 127.0.0.1, le conteneur ne peut pas l’atteindre. Liez-le au pont
Docker (l’utilisateur en lecture seule + le pare-feu de l’hôte le sécurisent), et utilisez
la passerelle du pont 172.17.0.1 comme db.host dans la configuration du runner —
jamais localhost, qui dans un conteneur désigne le conteneur lui-même.
D.4 Indexer les colonnes d’horodatage — le premier facteur de performance
Chaque base DME d’établissement rencontrée manquait d’index sur date_created. Sur un
site, chaque cycle de synchronisation devenait un balayage complet de 23 secondes d’une
table de 16,7 M de lignes ; la construction de l’index a pris moins d’une minute et l’a
éliminé.
-- vérifiez ce qui existe
SHOW INDEX FROM obs;
SELECT COUNT(*) FROM obs; -- connaissez la taille avant de construire
-- DDL en ligne de MySQL 8 : aucun verrou de table, sûr sur un DME en production, annulable sans risque
-- NOTE : options séparées par des ESPACES, pas des virgules (la virgule est une erreur de syntaxe — vérifié sur 8.0.42)
CREATE INDEX idx_obs_date_created ON obs (date_created) ALGORITHM=INPLACE LOCK=NONE;Construisez un index à la fois, la plus petite table d’abord, la plus grande (obs) en
dernier. Si vous devez annuler, c’est sans risque — MySQL défait l’index partiel (comptez à
peu près la durée de construction pour l’annulation).
Si le sql_mode du DME rejette les valeurs zéro-date héritées pendant une construction,
limitez le contournement à votre session : SET SESSION sql_mode='';
D.5 Installation de Docker et pré-vérification du réseau de build
Installez Docker avec l’empreinte minimale (--no-install-recommends), épinglez les
paquets (D.2). Puis — avant de lancer l’installateur — vérifiez que le réseau de build
de Docker fonctionne, car un réseau hôte sain ne garantit pas que le pont de build l’est :
docker run --rm curlimages/curl -sI --max-time 10 https://github.com
docker run --rm curlimages/curl -sI --max-time 10 https://objects.githubusercontent.comLes deux doivent renvoyer une ligne de statut HTTP. Si elles expirent (rencontré deux fois
— préférence IPv6 cassée, mauvais MTU), corrigez définitivement dans
/etc/docker/daemon.json :
{ "ipv6": false, "dns": ["8.8.8.8", "1.1.1.1"], "mtu": 1450 }sudo systemctl restart dockerSur les réseaux où apt lui-même échoue en IPv6 :
echo 'Acquire::ForceIPv4 "true";' | sudo tee /etc/apt/apt.conf.d/99force-ipv4 (sur un
site, cela a fait passer apt update de 71 secondes d’échecs à 4,7 secondes).
Partie E — Installation du runner (par site)
E.1 La commande unique
export GITHUB_TOKEN=<github-pat>
bash <(curl -fsSL -H "Authorization: token $GITHUB_TOKEN" \
https://raw.githubusercontent.com/<org>/health-sync/main/scripts/site-install.sh)Options si besoin : --api-token <token> (préremplit l’assistant), --config <path>
(réutilise un config.json existant), --install-dir <path> (défaut ~/health-sync).
L’installateur vérifie Docker, clone le dépôt, construit l’image du runner, déroule un assistant interactif et démarre le conteneur.
E.2 Aide-mémoire de l’assistant
| Question | Quoi répondre |
|---|---|
| Site ID | <site_id> — doit correspondre exactement au jeton et au schéma du préchargement |
| API endpoint | https://<central-host> |
| API token | le jeton brut de la partie B (affiché une seule fois à la génération) |
| DB type / host / port | mysql / 172.17.0.1 (pont Docker → MySQL de l’hôte) / 3306 |
| DB user / password / name | l’utilisateur syncer en lecture seule de D.3 / openmrs |
| Tables | votre jeu de tables (cœur clinique + facturation) |
| Clés primaires | vérifiez par table — p. ex. moh_bill_insurance_policy → insurance_policy_id |
| Colonne d’horodatage | date_created pour les tables standard ; created_date pour le module moh_bill_* |
start_from | la valeur par table affichée par le script de préchargement (coupure du dump − 1 jour) |
start_fromest la réponse critique pour la sécurité des données. Trop tôt ne fait que ré-upserter des lignes préchargées (gratuit). Absent ou trop tard et soit vous re-tirez des années d’historique, soit vous sautez des lignes en silence. Dans le doute, choisissez plus tôt.
À partir du déploiement n° 2, sautez l’essentiel de l’assistant : copiez le config.json
d’un site précédent, changez les cinq champs propres au site (site_id, api_token, mot
de passe DB, valeurs start_from), et passez-le avec --config.
Ajouter des tables à un site en fonctionnement plus tard :
docker compose exec runner node scripts/add-table.jsE.3 La configuration produite (forme)
{
"site_id": "<site_id>",
"api_endpoint": "https://<central-host>",
"api_token": "<raw-token>",
"db": { "type": "mysql", "host": "172.17.0.1", "port": 3306,
"user": "syncer", "password": "<strong-password>", "database": "openmrs" },
"tables": [
{ "name": "obs", "primary_key": "obs_id", "timestamp_col": "date_created",
"start_from": "2026-06-29 00:00:00",
"columns": { "obs_id": "integer", "person_id": "integer",
"date_created": "timestamp", "...": "..." } }
],
"sync_interval_seconds": 60,
"update_check_interval_seconds": 300,
"heartbeat_interval_seconds": 30,
"github": { "repo": "<org>/health-sync", "branch": "main", "token": "<github-pat>" }
}Notes : columns associe les noms aux types PostgreSQL (integer, bigint, text,
boolean, date, timestamp, numeric, jsonb) et est un objet, pas un tableau —
l’API centrale crée les tables à partir de lui. Surveillez les types sources : nous avons
un jour failli déclarer une colonne décimale en integer. config.json est ignoré par git
— il contient des secrets et n’est jamais committé.
Deux pièges à cette étape précise, rencontrés sur plusieurs sites :
- Ne démarrez jamais le conteneur avant que
config.jsonexiste — Docker crée silencieusement le chemin de bind-mount manquant comme répertoire, et chaque démarrage suivant échoue avec une erreur déroutante. Correctif :docker compose down && rm -rf config.json, créez le vrai fichier, redémarrez.- Si l’établissement ne peut pas du tout joindre Docker Hub, configurez un miroir de registre dans
daemon.jsonavant de construire.
E.4 Vérifier le déploiement
# 1. Journaux du runner — cherchez des cycles de synchronisation et zéro erreur
docker compose logs -f runner
# 2. Battement de cœur visible au centre ?
curl -s https://<central-host>/status | python3 -m json.tool
# → le site doit être "online", queue_depth proche de 0, last_sync récent
# 3. Les lignes circulent vraiment ? (sur le serveur central)
# exécutez deux fois à quelques minutes d'écart — le comptage doit croître avec l'activité clinique
SELECT count(*) FROM <site_id>.obs WHERE _synced_at > now() - interval '1 hour';Un site est terminé quand : les battements de cœur sont au vert, la profondeur de file revient à ~0 après chaque cycle, les comptages centraux avancent, et un cycle complet d’auto-mise à jour a été observé.
Partie F — Opérations au quotidien
F.1 Supervision de la flotte
curl -s https://<central-host>/status | python3 -m json.tool # en ligne / obsolète / hors ligne par siteLe tableau de bord (même hôte, /dashboard) montre des histogrammes horaires d’ingestion
par site et par table — un site à l’arrêt apparaît comme un trou visible, que le personnel
non technique remarque bien plus vite qu’un horodatage périmé.
F.2 Livrer un correctif à tous les sites
Poussez sur la branche principale. Chaque runner interroge, récupère et se relance de
lui-même dans le délai update_check_interval_seconds — aucun déplacement sur site.
Les données en file survivent aux redémarrages (état SQLite durable). L’API centrale ne se
met délibérément pas à jour toute seule ; déployez-la avec un rebuild ciblé :
git pull && docker compose -f docker-compose.central.yml up -d --build apiF.3 La console de débogage sur le runner
Chaque runner peut exposer une console accessible uniquement en boucle locale (jamais joignable depuis le réseau) :
DEV_MODE=1 docker compose up -d runner
docker compose exec runner nc localhost 9100status # profondeur de file, watermarks, MySQL vivant/mort, version du code
sync obs # déclenche maintenant le cycle de synchronisation d'une table
queue 10 # aperçu des éléments en file
set watermark obs 2026-06-29T00:00:00Z # force une resynchronisation depuis un instant (sûr : les upserts dédupliquent)
ping db / ping api # vérifications de connectivité
pause / resume # arrête toutes les boucles sans stopper le conteneurLa commande set watermark est aussi le mécanisme de rattrapage périodique des tables à
granularité journalière (created_date de type date, p. ex. plusieurs tables
moh_bill_*) — un watermark strict > sur une colonne de date peut manquer des écritures
tardives du même jour, donc le watermark est périodiquement reculé d’un jour ; les lignes
renvoyées se dédupliquent gratuitement.
F.4 Aide-mémoire de dépannage
| Symptôme | Cause probable | Correctif |
|---|---|---|
Runner : ECONNREFUSED 172.17.0.1:3306 | MySQL lié à 127.0.0.1 seulement | Liez au pont, redémarrez MySQL |
Runner : Access denied for 'syncer'@... | Utilisateur créé avec un hôte trop restrictif | Recréez avec @'%' |
| Démarrage du conteneur : « config.json is a directory » | Conteneur démarré avant l’existence de la config | rm -rf le répertoire, créez le fichier, redémarrez |
Le build Docker expire sur npm ci | Problème IPv6/MTU/DNS du pont de build | Correctif daemon.json (D.5) ; ponctuel : docker build --network=host |
| Cycles de synchronisation soudain lents | Index d’horodatage manquant à la source | SHOW INDEX FROM <table> ; construisez en ligne (D.4) |
| L’API renvoie des 500 sur une table, file bloquée | PK ou _synced_at manquant au centre (phase de préchargement sautée) | Appliquez les corrections de la phase 6 de la partie C ; la file se vide seule |
Import : Invalid default value for 'date_created' | Mode strict de MySQL 8 contre les zéro-dates héritées | Préfixez avec SET sql_mode=''; |
DELETE sur obs ne supprime rien en silence | FK auto-référentielles + session neuve | SET foreign_key_checks=0; dans la même instruction |
ALTER SYSTEM / CREATE INDEX CONCURRENTLY : erreurs de transaction | psql -c regroupe les instructions dans une transaction | Passez les instructions via stdin/heredoc |
| Mauvais certificat TLS sur votre port | Un autre service local possède le port | sudo ss -tlnp avant d’accuser le réseau |
| Serveur central injoignable pendant un préchargement | Le conteneur d’import sans plafond a mangé l’hôte | Toujours --memory=4g ; vérifiez ≥6 Go libres avant de commencer |
F.5 Anti-patterns que nous avons appris à éviter
| Anti-pattern | Pourquoi ça mord |
|---|---|
Indexer _synced_at avec un B-tree | Amplification d’écriture sur la colonne la plus chaude du système ; utilisez BRIN pour les requêtes de récence |
Partitionner obs prématurément | Casse la cible de conflit d’upsert sur chaque site déployé ; attendez qu’un site dépasse ~100 M de lignes |
Ajouter des index sans EXPLAIN ANALYZE | Facile d’en ajouter dix et de découvrir que le planificateur en utilise deux — mesurez, ajoutez, re-mesurez |
| Reculer les watermarks en cas d’échec de l’API | Les lignes sont déjà en sécurité dans la file ; le recul provoque une relecture sans borne |
COUNT(*) exact sur les tableaux de bord | Balayages complets qui croissent avec les données ; les estimations du catalogue donnent la même image en millisecondes |
| Lancer un travail long en SSH nu | Une session coupée = des heures perdues ; tmux, toujours |
Les règles de sécurité, condensées
Si vous n’emportez qu’une page, emportez celle-ci :
- Jamais sans plafond, jamais sans épinglage, jamais sans journal. Plafond mémoire sur
chaque conteneur de traitement ;
apt-mark holdsur tout ce que vous installez ; chaque chemin d’erreur journalise. - Lecture seule à la source. Le système de synchronisation doit être physiquement incapable d’endommager les données cliniques.
- Les doublons sont gratuits ; les trous perdent des données. Chevauchez les watermarks, upsertez tout, renvoyez sans crainte.
- Reprenable ou ça ne tourne pas. Fichiers d’état par phase (hors
/tmp),tmux, ré-exécutions idempotentes. - Mesurez, puis touchez. Comptages de lignes,
SHOW INDEX, charges moyennes et requêtes chronométrées avant tout changement sur un serveur hospitalier en production. - Signalez ce qui ne vous appartient pas. Les problèmes propres à l’établissement vont par écrit à son administrateur.
- Vérifiez par table, par site. Noms de clés primaires, noms et types des colonnes d’horodatage, formats de dumps et encodages varient — même entre établissements exploitant le même produit DME.
Toutes les commandes ont été vérifiées en production sur neuf déploiements d’établissements, de mars à juillet 2026. Des variables remplacent partout les vrais noms d’hôtes, identifiants et jetons — générez les vôtres.