TRENDING
Macro photo of an open hard disk drive showing the read-write head suspended just above a reflective platter
September 15, 2026
How to Build a Changed Block Tracking System in Python to Speed Up Incremental Backups
A massive rusted anchor chain shackle lying on a pebble beach, its iron surface deeply corroded orange and brown
September 15, 2026
Microsoft’s Humanist AI Code of Conduct Turns Agent Containment Into a Chain of Command
A hand holds a hypodermic syringe with visible dosage markings against a black background, illustrating a SQL injection vulnerability
September 15, 2026
CISA Orders Federal Agencies to Patch an Actively Exploited Cisco Email Gateway Flaw by September 17
A physical slide dimmer light switch positioned partway along its track, next to a wall outlet plate
September 15, 2026
How to Build a Feature Flag System in Python With Sticky Percentage Rollouts
Leeds Castle's medieval moat, portcullis gate, and stone bridge reflected in the water
September 15, 2026
Fyxer’s OpenAI Case Study Turns a Decade of Assistant Work Into an AI Moat
15 Sep 2026
SXZ.io SXZ.io
  • Home
Search the Site
Popular Searches:
Technology Amazon AI
Recent Posts
Two different ropes spliced together into one continuous line, a visual metaphor for combining two independent cryptographic secrets into one hybrid key
How to Build a Hybrid Post-Quantum Key Exchange in Python With X25519 and ML-KEM
September 15, 2026
An ABB industrial robot arm and a black mobile AGV robot standing together on a factory floor
Canonical’s Zenoh Snaps Turn ROS 2’s Middleware Fix Into a Packaging Decision
September 15, 2026
Macro photo of a ceramic microcontroller chip with an exposed gold die, representing embedded device security
Italy’s Exein Raises $270 Million to Build a Foundation Model for Physical AI Security
September 15, 2026
SXZ.io SXZ.io
  • Home

Categories

Articles 188 Posts
News 188 Posts
Learning Hub 159 Posts
Home/Learning Hub/How to Build a Changed Block Tracking System in Python to Speed Up Incremental Backups
Learning Hub

How to Build a Changed Block Tracking System in Python to Speed Up Incremental Backups

Learn how changed block tracking works by building a real Python implementation that detects modified disk blocks, ships incremental backups, and safely restores a chain of them.

September 15, 2026 21 Min Read
6

Imagine you are running a database on a 500 gigabyte Kubernetes persistent volume, and a backup job needs to protect it every few hours. Copying all 500 gigabytes every single run wastes network bandwidth, ties up the storage backend, and can blow straight through a maintenance window, even though maybe 50 megabytes of that volume actually changed since the last backup. Changed block tracking is the technique that fixes this: instead of copying an entire volume, you work out exactly which fixed-size chunks of data (blocks) changed since the last backup, and copy only those.

Table Of Content

  • What You Will Build
  • Prerequisites
  • Step 1: Build the Shared Checksumming and Diffing Library
  • Step 2: Create a Simulated Volume and Take a Baseline Snapshot
  • Step 3: The Naive Approach, a Full Backup Every Time
  • Step 4: Simulate Writes and Detect Which Blocks Changed
  • Step 5: Turn the Diff Into an Incremental Backup and Measure the Savings
  • Step 6: Chain Two Incrementals, and a Gotcha That Corrupts Restores Silently
  • Step 7: Why Block Size Is a Tradeoff, Not a Free Parameter
  • How This Maps to Kubernetes’ Real Changed Block Tracking API
  • Why Kubernetes Needed This At All
  • The Three Real Components
  • The Real gRPC Service Definition
  • What Changed in the September 2026 Beta
  • What This Tutorial Deliberately Simplifies
  • Common Mistakes and Gotchas
  • Step 8: Verify Everything With an Automated Test Suite
  • Verify the Whole Thing End to End
  • Next Steps

This is not a hypothetical problem, and it is not old news either. Kubernetes’ own native Changed Block Tracking (CBT) API moved to Beta in March 2026, and a September 14, 2026 blog post from the project walks through exactly what changed in that release. The API lets backup vendors ask a storage driver “which blocks changed between these two volume snapshots” over a standard gRPC interface instead of reverse-engineering every storage vendor’s proprietary format. By the end of this tutorial you will understand exactly what that API does and why, because you will have built a working, simplified version of the same idea yourself, entirely in Python, with no cluster, no CSI driver, and no cloud account required.

What You Will Build

You will build a small changed-block-tracking library from scratch that:

  • Divides a simulated storage volume into fixed-size blocks and fingerprints each one with a checksum
  • Compares two fingerprint snapshots to find exactly which blocks changed
  • Merges adjacent changed blocks into larger contiguous ranges, the same optimization the real API uses
  • Writes an incremental backup containing only the changed bytes, and measures the real savings
  • Chains two incremental backups together, and personally reproduces a dangerous, silent-corruption bug that happens when you apply them out of order
  • Measures, with real numbers, why block size is a genuine tradeoff and not a free parameter

Along the way you will see exactly how this maps onto Kubernetes’ real Changed Block Tracking API: the actual gRPC service definition, the actual message field names, and the actual components that make it work in production.

Prerequisites

  • Python 3.10 or newer. This tutorial was written and personally tested on Python 3.13.14, but nothing here depends on a feature newer than 3.10.
  • Comfort with basic file I/O, dictionaries, and reading a small class. No prior storage, backup, or Kubernetes experience is assumed; every term is defined the first time it is used.
  • Everything here uses only the Python standard library: hashlib, dataclasses, json, random, shutil. pytest is used only for the optional automated test suite in the last step (pip install pytest).
  • No live Kubernetes cluster, CSI storage driver, or cloud account of any kind. This tutorial builds the underlying concept from scratch; a later section shows exactly how it maps onto the real Kubernetes API so you know what to look for once you do have a cluster.

Step 1: Build the Shared Checksumming and Diffing Library

Everything in this tutorial is built on four small, testable functions. Put them in a file named cbt_lib.py, since every later step imports from it.

"""Shared building blocks for the changed block tracking demo."""
import hashlib
import json
from dataclasses import dataclass, asdict


@dataclass(frozen=True)
class BlockMetadata:
    """Mirrors the real CSI/Kubernetes SnapshotMetadata BlockMetadata message:
    a zero-based byte offset plus a size, describing one contiguous data range.
    """
    byte_offset: int
    size_bytes: int


def checksum_blocks(path, block_size):
    """Read a file block by block and return {block_index: sha256_hex_digest}.

    This dict is our stand-in for a storage snapshot: a compact fingerprint
    of every block's contents at one point in time, without keeping the
    block contents themselves.
    """
    checksums = {}
    with open(path, "rb") as f:
        index = 0
        while True:
            block = f.read(block_size)
            if not block:
                break
            checksums[index] = hashlib.sha256(block).hexdigest()
            index += 1
    return checksums


def changed_block_indices(base_checksums, target_checksums):
    """Compare two per-block checksum snapshots and return the sorted list
    of block indices whose checksum differs (added, removed, or modified).
    """
    changed = []
    for index in sorted(set(base_checksums) | set(target_checksums)):
        if base_checksums.get(index) != target_checksums.get(index):
            changed.append(index)
    return changed


def merge_into_ranges(block_indices, block_size):
    """Merge a sorted list of block indices into contiguous BlockMetadata
    ranges: adjacent changed blocks get reported as one bigger range instead
    of many small ones.
    """
    ranges = []
    for index in block_indices:
        offset = index * block_size
        if ranges and ranges[-1].byte_offset + ranges[-1].size_bytes == offset:
            prev = ranges[-1]
            ranges[-1] = BlockMetadata(prev.byte_offset, prev.size_bytes + block_size)
        else:
            ranges.append(BlockMetadata(offset, block_size))
    return ranges

Two design choices are worth calling out before moving on. First, checksum_blocks returns a plain dictionary keyed by block index, not a list; a real volume snapshot at time A and time B might not even have the same number of blocks if the volume grew, and a dict makes that comparison trivial with set(base) | set(target). Second, the BlockMetadata dataclass’s two field names, byte_offset and size_bytes, are not arbitrary. They are the exact field names used by the real Kubernetes and CSI SnapshotMetadata API, which the last major section of this tutorial shows you verbatim.

Add two more functions to the same file for computing a whole-file checksum (used only to verify a restore later) and for actually copying changed ranges into an incremental backup file plus a small JSON manifest describing where each range came from:

def sha256_file(path):
    """Whole-file SHA-256, used only to verify a restore is byte-for-byte correct."""
    h = hashlib.sha256()
    with open(path, "rb") as f:
        while True:
            chunk = f.read(1024 * 1024)
            if not chunk:
                break
            h.update(chunk)
    return h.hexdigest()


def write_incremental_backup(volume_path, ranges, delta_path, manifest_path):
    """Copy only the given byte ranges out of volume_path into delta_path,
    back to back, and write a JSON manifest recording which range landed at
    which offset, so a restore can put each range back where it belongs.
    """
    manifest = []
    total_bytes = 0
    with open(volume_path, "rb") as src, open(delta_path, "wb") as dst:
        for r in ranges:
            src.seek(r.byte_offset)
            data = src.read(r.size_bytes)
            dst.write(data)
            manifest.append({"byte_offset": r.byte_offset, "size_bytes": r.size_bytes})
            total_bytes += len(data)
    with open(manifest_path, "w", encoding="utf-8") as f:
        json.dump(manifest, f)
    return total_bytes


def apply_incremental(restore_path, delta_path, manifest_path):
    """Overlay one incremental backup's changed ranges onto restore_path,
    which must already exist (either the base backup or the result of
    applying earlier incrementals in the chain).
    """
    with open(manifest_path, "r", encoding="utf-8") as f:
        manifest = json.load(f)
    with open(delta_path, "rb") as delta, open(restore_path, "r+b") as dst:
        for entry in manifest:
            data = delta.read(entry["size_bytes"])
            dst.seek(entry["byte_offset"])
            dst.write(data)

Step 2: Create a Simulated Volume and Take a Baseline Snapshot

Real storage volumes (a cloud disk, a SAN LUN, a Kubernetes PersistentVolume backed by a CSI driver) are enormous arrays of bytes divided into fixed-size blocks, typically 512 bytes to a few kilobytes each. Simulate one with a plain file on disk, using a 4096-byte block size, which matches a common filesystem block size:

"""Step 1: create a simulated block volume and take our first ('base') snapshot."""
import random

from cbt_lib import checksum_blocks

VOLUME_PATH = "volume.bin"
BLOCK_SIZE = 4096
VOLUME_SIZE = 10 * 1024 * 1024  # 10 MiB


def create_volume(path, size, seed):
    rng = random.Random(seed)
    with open(path, "wb") as f:
        remaining = size
        while remaining > 0:
            chunk = min(1024 * 1024, remaining)
            f.write(rng.randbytes(chunk))
            remaining -= chunk


if __name__ == "__main__":
    create_volume(VOLUME_PATH, VOLUME_SIZE, seed=42)
    base_snapshot = checksum_blocks(VOLUME_PATH, BLOCK_SIZE)

    print(f"Volume size:      {VOLUME_SIZE:,} bytes")
    print(f"Block size:       {BLOCK_SIZE:,} bytes")
    print(f"Number of blocks: {len(base_snapshot):,}")
    print("First 3 block checksums (first 12 hex chars):")
    for i in range(3):
        print(f"  block {i}: {base_snapshot[i][:12]}")

Save this as step1_setup.py and run it. It uses a seeded random generator, so anyone running it gets identical output:

Volume size:      10,485,760 bytes
Block size:       4,096 bytes
Number of blocks: 2,560
First 3 block checksums (first 12 hex chars):
  block 0: 27ea8434ddf3
  block 1: 7596317f7671
  block 2: d48c92a570a8

base_snapshot is your first changed-block-tracking snapshot. It is not a copy of the volume, it is a dictionary of 2,560 short hex strings, one per block. That is the whole trick behind cheap change detection: keep a small fingerprint of what a block looked like, not the block itself, and compare fingerprints later instead of comparing full contents.

Step 3: The Naive Approach, a Full Backup Every Time

Before building anything clever, establish the baseline every backup system starts from: copy the entire volume.

"""Step 2: the naive approach every backup starts with: copy the entire volume."""
import os
import shutil

from step1_setup import VOLUME_PATH

FULL_BACKUP_PATH = "backup_full_0.bin"

if __name__ == "__main__":
    shutil.copyfile(VOLUME_PATH, FULL_BACKUP_PATH)
    size = os.path.getsize(FULL_BACKUP_PATH)
    print(f"Full backup 0 written: {size:,} bytes copied out of the volume")

Save as step2_full_backup.py and run it:

Full backup 0 written: 10,485,760 bytes copied out of the volume

On a local 10 MiB test file, copying takes a fraction of a second no matter which approach you use, so timing this locally would not teach you much. What changed block tracking actually saves in production is network egress and remote storage cost for a volume that might be hundreds of gigabytes and gets backed up every few hours. The metric that matters is bytes moved, not wall-clock time on a laptop, so from here on this tutorial measures bytes.

Step 4: Simulate Writes and Detect Which Blocks Changed

Now simulate an application actually using the volume: write to a scattered handful of blocks, the way a database or filesystem naturally would, then take a second snapshot and diff it against the first.

"""Step 3: simulate a batch of writes to the volume, take a second snapshot,
and compute which blocks actually changed.
"""
import random

from cbt_lib import checksum_blocks, changed_block_indices, merge_into_ranges
from step1_setup import VOLUME_PATH, BLOCK_SIZE


def pick_change_pattern(rng, total_blocks, n_clusters, cluster_sizes):
    """Pick a realistic mix of isolated single-block writes and small
    multi-block runs (a program rewriting one field touches one block;
    rewriting a whole record touches several adjacent ones).
    """
    indices = set()
    for _ in range(n_clusters):
        start = rng.randrange(0, total_blocks - 10)
        size = rng.choice(cluster_sizes)
        for i in range(size):
            if start + i < total_blocks:
                indices.add(start + i)
    return indices


def apply_writes(path, block_indices, block_size, seed):
    rng = random.Random(seed)
    with open(path, "r+b") as f:
        for index in sorted(block_indices):
            f.seek(index * block_size)
            f.write(rng.randbytes(block_size))


if __name__ == "__main__":
    base_snapshot = checksum_blocks(VOLUME_PATH, BLOCK_SIZE)
    total_blocks = len(base_snapshot)

    pattern_rng = random.Random(1001)
    round1_indices = pick_change_pattern(
        pattern_rng, total_blocks, n_clusters=15, cluster_sizes=(1, 1, 1, 2, 3, 5)
    )
    apply_writes(VOLUME_PATH, round1_indices, BLOCK_SIZE, seed=2001)

    snapshot1 = checksum_blocks(VOLUME_PATH, BLOCK_SIZE)
    changed = changed_block_indices(base_snapshot, snapshot1)
    ranges = merge_into_ranges(changed, BLOCK_SIZE)

    print(f"Blocks actually written in round 1: {len(round1_indices)}")
    print(f"Blocks flagged as changed by the diff: {len(changed)}")
    print(f"Merged into {len(ranges)} contiguous ranges:")
    for r in ranges:
        print(f"  offset={r.byte_offset:>9,}  size={r.size_bytes:>6,}")

    with open("round1_indices.txt", "w") as f:
        f.write(",".join(str(i) for i in sorted(round1_indices)))

Save as step3_first_change_round.py and run it:

Blocks actually written in round 1: 42
Blocks flagged as changed by the diff: 42
Merged into 14 contiguous ranges:
  offset=  315,392  size=12,288
  offset=  847,872  size= 4,096
  offset=  983,040  size= 4,096
  offset=1,527,808  size=12,288
  offset=1,691,648  size=20,480
  offset=3,145,728  size=12,288
  offset=3,338,240  size=12,288
  offset=6,279,168  size=12,288
  offset=6,328,320  size= 4,096
  offset=6,725,632  size=12,288
  offset=6,819,840  size=20,480
  offset=8,269,824  size=20,480
  offset=10,256,384  size= 4,096
  offset=10,280,960  size=20,480

Two things to notice. First, the diff caught exactly 42 of 42 changed blocks, with zero false positives and zero misses, because SHA-256 checksums make an undetected collision between two different 4,096-byte blocks astronomically unlikely. Second, 42 individually changed blocks collapsed into only 14 ranges once adjacent blocks were merged, because some of the writes landed next to each other (a 5-block cluster becomes one 20,480-byte range instead of five separate 4,096-byte entries). That merge step matters a lot once you get to Step 5’s numbers: fewer, larger ranges mean less bookkeeping overhead for whatever consumes this metadata next.

Step 5: Turn the Diff Into an Incremental Backup and Measure the Savings

Now use those ranges for what they exist for: copy only the changed bytes into a small incremental backup, instead of copying the whole volume again.

"""Step 4: write an incremental backup containing only the changed ranges,
and measure how many bytes it actually saves compared to a second full backup.
"""
import os

from cbt_lib import checksum_blocks, changed_block_indices, merge_into_ranges, write_incremental_backup
from step1_setup import VOLUME_PATH, BLOCK_SIZE, VOLUME_SIZE
from step2_full_backup import FULL_BACKUP_PATH

if __name__ == "__main__":
    base_snapshot = checksum_blocks(FULL_BACKUP_PATH, BLOCK_SIZE)
    current_snapshot = checksum_blocks(VOLUME_PATH, BLOCK_SIZE)

    changed = changed_block_indices(base_snapshot, current_snapshot)
    ranges = merge_into_ranges(changed, BLOCK_SIZE)

    bytes_written = write_incremental_backup(
        VOLUME_PATH, ranges, "delta_1.bin", "delta_1_manifest.json"
    )

    full_backup_bytes = VOLUME_SIZE
    savings_pct = (1 - bytes_written / full_backup_bytes) * 100

    print(f"A second full backup would copy: {full_backup_bytes:,} bytes")
    print(f"Incremental backup 1 copies:     {bytes_written:,} bytes")
    print(f"Bytes saved:                     {savings_pct:.1f}%")
    print(f"delta_1.bin on disk:              {os.path.getsize('delta_1.bin'):,} bytes")

Save as step4_incremental_backup.py and run it:

A second full backup would copy: 10,485,760 bytes
Incremental backup 1 copies:     172,032 bytes
Bytes saved:                     98.4%
delta_1.bin on disk:              172,032 bytes

That is a 98.4 percent reduction in bytes moved and stored for this particular change pattern, purely because the diff let the backup skip the 2,518 blocks (2,560 total minus the 42 that changed) that never changed. In a database that only touches a small fraction of its pages between backups, and most real databases do, this kind of ratio is exactly why backup vendors care so much about getting this right.

Step 6: Chain Two Incrementals, and a Gotcha That Corrupts Restores Silently

One incremental backup is not the whole story. In practice you take a base backup, then an incremental, then another incremental, and so on, and eventually you have to restore from a chain of them. This step simulates a second round of writes, takes a second incremental backup relative to the first, and then does two restores: one correct, and one that skips a step to show exactly what breaks.

"""Step 5: a second round of writes, a second incremental backup, and the
backup-chain gotcha: incrementals must be replayed in order.
"""
import random
import shutil

from cbt_lib import (
    checksum_blocks,
    changed_block_indices,
    merge_into_ranges,
    write_incremental_backup,
    apply_incremental,
    sha256_file,
)
from step1_setup import VOLUME_PATH, BLOCK_SIZE
from step2_full_backup import FULL_BACKUP_PATH
from step3_first_change_round import pick_change_pattern, apply_writes

if __name__ == "__main__":
    with open("round1_indices.txt") as f:
        round1_indices = {int(x) for x in f.read().split(",")}

    # This is the state of the volume right after round 1: our "snapshot 1".
    snapshot1 = checksum_blocks(VOLUME_PATH, BLOCK_SIZE)
    total_blocks = len(snapshot1)

    pattern_rng = random.Random(1002)
    round2_pattern = pick_change_pattern(
        pattern_rng, total_blocks, n_clusters=10, cluster_sizes=(1, 1, 2, 3)
    )
    # Force a few of round 1's blocks to be touched again, so the restore
    # chain has both overlapping and non-overlapping changes to deal with.
    forced_overlap = set(sorted(round1_indices)[:3])
    round2_indices = round2_pattern | forced_overlap
    apply_writes(VOLUME_PATH, round2_indices, BLOCK_SIZE, seed=3001)

    snapshot2 = checksum_blocks(VOLUME_PATH, BLOCK_SIZE)
    changed2 = changed_block_indices(snapshot1, snapshot2)
    ranges2 = merge_into_ranges(changed2, BLOCK_SIZE)
    bytes2 = write_incremental_backup(VOLUME_PATH, ranges2, "delta_2.bin", "delta_2_manifest.json")

    round1_only = round1_indices - round2_indices
    print(f"Round 2 wrote {len(round2_indices)} blocks ({len(forced_overlap)} re-touch round 1's blocks)")
    print(f"Incremental backup 2 copies {bytes2:,} bytes across {len(ranges2)} ranges")
    print(f"Blocks round 1 changed that round 2 never touched again: {len(round1_only)}")
    print()

    # --- Correct restore: replay the chain in order: base, then incr 1, then incr 2 ---
    shutil.copyfile(FULL_BACKUP_PATH, "restore_correct.bin")
    apply_incremental("restore_correct.bin", "delta_1.bin", "delta_1_manifest.json")
    apply_incremental("restore_correct.bin", "delta_2.bin", "delta_2_manifest.json")
    correct_matches = sha256_file("restore_correct.bin") == sha256_file(VOLUME_PATH)
    print(f"Correct restore (base -> incr1 -> incr2) matches the live volume: {correct_matches}")

    # --- Wrong restore: skip incremental 1 and go straight from base to incr 2 ---
    shutil.copyfile(FULL_BACKUP_PATH, "restore_wrong.bin")
    apply_incremental("restore_wrong.bin", "delta_2.bin", "delta_2_manifest.json")
    wrong_matches = sha256_file("restore_wrong.bin") == sha256_file(VOLUME_PATH)
    wrong_checksums = checksum_blocks("restore_wrong.bin", BLOCK_SIZE)
    live_checksums = checksum_blocks(VOLUME_PATH, BLOCK_SIZE)
    corrupted_blocks = changed_block_indices(wrong_checksums, live_checksums)
    print(f"Wrong restore  (base -> incr2, incr1 skipped) matches the live volume: {wrong_matches}")
    print(f"Blocks silently wrong in that restore: {len(corrupted_blocks)}")
    print(f"  (expected to equal round-1-only block count: {len(round1_only)})")

Save as step5_second_round_and_chain.py and run it:

Round 2 wrote 18 blocks (3 re-touch round 1's blocks)
Incremental backup 2 copies 73,728 bytes across 10 ranges
Blocks round 1 changed that round 2 never touched again: 39

Correct restore (base -> incr1 -> incr2) matches the live volume: True
Wrong restore  (base -> incr2, incr1 skipped) matches the live volume: False
Blocks silently wrong in that restore: 39
  (expected to equal round-1-only block count: 39)

This is the single most important gotcha in this whole tutorial, and it deserves attention: applying delta_2 straight onto the base backup does not raise an exception, does not print a warning, and produces a file of the exact right size. It just silently reconstructs the wrong data in 39 blocks, specifically the blocks that round 1 changed and round 2 never touched again. The manifest for delta_2 only ever recorded the ranges that changed between snapshot 1 and snapshot 2; it has no way to know, and no reason to know, about changes that happened before snapshot 1 existed. An incremental backup is only ever correct relative to the specific snapshot it was taken against, which means a restore chain has to be replayed in the exact order the backups were taken, with nothing skipped. Any backup tool that lets you cherry-pick which incrementals to apply is a tool that will eventually let someone build the wrong restore chain by accident.

Step 7: Why Block Size Is a Tradeoff, Not a Free Parameter

Every example so far used a fixed 4,096-byte block size without asking whether that number was a good choice. It is worth measuring, with real numbers, why it matters. Apply the exact same tiny, scattered edits to a copy of the volume, then re-run the diff at three different block sizes.

"""Step 6: block size is a tuning knob, not a free parameter. Apply the exact
same sparse, byte-level writes and re-run the diff at three different block
sizes to see the tradeoff directly.
"""
import random
import shutil

from cbt_lib import checksum_blocks, changed_block_indices, merge_into_ranges
from step1_setup import VOLUME_PATH, VOLUME_SIZE

BASE_PATH = "tradeoff_base.bin"
EDITED_PATH = "tradeoff_edited.bin"
N_EDITS = 20
EDIT_SIZE = 4  # bytes touched per edit -- smaller than any block size we test


def apply_sparse_edits(path, n_edits, edit_size, volume_size, seed):
    rng = random.Random(seed)
    with open(path, "r+b") as f:
        for _ in range(n_edits):
            pos = rng.randrange(0, volume_size - edit_size)
            f.seek(pos)
            f.write(rng.randbytes(edit_size))


if __name__ == "__main__":
    shutil.copyfile(VOLUME_PATH, BASE_PATH)
    shutil.copyfile(VOLUME_PATH, EDITED_PATH)
    apply_sparse_edits(EDITED_PATH, N_EDITS, EDIT_SIZE, VOLUME_SIZE, seed=77)

    print(f"Applied {N_EDITS} scattered edits of {EDIT_SIZE} bytes each "
          f"({N_EDITS * EDIT_SIZE} real bytes changed) to a copy of the volume.")
    print()
    header = f"{'block size':>12} {'changed blocks':>15} {'ranges':>8} {'bytes to copy':>15} {'overhead vs real bytes':>24}"
    print(header)

    for block_size in (512, 4096, 65536):
        base_sums = checksum_blocks(BASE_PATH, block_size)
        edited_sums = checksum_blocks(EDITED_PATH, block_size)
        changed = changed_block_indices(base_sums, edited_sums)
        ranges = merge_into_ranges(changed, block_size)
        total_bytes = sum(r.size_bytes for r in ranges)
        overhead = total_bytes / (N_EDITS * EDIT_SIZE)
        print(f"{block_size:>12,} {len(changed):>15,} {len(ranges):>8,} {total_bytes:>15,} {overhead:>23.1f}x")

Save as step6_blocksize_tradeoff.py and run it:

Applied 20 scattered edits of 4 bytes each (80 real bytes changed) to a copy of the volume.

  block size  changed blocks   ranges   bytes to copy   overhead vs real bytes
         512              20       20          10,240                   128.0x
       4,096              20       20          81,920                  1024.0x
      65,536              20       14       1,310,720                 16384.0x

Only 80 real bytes changed across the whole 10 MiB volume, but the amount of data that has to move in the incremental backup depends entirely on the block size: 10,240 bytes at 512-byte blocks, 81,920 bytes at 4,096-byte blocks, and a striking 1,310,720 bytes, more than 16,000 times the real change, at 65,536-byte blocks. A single 4-byte write anywhere inside a block forces the entire block to be treated as changed, since that is the smallest unit the system can report on. The number of ranges tells the other half of the story: at 65,536 bytes, some of the 20 scattered edits happened to land inside the same 64 KB block, so 20 changed blocks collapsed into only 14 ranges, meaning less metadata to track but far more wasted bytes per range. Smaller blocks minimize wasted bytes in the incremental backup at the cost of more metadata entries to track; larger blocks minimize metadata at the cost of copying far more unchanged data alongside every real change. There is no universally correct block size, only a tradeoff tuned to how sparse or clustered your real write pattern is.

How This Maps to Kubernetes’ Real Changed Block Tracking API

Everything above is a from-scratch, checksum-based approximation built so it can run with nothing but the Python standard library. The real Kubernetes feature solves the exact same problem with a formally specified gRPC service. Understanding the mapping is what lets you read the real API’s documentation and immediately know what each piece is for.

Why Kubernetes Needed This At All

Kubernetes’ own announcement of the alpha version of this feature lays out the motivation plainly. Traditional full-volume backup approaches face three problems: “long backup windows” where “full volume backups can take hours for large datasets, making it difficult to complete within maintenance windows”; “high resource utilization” since “backup operations consume substantial network bandwidth and I/O resources”; and “increased storage costs” because “repetitive full backups store redundant data, causing storage requirements to grow linearly even when only a small percentage of data actually changes between backups.” Those are precisely the three costs Step 5’s 98.4 percent savings number was demonstrating on a toy scale.

The Three Real Components

That same announcement describes the implementation as three primary components. The CSI SnapshotMetadata Service API is “an API, offered by gRPC, that provides volume snapshot and changed block data,” and is implemented by the storage vendor’s own CSI driver. The SnapshotMetadataService API is “a Kubernetes CustomResourceDefinition (CRD) that advertises CSI driver metadata service availability and connection details to cluster clients,” so a backup application can discover which drivers on the cluster support this at all. The External Snapshot Metadata Sidecar is “an intermediary component that connects CSI drivers to backup applications via a standardized gRPC interface,” the piece that actually sits between a backup tool and the driver. The Kubernetes CSI developer documentation describes what that sidecar actually does day to day: it authenticates and authorizes each backup application’s request, then “acts as a proxy as it fetches the desired metadata from the CSI driver and streams it directly to the requesting application,” which keeps that traffic off the Kubernetes API server entirely.

The Real gRPC Service Definition

The real protobuf schema, published in the kubernetes-csi/external-snapshot-metadata repository, defines a service with exactly two RPCs:

service SnapshotMetadata {
  rpc GetMetadataAllocated(GetMetadataAllocatedRequest)
      returns (stream GetMetadataAllocatedResponse) {}
  rpc GetMetadataDelta(GetMetadataDeltaRequest)
      returns (stream GetMetadataDeltaResponse) {}
}

message BlockMetadata {
  int64 byte_offset = 1;
  int64 size_bytes = 2;
}

GetMetadataAllocated answers “which parts of this single snapshot actually have data in them,” useful for a first, base backup of a sparse volume. GetMetadataDelta answers “which parts changed between this base snapshot and this later target snapshot,” exactly the question changed_block_indices() answers in this tutorial, and it takes a base_snapshot_id plus a target_snapshot_name as arguments for that reason. Both RPCs return a list of BlockMetadata messages, and as you can see, the real schema uses the same byte_offset and size_bytes field names this tutorial’s BlockMetadata dataclass borrowed on purpose.

Notice both RPCs return a stream of responses, not one single response. A real volume can be measured in terabytes, so its full changed-range list might not fit comfortably in one gRPC message or in memory at once. That is also why both request messages include a starting_offset field: per the spec’s own comments, a client “should specify this value to be the offset of the byte position immediately after the last byte of the last data range received, if continuing an interrupted operation,” which is the production-grade version of resuming a paused download. This tutorial’s 10 MiB test volume never needed that, since its entire changed-range list comfortably fits in a Python list in memory, but a real backup vendor’s client code has to handle exactly this.

What Changed in the September 2026 Beta

This feature shipped as Alpha in September 2025, and moved to Beta with the external-snapshot-metadata project’s v1.0.0 release in March 2026. A September 14, 2026 Kubernetes blog post by Prasad Ghangal of Veeam Kasten (a Kubernetes-native backup vendor, and exactly the kind of company that consumes this API) walks through what changed in that Beta release: the SnapshotMetadataService CRD was promoted from v1alpha1 to v1beta1, now served at cbt.storage.k8s.io/v1beta1, with the schema itself left unchanged. The feature still applies only to block volumes; file-volume and network file-share changed-list tracking are explicitly out of scope. The announcement’s own “Trying it out” section puts the real-driver checklist plainly: make sure your CSI driver supports volume snapshots and ships the external-snapshot-metadata sidecar, install the v1beta1 CRD, create a SnapshotMetadataService resource for that driver, and then use a client, either the project’s own snapshot-metadata-lister reference tool or your own implementation, to call GetMetadataAllocated and GetMetadataDelta. The project’s own hostpath CSI driver example is the suggested starting point for seeing the full flow end to end on a real cluster.

What This Tutorial Deliberately Simplifies

The CSI spec does not mandate how a driver computes GetMetadataDelta internally, only the wire contract for reporting the result. This tutorial computed changes by checksumming every block after the fact and comparing, which is simple to build with nothing but the standard library, but it is not necessarily how a production storage system does it. Many real storage backends track a write-time dirty bitmap that gets updated the instant a block is written, so answering “what changed” never requires re-reading and re-hashing the whole volume; others rely on copy-on-write snapshot metadata they already maintain for other reasons. The checksum-and-compare approach used here is a teaching approximation chosen specifically because it needs no block-device access and no storage driver at all, not a claim about how any particular CSI driver is implemented.

Common Mistakes and Gotchas

  • Reaching for file size or modification time instead of content. A raw block device does not have a meaningful “last modified” timestamp the way a regular file does, and its size never changes on a write. This absence is part of why block-level checksumming or dirty-bitmap tracking exists in the first place: there is no cheap metadata shortcut available below the filesystem layer.
  • Picking a block size without measuring your real write pattern. Step 7 showed the same 80 real changed bytes turning into anywhere from 10,240 to 1,310,720 bytes of incremental payload purely based on block size. A block size tuned for a database’s small, scattered page writes will be badly wrong for a workload that rewrites data in large contiguous chunks, and vice versa.
  • Applying incrementals out of order, or skipping one in a chain. Step 6 showed this produces no error, no warning, and a file of the exact correct size, while still being silently wrong in every block that an earlier, skipped incremental was responsible for. Always verify a restore chain’s ordering and completeness before trusting an incremental restore, ideally with a whole-volume checksum comparison like this tutorial’s sha256_file() check, not just “did the restore command exit with status 0.”
  • Assuming a diff with zero false positives means a diff with zero false negatives. This tutorial’s SHA-256-based diff never produced a false positive across any of its runs, which is expected given how collision-resistant SHA-256 is, but that says nothing about a different implementation. A dirty-bitmap-based tracker that fails to record a write during a crash, for instance, could report a block as unchanged when it was not, which is a false negative and far more dangerous than a false positive, since it silently drops real data from the backup with no visible symptom at all.

Step 8: Verify Everything With an Automated Test Suite

Manually running scripts and eyeballing output is how you build understanding, but a test suite is how you keep the guarantees in place as code changes. Save this as test_cbt.py in the same directory:

"""Automated tests for the changed-block-tracking building blocks."""
import random

from cbt_lib import (
    checksum_blocks,
    changed_block_indices,
    merge_into_ranges,
    write_incremental_backup,
    apply_incremental,
    sha256_file,
    BlockMetadata,
)


def make_volume(path, size, seed):
    rng = random.Random(seed)
    with open(path, "wb") as f:
        f.write(rng.randbytes(size))


def test_checksums_are_deterministic(tmp_path):
    path = tmp_path / "vol.bin"
    make_volume(path, 4096 * 5, seed=1)
    first = checksum_blocks(path, 4096)
    second = checksum_blocks(path, 4096)
    assert first == second


def test_no_changes_detected_for_identical_snapshots(tmp_path):
    path = tmp_path / "vol.bin"
    make_volume(path, 4096 * 5, seed=1)
    snap_a = checksum_blocks(path, 4096)
    snap_b = checksum_blocks(path, 4096)
    assert changed_block_indices(snap_a, snap_b) == []


def test_a_single_modified_byte_flags_exactly_one_block(tmp_path):
    path = tmp_path / "vol.bin"
    make_volume(path, 4096 * 5, seed=1)
    base = checksum_blocks(path, 4096)
    with open(path, "r+b") as f:
        f.seek(4096 * 2)  # inside block index 2, not on a boundary
        f.write(b"\x00")
    target = checksum_blocks(path, 4096)
    assert changed_block_indices(base, target) == [2]


def test_growing_the_volume_flags_the_new_blocks(tmp_path):
    path = tmp_path / "vol.bin"
    make_volume(path, 4096 * 3, seed=1)
    base = checksum_blocks(path, 4096)
    with open(path, "ab") as f:
        f.write(random.Random(2).randbytes(4096 * 2))
    target = checksum_blocks(path, 4096)
    assert changed_block_indices(base, target) == [3, 4]


def test_merge_into_ranges_merges_only_adjacent_blocks():
    ranges = merge_into_ranges([0, 1, 2, 5, 6, 10], block_size=100)
    assert ranges == [
        BlockMetadata(byte_offset=0, size_bytes=300),
        BlockMetadata(byte_offset=500, size_bytes=200),
        BlockMetadata(byte_offset=1000, size_bytes=100),
    ]


def test_merge_into_ranges_handles_empty_input():
    assert merge_into_ranges([], block_size=100) == []


def test_incremental_backup_roundtrip_reconstructs_exact_bytes(tmp_path):
    volume = tmp_path / "vol.bin"
    base_backup = tmp_path / "base.bin"
    make_volume(volume, 4096 * 8, seed=5)

    import shutil

    shutil.copyfile(volume, base_backup)
    base_sums = checksum_blocks(base_backup, 4096)

    # Modify two non-adjacent blocks.
    with open(volume, "r+b") as f:
        f.seek(4096 * 1)
        f.write(random.Random(9).randbytes(4096))
        f.seek(4096 * 6)
        f.write(random.Random(10).randbytes(4096))

    target_sums = checksum_blocks(volume, 4096)
    changed = changed_block_indices(base_sums, target_sums)
    ranges = merge_into_ranges(changed, 4096)
    assert len(ranges) == 2  # the two writes were not adjacent

    delta_path = tmp_path / "delta.bin"
    manifest_path = tmp_path / "delta_manifest.json"
    write_incremental_backup(volume, ranges, delta_path, manifest_path)

    restored = tmp_path / "restored.bin"
    shutil.copyfile(base_backup, restored)
    apply_incremental(restored, delta_path, manifest_path)

    assert sha256_file(restored) == sha256_file(volume)


def test_incremental_of_incremental_only_works_when_applied_in_order(tmp_path):
    import shutil

    volume = tmp_path / "vol.bin"
    base_backup = tmp_path / "base.bin"
    make_volume(volume, 4096 * 8, seed=20)
    shutil.copyfile(volume, base_backup)
    snap0 = checksum_blocks(base_backup, 4096)

    with open(volume, "r+b") as f:
        f.seek(4096 * 1)
        f.write(random.Random(21).randbytes(4096))
    snap1 = checksum_blocks(volume, 4096)
    ranges1 = merge_into_ranges(changed_block_indices(snap0, snap1), 4096)
    write_incremental_backup(volume, ranges1, tmp_path / "d1.bin", tmp_path / "m1.json")

    with open(volume, "r+b") as f:
        f.seek(4096 * 4)
        f.write(random.Random(22).randbytes(4096))
    snap2 = checksum_blocks(volume, 4096)
    ranges2 = merge_into_ranges(changed_block_indices(snap1, snap2), 4096)
    write_incremental_backup(volume, ranges2, tmp_path / "d2.bin", tmp_path / "m2.json")

    correct = tmp_path / "correct.bin"
    shutil.copyfile(base_backup, correct)
    apply_incremental(correct, tmp_path / "d1.bin", tmp_path / "m1.json")
    apply_incremental(correct, tmp_path / "d2.bin", tmp_path / "m2.json")
    assert sha256_file(correct) == sha256_file(volume)

    skipped = tmp_path / "skipped.bin"
    shutil.copyfile(base_backup, skipped)
    apply_incremental(skipped, tmp_path / "d2.bin", tmp_path / "m2.json")
    assert sha256_file(skipped) != sha256_file(volume)

Run it with pytest test_cbt.py -v:

============================= test session starts =============================
platform win32 -- Python 3.13.14, pytest-9.1.1, pluggy-1.6.0
collected 8 items

test_cbt.py::test_checksums_are_deterministic PASSED                     [ 12%]
test_cbt.py::test_no_changes_detected_for_identical_snapshots PASSED     [ 25%]
test_cbt.py::test_a_single_modified_byte_flags_exactly_one_block PASSED  [ 37%]
test_cbt.py::test_growing_the_volume_flags_the_new_blocks PASSED         [ 50%]
test_cbt.py::test_merge_into_ranges_merges_only_adjacent_blocks PASSED   [ 62%]
test_cbt.py::test_merge_into_ranges_handles_empty_input PASSED           [ 75%]
test_cbt.py::test_incremental_backup_roundtrip_reconstructs_exact_bytes PASSED [ 87%]
test_cbt.py::test_incremental_of_incremental_only_works_when_applied_in_order PASSED [100%]

============================== 8 passed in 0.14s ==============================

Notice these tests use pytest’s tmp_path fixture for every file, which gives each test its own throwaway directory rather than reusing the volume.bin from the earlier steps. That matters because the two chain-ordering tests deliberately construct a fresh, small, three-block scenario where the “corrupted” result is checked with a direct assertion rather than a printed number, which is what makes this a regression test and not just a demo: if a future change to apply_incremental or merge_into_ranges ever broke the ordering guarantee, test_incremental_of_incremental_only_works_when_applied_in_order would fail immediately instead of silently producing wrong backups in production.

Verify the Whole Thing End to End

To confirm everything in this tutorial reproduces on your own machine, create a fresh directory, save all seven files (cbt_lib.py, step1_setup.py through step6_blocksize_tradeoff.py, and test_cbt.py), and run them in order:

python step1_setup.py
python step2_full_backup.py
python step3_first_change_round.py
python step4_incremental_backup.py
python step5_second_round_and_chain.py
python step6_blocksize_tradeoff.py
pytest test_cbt.py -v

Because every random number generator in this tutorial is explicitly seeded, every number in this post, the 42 changed blocks, the 14 merged ranges, the 98.4 percent savings, the 39 silently corrupted blocks, the block-size overhead table, should reproduce exactly on any machine running a compatible Python version. If any of those numbers come out different, that is a signal something in the code was transcribed incorrectly, not that the concept is unreliable.

Next Steps

From here, a natural next step is trying this against a real cluster once you have Docker or another container runtime available: spin up a local kind cluster, install the CSI hostpath driver from the kubernetes-csi/csi-driver-host-path project along with its external-snapshot-metadata sidecar, and call the real GetMetadataDelta RPC with a gRPC client instead of this tutorial’s in-process Python functions. The official CSI developer documentation and the Kubernetes enhancement proposal (KEP-3314) are the best starting points for that.

If you found the checksum-and-compare technique in this tutorial interesting, this site’s Merkle tree tutorial builds a related but different structure for detecting tampering across a whole dataset using far fewer comparisons than checksumming every block individually. For the durability half of the backup story (making sure a write is never lost even if the process crashes mid-operation), see the write-ahead log tutorial. And for the equally important, opposite concern to this post (verifying that a backup you already took can actually be restored, rather than efficiently capturing it in the first place), see the CNCF disaster recovery guide on why backups are an unreliable signal until you test restoring them.

Tags:

AlgorithmsData DurabilityIncremental BackupKubernetes StoragePython

Share

A massive rusted anchor chain shackle lying on a pebble beach, its iron surface deeply corroded orange and brown
Previous Post

Microsoft’s Humanist AI Code of Conduct Turns Agent Containment Into a Chain of Command

Macro photo of a ceramic microcontroller chip with an exposed gold die, representing embedded device security
Next Post

Italy’s Exein Raises $270 Million to Build a Foundation Model for Physical AI Security

No Comment! Be the first one.

Leave a Reply Cancel reply

Your email address will not be published. Required fields are marked *

Latest
15 Sep
How to Build a Hybrid Post-Quantum Key Exchange in Python With X25519 and ML-KEM
15 Sep
Canonical’s Zenoh Snaps Turn ROS 2’s Middleware Fix Into a Packaging Decision
Trending
September 15, 2026
How to Build a Hybrid Post-Quantum Key Exchange in Python With X25519 and ML-KEM
September 15, 2026
Canonical’s Zenoh Snaps Turn ROS 2’s Middleware Fix Into a Packaging Decision
September 15, 2026
Italy’s Exein Raises $270 Million to Build a Foundation Model for Physical AI Security
September 15, 2026
How to Build a Changed Block Tracking System in Python to Speed Up Incremental Backups
September 15, 2026
Microsoft’s Humanist AI Code of Conduct Turns Agent Containment Into a Chain of Command
September 15, 2026
CISA Orders Federal Agencies to Patch an Actively Exploited Cisco Email Gateway Flaw by September 17

Related Posts

A laptop wrapped in a chain and padlock, illustrating least-privilege controls for AI agents.
Learning Hub

How to Secure Tool-Using AI Agents Before They Touch Production

June 8, 2026
Colorful sticky notes arranged on an office wall, symbolizing governance checklists and planning.
Learning Hub

AI Governance for Agentic Apps: A Practical Checklist for Builders

June 8, 2026
A technician connects green fiber optic cables at a data center, representing a private production inference endpoint.
Learning Hub

How to Deploy a Fine-Tuned LLM Behind a Private Production Inference Endpoint

June 8, 2026
Narrow aisle behind black supercomputer racks in a data center
Learning Hub

Kubernetes SELinux Volume Labeling: What Cluster Operators Should Audit Before v1.37

June 8, 2026
SXZ.io SXZ.io
  • [email protected]

Categories

Articles
Learning Hub
News

All Rights Reserved by SXZ.io ©2026