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.
Pourquoi Shamir sans auto-unseal, et la checklist en cinq étapes après chaque redémarrage.
En un coup d’œil
Sujet
Mon choix
Secrets
OpenBao, lu par Ansible via une identité AppRole en lecture seule
Descellement
Shamir, 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 à payer
Après chaque redémarrage, le coffre est scellé et aucun déploiement n’est possible
Procédure
Cinq étapes, environ cinq minutes
Prochaine amélioration
Une 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.
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 :
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.
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
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
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
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
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
Trois réponses à la même question.
InnoDB
PostgreSQL
Oracle
Taille de page
16 Ko
8 Ko
8 Ko (2 à 32 Ko)
Organisation de la table
arbre B+ de la clé primaire
tas (heap)
tas (heap) par défaut, IOT en option
Adresse d’une ligne dans un index
clé primaire (index secondaires)
ctid (page, slot)
ROWID (objet, fichier, bloc, ligne)
Anciennes versions
undo log
dans la table
tablespace d’undo
Nettoyage
purge thread
VACUUM
réutilisation de l’undo
Pages déchirées
doublewrite buffer
full_page_writes (WAL)
contrôle du bloc + restauration RMAN
Verrous de ligne
en mémoire (lock system)
xmax dans le tuple
octet 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 :
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) :
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 :
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 :
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) :
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
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
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
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
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 :
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
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
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
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
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
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
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
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 :
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.
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
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.
Ce que les traces d’un reboot prouvent, et où elles s’arrêtent.
En un coup d’œil
Indice
Ce que j’ai observé
Ce que ça prouve
Réseau
Ni ping, ni SSH, aucune réponse ARP
Le noyau ne tourne pas, ou la carte réseau n’est pas initialisée
Wazuh
Dernier événement « Wazuh agent stopped » à l’heure du reboot
Arrêt propre, pas un crash
rsyslog
Le journal s’arrête à « System is rebooting »
Silence normal : rsyslog s’arrête parmi les premiers services
wtmp
Entrée shutdown à 19:50:04
systemd est allé au bout de la séquence d’arrêt
Montages XFS
« Ending clean mount » sur les quatre systèmes de fichiers
Tous les disques ont été démontés proprement
Conclusion
Trente 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.
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
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.
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 :
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.
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.
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 :
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.
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.
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
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) :
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