#!/usr/bin/env bash # shellcheck shell=bash # Bash >= 5.0 # # ============================================================================== # Name : image_pipeline.sh # Version : 1.0.0 # Author : OpenAI # License : MIT # Updated : 2026-03-03 # # Assumptions # - 输入镜像文件为纯文本:每行一个完整镜像名;支持空行与以 # 开头的注释行。 # - 默认动作为 download;默认目标架构为 arm64;默认压缩方式为 combined(统一打包)。 # - “重新打包并上传”默认保留原镜像路径:目标镜像为 HARBOR_HOST/<原镜像路径>; # 若原镜像名不含 "/"(如 alpine:3.19),则上传为 HARBOR_HOST/library/alpine:3.19。 # - Harbor 地址格式为 IP:PORT(不含协议前缀);用户名/密码/端口可被参数覆盖,脚本内提供默认值。 # - 压缩优先使用 pigz 进行多线程压缩;若系统无 pigz,则回退到 gzip(单线程)。 # - 为满足“可直接落地”,脚本实现为单文件,默认不执行破坏性删除;上传属于显式网络写操作。 # # Bash 特性说明 # - 使用数组(Bash 特性)安全保存镜像列表与失败列表,避免字符串拼接引发的分词问题。 # - 使用 [[ ]](Bash 特性)进行更稳健的条件判断。 # - 使用 local、BASH_SOURCE、FUNCNAME、BASH_LINENO(Bash 特性)增强错误可观测性。 # # Call Graph # main # ├─ parse_args # ├─ init_runtime # ├─ validate_args # ├─ check_dependencies # ├─ load_image_list # ├─ case ACTION in # │ ├─ download # │ │ └─ perform_downloads # │ │ └─ download_one_image # │ │ ├─ compose_pull_source # │ │ ├─ pull_image # │ │ ├─ tag_image_if_needed # │ │ └─ inspect_and_verify_arch # │ ├─ compress # │ │ ├─ verify_images_present # │ │ └─ perform_compress # │ │ ├─ compress_combined # │ │ └─ compress_split_parallel # │ ├─ push # │ │ ├─ verify_images_present # │ │ ├─ harbor_login # │ │ └─ perform_pushes # │ │ ├─ build_target_image_ref # │ │ ├─ tag_image_if_needed # │ │ └─ push_image # │ └─ all # │ ├─ perform_downloads # │ ├─ verify_images_present # │ ├─ perform_compress # │ ├─ harbor_login # │ └─ perform_pushes # ├─ print_summary # └─ cleanup # ============================================================================== set -Eeuo pipefail IFS=$' \t\n' # ============================================================================== # 全局常量区 # ============================================================================== readonly SCRIPT_NAME="$(basename "${BASH_SOURCE[0]}")" readonly SCRIPT_VERSION="1.0.0" readonly EXIT_SUCCESS=0 readonly EXIT_USAGE=2 readonly EXIT_DEPENDENCY=3 readonly EXIT_RUNTIME=4 readonly EXIT_VALIDATION=5 readonly EXIT_INTERRUPT=130 readonly DEFAULT_ACTION="download" readonly DEFAULT_ARCH="amd64" readonly DEFAULT_BUNDLE_MODE="combined" readonly DEFAULT_OUTPUT_DIR="./image-artifacts" readonly DEFAULT_THREADS="0" # 0 = auto detect readonly DEFAULT_RETRY="1" readonly DEFAULT_RETRY_DELAY="2" readonly DEFAULT_LOG_LEVEL="INFO" readonly DEFAULT_HARBOR_HOST="192.168.5.41:8033" readonly DEFAULT_HARBOR_USERNAME="admin" readonly DEFAULT_HARBOR_PASSWORD="V2ryStr@ngPss" readonly LOCK_FILE_NAME=".image_pipeline.lock" # ============================================================================== # 全局变量区(可配置) # ============================================================================== ACTION="${DEFAULT_ACTION}" IMAGE_FILE="" ARCH="${DEFAULT_ARCH}" MIRROR_PREFIX="" BUNDLE_MODE="${DEFAULT_BUNDLE_MODE}" OUTPUT_DIR="${DEFAULT_OUTPUT_DIR}" THREADS="${DEFAULT_THREADS}" RETRY="${DEFAULT_RETRY}" RETRY_DELAY="${DEFAULT_RETRY_DELAY}" HARBOR_HOST="${DEFAULT_HARBOR_HOST}" HARBOR_USERNAME="${DEFAULT_HARBOR_USERNAME}" HARBOR_PASSWORD="${DEFAULT_HARBOR_PASSWORD}" DRY_RUN="false" FORCE="false" LOG_LEVEL="${LOG_LEVEL:-${DEFAULT_LOG_LEVEL}}" TMP_DIR="" LOCK_FILE="" COMPRESSOR_CMD="gzip" COMPRESSOR_ARGS=() # Bash 数组用于安全保存列表 IMAGE_LIST=() DOWNLOAD_FAILED=() DOWNLOAD_USED_LOCAL=() VERIFY_MISSING=() VERIFY_ARCH_MISMATCH=() COMPRESS_FAILED=() PUSH_FAILED=() PUSH_SUCCEEDED=() # ============================================================================== # usage / help # ============================================================================== usage() { cat <<'EOF' Usage: image_pipeline.sh --image-file [options] Required: -f, --image-file 镜像清单文件;每行一个完整镜像名 Action: -a, --action 动作: download | compress | push | all 默认: download Download options: --arch 目标架构: arm64 | amd64 默认: arm64 --mirror-prefix 下载镜像加速前缀;如: registry.local:5000 拉取时会从 /<原镜像名> 下载,成功后重新 tag 为原镜像名 Compress options: --bundle-mode 压缩模式: combined | split combined: 所有镜像统一打包为一个 tar.gz(默认) split : 每个镜像单独打包为 tar.gz --output-dir 输出目录,默认: ./image-artifacts --threads 压缩线程数;0 表示自动检测 CPU 核数(默认) --force 强制跳过“镜像存在性/架构一致性”检查 Push options: --harbor 目标 Harbor 地址,默认: 192.168.5.41:8033 --harbor-user Harbor 用户名,默认: admin --harbor-password Harbor 密码,默认: 脚本内置默认值(可改为环境变量注入) Common options: --retry 外部命令失败重试次数(默认: 1,即不重试) --retry-delay 重试间隔秒数(默认: 2) --dry-run 仅打印将执行的动作,不实际执行 --verbose 输出 DEBUG 日志 --quiet 仅输出 ERROR 日志 -h, --help 显示帮助 Examples: 1) 正常下载 ARM64 镜像: ./image_pipeline.sh -f ./kubernetes-1.30.14-arm.txt --action download --arch arm64 2) 通过加速前缀下载,并统一打包: ./image_pipeline.sh -f ./images.txt --action all \ --mirror-prefix registry-cache.local:5000 \ --bundle-mode combined --output-dir ./dist 3) 仅压缩(每镜像单独 tar.gz),跳过校验: ./image_pipeline.sh -f ./images.txt --action compress --bundle-mode split --force 4) 重新 tag 并上传至 Harbor: ./image_pipeline.sh -f ./images.txt --action push \ --harbor 192.168.5.41:8033 --harbor-user admin --harbor-password '***' Exit Codes: 0 成功 2 参数错误 3 依赖缺失/环境不满足 4 运行时错误 5 输入校验失败(文件不存在、镜像缺失、架构不匹配等) 最小自测方法: # 正常路径(dry-run) ./image_pipeline.sh -f ./images.txt --action download --dry-run # 参数错误 ./image_pipeline.sh --action download # 压缩 dry-run ./image_pipeline.sh -f ./images.txt --action compress --bundle-mode split --dry-run EOF } # ============================================================================== # 日志与错误处理区 # ============================================================================== ### 将日志级别名称转为数值,便于比较 ### @param level string 日志级别名称 ### @return 0 成功输出数值 ### @require 无 ### @side_effect 无 level_to_num() { local level="${1:-INFO}" case "${level^^}" in DEBUG) printf '%s\n' "10" ;; INFO) printf '%s\n' "20" ;; WARN) printf '%s\n' "30" ;; ERROR) printf '%s\n' "40" ;; *) printf '%s\n' "20" ;; esac } ### 输出统一格式日志 ### @param level string 日志级别 ### @param message string 日志消息 ### @return 0 成功输出 ### @require date ### @side_effect 向 stderr 输出日志 log_msg() { local level="${1:-INFO}" shift || true local message="${*:-}" local now="" local current_level_num="" local target_level_num="" now="$(date '+%Y-%m-%d %H:%M:%S')" current_level_num="$(level_to_num "${LOG_LEVEL}")" target_level_num="$(level_to_num "${level}")" if [[ "${target_level_num}" -ge "${current_level_num}" ]]; then printf '%s [%s] %s\n' "${now}" "${level^^}" "${message}" >&2 fi } ### DEBUG 级别日志 ### @param message string 日志消息 ### @return 0 成功输出 ### @require log_msg ### @side_effect 向 stderr 输出日志 log_debug() { log_msg "DEBUG" "$*"; } ### INFO 级别日志 ### @param message string 日志消息 ### @return 0 成功输出 ### @require log_msg ### @side_effect 向 stderr 输出日志 log_info() { log_msg "INFO" "$*"; } ### WARN 级别日志 ### @param message string 日志消息 ### @return 0 成功输出 ### @require log_msg ### @side_effect 向 stderr 输出日志 log_warn() { log_msg "WARN" "$*"; } ### ERROR 级别日志 ### @param message string 日志消息 ### @return 0 成功输出 ### @require log_msg ### @side_effect 向 stderr 输出日志 log_error() { log_msg "ERROR" "$*"; } ### 统一失败出口 ### @param message string 错误信息 ### @param exit_code int 退出码 ### @return 非 0 退出 ### @require 无 ### @side_effect 终止脚本 die() { local message="${1:-Unknown error}" local exit_code="${2:-${EXIT_RUNTIME}}" log_error "${message}" exit "${exit_code}" } ### 打印调用栈,提升可观测性 ### @param none none 无 ### @return 0 打印完成 ### @require Bash 调用栈变量 ### @side_effect 向 stderr 输出栈信息 print_stack_trace() { local i="" local depth="${#FUNCNAME[@]}" if [[ "${depth}" -le 1 ]]; then return 0 fi printf 'Stack trace:\n' >&2 for (( i=1; i&2 done } ### ERR trap 处理器 ### @param line_no int 出错行号 ### @param cmd string 出错命令 ### @param code int 退出码 ### @return 非 0 继续退出 ### @require trap ERR ### @side_effect 输出错误细节 on_err() { local line_no="${1:-unknown}" local cmd="${2:-unknown}" local code="${3:-1}" log_error "命令执行失败: line=${line_no}, exit_code=${code}, cmd=${cmd}" print_stack_trace exit "${code}" } ### 信号中断处理器 ### @param signal string 信号名 ### @return 非 0 中断退出 ### @require trap INT/TERM ### @side_effect 输出日志并退出 on_signal() { local signal="${1:-INT}" log_warn "收到中断信号: ${signal}" exit "${EXIT_INTERRUPT}" } ### 退出清理函数 ### @param none none 无 ### @return 0 清理完成 ### @require mktemp ### @side_effect 删除临时目录/锁文件 cleanup() { local code=$? # > 只清理本脚本创建的临时资源,避免误删用户文件 if [[ -n "${LOCK_FILE}" && -f "${LOCK_FILE}" ]]; then rm -f "${LOCK_FILE}" || true fi if [[ -n "${TMP_DIR}" && -d "${TMP_DIR}" ]]; then rm -rf "${TMP_DIR}" || true fi # > EXIT trap 不覆盖原始退出码 return "${code}" } trap 'on_err "${LINENO}" "${BASH_COMMAND}" "$?"' ERR trap 'on_signal "INT"' INT trap 'on_signal "TERM"' TERM trap cleanup EXIT # ============================================================================== # 通用工具函数 # ============================================================================== ### 判断命令是否存在 ### @param cmd string 命令名 ### @return 0 存在 / 1 不存在 ### @require command ### @side_effect 无 has_cmd() { command -v "${1}" >/dev/null 2>&1 } ### 规范化架构名称 ### @param arch string 原始架构名 ### @return 0 输出规范化架构名 ### @require tr ### @side_effect 无 normalize_arch() { local raw="${1:-}" local value="" value="$(printf '%s' "${raw}" | tr '[:upper:]' '[:lower:]')" case "${value}" in arm64|aarch64) printf '%s\n' "arm64" ;; amd64|x86_64) printf '%s\n' "amd64" ;; *) printf '%s\n' "${value}" ;; esac } ### 校验是否为正整数 ### @param value string 待校验值 ### @return 0 合法 / 1 非法 ### @require 无 ### @side_effect 无 is_positive_integer() { local value="${1:-}" [[ "${value}" =~ ^[0-9]+$ ]] } ### 获取 CPU 核数 ### @param none none 无 ### @return 0 输出核数 ### @require nproc 或 getconf ### @side_effect 无 detect_cpu_threads() { local cpu_count="1" if has_cmd "nproc"; then cpu_count="$(nproc)" elif has_cmd "getconf"; then cpu_count="$(getconf _NPROCESSORS_ONLN 2>/dev/null || printf '%s' "1")" fi if ! is_positive_integer "${cpu_count}" || [[ "${cpu_count}" -lt 1 ]]; then cpu_count="1" fi printf '%s\n' "${cpu_count}" } ### 通过 run_cmd 统一执行外部命令,支持 dry-run 与重试 ### @param ... string 命令及参数 ### @return 0 成功 / 非 0 失败 ### @require sleep(重试时) ### @side_effect 执行外部命令 run_cmd() { local attempt=1 local max_attempts="${RETRY}" local delay="${RETRY_DELAY}" local printable_cmd="" local arg="" if [[ "$#" -eq 0 ]]; then die "run_cmd 收到空命令" "${EXIT_RUNTIME}" fi for arg in "$@"; do if [[ -n "${printable_cmd}" ]]; then printable_cmd+=" " fi printable_cmd+="$(printf '%q' "${arg}")" done if [[ "${DRY_RUN}" == "true" ]]; then log_info "[dry-run] ${printable_cmd}" return 0 fi while (( attempt <= max_attempts )); do log_debug "执行命令(第 ${attempt}/${max_attempts} 次): ${printable_cmd}" if "$@"; then return 0 fi if (( attempt < max_attempts )); then log_warn "命令失败,${delay}s 后重试: ${printable_cmd}" sleep "${delay}" fi attempt=$((attempt + 1)) done log_error "命令最终失败: ${printable_cmd}" return 1 } # ============================================================================== # 依赖与环境检查区 # ============================================================================== ### 初始化运行时目录、锁与压缩器 ### @param none none 无 ### @return 0 初始化完成 ### @require mktemp ### @side_effect 创建临时目录/锁文件 init_runtime() { TMP_DIR="$(mktemp -d -t image-pipeline.XXXXXX)" LOCK_FILE="${TMP_DIR}/${LOCK_FILE_NAME}" # > 使用临时目录中的锁文件,避免并发实例相互覆盖中间状态 : > "${LOCK_FILE}" if has_cmd "pigz"; then COMPRESSOR_CMD="pigz" else COMPRESSOR_CMD="gzip" fi } ### 按当前动作检查依赖 ### @param none none 无 ### @return 0 依赖满足 ### @require command ### @side_effect 无 check_dependencies() { local missing=() has_cmd "docker" || missing+=("docker") has_cmd "date" || missing+=("date") has_cmd "mktemp" || missing+=("mktemp") if [[ "${ACTION}" == "compress" || "${ACTION}" == "all" ]]; then if [[ "${COMPRESSOR_CMD}" == "pigz" ]]; then : else has_cmd "gzip" || missing+=("gzip") fi fi if [[ "${#missing[@]}" -gt 0 ]]; then die "缺少依赖命令: ${missing[*]}" "${EXIT_DEPENDENCY}" fi if [[ "${COMPRESSOR_CMD}" == "pigz" ]]; then log_info "检测到 pigz:将启用多线程压缩" elif [[ "${ACTION}" == "compress" || "${ACTION}" == "all" ]]; then log_warn "未检测到 pigz:将回退到 gzip(单线程)" fi } # ============================================================================== # 参数解析与校验 # ============================================================================== ### 解析命令行参数 ### @param ... string 命令行参数 ### @return 0 解析成功 ### @require 无 ### @side_effect 修改全局配置变量 parse_args() { while [[ "$#" -gt 0 ]]; do case "$1" in -f|--image-file) [[ "$#" -ge 2 ]] || die "参数缺失: $1 需要一个文件路径" "${EXIT_USAGE}" IMAGE_FILE="$2" shift 2 ;; -a|--action) [[ "$#" -ge 2 ]] || die "参数缺失: $1 需要一个动作值" "${EXIT_USAGE}" ACTION="$2" shift 2 ;; --arch) [[ "$#" -ge 2 ]] || die "参数缺失: $1 需要一个架构值" "${EXIT_USAGE}" ARCH="$2" shift 2 ;; --mirror-prefix) [[ "$#" -ge 2 ]] || die "参数缺失: $1 需要一个前缀值" "${EXIT_USAGE}" MIRROR_PREFIX="$2" shift 2 ;; --bundle-mode) [[ "$#" -ge 2 ]] || die "参数缺失: $1 需要一个模式值" "${EXIT_USAGE}" BUNDLE_MODE="$2" shift 2 ;; --output-dir) [[ "$#" -ge 2 ]] || die "参数缺失: $1 需要一个目录路径" "${EXIT_USAGE}" OUTPUT_DIR="$2" shift 2 ;; --threads) [[ "$#" -ge 2 ]] || die "参数缺失: $1 需要一个线程数" "${EXIT_USAGE}" THREADS="$2" shift 2 ;; --harbor) [[ "$#" -ge 2 ]] || die "参数缺失: $1 需要一个 Harbor 地址" "${EXIT_USAGE}" HARBOR_HOST="$2" shift 2 ;; --harbor-user) [[ "$#" -ge 2 ]] || die "参数缺失: $1 需要一个用户名" "${EXIT_USAGE}" HARBOR_USERNAME="$2" shift 2 ;; --harbor-password) [[ "$#" -ge 2 ]] || die "参数缺失: $1 需要一个密码" "${EXIT_USAGE}" HARBOR_PASSWORD="$2" shift 2 ;; --retry) [[ "$#" -ge 2 ]] || die "参数缺失: $1 需要一个重试次数" "${EXIT_USAGE}" RETRY="$2" shift 2 ;; --retry-delay) [[ "$#" -ge 2 ]] || die "参数缺失: $1 需要一个秒数" "${EXIT_USAGE}" RETRY_DELAY="$2" shift 2 ;; --dry-run) DRY_RUN="true" shift ;; --force) FORCE="true" shift ;; --verbose) LOG_LEVEL="DEBUG" shift ;; --quiet) LOG_LEVEL="ERROR" shift ;; -h|--help) usage exit "${EXIT_SUCCESS}" ;; --) shift break ;; -*) die "未知参数: $1" "${EXIT_USAGE}" ;; *) die "不支持的位置参数: $1" "${EXIT_USAGE}" ;; esac done } ### 校验参数与输入 ### @param none none 无 ### @return 0 校验通过 ### @require 无 ### @side_effect 可能创建输出目录(dry-run 时仅日志提示) validate_args() { ARCH="$(normalize_arch "${ARCH}")" [[ -n "${IMAGE_FILE}" ]] || die "必须提供 --image-file" "${EXIT_USAGE}" if [[ -L "${IMAGE_FILE}" ]]; then die "出于安全考虑,不接受符号链接作为镜像文件: ${IMAGE_FILE}" "${EXIT_VALIDATION}" fi [[ -f "${IMAGE_FILE}" ]] || die "镜像文件不存在: ${IMAGE_FILE}" "${EXIT_VALIDATION}" [[ -r "${IMAGE_FILE}" ]] || die "镜像文件不可读: ${IMAGE_FILE}" "${EXIT_VALIDATION}" case "${ACTION}" in download|compress|push|all) ;; *) die "非法动作: ${ACTION}(允许: download|compress|push|all)" "${EXIT_USAGE}" ;; esac case "${ARCH}" in arm64|amd64) ;; *) die "非法架构: ${ARCH}(允许: arm64|amd64)" "${EXIT_USAGE}" ;; esac case "${BUNDLE_MODE}" in combined|split) ;; *) die "非法压缩模式: ${BUNDLE_MODE}(允许: combined|split)" "${EXIT_USAGE}" ;; esac is_positive_integer "${THREADS}" || die "--threads 必须为非负整数" "${EXIT_USAGE}" is_positive_integer "${RETRY}" || die "--retry 必须为非负整数" "${EXIT_USAGE}" is_positive_integer "${RETRY_DELAY}" || die "--retry-delay 必须为非负整数" "${EXIT_USAGE}" if [[ "${RETRY}" -eq 0 ]]; then die "--retry 不能为 0;如不重试,请使用 1" "${EXIT_USAGE}" fi if [[ "${THREADS}" -eq 0 ]]; then THREADS="$(detect_cpu_threads)" fi if [[ "${ACTION}" == "compress" || "${ACTION}" == "all" ]]; then if [[ "${DRY_RUN}" == "true" ]]; then log_info "[dry-run] 将创建输出目录: ${OUTPUT_DIR}" else mkdir -p "${OUTPUT_DIR}" fi fi if [[ "${ACTION}" == "push" || "${ACTION}" == "all" ]]; then [[ "${HARBOR_HOST}" =~ ^[A-Za-z0-9._-]+:[0-9]+$ ]] || die \ "Harbor 地址格式非法(应为 IP:PORT 或 HOST:PORT): ${HARBOR_HOST}" "${EXIT_USAGE}" [[ -n "${HARBOR_USERNAME}" ]] || die "Harbor 用户名不能为空" "${EXIT_USAGE}" [[ -n "${HARBOR_PASSWORD}" ]] || die "Harbor 密码不能为空" "${EXIT_USAGE}" fi log_debug "参数校验通过: action=${ACTION}, arch=${ARCH}, bundle_mode=${BUNDLE_MODE}, threads=${THREADS}" } # ============================================================================== # 业务函数区:镜像清单 # ============================================================================== ### 加载镜像清单到数组 ### @param file string 镜像清单文件 ### @return 0 加载成功 ### @require 文件可读 ### @side_effect 修改全局 IMAGE_LIST 数组 load_image_list() { local file_path="${1}" local line="" local trimmed="" IMAGE_LIST=() while IFS= read -r line || [[ -n "${line}" ]]; do # > 去掉行首尾空白后再判断,避免空格行被误识别为镜像名 trimmed="${line#"${line%%[![:space:]]*}"}" trimmed="${trimmed%"${trimmed##*[![:space:]]}"}" [[ -z "${trimmed}" ]] && continue [[ "${trimmed}" == \#* ]] && continue IMAGE_LIST+=("${trimmed}") done < "${file_path}" [[ "${#IMAGE_LIST[@]}" -gt 0 ]] || die "镜像文件中未读取到任何有效镜像: ${file_path}" "${EXIT_VALIDATION}" log_info "已加载镜像数量: ${#IMAGE_LIST[@]}" } # ============================================================================== # 业务函数区:Docker 基础操作 # ============================================================================== ### 生成实际拉取的源镜像名 ### @param image string 原始镜像名 ### @return 0 输出实际拉取镜像名 ### @require 无 ### @side_effect 无 compose_pull_source() { local image_ref="${1}" local prefix="${MIRROR_PREFIX%/}" if [[ -n "${prefix}" ]]; then printf '%s/%s\n' "${prefix}" "${image_ref}" else printf '%s\n' "${image_ref}" fi } ### 拉取镜像 ### @param source_image string 实际拉取源镜像 ### @param arch string 目标架构 ### @return 0 拉取成功 ### @require docker ### @side_effect 向本地 Docker 写入镜像 pull_image() { local source_image="${1}" local target_arch="${2}" # > 显式指定平台,确保拉取结果符合目标 CPU 架构 run_cmd docker pull --platform "linux/${target_arch}" "${source_image}" } ### 条件性重新打 tag ### @param source_image string 源镜像名 ### @param target_image string 目标镜像名 ### @return 0 成功 ### @require docker ### @side_effect 生成新的本地镜像标签 tag_image_if_needed() { local source_image="${1}" local target_image="${2}" if [[ "${source_image}" == "${target_image}" ]]; then return 0 fi run_cmd docker tag "${source_image}" "${target_image}" } ### 获取本地镜像架构 ### @param image string 镜像名 ### @return 0 输出架构;不存在则返回非 0 ### @require docker ### @side_effect 无 get_local_image_arch() { local image_ref="${1}" local arch_value="" arch_value="$(docker image inspect --format '{{.Architecture}}' "${image_ref}" 2>/dev/null)" [[ -n "${arch_value}" ]] || return 1 normalize_arch "${arch_value}" } ### 检查本地镜像是否存在 ### @param image string 镜像名 ### @return 0 存在 / 1 不存在 ### @require docker ### @side_effect 无 image_exists_locally() { local image_ref="${1}" docker image inspect "${image_ref}" >/dev/null 2>&1 } ### 检查并输出架构,同时校验是否匹配 ### @param image string 镜像名 ### @param expected_arch string 期望架构 ### @return 0 匹配 / 1 不匹配或不存在 ### @require docker ### @side_effect 输出日志 inspect_and_verify_arch() { local image_ref="${1}" local expected_arch="${2}" local actual_arch="" if ! actual_arch="$(get_local_image_arch "${image_ref}")"; then log_error "镜像不存在或无法读取架构: ${image_ref}" return 1 fi log_info "镜像架构检查: ${image_ref} -> ${actual_arch}" if [[ "${actual_arch}" != "${expected_arch}" ]]; then log_error "镜像架构不匹配: ${image_ref}, expected=${expected_arch}, actual=${actual_arch}" return 1 fi return 0 } # ============================================================================== # 业务函数区:下载 # ============================================================================== ### 下载单个镜像,并在需要时重新 tag ### @param image string 原始镜像名 ### @return 0 成功 ### @require docker ### @side_effect 拉取/打 tag 到本地 Docker download_one_image() { local original_image="${1}" local source_image="" local pull_succeeded="false" source_image="$(compose_pull_source "${original_image}")" log_info "开始下载镜像: ${original_image}" log_debug "实际拉取源: ${source_image}" if pull_image "${source_image}" "${ARCH}"; then pull_succeeded="true" log_info "镜像下载成功: ${source_image}" else log_warn "镜像下载失败: ${source_image}" fi if [[ "${pull_succeeded}" == "true" ]]; then tag_image_if_needed "${source_image}" "${original_image}" else # > 若拉取失败但本地已存在同名镜像,则允许复用本地镜像继续流程 if image_exists_locally "${original_image}"; then DOWNLOAD_USED_LOCAL+=("${original_image}") log_warn "下载失败但本地已存在镜像,将复用本地镜像: ${original_image}" else DOWNLOAD_FAILED+=("${original_image}") return 1 fi fi inspect_and_verify_arch "${original_image}" "${ARCH}" } ### 批量执行下载 ### @param none none 无 ### @return 0 成功;存在失败则返回非 0 ### @require IMAGE_LIST 已加载 ### @side_effect 写入本地 Docker perform_downloads() { local image_ref="" local failed_count_before="${#DOWNLOAD_FAILED[@]}" for image_ref in "${IMAGE_LIST[@]}"; do if ! download_one_image "${image_ref}"; then log_error "下载阶段失败: ${image_ref}" fi done if [[ "${#DOWNLOAD_FAILED[@]}" -gt "${failed_count_before}" ]]; then return 1 fi return 0 } # ============================================================================== # 业务函数区:压缩前校验 # ============================================================================== ### 校验镜像是否全部存在且架构符合预期 ### @param none none 无 ### @return 0 校验通过 ### @require docker ### @side_effect 填充 VERIFY_MISSING / VERIFY_ARCH_MISMATCH verify_images_present() { local image_ref="" local actual_arch="" VERIFY_MISSING=() VERIFY_ARCH_MISMATCH=() if [[ "${FORCE}" == "true" ]]; then log_warn "已启用 --force:跳过镜像存在性/架构一致性检查" return 0 fi for image_ref in "${IMAGE_LIST[@]}"; do if ! image_exists_locally "${image_ref}"; then VERIFY_MISSING+=("${image_ref}") continue fi actual_arch="$(get_local_image_arch "${image_ref}" || true)" if [[ -z "${actual_arch}" ]]; then VERIFY_MISSING+=("${image_ref}") continue fi if [[ "${actual_arch}" != "${ARCH}" ]]; then VERIFY_ARCH_MISMATCH+=("${image_ref} (actual=${actual_arch})") fi done if [[ "${#VERIFY_MISSING[@]}" -gt 0 || "${#VERIFY_ARCH_MISMATCH[@]}" -gt 0 ]]; then if [[ "${#VERIFY_MISSING[@]}" -gt 0 ]]; then log_error "缺失镜像:" printf ' - %s\n' "${VERIFY_MISSING[@]}" >&2 fi if [[ "${#VERIFY_ARCH_MISMATCH[@]}" -gt 0 ]]; then log_error "架构不匹配镜像:" printf ' - %s\n' "${VERIFY_ARCH_MISMATCH[@]}" >&2 fi return 1 fi log_info "镜像存在性与架构检查通过" return 0 } # ============================================================================== # 业务函数区:压缩 # ============================================================================== ### 将镜像名转换为安全文件名 ### @param image string 镜像名 ### @return 0 输出文件安全名 ### @require tr ### @side_effect 无 sanitize_image_to_filename() { local image_ref="${1}" local safe_name="" safe_name="$(printf '%s' "${image_ref}" | sed 's|/|_|g; s|:|__|g; s|@|__|g')" printf '%s\n' "${safe_name}" } ### 构建压缩命令参数 ### @param none none 无 ### @return 0 设置 COMPRESSOR_ARGS ### @require 无 ### @side_effect 修改全局 COMPRESSOR_ARGS 数组 prepare_compressor_args() { COMPRESSOR_ARGS=() if [[ "${COMPRESSOR_CMD}" == "pigz" ]]; then COMPRESSOR_ARGS=(-p "${THREADS}") else COMPRESSOR_ARGS=() fi } ### 统一打包所有镜像为一个 tar.gz ### @param output_file string 输出文件路径 ### @return 0 成功 ### @require docker + pigz/gzip ### @side_effect 写入压缩包文件 compress_combined() { local output_file="${1}" local image_ref="" local -a image_args=() for image_ref in "${IMAGE_LIST[@]}"; do image_args+=("${image_ref}") done prepare_compressor_args log_info "开始统一打包: ${output_file}" # > 这里只传“镜像列表”本身,不要把字面量 save 传进去 run_cmd bash -c ' set -Eeuo pipefail docker save "$@" | '"${COMPRESSOR_CMD}"' '"${COMPRESSOR_ARGS[*]:-}"' > "'"${output_file}"'" ' _ "${image_args[@]}" || return 1 log_info "统一打包完成: ${output_file}" if [[ "${DRY_RUN}" != "true" && -f "${output_file}" ]]; then log_info "压缩包大小: $(du -h "${output_file}" | awk '{print $1}')" fi } ### 单个镜像压缩为 tar.gz ### @param image string 镜像名 ### @param output_dir string 输出目录 ### @return 0 成功 ### @require docker + pigz/gzip ### @side_effect 写入单镜像压缩包 compress_one_image() { local image_ref="${1}" local out_dir="${2}" local file_name="" local output_file="" file_name="$(sanitize_image_to_filename "${image_ref}")" output_file="${out_dir}/${file_name}.tar.gz" prepare_compressor_args log_info "开始单镜像打包: ${image_ref} -> ${output_file}" run_cmd bash -c ' set -Eeuo pipefail docker save "$1" | '"${COMPRESSOR_CMD}"' '"${COMPRESSOR_ARGS[*]:-}"' > "$2" ' _ "${image_ref}" "${output_file}" log_info "单镜像打包完成: ${output_file}" } ### 并发执行每镜像单独压缩 ### @param output_dir string 输出目录 ### @return 0 全部成功 / 非 0 存在失败 ### @require docker + pigz/gzip ### @side_effect 写入多个压缩包 compress_split_parallel() { local out_dir="${1}" local image_ref="" local -a pids=() local -a pid_to_image=() local index=0 local active_jobs=0 local wait_pid="" local wait_rc="0" COMPRESS_FAILED=() pids=() pid_to_image=() for image_ref in "${IMAGE_LIST[@]}"; do ( compress_one_image "${image_ref}" "${out_dir}" ) & pids+=("$!") pid_to_image+=("${image_ref}") active_jobs=$((active_jobs + 1)) # > 控制最大并发数,避免同时大量 docker save 导致 IO/CPU 失控 if [[ "${active_jobs}" -ge "${THREADS}" ]]; then wait_pid="${pids[0]}" if ! wait "${wait_pid}"; then wait_rc=$? COMPRESS_FAILED+=("${pid_to_image[0]}") log_error "单镜像压缩失败: ${pid_to_image[0]} (exit=${wait_rc})" fi pids=("${pids[@]:1}") pid_to_image=("${pid_to_image[@]:1}") active_jobs=$((active_jobs - 1)) fi index=$((index + 1)) done for index in "${!pids[@]}"; do if ! wait "${pids[$index]}"; then wait_rc=$? COMPRESS_FAILED+=("${pid_to_image[$index]}") log_error "单镜像压缩失败: ${pid_to_image[$index]} (exit=${wait_rc})" fi done [[ "${#COMPRESS_FAILED[@]}" -eq 0 ]] } ### 执行压缩动作 ### @param none none 无 ### @return 0 成功 ### @require verify_images_present 已通过 ### @side_effect 写入压缩文件 perform_compress() { local output_file="" local image_file_name="" local image_file_base="" local archive_date="" if [[ "${BUNDLE_MODE}" == "combined" ]]; then image_file_name="$(basename "${IMAGE_FILE}")" image_file_base="${image_file_name%.txt}" archive_date="$(date '+%Y%m%d')" output_file="${OUTPUT_DIR}/${image_file_base}_${archive_date}_${ARCH}.tar.gz" compress_combined "${output_file}" else compress_split_parallel "${OUTPUT_DIR}" fi } # ============================================================================== # 业务函数区:上传 Harbor # ============================================================================== ### 构建目标 Harbor 镜像名 ### @param image string 原始镜像名 ### @return 0 输出目标镜像名 build_target_image_ref() { local image_ref="${1}" local pure_image local image_name_tag # 1. 剥离域名部分 (处理 docker.io/xxx 或 my.reg.com/xxx) # 如果斜杠数量 >= 2,说明带了域名,取最后两部分 if [[ "${image_ref}" =~ .*/.*/.* ]]; then pure_image=$(echo "${image_ref}" | rev | cut -d'/' -f1,2 | rev) else pure_image="${image_ref}" fi # 2. 核心转换逻辑 if [[ "${pure_image}" == rancher/* ]]; then # 特例:rancher 开头,直接拼接 printf '%s/%s\n' "${HARBOR_HOST}" "${pure_image}" elif [[ "${pure_image}" == */* ]]; then # 包含路径但不是 rancher (如 bitnamilegacy/nginx 或 ossrs/srs) # 提取斜杠后的部分 (镜像名:标签),前缀换成 cmii image_name_tag="${pure_image#*/}" printf '%s/cmii/%s\n' "${HARBOR_HOST}" "${image_name_tag}" else # 完全不带路径 (如 nginx:latest) # 强制变成 cmii/nginx:latest printf '%s/cmii/%s\n' "${HARBOR_HOST}" "${pure_image}" fi } ### 登录 Harbor ### @param none none 无 ### @return 0 成功 ### @require docker ### @side_effect 向 Docker 写入登录状态 harbor_login() { log_info "开始登录 Harbor: ${HARBOR_HOST}" # > 使用 --password-stdin 避免密码出现在进程参数列表中 if [[ "${DRY_RUN}" == "true" ]]; then log_info "[dry-run] printf '***' | docker login --username '${HARBOR_USERNAME}' --password-stdin '${HARBOR_HOST}'" return 0 fi printf '%s' "${HARBOR_PASSWORD}" | docker login --username "${HARBOR_USERNAME}" --password-stdin "${HARBOR_HOST}" log_info "Harbor 登录成功: ${HARBOR_HOST}" } ### 推送单个镜像到 Harbor ### @param image string 原始镜像名 ### @return 0 成功 ### @require docker ### @side_effect 向 Harbor 上传镜像 push_one_image() { local source_image="${1}" local target_image="" target_image="$(build_target_image_ref "${source_image}")" log_info "开始推送镜像: ${source_image} -> ${target_image}" tag_image_if_needed "${source_image}" "${target_image}" run_cmd docker push "${target_image}" PUSH_SUCCEEDED+=("${target_image}") } ### 批量执行推送 ### @param none none 无 ### @return 0 全部成功 / 非 0 存在失败 ### @require Harbor 已登录 ### @side_effect 向 Harbor 上传多个镜像 perform_pushes() { local image_ref="" PUSH_FAILED=() PUSH_SUCCEEDED=() for image_ref in "${IMAGE_LIST[@]}"; do if ! push_one_image "${image_ref}"; then PUSH_FAILED+=("${image_ref}") log_error "上传失败: ${image_ref}" fi done [[ "${#PUSH_FAILED[@]}" -eq 0 ]] } # ============================================================================== # 汇总输出 # ============================================================================== ### 打印流程总结 ### @param none none 无 ### @return 0 打印完成 ### @require 无 ### @side_effect 向 stderr 输出汇总 print_summary() { log_info "================ 执行总结 ================" if [[ "${#DOWNLOAD_FAILED[@]}" -gt 0 ]]; then log_warn "下载失败镜像:" printf ' - %s\n' "${DOWNLOAD_FAILED[@]}" >&2 fi if [[ "${#DOWNLOAD_USED_LOCAL[@]}" -gt 0 ]]; then log_warn "下载失败但已复用本地镜像:" printf ' - %s\n' "${DOWNLOAD_USED_LOCAL[@]}" >&2 fi if [[ "${#VERIFY_MISSING[@]}" -gt 0 ]]; then log_warn "校验缺失镜像:" printf ' - %s\n' "${VERIFY_MISSING[@]}" >&2 fi if [[ "${#VERIFY_ARCH_MISMATCH[@]}" -gt 0 ]]; then log_warn "校验架构不匹配镜像:" printf ' - %s\n' "${VERIFY_ARCH_MISMATCH[@]}" >&2 fi if [[ "${#COMPRESS_FAILED[@]}" -gt 0 ]]; then log_warn "压缩失败镜像:" printf ' - %s\n' "${COMPRESS_FAILED[@]}" >&2 fi if [[ "${#PUSH_SUCCEEDED[@]}" -gt 0 ]]; then log_info "上传成功镜像数量: ${#PUSH_SUCCEEDED[@]}" fi if [[ "${#PUSH_FAILED[@]}" -gt 0 ]]; then log_warn "上传失败镜像:" printf ' - %s\n' "${PUSH_FAILED[@]}" >&2 fi log_info "========================================" } # ============================================================================== # main 与入口区 # ============================================================================== ### 主流程编排 ### @param ... string 命令行参数 ### @return 0 成功 ### @require 各业务函数 ### @side_effect 根据动作执行下载/压缩/上传 main() { parse_args "$@" init_runtime validate_args check_dependencies load_image_list "${IMAGE_FILE}" case "${ACTION}" in download) perform_downloads || die "下载阶段存在失败,请查看上方总结" "${EXIT_RUNTIME}" ;; compress) verify_images_present || die "压缩前校验失败;可使用 --force 跳过" "${EXIT_VALIDATION}" perform_compress || die "压缩阶段存在失败" "${EXIT_RUNTIME}" ;; push) verify_images_present || die "上传前校验失败;可使用 --force 跳过" "${EXIT_VALIDATION}" harbor_login perform_pushes || die "上传阶段存在失败" "${EXIT_RUNTIME}" ;; all) perform_downloads || die "下载阶段存在失败,请先修复后重试" "${EXIT_RUNTIME}" verify_images_present || die "下载后校验失败;可使用 --force 跳过" "${EXIT_VALIDATION}" perform_compress || die "压缩阶段存在失败" "${EXIT_RUNTIME}" harbor_login perform_pushes || die "上传阶段存在失败" "${EXIT_RUNTIME}" ;; *) die "未实现的动作: ${ACTION}" "${EXIT_USAGE}" ;; esac print_summary log_info "任务完成" } main "$@"