Skip to content

Supervision ​

Une instance Mankomail indique son état par deux points d’entrée HTTP et par ses journaux. Cette page décrit les deux, ce qui mérite d’être surveillé, comment dimensionner la base, et ce que fait l’instance d’elle-même après un redémarrage ou une coupure.

Les sondes de santé ​

Chaque processus applicatif, quel que soit son APP_ROLE, répond à deux points d’entrée sur son port HTTP. Aucun ne demande d’authentification.

Point d’entréeQuestion à laquelle il répondInterroge la base
/healthzLe processus est-il vivant ?Non
/readyzLe processus peut-il servir des requêtes ?Oui

/healthz répond toujours 200 tant que le processus tourne :

json
{ "status": "ok", "brand": "Mankomail", "version": "1.4.0", "uptimeSeconds": 5321.4 }

brand est la valeur de BRAND_NAME, version celle d’APP_VERSION, et uptimeSeconds le temps écoulé depuis le démarrage du processus.

/readyz fait deux vérifications, dans l’ordre : la base répond à une requête, puis chaque migration de l’image a été appliquée. Il répond 200 quand les deux passent :

json
{
  "status": "ready",
  "checks": [
    { "name": "database", "ok": true },
    { "name": "migrations", "ok": true }
  ]
}

et 503 sinon, avec la raison dans detail :

json
{
  "status": "not_ready",
  "checks": [
    { "name": "database", "ok": false, "detail": "connect ECONNREFUSED 172.18.0.2:5432" },
    { "name": "migrations", "ok": false, "detail": "database unreachable" }
  ]
}

Quand la base est joignable mais que des migrations manquent, la vérification migrations porte pending: suivi des noms de fichier des migrations manquantes.

Quelle sonde utiliser, et où ​

  • Sonde de vivacité (health check Docker, livenessProbe Kubernetes, superviseur de processus) : /healthz. Une sonde qui interrogerait la base ferait redémarrer en boucle des processus sains pendant une courte coupure de la base.
  • Sonde de disponibilité et répartiteur de charge (readinessProbe Kubernetes, vérifications amont d’un proxy) : /readyz.
  • Vérification de disponibilité externe : <PUBLIC_BASE_URL>/readyz, depuis l’extérieur du serveur, pour vérifier aussi le reverse proxy et le certificat.

L’image déclare un HEALTHCHECK Docker sur /healthz : toutes les 30 secondes, délai de 5 secondes, état « unhealthy » après 3 échecs, avec une période de démarrage de 20 secondes. docker compose ps affiche le résultat.

Le champ detail de /readyz peut contenir des messages d’erreur de la base et des noms de fichiers de migration. Si vous préférez ne pas les exposer publiquement, réservez /readyz à vos adresses de supervision au niveau du reverse proxy, et interrogez-le localement sur 127.0.0.1:<APP_PORT>.

Les journaux ​

L’application écrit ses journaux sur la sortie standard, un objet JSON par ligne. Avec le fichier Compose de référence, lisez-les avec :

bash
docker compose -f compose.reference.yaml logs -f app

Chaque ligne porte :

ChampContenu
levelfatal, error, warn, info, debug ou trace
timeHorodatage ISO 8601
roleL’APP_ROLE du processus
versionL’APP_VERSION du processus
msgLe message
moduleLa partie de l’application qui a écrit la ligne, quand elle en a une

Les requêtes HTTP sont journalisées au niveau info, avec un identifiant de requête (reqId) commun à la ligne de la requête et à celle de la réponse.

  • LOG_LEVEL fixe le niveau minimal : fatal, error, warn, info (défaut), debug, trace ou silent.
  • LOG_FORMAT accepte json (un objet JSON par ligne, pour les collecteurs de journaux) et pretty (lignes lisibles, pour le développement).
  • Les secrets et les données personnelles sont masqués : en-têtes d’autorisation, cookies, mots de passe, jetons, clés d’API, ainsi que l’objet, le corps et les destinataires des mails sont remplacés par [redacted].
  • Une erreur fatale au démarrage, comme une migration en échec ou une base injoignable, est écrite en texte simple, en commençant par fatal: failed to start, suivie de l’erreur.

Pour filtrer les lignes JSON, utilisez jq :

bash
docker compose -f compose.reference.yaml logs --no-log-prefix app \
  | jq -c 'select(.level == "error" or .level == "fatal")'

Le fichier Compose de référence garde au plus cinq fichiers de 10 Mo de journaux par conteneur. Pour les conserver plus longtemps, envoyez-les vers un système de journaux (Loki, Elasticsearch, un serveur syslog) avec un pilote de journalisation Docker ou un collecteur.

Les lignes de journal à connaître ​

Ligne de journalNiveauCe qu’elle signifie
configuration: …warnUne variable a une valeur invalide, et sa valeur par défaut est utilisée à la place.
ENCRYPTION_KEY is not set / ENCRYPTION_KEY is invalidwarnL’enregistrement des secrets est désactivé. Voir La clé de chiffrement.
database schema is up to dateinfoLes migrations sont appliquées.
postgres is not accepting connections yet, retryingwarnLa base n’est pas encore prête au démarrage. Normal pendant quelques secondes.
expired job leases reclaimed (a worker died holding them)warnDes tâches tenues par un processus mort ont été rendues à la file. Attendu après un crash ou un arrêt forcé.
claim failed, scheduler tick failederrorLes boucles de fond ne joignent pas la base. Répété chaque seconde pendant une coupure de la base.
postgres pool: connection lost while in usewarnUne connexion à la base a été coupée pendant une requête.
instance maintenance doneinfoL’entretien horaire a tourné.
APP_ROLE=api: this process runs no workerwarnCe processus ne fait aucun travail de fond : assurez-vous qu’un processus worker tourne ailleurs.

Quelques lignes error pendant un redémarrage de la base sont attendues. Les mêmes lignes répétées pendant des minutes, alors que PostgreSQL est debout, ne le sont pas.

Les métriques ​

La version actuelle n’expose aucun point de métriques : pas d’export Prometheus ni OpenTelemetry. Surveillez l’instance par les sondes de santé, les journaux, et quelques mesures que vous collectez vous-même.

MesureComment l’obtenirPourquoi
État de /readyzVérification HTTP externeLa disponibilité de l’instance.
Espace disque libreVotre supervision systèmeLe stockage objet et PostgreSQL grossissent avec les boîtes. Un disque plein arrête tout.
Taille de PostgreSQLSELECT pg_size_pretty(pg_database_size(current_database()));Elle guide le dimensionnement de la base, ci-dessous.
Âge de la plus ancienne tâche prêteRequête SQL ci-dessousLe meilleur signe d’un travail de fond bloqué.
Tâches qui ont épuisé leurs tentativesRequête SQL ci-dessousDu travail qui demande un regard humain.

La file de tâches vit dans la table jobs. Cette requête donne son état :

sql
SELECT
  count(*) FILTER (WHERE dead_at IS NULL AND claimed_by IS NULL AND run_at <= now()) AS ready,
  count(*) FILTER (WHERE dead_at IS NULL AND claimed_by IS NOT NULL)                 AS running,
  count(*) FILTER (WHERE dead_at IS NOT NULL)                                        AS dead,
  now() - min(run_at) FILTER (WHERE dead_at IS NULL AND claimed_by IS NULL AND run_at <= now())
    AS oldest_ready_age
FROM jobs;

Lancez-la avec :

bash
docker compose -f compose.reference.yaml exec postgres \
  sh -c 'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB"'
  • oldest_ready_age reste à quelques secondes sur une instance saine. Plusieurs minutes signifient qu’aucun processus ne prend de travail : vérifiez qu’un processus worker ou all tourne et que ses journaux ne montrent pas de claim failed.
  • dead compte les tâches qui ont épuisé leurs tentatives. Elles sont gardées 30 jours, puis supprimées par l’entretien. Un nombre qui ne cesse de croître mérite un regard sur leur kind et leur last_error.

Le schéma de la base est interne et peut changer d’une version à l’autre : vérifiez ces requêtes après une mise à jour.

Les échecs de workflows ne relèvent pas de l’exploitation : les membres les voient sur la page Activité et dans leurs notifications. Voir Erreurs et nouvelles tentatives.

Le dimensionnement ​

Les tests de charge du chemin d’ingestion montrent qu’un seul processus applicatif absorbe bien plus de mails entrants que n’en reçoit une organisation de cent boîtes : les workers de fond ne sont pas la première limite. La mémoire de PostgreSQL l’est.

  • Un message coûte environ 1,7 Ko dans PostgreSQL, index compris. Les corps de mails et les pièces jointes sont dans le stockage objet, de très loin le premier consommateur de disque.
  • L’image PostgreSQL démarre avec shared_buffers=128MB, quelle que soit la mémoire du conteneur. Vers 50 000 messages, les données utiles ne tiennent plus dans ce cache, et la lecture des fils anciens commence à toucher le disque.
Messages en baseTaille de PostgreSQLshared_buffersMémoire du conteneur PostgreSQL
50 000~110 Mo256 Mo2 Go (défaut)
500 000~1,1 Go1 Go4 Go
5 000 000~11 Go4 Go16 Go

Réglez les paramètres de PostgreSQL dans votre fichier Compose, et augmentez POSTGRES_MEMORY en conséquence :

yaml
services:
  postgres:
    command: >
      postgres -c shared_buffers=1GB -c effective_cache_size=3GB
               -c work_mem=16MB -c max_wal_size=4GB

Un max_wal_size plus grand espace les points de contrôle forcés, qui provoquent des écritures lentes ponctuelles pendant une grosse synchronisation initiale.

InstanceBoîtesConfiguration conseillée
Évaluation1 à 5Le fichier Compose de référence tel quel.
Équipe10 à 50shared_buffers=512MB, max_wal_size=4GB, conteneur PostgreSQL à 4 Go. Sauvegarde quotidienne.
Grande instance100 et plusPostgreSQL hors du fichier Compose (service managé ou machine dédiée), processus api et worker séparés, stockage objet externe. Gardez DATABASE_POOL_MAX × nombre de processus sous max_connections.

DATABASE_STATEMENT_TIMEOUT_MS (30 secondes par défaut) annule toute requête qui dure plus longtemps, pour qu’une requête qui dérape ne puisse pas garder une connexion indéfiniment. Les migrations et les sauvegardes n’en héritent pas.

Redémarrages et reprise ​

L’instance est faite pour être arrêtée et tuée. Tout son état vit dans PostgreSQL : un redémarrage ne perd aucun travail.

Arrêt propre. À la réception de SIGTERM ou SIGINT, le processus cesse d’accepter des requêtes, arrête son planificateur, laisse les tâches qu’il tient se terminer pendant 30 secondes au plus, ferme ses connexions à la base, et écrit shutdown complete. Laissez au conteneur au moins 40 secondes pour s’arrêter : voir Le travail en cours pendant une mise à jour.

Crash ou arrêt forcé. Chaque tâche tenue par un processus a un bail de 60 secondes, renouvelé tant qu’elle tourne. Quand un processus meurt, ses baux expirent, et un processus qui fait tourner le travail de fond rend les tâches à la file (expired job leases reclaimed). Une tâche qui échoue sans cesse finit parmi les tâches mortes au lieu de tuer les processus l’un après l’autre.

Démarrage avant la base. Au démarrage, l’application attend PostgreSQL jusqu’à 60 secondes, avec un délai croissant entre les tentatives. Un mot de passe faux, un rôle inconnu ou une base inexistante arrêtent le démarrage tout de suite, avec le message du serveur : attendre n’y changerait rien. Après 60 secondes sans base, le processus s’arrête sur la dernière erreur de PostgreSQL, et Docker le redémarre.

Coupure de la base en cours de route. Un redémarrage, un crash de PostgreSQL ou une coupure réseau n’arrêtent pas l’application :

Pendant la coupureAu retour de la base
/healthz200200
/readyz503, avec l’erreur de PostgreSQL dans detail200 dès la première connexion acceptée, sans redémarrer l’application
Requêtes HTTP qui ont besoin de la baseÉchouent avec une erreurFonctionnent de nouveau
Travail de fondclaim failed et scheduler tick failed, chaque secondeReprend seul ; les tâches interrompues sont rendues à la file à l’expiration de leur bail

Si /readyz reste à 503 alors que PostgreSQL accepte les connexions, lisez son detail : il pointe le plus souvent un problème d’authentification ou de migration. Un conteneur applicatif qui redémarre après chaque coupure de la base n’est pas un comportement attendu.