Quelques pensées

Terres dévastées

Vallée de montagne enneigée entre deux sommets rocheux, sous un ciel bleu
Schéma : redémarrage, OpenBao scellé sans auto-unseal, déploiements bloqués, raisons du choix de Shamir et checklist en cinq étapes

OpenBao sans auto-unseal : pourquoi Shamir, et la checklist après chaque redémarrage

Tous mes playbooks Ansible lisent leurs secrets (mots de passe de bases, clés d’API, jetons) dans OpenBao, le fork libre de HashiCorp Vault. Les secrets ne sont donc écrits ni dans le dépôt Git, ni dans des fichiers chiffrés à faire circuler : Ansible les demande au moment du déploiement, avec une identité AppRole qui n’a que le droit de lecture. Cet article explique pourquoi j’ai choisi de ne pas utiliser l’auto-unseal, ce que ça coûte, et la checklist que je suis après chaque redémarrage.

Schéma : redémarrage, OpenBao scellé sans auto-unseal, déploiements bloqués, raisons du choix de Shamir et checklist en cinq étapes
Pourquoi Shamir sans auto-unseal, et la checklist en cinq étapes après chaque redémarrage.

En un coup d’œil

SujetMon choix
SecretsOpenBao, lu par Ansible via une identité AppRole en lecture seule
DescellementShamir, 5 parts nécessaires sur 5 (t = n = 5), aucune stockée sur le serveur
Auto-unsealÉcarté : pas de dépendance à un fournisseur cloud, un seul serveur
Prix à payerAprès chaque redémarrage, le coffre est scellé et aucun déploiement n’est possible
ProcédureCinq étapes, environ cinq minutes
Prochaine améliorationUne alerte Prometheus sur l’état de scellement

Scellé, descellé : de quoi parle-t-on

OpenBao chiffre tout ce qu’il stocke avec une clé maîtresse. Au démarrage, il ne connaît pas cette clé : il est scellé. Il tourne et répond aux requêtes, mais refuse de lire ou d’écrire le moindre secret. Pour le desceller, il faut reconstituer la clé. Il existe deux grandes approches :

  • Shamir : à l’initialisation, la clé est découpée en n parts, dont t suffisent à la reconstituer. Au démarrage, il faut saisir ces parts à la main (bao operator unseal) ;
  • auto-unseal : la clé est confiée à un service externe (un KMS cloud, un HSM, un autre Vault). OpenBao la récupère tout seul au démarrage.

Mon choix : Shamir, sans auto-unseal

bao operator init -key-shares=5 -key-threshold=5
# Unseal Key 1: …   (×5)
# Initial Root Token: …

Ici, les cinq parts sont nécessaires (t = n = 5), et aucune n’est stockée sur le serveur. Elles sont conservées dans un endroit séparé, avec des copies sur deux autres supports physiques. Ce choix a des raisons précises :

Scellement d'OpenBao : cinq parts Shamir reconstruisent la clé maîtresse en mémoire, qui déchiffre les secrets lus par Ansible
Cinq parts, toutes nécessaires : la clé maîtresse n’existe qu’en mémoire, chaque redémarrage laisse OpenBao scellé.
  • un vol du disque ou d’une sauvegarde ne donne rien : sans les parts, les données d’OpenBao ne sont qu’un bloc chiffré ;
  • pas de dépendance à un fournisseur cloud : un auto-unseal par KMS ferait dépendre tout le parc d’un compte cloud et de sa disponibilité ;
  • un seul serveur : l’auto-unseal se justifie surtout pour des grappes qui redémarrent souvent, ce qui n’est pas mon cas.

Le prix à payer

Après n’importe quel redémarrage (mise à jour du noyau, coupure de courant, plantage), OpenBao repart scellé. Et comme tous les playbooks lisent leurs secrets dedans, aucun déploiement n’est possible sur aucune machine tant que quelqu’un n’a pas saisi les parts. Le symptôme côté Ansible est explicite :

fatal: … Vault is sealed

C’est un compromis assumé : un peu d’intervention humaine après un redémarrage, en échange d’un coffre qui ne s’ouvre jamais tout seul. Il faut juste ne pas l’oublier, et c’est l’objet de la checklist ci-dessous.

La checklist après redémarrage

Après chaque redémarrage du serveur qui héberge OpenBao, voici la procédure que je suis, dans l’ordre. Elle tient en cinq minutes.

1. Pointer le client sur le bon serveur

Chez moi, OpenBao écoute en HTTP sur 127.0.0.1, et OpenResty assure le TLS devant. Le client bao essaie HTTPS par défaut, d’où cette erreur trompeuse :

bao status
# Error checking seal status: … http: server gave HTTP response to HTTPS client
export BAO_ADDR=http://127.0.0.1:8200      # en local, sur le serveur
# ou : export BAO_ADDR=https://secrets.example.org   (via OpenResty)

Je mets cet export dans le .bashrc de root sur ce serveur, pour ne plus y penser.

2. Vérifier l’état

bao status
# Sealed          true
# Total Shares    5
# Threshold       5
# Unseal Progress 0/5

Sealed true confirme le besoin de desceller. Unseal Progress montre combien de parts ont déjà été saisies : la saisie peut se faire en plusieurs fois, et même par plusieurs personnes.

3. Desceller

bao operator unseal      # répété 5 fois ; chaque part est demandée sans écho à l'écran

Sans argument, la commande demande la part de façon interactive et ne l’affiche pas. Ne passez jamais une part en argument de la ligne de commande : elle finirait dans l’historique du shell et dans la liste des processus. Et ne la collez jamais dans un outil qui garde un historique, qu’il s’agisse d’une messagerie ou d’un assistant.

4. Vérifier la chaîne complète

Un coffre descellé ne suffit pas : il faut que les déploiements puissent vraiment lire leurs secrets. Je teste donc depuis le nœud de contrôle Ansible, avec la même identité AppRole que les playbooks :

# connexion AppRole, puis lecture des métadonnées d'un secret (pas de sa valeur)
bao write -field=token auth/approle/login role_id=… secret_id=… > /tmp/t
BAO_TOKEN=$(cat /tmp/t) bao kv metadata get -mount=ansible postgresql | head -3
shred -u /tmp/t

Lire les métadonnées (version, dates) prouve que l’authentification et les droits fonctionnent, sans faire transiter la valeur du secret.

5. Reprendre les déploiements

Un ansible-playbook … --check sur n’importe quel playbook qui lit un secret confirme que tout est revenu à la normale.

Ne pas l’oublier

Le plus grand risque, c’est l’oubli : un redémarrage nocturne, et personne ne s’aperçoit que les déploiements sont bloqués avant d’en avoir besoin. Une alerte Prometheus sur l’état de scellement préviendrait dès que ça arrive : le point /v1/sys/health renvoie 503 quand le coffre est scellé, et une sonde blackbox suffit à le surveiller. C’est la prochaine amélioration sur ma liste. Après chaque redémarrage du serveur de la maison, le descellement fait désormais partie de ma checklist, au même titre que la vérification des services.

Ce que j’en retiens

  • Un coffre qui ne s’ouvre pas tout seul est un choix de sécurité, avec un coût d’exploitation que l’on accepte en connaissance de cause ;
  • Ne passez jamais une part de clé en argument ni dans un outil qui garde un historique ;
  • Vérifiez la chaîne complète après le descellement, depuis le nœud Ansible et avec la même identité AppRole que les playbooks ;
  • Surveillez l’état de scellement (/v1/sys/health) : le risque principal est l’oubli.

Articles liés

Schéma : structure logique et fichiers physiques d’Oracle

Dans les fichiers d’une base de données (3/3) : Oracle, et les trois côte à côte

Dernier volet de la série, Oracle. Je ne fais pas tourner Oracle chez moi. Ce billet s’est d’abord appuyé sur mon métier de DBA et sur la documentation d’Oracle ; je l’ai depuis vérifié sur un conteneur Oracle AI Database 26ai Free jetable (voir la fin du billet). Oracle est le plus ancien des trois moteurs, et le plus explicite sur sa structure : il distingue soigneusement la vue logique (tablespaces, segments, extents, blocs) des fichiers physiques, et il expose presque tout dans des vues du dictionnaire.

Structure logique et fichiers physiques

Structure logique et physique d'une base Oracle
Côté logique, la hiérarchie tablespace, segment, extent, bloc ; côté physique, les datafiles.

Une base Oracle se compose de plusieurs familles de fichiers :

  • les datafiles contiennent les données. Un tablespace regroupe un ou plusieurs datafiles, et un datafile n’appartient qu’à un seul tablespace ;
  • les control files sont la carte de la base : la liste des datafiles et des journaux, le SCN (le numéro de version global de la base) du dernier checkpoint, l’historique des sauvegardes RMAN. On les multiplexe sur plusieurs disques, car sans eux la base ne démarre pas ;
  • les online redo logs forment un journal circulaire, organisé en groupes de fichiers identiques (membres). Quand un groupe est plein, Oracle passe au suivant (log switch). En mode ARCHIVELOG, chaque groupe plein est copié en archived log avant d’être réutilisé : c’est ce qui permet une restauration à un instant précis ;
  • le tablespace d’undo garde les images d’avant des données modifiées ;
  • les tempfiles servent aux tris et aux jointures qui ne tiennent pas en mémoire ; ils ne sont pas journalisés ni sauvegardés ;
  • le spfile contient les paramètres de l’instance, et le fichier de mots de passe permet les connexions administrateur à distance.
SELECT tablespace_name, file_name, bytes/1024/1024 AS mb, autoextensible FROM dba_data_files;
SELECT group#, members, bytes/1024/1024 AS mb, status FROM v$log;
SELECT name FROM v$controlfile;

Côté logique, un segment correspond à un objet qui occupe de la place : une table, un index, une partition, un LOB. Un segment est composé d’extents, chacun formé de blocs contigus dans un seul datafile. Un segment peut donc s’étaler sur plusieurs fichiers, extent par extent. Enfin, le bloc est l’unité d’entrée-sortie, 8 Ko par défaut (DB_BLOCK_SIZE, fixé à la création de la base).

SELECT segment_name, segment_type, extents, blocks FROM dba_segments WHERE owner = 'SHOP';
SELECT extent_id, file_id, block_id, blocks FROM dba_extents
WHERE owner = 'SHOP' AND segment_name = 'ORDERS' ORDER BY extent_id;

Dans un datafile Oracle

Organisation d'un datafile et d'un segment ASSM
En-tête de fichier, bitmap des extents, blocs bitmap ASSM, en-tête de segment, blocs de données et High Water Mark.

Le début de chaque datafile contient son en-tête (identité du fichier, SCN du dernier checkpoint, état de sauvegarde), puis, dans un tablespace géré localement (le cas normal aujourd’hui), un bitmap qui indique quels extents sont occupés. Avant, cette information vivait dans des tables du dictionnaire, ce qui créait de la contention lors des allocations. Deux politiques d’allocation existent : UNIFORM (tous les extents ont la même taille) et AUTOALLOCATE, où Oracle choisit des extents de plus en plus grands à mesure que le segment grossit (64 Ko, puis 1 Mo, puis 8 Mo… ; dans le test en fin de billet, un extent de 5 Mo est aussi apparu entre ceux de 1 Mo et de 8 Mo).

Dans un segment en ASSM (Automatic Segment Space Management, le défaut), des blocs bitmap à trois niveaux décrivent le remplissage de chaque bloc de données, par tranches de 25 %. Une session qui insère cherche un bloc assez libre dans ces bitmaps, et des sessions différentes sont orientées vers des blocs différents, ce qui évite qu’elles se battent pour le même bloc. L’en-tête de segment garde la liste des extents et la High Water Mark (HWM), la limite des blocs déjà formatés. Un parcours complet de la table lit tous les blocs jusqu’à la HWM, même vides : après un gros DELETE, un ALTER TABLE orders SHRINK SPACE (ou MOVE) fait redescendre la HWM.

Anatomie d’un bloc Oracle

Structure d'un bloc de données Oracle de 8 Ko
En-tête cache, ITL, répertoires, espace libre, lignes et queue.
  • L’en-tête cache (20 octets) identifie le bloc : type, adresse (le RDBA, numéro de fichier et de bloc), SCN de la dernière modification, somme de contrôle. Avec DB_BLOCK_CHECKSUM=TYPICAL, la somme est vérifiée à chaque lecture depuis le disque ;
  • l’ITL (Interested Transaction List) est propre à Oracle : une entrée de 24 octets par transaction qui modifie le bloc, avec son identifiant (XID), l’adresse de son undo et son état. INITRANS fixe le nombre d’entrées réservées à la création du bloc. Si toutes sont prises et que le bloc n’a plus de place pour en ajouter, une nouvelle transaction attend : c’est l’attente enq: TX - allocate ITL entry, qu’on corrige en augmentant INITRANS sur les tables très concurrentes ;
  • le répertoire des lignes donne le décalage de chaque ligne dans le bloc, deux octets par ligne. Un ROWID désigne une entrée de ce répertoire, ce qui permet, comme chez PostgreSQL, de déplacer une ligne dans le bloc sans changer son adresse ;
  • PCTFREE (10 % par défaut) réserve de l’espace libre pour les UPDATE qui allongent les lignes. Sans cette réserve, une ligne qui grandit ne tient plus dans son bloc et doit migrer : elle part dans un autre bloc et laisse à sa place un pointeur. Chaque lecture par ROWID coûte alors deux blocs. Une ligne trop grande pour n’importe quel bloc est chaînée sur plusieurs blocs. La statistique table fetch continued row mesure les deux ;
  • la queue (4 octets) répète une partie du SCN, du type et de la séquence de l’en-tête : un bloc dont la tête et la queue ne correspondent pas a été écrit à moitié (fractured block). Oracle n’a pas de doublewrite : ce cas est détecté à la lecture, et le bloc se répare depuis une sauvegarde RMAN et les journaux (RECOVER ... BLOCK). Pendant une sauvegarde à chaud manuelle (BEGIN BACKUP), Oracle écrit l’image complète des blocs dans le redo à leur première modification, comme le full_page_writes de PostgreSQL.

Une ligne et son ROWID

Format d'une ligne Oracle et structure du ROWID
Une ligne : en-tête de 3 octets puis longueur et valeur de chaque colonne ; le ROWID en quatre parties.

Une ligne commence par trois octets : des indicateurs, l’octet de verrou et le nombre de colonnes. Viennent ensuite, pour chaque colonne, sa longueur et sa valeur. Les NULL au milieu de la ligne coûtent un octet (0xFF), et les NULL en fin de ligne ne sont pas stockés du tout. Placer les colonnes souvent nulles en dernier économise donc de la place.

L’octet de verrou est le détail le plus élégant du format : quand une transaction modifie une ligne, elle y inscrit le numéro de son entrée dans l’ITL du bloc. Le verrou de ligne est donc stocké dans le bloc lui-même, sans table de verrous en mémoire : verrouiller un million de lignes ne coûte pas plus de mémoire que d’en verrouiller une. Une autre transaction qui veut la même ligne lit l’ITL, trouve la transaction propriétaire et attend la fin de celle-ci. Après le COMMIT, l’octet de verrou n’est pas remis à zéro tout de suite : il continue de pointer vers l’entrée ITL jusqu’à ce qu’une visite ultérieure nettoie le bloc (voir le test en fin de billet).

Le ROWID est l’adresse physique d’une ligne : numéro d’objet, numéro de fichier relatif au tablespace (0 dans un tablespace bigfile, le type par défaut sur Oracle 26ai Free), numéro de bloc, numéro de ligne dans le répertoire. C’est l’accès le plus rapide qui soit, et c’est ce que contiennent les index B-tree. Le paquet DBMS_ROWID le décode :

SELECT rowid,
       dbms_rowid.rowid_object(rowid)       AS obj,
       dbms_rowid.rowid_relative_fno(rowid) AS file#,
       dbms_rowid.rowid_block_number(rowid) AS block#,
       dbms_rowid.rowid_row_number(rowid)   AS row#
FROM shop.orders WHERE id = 7;

Pour voir le contenu brut d’un bloc, ALTER SYSTEM DUMP DATAFILE 4 BLOCK 151; écrit sa description complète (en-têtes, ITL, chaque ligne en hexadécimal) dans le fichier de trace de la session. À réserver à une base de test : c’est très instructif, mais ce n’est pas un outil de production.

Lecture cohérente : l’undo

Comme InnoDB, Oracle modifie les blocs sur place et range l’image d’avant dans l’undo. Quand une requête démarre, elle note le SCN courant. Si elle tombe sur un bloc modifié après ce SCN, elle en fabrique une copie en mémoire et lui applique à rebours les enregistrements d’undo désignés par l’ITL, jusqu’à obtenir le bloc tel qu’il était à son SCN de départ : c’est le consistent read. Les lecteurs ne bloquent jamais les écrivains, ni l’inverse.

Le revers : si l’undo nécessaire a déjà été écrasé par des transactions plus récentes, la requête échoue avec ORA-01555: snapshot too old. UNDO_RETENTION indique combien de temps Oracle doit essayer de garder l’undo, à condition que le tablespace d’undo soit assez grand. Autre phénomène, cousin des hint bits de PostgreSQL : le delayed block cleanout. Après un gros COMMIT, Oracle ne revisite pas tous les blocs modifiés, et c’est le premier SELECT suivant qui nettoie les entrées ITL… et génère du redo, à la surprise générale.

Les trois moteurs côte à côte

Où vivent les anciennes versions dans InnoDB, PostgreSQL et Oracle
Trois réponses à la même question.
InnoDBPostgreSQLOracle
Taille de page16 Ko8 Ko8 Ko (2 à 32 Ko)
Organisation de la tablearbre B+ de la clé primairetas (heap)tas (heap) par défaut, IOT en option
Adresse d’une ligne dans un indexclé primaire (index secondaires)ctid (page, slot)ROWID (objet, fichier, bloc, ligne)
Anciennes versionsundo logdans la tabletablespace d’undo
Nettoyagepurge threadVACUUMréutilisation de l’undo
Pages déchiréesdoublewrite bufferfull_page_writes (WAL)contrôle du bloc + restauration RMAN
Verrous de ligneen mémoire (lock system)xmax dans le tupleoctet de verrou → ITL du bloc

Aucun de ces choix n’est meilleur dans l’absolu. L’index cluster d’InnoDB rend les recherches par clé primaire imbattables mais pénalise les clés aléatoires. Le tas de PostgreSQL rend les mises à jour simples et les retours en arrière instantanés, au prix de VACUUM. L’undo séparé d’Oracle et d’InnoDB garde les tables compactes, mais impose de dimensionner l’undo. Savoir comment les données sont rangées sur le disque, c’est savoir lequel de ces compromis on paie, et à quel moment.

Ce que j’ai mesuré (mise à jour du 2026-11-04)

J’ai d’abord écrit ce billet à partir de la documentation. Je l’ai ensuite testé sur Oracle AI Database 26ai Free 23.26.3.0.0, l’image container-registry.oracle.com/database/free lancée avec podman 5.8.2 sur Rocky Linux 10.2, WSL2, dans la base pluggable FREEPDB1, avec un utilisateur et un tablespace jetables. C’est une édition Free, pas Enterprise, et pas une 19c : les comportements ci-dessous sont ceux de cette version. Les tests ont corrigé deux points plus haut (le numéro de fichier du ROWID et la cellule du tableau pour InnoDB) et ajouté deux précisions (l’extent de 5 Mo, et l’octet de verrou après un commit).

Fichiers et structure logique. Trois groupes de redo de 200 Mo avec un seul membre, deux fichiers de contrôle, le mode ARCHIVELOG, un bloc de 8 Ko, DB_BLOCK_CHECKSUM = TYPICAL. Le tablespace d’undo et les datafiles sont dans la PDB, les redo logs et les fichiers de contrôle dans la base conteneur. Le type de tablespace par défaut est BIGFILE :

BANNER_FULL
------------------------------------------------------------------------------------------
Oracle AI Database 26ai Free Release 23.26.3.0.0 - Develop, Learn, and Run for Free
Version 23.26.3.0.0
NAME	  CDB	  CON_ID
--------- --- ----------
FREE	  YES	       0
### in the PDB FREEPDB1
TABLESPACE_N FILE_NAME									    MB AUT
------------ ---------------------------------------------------------------------- ---------- ---
SYSTEM	     /opt/oracle/oradata/FREE/FREEPDB1/system01.dbf				   290 YES
SYSAUX	     /opt/oracle/oradata/FREE/FREEPDB1/sysaux01.dbf				   420 YES
UNDOTBS1     /opt/oracle/oradata/FREE/FREEPDB1/undotbs01.dbf				  2690 YES
USERS	     /opt/oracle/oradata/FREE/FREEPDB1/users01.dbf				     7 YES
SHOP_TS      /opt/oracle/oradata/FREE/FREEPDB1/shop_ts01.dbf				   900 YES
DB_BLOCK_SIZE
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
8192
    GROUP#    MEMBERS	      MB STATUS
---------- ---------- ---------- ----------------
	 1	    1	     200 CURRENT
	 2	    1	     200 ACTIVE
	 3	    1	     200 ACTIVE
NAME
----------------------------------------------------------------------
/opt/oracle/oradata/FREE/control01.ctl
/opt/oracle/oradata/FREE/control02.ctl
TABLESPACE_N SEGMEN ALLOCATIO BLOCK_SIZE
------------ ------ --------- ----------
SYSTEM	     MANUAL SYSTEM	    8192
SYSAUX	     AUTO   SYSTEM	    8192
UNDOTBS1     MANUAL SYSTEM	    8192
TEMP	     MANUAL UNIFORM	    8192
USERS	     AUTO   SYSTEM	    8192
SHOP_TS      AUTO   SYSTEM	    8192
NAME								       VALUE
---------------------------------------------------------------------- ------------------------------------------------------------
spfile								       /opt/oracle/product/26ai/dbhomeFree/dbs/spfileFREE.ora
db_block_checksum						       TYPICAL
db_block_size							       8192
undo_tablespace 						       UNDOTBS1
undo_retention							       900
LOG_MODE
------------
ARCHIVELOG
TABLESPACE_N BIG
------------ ---
SYSTEM	     YES
SYSAUX	     YES
UNDOTBS1     YES
TEMP	     NO
USERS	     YES
SHOP_TS      YES
DEFAULT_TABLESPACE_TYPE
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
BIGFILE
FILE_NAME								       MB
---------------------------------------------------------------------- ----------
/opt/oracle/oradata/FREE/FREEPDB1/temp01.dbf				       20
USERNAME   SYSDB
---------- -----
SYS	   TRUE
### redo logs and control files live in the CDB, not in the PDB
    GROUP# MEMBER
---------- ----------------------------------------------------------------------
	 3 /opt/oracle/oradata/FREE/redo03.log
	 2 /opt/oracle/oradata/FREE/redo02.log
	 1 /opt/oracle/oradata/FREE/redo01.log

La croissance des extents. 3 millions de lignes dans un nouveau tablespace AUTOALLOCATE (ALLOCATION_TYPE = SYSTEM, ASSM) : 16 extents de 64 Ko, puis 63 de 1 Mo, un de 5 Mo, puis des extents de 8 Mo :

TABLESPACE_NAME 	       SEGMEN ALLOCATIO
------------------------------ ------ ---------
SHOP_TS 		       AUTO   SYSTEM
SEGMENT_NAME															 SEGMENT_TYPE	       EXTENTS	   BLOCKS	  MB
-------------------------------------------------------------------------------------------------------------------------------- ------------------ ---------- ---------- ----------
ORDERS																 TABLE			    88	    17024	 133
 EXTENT_ID    FILE_ID	BLOCK_ID     BLOCKS
---------- ---------- ---------- ----------
	 0	   17	     776	  8
	 1	   17	     792	  8
	 2	   17	     808	  8
 EXTENT_KB    EXTENTS FIRST_EXTENT LAST_EXTENT
---------- ---------- ------------ -----------
	64	   16		 0	    15
      1024	   63		16	    78
      5120	    1		79	    79
      8192	    8		80	    87

Le high water mark. Après avoir supprimé toutes les lignes d’une table de 500 000 lignes, le segment garde ses 7 936 blocs et un parcours complet de la table vide coûte 7 740 consistent gets. Après SHRINK SPACE, le segment fait 8 blocs et le parcours en coûte 2 :

WHAT				       BLOCKS	      MB
---------------------------------- ---------- ----------
after load				 7936	      62
WHAT				       BLOCKS	      MB
---------------------------------- ---------- ----------
after DELETE of all rows		 7936	      62
NAME				    VALUE
------------------------------ ----------
consistent gets 		    35822
 ROWS_LEFT
----------
	 0
NAME				    VALUE
------------------------------ ----------
consistent gets 		    43562
WHAT				       BLOCKS	      MB
---------------------------------- ---------- ----------
after SHRINK SPACE			    8	   .0625
NAME				    VALUE
------------------------------ ----------
consistent gets 		    44895
 ROWS_LEFT
----------
	 0
NAME				    VALUE
------------------------------ ----------
consistent gets 		    44897

Un ROWID décodé avec DBMS_ROWID. Le numéro de fichier vaut 0 parce que le tablespace est de type bigfile ; le numéro de bloc 1302 est celui du bloc vidé plus bas (identifiant de fichier absolu 17) :

ROWID			     ID        OBJ	FILE#	  BLOCK#       ROW#
-------------------- ---------- ---------- ---------- ---------- ----------
AAAR2tAAAAAAAUWAAA	      1      73133	    0	    1302	  0
AAAR2tAAAAAAAUWAAB	      2      73133	    0	    1302	  1
AAAR2tAAAAAAAUWAAC	      3      73133	    0	    1302	  2

ALTER SYSTEM DUMP DATAFILE 17 BLOCK 1302 juste après les insertions et le commit, abrégé aux lignes utiles. Le bloc a 2 entrées ITL (itc: 2). Chaque ligne commence par tl (longueur totale), fb (indicateurs), lb (l’octet de verrou) et cc (le nombre de colonnes). La ligne d’id 2 (tab 0, row 1) a un NULL au milieu (*NULL*) et un NULL final qui n’est pas stocké (cc: 4 pour 5 colonnes) ; la ligne d’id 3 (tab 0, row 2) ne stocke que 2 colonnes. Après le commit, les trois lignes portent encore lb: 0x1 et l’entrée ITL affiche toujours Lck 3 : le commit ne nettoie pas le bloc :

  frmt: 0x02 chkval: 0xa025 type: 0x06=trans data
Block header dump:  0x00000516
 Object id on Block? Y
 seg/obj: 0x11dad  csc:  0x00000000002faa20  itc: 2  flg: E-  typ: 1 - DATA
 Itl           Xid                  Uba         Flag  Lck        Scn/Fsc
0x01   0x0003.01f.000002c5  0x0000af26.0159.25  --U-    3  fsc 0x0000.002faa22
0x02   0x0000.000.00000000  0x00000000.0000.00  ----    0  fsc 0x0000.00000000
bdba: 0x00000516 type: 0x06 SAGE: 0
nrow=3
frre=-1
fsbo=0x18
fseo=0x1f68
avsp=0x1f4f
tosp=0x1f4f
0xe:pti[0]	nrow=3	offs=0
tab 0, row 0, @0x1f7b
tl: 29 fb: --H-FL-- lb: 0x1  cc: 5
col  0: [ 2]  c1 02
col  1: [ 5]  61 6c 70 68 61
col  2: [ 4]  62 65 74 61
col  3: [ 5]  67 61 6d 6d 61
col  4: [ 5]  64 65 6c 74 61
tab 0, row 1, @0x1f70
tl: 11 fb: --H-FL-- lb: 0x1  cc: 4
col  0: [ 2]  c1 03
col  1: [ 1]  78
col  2: *NULL*
col  3: [ 1]  7a
tab 0, row 2, @0x1f68
tl: 8 fb: --H-FL-- lb: 0x1  cc: 2
col  0: [ 2]  c1 04
col  1: [ 1]  78

Le même bloc pendant qu’une autre session détient un UPDATE non validé de la ligne d’id 1 (tab 0, row 0). La ligne modifiée porte maintenant lb: 0x2, qui désigne l’entrée ITL 2 (Lck 1, un nouvel identifiant de transaction) ; l’entrée 1 a été nettoyée (C---, Lck 0) et les autres lignes ont lb: 0x0. La ligne a grossi de 29 à 31 octets et s’est déplacée dans le bloc :

 Itl           Xid                  Uba         Flag  Lck        Scn/Fsc
0x01   0x0003.01f.000002c5  0x0000af26.0159.25  C---    0  scn  0x00000000002faa22
0x02   0x0005.00b.0000029d  0x00000184.013f.0b  ----    1  fsc 0x0000.00000000
tab 0, row 0, @0x1f49
tl: 31 fb: --H-FL-- lb: 0x2  cc: 5
tab 0, row 1, @0x1f70
tl: 11 fb: --H-FL-- lb: 0x0  cc: 4
tab 0, row 2, @0x1f68
tl: 8 fb: --H-FL-- lb: 0x0  cc: 2

Verrous de ligne et lignes migrées. Modifier 1 ligne ou 500 000 lignes prend les deux mêmes verrous dans V$LOCK (un TX, un TM) ; ce qui grossit, c’est l’undo (1 enregistrement, puis 500 000 enregistrements dans 5 815 blocs d’undo). Verrouiller un million de lignes ne coûte donc pas de mémoire dans une table de verrous, mais coûte de l’undo. Pour les lignes migrées, 100 000 lignes ont été insérées avec PCTFREE 0 puis allongées de 1 à 300 caractères : les relire a enregistré 99 733 événements table fetch continued row (des lignes qu’il a fallu suivre vers un autre bloc) :

### locking 1 row versus 500000 rows: v$lock and undo
WHAT		       TY      LOCKS
---------------------- -- ----------
after 1 row	       TM	   1
after 1 row	       TX	   1
WHAT			USED_UBLK  USED_UREC
---------------------- ---------- ----------
after 1 row			1	   1
WHAT		       TY      LOCKS
---------------------- -- ----------
after 500000 rows      TM	   1
after 500000 rows      TX	   1
WHAT			USED_UBLK  USED_UREC
---------------------- ---------- ----------
after 500000 rows	     5815     500000
### migrated rows: PCTFREE 0, rows that grow
TABLE_NAME															   NUM_ROWS	BLOCKS	CHAIN_CNT
-------------------------------------------------------------------------------------------------------------------------------- ---------- ---------- ----------
MIG																     100000	  4528		0
  COUNT(*)
----------
    100000
STAT				       DELTA
--------------------------------- ----------
table fetch continued row (delta)      99733

La lecture cohérente. Une transaction en lecture seule continue de voir la valeur 10 après qu’une autre session a validé 20 ; les statistiques montrent un enregistrement d’undo appliqué :

WHO			V
-------------- ----------
reader, before	       10
WHO			   V
----------------- ----------
writer, committed	  20
WHO				  V
------------------------ ----------
reader, after the commit	 10
NAME								  VALUE
------------------------------------------------------------ ----------
consistent changes						      1
data blocks consistent reads - undo records applied		      1

Le nettoyage différé des blocs. Après avoir modifié et validé 2 000 000 de lignes, un simple SELECT COUNT(*) a généré environ 5 Mo de redo et 57 046 « cleanouts only – consistent read gets » :

  COUNT(*)
----------
   2000000
STAT				       DELTA
--------------------------------- ----------
redo size generated by the SELECT    5020444
STAT					   DELTA
------------------------------------- ----------
cleanouts only - consistent read gets	   57046

L’attente ITL. Une table de 3 000 lignes d’un caractère, créée avec PCTFREE 0, remplit complètement ses blocs : le bloc de la ligne 1500 contient 733 lignes et il lui reste 7 octets (avsp=0x7), avec ses 2 entrées ITL. Deux sessions modifient chacune une ligne différente de ce bloc et restent sans valider. Une troisième session qui modifie une troisième ligne du même bloc ne peut pas obtenir d’entrée ITL, faute de place pour en ajouter : elle attend sur enq: TX - allocate ITL entry, bloquée par l’une des autres sessions, jusqu’à leur annulation (14,7 secondes ici) :

block 38701, rows with ids 1467, 1468 and 1469 are used
 seg/obj: 0x11de1  csc:  0x000000000040b7b1  itc: 2  flg: E-  typ: 1 - DATA
 Itl           Xid                  Uba         Flag  Lck        Scn/Fsc
0x01   0x0008.005.0000026d  0x0000b5a0.00d7.04  --U-  733  fsc 0x0000.0040b7b3
0x02   0x0000.000.00000000  0x00000000.0000.00  ----    0  fsc 0x0000.00000000
nrow=733
avsp=0x7
       SID USERNAME EVENT				     SECONDS_IN_WAIT BLOCKING_SESSION
---------- -------- ---------------------------------------- --------------- ----------------
       227 SHOP     enq: TX - allocate ITL entry			   2		   58
Elapsed: 00:00:14.73

ORA-01555. Une table de 300 000 lignes est chargée normalement, puis le tablespace d’undo est remplacé par un tablespace de 10 Mo qui ne peut pas grandir, avec UNDO_RETENTION = 5. Un lecteur parcourt la table par son index dans une boucle PL/SQL et dort toutes les 20 000 lignes ; pendant ce temps, un écrivain valide 240 petites mises à jour (8 passes sur la table, 10 000 lignes par commit). Le lecteur échoue après 79 971 lignes parce que l’undo dont il avait besoin a été écrasé. Mon premier essai n’a pas échoué, sans doute parce que l’optimiseur avait lu et trié toutes les lignes dès le premier fetch, si bien que le lecteur n’avait plus besoin d’anciens blocs (je n’ai pas regardé le plan). Forcer un parcours d’index avec un hint a fait apparaître l’erreur :

undo_tablespace      UNDO_SMALL
undo_retention	     5
reader: failed after 79971 rows: ORA-01555: snapshot too old: rollback segment
number	with name "" too small
writer: 8 passes of 30 commits done

Non testé : la vérification de la queue de bloc et la réparation d’un bloc fracturé avec RMAN, les images de bloc complètes écrites pendant une sauvegarde à chaud, les tailles d’une entrée ITL (24 octets) et d’une entrée du répertoire des lignes (2 octets), l’octet unique d’un NULL au milieu d’une ligne, les tables organisées en index, et toute édition ou version autre qu’Oracle AI Database 26ai Free.


Ce billet fait partie d’une série de notes d’administration système (tag « carnet d’admin »).
Série « Dans les fichiers d’une base de données », partie 3/3. · ← Billet précédent · English version

Articles liés

Schéma : le répertoire de données de PostgreSQL (PGDATA)

Dans les fichiers d’une base de données (2/3) : PostgreSQL

Après InnoDB, PostgreSQL. Les deux moteurs résolvent les mêmes problèmes (stocker des lignes, les retrouver vite, survivre à un crash, laisser lire pendant qu’on écrit) mais ont fait des choix presque opposés. Les connaître explique le besoin de VACUUM, le gonflement des tables (bloat), ou pourquoi le premier SELECT après un gros chargement écrit sur le disque.

Le répertoire de données

Arborescence du répertoire PGDATA
PGDATA : un répertoire par base, un fichier par table ou index, nommés par numéro.

Premier réflexe à perdre : chercher ses tables par leur nom. PostgreSQL désigne tout par des numéros. Chaque base a un OID, et c’est le nom de son répertoire sous base/. Chaque table, index ou séquence a un relfilenode, et c’est le nom de son fichier. Ce numéro change quand la table est réécrite (TRUNCATE, VACUUM FULL, CLUSTER, certains ALTER TABLE) : PostgreSQL crée un nouveau fichier et bascule, ce qui rend ces opérations sûres en cas de crash.

SELECT oid, datname FROM pg_database;
SELECT pg_relation_filepath('orders');        -- base/16384/24576
SELECT pg_size_pretty(pg_total_relation_size('orders'));   -- table + index + TOAST
  • global/ contient les catalogues partagés par toutes les bases (rôles, liste des bases) et pg_control, le petit fichier qui dit où reprendre après un crash ;
  • pg_wal/ est le journal d’écriture anticipée (WAL), en segments de 16 Mo : l’équivalent du redo d’InnoDB ;
  • pg_xact/ garde l’état de chaque transaction sur deux bits : en cours, validée, annulée. C’est lui qui permet de savoir si une version de ligne est visible ;
  • pg_tblspc/ contient des liens symboliques vers les tablespaces créés ailleurs. Chez PostgreSQL, un tablespace n’est qu’un répertoire sur un autre disque.

Une relation : des forks et des segments

Les forks d'une relation PostgreSQL
Le main fork contient les données ; fsm et vm sont des fichiers auxiliaires.

Chaque table est en réalité un petit ensemble de fichiers. Le main fork contient les données, découpé en fichiers de 1 Go au plus (24576, 24576.1…). Cette limite date d’une époque où tous les systèmes de fichiers ne géraient pas les gros fichiers, et elle simplifie toujours la sauvegarde et la copie. La free space map indique approximativement la place libre de chaque page, pour qu’un INSERT trouve vite une page où écrire. La visibility map garde deux bits par page : « toutes les lignes sont visibles par tout le monde » et « toutes les lignes sont gelées ». Elle permet à VACUUM de sauter les pages déjà propres, et aux index-only scans de ne pas lire la table.

Anatomie d’une page PostgreSQL

Structure d'une page PostgreSQL de 8 Ko
Une page de table : en-tête, pointeurs de ligne, espace libre, tuples.

Les pages font 8 Ko (une constante de compilation, BLCKSZ). Leur structure est la même pour les tables et les index :

  • l’en-tête de 24 octets contient le pd_lsn, la position dans le WAL de la dernière modification. PostgreSQL l’utilise pour une règle simple : une page ne peut pas être écrite sur le disque avant que le WAL qui la décrit le soit. Il contient aussi la somme de contrôle (activée par défaut à l’initialisation depuis PostgreSQL 18, à demander avec initdb -k avant), et deux décalages, pd_lower et pd_upper, qui bornent l’espace libre ;
  • les line pointers forment un tableau en début de page. Chacun contient le décalage et la longueur d’un tuple. L’adresse d’une ligne, son ctid, vaut (numéro de page, numéro de pointeur), par exemple (0,2). Cette indirection permet de déplacer un tuple dans la page pour la défragmenter sans changer son adresse, donc sans toucher aux index ;
  • les tuples sont écrits depuis la fin de la page, vers le haut. Quand pd_lower (fin des pointeurs) rejoint pd_upper (début des tuples), la page est pleine ;
  • l’espace spécial en fin de page est vide pour une table. Les index B-tree y rangent les liens vers les pages voisines et le niveau dans l’arbre.

On peut tout regarder avec l’extension pageinspect, sur une base de test :

CREATE EXTENSION pageinspect;
SELECT lower, upper, special, pagesize FROM page_header(get_raw_page('orders', 0));
SELECT lp, lp_off, lp_len, t_xmin, t_xmax, t_ctid
FROM heap_page_items(get_raw_page('orders', 0));

Un tuple

En-tête d'un tuple PostgreSQL
Les 23 octets d’en-tête qui précèdent chaque version de ligne.

Chaque version de ligne commence par 23 octets d’en-tête, arrondis à 24 par l’alignement. Les champs essentiels sont t_xmin (la transaction qui a créé cette version), t_xmax (celle qui l’a supprimée ou remplacée) et t_ctid (l’adresse de la version suivante). Trois détails pratiques :

  • l’alignement : chaque colonne est alignée selon son type (8 octets pour bigint et timestamp). Une colonne boolean suivie d’un bigint laisse 7 octets perdus. Ordonner les colonnes de la plus grande à la plus petite peut réduire une grosse table jusqu’à environ 20 % (19 % sur une table de type commandes dans mon test, voir plus bas) ;
  • les NULL ne prennent qu’un bit dans le bitmap, présent seulement si la ligne contient au moins un NULL ;
  • TOAST : quand une ligne dépasse environ 2 Ko, PostgreSQL compresse d’abord les grandes valeurs (pglz, ou lz4 si on le demande), puis, si ça ne suffit pas, les déplace dans une table annexe, pg_toast.pg_toast_<oid>, découpées en morceaux. La ligne ne garde qu’un pointeur de 18 octets. C’est transparent, mais un SELECT * qui lit une colonne TOAST va chercher ces morceaux : ne sélectionnez que les colonnes utiles.

Un UPDATE PostgreSQL ne modifie rien sur place

Un UPDATE PostgreSQL crée une nouvelle version
L’ancienne version reste dans la page, marquée par son xmax, jusqu’au VACUUM.

C’est la différence fondamentale avec InnoDB et Oracle. Chez PostgreSQL, un UPDATE est un DELETE suivi d’un INSERT : la transaction écrit une nouvelle version complète de la ligne, et inscrit son numéro dans le xmax de l’ancienne. Il n’y a pas de journal d’undo. Les anciennes versions sont l’undo, et elles vivent dans la table.

Pour décider si une version est visible, une transaction compare xmin et xmax à son instantané (snapshot) : la version est visible si sa création est validée et antérieure à l’instantané, et si sa suppression n’a pas eu lieu, a été annulée ou est postérieure. Consulter pg_xact à chaque fois coûterait cher. La première transaction qui lit une version note donc le résultat dans des hint bits de l’en-tête (« xmin validé », « xmax annulé »…). Cela explique un phénomène qui surprend souvent : le premier SELECT après un gros chargement écrit sur le disque, parce qu’il pose ces bits sur chaque page.

Ce que ça implique : VACUUM, HOT et le gel

  • VACUUM retire les versions que plus aucune transaction ne peut voir, rend leur place réutilisable (la free space map est mise à jour) et met à jour la visibility map. Il ne rend pas l’espace au système de fichiers, sauf pour les pages vides en fin de fichier. Un VACUUM simple ne réécrit jamais la table. VACUUM FULL, CLUSTER et l’extension pg_repack (sans verrou long) le font, tout comme TRUNCATE et certaines formes d’ALTER TABLE, comme le changement de type d’une colonne ;
  • une transaction ouverte trop longtemps bloque le nettoyage de toutes les versions plus récentes qu’elle, dans toute la base. C’est la première chose à vérifier quand une table gonfle : SELECT pid, xact_start, state FROM pg_stat_activity ORDER BY xact_start; ;
  • les mises à jour HOT (Heap-Only Tuple) : si aucune colonne indexée ne change et que la nouvelle version tient dans la même page, PostgreSQL ne touche pas aux index. L’index pointe toujours vers l’ancien pointeur, qui mène à la nouvelle version par la chaîne de ctid. Laisser de la place dans les pages augmente les chances de HOT : ALTER TABLE orders SET (fillfactor = 90); ;
  • le gel (freezing) : les identifiants de transaction tiennent sur 32 bits et finissent par boucler : la limite dure est 231, soit environ 2,1 milliards de transactions, mais autovacuum lance le travail anti-bouclage bien plus tôt, à autovacuum_freeze_max_age (200 millions par défaut). Pour qu’une très vieille ligne ne paraisse pas soudain « dans le futur », VACUUM la marque comme gelée, visible par tous. Si autovacuum prend trop de retard, PostgreSQL finit par refuser les écritures pour se protéger : c’est pourquoi on ne désactive jamais autovacuum.

Les index et le WAL

Un index B-tree utilise les mêmes pages de 8 Ko. Ses entrées contiennent la clé et le ctid du tuple, c’est-à-dire une adresse physique, contrairement à InnoDB qui stocke la clé primaire. Une recherche par index est donc une seule descente plus une lecture de page. Mais l’index ne sait rien de la visibilité : il faut lire la table pour savoir si la version trouvée est visible, sauf si la visibility map dit que la page est entièrement visible. C’est ce qui rend les index-only scans efficaces sur une table bien nettoyée.

Enfin, la protection contre les pages déchirées : le problème est le même que chez InnoDB (8 Ko écrits par un disque qui garantit 4 Ko), la solution est différente. Avec full_page_writes = on, la première modification d’une page après chaque checkpoint écrit l’image complète de la page dans le WAL. Après un crash, PostgreSQL repart de cette image intacte. C’est l’équivalent du doublewrite buffer, et la raison pour laquelle le volume de WAL grimpe juste après chaque checkpoint.

Dernier billet de la série : Oracle, qui partage avec InnoDB l’undo séparé, mais pousse la logique plus loin, avec des verrous rangés dans les blocs eux-mêmes.

Ce que j’ai mesuré (mise à jour du 2026-11-04)

J’ai d’abord écrit ce billet à partir de la documentation. Je l’ai ensuite testé sur PostgreSQL 16.15 (le paquet de Rocky Linux 10.2), WSL2, sur un cluster jetable créé avec un simple initdb. Ces tests ont corrigé trois affirmations plus haut (ce qui réécrit une table, le chiffre du bouclage et la fourchette de 10 à 20 %). J’ai aussi testé PostgreSQL 18.6 (paquets PGDG), voir le dernier bloc de cette section : les sommes de contrôle y sont activées par défaut, et désactivées sur la 16.15 après un simple initdb.

Quelles opérations changent le nom du fichier (relfilenode). Un VACUUM simple non ; VACUUM FULL, CLUSTER, ALTER COLUMN TYPE et TRUNCATE oui. ADD COLUMN non, même avec un DEFAULT constant :

start                         : base/16384/16438
after VACUUM                  : base/16384/16438
after VACUUM FULL             : base/16384/16445
after CLUSTER                 : base/16384/16451
after ALTER COLUMN TYPE       : base/16384/16459
after ADD COLUMN (nullable)   : base/16384/16459
after ADD COLUMN DEFAULT 0    : base/16384/16459
after TRUNCATE                : base/16384/16466

Les hint bits. HEAP_XMIN_COMMITTED vaut 0x0100. Il n’est pas positionné après l’INSERT et l’est par le premier SELECT, ce qui explique que la première lecture après un chargement massif écrive :

lp | infomask | xmin_committed 
----+----------+----------------
  1 | 800      | f
  2 | 800      | f
  3 | 800      | f
(3 rows)

-- after SELECT count(*)
 lp | infomask | xmin_committed 
----+----------+----------------
  1 | 900      | t
  2 | 900      | t
  3 | 900      | t
(3 rows)

VACUUM contre VACUUM FULL, et un snapshot resté ouvert. 300 000 lignes, 90 % supprimées. Le VACUUM simple retire 270 000 versions mortes et le fichier garde ses 69 Mo ; VACUUM FULL écrit un nouveau fichier de 7 Mo. Avec une transaction REPEATABLE READ laissée ouverte, 100 000 lignes supprimées sont « dead but not yet removable » ; une fois la transaction terminée, VACUUM les retire et tronque les pages vides en fin de fichier :

-- after loading 300000 rows
   file base/16384/16470: 72286208 bytes (69 MB), dead tuples (stats): 0, live: 300000
-- after DELETE of 90% of the rows
   file base/16384/16470: 72286208 bytes (69 MB), dead tuples (stats): 270000, live: 30000
pages: 0 removed, 8824 remain, 8824 scanned (100.00% of total)
tuples: 270000 removed, 30000 remain, 0 are dead but not yet removable
-- after VACUUM
   file base/16384/16470: 72286208 bytes (69 MB), dead tuples (stats): 0, live: 30000
-- after VACUUM FULL
   file base/16384/16475: 7233536 bytes (7064 kB), dead tuples (stats): 0, live: 30000
-- VACUUM VERBOSE while the old snapshot is open
pages: 0 removed, 3824 remain, 3824 scanned (100.00% of total)
tuples: 0 removed, 130000 remain, 100000 are dead but not yet removable
-- VACUUM VERBOSE after the transaction ended
pages: 2941 removed, 883 remain, 2942 scanned (76.94% of total)
tuples: 100000 removed, 6931 remain, 0 are dead but not yet removable

L’ordre des colonnes. La première paire est une table construite pour perdre du padding (boolean, bigint, boolean, …), la seconde une table de type commandes (boolean, bigint, smallint, timestamptz, …). 1 000 000 de lignes chacune. Le gain dépend du schéma :

-- cas construit pour perdre du padding : col_bad (boolean, bigint, ...) contre col_good (trié)
 relname  |  bytes   | size  
----------+----------+-------
 col_good | 60235776 | 57 MB
 col_bad  | 76562432 | 73 MB
(2 rows)

saving: 21.3 %

-- table de type commandes : col_real_a (ordre par défaut) contre col_real_b (trié)
  relname   | size  |  bytes   
------------+-------+----------
 col_real_a | 80 MB | 84377600
 col_real_b | 65 MB | 68272128
(2 rows)

saving: 19.1 %

Les segments. Une table de 1 482 Mo est découpée en fichiers de 1 Gio :

1073741824 Nov  4 17:25 /tmp/dp/data/base/16384/16525
479920128 Nov  4 17:26 /tmp/dp/data/base/16384/16525.1

TOAST. Une valeur de 960 caractères reste dans la ligne. Des valeurs de 2 240 et 9 600 caractères vont dans la table TOAST, par morceaux d’environ 2 Ko (2 et 5 morceaux) :

chunk_id | chunks | bytes 
----------+--------+-------
    16534 |      2 |  2240
    16535 |      5 |  9600
(2 rows)

 id | chars | stored_bytes 
----+-------+--------------
  1 |   960 |          964
  2 |  2240 |         2240
  3 |  9600 |         9600
(3 rows)

(a value with no chunk in the toast table stayed in the heap tuple)

Les images de page complètes. Juste après un CHECKPOINT, le premier UPDATE d’une page écrit environ 26 Ko de WAL dans ce test, surtout des images de page (les enregistrements dont le total avoisine 8 Ko ; « page image » marque ceux dont le total est très supérieur à l’enregistrement lui-même, donc avec une page jointe). L’UPDATE suivant sur la même page écrit 128 octets, hors enregistrement de commit :

-- first UPDATE (start 0/99272A78, end 0/99279350)
Heap2     PRUNE       record   60 B, total  7456 B   page image
Heap      LOCK        record   59 B, total  8219 B   page image
Heap      UPDATE      record   73 B, total  3589 B   page image
Btree     INSERT_LEAF record   53 B, total  7453 B   page image
-- second UPDATE (start 0/99279350, end 0/99279400)
Heap2     PRUNE       record   57 B, total    57 B
Heap      HOT_UPDATE  record   71 B, total    71 B

PostgreSQL 18.6 (paquets PGDG, Rocky Linux 10.2, WSL2), simple initdb. data_checksums vaut on, initdb --no-data-checksums le désactive et -k est toujours accepté. La somme de contrôle de la page 0 calculée par page_checksum() est égale à celle stockée dans le fichier, et pg_checksums --check ne trouve aucun bloc défectueux. Les opérations qui réécrivent une table sont les mêmes que sur la 16.15 :

 PostgreSQL 18.6 on x86_64-pc-linux-gnu, compiled by gcc (GCC) 14.3.1 20251022 (Red Hat 14.3.1-4), 64-bit
 data_checksums   | on
 server_version   | 18.6
Data page checksum version:           1
Blocks scanned:  3842
Bad checksums:  0
### initdb --no-data-checksums, and initdb -k, in two scratch directories
initdb --no-data-checksums    -> Data page checksum version:           0
initdb -k                     -> Data page checksum version:           1
page_checksum() : 15056
on disk pd_checksum: 15056
start                         : base/16384/16436
after VACUUM                  : base/16384/16436
after VACUUM FULL             : base/16384/16444
after CLUSTER                 : base/16384/16450
after ALTER COLUMN TYPE       : base/16384/16459
after ADD COLUMN (nullable)   : base/16384/16459
after ADD COLUMN DEFAULT 0    : base/16384/16459
after TRUNCATE                : base/16384/16466

Ce billet fait partie d’une série de notes d’administration système (tag « carnet d’admin »).
Série « Dans les fichiers d’une base de données », partie 2/3. · ← Billet précédent · Billet suivant → · English version

Articles liés

Schéma : le répertoire de données de MySQL 8 (datadir)

Dans les fichiers d’une base de données (1/3) : InnoDB, de MySQL à MariaDB

Avec InnoDB comme avec les autres moteurs, une base de données relationnelle donne l’illusion de tables, de lignes et de colonnes. Sur le disque, il n’y a que des fichiers découpés en blocs de taille fixe. Comprendre comment ces blocs sont organisés explique beaucoup de comportements qui semblent mystérieux : pourquoi un fichier ne rétrécit pas après un DELETE, pourquoi une clé primaire en UUID ralentit les insertions, pourquoi un index secondaire coûte plus cher qu’on ne le pense, ou ce que protège vraiment le « doublewrite buffer ».

Cette série de trois billets ouvre le capot de trois moteurs : InnoDB (le moteur de stockage de MySQL et de MariaDB), PostgreSQL et Oracle. Le même fil conducteur les traverse : les fichiers, les pages, les lignes, et l’endroit où vivent les anciennes versions des données. Premier arrêt : InnoDB.

Le répertoire de données

Arborescence du datadir de MySQL 8
Les fichiers d’une instance MySQL 8 et leurs équivalents MariaDB.

Chaque fichier a un rôle précis :

  • ibdata1, le tablespace système. Historiquement, il contenait tout : tables, index, undo, dictionnaire. Aujourd’hui, il ne garde plus que le change buffer (sur MySQL) et, sur MariaDB, le doublewrite et parfois l’undo. Il ne rétrécit jamais : un ibdata1 de 50 Go gonflé par une vieille transaction le restera, et la seule façon de le réduire est un export complet suivi d’une réimportation ;
  • un fichier .ibd par table, grâce à innodb_file_per_table=ON (le défaut depuis MySQL 5.6). C’est ce qui permet de récupérer l’espace d’une table supprimée ou vidée (DROP, TRUNCATE), de déplacer une table seule, ou de restaurer un seul tablespace ;
  • les tablespaces d’undo (undo_001…) : les images d’avant des lignes modifiées, pour les ROLLBACK et pour la lecture cohérente des autres transactions ;
  • le journal redo : chaque modification de page y est décrite avant que la page elle-même soit écrite sur le disque. Après un crash, InnoDB rejoue ce journal ;
  • le doublewrite buffer, expliqué plus bas ;
  • les binlogs, qui n’appartiennent pas à InnoDB mais au serveur : ils décrivent les changements au niveau SQL, pour la réplication et la restauration à un instant donné.

Une base de données SQL (CREATE DATABASE shop) n’est rien d’autre qu’un répertoire. Les noms exacts des fichiers globaux varient : MySQL et MariaDB ont divergé sur le dictionnaire (MySQL 8 a remplacé les fichiers .frm par un dictionnaire transactionnel dans mysql.ibd, MariaDB a gardé les .frm), sur le redo et sur le doublewrite. Le format interne des pages, lui, est commun. Pour voir les tablespaces d’une instance :

-- MySQL 8
SELECT name, file_size, allocated_size FROM information_schema.INNODB_TABLESPACES;
-- MariaDB
SELECT name, file_size, allocated_size FROM information_schema.INNODB_SYS_TABLESPACES;

Pages, extents, segments

Hiérarchie tablespace, segments, extents, pages
Un tablespace est découpé en pages de 16 Ko, regroupées en extents de 1 Mo, attribués aux segments de chaque index.

L’unité de base est la page, de 16 Ko par défaut. Sa taille (innodb_page_size) est fixée à la création de l’instance et ne peut plus changer ensuite. Tout ce qu’InnoDB lit ou écrit, il le lit ou l’écrit par pages entières : lire une ligne de 100 octets, c’est charger 16 Ko en mémoire, dans le buffer pool.

Les pages sont regroupées en extents de 64 pages, soit 1 Mo. Allouer par extents plutôt que page par page garde les pages d’un même index physiquement proches, ce qui rend les lectures séquentielles efficaces.

Chaque index possède deux segments : un pour ses pages feuilles, un pour ses pages intermédiaires. Les séparer garde les feuilles (là où sont les données) contiguës, pour les parcours par plage. Pour ne pas réserver 1 Mo à chaque petite table, un segment reçoit d’abord jusqu’à 32 pages isolées, et ne passe aux extents complets qu’ensuite. C’est pourquoi une table vide occupe une centaine de kilo-octets, et pas plusieurs mégaoctets.

Les premières pages d’un .ibd sont réservées à la gestion de l’espace : la page 0 (FSP_HDR) décrit l’état des extents, la page 1 le bitmap du change buffer, la page 2 les « inodes » des segments. Sur MySQL 8, la page 3 contient la description de la table en JSON (SDI) et la racine de l’index cluster est en page 4. Sur MariaDB, qui garde des .frm, la racine est en page 3.

Anatomie d’une page InnoDB

Structure d'une page d'index InnoDB de 16 Ko
Une page d’index : en-têtes, lignes, espace libre, répertoire et queue.

Chaque zone a une raison d’être :

  • Le FIL header est commun à toutes les pages, quel que soit leur type. Il contient le numéro de page, le type de page et le LSN (Log Sequence Number) de la dernière modification. Dans l’ancienne disposition, il contient aussi la somme de contrôle, qui détecte une corruption à la lecture ; sur MariaDB 10.11, la disposition par défaut (full_crc32) laisse les octets 0 à 3 à zéro et place la somme de contrôle tout à la fin de la page (voir les tests en fin de billet). Après un crash, InnoDB compare ce LSN à celui de chaque entrée du redo : il ne rejoue que ce que la page n’a pas encore reçu. Les pointeurs vers les pages précédente et suivante relient toutes les pages d’un même niveau de l’arbre ;
  • l’INDEX header donne le niveau de la page dans l’arbre (0 pour une feuille), le nombre de lignes et l’identifiant de l’index auquel elle appartient ;
  • infimum et supremum sont deux fausses lignes, la plus petite et la plus grande possibles. Elles servent de début et de fin à la liste chaînée des lignes, ce qui simplifie le code d’insertion ;
  • les lignes sont écrites dans l’ordre où elles arrivent, mais chacune pointe vers la suivante dans l’ordre de la clé. Insérer au milieu ne déplace rien : il suffit de modifier deux pointeurs ;
  • le page directory part de la fin de la page et grandit vers le haut. Il contient un « slot » pour 4 à 8 lignes. Pour trouver une clé, InnoDB fait une recherche dichotomique dans ces slots, puis parcourt au plus quelques lignes de la liste chaînée ;
  • le FIL trailer occupe les 8 derniers octets de la page. Dans l’ancienne disposition, il répète la somme de contrôle et les 32 bits de poids faible du LSN : une page dont le début et la fin ne correspondent pas n’a été écrite qu’à moitié. Sur MariaDB 10.11 (full_crc32), les 4 derniers octets sont la somme de contrôle elle-même, un CRC-32C du reste de la page, et les 4 octets qui les précèdent sont les 32 bits de poids faible du LSN.

Quand une page est pleine et qu’une ligne doit y entrer, InnoDB la coupe en deux (page split) et met à jour la page parente. Quand deux pages voisines descendent sous la moitié de leur remplissage, il peut les fusionner. Ces opérations sont coûteuses et laissent des pages à moitié vides : c’est la fragmentation.

Le doublewrite buffer : pourquoi écrire deux fois

Un disque garantit l’atomicité d’écriture de 512 octets ou 4 Ko, pas de 16 Ko. Si le serveur s’arrête au milieu de l’écriture d’une page, celle-ci peut se retrouver à moitié ancienne, à moitié nouvelle : une torn page. Le redo ne peut pas la réparer, car il décrit des modifications à appliquer sur une page cohérente. InnoDB écrit donc chaque page d’abord dans le doublewrite buffer, puis à son emplacement définitif. Après un crash, une page déchirée est remplacée par sa copie intacte, puis le redo s’applique. Sur un système de fichiers qui garantit lui-même des écritures atomiques de 16 Ko (ZFS, certains SSD), on peut le désactiver.

Une ligne

Format d'une ligne InnoDB DYNAMIC
Une ligne de l’index cluster et le détail de son en-tête.
  • Les colonnes cachées : DB_TRX_ID est l’identifiant de la dernière transaction qui a modifié la ligne, DB_ROLL_PTR pointe vers l’image précédente dans l’undo. Une transaction qui ne doit pas voir la modification suit ce pointeur pour reconstruire l’ancienne version ;
  • sans clé primaire, InnoDB utilise la première clé unique non nulle, ou à défaut ajoute une colonne cachée DB_ROW_ID de 6 octets, alimentée par un compteur global partagé par toutes ces tables. Définissez toujours une clé primaire ;
  • le bit « deleted » : un DELETE ne fait que marquer la ligne. Elle est réellement retirée plus tard par le purge thread, une fois qu’aucune transaction ne peut plus la voir ;
  • les NULL ne coûtent qu’un bit dans le bitmap : aucune place dans les données ;
  • les colonnes longues (TEXT, BLOB, gros VARCHAR) : une ligne doit tenir dans environ la moitié d’une page, pour qu’une page contienne au moins deux lignes. Au format DYNAMIC, une valeur trop longue part entière dans des pages de débordement, et la ligne ne garde qu’un pointeur de 20 octets.

L’index cluster d’InnoDB : la table est un arbre

Index cluster et index secondaire InnoDB
Les feuilles de l’index PRIMARY contiennent les lignes ; un index secondaire contient la clé primaire.

C’est la décision de conception la plus importante d’InnoDB : la table est l’index de la clé primaire. Il n’existe pas de « tas » de lignes à côté : les lignes complètes sont stockées dans les feuilles d’un arbre B+ trié par clé primaire. Plusieurs conséquences pratiques en découlent :

  • une recherche par clé primaire descend l’arbre et trouve directement la ligne. Avec des pages de 16 Ko et une clé BIGINT, une page intermédiaire référence plus d’un millier de pages filles : trois niveaux suffisent pour des centaines de millions de lignes ;
  • une clé primaire croissante (AUTO_INCREMENT) ajoute toujours à droite de l’arbre : les pages se remplissent proprement, sans split au milieu. Une clé aléatoire (UUID v4) insère n’importe où, provoque des splits partout et laisse des pages à moitié pleines. Les UUID ordonnés dans le temps (UUID v7) évitent ce problème ;
  • un index secondaire ne contient pas l’adresse physique de la ligne mais sa clé primaire. Une recherche par index secondaire coûte donc deux descentes d’arbre. En contrepartie, déplacer une ligne lors d’un split ne demande pas de mettre à jour les index secondaires ;
  • une clé primaire longue est recopiée dans chaque index secondaire : une clé de 36 caractères multiplie la taille de tous les index ;
  • un index couvrant, qui contient toutes les colonnes dont la requête a besoin, évite la seconde descente : EXPLAIN affiche alors Using index.

Pourquoi le fichier ne rétrécit pas

Un DELETE massif libère de la place dans les pages, que les insertions suivantes réutiliseront, mais ne rend rien au système de fichiers. Pour récupérer l’espace, il faut reconstruire la table :

OPTIMIZE TABLE shop.orders;          -- équivaut, pour InnoDB, à ALTER TABLE … FORCE
-- data_free n'est qu'une estimation, voir les tests plus bas
SELECT table_name, data_free/1024/1024 AS free_mb
FROM information_schema.tables WHERE table_schema='shop' ORDER BY data_free DESC;

N’utilisez pas data_free pour décider si une reconstruction vaut le coup : dans mon test, il est resté à 7,0 Mo après la suppression de 90 % d’une table dont le fichier faisait 80 Mio, et la reconstruction a ramené le fichier à 9 Mio. C’est la taille du fichier sur disque que la reconstruction récupère.

La reconstruction copie toutes les lignes dans un nouveau fichier, compact, puis remplace l’ancien. Elle se fait en ligne sur les versions récentes, mais demande temporairement l’espace d’une seconde copie de la table.

Prochain billet : PostgreSQL, qui a fait le choix inverse sur presque tous ces points : une table en tas, des pages de 8 Ko, et des anciennes versions gardées dans la table elle-même.

Ce que j’ai mesuré (mise à jour du 2026-11-04)

J’ai d’abord écrit ce billet à partir de la documentation. Je l’ai ensuite testé sur MariaDB 10.11.18, Rocky Linux 10.2, WSL2, sur une instance jetable avec un buffer pool de 256 Mo. Ces tests ont corrigé deux affirmations plus haut (la position de la somme de contrôle et data_free) et confirmé la disposition des pages, les colonnes cachées, le comportement d’OPTIMIZE TABLE et l’emplacement du doublewrite buffer. Je n’ai pas testé MySQL 8 : les numéros de page 3 (SDI) et 4 (racine) pour MySQL viennent de la documentation.

Les deux vues de l’information schema, et les fichiers d’une base d’une seule table :

-rw-rw---- 1 mysql mysql  1055 Nov  4 17:21 orders.frm
-rw-rw---- 1 mysql mysql 65536 Nov  4 17:21 orders.ibd
| name             | file_size | allocated_size |
| innodb_system    |  12582912 |       12587008 |
| shop/orders      |     65536 |          65536 |
| innodb_temporary |  12582912 |       12582912 |
ERROR 1109 (42S02) at line 1: Unknown table 'INNODB_TABLESPACES' in information_schema

Les premières pages de orders.ibd (4 pages de 16 Kio), après un arrêt propre. La page 0 est l’en-tête de l’espace, la 1 le bitmap du change buffer, la 2 les inodes de segments, et la page 3 est la racine de l’index clustered :

page 0: type=8 (FSP_HDR) page_no=0 space_id=5 lsn=46034 prev=0xffffffff next=0xffffffff
page 1: type=5 (IBUF_BITMAP) page_no=1 space_id=5 lsn=45760 prev=0xffffffff next=0xffffffff
page 2: type=3 (INODE) page_no=2 space_id=5 lsn=46034 prev=0xffffffff next=0xffffffff
page 3: type=17855 (INDEX) page_no=3 space_id=5 lsn=47160 prev=0xffffffff next=0xffffffff

Où se trouve la somme de contrôle. Avec innodb_checksum_algorithm = full_crc32, les octets 0 à 3 sont à zéro, et les 4 derniers octets égalent le CRC-32C des 16380 premiers octets de la page, pour chacune des 4 pages :

innodb_checksum_algorithm = full_crc32
page 0: bytes 0-3=0x00000000  last 4 bytes=0x34c1c1a7  crc32c(page without last 4 bytes)=0x34c1c1a7  match=True
page 1: bytes 0-3=0x00000000  last 4 bytes=0x248d385f  crc32c(page without last 4 bytes)=0x248d385f  match=True
page 2: bytes 0-3=0x00000000  last 4 bytes=0x85d98b4a  crc32c(page without last 4 bytes)=0x85d98b4a  match=True
page 3: bytes 0-3=0x00000000  last 4 bytes=0x3e60cc66  crc32c(page without last 4 bytes)=0x3e60cc66  match=True

Les colonnes cachées, lues directement dans la page 3. Après la clé primaire sur 4 octets (80000001, bit de signe inversé) viennent DB_TRX_ID sur 6 octets et DB_ROLL_PTR sur 7 octets, puis les colonnes utilisateur :

id=1 at page offset 128: pk=80000001 DB_TRX_ID=000000000013 DB_ROLL_PTR=84000001340110 columns: 616c6963656669727374060300000018
id=2 at page offset 163: pk=80000002 DB_TRX_ID=000000000013 DB_ROLL_PTR=8400000134011c columns: 626f627365636f6e64050500000020ff
id=3 at page offset 197: pk=80000003 DB_TRX_ID=000000000013 DB_ROLL_PTR=84000001340128 columns: 6361726f6c7468697264000000000000

Un DELETE ne réduit pas le fichier, data_free ne voit pas l’espace libre, et OPTIMIZE TABLE reconstruit la table. ALTER TABLE ... FORCE donne la même taille finale (9437184 octets). ibdata1 est passé de 12 à 76 Mio pendant le chargement (les undo logs sont ici dans le tablespace système, innodb_undo_tablespaces = 0) et n’a pas diminué ensuite. Dans les lignes de tableau ci-dessous, les colonnes sont table_rows, data_mb, index_mb et data_free_mb :

-- after loading 300000 rows
   big.ibd: 83886080 bytes
   ibdata1: 79691776 bytes
|     299999 |     0.0 |      0.0 |          7.0 |
-- after DELETE of 90% of the rows
   big.ibd: 83886080 bytes
   ibdata1: 79691776 bytes
|      29999 |     0.0 |      0.0 |          7.0 |
| shop.big | optimize | note     | Table does not support optimize, doing recreate + analyze instead |
-- after OPTIMIZE TABLE
   big.ibd: 9437184 bytes
   ibdata1: 79691776 bytes
|      29614 |     8.5 |      0.0 |          0.0 |
-- before ALTER TABLE FORCE
   big.ibd: 146800640 bytes
   ibdata1: 79691776 bytes
|      29614 |     8.5 |      0.0 |          7.0 |
-- after ALTER TABLE FORCE
   big.ibd: 9437184 bytes
   ibdata1: 79691776 bytes
|      29614 |     8.5 |      0.0 |          0.0 |

Les clés aléatoires. 500 000 lignes, buffer pool de 256 Mo, tout en mémoire. L’UUID() horodaté (version 1) et la clé aléatoire de type version 4 donnent des fichiers plus gros qu’un BIGINT séquentiel. Les largeurs de clé diffèrent (8 et 36 octets) : comparez plutôt les deux variantes d’UUID entre elles, la clé aléatoire est 40 % plus grosse, ce qui est cohérent avec des pages moins remplies (je n’ai pas mesuré le remplissage). Je n’ai pas pu montrer de ralentissement des insertions avec tout en mémoire, et la dernière ligne inclut le coût de RANDOM_BYTES() :

insert 500000 rows, sequential bigint : 0.8 s
insert 500000 rows, random UUID (v1)  : 1.1 s
| shop/pk_seq  |    32.0 |
| shop/pk_uuid |    48.0 |
insert 500000 rows, random v4-like UUID : 2.3 s
| shop/pk_rand |    68.0 |

Où se trouve le doublewrite buffer. J’ai démarré MariaDB 10.11.18 deux fois, avec la valeur par défaut innodb_doublewrite=ON puis avec OFF, chargé 20 000 lignes dans une table et tracé les écritures de pages avec strace. Les deux essais utilisaient innodb_use_native_aio=OFF, sinon les écritures ne passent pas par pwrite64 et strace ne les voit pas. Avec le doublewrite activé, ibdata1 reçoit deux écritures groupées qui commencent à la page 64 (160 Kio et 64 Kio), et Innodb_dblwr_pages_written passe de 0 à 39. Désactivé, rien n’est écrit à la page 64 d’ibdata1 et le compteur reste à 0. Sur MariaDB 10.11, le doublewrite buffer se trouve donc dans le tablespace système. Je n’ai pas regardé sa disposition exacte au-delà de la page de départ.

################ innodb_doublewrite = ON (default), native AIO off
| innodb_doublewrite | ON    |
| Innodb_dblwr_pages_written | 0     |
| Innodb_dblwr_pages_written | 39    |
  ibdata1             4 writes,     262144 bytes, pages 0..316
first alternations (file, consecutive writes): [['dw.ibd', 10], ['ibdata1', 1], ['dw.ibd', 39], ['ibdata1', 1], ['dw.ibd', 1], ['ibdata1', 2]]
ibdata1 writes as (first page, KiB): [(316, 16), (64, 160), (64, 64), (0, 16)]
################ innodb_doublewrite = OFF, native AIO off
| innodb_doublewrite | OFF   |
| Innodb_dblwr_pages_written | 0     |
| Innodb_dblwr_pages_written | 0     |
  ibdata1             2 writes,      32768 bytes, pages 0..49
first alternations (file, consecutive writes): [['dw.ibd', 5], ['ibdata1', 1], ['dw.ibd', 20], ['ibdata1', 1]]
ibdata1 writes as (first page, KiB): [(49, 16), (0, 16)]

Les compteurs sont ceux de SHOW GLOBAL STATUS LIKE 'Innodb_dblwr%', avant et après le chargement.


Ce billet fait partie d’une série de notes d’administration système (tag « carnet d’admin »).
Série « Dans les fichiers d’une base de données », partie 1/3. · Billet suivant → · English version

Articles liés

Chronologie du reboot (19:49 à 20:20) et les preuves d'un arrêt propre : Wazuh, rsyslog, wtmp, montages XFS, puis blocage matériel dû à la carte mère

Le serveur qui ne revenait pas après un reboot : lire les traces d’un arrêt, la carte mère coupable

Un mercredi soir, mise à jour classique du serveur de la maison : dnf update -y, une centaine de paquets dont un nouveau noyau et de nouveaux firmwares, puis reboot. D’habitude, la machine revient en moins d’une minute. Cette fois, rien. Pas de ping, pas de SSH, et surtout aucune réponse ARP sur le réseau local : vu du switch, la carte réseau n’existait plus. Cet article reprend l’enquête dans l’ordre : le symptôme, les traces qu’un arrêt laisse derrière lui, puis le coupable : la carte mère.

Chronologie du reboot (19:49 à 20:20) et les preuves d'un arrêt propre : Wazuh, rsyslog, wtmp, montages XFS, puis blocage matériel dû à la carte mère
Ce que les traces d’un reboot prouvent, et où elles s’arrêtent.

En un coup d’œil

IndiceCe que j’ai observéCe que ça prouve
RéseauNi ping, ni SSH, aucune réponse ARPLe noyau ne tourne pas, ou la carte réseau n’est pas initialisée
WazuhDernier événement « Wazuh agent stopped » à l’heure du rebootArrêt propre, pas un crash
rsyslogLe journal s’arrête à « System is rebooting »Silence normal : rsyslog s’arrête parmi les premiers services
wtmpEntrée shutdown à 19:50:04systemd est allé au bout de la séquence d’arrêt
Montages XFS« Ending clean mount » sur les quatre systèmes de fichiersTous les disques ont été démontés proprement
ConclusionTrente minutes sans aucune activitéBlocage après l’appel de redémarrage du noyau : carte mère ou firmware

Le symptôme : une machine muette

Le détail de l’ARP compte. Une machine qui démarre mais dont un service plante répond toujours à l’ARP, car c’est le noyau qui répond, pas une application. L’absence d’ARP signifie que le noyau ne tourne pas, ou que la carte réseau n’est pas initialisée. Le problème est donc bas niveau : démarrage du noyau, initramfs, firmware de la carte mère, ou machine tout simplement éteinte.

Ce qu’on peut vérifier à distance

Avant de descendre devant la machine, j’ai rassemblé ce qui était visible depuis le reste du réseau :

arping -c 3 -I eth0 192.168.10.17     # aucune réponse : la carte réseau est muette
ip neigh show 192.168.10.17           # entrée FAILED dans la table ARP du voisin
  • Le SIEM (Wazuh) avait reçu un dernier événement de l’agent de la machine : « Wazuh agent stopped », à l’heure exacte du reboot. C’est le signe d’un arrêt propre, pas d’un crash. Un crash n’aurait laissé aucun message.
  • Les autres machines du réseau fonctionnaient normalement : le problème ne venait ni du switch ni du routeur.
  • La machine virtuelle hébergée sur ce serveur était tombée avec lui, ce qui est cohérent avec un hôte éteint.

Ce qu’il fallait voir à l’écran

Sans carte de gestion à distance (pas d’IPMI sur une carte mère grand public), la seule façon de savoir est de regarder l’écran. Voici la liste que je me suis préparée, de la cause la plus probable à la moins probable, pour ne pas improviser devant la console :

  • Bloqué dans GRUB ? Choisir le noyau précédent dans le menu. S’il démarre, le nouveau noyau ou son initramfs sont en cause. On le fixe ensuite avec grubby --set-default.
  • Shell d’urgence (dracut, emergency mode) ? journalctl -xb -p err montre pourquoi : volume LVM introuvable, entrée fstab invalide, module manquant dans l’initramfs.
  • Réétiquetage SELinux ou fsck en cours ? C’est long sur un gros disque. Il faut attendre, surtout ne pas couper.
  • Démarré mais sans réseau ? ip -br a et nmcli dev : une carte renommée ou « unmanaged » après une mise à jour arrive plus souvent qu’on ne croit.
  • Écran noir, ventilateurs qui tournent, rien ne bouge ? C’est le cas le plus déroutant. C’était le mien.

La machine a fini par repartir après une demi-heure, avec un appui long sur le bouton d’alimentation. Le système a démarré normalement sur le nouveau noyau, sans aucune erreur. Restait à comprendre ce qui s’était passé, alors que le journal système ne gardait que le démarrage en cours. C’est l’objet de la section suivante.

Lire les traces d’un arrêt

La machine est revenue, mais journalctl ne montre que le démarrage en cours. Sur ce serveur, le journal n’est pas persistant (Storage=auto sans répertoire /var/log/journal). Les traces du démarrage précédent sont donc perdues de ce côté-là. Heureusement, d’autres sources survivent à un redémarrage.

Source 1 : rsyslog et /var/log/messages

grep -E "System is rebooting|Command line:" /var/log/messages | tail -4
# 19:49:42 systemd-logind: System is rebooting.
# 20:20:22 kernel: Command line: BOOT_IMAGE=(hd0,gpt2)/vmlinuz-6.12.0-211.67.1…

Le journal s’arrête net à « System is rebooting », puis reprend trente minutes plus tard avec la ligne de commande du nouveau noyau. Ce silence n’est pas suspect : rsyslog fait partie des premiers services arrêtés pendant un reboot, il n’écrit donc jamais la fin de la séquence d’arrêt. Il faut chercher la suite ailleurs.

Source 2 : wtmp, le registre des connexions et des arrêts

last -x -F | head -6
# reboot   system boot  6.12.0-211.67.1  Wed Oct 21 20:20:26 2026   still running
# shutdown system down  6.12.0-211.54.1  Wed Oct 21 19:50:04 2026 - 20:20:26 (00:30)

last -x affiche aussi les entrées système, pas seulement les connexions. La ligne shutdown system down est écrite par systemd-update-utmp à la toute fin de la séquence d’arrêt, quand presque tous les services sont déjà arrêtés. Sa présence prouve que systemd est allé au bout. Elle est horodatée à 19:50:04, soit 22 secondes après le début du reboot. La colonne « (00:30) » est la durée jusqu’au démarrage suivant : trente minutes pendant lesquelles la machine n’a rien fait.

Source 3 : le montage des systèmes de fichiers au démarrage suivant

grep -E "XFS .*(Mounting|recovery|clean mount)" /var/log/messages | grep "20:20"
# XFS (dm-0): Mounting V5 Filesystem …
# XFS (dm-0): Ending clean mount

Quand un système de fichiers XFS n’a pas été démonté proprement, le montage suivant rejoue son journal et affiche « Starting recovery » puis « Ending recovery ». Ici, les quatre systèmes de fichiers indiquent « Ending clean mount » : ils avaient tous été démontés correctement. Le système d’exploitation avait donc terminé son arrêt avant que la machine ne se fige.

Chronologie du redémarrage : les journaux couvrent l'arrêt propre, puis le silence jusqu'à la coupure
Où s’arrêtent les traces : tout est journalisé jusqu’à l’appel de redémarrage du noyau, le blocage est côté matériel.

Ce que ça permet de conclure

  • l’arrêt systemd est allé au bout (entrée shutdown dans wtmp) ;
  • les disques ont été démontés proprement (montage « clean » ensuite) ;
  • la machine n’a ensuite plus rien fait pendant trente minutes, jusqu’à ce qu’on la relance à la main.

Le blocage s’est donc produit après que le noyau a demandé à la carte mère de redémarrer, quelque part entre cet appel et le BIOS. Ce n’est ni un paquet, ni un service, ni un système de fichiers.

Pour la prochaine fois

Rendre le journal persistant simplifie beaucoup ce genre d’enquête. C’est une ligne :

mkdir -p /var/log/journal && systemd-tmpfiles --create --prefix /var/log/journal
journalctl --list-boots      # les démarrages précédents apparaissent désormais

Même persistant, le journal ne verra jamais ce qui se passe après l’arrêt du noyau. Les indices de wtmp et des montages restent les plus fiables pour savoir si un arrêt s’est terminé proprement.

Le coupable : la carte mère

Récapitulons les preuves : l’arrêt systemd s’est terminé, les systèmes de fichiers ont été démontés proprement, puis la machine n’a plus rien fait pendant trente minutes. Le coupable se trouve donc entre l’appel de redémarrage du noyau et le premier écran du BIOS. Autrement dit, la carte mère n’a jamais effectué sa réinitialisation.

Comment Linux redémarre une machine

À la fin de l’arrêt, le noyau appelle reboot(), et celui-ci doit déclencher un vrai reset matériel. Sur x86, le noyau essaie plusieurs méthodes, dans un ordre par défaut qui dépend de la machine :

  • acpi : écrire dans le registre de reset déclaré par le firmware (table ACPI FADT) ;
  • kbd : la méthode historique, une impulsion via le contrôleur clavier ;
  • pci : écrire dans le port 0xCF9, le registre de reset du chipset ;
  • efi : demander au firmware UEFI de redémarrer ;
  • triple : provoquer une « triple faute » du processeur, en dernier recours.

Si la méthode choisie ne fonctionne pas sur une carte précise, le noyau a tout arrêté et attend un reset qui ne vient jamais. C’est exactement le tableau observé : écran noir, ventilateurs qui tournent, plus aucune activité réseau.

Pourquoi cette fois-là, et pas les autres ?

Le redémarrage suivant, sur le nouveau noyau, a pris 34 secondes, sans aucun souci. Plusieurs facteurs ont pu jouer le soir du blocage : l’ancien noyau avec 35 jours d’uptime, et une mise à jour des firmwares (linux-firmware) dans la même transaction. Ces blocages au reset sont connus pour être intermittents sur certaines cartes mères grand public. Je n’ai pas pu reproduire le problème, et je n’ai donc rien changé dans l’immédiat.

Si ça se reproduit

La piste est de forcer une autre méthode de reset avec le paramètre noyau reboot=, puis de tester plusieurs redémarrages :

grubby --update-kernel=ALL --args="reboot=pci"     # ou reboot=efi, reboot=acpi
grubby --info=DEFAULT | grep args                  # vérifier
reboot                                             # tester, plusieurs fois
grubby --update-kernel=ALL --remove-args="reboot"  # pour revenir en arrière

reboot=pci (le registre 0xCF9 du chipset) est souvent la plus fiable sur le matériel Intel grand public, et reboot=efi sur les firmwares UEFI récents. Le paramètre s’applique à tous les noyaux installés, y compris les prochains.

Ce que j’en retiens

  • avant d’accuser une mise à jour, vérifiez si l’arrêt s’est terminé : wtmp et les montages « clean » le disent sans ambiguïté ;
  • un blocage après un arrêt propre relève du matériel ou du firmware, pas du système : aucun retour arrière de paquets ne l’aurait corrigé ;
  • rendez le journal persistant (/var/log/journal) pour que les démarrages précédents restent consultables ;
  • si la machine héberge un service dont tout dépend, notez la procédure de reprise. Ici, le coffre à secrets doit être descellé à la main après chaque redémarrage : c’est le sujet d’un autre article.

Articles liés

Schéma : pourquoi /boot de 1 Go déborde, les cinq étapes de migration vers une partition de 2 Go et le miroir de /data

Un /boot trop petit, des disques en miroir et des alertes : ménage de printemps… en octobre

Petite matinée de ménage sur le serveur de la maison (un Rocky Linux 10 sur une vieille carte mère ASRock). Au programme : une partition /boot devenue trop petite, des disques qui dormaient dans la machine et qu’il était temps de mettre en miroir, et des alertes Prometheus pour ne plus découvrir un disque mort par hasard. Retour d’expérience, commandes comprises.

Schéma : pourquoi /boot de 1 Go déborde, les cinq étapes de migration vers une partition de 2 Go et le miroir de /data
Pourquoi /boot déborde, la migration en cinq étapes, et le détour pour mettre /data en miroir.

Le problème : un /boot de 1 Go qui ne suffit plus

L’installeur de Rocky crée par défaut un /boot de 1 Go. Ça paraissait large… jusqu’à ce que je regarde ce qu’il contient vraiment :

$ ls -la --block-size=M /boot
152M initramfs-6.12.0-211.67.1.el10_2.x86_64.img
 39M initramfs-6.12.0-211.67.1.el10_2.x86_64kdump.img
 16M vmlinuz-6.12.0-211.67.1.el10_2.x86_64
159M initramfs-0-rescue-….img
 16M vmlinuz-0-rescue-…

Environ 217 Mo par noyau, plus 175 Mo pour l’image de secours. Avec installonly_limit=3 dans dnf.conf, on arrive à ~825 Mo sur 960. Et le piège : pendant une mise à jour, dnf installe le nouveau noyau avant de supprimer le plus ancien. Il y a donc brièvement quatre noyaux, et ça ne passe plus.

Pourquoi un initramfs de 152 Mo ? lsinitrd donne la réponse : une centaine de Mo de firmwares NVIDIA (GSP) tirés par le pilote nouveau, parce qu’il y a une GeForce GTX 1060 dans la machine. Je garde nouveau, donc la solution, c’est un /boot plus grand.

$ lsinitrd /boot/initramfs-$(uname -r).img | sort -k5 -n | tail -3
… 13371848 usr/lib/firmware/nvidia/tu102/gsp/gsp-570.144.bin.xz
… 25639648 usr/lib/firmware/nvidia/ga102/gsp/gsp-535.113.01.bin.xz
… 51324396 usr/lib/firmware/nvidia/ga102/gsp/gsp-570.144.bin.xz

Pourquoi ne pas simplement agrandir la partition ?

Parce que la partition qui suit /boot est le volume physique LVM du système, collé juste derrière. Et XFS ne sait pas rétrécir. Agrandir sur place voudrait dire déplacer tout le LVM (pvmove vers une nouvelle partition, supprimer l’ancienne, agrandir /boot dans le trou). Faisable à chaud, mais c’est beaucoup de manipulations sur le disque système d’une machine qui venait justement de rester plantée au redémarrage deux jours plus tôt.

Il restait plus de 300 Go libres en fin de disque. Plan plus simple : créer une nouvelle partition /boot de 2 Go dans cet espace libre, y copier l’ancien /boot, et réinstaller GRUB pour qu’il la prenne en compte. L’ancienne partition reste intacte et sert de filet de sécurité.

Migrer /boot vers une nouvelle partition (BIOS + GPT)

Le point important, c’est le mode de démarrage. Ici c’est du BIOS « legacy » sur une table GPT : GRUB loge son étage principal (core.img) dans la petite partition bios_grub de 1 Mo, et ce core.img contient en dur l’emplacement de /boot/grub2. Si on déplace /boot sans relancer grub2-install, GRUB continue de lire l’ancienne partition. En UEFI, ce serait le grub.cfg de l’ESP qu’il faudrait mettre à jour à la place.

$ parted -s /dev/sde unit MiB print free
Number  Start    End        Size       File system  Name  Flags
 1      1.00MiB  2.00MiB    1.00MiB                       bios_grub
 2      2.00MiB  1026MiB    1024MiB    xfs                bls_boot
 3      1026MiB  160406MiB  159380MiB                     lvm
        160406MiB  476940MiB  316534MiB  Free Space

À faire avec un accès à la console (écran/clavier ou IPMI), au cas où le premier redémarrage se passe mal.

1. Créer et formater la nouvelle partition

parted -s /dev/sde mkpart boot2 xfs 160406MiB 162454MiB   # 2 GiB -> sde4
mkfs.xfs -L boot2 /dev/sde4

2. Copier le contenu de /boot

mkdir -p /mnt/newboot
mount /dev/sde4 /mnt/newboot
cp -a /boot/. /mnt/newboot/      # -a conserve droits, liens et contextes SELinux
restorecon -R /mnt/newboot

3. Pointer /etc/fstab sur la nouvelle partition

cp -a /etc/fstab /etc/fstab.bak
blkid -s UUID -o value /dev/sde4          # nouvel UUID
# remplacer l'UUID de la ligne /boot par celui-ci, puis :
systemctl daemon-reload
umount /mnt/newboot
umount /boot && mount /boot
findmnt /boot                             # doit afficher /dev/sde4

4. Réinstaller GRUB et régénérer sa configuration

grub2-install /dev/sde                    # core.img pointe maintenant vers sde4
grub2-mkconfig -o /boot/grub2/grub.cfg

Les entrées de démarrage BLS (/boot/loader/entries/*.conf) donnent des chemins relatifs à la partition /boot. Elles fonctionnent donc telles quelles, et grubenv (le noyau par défaut) a été copié avec le reste.

5. Redémarrer et vérifier

reboot
findmnt /boot            # /dev/sde4
df -h /boot              # ~2 Go
grubby --default-kernel  # toujours le bon noyau

Retour arrière : l’ancienne partition sde2 n’a pas été touchée. Si GRUB tombe sur son invite de secours, on peut la réutiliser à la main :

grub rescue> set prefix=(hd0,gpt2)/grub2
grub rescue> set root=(hd0,gpt2)
grub rescue> insmod normal
grub rescue> normal

Ensuite, on remet l’ancien UUID dans /etc/fstab et on relance grub2-install /dev/sde. Une fois la nouvelle partition validée sur plusieurs redémarrages et mises à jour du noyau, l’ancienne n’est plus qu’un giga perdu, ce qui n’a rien de grave.

Au passage : mettre /data en miroir… quand il manque 4 Mo

La machine avait aussi des disques qui ne servaient à rien. J’en profite pour mettre en miroir /data (931 Go, bases de données, dépôts, sauvegardes) avec LVM RAID1. Sur le papier, c’est une seule commande, sans interruption :

lvconvert --type raid1 -m1 data/lvol0 /dev/sdd

En pratique, elle échoue. Un LV RAID1 a besoin, sur chaque disque, d’une petite zone de métadonnées (rmeta, une extent de 4 Mo) en plus de la copie des données. Or le LV d’origine occupait toutes les extents du premier disque (238 467 sur 238 467), et le second disque est exactement de la même taille. Il manque donc 4 Mo de chaque côté. Comme XFS ne rétrécit pas, impossible d’en libérer.

La solution consiste à faire le chemin dans l’autre sens :

  • créer sur le nouveau disque un LV une extent plus petit, le formater, et y copier /data à chaud (rsync -aHAXS --numeric-ids, qui conserve liens durs, ACL, xattrs/SELinux et fichiers creux) ;
  • arrêter les services qui utilisent /data (MariaDB, PostgreSQL, Forgejo…), faire un dernier rsync --delete, renommer les LV pour que le nouveau prenne le nom de l’ancien (le fstab n’a pas besoin de changer), puis redémarrer les services. Comptez un quart d’heure d’interruption ;
  • garder l’ancien LV un jour comme filet de sécurité, puis le supprimer et lancer lvconvert --type raid1 -m1 vers l’ancien disque, qui a maintenant la place pour sa zone rmeta. La synchronisation se fait en arrière-plan.

Leçon retenue : quand on crée un LV qu’on voudra peut-être mettre en miroir un jour, laisser quelques extents libres (-l 99%FREE plutôt que 100%FREE) évite tout ce détour.

Pour deux autres vieux disques identiques, sans données, c’est beaucoup plus simple. On crée directement un volume miroir et LVM réserve lui-même la place des métadonnées :

vgcreate bulk /dev/sda /dev/sdb
lvcreate --type raid1 -m1 -l 100%FREE -n lvol0 bulk
mkfs.xfs /dev/bulk/lvol0
# dans /etc/fstab, nofail : un vieux disque qui lâche ne doit pas bloquer le démarrage
/dev/bulk/lvol0  /bulk  xfs  defaults,nofail  0 0

Surveiller les miroirs avec Prometheus

Un miroir dont personne ne remarque qu’il a perdu un disque n’apporte pas grand-chose. Par défaut, LVM se contente d’écrire un avertissement dans les journaux, et node_exporter ne sait rien du RAID LVM (son collecteur mdadm ne couvre que md). J’ai donc ajouté un petit script, lancé toutes les deux minutes par un timer systemd, qui écrit l’état des LV RAID dans le répertoire du collecteur textfile de node_exporter (--collector.textfile.directory) :

lvs --noheadings --nosuffix --separator ';' \
    -o vg_name,lv_name,lv_health_status,raid_sync_action,copy_percent,raid_mismatch_count \
    --select 'segtype=~raid'

On obtient des métriques du genre lvm_raid_healthy (0 dès que lv_health_status n’est plus vide, par exemple partial quand un disque manque), lvm_raid_sync_percent et lvm_raid_mismatch_count. Le fichier est écrit dans un fichier temporaire puis renommé avec mv, pour qu’un scrape ne tombe jamais sur un fichier à moitié écrit. Quatre alertes s’appuient dessus :

- alert: LvmRaidDegraded        # un disque manque ou lâche
  expr: lvm_raid_healthy == 0
  for: 5m
- alert: LvmRaidNotInSync       # resynchronisation qui ne finit pas
  expr: lvm_raid_sync_percent < 100
  for: 12h
- alert: LvmRaidMismatches      # le dernier scrub a trouvé des différences
  expr: lvm_raid_mismatch_count > 0
- alert: LvmRaidMetricsStale    # le script lui-même ne tourne plus
  expr: time() - lvm_raid_textfile_last_run_seconds > 900

Côté matériel, des disques WD de 1, 2 et 3 To attendent sur l’étagère. Le jour où une alerte se déclenche : on remplace le disque, puis pvcreate, vgextend et lvconvert --repair, et LVM reconstruit le miroir à chaud.

En résumé

  • Un /boot de 1 Go, c’est juste dès qu’un pilote embarque de gros firmwares dans l’initramfs. Vérifiez avec lsinitrd.
  • Déplacer /boot vers une nouvelle partition est plus simple et moins risqué que d’agrandir l’existante. En BIOS/GPT, il ne faut pas oublier grub2-install.
  • LVM RAID1 a besoin d’une extent libre par disque pour ses métadonnées. Un LV en 100%FREE sur deux disques identiques ne peut pas être converti sur place.
  • Un miroir sans alerte, c’est une panne qui attend son heure. Le collecteur textfile de node_exporter se prête très bien à ce genre de petit ajout.

Ce billet ouvre une série de notes d’administration système (tag « carnet d’admin »).
English version

Articles liés

Ressac

Les épisodes dépressifs sont comme le ressac des vagues ils vont et viennent, imprévisibles, mais lourds et violents

UnitedHealth, un cabinet d’avocats et la diffamation (Fortune)

https://fortune.com/2025/02/10/unitedhealth-defamation-law-firm-social-media/

Hardening systemd services is hard.

Especially when you want to harden php-pm services with jit enabled and with nextcloud running… More infos later

Articles liés

Un appartement moins rempli

Seule dans cet appartement, Mathilde est partie, Lana est partie samedi 30 mars…

Il reste moi et les 3 poilus : Connor, Freya et Nyx.

Ça fait bizarre mais pas désagréable.

Manon m’a bien aidé à le ranger déjà.

C’est pas désagréable en vrai, juste bizarre

Page 3 of 33

Propulsé par WordPress & Thème par Anders Norén