Docker 镜像备份/恢复管理脚本

年初的时候做了个 飞牛NAS Docker 容器镜像备份 的脚本,实际使用中有些不方便的,做了点升级,实现指定或者批量备份容器中镜像,自动根据项目文件夹中的docker-compose.yml(yaml)单个或者批量恢复项目。

具体脚本代码如下,可以保存成 xxx.sh,比如docker.sh,通过命令 bash ./docker.sh 执行,需要root权限。

#!/bin/bash
# =============================================
# Docker 镜像备份/恢复管理脚本
# 作者:心若随风
# =============================================

# 权限检查
if [ "$(id -u)" -ne 0 ]; then
    echo -e "\033[31m错误:必须使用 root 权限运行此脚本!\033[0m"
    exit 1
fi

# 样式
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'
BLUE='\033[0;34m'; CYAN='\033[38;5;51m'; NC='\033[0m'
CHECKMARK="✅"; CROSS="❌"; PROCESS="⚙️"; INFO='\033[0;36m'

# 全局变量
found_projects=()
PAGE_SIZE=0
BASE_PATH=""
IMAGE_BAK_DIR="$(pwd)/Image_Bak"

# 工具函数
ensure_dir_exists() { [ ! -d "$1" ] && mkdir -p "$1"; }
get_auto_page_size() {
    local lines=$(tput lines 2>/dev/null || echo 24)
    PAGE_SIZE=$((lines-16))
    ((PAGE_SIZE<5)) && PAGE_SIZE=5
    ((PAGE_SIZE>20)) && PAGE_SIZE=20
}
print_title() {
    clear
    echo -e "${CYAN}=====================================================${NC}"
    echo -e "${CYAN}        Docker 镜像备份/恢复管理脚本 by 心若随风        ${NC}"
    echo -e "${CYAN}=====================================================${NC}\n"
}

# 扫描 Docker 项目
scan_projects() {
    found_projects=()
    echo -e "${BLUE}🔍 正在扫描 [$BASE_PATH] 下的 Docker Compose 项目...${NC}"
    while IFS= read -r -d '' dir; do
        if [ -f "$dir/docker-compose.yml" ] || [ -f "$dir/docker-compose.yaml" ]; then
            found_projects+=("$dir")
        fi
    done < <(find "$BASE_PATH" -maxdepth 2 -type d -print0 2>/dev/null)
    [ ${#found_projects[@]} -eq 0 ] && { echo -e "${RED}${CROSS} 未找到 Docker Compose 项目!${NC}"; exit 1; }
}

# 获取项目镜像
get_images_from_compose() {
    local dir="$1"
    local compose_file="$dir/docker-compose.yml"
    [ ! -f "$compose_file" ] && compose_file="$dir/docker-compose.yaml"
    docker compose -f "$compose_file" config 2>/dev/null | awk '/image:/ {print $2}'
}

# ======================== 备份 ========================
# 全部本地镜像
backup_all_images() {
    ensure_dir_exists "$IMAGE_BAK_DIR"
    echo -e "\n${BLUE}开始备份所有本地镜像...${NC}"

    mapfile -t all_images < <(docker image ls --format "{{.Repository}}:{{.Tag}}" | sort -u)

    if [ ${#all_images[@]} -eq 0 ]; then
        echo -e "${YELLOW}⚠️ 当前没有本地镜像,无法备份${NC}"
        read -p "按任意键返回主菜单..."
        return
    fi

    for img in "${all_images[@]}"; do
        [ "$img" = "<none>:<none>" ] && continue
        safe_name=$(echo "$img" | tr '/:' '__')
        tar_file="$IMAGE_BAK_DIR/${safe_name}.tar"
        [ -f "$tar_file" ] && rm -f "$tar_file"
        echo -e "${PROCESS} 备份镜像: ${YELLOW}$img${NC}"
        docker save -o "$tar_file" "$img" && echo -e "${GREEN}${CHECKMARK} 已保存: $tar_file${NC}" || echo -e "${RED}${CROSS} 保存失败: $img${NC}"
    done
    read -p "按任意键返回主菜单..."
}

# 单个镜像
backup_single_image() {
    ensure_dir_exists "$IMAGE_BAK_DIR"
    mapfile -t all_images < <(docker image ls --format "{{.Repository}}:{{.Tag}}" | sort -u)
    if [ ${#all_images[@]} -eq 0 ]; then
        echo -e "${YELLOW}⚠️ 当前没有本地镜像可备份${NC}"
        read -p "按任意键返回主菜单..."
        return
    fi

    echo -e "\n${BLUE}当前所有本地镜像:${NC}"
    local idx=1
    for img in "${all_images[@]}"; do
        echo "  $idx) $img"
        ((idx++))
    done

    read -p "请选择镜像编号: " num
    img="${all_images[$((num-1))]}"
    [ -z "$img" ] && { echo -e "${RED}${CROSS} 无效选择${NC}"; read -p "按任意键返回主菜单..."; return; }
    safe_name=$(echo "$img" | tr '/:' '__')
    tar_file="$IMAGE_BAK_DIR/${safe_name}.tar"
    [ -f "$tar_file" ] && rm -f "$tar_file"
    docker save -o "$tar_file" "$img" && echo -e "${GREEN}${CHECKMARK} 备份成功: $tar_file${NC}" || echo -e "${RED}${CROSS} 备份失败: $img${NC}"
    read -p "按任意键返回主菜单..."
}

# ======================== 导入镜像 ========================
import_images() {
    local dir="$1"
    mapfile -t images < <(get_images_from_compose "$dir")
    [ ${#images[@]} -eq 0 ] && return

    for img in "${images[@]}"; do
        safe_name=$(echo "$img" | tr '/:' '__')
        tar_file="$IMAGE_BAK_DIR/${safe_name}.tar"
        local_exist=0; bak_exist=0
        docker image inspect "$img" >/dev/null 2>&1 && local_exist=1
        [ -f "$tar_file" ] && bak_exist=1

        echo ""
        echo "================================================"
        echo "镜像: $img"
        echo "================================================"

        if [ $local_exist -eq 1 ] && [ $bak_exist -eq 1 ]; then
            echo "1) 使用容器内镜像"
            echo "2) 从备份导入"
            echo "3) 在线拉取最新镜像"
            read -p "请选择 [1]: " choice
            choice=${choice:-1}
            case $choice in
                2) docker load -i "$tar_file" ;;
                3) docker pull "$img" ;;
                *) echo "使用容器内镜像" ;;
            esac
        elif [ $local_exist -eq 1 ]; then
            echo "1) 使用容器内镜像"
            echo "2) 在线拉取最新镜像"
            read -p "请选择 [1]: " choice
            choice=${choice:-1}
            [[ "$choice" == "2" ]] && docker pull "$img"
        elif [ $bak_exist -eq 1 ]; then
            echo "1) 从备份导入"
            echo "2) 在线拉取最新镜像"
            read -p "请选择 [1]: " choice
            choice=${choice:-1}
            [[ "$choice" == "1" ]] && docker load -i "$tar_file"
            [[ "$choice" == "2" ]] && docker pull "$img"
        else
            echo "本地镜像和备份均不存在,开始在线拉取..."
            docker pull "$img"
        fi
    done
}

# ======================== 单个项目恢复 ========================
restore_single_project() {
    local dir="$1"
    local project_name=$(basename "$dir")
    echo -e "\n${PROCESS} 开始恢复项目:${YELLOW}$project_name${NC}"
    import_images "$dir"
    cd "$dir" || { echo -e "${RED}${CROSS} 无法进入项目目录 [$dir]${NC}"; return; }
    docker compose up -d
    [ $? -eq 0 ] && echo -e "${GREEN}${CHECKMARK} 项目 [$project_name] 恢复完成${NC}" || echo -e "${RED}${CROSS} 恢复失败 [$project_name]${NC}"
    cd - >/dev/null
}

# ======================== 批量恢复 ========================
restore_all_projects() {

    # ==============================
    # 列出即将恢复的项目
    # ==============================
    echo ""
    echo -e "${RED}═══════════════════════════════════════${NC}"
    echo -e "${RED}        即将恢复以下项目${NC}"
    echo -e "${RED}═══════════════════════════════════════${NC}"

    local idx=1
    for dir in "${found_projects[@]}"; do
        echo " $idx) $(basename "$dir")"
        ((idx++))
    done

    echo ""
    echo -e "${YELLOW}共 ${#found_projects[@]} 个项目${NC}"
    echo ""

    # ==============================
    # 二次确认
    # ==============================
    read -p "请输入 YES 确认恢复全部项目: " confirm
    if [ "$confirm" != "YES" ]; then
        echo -e "${YELLOW}已取消操作。${NC}"
        read -p "按任意键返回主菜单..."
        return
    fi

    # ==============================
    # 批量恢复逻辑
    # ==============================
    for dir in "${found_projects[@]}"; do
        local images=($(get_images_from_compose "$dir"))
        [ ${#images[@]} -eq 0 ] && continue

        for img in "${images[@]}"; do
            safe_name=$(echo "$img" | tr '/:' '__')
            tar_file="$IMAGE_BAK_DIR/${safe_name}.tar"

            if docker image inspect "$img" >/dev/null 2>&1; then
                echo -e "${INFO} [$img] 已存在本地,跳过导入${NC}"
            elif [ -f "$tar_file" ]; then
                docker load -i "$tar_file" && echo -e "${GREEN}${CHECKMARK} 已从备份导入 [$img]${NC}" || echo -e "${RED}${CROSS} 导入失败 [$img]${NC}"
            else
                echo -e "${YELLOW}未找到本地备份,在线拉取 [$img]${NC}"
                docker pull "$img" && echo -e "${GREEN}${CHECKMARK} 在线拉取成功 [$img]${NC}" || echo -e "${RED}${CROSS} 拉取失败 [$img]${NC}"
            fi
        done

        cd "$dir" || continue
        docker compose up -d --force-recreate
        cd - >/dev/null
    done

    echo -e "\n${GREEN}${CHECKMARK} 全部项目恢复完成${NC}"
    read -p "按任意键返回主菜单..."
}
# ======================== 菜单分页浏览 ========================
menu_list_and_restore_single() {
    get_auto_page_size
    local total_projects=${#found_projects[@]}
    local total_pages=$(( (total_projects + PAGE_SIZE - 1) / PAGE_SIZE ))
    local current_page=1
    local selected_idx=""
    while [ $current_page -le $total_pages ]; do
        local start_idx=$(( (current_page-1) * PAGE_SIZE ))
        local end_idx=$(( start_idx + PAGE_SIZE - 1 ))
        [ $end_idx -ge $total_projects ] && end_idx=$(( total_projects-1 ))
        echo -e "\n${CYAN}========== 第 $current_page/$total_pages 页 ==========${NC}"
        for ((i=start_idx; i<=end_idx; i++)); do
            echo -e "  ${GREEN}$((i+1)))${NC} $(basename "${found_projects[$i]}")"
        done
        echo -e "${BLUE}-----------------------------------------------------${NC}"
        if [ $current_page -eq $total_pages ]; then
            echo -ne "${YELLOW}输入项目编号恢复(0 返回主菜单),按回车翻页:${NC}"
        else
            echo -ne "${YELLOW}输入项目编号恢复,按回车翻下一页:${NC}"
        fi
        read user_input
        if [ -z "$user_input" ]; then
            if [ $current_page -eq $total_pages ]; then break; else current_page=$((current_page+1)); clear; fi
        else
            if ! [[ "$user_input" =~ ^[0-9]+$ ]]; then
                echo -e "${RED}${CROSS} 输入无效!${NC}"; read -p "按任意键继续..."; clear; continue
            fi
            if [ "$user_input" -eq 0 ] && [ $current_page -eq $total_pages ]; then
                selected_idx="0"; break
            elif [ "$user_input" -ge 1 ] && [ "$user_input" -le "$total_projects" ]; then
                selected_idx="$user_input"; break
            else
                echo -e "${RED}${CROSS} 编号无效!${NC}"; read -p "按任意键继续..."; clear; continue
            fi
        fi
    done
    if [ "$selected_idx" = "0" ]; then return
    elif [ -n "$selected_idx" ]; then
        restore_single_project "${found_projects[$((selected_idx-1))]}"
        read -p "按任意键返回主菜单..."
    else
        echo -e "${YELLOW}未选择任何项目,返回主菜单...${NC}"; read -p "按任意键继续..."
    fi
}

# ======================== 主菜单 ========================
main_menu() {
    while true; do
        print_title
        echo -e "${YELLOW}🔧 主菜单${NC}"
        echo -e "${BLUE}-----------------------------------------------------${NC}"
        echo -e "  1) 备份所有本地镜像"
        echo -e 
        echo -e "  2) 备份单个本地镜像"
        echo -e 
        echo -e "  3) 恢复单个项目(会搜索项目文件夹中的项目,输入数值恢复)"
        echo -e 
        echo -e "  4) 恢复全部项目(会恢复项目文件夹下每个项目,慎用!!)"
        echo -e 
        echo -e "  0) 退出"
        echo -e "${BLUE}-----------------------------------------------------${NC}"
        echo -ne "${YELLOW}请选择操作(0-4):${NC}"
        read choice
        echo ""
        case $choice in
            1) backup_all_images ;;
            2) backup_single_image ;;
            3) menu_list_and_restore_single ;;
            4) restore_all_projects ;;
            0) echo -e "${GREEN}👋 感谢使用!${NC}"; exit 0 ;;
            *) echo -e "${RED}${CROSS} 无效选择!请输入 0-4 之间的数字${NC}"; read -p "按任意键返回主菜单..." ;;
        esac
    done
}

# 主入口
main() {
    print_title
    echo -ne "${YELLOW}请输入 Docker 项目目录路径(例如:/vol1/1000/000.Docker):\n> ${NC}"
    read BASE_PATH
    [ -z "$BASE_PATH" ] || [ ! -d "$BASE_PATH" ] && { echo -e "${RED}${CROSS} 路径无效!${NC}"; exit 1; }
    ensure_dir_exists "$IMAGE_BAK_DIR"
    scan_projects
    main_menu
}

main

Language
中文(简体) 中文(繁體) 日本語 한국어 русский English français Deutsch español italiano বাংলা (ভারত) العربية ไทย Tiếng Việt Bahasa Melayu Filipino ελληνικά magyar dansk norsk íslenska Gaeilge