Created
November 19, 2025 23:54
-
-
Save linux4life798/69d4d36dc85280ff54076987275ae374 to your computer and use it in GitHub Desktop.
Migrate a /var/lib/docker from one BTRFS filesystem to another encapsulating BTRFS filesystem and subvolumes.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/bin/bash | |
| # Migrate a /var/lib/docker from one BTRFS filesystem to another encapsulating | |
| # BTRFS filesystem and subvolume(s). | |
| # | |
| # This script tries to correctly handle transfering the hierarchy of subvolumes | |
| # and snapshots that Docker creates in the /var/lib/docker/btrfs/subvolumes | |
| # directory, when it was using the btrfs storage driver. | |
| # | |
| # This is situation when you start using Docker on a machine using BTRFS as | |
| # the root filesystem, but forgot to create an encapsulating subvolume for | |
| # /var/lib/docker. This is also useful for moving /var/lib/docker from one BTRFS | |
| # filesystem to another. | |
| # | |
| # Requirements: | |
| # - Run as root (or via sudo) with Docker stopped. | |
| # - Source and destination paths must reside on BTRFS filesystems. | |
| # - Destination subvolume (e.g. /mnt/@docker) must not already exist. | |
| # | |
| # Usage: | |
| # sudo systemctl stop docker.service | |
| # # Assume that the new /var/lib/docker location will be on the BTRFS | |
| # # filesystem mounted at /mnt. The new encapsulating subvol will be @docker. | |
| # sudo docker-btrfs-migrate.bash migrate /mnt/@docker | |
| # # Verify the final rsync dry-run reports no changes. | |
| # # Move the original /var/lib/docker to /var/lib/docker.bak. | |
| # # Mount the new @docker subvolume to /var/lib/docker via fstab or another method. | |
| # # /etc/fstab: | |
| # # /dev/sda1 /var/lib/docker btrfs defaults,subvol=@docker 0 0 | |
| # # sudo systemctl daemon-reload | |
| # sudo systemctl start docker.service | |
| # | |
| # Actions: | |
| # migrate <dst> Copy ${DKR} to <dst>, including subvolume hierarchy. | |
| # restore-rw Revert ${DKR}/btrfs/subvolumes back to read/write if you abort. | |
| # debug Inspect source tree and preview btrfs send commands (DEBUG=1). | |
| # send Stream subvolumes without receiving (advanced workflows). | |
| # | |
| # Tips: | |
| # - You can run `DEBUG=1 ./docker-btrfs-migrate.bash send` to confirm send order. | |
| # - `btrfs subvolume list -t ${DKR}` is useful for auditing before/after migration. | |
| # | |
| # Craig Hesling <[email protected]> | |
| # Exit on error. | |
| set -e | |
| DKR=/var/lib/docker | |
| # https://github.com/linux4life798/bash-includes/blob/main/bash_include.d/1-msg-print.bash | |
| # Print command in Blue and then run it. | |
| # Usage: msg-run [cmd] [args...] | |
| # | |
| # Example: msg-run confirm | |
| msg-run() { | |
| local f="" redir="" | |
| for f in " <$(readlink /proc/$$/fd/0)" " >$(readlink /proc/$$/fd/1)" " 2>$(readlink /proc/$$/fd/2)"; do | |
| redir+="${f##*/dev/pts/*}" | |
| done | |
| printf "\E[34m%s\E[m\n" "> $*${redir}" >&2 | |
| [[ -n "$DEBUG" ]] && return 0 | |
| "$@" | |
| local ret="$?"; echo "Returned ${ret}" >&2; return "${ret}" | |
| } | |
| # Usage: btrfs-send <depth> <subvol_parent_path|-> <subvol_path> [<subvol2_path>...] | |
| btrfs-send() { | |
| local depth="$1" | |
| local subvol_parent="$2" | |
| shift 2 | |
| local -a subvols=( "${@}" ) | |
| local opts=( "--proto=0" "--compressed-data" "-e" ) | |
| if [[ "${subvol_parent}" != "-" ]]; then | |
| opts+=( "-p ${subvol_parent}" ) | |
| fi | |
| printf "%*s" "${depth}" "" >&2 | |
| msg-run btrfs send ${opts[@]} ${subvols[@]} | |
| } | |
| # Lookup a BTRFS subvolume by UUID. | |
| # Usage: btrfs-subvol-lookup-uuid <fs_path> <uuid> | |
| # | |
| # Example: btrfs-subvol-lookup-uuid /var/lib/docker 36c8c3b8-4a38-5f46-9813-d41ddae98fc7 | |
| btrfs-subvol-lookup-uuid() { | |
| local fs_path="$1" | |
| local uuid="$2" | |
| btrfs subvolume show --uuid "${uuid}" "${fs_path}" | |
| } | |
| # Lookup a BTRFS subvolume by path. | |
| # Usage: btrfs-subvol-lookup-path <subvol_path> | |
| # | |
| # Example: btrfs-subvol-lookup-path /var/lib/docker/btrfs/subvolumes/f46e053e49384e303d7b10fa686935704a7214a6ba995a58fa445726686a9ef0 | |
| btrfs-subvol-lookup-path() { | |
| local subvol_path="$1" | |
| btrfs subvolume show "${subvol_path}" | |
| } | |
| # Extracts the UUID from the output of 'btrfs subvolume show'. | |
| # Usage: btrfs subvolume show <subvol_path> | btrfs-subvol-show-parse-uuid | |
| # Returns: the UUID string | |
| # Example: | |
| # btrfs subvolume show /var/lib/docker/btrfs/subvolumes/xyz | btrfs-subvol-show-parse-uuid | |
| # # Output: 12345678-1234-5678-90ab-cdef12345678 | |
| btrfs-subvol-show-parse-uuid() { | |
| awk '/^[[:space:]]*UUID:/{print $2}' | |
| } | |
| # Extracts the Parent UUID from the output of 'btrfs subvolume show'. | |
| # Usage: btrfs subvolume show <subvol_path> | btrfs-subvol-show-parse-parent | |
| # Returns: the Parent UUID string, or '-' if none | |
| # Example: | |
| # btrfs subvolume show /var/lib/docker/btrfs/subvolumes/xyz | btrfs-subvol-show-parse-parent | |
| # # Output: 87654321-4321-ba09-8765-210fedcba987 | |
| btrfs-subvol-show-parse-parent() { | |
| awk '/^[[:space:]]*Parent UUID:/{print $3}' | |
| } | |
| # Extracts the path from the output of 'btrfs subvolume show' and prepends the expected base path. | |
| # Usage: btrfs subvolume show <subvol_path> | btrfs-subvol-show-parse-path [<expected_base_path>] | |
| # Returns: the absolute subvolume path | |
| # Example: | |
| # btrfs subvolume show /var/lib/docker/btrfs/subvolumes/xyz | btrfs-subvol-show-parse-path /var/lib/docker | |
| # # Output: /var/lib/docker/btrfs/subvolumes/xyz | |
| btrfs-subvol-show-parse-path() { | |
| local expected_path="${1:-/var/lib/docker}" | |
| read line | |
| echo "${expected_path}${line##*${expected_path}}" | |
| } | |
| # Usage: btrfs-tree-build <associative_array_name> | |
| btrfs-tree-build() { | |
| local -n arr="$1" | |
| local path="${DKR}/btrfs/subvolumes" | |
| local subvol | |
| for subvol in "${path}"/*; do | |
| local info="$(btrfs subvolume show "${subvol}")" | |
| local uuid="$(btrfs-subvol-show-parse-uuid <<<"${info}")" | |
| local parent_uuid="$(btrfs-subvol-show-parse-parent <<<"${info}")" | |
| arr["${uuid}"]="${parent_uuid}" | |
| done | |
| } | |
| # Find all children of a given parent UUID. | |
| # If no parent UUID is provided, find all roots. | |
| # Usage: btrfs-tree-find-children <associative_array_name> [<parent_uuid>] | |
| # | |
| # Example: | |
| # # Find all roots. | |
| # btrfs-tree-find-children src_uuid_tree | |
| # # Find all children of a given parent UUID. | |
| # btrfs-tree-find-children src_uuid_tree 36c8c3b8-4a38-5f46-9813-d41ddae98fc7 | |
| btrfs-tree-find-children() { | |
| local -n arr="$1" | |
| local parent_uuid="${2:--}" | |
| local child | |
| for child in "${!arr[@]}"; do | |
| if [[ "${arr["${child}"]}" == "${parent_uuid}" ]]; then | |
| echo "${child}" | |
| fi | |
| done | |
| } | |
| # Usage: btrfs-tree-depth-first <associative_array_name> <function> [<parent_uuid>] [<depth>] | |
| # | |
| # function: function <depth> <subvol_parent_path|-> <subvol_path> | |
| # | |
| # Example: btrfs-tree-depth-first src_uuid_tree btrfs-send | |
| btrfs-tree-depth-first() { | |
| local -n arr="$1" | |
| local func="$2" # func <depth> <subvol_parent_path|-> <subvol_path> | |
| local parent_uuid="${3:--}" | |
| local depth="${4:-0}" | |
| local path="${DKR}/btrfs/subvolumes" | |
| local -a child_uuids | |
| mapfile -t child_uuids < <(btrfs-tree-find-children "${!arr}" "${parent_uuid}") | |
| local parent_path="-" | |
| if [[ "${parent_uuid}" != "-" ]]; then | |
| local info="$(btrfs-subvol-lookup-uuid "${path}" "${parent_uuid}")" | |
| parent_path="$(btrfs-subvol-show-parse-path "${path}" <<<"${info}")" | |
| fi | |
| local subvol_uuid | |
| for subvol_uuid in "${child_uuids[@]}"; do | |
| local info="$(btrfs-subvol-lookup-uuid "${path}" "${subvol_uuid}")" | |
| local subvol_path="$(btrfs-subvol-show-parse-path "${path}" <<<"${info}")" | |
| "${func}" "${depth}" "${parent_path}" "${subvol_path}" | |
| btrfs-tree-depth-first "${!arr}" "${func}" "${subvol_uuid}" "$(( depth + 1 ))" | |
| done | |
| } | |
| # Usage: btrfs-tree-breath-first <associative_array_name> <function> [<parent_uuid>] [<depth>] | |
| # | |
| # function: function <depth> <subvol_parent_path|-> <subvol_child_path> [<subvol_child2_path>...] | |
| # | |
| # Example: btrfs-tree-breath-first src_uuid_tree btrfs-send | |
| btrfs-tree-breath-first() { | |
| local -n arr="$1" | |
| local func="$2" # func <depth> <subvol_parent_path|-> <subvol_child_path> [<subvol_child2_path>...] | |
| local parent_uuid="${3:--}" | |
| local depth="${4:-0}" | |
| local path="${DKR}/btrfs/subvolumes" | |
| local -a child_uuids | |
| mapfile -t child_uuids < <(btrfs-tree-find-children "${!arr}" "${parent_uuid}") | |
| if [[ ${#child_uuids[@]} -eq 0 ]]; then | |
| return | |
| fi | |
| local parent_path="-" | |
| if [[ "${parent_uuid}" != "-" ]]; then | |
| local info="$(btrfs-subvol-lookup-uuid "${path}" "${parent_uuid}")" | |
| parent_path="$(btrfs-subvol-show-parse-path "${path}" <<<"${info}")" | |
| fi | |
| local -a child_paths | |
| local child_uuid | |
| for child_uuid in "${child_uuids[@]}"; do | |
| local info="$(btrfs-subvol-lookup-uuid "${path}" "${child_uuid}")" | |
| child_paths+=( "$(btrfs-subvol-show-parse-path "${path}" <<<"${info}")" ) | |
| done | |
| "${func}" "${depth}" "${parent_path}" "${child_paths[@]}" | |
| local subvol_uuid | |
| for subvol_uuid in "${child_uuids[@]}"; do | |
| btrfs-tree-breath-first "${!arr}" "${func}" "${subvol_uuid}" "$(( depth + 1 ))" | |
| done | |
| } | |
| ################################################################################ | |
| # Set the BTRFS read-only property (ro) for subvolumes. | |
| # | |
| # Usage: btrfs-set-ro [-f] <true|false> <subvol_paths...> | |
| # Example: btrfs-set-ro true /var/lib/docker/btrfs/subvolumes/* | |
| btrfs-set-ro() { | |
| local opts=( ) | |
| if [[ "$1" == "-f" ]]; then | |
| opts+=( "-f" ) | |
| shift | |
| fi | |
| local ro_boolean="$1" | |
| shift | |
| local paths=( "${@}" ) | |
| local path | |
| for path in "${paths[@]}"; do | |
| msg-run btrfs property set "${opts[@]}" "${path}" ro "${ro_boolean}" | |
| done | |
| } | |
| docker-migrate() { | |
| local dst_path="$1" | |
| if [[ -z "${dst_path}" ]]; then | |
| echo "Error: Destination path is required" | |
| exit 1 | |
| fi | |
| if systemctl is-active docker.service >/dev/null 2>&1; then | |
| echo "Error: Docker service is running, please stop it" | |
| exit 1 | |
| fi | |
| # DEBUG=1 | |
| echo "# Create Destination Subvolume:" >&2 | |
| msg-run btrfs -v subvolume create "${dst_path}" | |
| echo >&2 | |
| echo "# Copy Docker Root Files:" >&2 | |
| msg-run rsync -avHAXi --exclude='btrfs/subvolumes/*' "${DKR}/" "${dst_path}/" | |
| echo >&2 | |
| echo "# Set source subvolumes to ro:" >&2 | |
| btrfs-set-ro true "${DKR}"/btrfs/subvolumes/* | |
| echo >&2 | |
| echo "# Build BTRFS Subvolume Parent Tree:" >&2 | |
| local -A src_uuid_tree=( ) | |
| btrfs-tree-build src_uuid_tree | |
| echo "# Send:" >&2 | |
| btrfs-tree-breath-first src_uuid_tree btrfs-send | msg-run btrfs receive "${dst_path}/btrfs/subvolumes/" | |
| echo >&2 | |
| echo "# Set destination subvolumes to rw:" >&2 | |
| btrfs-set-ro -f false "${dst_path}"/btrfs/subvolumes/* | |
| echo >&2 | |
| echo "# Update timestamps for subvolume root directories to match source:" >&2 | |
| # Update permission and timestamp only of subvolumes. | |
| # If a previous part of this script failed to create subvoumes in the | |
| # destination, this rsync will incorrectly create the missing dirs here, | |
| # but the final sync check should catch these issue (sorta). | |
| msg-run rsync -avHAXi --exclude='btrfs/subvolumes/*/*' "${DKR}/" "${dst_path}/" | |
| echo >&2 | |
| echo "# DRYRUN: Check that all files are identical from source to destination:" >&2 | |
| # -a: archive mode | |
| # -v: verbose | |
| # -n: dry run | |
| # --checksum: use checksum to verify files | |
| # --delete: delete files that are not in the source | |
| local lines | |
| lines="$(msg-run rsync -avni --checksum --delete "${DKR}/" "${dst_path}/" | tee /dev/stderr | wc -l)" | |
| if [[ "${lines}" -ne 4 ]]; then | |
| echo >&2 | |
| echo "Error: rsync output is not as expected: ${lines}" >&2 | |
| echo "This means that we FAILED to create a perfect replica of the source docker directory." >&2 | |
| echo "Ideally, rsync should report 0 changes to an file or dir." >&2 | |
| exit 1 | |
| fi | |
| # --omit-dir-times: ignore directory timestamps (the subvolumes dirs may have modified timestamps) | |
| echo >&2 | |
| echo "# Successfully migrated /var/lib/docker to ${dst_path}." >&2 | |
| } | |
| main() { | |
| local action="$1" | |
| shift | |
| local -A src_uuid_tree=( ) | |
| case "${action}" in | |
| migrate) | |
| docker-migrate "$@" | |
| ;; | |
| restore-rw) | |
| # If you do not end up using the new destination docker lib, then you | |
| # can use this command to restore the rw permission of the original | |
| # docker lib subvolumes. | |
| echo "# Restoring original ${DKR} subvolumes to rw:" >&2 | |
| btrfs-set-ro false "${DKR}"/btrfs/subvolumes/* | |
| ;; | |
| debug) | |
| echo "# Build BTRFS Subvolume Parent Tree:" >&2 | |
| btrfs-tree-build src_uuid_tree | |
| declare -pA src_uuid_tree | |
| echo >&2 | |
| echo "# Find Roots:" >&2 | |
| local -a roots | |
| mapfile -t roots< <(btrfs-tree-find-children src_uuid_tree) | |
| local root | |
| for root in "${roots[@]}"; do | |
| btrfs-subvol-lookup-uuid $DKR "${root}" | |
| btrfs-tree-find-children src_uuid_tree "${root}" | |
| done | |
| echo >&2 | |
| echo "# Dry-run Send:" >&2 | |
| DEBUG=1 | |
| # btrfs-tree-depth-first src_uuid_tree btrfs-send | |
| btrfs-tree-breath-first src_uuid_tree btrfs-send | |
| ;; | |
| send) | |
| echo "# Build BTRFS Subvolume Parent Tree:" >&2 | |
| btrfs-tree-build src_uuid_tree | |
| echo "# Send:" >&2 | |
| btrfs-tree-breath-first src_uuid_tree btrfs-send | |
| ;; | |
| -h|--help|*) | |
| echo "Usage: docker-btrfs-migrate.bash <action>" | |
| echo "Actions:" | |
| echo " migrate <new_destination_path>" | |
| echo " restore-rw: restore ${DRK} subvolumes to rw" | |
| echo " debug" | |
| echo " send" | |
| exit 1 | |
| ;; | |
| esac | |
| } | |
| main "$@" |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment