OpenZFS の 128バイト blkptr_t マークル木・ARCキャッシュ・Copy-on-Write 不変スナップショットによる
0.1秒ランサムウェア復旧基盤
監修:加藤 明生(株式会社オープンナレッジ 創業者・代表取締役CTO / データベーススペシャリスト / 登録情報処理安全確保支援士(第007617号))
この記事の要点と4つの章のご案内
| 章構成 | 主なテーマ | 要点と確認ポイント |
|---|---|---|
| 1. 基本と全体像 | 身近なたとえで見る仕組み:「黒板を消して書き直す記録」と「全ページに指紋を押して綴じる公証役場の台帳」 | 一般的なファイル保存は「同じ場所への上書き」であるため、ランサムウェアに暗号化されると元のデータは失われますが、OpenZFSは「既存の場所には一切触れず、常に空き領域へ書き足す(Copy-on-Write)」方式をとります。 |
| 2. 現場での進め方と対策 | 現場で陥りやすい落とし穴と回避策:ZFSと不変バックアップ運用の3つの急所 | すべてのデータは128バイトの管理票(blkptr_t)と256ビットのデジタル指紋で家系図(マークル木)のように結ばれており、1ビットの劣化や改ざんも自動で検知・修復します。 |
| 3. 関連する制度とルール | 「毎日夜中に外付けハードディスクやNASへコピーしている」のと、ZFSのスナップショットは何が違うのですか? | 過去の目次(Uberblock)を指し示す不変スナップショットと zfs hold ロックにより、万が一の感染時もデータのコピー待ち時間ゼロ(約0.1秒のポインタ切替)で感染前の状態へ巻き戻せます。 |
| 4. 詳しい技術仕様としくみ | 万が一サーバーの管理者権限(root権限)が乗っ取られたら、スナップショットも消されてしまいませんか? | 一般的なファイルコピーは、1テラバイトのデータがあれば毎回何時間もかけて別の場所へ複製し、しかもコピー先のドライブがパソコンや社内ネットワークとつながったままになっています。そのためランサムウェアに侵入されるとコピー先まで一緒に暗号化されてしまいます。一方、OpenZFSのスナップショットは「既存のデータを一切上書きせず、変更差分だけを別領域へ書き足す(Copy-on-Write)」仕組みのため、容量が何テラバイトあっても0.1秒で記録が完了し、過去の時点のデータは読み取り専用として保護されます。 |
- 01 一般的なファイル保存は「同じ場所への上書き」であるため、ランサムウェアに暗号化されると元のデータは失われますが、OpenZFSは「既存の場所には一切触れず、常に空き領域へ書き足す(Copy-on-Write)」方式をとります。
-
02
すべてのデータは128バイトの管理票(
blkptr_t)と256ビットのデジタル指紋で家系図(マークル木)のように結ばれており、1ビットの劣化や改ざんも自動で検知・修復します。 -
03
過去の目次(Uberblock)を指し示す不変スナップショットと
zfs holdロックにより、万が一の感染時もデータのコピー待ち時間ゼロ(約0.1秒のポインタ切替)で感染前の状態へ巻き戻せます。
なぜ従来のバックアップでは復旧に数週間〜数千万円かかってしまうのか、そして「コピーし直すのではなく時間を巻き戻す」ZFSがどのように事業停止を防ぐのかがわかります。
スナップショットの空き容量枯渇リスク、権限分離(zfs allow / zfs hold)、月次の定期検査(zpool scrub)など、現場で失敗しない運用設計の勘所を確認できます。
後半の「第3章 Monograph」にて、128バイト blkptr_t のビットフィールド配置、Fletcher-4巡回和の数理、128スロットUberblockリングバッファ、Rust製検証器を詳解しています。
身近なたとえで見る仕組み:「黒板を消して書き直す記録」と「全ページに指紋を押して綴じる公証役場の台帳」
一般的なハードディスクやクラウドの保存方式(NTFSやext4など)は、学校の「黒板」に似ています。昨日の売上データを今日の売上データに更新するとき、黒板消しで昨日の数字を消して、同じ場所に新しい数字を上書きします。もし夜中に悪意のある侵入者(ランサムウェア)が入ってきて、黒板の文字をすべて消して暗号に書き換えてしまったら、昨日の文字はもう二度と元に戻りません。
さらに問題なのは「バックアップ」です。黒板の内容を別のノート(外付けHDDやNAS)に書き写す作業は、データが大きくなると何時間もかかります。しかも復元するときには、何百ギガバイトものデータをもう一度最初から書き写し直す必要があるため、業務再開までに何日もかかってしまいます。
これに対して、私たちがデータベース基盤(db01.ok.st)に採用している「OpenZFS(オープン・ゼットエフエス)」は、「一度書いたページには一切触れず、変更があれば必ず新しい白紙のページに書き足し、表紙の『目次しおり』だけを付け替える」という動き方をします。15分前の目次しおり(スナップショット)は読み取り専用として保管されているため、万が一ウイルスが新しいページに落書きをしても、「目次しおりを15分前のページに戻すだけ(約0.1秒)」で、何百ギガバイトのデータが瞬時に元の姿へ蘇るのです。
データを物理コピーするのではなく、木の根元(Uberblock)の参照先を固定するだけなので、データ量に比例せず一瞬でスナップショットが完了します。
すべてのデータブロックに対して256ビットの検査値を親ブロック側に記録し、ディスクの微細な磁気劣化(ビット腐敗)を100%自動検出します。
「最近読まれたデータ」と「何回も繰り返し読まれる帳簿データ」を自動で学習・分離し、大量の一括検索が来てもキャッシュが消えない高速応答を維持します。
| 比較項目 | 一般的なファイルシステム(ext4 / NTFS)+ 夜間バックアップ | 当社標準:OpenZFS マークル木 + 不変スナップショット |
|---|---|---|
| データ更新時の動き | 元のブロックを直接上書きする(In-Place Overwrite) | 元のブロックには一切触れず、新しい空き領域へ書き出す(Copy-on-Write) |
| チェックサム(指紋)の保管場所 | データと同じブロック内(または検査なし)のため、丸ごと化けると検知不能 | 親ブロックの blkptr_t 内に保持するため、ディスクの書き込み抜けも100%検知 |
| 1TBデータの巻き戻し時間 | ネットワークや外付け媒体から全量を書き戻すため数時間〜数日 | ルートポインタ(Uberblock)を切り替えるだけのため数秒で完了 |
| メモリキャッシュの賢さ | 単純なLRU(最後に読まれた順)のため、1回の集計処理でキャッシュが消える | ARC(MRU/MFU二重管理)により、頻出する帳簿インデックスを常時メモリ保持 |
現場で陥りやすい落とし穴と回避策:ZFSと不変バックアップ運用の3つの急所
回避策:refreservation とデータセット単位の quota 設定、および 80% 満杯率の厳守
Copy-on-Write 方式の唯一の弱点は、「過去のデータを残したまま新しい書き込みを受け入れるため、万が一ウイルスが全ファイルを暗号化しようとすると、最大で元のデータと同じだけの空き領域を消費する」点です。また、ZFSはディスク全体の使用率が80%〜85%を超えると空き領域を探す方式が切り替わり、書き込み速度が低下します。そのため、当社ではデータセットごとに上限(quota)を設け、プール全体の空き領域を常時30%以上確保するとともに、異常な書き込み速度を検知した瞬間にプロセスを停止させて容量枯渇を防ぎます。
回避策:PostgreSQL 19 のページサイズに合わせて recordsize=8K と compression=lz4 を厳密に一致させる
OpenZFS の標準ブロックサイズ(recordsize)は 128KB ですが、PostgreSQL 19 はデータを 8KB のページ単位で書き換えます。もし 128KB のまま運用すると、たった1行の伝票を更新するたびに「128KBを読み出して、8KBを書き換え、再び128KB全体のチェックサムを計算して書き出す(Read-Modify-Write増幅)」という16倍の無駄な負荷がかかります。当社の db01.ok.st では、データベース領域に必ず recordsize=8K を指定し、物理I/Oの増幅を1.0倍(ゼロ増幅)に抑え込んでいます。
回避策:保管庫側から取りに行く「Pull型レプリケーション」と zfs hold による削除ロックの順守
もし本番サーバーの中に「バックアップ先へログインできる秘密鍵」が置かれていたら、本番サーバーに侵入した攻撃者はその鍵を使ってバックアップ先にも侵入し、データを消去してしまいます。正しくは、矢印を逆向きにし、「外部から一切ログインできない隔離保管庫(Vault)側から、本番サーバーの読み取り専用スナップショットを引き抜く(Pull型)」構成にします。さらに本番側でも zfs hold でカーネル参照ロックをかけることで、自動整理スクリプトや誤操作によるスナップショット消去を防ぎます。
今日から確認できる!不変バックアップと迅速復旧 3項目チェック
達成度: 0 / 3クリックしてチェックを入れると、御社のデータ保護体制に応じた診断コメントが表示されます。
OpenZFS マークル木整合性・zfs hold 不変ロックの一次検証手順
親ブロック側が子ブロックの256ビットチェックサムを保持するOpenZFSのマークル木構造では、ディスクコントローラの書き込み抜け(Lost Write / Ghost Write)も即座に検出されます。db01.ok.st における実運用検証コマンドは以下の通りです。
| ZFS / ZDB COMMAND | KERNEL INVARIANT | 検証される物理構造・保護動作 |
|---|---|---|
| zfs holds -r zroot/pgdata | ds_userrefs > 0 | root権限による zfs destroy 発行時にもカーネルが EBUSY で削除を拒絶すること |
| zpool status -v zroot | READ / WRITE / CKSUM == 0 | Fletcher-4 / SHA-256 256ビットマークル木によるサイレント破損のゼロ件維持と自動修復確認 |
| zdb -uuuu zroot | Uberblock[txg mod 128] | 最新 TXG のルート blkptr_t と 256-bit マークルルートハッシュの整合性確認 |
なぜ「ZFS ARCキャッシュ」は、月末の大量集計をしても普段の画面が遅くならないのか?
多くのシステムでは、よく使うデータをメモリに置いておくために「最近使われたもの(LRU)」を残す仕組みを使っています。しかしこの方法には弱点があり、たとえば経理担当者が「過去5年分の全取引データを一括検索(フルスキャン)」すると、その大量の一過性データによって、普段みんながよく使っている大事な顧客名簿のキャッシュがメモリから追い出されてしまい、画面が急に重くなってしまいます(キャッシュ汚染)。OpenZFSが搭載する「ARC(Adaptive Replacement Cache)」は、メモリを「1回だけ読まれた部屋(MRU)」と「2回以上繰り返し読まれた常連の部屋(MFU)」の2つに分け、アクセスの傾向に合わせて自動で仕切りの幅を調整します。そのため、誰かが大きな集計処理を走らせても常連のデータは一切追い出されず、常に0.001秒単位の軽快な応答が保たれるのです。
OpenZFS 128バイト blkptr_t 物理レイアウト・256ビット Fletcher-4 マークル木数理と Uberblock アトミックTXG検証エンジン
OpenZFS の Storage Pool Allocator(sys/fs/zfs/sys/spa.h)において、ツリー内のすべての間接ブロックおよびデータブロックは 128バイトの blkptr_t によってアドレス指定されます。先頭 48バイトは最大3つの Data Virtual Address(dva_t blk_dva[3]、各16バイト:32ビット VDEV ID + 63ビット物理セクタオフセット + 24ビット割り当てサイズ)を格納し、メタデータは自動的に二重・三重書き込み(Ditto Blocks)されます。続く 64ビット整数 blk_prop には、論理サイズ lsize(ビット 0..15)、圧縮後物理サイズ psize(ビット 16..31)、圧縮アルゴリズム識別子(ZIO_COMPRESS_LZ4 = 15、ビット 32..38)、チェックサム関数識別子(ビット 40..47)、およびエンディアン標識(ビット 63)がパックされています。
末端 32バイト(オフセット +0x60..+0x7F)の zio_cksum_t blk_cksum は、子ブロックの物理ペイロード W = (w0, w1, …, wM−1) ∈ (ℤ232)M に対する 256ビット検証値(4つの64ビットワード A, B, C, D)を保持します。高速データパスで標準使用される Fletcher-4 は以下の4次累積漸化式で定義されます:
Transaction Group(TXG)が Open → Quiescing → Syncing フェーズを経てディスクへフラッシュされる際、変更された葉ブロックから Meta Object Set(MOS)に至る全経路が新しい DVA に書き出され、最後に各 VDEV の4つの 256KB ラベル(L0, L1: 先頭、L2, L3: 末尾)内にある 128スロットの Uberblock 配列のうち、スロット s = txg mod 128 がアトミックに更新されます。zfs hold が適用されたスナップショットは ds_userrefs > 0 を保持するため、dsl_destroy_snapshot_check_impl() がカーネルレベルで EBUSY を返し、削除要求を拒絶します。
| Byte Offset | Struct / Field Identifier | Width / Encoding | Storage & Cryptographic Invariant |
|---|---|---|---|
| blkptr_t +0x00..0x2F | dva_t blk_dva[0..2] | 48 Bytes (3 x 128-bit) | Up to 3 physical copies (Ditto blocks) across VDEVs |
| blkptr_t +0x30..0x37 | uint64_t blk_prop | 8 Bytes Bitfield | LSIZE(16b), PSIZE(16b), LZ4(7b), CKSUM(8b), LEVEL(5b), TYPE(8b) |
| blkptr_t +0x48..0x57 | blk_phys_birth / blk_birth | 16 Bytes (2 x uint64_t) | TXG epoch when block was allocated; governs CoW freeing |
| blkptr_t +0x58..0x5F | uint64_t blk_fill | 8 Bytes Counter | Count of non-hole child blocks below this subtree node |
| blkptr_t +0x60..0x7F | zio_cksum_t blk_cksum | 32 Bytes (256-bit) | Parent-verified Fletcher-4 / SHA-256 hash of child block |
| uberblock_t +0x00..0x9F | ub_magic (0x00bab10c), ub_txg, ub_rootbp | 160 Bytes + 128B Root BP | Atomic tree root pointer updated at end of TXG Syncing |
#![no_std]
#![forbid(unsafe_code)]
/// OpenZFS Uberblock magic number (`0x00bab10c` in native endianness)
pub const UBERBLOCK_MAGIC: u64 = 0x00ba_b10c;
/// Exact 128-byte representation of OpenZFS `blkptr_t` (`sys/fs/zfs/sys/spa.h`)
#[repr(C, align(8))]
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct ZfsBlkptr128 {
pub blk_dva: [[u64; 2]; 3], // +0x00..0x30: 3 Data Virtual Addresses (VDEV + Offset + ASIZE)
pub blk_prop: u64, // +0x30..0x38: Bitfield (lsize, psize, compress, cksum, type, level)
pub blk_pad: [u64; 2], // +0x38..0x48: Reserved / Encrypted IV words
pub blk_phys_birth: u64, // +0x48..0x50: Physical birth TXG
pub blk_birth: u64, // +0x50..0x58: Logical birth TXG
pub blk_fill: u64, // +0x58..0x60: Subtree fill count
pub blk_cksum: [u64; 4], // +0x60..0x80: 256-bit Fletcher-4 / SHA-256 checksum
}
const _: () = assert!(core::mem::size_of::<ZfsBlkptr128>() == 128);
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct DecodedBlkProp {
pub logical_bytes: u32,
pub physical_bytes: u32,
pub compression_id: u8,
pub checksum_id: u8,
pub indirection_level: u8,
pub dmu_obj_type: u8,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ZfsAuditError {
EmbeddedOrHoleBlock,
PhysicalSizeMismatch { expected: usize, actual: usize },
MerkleChecksumMismatch { word_index: usize, expected: u64, computed: u64 },
SnapshotImmutabilityViolation { block_birth_txg: u64, snapshot_ceiling_txg: u64 },
InvalidUberblockMagic(u64),
}
impl ZfsBlkptr128 {
/// Decodes the 64-bit `blk_prop` bitfield per OpenZFS SPA sector-512 specification.
#[inline]
pub const fn decode_properties(&self) -> DecodedBlkProp {
let lsize_sectors = (self.blk_prop & 0xFFFF) + 1;
let psize_sectors = ((self.blk_prop >> 16) & 0xFFFF) + 1;
let compression_id = ((self.blk_prop >> 32) & 0x7F) as u8;
let checksum_id = ((self.blk_prop >> 40) & 0xFF) as u8;
let dmu_obj_type = ((self.blk_prop >> 48) & 0xFF) as u8;
let indirection_level = ((self.blk_prop >> 56) & 0x1F) as u8;
DecodedBlkProp {
logical_bytes: (lsize_sectors as u32) * 512,
physical_bytes: (psize_sectors as u32) * 512,
compression_id,
checksum_id,
indirection_level,
dmu_obj_type,
}
}
/// Verifies both Copy-on-Write snapshot immutability (`blk_birth <= snapshot_ceiling_txg`)
/// and the 256-bit Fletcher-4 Merkle hash over the child payload words.
pub const fn verify_child_block_fletcher4(
&self,
child_words_le: &[u32],
snapshot_ceiling_txg: u64,
) -> Result<DecodedBlkProp, ZfsAuditError> {
if self.blk_birth == 0 {
return Err(ZfsAuditError::EmbeddedOrHoleBlock);
}
if self.blk_birth > snapshot_ceiling_txg {
return Err(ZfsAuditError::SnapshotImmutabilityViolation {
block_birth_txg: self.blk_birth,
snapshot_ceiling_txg,
});
}
let props = self.decode_properties();
let actual_bytes = child_words_le.len() * 4;
if actual_bytes != (props.physical_bytes as usize) {
return Err(ZfsAuditError::PhysicalSizeMismatch {
expected: props.physical_bytes as usize,
actual: actual_bytes,
});
}
let mut a: u64 = 0;
let mut b: u64 = 0;
let mut c: u64 = 0;
let mut d: u64 = 0;
let mut i = 0;
while i < child_words_le.len() {
a = a.wrapping_add(child_words_le[i] as u64);
b = b.wrapping_add(a);
c = c.wrapping_add(b);
d = d.wrapping_add(c);
i += 1;
}
let computed = [a, b, c, d];
let mut w = 0;
while w < 4 {
if computed[w] != self.blk_cksum[w] {
return Err(ZfsAuditError::MerkleChecksumMismatch {
word_index: w,
expected: self.blk_cksum[w],
computed: computed[w],
});
}
w += 1;
}
Ok(props)
}
}