Salle 3
Copie sur écriture, sommes de contrôle et autoréparation
Les disques rendent des données fausses sans signaler d’erreur. Un bit s’altère en silence ; une écriture atterrit au mauvais endroit, ou n’atterrit jamais ; un bogue de micrologiciel répond avec des secteurs périmés. Une somme de contrôle rangée à côté des données ne peut pas détecter une écriture perdue ou égarée, car le bloc périmé porte une somme qui lui correspond. Et un système de fichiers qui écrase les blocs vivants sur place peut rester, après une panne, à moitié ancien et à moitié nouveau.
ZFS répond aux deux problèmes avec une règle et un emplacement.
1 Ne jamais écraser un bloc vivant
Quand un bloc change, ZFS écrit la nouvelle version ailleurs et laisse l’ancienne à sa place. Le pointeur vers le bloc change, donc son parent change et s’écrit ailleurs lui aussi, et ainsi de suite jusqu’à la racine. L’ancien arbre reste entier jusqu’à ce qu’une seule écriture, le nouvel uberblock, fasse passer le pool d’un arbre à l’autre. Réécrivez un bloc pas à pas, et coupez le courant avant la validation.
| Pointeur | Adresse | Naissance | Somme de contrôle |
|---|
Les pointeurs modifiés sont marqués. Les sommes sont de vraies valeurs fletcher4 calculées sur les blocs de démonstration (8 derniers chiffres hexadécimaux sur 64).
Version texte de cette figure
- L’arbre a un uberblock au sommet, un bloc indirect racine R, deux blocs indirects I0 et I1, et quatre blocs de données D0 à D3. Chaque pointeur de bloc contient l’adresse de l’enfant, son txg de naissance et sa somme de contrôle.
- Réécrire un bloc de données écrit une nouvelle copie à une nouvelle adresse ; l’ancienne copie reste en place.
- Son parent a alors besoin d’un nouveau pointeur (nouvelle adresse, nouvelle somme) : le parent est copié à son tour, et ainsi de suite jusqu’à R.
- Écrire le nouvel uberblock dans la case suivante de l’anneau est la validation. Avant elle, une coupure de courant laisse l’ancien arbre intact et courant ; après elle, le nouvel arbre est le pool.
- Les blocs remplacés sont libérés, sauf si un instantané les référence encore : ils restent alors dans la deadlist.
2 La somme de contrôle habite chez le parent
Chaque pointeur de bloc, le blkptr_t de 128 octets, porte la
somme de contrôle de 256 bits du bloc vers lequel il pointe. Un bloc ne se porte jamais
garant de lui-même : son parent s’en charge, et le parent du parent se porte garant du parent, jusqu’à
l’uberblock. Lire un bloc, c’est recalculer sa somme et la comparer à celle du pointeur qui y mène. Sur un
miroir, un écart envoie la lecture vers l’autre disque, et la bonne copie est réécrite sur la mauvaise.
Pointeurs dans le bloc parent : les sommes attendues
Version texte de cette figure
- Un fichier de quatre blocs, D0 à D3, est sur un miroir à deux voies : le disque A et le disque B en gardent chacun une copie complète. Le bloc parent contient, dans chaque pointeur de bloc, la somme fletcher4 du bloc pointé.
- Dégradation silencieuse : un bit de D1 s’inverse sur le disque A. Le disque rend les octets sans erreur. Recalculer fletcher4 sur ces octets donne une autre valeur que celle du pointeur : ECKSUM.
- Écriture perdue : le disque A a confirmé l’écriture du nouveau D2 sans la faire. Avec la copie sur écriture, cette écriture visait une nouvelle adresse, pas l’ancien D2 ; l’adresse garde donc ce qu’elle contenait avant, les octets périmés d’un ancien bloc, libéré depuis. Ces octets peuvent être cohérents avec eux-mêmes ; seule la somme rangée dans le parent, qui décrit le nouveau D2, les trahit.
- En cas d’écart, le miroir lit l’autre disque. Si cette copie correspond, elle est rendue à l’application et réécrite sur la mauvaise copie : le pool se répare lors d’une simple lecture.
- Si les deux copies sont abîmées, la lecture échoue avec une erreur plutôt que de rendre des données fausses. Chaque écart est compté contre le disque qui l’a rendu.
L’idée, à trois profondeurs
En une phrase
ZFS n’écrase jamais de données vivantes et range la somme de contrôle de chaque bloc dans le pointeur qui y mène : une panne ne peut pas laisser le pool à moitié écrit, et un disque ne peut pas rendre de données fausses sans être pris.
En un paragraphe
Chaque écriture va à un nouvel endroit : le bloc reçoit une nouvelle adresse, ce qui change le pointeur de bloc de son parent, donc le parent, jusqu’à la racine. Rien de ce qu’atteint l’uberblock courant n’est jamais modifié : à chaque instant, les disques contiennent un arbre complet et cohérent, et l’écriture d’un seul nouvel uberblock valide le suivant. Chaque pointeur de bloc porte aussi la somme de contrôle de son enfant, ce qui fait de l’arbre un arbre de Merkle : une racine de confiance se porte garante de chaque bloc en dessous d’elle. Un écart à la lecture est une erreur détectée, jamais une corruption silencieuse, et avec une seconde copie (un miroir, ou jusqu’à trois adresses dans un même pointeur de bloc) ZFS rend la bonne copie et répare la mauvaise.
En détail
Où une écriture devient un nouveau bloc. Dans le pipeline d’écriture,
zio_write_compress choisit entre une réécriture sur place et une nouvelle allocation.
zio_write_compress
Extrait de module/zfs/zio.c, lignes
2086–2105, OpenZFS zfs-2.4.4.
Voir sur GitHub
/*
* The final pass of spa_sync() must be all rewrites, but the first
* few passes offer a trade-off: allocating blocks defers convergence,
* but newly allocated blocks are sequential, so they can be written
* to disk faster. Therefore, we allow the first few passes of
* spa_sync() to allocate new blocks, but force rewrites after that.
* There should only be a handful of blocks after pass 1 in any case.
*/
if (!BP_IS_HOLE(bp) && BP_GET_BIRTH(bp) == zio->io_txg &&
BP_GET_PSIZE(bp) == psize &&
pass >= zfs_sync_pass_rewrite) {
VERIFY3U(psize, !=, 0);
enum zio_stage gang_stages = zio->io_pipeline & ZIO_GANG_STAGES;
zio->io_pipeline = ZIO_REWRITE_PIPELINE | gang_stages;
zio->io_flags |= ZIO_FLAG_IO_REWRITE;
} else {
BP_ZERO(bp);
zio->io_pipeline = ZIO_WRITE_PIPELINE;
}-
L2094–L2101
La réécriture sur place n’est permise que pour un bloc né dans le txg même en cours de synchronisation (
BP_GET_BIRTH(bp) == zio->io_txg), de taille inchangée, à partir de la passezfs_sync_pass_rewrite(2). Aucun uberblock validé ne référence encore un tel bloc : l’écraser ne touche aucune donnée vivante. C’est l’unique exception à la copie sur écriture pour les blocs de l’arbre. -
L2102–L2104
Toute autre écriture remet le pointeur de bloc à zéro et suit le pipeline d’écriture complet, dont l’étape DVA_ALLOCATE donne normalement au bloc une nouvelle adresse :
zio_dva_allocatevérifie que le pointeur est un trou avant d’appelermetaslab_alloc. Juste après, trois cas changent de pipeline : un bloc qui se compresse en rien devient un trou, la déduplication prend le pipeline DDT, et le nopwrite ajoute son étape.
Quand l’écriture se termine, dbuf_write_done appelle
dsl_dataset_block_kill sur l’ancien pointeur et dsl_dataset_block_born sur le
nouveau. L’ancien bloc n’est libéré que s’il est né après le dernier instantané ; sinon
il passe dans la deadlist du dataset, et l’instantané le garde.
Où la somme est vérifiée. À l’écriture, zio_checksum_compute
range la valeur calculée dans bp->blk_cksum. À la lecture, le bloc est
comparé à ce champ.
zio_checksum_error_impl
Extrait de module/zfs/zio_checksum.c, lignes
501–538, OpenZFS zfs-2.4.4.
Voir sur GitHub
} else {
byteswap = BP_SHOULD_BYTESWAP(bp);
expected_cksum = bp->blk_cksum;
ci->ci_func[byteswap](abd, size,
spa->spa_cksum_tmpls[checksum], &actual_cksum);
}
/*
* MAC checksums are a special case since half of this checksum will
* actually be the encryption MAC. This will be verified by the
* decryption process, so we just check the truncated checksum now.
* Objset blocks use embedded MACs so we don't truncate the checksum
* for them.
*/
if (bp != NULL && BP_USES_CRYPT(bp) &&
BP_GET_TYPE(bp) != DMU_OT_OBJSET) {
if (!(ci->ci_flags & ZCHECKSUM_FLAG_DEDUP)) {
actual_cksum.zc_word[0] ^= actual_cksum.zc_word[2];
actual_cksum.zc_word[1] ^= actual_cksum.zc_word[3];
}
actual_cksum.zc_word[2] = 0;
actual_cksum.zc_word[3] = 0;
expected_cksum.zc_word[2] = 0;
expected_cksum.zc_word[3] = 0;
}
if (info != NULL) {
info->zbc_checksum_name = ci->ci_name;
info->zbc_byteswapped = byteswap;
info->zbc_injected = 0;
info->zbc_has_cksum = 1;
}
if (!ZIO_CHECKSUM_EQUAL(actual_cksum, expected_cksum))
return (SET_ERROR(ECKSUM));
return (0);-
L502–L505
La valeur attendue est
bp->blk_cksum, prise dans le pointeur de bloc que le lecteur a suivi ; la valeur réelle est recalculée sur les octets qui viennent d’être lus. - L515–L526 Blocs chiffrés autres que les objsets : la moitié des 256 bits est le MAC du chiffrement, vérifié au déchiffrement ; seuls les 128 autres bits sont comparés ici.
-
L535–L536
Un écart renvoie
ECKSUM. Sur un miroir, ce contrôle a lieu dans la lecture de chaque enfant, pas dans le parent :zio_vdev_child_iodescend l’étape CHECKSUM_VERIFY vers l’enfant. Chaque erreur est comptée sur le vdev feuille.
La chaîne s’arrête à l’uberblock : ub_rootbp n’a pas de pointeur parent. Chaque case
d’uberblock est protégée à la place par une somme SHA-256 « label » intégrée, dont le
vérificateur est la position de la case sur le disque. Les en-têtes gang et les
blocs du ZIL (zilog, zilog2)
portent eux aussi une somme intégrée, et
checksum=off existe. La valeur par défaut, checksum=on,
signifie fletcher4 : quatre sommes cumulées de 64 bits sur le bloc
lu en mots de 32 bits. Les figures ci-dessus la calculent pour de vrai.
Où le miroir se répare. Dans vdev_mirror_io_done, si aucun enfant n’a encore
rendu de bonnes données, vdev_mirror_child_select choisit un autre
enfant et une nouvelle lecture part. S’il n’en reste aucun, la lecture échoue avec une
erreur. La réparation exige au moins une bonne copie, un pool accessible en écriture, et soit une
erreur inattendue, soit un resilver, soit un scrub de resilver (L883–L886). Alors :
vdev_mirror_io_done
Extrait de module/zfs/vdev_mirror.c, lignes
900–934, OpenZFS zfs-2.4.4.
Voir sur GitHub
if (mc->mc_error == 0) {
vdev_ops_t *ops = mc->mc_vd->vdev_ops;
if (mc->mc_tried)
continue;
/*
* We didn't try this child. We need to
* repair it if:
* 1. it's a scrub (in which case we have
* tried everything that was healthy)
* - or -
* 2. it's an indirect or distributed spare
* vdev (in which case it could point to any
* other vdev, which might have a bad DTL)
* - or -
* 3. the DTL indicates that this data is
* missing from this vdev
*/
if (!(zio->io_flags & ZIO_FLAG_SCRUB) &&
ops != &vdev_indirect_ops &&
ops != &vdev_draid_spare_ops &&
!vdev_dtl_contains(mc->mc_vd, DTL_PARTIAL,
zio->io_txg, 1))
continue;
mc->mc_error = SET_ERROR(ESTALE);
}
zio_nowait(zio_vdev_child_io(zio, zio->io_bp,
mc->mc_vd, mc->mc_offset,
zio->io_abd, zio->io_size, ZIO_TYPE_WRITE,
zio->io_priority == ZIO_PRIORITY_REBUILD ?
ZIO_PRIORITY_REBUILD : ZIO_PRIORITY_ASYNC_WRITE,
ZIO_FLAG_IO_REPAIR | (unexpected_errors ?
ZIO_FLAG_SELF_HEAL : 0), NULL, NULL));
}- L900–L904 Les enfants qui ont rendu de bonnes données ne sont jamais réécrits : le commentaire juste au-dessus avertit qu’une coupure de courant pendant la réécriture de la seule bonne copie pourrait la perdre.
- L905–L924 Un enfant qui n’a pas été essayé n’est réparé que pendant un scrub, pour les vdevs indirects ou les spares dRAID, ou quand sa DTL indique que ce txg y manque.
-
L927–L933
Le bon tampon
zio->io_abdest écrit sur chaque enfant abîmé par une écritureZIO_FLAG_IO_REPAIR, marquéeZIO_FLAG_SELF_HEALquand la cause était une erreur inattendue.
Ce que les figures laissent de côté
Les figures montrent un seul fichier, d’où un arbre court : dans un pool, il descend de l’uberblock par le MOS, le dataset et le dnode de l’objet. Elles laissent de côté les passes de synchronisation, les blocs gang, la déduplication, le nopwrite, les MAC du chiffrement, et le choix, selon la charge, du côté du miroir lu en premier (ici, toujours le disque A). L’anneau d’uberblocks a 32 cases, comme sur des disques à secteurs de 4 Kio. Les sommes sont de vraies valeurs fletcher4 calculées sur les blocs de démonstration ; les adresses et les numéros de txg sont inventés.