#!/usr/bin/env bash # ============================================================================== # Metadata # ============================================================================== # Author : ChatGPT # Version : 1.1.0 # License : MIT # Last Update : 2026-03-02 # Bash : >= 5.0 # Description : 生产级 Docker 在线安装脚本(CentOS 7 / CentOS 8 / openEuler 20.03 / 22.03) # # Assumptions # 1) 默认使用 YUM 作为包管理工具(按你的要求),即使系统存在 dnf,也优先走 yum。 # 2) openEuler 使用 openEuler Everything 临时仓补齐依赖;若仍不足,再回退到 CentOS 8 Vault 兼容仓。 # 3) Docker 仓库按 el7 / el8 路径适配;openEuler 20.03 / 22.03 映射为 el8。 # 4) GPG 支持三种模式: # - strict(默认):启用 gpgcheck,并优先使用镜像站 gpgkey(或官方 gpgkey) # - mirror:显式使用镜像站 gpgkey # - skip:跳过 repo 包签名校验(gpgcheck=0),适合受限网络或 key 拉取失败场景 # 5) 为安全起见,默认不覆盖已有 daemon.json;如需覆盖,请加 --force。 # # ============================================================================== # Call Graph (ASCII) # ============================================================================== # main # | # +--> init_runtime # | +--> init_traps # | +--> detect_os # | +--> validate_arch # | # +--> parse_args # +--> validate_args # +--> check_requirements # +--> prepare_proxy_env # +--> cleanup_conflicting_packages # +--> configure_docker_repo # +--> handle_gpg_key # +--> install_prerequisites # | +--> add_openeuler_everything_repo # | +--> add_el8_compat_repo # | # +--> resolve_docker_version # +--> install_docker_packages # +--> configure_docker_daemon # +--> enable_and_start_docker # +--> verify_installation # +--> print_summary # # ============================================================================== # Strict Mode / Safety # ============================================================================== set -Eeuo pipefail set -o errtrace IFS=$' \t\n' # ============================================================================== # Constants # ============================================================================== readonly SCRIPT_NAME="$(basename "$0")" readonly SCRIPT_VERSION="1.1.0" readonly EC_OK=0 readonly EC_GENERAL=1 readonly EC_USAGE=2 readonly EC_UNSUPPORTED_OS=10 readonly EC_REQUIREMENT=11 readonly EC_PERMISSION=12 readonly EC_NETWORK=20 readonly EC_REPO=21 readonly EC_INSTALL=22 readonly EC_SERVICE=23 readonly EC_VERIFY=24 readonly LOG_DEBUG=10 readonly LOG_INFO=20 readonly LOG_WARN=30 readonly LOG_ERROR=40 readonly DEFAULT_LOG_LEVEL="INFO" readonly DEFAULT_MIRROR_PROVIDER="aliyun" readonly DEFAULT_DOCKER_VERSION="20.10" readonly DEFAULT_GPG_MODE="strict" readonly TMP_ROOT_PREFIX="/tmp/docker-online-install" readonly DOCKER_REPO_FILE="/etc/yum.repos.d/docker-ce.repo" readonly DOCKER_DAEMON_CFG="/etc/docker/daemon.json" readonly OE_PREREQ_REPO_FILE="/etc/yum.repos.d/openeuler-prereq-tmp.repo" readonly EL8_COMPAT_REPO_FILE="/etc/yum.repos.d/el8-prereq-compat-tmp.repo" readonly MIRROR_URL_ALIYUN="https://mirrors.aliyun.com/docker-ce/linux/centos" readonly MIRROR_URL_TENCENT="https://mirrors.tencent.com/docker-ce/linux/centos" readonly MIRROR_URL_HUAWEI="https://repo.huaweicloud.com/docker-ce/linux/centos" readonly MIRROR_URL_OFFICIAL="https://download.docker.com/linux/centos" readonly OFFICIAL_DOCKER_GPG_KEY_URL="https://download.docker.com/linux/centos/gpg" readonly OE_REPO_BASE="https://repo.openeuler.org" # ============================================================================== # Globals # ============================================================================== LOG_LEVEL="${LOG_LEVEL:-$DEFAULT_LOG_LEVEL}" DRY_RUN=0 FORCE=0 QUIET=0 CONFIRM=0 MIRROR_PROVIDER="$DEFAULT_MIRROR_PROVIDER" DOCKER_VERSION="${DOCKER_VERSION:-$DEFAULT_DOCKER_VERSION}" GPG_MODE="${GPG_MODE:-$DEFAULT_GPG_MODE}" PROXY_URL="${PROXY_URL:-}" SKIP_START=0 SKIP_ENABLE=0 CONFIGURE_DAEMON=1 REMOVE_CONFLICTS=1 OS_ID_RAW="" OS_ID="" OS_VERSION_ID="" OS_EL_VER="" OS_PRETTY_NAME="" OS_ARCH="" PKG_MGR="yum" MIRROR_BASE_URL="" DOCKER_GPG_KEY_URL="" TMP_DIR="" INSTALL_FLAGS=() PKG_GLOBAL_OPTS=() PKG_MGR_STABLE_OPTS=() # Bash 数组用于安全传参,避免 eval。 PREREQ_PKGS=( "container-selinux" "fuse-overlayfs" "slirp4netns" ) # ============================================================================== # Logging # ============================================================================== ### 将日志级别转换为数值 ### @param level string 日志级别 ### @return 0 输出对应数值 ### @require none log_level_to_num() { case "${1:-INFO}" in DEBUG) printf '%s\n' "$LOG_DEBUG" ;; INFO) printf '%s\n' "$LOG_INFO" ;; WARN) printf '%s\n' "$LOG_WARN" ;; ERROR) printf '%s\n' "$LOG_ERROR" ;; *) printf '%s\n' "$LOG_INFO" ;; esac } ### 统一日志输出 ### @param level string 日志级别 ### @param message string 消息 ### @return 0 成功 ### @require date ### @side_effect 向 stderr 输出 log_msg() { local level="${1:?level required}" shift local message="${*:-}" local current_num target_num timestamp current_num="$(log_level_to_num "$LOG_LEVEL")" target_num="$(log_level_to_num "$level")" if [[ "$target_num" -lt "$current_num" ]]; then return 0 fi if [[ "$QUIET" -eq 1 && "$level" != "ERROR" ]]; then return 0 fi timestamp="$(date '+%F %T')" printf '%s [%s] %s: %s\n' "$timestamp" "$level" "$SCRIPT_NAME" "$message" >&2 } ### DEBUG 日志 ### @param message string 日志消息 ### @return 0 成功 ### @require none log_debug() { log_msg "DEBUG" "$@"; } ### INFO 日志 ### @param message string 日志消息 ### @return 0 成功 ### @require none log_info() { log_msg "INFO" "$@"; } ### WARN 日志 ### @param message string 日志消息 ### @return 0 成功 ### @require none log_warn() { log_msg "WARN" "$@"; } ### ERROR 日志 ### @param message string 日志消息 ### @return 0 成功 ### @require none log_error() { log_msg "ERROR" "$@"; } # ============================================================================== # Error / Trap / Cleanup # ============================================================================== ### 统一失败退出 ### @param message string 错误消息 ### @param exit_code int 退出码 ### @return 失败退出 ### @require none die() { local message="${1:-unknown error}" local exit_code="${2:-$EC_GENERAL}" log_error "$message" exit "$exit_code" } ### 输出函数调用栈 ### @param none ### @return 0 成功 ### @require none print_call_stack() { local i for ((i=1; i<${#FUNCNAME[@]}; i++)); do printf ' at %s() %s:%s\n' \ "${FUNCNAME[$i]}" \ "${BASH_SOURCE[$i]:-unknown}" \ "${BASH_LINENO[$((i-1))]:-unknown}" >&2 done } ### ERR trap 处理器 ### @param none ### @return 0 由 trap 调用 ### @require none on_err() { local exit_code="$?" local cmd="${BASH_COMMAND:-unknown}" local line_no="${BASH_LINENO[0]:-unknown}" local src="${BASH_SOURCE[1]:-${BASH_SOURCE[0]:-unknown}}" log_error "命令执行失败:${cmd}" log_error "失败位置:${src}:${line_no}" log_error "退出码:${exit_code}" print_call_stack exit "$exit_code" } ### 信号处理器 ### @param signal string 信号名 ### @return 130 中断退出 ### @require none on_signal() { local signal_name="${1:-SIGNAL}" log_warn "收到信号:${signal_name},开始退出。" exit 130 } ### EXIT trap 处理器 ### @param none ### @return 0 成功 ### @require none ### @side_effect 清理临时目录 on_exit() { local exit_code="$?" cleanup || true if [[ "$exit_code" -eq 0 ]]; then log_info "脚本执行完成。" else log_warn "脚本退出,状态码:${exit_code}" fi } ### 清理临时文件 ### @param none ### @return 0 成功 ### @require rm ### @side_effect 删除临时目录、临时 repo cleanup() { # if [[ -f "$OE_PREREQ_REPO_FILE" ]]; then # rm -f -- "$OE_PREREQ_REPO_FILE" # fi # if [[ -f "$EL8_COMPAT_REPO_FILE" ]]; then # rm -f -- "$EL8_COMPAT_REPO_FILE" # fi if [[ -n "${TMP_DIR:-}" && -d "${TMP_DIR:-}" ]]; then rm -rf -- "$TMP_DIR" fi return 0 } ### 初始化 trap ### @param none ### @return 0 成功 ### @require none init_traps() { trap 'on_err' ERR trap 'on_signal INT' INT trap 'on_signal TERM' TERM trap 'on_exit' EXIT } # ============================================================================== # Helpers # ============================================================================== ### 输出帮助信息 ### @param none ### @return 0 成功 ### @require cat usage() { cat < DEBUG|INFO|WARN|ERROR --mirror aliyun|tencent|huawei|official(默认:aliyun) --proxy 代理地址,例如:http://192.168.1.10:3128 --docker-version Docker 版本前缀(默认:20.10) --gpg-mode strict|mirror|skip(默认:strict) --skip-start 安装后不启动 Docker --skip-enable 安装后不设置开机自启 --skip-daemon-config 不写入 /etc/docker/daemon.json --keep-conflicts 不移除旧 Docker 冲突包 --yes 非交互确认 说明: 1) 支持:CentOS 7 / CentOS 8 / openEuler 20.03 / openEuler 22.03 2) openEuler 会优先启用临时 Everything 仓补齐依赖,再回退 CentOS 8 Vault 仓 3) 包管理工具固定使用 yum 4) --gpg-mode skip 可跳过 repo 包签名校验(适合 GPG key 获取困难场景) 示例: sudo bash ${SCRIPT_NAME} --mirror aliyun sudo bash ${SCRIPT_NAME} --mirror tencent --proxy http://192.168.1.10:3128 sudo bash ${SCRIPT_NAME} --gpg-mode skip --docker-version 24.0 sudo bash ${SCRIPT_NAME} --dry-run --mirror huawei EOF } ### 安全执行外部命令(支持 dry-run,并输出真实失败命令) ### @param ... command 命令及参数 ### @return 0 成功;非 0 失败 ### @require none run_cmd() { local -a cmd=( "$@" ) local rc=0 [[ "${#cmd[@]}" -gt 0 ]] || die "run_cmd 收到空命令。" "$EC_GENERAL" log_debug "执行命令:${cmd[*]}" if [[ "$DRY_RUN" -eq 1 ]]; then printf '[DRY-RUN] %s\n' "${cmd[*]}" >&2 return 0 fi # > 在函数内接管错误输出,避免 ERR trap 只能拿到 "${cmd[@]}" 这种包装表达式 set +e "${cmd[@]}" rc=$? set -e if [[ "$rc" -ne 0 ]]; then log_error "外部命令执行失败:${cmd[*]}" log_error "命令退出码:${rc}" return "$rc" fi return 0 } ### 以 root 权限执行命令 ### @param ... command 命令及参数 ### @return 0 成功;非 0 失败 ### @require sudo(非 root 时) run_as_root() { local -a cmd=( "$@" ) if [[ "$(id -u)" -eq 0 ]]; then run_cmd "${cmd[@]}" else command -v sudo >/dev/null 2>&1 || die "当前非 root,且缺少 sudo。" "$EC_PERMISSION" run_cmd sudo "${cmd[@]}" fi } ### 校验命令是否存在 ### @param cmd string 命令名 ### @return 0 存在;非 0 不存在 ### @require none require_cmd() { local cmd="${1:?command required}" command -v "$cmd" >/dev/null 2>&1 } ### 安全写文件(原子替换) ### @param target string 目标路径 ### @param content string 内容 ### @return 0 成功;非 0 失败 ### @require mktemp install dirname ### @side_effect 写入文件 write_file_atomic() { local target="${1:?target required}" local content="${2:-}" local tmp_file="" tmp_file="$(mktemp "${TMP_DIR}/write.XXXXXX")" printf '%s' "$content" > "$tmp_file" run_as_root mkdir -p "$(dirname "$target")" run_as_root install -m 0644 "$tmp_file" "$target" rm -f -- "$tmp_file" } ### 读取确认输入 ### @param prompt string 提示信息 ### @return 0 同意;1 拒绝 ### @require read confirm_action() { local prompt="${1:-确认继续?}" local answer="" if [[ "$CONFIRM" -eq 1 ]]; then return 0 fi printf '%s [y/N]: ' "$prompt" >&2 read -r answer case "$answer" in y|Y|yes|YES) return 0 ;; *) return 1 ;; esac } # ============================================================================== # Arg Parsing # ============================================================================== ### 解析命令行参数 ### @param ... args 参数列表 ### @return 0 成功;非 0 失败 ### @require none parse_args() { while [[ "$#" -gt 0 ]]; do case "$1" in -h|--help) usage exit 0 ;; --dry-run) DRY_RUN=1 ;; --force) FORCE=1 ;; --verbose) LOG_LEVEL="DEBUG" ;; --quiet) QUIET=1 LOG_LEVEL="ERROR" ;; --log-level) shift [[ "$#" -gt 0 ]] || die "--log-level 缺少参数。" "$EC_USAGE" LOG_LEVEL="$1" ;; --mirror) shift [[ "$#" -gt 0 ]] || die "--mirror 缺少参数。" "$EC_USAGE" MIRROR_PROVIDER="$1" ;; --proxy) shift [[ "$#" -gt 0 ]] || die "--proxy 缺少参数。" "$EC_USAGE" PROXY_URL="$1" ;; --docker-version) shift [[ "$#" -gt 0 ]] || die "--docker-version 缺少参数。" "$EC_USAGE" DOCKER_VERSION="$1" ;; --gpg-mode) shift [[ "$#" -gt 0 ]] || die "--gpg-mode 缺少参数。" "$EC_USAGE" GPG_MODE="$1" ;; --skip-start) SKIP_START=1 ;; --skip-enable) SKIP_ENABLE=1 ;; --skip-daemon-config) CONFIGURE_DAEMON=0 ;; --keep-conflicts) REMOVE_CONFLICTS=0 ;; --yes) CONFIRM=1 ;; -*) die "未知参数:$1" "$EC_USAGE" ;; *) die "不支持的位置参数:$1" "$EC_USAGE" ;; esac shift done } ### 校验参数合法性 ### @param none ### @return 0 成功;非 0 失败 ### @require none validate_args() { case "$LOG_LEVEL" in DEBUG|INFO|WARN|ERROR) ;; *) die "无效的 --log-level:${LOG_LEVEL}" "$EC_USAGE" ;; esac case "$MIRROR_PROVIDER" in aliyun|tencent|huawei|official) ;; *) die "无效的 --mirror:${MIRROR_PROVIDER}" "$EC_USAGE" ;; esac case "$GPG_MODE" in strict|mirror|skip) ;; *) die "无效的 --gpg-mode:${GPG_MODE}" "$EC_USAGE" ;; esac if [[ -n "$PROXY_URL" && ! "$PROXY_URL" =~ ^https?://[^[:space:]]+$ ]]; then die "无效的 --proxy:${PROXY_URL}" "$EC_USAGE" fi } # ============================================================================== # OS / Environment # ============================================================================== ### 检测操作系统 ### @param none ### @return 0 成功;非 0 失败 ### @require /etc/os-release detect_os() { [[ -r "/etc/os-release" ]] || die "缺少 /etc/os-release。" "$EC_UNSUPPORTED_OS" # shellcheck disable=SC1091 . /etc/os-release OS_ID_RAW="${ID:-}" OS_ID="$(printf '%s' "$OS_ID_RAW" | tr '[:upper:]' '[:lower:]')" OS_VERSION_ID="${VERSION_ID:-}" OS_PRETTY_NAME="${PRETTY_NAME:-${NAME:-unknown}}" OS_ARCH="$(uname -m)" log_info "系统识别:ID=${OS_ID_RAW}(规范化=${OS_ID}), VERSION_ID=${OS_VERSION_ID}, ARCH=${OS_ARCH}" case "$OS_ID" in centos) case "${OS_VERSION_ID%%.*}" in 7) OS_EL_VER="7" ;; 8) OS_EL_VER="8" ;; *) die "不支持的 CentOS 版本:${OS_VERSION_ID}" "$EC_UNSUPPORTED_OS" ;; esac ;; openeuler) # > 修复 openEuler 大小写问题:ID="openEuler" 会先转小写再判断 case "${OS_VERSION_ID%%.*}" in 20|22) OS_EL_VER="8" ;; *) die "不支持的 openEuler 版本:${OS_VERSION_ID}" "$EC_UNSUPPORTED_OS" ;; esac log_info "openEuler 已映射到 Docker el8 仓路径。" ;; *) die "不支持的系统:${OS_PRETTY_NAME}" "$EC_UNSUPPORTED_OS" ;; esac } ### 校验架构 ### @param none ### @return 0 成功;非 0 失败 ### @require none validate_arch() { case "$OS_ARCH" in x86_64|aarch64) ;; *) die "不支持的架构:${OS_ARCH}" "$EC_UNSUPPORTED_OS" ;; esac } ### 初始化运行时 ### @param none ### @return 0 成功 ### @require mktemp init_runtime() { init_traps TMP_DIR="$(mktemp -d "${TMP_ROOT_PREFIX}.XXXXXX")" detect_os validate_arch } # ============================================================================== # Requirements / Proxy # ============================================================================== ### 检查依赖 ### @param none ### @return 0 成功;非 0 失败 ### @require 基础命令 check_requirements() { local required_cmds=( curl sed awk grep tee uname rpm systemctl yum ) local cmd="" for cmd in "${required_cmds[@]}"; do require_cmd "$cmd" || die "缺少依赖命令:${cmd}" "$EC_REQUIREMENT" done if [[ "$(id -u)" -ne 0 ]] && ! require_cmd sudo; then die "当前非 root,且缺少 sudo。" "$EC_PERMISSION" fi } ### 准备代理环境 ### @param none ### @return 0 成功 ### @require none prepare_proxy_env() { PKG_GLOBAL_OPTS=() PKG_MGR_STABLE_OPTS=( "--setopt=timeout=60" "--setopt=retries=10" "--setopt=minrate=1k" ) if [[ -n "$PROXY_URL" ]]; then export http_proxy="$PROXY_URL" export https_proxy="$PROXY_URL" export HTTP_PROXY="$PROXY_URL" export HTTPS_PROXY="$PROXY_URL" export no_proxy="${no_proxy:-localhost,127.0.0.1}" export NO_PROXY="${NO_PROXY:-localhost,127.0.0.1}" PKG_GLOBAL_OPTS+=( "--setopt=proxy=${PROXY_URL}" ) log_info "已启用代理:${PROXY_URL}" else log_info "未启用代理。" fi } ### 获取镜像基地址 ### @param provider string 镜像提供商 ### @return 0 成功输出 URL;非 0 失败 ### @require none get_mirror_base_url() { local provider="${1:?provider required}" case "$provider" in aliyun) printf '%s\n' "$MIRROR_URL_ALIYUN" ;; tencent) printf '%s\n' "$MIRROR_URL_TENCENT" ;; huawei) printf '%s\n' "$MIRROR_URL_HUAWEI" ;; official) printf '%s\n' "$MIRROR_URL_OFFICIAL" ;; *) return 1 ;; esac } ### 获取 gpgkey URL ### @param provider string 镜像提供商 ### @return 0 成功输出 URL;非 0 失败 ### @require none get_gpg_key_url() { local provider="${1:?provider required}" case "$provider" in aliyun) printf '%s\n' "${MIRROR_URL_ALIYUN}/gpg" ;; tencent) printf '%s\n' "${MIRROR_URL_TENCENT}/gpg" ;; huawei) printf '%s\n' "${MIRROR_URL_HUAWEI}/gpg" ;; official) printf '%s\n' "${OFFICIAL_DOCKER_GPG_KEY_URL}" ;; *) return 1 ;; esac } # ============================================================================== # YUM Wrappers # ============================================================================== ### 执行 yum 命令 ### @param ... args yum 参数 ### @return 0 成功;非 0 失败 ### @require yum pkg_mgr() { local -a cmd=( "$PKG_MGR" "${PKG_GLOBAL_OPTS[@]}" "$@" ) run_as_root "${cmd[@]}" } ### 安装软件包 ### @param ... pkgs 包名列表 ### @return 0 成功;非 0 失败 ### @require yum install_pkgs() { local -a packages=( "$@" ) [[ "${#packages[@]}" -gt 0 ]] || die "install_pkgs 收到空包列表。" "$EC_GENERAL" pkg_mgr install -y "${PKG_MGR_STABLE_OPTS[@]}" "${packages[@]}" } ### 卸载软件包 ### @param ... pkgs 包名列表 ### @return 0 成功;非 0 失败 ### @require yum remove_pkgs() { local -a packages=( "$@" ) [[ "${#packages[@]}" -gt 0 ]] || die "remove_pkgs 收到空包列表。" "$EC_GENERAL" pkg_mgr remove -y "${packages[@]}" } ### 刷新缓存(Docker 镜像失败时自动回退官方源) ### @param none ### @return 0 成功;非 0 失败 ### @require yum make_cache() { log_info "刷新系统软件仓缓存..." if ! pkg_mgr makecache -y "${PKG_MGR_STABLE_OPTS[@]}"; then log_warn "全量 makecache 失败,尝试仅刷新 Docker 仓..." fi log_info "刷新 docker-ce-stable 元数据..." if pkg_mgr makecache -y "${PKG_MGR_STABLE_OPTS[@]}" --disablerepo='*' --enablerepo='docker-ce-stable'; then log_info "docker-ce-stable 元数据刷新成功。" return 0 fi log_warn "当前 Docker 镜像源不可用:${MIRROR_PROVIDER} (${MIRROR_BASE_URL})" # > 国内镜像失败时,自动回退到官方源,避免直接中断 if [[ "$MIRROR_PROVIDER" != "official" ]]; then log_warn "开始自动回退到 Docker 官方源..." MIRROR_PROVIDER="official" configure_docker_repo handle_gpg_key if pkg_mgr makecache -y "${PKG_MGR_STABLE_OPTS[@]}" --disablerepo='*' --enablerepo='docker-ce-stable'; then log_info "已成功回退到 Docker 官方源。" return 0 fi fi die "docker-ce-stable 元数据刷新失败:国内镜像与官方源均不可用,请检查网络/代理/仓库地址。" "$EC_REPO" } # ============================================================================== # Repo / GPG # ============================================================================== ### 移除旧冲突包 ### @param none ### @return 0 成功;非 0 失败 ### @require yum cleanup_conflicting_packages() { local -a conflicts=( docker docker-common docker-client docker-client-latest docker-latest docker-latest-logrotate docker-logrotate docker-engine docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin ) if [[ "$REMOVE_CONFLICTS" -ne 1 ]]; then log_info "已按参数要求跳过冲突包清理。" return 0 fi remove_pkgs "${conflicts[@]}" || log_warn "冲突包清理未完全成功,继续尝试。" } ### 配置 Docker repo ### @param none ### @return 0 成功;非 0 失败 ### @require none ### @side_effect 写入 /etc/yum.repos.d/docker-ce.repo configure_docker_repo() { local gpgcheck_value="1" local repo_content="" MIRROR_BASE_URL="$(get_mirror_base_url "$MIRROR_PROVIDER")" \ || die "获取镜像 URL 失败。" "$EC_REPO" if [[ "$GPG_MODE" == "skip" ]]; then gpgcheck_value="0" DOCKER_GPG_KEY_URL="" else if [[ "$GPG_MODE" == "mirror" ]]; then DOCKER_GPG_KEY_URL="$(get_gpg_key_url "$MIRROR_PROVIDER")" || die "获取镜像 GPG Key URL 失败。" "$EC_REPO" else # strict 默认也优先用镜像站 key,失败时导入阶段再回退官方 DOCKER_GPG_KEY_URL="$(get_gpg_key_url "$MIRROR_PROVIDER")" || die "获取 GPG Key URL 失败。" "$EC_REPO" fi fi if [[ -f "$DOCKER_REPO_FILE" && "$FORCE" -ne 1 ]]; then log_warn "检测到已有 ${DOCKER_REPO_FILE},未使用 --force,跳过覆盖。" return 0 fi if [[ -f "$DOCKER_REPO_FILE" && "$FORCE" -eq 1 ]]; then confirm_action "检测到已有 ${DOCKER_REPO_FILE},是否覆盖?" || die "用户取消覆盖 repo 文件。" "$EC_USAGE" fi repo_content="[docker-ce-stable] name=Docker CE Stable - \$basearch baseurl=${MIRROR_BASE_URL}/${OS_EL_VER}/\$basearch/stable enabled=1 gpgcheck=${gpgcheck_value} " if [[ "$gpgcheck_value" == "1" ]]; then repo_content="${repo_content}gpgkey=${DOCKER_GPG_KEY_URL} " fi repo_content="${repo_content}retries=10 timeout=60 minrate=1k skip_if_unavailable=0 [docker-ce-test] name=Docker CE Test - \$basearch baseurl=${MIRROR_BASE_URL}/${OS_EL_VER}/\$basearch/test enabled=0 gpgcheck=${gpgcheck_value} " if [[ "$gpgcheck_value" == "1" ]]; then repo_content="${repo_content}gpgkey=${DOCKER_GPG_KEY_URL} " fi write_file_atomic "$DOCKER_REPO_FILE" "$repo_content" log_info "Docker repo 已写入:${DOCKER_REPO_FILE}" } ### 处理 GPG key 导入 ### @param none ### @return 0 成功;非 0 失败 ### @require curl rpm handle_gpg_key() { local gpg_tmp="" local try_url="" if [[ "$GPG_MODE" == "skip" ]]; then log_warn "已启用 --gpg-mode skip:跳过 GPG key 导入与 repo 包签名校验。" return 0 fi if [[ "$DRY_RUN" -eq 1 ]]; then log_info "DRY-RUN:跳过实际导入 GPG key。" return 0 fi gpg_tmp="$(mktemp "${TMP_DIR}/docker-gpg.XXXXXX")" # > 优先使用镜像站 gpgkey;strict 模式下若失败,再回退官方 key if curl -fsSL --connect-timeout 15 --retry 3 "$DOCKER_GPG_KEY_URL" -o "$gpg_tmp"; then try_url="$DOCKER_GPG_KEY_URL" elif [[ "$GPG_MODE" == "strict" ]] && curl -fsSL --connect-timeout 15 --retry 3 "$OFFICIAL_DOCKER_GPG_KEY_URL" -o "$gpg_tmp"; then try_url="$OFFICIAL_DOCKER_GPG_KEY_URL" log_warn "镜像站 GPG key 获取失败,已回退官方 key。" else rm -f -- "$gpg_tmp" die "GPG key 下载失败;可改用 --gpg-mode skip 跳过校验。" "$EC_REPO" fi rpm --import "$gpg_tmp" || { rm -f -- "$gpg_tmp" die "导入 GPG key 失败(来源:${try_url})。可使用 --gpg-mode skip。" "$EC_REPO" } rm -f -- "$gpg_tmp" log_info "GPG key 导入成功:${try_url}" } # ============================================================================== # Prerequisites # ============================================================================== ### 添加 openEuler 临时仓(优先 OS,其次 Everything) ### @param none ### @return 0 成功;非 0 失败 ### @require none ### @side_effect 写入临时 repo 文件 add_openeuler_everything_repo() { local os_baseurl="" local everything_baseurl="" [[ "$OS_ID" == "openeuler" ]] || return 1 [[ ! -f "$OE_PREREQ_REPO_FILE" ]] || return 0 case "${OS_VERSION_ID%%.*}" in 20) os_baseurl="${OE_REPO_BASE}/openEuler-20.03-LTS-SP3/OS/\$basearch/" everything_baseurl="${OE_REPO_BASE}/openEuler-20.03-LTS-SP3/everything/\$basearch/" ;; 22) os_baseurl="${OE_REPO_BASE}/openEuler-22.03-LTS-SP3/OS/\$basearch/" everything_baseurl="${OE_REPO_BASE}/openEuler-22.03-LTS-SP3/everything/\$basearch/" ;; *) return 1 ;; esac write_file_atomic "$OE_PREREQ_REPO_FILE" "[openeuler-os-tmp] name=openEuler OS (temporary) baseurl=${os_baseurl} enabled=0 gpgcheck=0 skip_if_unavailable=1 [openeuler-everything-tmp] name=openEuler Everything (temporary) baseurl=${everything_baseurl} enabled=0 gpgcheck=0 skip_if_unavailable=1 " log_info "已添加 openEuler 临时仓:${OE_PREREQ_REPO_FILE}(优先 OS,其次 Everything)" } ### 添加 EL8 兼容仓(仅用于 openEuler 依赖兜底,优先国内镜像,默认禁用) ### @param none ### @return 0 成功;非 0 失败 ### @require none ### @side_effect 写入临时 repo 文件 add_el8_compat_repo() { local base_root="" # > 该兼容仓是给 openEuler 补 Docker 依赖的兜底方案,不用于普通 CentOS 主流程 [[ "$OS_ID" == "openeuler" ]] || return 1 [[ "$OS_EL_VER" == "8" ]] || return 1 [[ ! -f "$EL8_COMPAT_REPO_FILE" ]] || return 0 case "$MIRROR_PROVIDER" in aliyun) base_root="https://mirrors.aliyun.com/centos-vault/centos/8.5.2111" ;; tencent) base_root="https://mirrors.tencent.com/centos-vault/8.5.2111" ;; huawei) base_root="https://repo.huaweicloud.com/centos-vault/8.5.2111" ;; official|*) base_root="https://vault.centos.org/8.5.2111" ;; esac write_file_atomic "$EL8_COMPAT_REPO_FILE" "[el8-compat-baseos-tmp] name=EL8 Compatibility BaseOS (temporary for openEuler) baseurl=${base_root}/BaseOS/\$basearch/os/ enabled=0 gpgcheck=0 skip_if_unavailable=1 [el8-compat-appstream-tmp] name=EL8 Compatibility AppStream (temporary for openEuler) baseurl=${base_root}/AppStream/\$basearch/os/ enabled=0 gpgcheck=0 skip_if_unavailable=1 [el8-compat-powertools-tmp] name=EL8 Compatibility PowerTools (temporary for openEuler) baseurl=${base_root}/PowerTools/\$basearch/os/ enabled=0 gpgcheck=0 skip_if_unavailable=1 [el8-compat-extras-tmp] name=EL8 Compatibility Extras (temporary for openEuler) baseurl=${base_root}/extras/\$basearch/os/ enabled=0 gpgcheck=0 skip_if_unavailable=1 " log_info "已添加 openEuler 专用 EL8 兼容临时仓:${EL8_COMPAT_REPO_FILE}(${base_root})" } ### 尝试安装单个依赖包(container-selinux 安装后再校验版本约束) ### @param pkg string 包名 ### @param ... extra_flags string 额外 yum 参数 ### @return 0 成功;1 失败 ### @require yum rpm try_install_pkg() { local pkg="${1:?pkg required}" shift local -a extra_flags=( "$@" ) if ! pkg_mgr install -y "${PKG_MGR_STABLE_OPTS[@]}" "${extra_flags[@]}" "$pkg" >/dev/null 2>&1; then return 1 fi if [[ "$pkg" == "container-selinux" ]]; then is_container_selinux_satisfied return $? fi rpm -q "$pkg" >/dev/null 2>&1 } ### 判断 container-selinux 是否满足 Docker 所需依赖(直接使用 RPM 依赖语义) ### @param none ### @return 0 满足;1 不满足 ### @require rpm is_container_selinux_satisfied() { # > 不再手写 epoch/version 比较,直接让 rpm 按依赖表达式判断 if rpm -q --whatprovides 'container-selinux >= 2:2.74' >/dev/null 2>&1; then return 0 fi return 1 } ### 安装前置依赖(openEuler 优先 OS 仓安装 container-selinux) ### @param none ### @return 0 成功;非 0 失败 ### @require yum rpm install_prerequisites() { local -a missing=() local -a still_missing=() local -a unresolved=() local pkg="" log_info "检查前置依赖:${PREREQ_PKGS[*]}" for pkg in "${PREREQ_PKGS[@]}"; do pkg="$(printf '%s' "$pkg" | tr -d '\r' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')" if [[ "$pkg" == "container-selinux" ]]; then if is_container_selinux_satisfied; then log_info "依赖已满足:container-selinux >= 2:2.74" else missing+=( "container-selinux" ) log_warn "container-selinux 未安装或版本不足(需要 >= 2:2.74)" fi continue fi if rpm -q "$pkg" >/dev/null 2>&1; then log_info "依赖已存在:${pkg}" else missing+=( "$pkg" ) log_warn "缺少依赖:${pkg}" fi done if [[ "${#missing[@]}" -eq 0 ]]; then log_info "前置依赖已满足。" return 0 fi # 1) 当前系统仓 for pkg in "${missing[@]}"; do if try_install_pkg "$pkg"; then log_info "${pkg}:已从当前系统仓安装/升级。" else still_missing+=( "$pkg" ) fi done if [[ "${#still_missing[@]}" -eq 0 ]]; then return 0 fi # 2) openEuler:优先临时仓 if [[ "$OS_ID" == "openeuler" ]]; then add_openeuler_everything_repo || log_warn "添加 openEuler 临时仓失败。" unresolved=() for pkg in "${still_missing[@]}"; do if [[ "$pkg" == "container-selinux" ]]; then # > container-selinux 优先从 OS 仓拿,OS 仓已明确存在满足版本的包 if try_install_pkg "$pkg" "--enablerepo=openeuler-os-tmp"; then log_info "container-selinux:已从 openEuler OS 仓安装/升级。" elif try_install_pkg "$pkg" "--enablerepo=openeuler-everything-tmp"; then log_info "container-selinux:已从 openEuler Everything 仓安装/升级。" else unresolved+=( "container-selinux" ) fi else if try_install_pkg "$pkg" "--enablerepo=openeuler-everything-tmp"; then log_info "${pkg}:已从 openEuler Everything 仓安装。" else unresolved+=( "$pkg" ) fi fi done still_missing=( "${unresolved[@]}" ) fi if [[ "${#still_missing[@]}" -eq 0 ]]; then return 0 fi # 3) openEuler:再用 EL8 兼容仓兜底 if [[ "$OS_ID" == "openeuler" && "$OS_EL_VER" == "8" ]]; then add_el8_compat_repo || log_warn "添加 EL8 兼容仓失败。" unresolved=() for pkg in "${still_missing[@]}"; do if try_install_pkg "$pkg" \ "--enablerepo=el8-compat-baseos-tmp" \ "--enablerepo=el8-compat-appstream-tmp" \ "--enablerepo=el8-compat-powertools-tmp" \ "--enablerepo=el8-compat-extras-tmp"; then log_info "${pkg}:已从 EL8 兼容仓安装。" else unresolved+=( "$pkg" ) fi done still_missing=( "${unresolved[@]}" ) fi if [[ "${#still_missing[@]}" -gt 0 ]]; then die "前置依赖仍缺失或版本不足:${still_missing[*]}(尤其 container-selinux 需满足 'container-selinux >= 2:2.74')" "$EC_INSTALL" fi } # ============================================================================== # Docker Version # ============================================================================== ### 解析 Docker 可用版本 ### @param none ### @return 0 成功 ### @require yum awk sed grep sort tail resolve_docker_version() { local available="" log_info "刷新 docker-ce-stable 元数据..." pkg_mgr makecache -y "${PKG_MGR_STABLE_OPTS[@]}" --disablerepo='*' --enablerepo='docker-ce-stable' || log_warn "仅 docker-ce-stable 刷新失败,继续。" available="$( yum "${PKG_GLOBAL_OPTS[@]}" list available docker-ce --showduplicates 2>/dev/null \ | awk '/^docker-ce\./ {print $2}' \ | sed 's/^[0-9]*://' \ | grep "^${DOCKER_VERSION}" \ | sort -t'.' -k1,1n -k2,2n -k3,3n \ | tail -1 )" || true if [[ -z "$available" ]]; then log_warn "未解析到精确版本,将使用通配安装:${DOCKER_VERSION}*" DOCKER_VERSION="" else DOCKER_VERSION="$available" log_info "已解析 Docker 版本:${DOCKER_VERSION}" fi } ### 将 Docker repo 的 gpgcheck / repo_gpgcheck 临时关闭 ### @param none ### @return 0 成功;非 0 失败 ### @require sed grep ### @side_effect 修改 /etc/yum.repos.d/docker-ce.repo disable_docker_repo_gpgcheck() { [[ -f "$DOCKER_REPO_FILE" ]] || die "Docker repo 文件不存在:${DOCKER_REPO_FILE}" "$EC_REPO" if [[ "$DRY_RUN" -eq 1 ]]; then printf '[DRY-RUN] disable gpg checks in %s\n' "$DOCKER_REPO_FILE" >&2 return 0 fi run_as_root sed -i \ -e 's/^gpgcheck=.*/gpgcheck=0/g' \ -e 's/^repo_gpgcheck=.*/repo_gpgcheck=0/g' \ "$DOCKER_REPO_FILE" if ! grep -q '^repo_gpgcheck=' "$DOCKER_REPO_FILE"; then run_as_root bash -c "printf '\nrepo_gpgcheck=0\n' >> '$DOCKER_REPO_FILE'" fi log_warn "已关闭 Docker repo 的 gpgcheck / repo_gpgcheck:${DOCKER_REPO_FILE}" make_cache } ### 执行 yum 安装并记录日志,避免管道触发 ERR trap ### @param log_file string 日志文件路径 ### @param ... args string yum install 后续参数 ### @return 0 成功;非 0 失败(返回 yum 的退出码) ### @require yum tee ### @side_effect 写入日志文件 run_yum_install_with_log() { local log_file="${1:?log_file required}" shift local rc=0 # > 用 if 包裹失败管道,避免 set -E/ERR trap 把失败点记录成 tee if yum "${PKG_GLOBAL_OPTS[@]}" install -y \ "${PKG_MGR_STABLE_OPTS[@]}" \ "${INSTALL_FLAGS[@]}" \ "$@" 2>&1 | tee "$log_file"; then return 0 else rc=${PIPESTATUS[0]:-1} return "$rc" fi } ### 安装 Docker 软件包(处理未签名包 / GPG 校验失败,并避免 tee 管道触发 ERR trap) ### @param none ### @return 0 成功;非 0 失败 ### @require yum grep mktemp install_docker_packages() { local pkg_ce="" local pkg_cli="" local install_log="" local rc=0 if [[ -n "$DOCKER_VERSION" && "$DOCKER_VERSION" =~ - ]]; then pkg_ce="docker-ce-${DOCKER_VERSION}" pkg_cli="docker-ce-cli-${DOCKER_VERSION}" elif [[ -n "$DOCKER_VERSION" ]]; then pkg_ce="docker-ce-${DOCKER_VERSION}*" pkg_cli="docker-ce-cli-${DOCKER_VERSION}*" else pkg_ce="docker-ce" pkg_cli="docker-ce-cli" fi log_info "开始安装 Docker:${pkg_ce} ${pkg_cli}" if [[ "$DRY_RUN" -eq 1 ]]; then pkg_mgr install -y \ "${PKG_MGR_STABLE_OPTS[@]}" \ "${INSTALL_FLAGS[@]}" \ "$pkg_ce" \ "$pkg_cli" \ containerd.io \ docker-compose-plugin return 0 fi install_log="$(mktemp "${TMP_DIR}/yum-install.XXXXXX")" if run_yum_install_with_log \ "$install_log" \ "$pkg_ce" \ "$pkg_cli" \ containerd.io \ docker-compose-plugin; then rm -f -- "$install_log" log_info "Docker 软件包安装成功。" return 0 fi rc=$? # > 两类都要处理: # > 1) 包本身未签名:is not signed # > 2) 包有签名但校验失败:GPG check FAILED if grep -Eiq 'is not signed|Package .* is not signed|GPG check FAILED|Public key for .* is not installed' "$install_log"; then log_warn "检测到 Docker 仓包签名校验失败,准备临时关闭 Docker 仓 gpgcheck 后重试。" disable_docker_repo_gpgcheck if run_yum_install_with_log \ "$install_log" \ "$pkg_ce" \ "$pkg_cli" \ containerd.io \ docker-compose-plugin; then rm -f -- "$install_log" log_warn "已通过关闭 Docker 仓 gpgcheck 成功完成安装。" return 0 fi rc=$? rm -f -- "$install_log" die "关闭 gpgcheck 后重试仍失败,退出码:${rc}" "$EC_INSTALL" fi rm -f -- "$install_log" die "Docker 安装失败,且并非签名校验类错误,退出码:${rc}" "$EC_INSTALL" } # ============================================================================== # Docker Config / Service # ============================================================================== ### 配置 Docker daemon.json ### @param none ### @return 0 成功;非 0 失败 ### @require none ### @side_effect 写入 /etc/docker/daemon.json configure_docker_daemon() { local content="" if [[ "$CONFIGURE_DAEMON" -ne 1 ]]; then log_info "已按参数要求跳过 daemon.json 配置。" return 0 fi content='{ "registry-mirrors": [ "https://docker.xuanyuan.me", "https://docker.1ms.run", "https://docker.m.daocloud.io" ], "log-driver": "json-file", "log-opts": { "max-size": "300m", "max-file": "3" }, "storage-driver": "overlay2", "live-restore": true } ' if [[ -f "$DOCKER_DAEMON_CFG" && "$FORCE" -ne 1 ]]; then log_warn "检测到已有 ${DOCKER_DAEMON_CFG},未使用 --force,跳过覆盖。" return 0 fi if [[ -f "$DOCKER_DAEMON_CFG" && "$FORCE" -eq 1 ]]; then confirm_action "检测到已有 ${DOCKER_DAEMON_CFG},是否覆盖?" || die "用户取消覆盖 daemon.json。" "$EC_USAGE" fi write_file_atomic "$DOCKER_DAEMON_CFG" "$content" log_info "已写入 Docker daemon 配置:${DOCKER_DAEMON_CFG}" } ### 启用并启动 Docker ### @param none ### @return 0 成功;非 0 失败 ### @require systemctl enable_and_start_docker() { run_as_root systemctl daemon-reload if [[ "$SKIP_ENABLE" -ne 1 ]]; then run_as_root systemctl enable docker || die "设置 Docker 开机自启失败。" "$EC_SERVICE" else log_info "已跳过开机自启。" fi if [[ "$SKIP_START" -ne 1 ]]; then run_as_root systemctl start docker || die "启动 Docker 失败。" "$EC_SERVICE" else log_info "已跳过启动 Docker。" fi } ### 验证安装结果 ### @param none ### @return 0 成功;非 0 失败 ### @require docker verify_installation() { if [[ "$SKIP_START" -eq 1 ]]; then log_warn "已跳过启动,因此不做运行态校验。" return 0 fi require_cmd docker || die "未检测到 docker 命令。" "$EC_VERIFY" run_cmd docker --version || die "docker --version 校验失败。" "$EC_VERIFY" run_cmd docker info >/dev/null 2>&1 || die "docker info 校验失败。" "$EC_VERIFY" if run_cmd docker compose version >/dev/null 2>&1; then log_info "docker compose 插件可用。" else log_warn "docker compose 插件校验失败,但主安装已完成。" fi } ### 打印摘要 ### @param none ### @return 0 成功 ### @require none print_summary() { log_info "安装摘要:" log_info " 系统 : ${OS_PRETTY_NAME}" log_info " OS ID : ${OS_ID_RAW} -> ${OS_ID}" log_info " 架构 : ${OS_ARCH}" log_info " EL 映射版本 : el${OS_EL_VER}" log_info " 包管理器 : ${PKG_MGR}" log_info " 镜像提供方 : ${MIRROR_PROVIDER}" log_info " Docker 仓地址 : ${MIRROR_BASE_URL}" log_info " GPG 模式 : ${GPG_MODE}" log_info " 代理 : ${PROXY_URL:-}" log_info " container-selinux : $(rpm -q container-selinux 2>/dev/null || echo not-installed)" log_info " fuse-overlayfs : $(rpm -q fuse-overlayfs 2>/dev/null || echo not-installed)" log_info " slirp4netns : $(rpm -q slirp4netns 2>/dev/null || echo not-installed)" } # ============================================================================== # Main # ============================================================================== ### 主流程 ### @param ... args 参数列表 ### @return 0 成功;非 0 失败 ### @require none main() { parse_args "$@" validate_args init_runtime check_requirements prepare_proxy_env log_info "开始执行 Docker 在线安装脚本 v${SCRIPT_VERSION}" cleanup_conflicting_packages configure_docker_repo handle_gpg_key make_cache install_prerequisites resolve_docker_version install_docker_packages configure_docker_daemon enable_and_start_docker verify_installation print_summary exit "$EC_OK" } main "$@" # ============================================================================== # Minimal Self-Test # ============================================================================== # 1) 正常路径: # sudo bash docker-online-install.sh --mirror aliyun --yes # # 2) 参数错误: # bash docker-online-install.sh --gpg-mode invalid # # 3) dry-run: # sudo bash docker-online-install.sh --mirror huawei --proxy http://192.168.1.10:3128 --dry-run # ==============================================================================