Skip to Content
L’échange d’apprentissage de HIC débute le 13 Juillet 2026. Voir le programme
PlateformeHealth SyncRunbook de déploiement

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 :

VariableSignification
<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
openmrsLe 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.tool

Règle apprise à la dure : limitez toujours les commandes compose au service que vous déployez (up -d --build api, pas un up -d nu). Sur un serveur partagé, un up -d non 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>/status

Anecdote 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émarrage

Adaptez 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 preload

C.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; done

Phase 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 openmrs

Vé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éessayez

Phase 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 source

C.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_bill

Partie 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.log

Consignez 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-upgrades

Tout 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-plugin

Les 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.com

Les 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 docker

Sur 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

QuestionQuoi répondre
Site ID<site_id> — doit correspondre exactement au jeton et au schéma du préchargement
API endpointhttps://<central-host>
API tokenle jeton brut de la partie B (affiché une seule fois à la génération)
DB type / host / portmysql / 172.17.0.1 (pont Docker → MySQL de l’hôte) / 3306
DB user / password / namel’utilisateur syncer en lecture seule de D.3 / openmrs
Tablesvotre jeu de tables (cœur clinique + facturation)
Clés primairesvérifiez par table — p. ex. moh_bill_insurance_policyinsurance_policy_id
Colonne d’horodatagedate_created pour les tables standard ; created_date pour le module moh_bill_*
start_fromla valeur par table affichée par le script de préchargement (coupure du dump − 1 jour)

start_from est 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.js

E.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 :

  1. Ne démarrez jamais le conteneur avant que config.json existe — 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.
  2. Si l’établissement ne peut pas du tout joindre Docker Hub, configurez un miroir de registre dans daemon.json avant 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 site

Le 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_secondsaucun 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 api

F.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 9100
status # 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 conteneur

La 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ômeCause probableCorrectif
Runner : ECONNREFUSED 172.17.0.1:3306MySQL lié à 127.0.0.1 seulementLiez au pont, redémarrez MySQL
Runner : Access denied for 'syncer'@...Utilisateur créé avec un hôte trop restrictifRecréez avec @'%'
Démarrage du conteneur : « config.json is a directory »Conteneur démarré avant l’existence de la configrm -rf le répertoire, créez le fichier, redémarrez
Le build Docker expire sur npm ciProblème IPv6/MTU/DNS du pont de buildCorrectif daemon.json (D.5) ; ponctuel : docker build --network=host
Cycles de synchronisation soudain lentsIndex d’horodatage manquant à la sourceSHOW INDEX FROM <table> ; construisez en ligne (D.4)
L’API renvoie des 500 sur une table, file bloquéePK 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éesPréfixez avec SET sql_mode='';
DELETE sur obs ne supprime rien en silenceFK auto-référentielles + session neuveSET foreign_key_checks=0; dans la même instruction
ALTER SYSTEM / CREATE INDEX CONCURRENTLY : erreurs de transactionpsql -c regroupe les instructions dans une transactionPassez les instructions via stdin/heredoc
Mauvais certificat TLS sur votre portUn autre service local possède le portsudo ss -tlnp avant d’accuser le réseau
Serveur central injoignable pendant un préchargementLe conteneur d’import sans plafond a mangé l’hôteToujours --memory=4g ; vérifiez ≥6 Go libres avant de commencer

F.5 Anti-patterns que nous avons appris à éviter

Anti-patternPourquoi ça mord
Indexer _synced_at avec un B-treeAmplification d’écriture sur la colonne la plus chaude du système ; utilisez BRIN pour les requêtes de récence
Partitionner obs prématurémentCasse 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 ANALYZEFacile 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’APILes lignes sont déjà en sécurité dans la file ; le recul provoque une relecture sans borne
COUNT(*) exact sur les tableaux de bordBalayages complets qui croissent avec les données ; les estimations du catalogue donnent la même image en millisecondes
Lancer un travail long en SSH nuUne 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 :

  1. Jamais sans plafond, jamais sans épinglage, jamais sans journal. Plafond mémoire sur chaque conteneur de traitement ; apt-mark hold sur tout ce que vous installez ; chaque chemin d’erreur journalise.
  2. Lecture seule à la source. Le système de synchronisation doit être physiquement incapable d’endommager les données cliniques.
  3. Les doublons sont gratuits ; les trous perdent des données. Chevauchez les watermarks, upsertez tout, renvoyez sans crainte.
  4. Reprenable ou ça ne tourne pas. Fichiers d’état par phase (hors /tmp), tmux, ré-exécutions idempotentes.
  5. 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.
  6. Signalez ce qui ne vous appartient pas. Les problèmes propres à l’établissement vont par écrit à son administrateur.
  7. 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.

Dernière mise à jour le