Konubinix' opinionated web of thoughts

Migrate an Ipfs Repo Off a Failing Disk

Fleeting

what
how to move chosen cids of an ipfs node from a kubo repo on a failing disk into a new repo on another disk of the same host, and the tools that do it.
tl;dr
copy each cid’s block files straight from flatfs to flatfs, read in their order on the old disk, check sizes during the copy and dag stat after, and get what the old disk cannot read from another node.
what
debug health of my ipfs swarm runs it to move roddenberry off bimbap, and holds the measures quoted below.

what the method rests on

what
kubo keeps each block as its own file in a flatfs directory, named after the block’s multihash and sharded as the repo’s blocks/SHARDING file says.
  • therefore: where the old repo, the new one and the rescuing node shard alike, copying a cid’s block files from one blocks directory to another puts the cid in that repo, with no ipfs command and no daemon.
what
the copied blocks are not pinned in the new repo.
  • therefore: the method holds only on a node that never runs garbage collection, or once the cids are pinned or put in MFS there.
how
the scripts tangle to var/run/user/1000/ipfshealth and run from the workstation, konix, each taking the host and paths as arguments.
how
the old repo’s node keeps running on the old disk until the switch: the copy only reads its block files.

#!/usr/bin/env bash
# Prints how the old and the new repo on <host>, then this workstation's own repo, shard their block files;
# the copy needs all three alike.
# usage: check_sharding.sh <host> <old repo> <new repo>
set -o nounset

ssh -n "$1" cat "$2/blocks/SHARDING" "$3/blocks/SHARDING"
cat "${IPFS_PATH:-${HOME}/.ipfs}/blocks/SHARDING"

the steps

how
in their order: make the new repo, check the sharding, cut the cids into batches, start the chain, follow it, rescue what the old disk cannot give, rerun the chain to check the rescued batches.
how
then cut the batches again, for the cids added meanwhile, and run the chain again, until no new cid comes; then the node is switched onto the new repo.

the new repo

how
kubo’s docker image makes the repo, run as the ssh user, so that user’s copies can write into it.
how
no daemon runs on the new repo until the switch, so each command takes the repo’s lock in turn.

#!/usr/bin/env bash
# Makes a new kubo repo at <repo> on <host>, owned by the ssh user.
# usage: init_repo.sh <host> <repo>
set -o nounset

ssh -n "$1" "docker run --rm --user \"\$(id -u):\$(id -g)\" -v $2:/data/ipfs --entrypoint ipfs \
    ipfs/kubo:v0.39.0 init --profile server"

the batches

how
cut_batches.sh takes the cids to move on its input, keeps those no batch holds yet, and cuts them into batches of 1000 on the host, numbered after the last one.
  • why: the cids to move may keep growing during the copy; run again after it, it adds only the new ones.
  • why: a batch is the unit the copy resumes from and the check marks whole.

#!/usr/bin/env bash
# Cuts the cids read on stdin that no batch on <host> holds yet into batches of 1000, numbered after the last.
# usage: cut_batches.sh <host> <migration dir> < cids, bare or as /ipfs/<cid>
set -o nounset

host="$1"
dir="$2"
next="$(ssh -n "${host}" "mkdir -p ${dir}/batches && ls ${dir}/batches | wc -l")"
new="$(comm -13 <(ssh -n "${host}" "cat ${dir}/batches/* 2> /dev/null" | sort -u) \
    <(grep -v '^$' | sed 's|^\([^/]\)|/ipfs/\1|' | sort -u))"
if test -n "${new}"
then
    echo "${new}" | ssh "${host}" split --lines=1000 --numeric-suffixes="${next}" --suffix-length=3 - \
        "${dir}/batches/"
fi
echo "$(echo -n "${new}" | grep -c .) new cids, from batch ${next}"

listing a cid’s block files

what
ipfs refs -r lists a DAG’s blocks through the node, reading every block it walks.
what
in a dag-pb block, links come before data, so its first byte, 0x12, tells a node with children from a leaf, and a raw block never has children.
how
list_blocks.py reads the block files itself: inner nodes whole, one byte of each dag-pb leaf, raw leaves not at all.
  • what: on roddenberry, it listed the same 47851 files as ipfs refs -r in 18 s, where refs took 23 min.

#!/usr/bin/env python3
"""List the flatfs block files of whole DAGs, reading the block files of a repo directly, without ipfs.

usage: list_blocks.py <blocks dir> < lines of /ipfs/<cid>
prints one <shard>/<KEY>.data per block, each once; complains on stderr about blocks it can't find or read.

A dag-pb block holds its links (field 2) before its data (field 1), so its first byte tells whether it has
children: 0x12 means links follow, anything else means a leaf. Leaves, which hold nearly all the bytes,
are thus never read past their first byte. raw blocks have no children and are not opened at all.
"""
import base64
import sys

DAG_PB = 0x70
RAW = 0x55
IDENTITY = 0x00
B58 = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"


def varint(data, pos):
    value = shift = 0
    while True:
        byte = data[pos]
        pos += 1
        value |= (byte & 0x7F) << shift
        if not byte & 0x80:
            return value, pos
        shift += 7


def b58decode(text):
    number = 0
    for char in text:
        number = number * 58 + B58.index(char)
    raw = number.to_bytes((number.bit_length() + 7) // 8, "big")
    return b"\0" * (len(text) - len(text.lstrip("1"))) + raw


def parse_binary_cid(data):
    """(codec, multihash) of a binary cid"""
    if len(data) == 34 and data[0] == 0x12 and data[1] == 0x20:
        return DAG_PB, data
    version, pos = varint(data, 0)
    if version != 1:
        raise ValueError(f"cid version {version}")
    codec, pos = varint(data, pos)
    return codec, data[pos:]


def parse_text_cid(text):
    if text.startswith("Qm"):
        return DAG_PB, b58decode(text)
    if text.startswith("b"):
        padded = text[1:].upper() + "=" * (-len(text[1:]) % 8)
        return parse_binary_cid(base64.b32decode(padded))
    raise ValueError(f"unsupported cid {text}")


def block_file(multihash):
    # flatfs next-to-last/2: the key is the base32 multihash, sharded by its next-to-last two characters
    key = base64.b32encode(multihash).decode().rstrip("=")
    return f"{key[-3:-1]}/{key}.data"


def links(content):
    """binary cids of a dag-pb node's links"""
    pos = 0
    while pos < len(content):
        tag, pos = varint(content, pos)
        if tag & 7 != 2:
            raise ValueError(f"dag-pb field {tag >> 3} of wire type {tag & 7}")
        length, pos = varint(content, pos)
        field = content[pos:pos + length]
        pos += length
        if tag >> 3 != 2:
            continue
        inner = 0
        while inner < len(field):
            inner_tag, inner = varint(field, inner)
            if inner_tag & 7 == 2:
                inner_length, inner = varint(field, inner)
                if inner_tag >> 3 == 1:
                    yield field[inner:inner + inner_length]
                inner += inner_length
            elif inner_tag & 7 == 0:
                _, inner = varint(field, inner)
            else:
                raise ValueError(f"dag-pb link wire type {inner_tag & 7}")


def main():
    blocks = sys.argv[1]
    seen = set()
    failed = False
    todo = [parse_text_cid(line.strip().removeprefix("/ipfs/")) for line in sys.stdin if line.strip()]
    while todo:
        codec, multihash = todo.pop()
        if multihash[0] == IDENTITY:
            # inlined in its cid, no block file
            continue
        path = block_file(multihash)
        if path in seen:
            continue
        seen.add(path)
        print(path)
        if codec == RAW:
            continue
        if codec != DAG_PB:
            print(f"unsupported codec {codec:#x}: {path}", file=sys.stderr)
            failed = True
            continue
        try:
            with open(f"{blocks}/{path}", "rb") as block:
                if block.read(1) != b"\x12":
                    continue
                block.seek(0)
                content = block.read()
            todo.extend(parse_binary_cid(child) for child in links(content))
        except (OSError, ValueError, IndexError) as error:
            print(f"cannot read {path}: {error}", file=sys.stderr)
            failed = True
    sys.exit(1 if failed else 0)


main()

the order to read them in

what
flatfs names each block file after its hash, so a list of block files in hash order lands all over the disk.
what
read_order.sh lists one batch’s block files, reads 800 of them in that hash order, then 800 others sorted by the first physical block filefrag gives for each, any running copy paused meanwhile.
  • how: pick a batch not read since the old disk was mounted, so none of its files come from memory.
  • what: on roddenberry’s failing disk, disk order read 49 MB/s, hash order 26 MB/s.
how
copy_sorted.py sorts each batch’s missing block files by their first physical block and copies them in that order, each to a temporary name then renamed.
  • why: renamed whole, a block file interrupted mid copy is absent, never short, and a rerun copies it again.

#!/usr/bin/env bash
# Times reading one batch's block files on <host> in hash order, then in their order on disk.
# usage: read_order.sh <host> <old blocks dir> <migration dir> <batch>
set -o nounset

ssh "$1" bash -s "$2" "$3" "$4" <<'EOF'
cd "$1" || exit 1
files="$(python3 "$2/list_blocks.py" . < "$2/batches/$3" 2> /dev/null)"
hash_order="$(echo "${files}" | sed -n '1,800p')"
# first physical block of each file, from its extent map
disk_order="$(echo "${files}" | sed -n '801,1600p' | xargs /usr/sbin/filefrag -e \
    | awk '/^File size of/ { file = $4 } /^ +0:/ { sub(/\.\./, "", $4); print $4, file }' \
    | sort -n | cut -d' ' -f2)"

paused="$(pgrep -f "^python3 $2/copy_sorted" | tr '\n' ' ')"
test -n "${paused}" && kill -STOP ${paused}
trap 'test -n "${paused}" && kill -CONT ${paused}' EXIT

for order in hash_order disk_order
do
    list="${!order}"
    bytes="$(echo "${list}" | tr '\n' '\0' | du -cb --files0-from=- | tail -n 1 | cut -f1)"
    start="$(date +%s%N)"
    echo "${list}" | xargs cat > /dev/null
    ms=$(( ($(date +%s%N) - start) / 1000000 ))
    echo "${order}: $(echo "${list}" | wc -l) files, $((bytes / 1000000)) MB in ${ms} ms," \
         "$(( bytes / 1000 / (ms + 1) )) MB/s"
done
EOF

#!/usr/bin/env python3
"""Copy block files from one flatfs to another, reading them in the order they lie on the source disk.

usage: copy_sorted.py <source blocks dir> <target blocks dir> <file of shard/KEY.data lines>

Read in hash order, the head seeks before each file, which halves what the failing disk reads; sorted by their
first physical block, reads mostly follow each other. Files already in the target are skipped. Each file is
written under a temporary name then renamed, so a block file is whole or absent. Prints how many files and
bytes were copied, and each file it could not read; exits non zero if any.
"""
import os
import subprocess
import sys
import time

source, target, listing = sys.argv[1:4]
with open(listing) as lines:
    wanted = [line.strip() for line in lines if line.strip()]
missing = [path for path in wanted if not os.path.exists(os.path.join(target, path))]


def physical_start(paths):
    """first physical block of each file, from filefrag's extent maps; unknown ones go last"""
    start = {}
    for index in range(0, len(paths), 500):
        chunk = paths[index:index + 500]
        result = subprocess.run(["/usr/sbin/filefrag", "-e", *chunk], cwd=source, capture_output=True,
                                text=True)
        current = None
        for line in result.stdout.splitlines():
            if line.startswith("File size of "):
                current = line[len("File size of "):].split(" is ")[0]
            elif current is not None and line.strip().startswith("0:"):
                start[current] = int(line.split(":")[2].split("..")[0])
                current = None
    return start


began = time.monotonic()
start = physical_start(missing)
missing.sort(key=lambda path: start.get(path, float("inf")))
print(f"sorted {len(missing)} missing files by place on disk in {time.monotonic() - began:.0f} s", flush=True)
began = time.monotonic()

copied = size = 0
failed = []
for path in missing:
    destination = os.path.join(target, path)
    try:
        with open(os.path.join(source, path), "rb") as block:
            content = block.read()
    except OSError as error:
        failed.append(f"{path}: {error}")
        continue
    os.makedirs(os.path.dirname(destination), exist_ok=True)
    with open(destination + ".tmp", "wb") as out:
        out.write(content)
    os.rename(destination + ".tmp", destination)
    copied += 1
    size += len(content)

for failure in failed:
    print(f"could not read {failure}")
print(f"copied {copied} files, {size} bytes in {time.monotonic() - began:.0f} s; {len(wanted) - len(missing)}"
      f" already there, {len(failed)} unreadable")
sys.exit(1 if failed else 0)

checking the copy

what
ipfs dag stat --offline walks a cid’s whole DAG in a repo and fails on any block missing there, reading every block back1.
what
disk_load.sh samples the disks’ counters in /proc/diskstats twenty seconds apart: the share of time each disk was busy, what it read and wrote, and the mean time of one read.
  • what: on roddenberry, a copy beside a dag stat of the batch before kept the new disk 98% busy and the old one read ~7 MB/s; with the dag stat paused, the new disk was 32% busy.
  • therefore: a dag stat beside the copy starves its writes on the new disk.
how
right after each batch, check_present.py only compares each block file’s size in the new repo with the old one’s; dag stat runs over every batch once the old disk is no longer read.
  • what: so checked, roddenberry’s batches of 8 to 12 GB copied at 31 to 41 MB/s.

#!/usr/bin/env bash
# Samples the given disks of <host> twenty seconds apart: busy share, reads, mean read time, writes.
# usage: disk_load.sh <host> <disk>...
set -o nounset

host="$1"
shift
pattern=" ($(IFS='|'; echo "$*")) "
ssh -n "${host}" "grep -E '${pattern}' /proc/diskstats; sleep 20; grep -E '${pattern}' /proc/diskstats" \
    | awk '{ name = $3 }
           name in reads { printf "%s: busy %d%%, read %d MB in %d reads of %d ms each, wrote %d MB\n",
                             name, ($13 - busy[name]) / 200, ($6 - sread[name]) / 2048,
                             $4 - reads[name], ($7 - rms[name]) / ($4 - reads[name] + 1),
                             ($10 - swritten[name]) / 2048; next }
           { reads[name] = $4; sread[name] = $6; rms[name] = $7; swritten[name] = $10; busy[name] = $13 }'

#!/usr/bin/env python3
"""Check that each listed block file is in the target flatfs with the same size as in the source.

usage: check_present.py <source blocks dir> <target blocks dir> <file of shard/KEY.data lines>

A light check run right after a copy: it reads only file sizes, never block contents, so it costs the target
disk nothing beside the copy. Prints each missing or short file; exits non zero if any. Whether every DAG is
whole is left to dag stat --offline, run over all batches once the source no longer needs reading.
"""
import os
import sys

source, target, listing = sys.argv[1:4]
bad = 0
with open(listing) as lines:
    for path in (line.strip() for line in lines):
        if not path:
            continue
        try:
            copied = os.stat(os.path.join(target, path)).st_size
        except FileNotFoundError:
            print(f"missing: {path}")
            bad += 1
            continue
        try:
            original = os.stat(os.path.join(source, path)).st_size
        except OSError as error:
            print(f"cannot stat source {path}: {error}")
            bad += 1
            continue
        if copied != original:
            print(f"size {copied} instead of {original}: {path}")
            bad += 1
print(f"{bad} block files missing or short")
sys.exit(1 if bad else 0)

the chain

how
copy_all.sh, on the host, copies every batch not yet copied: lists its block files, copies them sorted, checks their sizes, marks it copied.
how
then it runs dag stat over every batch not yet whole, marks a whole one imported, and lists each cid not whole in copy/broken.
  • why: each step leaves a marker, so a rerun resumes where the last one stopped, and checks only what is left.
how
start_chain.sh puts the scripts on the host and starts copy_all.sh there detached, logging to copy/all.log, since a whole migration lasts hours; chain_status.sh tells where it is without waiting.

#!/usr/bin/env bash
# Copies every batch not yet copied, one after the other: list its block files, copy them in disk order, check
# each copied file is there with its size. Once the old repo needs no more reading, checks every batch not yet
# whole offline in the new repo, marking whole batches imported and listing the cids that are not in
# copy/broken. Run detached; it logs to copy/all.log.
# usage: copy_all.sh <migration dir> <old repo> <new repo>
set -o nounset
set -o pipefail

dir="$1"
old="$2"
new="$3"
kubo="ipfs/kubo:v0.39.0"

mkdir -p "${dir}/copy" "${dir}/copied" "${dir}/imported"
exec >> "${dir}/copy/all.log" 2>&1

log () {
    echo "$(date -Is) $*"
}

copy () {
    local name="$1" work="${dir}/copy/$1"
    mkdir -p "${work}"
    python3 "${dir}/list_blocks.py" "${old}/blocks" < "${dir}/batches/${name}" > "${work}/files" \
        || log "${name}: some blocks could not be listed, see above"
    log "${name}: $(wc -l < "${work}/files") block files"
    # unreadable files are listed and left for the check to name the cids they break
    python3 "${dir}/copy_sorted.py" "${old}/blocks" "${new}/blocks" "${work}/files" \
        || log "${name}: some block files could not be read"
    # sizes only: reading the copied bytes back here would compete with the copy for the new disk
    if python3 "${dir}/check_present.py" "${old}/blocks" "${new}/blocks" "${work}/files"
    then log "${name}: copied, every block file present"
    else log "${name}: copied, some block files missing, see above"
    fi
}

# marks the batch imported only when every one of its cids is whole in the new repo
check () {
    local name="$1" result
    result="$(docker run --rm -i --user "$(id -u):$(id -g)" -v "${new}:/data/ipfs" --entrypoint sh "${kubo}" -c '
        broken=0
        while read -r path
        do
            if ! error="$(ipfs dag stat --offline --progress=false "${path#/ipfs/}" 2>&1 > /dev/null)"
            then broken=$((broken + 1)); echo "not whole: ${path}: ${error}"
            fi
        done
        echo "broken ${broken}"
    ' < "${dir}/batches/${name}")"
    echo "${result}" | grep "^not whole"
    if test "$(echo "${result}" | tail -n 1)" = "broken 0"
    then
        echo whole-by-dag-stat > "${dir}/imported/${name}"
        log "${name}: checked, all whole"
    else
        echo "${result}" | sed -n "s|^not whole: \([^:]*\):.*|${name} \1|p" >> "${dir}/copy/broken"
        log "${name}: checked, $(echo "${result}" | tail -n 1 | cut -d' ' -f2) cids not whole, listed in broken"
    fi
}

for batch in "${dir}"/batches/*
do
    name="$(basename "${batch}")"
    test -e "${dir}/imported/${name}" -o -e "${dir}/copied/${name}" && continue
    copy "${name}"
    touch "${dir}/copied/${name}"
done
log "all batches copied, the old repo no longer read; checking every DAG in the new one"

# lists only what this check finds, so a cid rescued since drops out
: > "${dir}/copy/broken"
for batch in "${dir}"/batches/*
do
    name="$(basename "${batch}")"
    test -e "${dir}/imported/${name}" && continue
    check "${name}"
done
log "all batches checked; $(ls "${dir}/imported" | wc -l) of $(ls "${dir}/batches" | wc -l) whole," \
    "$(wc -l < "${dir}/copy/broken") cids to rescue, listed in copy/broken"

#!/usr/bin/env bash
# Puts the copy scripts in <migration dir> on <host>, then starts copy_all.sh there detached, unless it runs.
# usage: start_chain.sh <host> <migration dir> <old repo> <new repo>
set -o nounset

host="$1"
dir="$2"
here="$(dirname "$(readlink -f "$0")")"
ssh -n "${host}" mkdir -p "${dir}"
rsync -a "${here}/list_blocks.py" "${here}/copy_sorted.py" "${here}/check_present.py" "${here}/copy_all.sh" \
    "${host}:${dir}/"
if ssh -n "${host}" pgrep -f "'^bash ${dir}/copy_all.sh'" > /dev/null
then
    echo "copy_all.sh already runs"
else
    ssh -n "${host}" "setsid nohup bash ${dir}/copy_all.sh ${dir} $3 $4 > /dev/null 2>&1 < /dev/null &"
    echo "copy_all.sh started"
fi

#!/usr/bin/env bash
# Tells where the chain on <host> is: its last log lines, how many batches are whole, whether it runs.
# usage: chain_status.sh <host> <migration dir>
set -o nounset

dir="$2"
ssh -n "$1" "tail -n 5 ${dir}/copy/all.log; \
    echo whole: \$(ls ${dir}/imported | wc -l) of \$(ls ${dir}/batches | wc -l) batches; \
    pgrep -f '^bash ${dir}/copy_all.sh' > /dev/null && echo running || echo ended"

rescuing what the old disk cannot give

what
a failing disk leaves some block files unreadable, an input/output error; copy_sorted.py skips them and the dag stat lists the cids they break in copy/broken.
what
a cid can be meant for the node and yet missing from its repo altogether, never fetched.
how
rescue_broken.sh gets each broken cid into the workstation’s own node: first by the retrieve command given, a backup say, else from the swarm, where ipfs dag stat fetches every block of a DAG1.
how
then it lists the cid’s block files in the workstation’s repo with list_blocks.py and copies those the new repo lacks into it; a rerun of the chain then checks the batches not yet whole.

#!/usr/bin/env bash
# Gets each cid listed in copy/broken on <host> into this workstation's node, then copies its block files into
# the new repo there.
# usage: rescue_broken.sh <host> <migration dir> <new repo> [retrieve command, given each /ipfs/<cid>]
set -o nounset

host="$1"
dir="$2"
new="$3"
shift 3
here="$(dirname "$(readlink -f "$0")")"
blocks="${IPFS_PATH:-${HOME}/.ipfs}/blocks"
for path in $(ssh -n "${host}" "cut -d' ' -f2 ${dir}/copy/broken" | sort -u)
do
    if ! { test $# -gt 0 && "$@" "${path}" < /dev/null > /dev/null 2>&1; } \
            && ! ipfs dag stat --progress=false "${path#/ipfs/}" < /dev/null > /dev/null
    then
        echo "${path}: neither retrieved nor on the swarm"
        continue
    fi
    echo "${path}" | python3 "${here}/list_blocks.py" "${blocks}" \
        | rsync -a --ignore-existing --files-from=- "${blocks}/" "${host}:${new}/blocks/"
    echo "${path}: blocks copied from this workstation"
done

Notes linking here


  1. « ‘ipfs dag stat’ fetches a DAG and returns various statistics about it. » — primary, ipfs dag stat --help, kubo 0.43.1, run on konix on 2026-10-09.

     ↩︎ ↩︎