#!/usr/bin/env bash # # check_images.sh — 检查 deploy.stack 常用 Docker 镜像的最新版本 # # 数据源: # - Docker Hub v2 API: https://hub.docker.com/v2/repositories///tags/ # - 通用 registry v2: https:///v2//tags/list # # 用法: # ./check_images.sh # 全量 # ./check_images.sh gitea nginx postgres # 名称包含关键字的镜像 # ./check_images.sh -j 20 # 并发数(默认 10) # ./check_images.sh --json # JSON 输出 # ./check_images.sh --pre # 含 rc/beta/dev 等预发布 # ./check_images.sh --outdated # 只显示可升级的 # ./check_images.sh --proxy # source ./proxy.sh 后开启代理 # ./check_images.sh --timeout 20 # 单镜像超时(秒) # ./check_images.sh -h | --help # 帮助 # # 退出码: # 0 = 全部最新或仅有 unknown;1 = 存在可升级;2 = 致命错误 # # 注意: # - 私有仓库(hub.tp229.com:3500、hub.wesais.cn、hub.6t7.net 等)大多不开 # 匿名 tags API,这些会标 "unknown",不会算作失败 # - Docker Hub 限流:匿名 100 req/IP/6h;并发 > 20 易触发 429 # - tag 命名差异大(日期 tag it-tools:20241022、变体 tag mysql:8.4-alpine # 等),本脚本按 "last_updated 倒序 + 非预发布" 选 "最新",不做语义版本 # 比较,仅字符串对比 # - 默认排除预发布标记:-rc / -beta / -alpha / -dev / -pre / -edge / # nightly / experimental / testing;edge 频道镜像(netdata/netdata:edge-0) # 在 --pre 模式下可见 # # 扩展: # 如需新增/修改镜像,编辑下方 IMAGES 数组即可。 # # 依赖:curl、python3(仅用于 JSON 解析) set -euo pipefail # ===================== 默认配置 ===================== PARALLEL=10 JSON_MODE=0 INCLUDE_PRE=0 ONLY_OUTDATED=0 USE_PROXY=0 TIMEOUT=15 declare -a FILTERS=() # 镜像清单:image|current_tag|note # - current_tag 写 "-" 表示只查最新不比较;latest 也算"已最新" # - note 仅展示用 IMAGES=( # ---- 自托管应用 ---- "vaultwarden/server|1.36.0-alpine|" "xhofe/alist|v3.32.0-ffmpeg|带 ffmpeg 变体" "adminer|5.4.2|" "anqicms/anqicms|latest|" "mouday/domain-admin|v1.6.78|" "pubuzhixing/drawnix|v0.4.1|" "sigoden/dufs|v0.46.0|" "pawelmalak/flame|multiarch2.3.1|多架构" "gitea/gitea|1.27.3-rootless|rootless 变体" "binwiederhier/ntfy|v2.24|" "joplin/server|3.6.1|" "neosmemo/memos|0.30.0|" "n8nio/n8n|2.3.6|" "redf0x1/camofox-browser|2.4.7|" "nextcloud/all-in-one|latest|" "owncloud/ocis|latest|" "wg-easy/wg-easy|15.2.2|" # ---- 数据库 ---- "redis|8.4.0-alpine|alpine 变体" "postgres|18.4|" "mysql|8.4|LTS" "percona|8.0.35-27|" "mongo|5.0|" "couchdb|3.5|" "valkey/valkey|9.0.0|" "quay.io/coreos/etcd|v3.5.0|" # ---- 监控 ---- "grafana/grafana|13.0.2|" "grafana/loki|3.6.2|" "victoriametrics/victoria-metrics|v1.126.0|" "flashcatcloud/nightingale|9.0.0|" "flashcatcloud/categraf|v0.4.15|" "netdata/netdata|edge-0|edge 频道" # ---- 反代/网关 ---- "haproxy|3.3.0|" "caddy|2.10.0|" "traefik|3.5.0|" "portainer/portainer-ce|2.27.4|" "portainer/agent|2.27.4|" # ---- 私有仓库(多返回 unknown)---- "hub.tp229.com:3500/registry|3.0|私有" "hub.tp229.com:3500/cnphpbb/registry-ui|latest|私有" "hub.6t7.net/cnphpbb/it-tools|20241022|日期 tag" "hub.wesais.cn/cnphpbb/mynat|v250603|日期 tag" "hub.tp229.com:3500/ansible-alpine|py3.13-rootless|私有" "hub.tp229.com:3500/gitea/gitea|1.27.3-rootless|私有镜像示例" ) # ===================== 参数解析 ===================== while [[ $# -gt 0 ]]; do case "$1" in -j|--parallel) PARALLEL="$2"; shift 2 ;; --json) JSON_MODE=1; shift ;; --pre) INCLUDE_PRE=1; shift ;; --outdated) ONLY_OUTDATED=1; shift ;; --proxy) USE_PROXY=1; shift ;; --timeout) TIMEOUT="$2"; shift 2 ;; -h|--help) sed -n '2,30p' "$0" exit 0 ;; -*) echo "未知选项: $1" >&2 echo "使用 -h 查看帮助" >&2 exit 2 ;; *) FILTERS+=("$1"); shift ;; esac done # ===================== 代理 ===================== if [[ $USE_PROXY -eq 1 ]]; then _proxy_sh="$(cd "$(dirname "$0")" && pwd)/proxy.sh" if [[ -f "$_proxy_sh" ]]; then # shellcheck disable=SC1090 source "$_proxy_sh" proxy_on >/dev/null || true else echo "⚠️ 未找到 proxy.sh(${_proxy_sh}),跳过代理" >&2 fi fi # ===================== 依赖检查 ===================== for cmd in curl python3; do if ! command -v "$cmd" >/dev/null 2>&1; then echo "❌ 缺少依赖: $cmd" >&2 exit 2 fi done # ===================== 函数 ===================== # 解析 registry 类型与 API URL,输出 "|" resolve_registry() { local image="$1" # 通用 v2 registry:含端口或知名前缀 if [[ "$image" == *":"*"/"* ]] || \ [[ "$image" == hub.* ]] || \ [[ "$image" == ghcr.io/* ]] || \ [[ "$image" == quay.io/* ]] || \ [[ "$image" == gcr.io/* ]]; then local host_port="${image%%/*}" local repo_path="${image#*/}" echo "v2|https://${host_port}/v2/${repo_path}/tags/list" return fi # Docker Hub local ns repo if [[ "$image" == */* ]]; then ns="${image%%/*}" repo="${image##*/}" else ns="library" repo="$image" fi echo "dockerhub|https://hub.docker.com/v2/repositories/${ns}/${repo}/tags/?page_size=100&ordering=last_updated" } # python3 解析 JSON,从 stdin 读取 # 输出 "TAG" 或 "ERR|reason" parse_latest() { python3 - "$INCLUDE_PRE" <<'PYEOF' import json, sys, re include_pre = sys.argv[1] == "1" raw = sys.stdin.read() if not raw.strip(): print("ERR|empty response") sys.exit(0) try: data = json.loads(raw) except json.JSONDecodeError as e: print(f"ERR|invalid json: {e}") sys.exit(0) tags = [] if isinstance(data, dict) and "results" in data: for t in data["results"]: if isinstance(t, dict): tags.append((t.get("name", ""), t.get("last_updated", "") or "")) elif isinstance(data, dict) and "tags" in data: for n in data.get("tags") or []: tags.append((n, "")) else: if isinstance(data, dict): detail = data.get("detail") or data.get("message") or "unknown" else: detail = "unknown" print(f"ERR|{detail}") sys.exit(0) if not tags: print("ERR|no tags") sys.exit(0) def version_key(name): """可排序的版本主键:v1.27.3 → (1,27,3);无法识别 → (0,)""" m = re.match(r"^v?(\d+(?:\.\d+)*)", name) if not m: return (0,) return tuple(int(p) for p in m.group(1).split(".")) # 主:版本降序;次:好变体优先;再次:last_updated 降序 tags.sort(key=lambda x: (version_key(x[0]), 1 if is_good_variant(x[0]) else 0, x[1], x[0]), reverse=True) # 按 . 或 - 切分 tag,任一段命中预发布关键词即视为预发布 PRE_MARKERS = { "rc", "beta", "alpha", "dev", "pre", "edge", PRE_MARKERS = {"rc", "beta", "alpha", "dev", "pre", "edge", "nightly", "experimental", "testing", "ea", } def is_pre_release(name: str) -> bool: parts = re.split(r"[.\-]", name.lower()) # 去掉头尾数字:rc1→rc、19beta3→beta、2.0.0-rc.1→rc parts = [re.sub(r"^\d+|\d+$", "", p) for p in parts] return any(p in PRE_MARKERS for p in parts) # 版本型 tag 前缀:v可选,后接数字与点(如 2.10.0、v1.27.3-rootless、8.4.0-alpine) VERSION_PREFIX = re.compile(r"^v?\d+(?:\.\d+)*") def looks_like_version(name: str) -> bool: return bool(VERSION_PREFIX.match(name)) # 不适合作为 "最新推荐" 的后缀:Windows 变体 / Oracle Linux / x86_64 桌面 / 早期访问 / CentOS / 企业版 / builder BAD_VARIANTS = { "nanoserver", "windowsservercore", "wincore", "oraclelinux", "oracle", "pc", # x86_64 桌面构建(服务器场景不需要) "ea", # Early Access "centos", "rockylinux", "alma", # RHEL 系变体(服务器场景多不用) "enterprise", "fips", # 企业版 / FIPS(需要商业授权) "builder", # 镜像构建阶段,不适合运行时 } def is_good_variant(name: str) -> bool: base = re.sub(r"^v?\d+(?:\.\d+)*", "", name.lower()).lstrip("-") return not any(bad in base for bad in BAD_VARIANTS) # 三遍选择:版本型 > 非预发布非平台变体 > latest 回退 fallback_latest = None seen = set() for name, _ in tags: if not name or name in seen: continue seen.add(name) if name.lower() == "latest": fallback_latest = name continue if not include_pre and is_pre_release(name): continue if not looks_like_version(name): continue print(name) sys.exit(0) # 第二遍:含日期/字母前缀但仍像版本(20241022、py3.13、multiarch2.3.1 等) for name, _ in tags: if not name or name in seen: continue seen.add(name) if name.lower() == "latest": continue if not include_pre and is_pre_release(name): continue print(name) sys.exit(0) if fallback_latest: print(fallback_latest) elif tags: print(tags[0][0]) PYEOF } # 检查单个镜像,stdout: IMAGE|CURRENT|LATEST|STATUS|NOTE check_one() { local image="$1" local current="$2" local note="$3" local api_info api_type url api_info=$(resolve_registry "$image") api_type="${api_info%%|*}" url="${api_info#*|}" local body if ! body=$(curl -sS --max-time "$TIMEOUT" "$url" 2>/dev/null); then printf '%s|%s|%s|unknown|%s\n' "$image" "$current" "(unreachable)" "$note" return 0 fi if [[ -z "$body" ]]; then printf '%s|%s|%s|unknown|%s\n' "$image" "$current" "(unreachable)" "$note" return 0 fi local parsed latest parsed=$(printf '%s' "$body" | parse_latest) || true if [[ "$parsed" == ERR* ]]; then printf '%s|%s|%s|unknown|%s\n' "$image" "$current" "$parsed" "$note" return 0 fi latest="$parsed" local status # 同版本(忽略后缀)视为已最新:1.36.0 vs 1.36.0-alpine、3.6.1 vs 3.6.1-bookworm local cur_base lat_base cur_base=$(echo "$current" | sed -E 's/^v?([0-9]+(\.[0-9]+)*).*/\1/') lat_base=$(echo "$latest" | sed -E 's/^v?([0-9]+(\.[0-9]+)*).*/\1/') if [[ "$current" == "-" ]] || [[ "$current" == "latest" ]] \ || [[ "$current" == "$latest" ]] || [[ "$cur_base" == "$lat_base" && -n "$cur_base" ]]; then status="up-to-date" else status="outdated" fi printf '%s|%s|%s|%s|%s\n' "$image" "$current" "$latest" "$status" "$note" } # 表格输出 display_table() { local -a rows=("$@") local total=${#rows[@]} local up_to_date=0 outdated=0 unknown=0 for row in "${rows[@]}"; do IFS='|' read -r _ _ _ status _ <<< "$row" case "$status" in up-to-date) up_to_date=$((up_to_date+1)) ;; outdated) outdated=$((outdated+1)) ;; unknown) unknown=$((unknown+1)) ;; esac done printf '\033[36m┌──────────────────────────────────────────────────────────────────────────────────────────────┐\033[0m\n' printf '\033[36m│\033[0m \033[1;36m%-46s %-14s %-14s %-14s\033[0m \033[36m│\033[0m\n' "IMAGE" "CURRENT" "LATEST" "STATUS" printf '\033[36m├──────────────────────────────────────────────────────────────────────────────────────────────┤\033[0m\n' for row in "${rows[@]}"; do IFS='|' read -r img cur latest status _ <<< "$row" local status_disp latest_disp case "$status" in up-to-date) status_disp=$(printf '\033[32m✓ up-to-date\033[0m') latest_disp="$latest" ;; outdated) status_disp=$(printf '\033[33m↑ outdated\033[0m') latest_disp=$(printf '\033[36m%s\033[0m' "$latest") ;; unknown) status_disp=$(printf '\033[31m✗ unknown\033[0m') latest_disp=$(printf '\033[2m%s\033[0m' "$latest") ;; *) status_disp="$status" latest_disp="$latest" ;; esac local img_short="$img" if [[ ${#img_short} -gt 46 ]]; then img_short="${img_short:0:43}..." fi printf '\033[36m│\033[0m %-46s %-14s %-14s %-30b \033[36m│\033[0m\n' \ "$img_short" "$cur" "$latest_disp" "$status_disp" done printf '\033[36m└──────────────────────────────────────────────────────────────────────────────────────────────┘\033[0m\n' echo printf '\033[1;36m📊 汇总:\033[0m 总计 %d | \033[32m✓ %d\033[0m | \033[33m↑ %d\033[0m | \033[31m? %d\033[0m\n' \ "$total" "$up_to_date" "$outdated" "$unknown" } # JSON 输出 display_json() { local -a rows=("$@") echo "[" local first=1 for row in "${rows[@]}"; do IFS='|' read -r img cur latest status note <<< "$row" if [[ $first -eq 1 ]]; then printf ' {"image":"%s","current":"%s","latest":"%s","status":"%s","note":"%s"}' \ "$img" "$cur" "$latest" "$status" "$note" first=0 else printf ',\n {"image":"%s","current":"%s","latest":"%s","status":"%s","note":"%s"}' \ "$img" "$cur" "$latest" "$status" "$note" fi done echo echo "]" } # ===================== 主逻辑 ===================== tmpdir=$(mktemp -d) trap 'rm -rf "$tmpdir"' EXIT declare -a rows=() active=0 idx=0 for entry in "${IMAGES[@]}"; do IFS='|' read -r image current note <<< "$entry" [[ -z "$image" ]] && continue if [[ ${#FILTERS[@]} -gt 0 ]]; then matched=0 for f in "${FILTERS[@]}"; do [[ "$image" == *"$f"* ]] && matched=1 done [[ $matched -eq 0 ]] && continue fi ( check_one "$image" "$current" "$note" > "$tmpdir/$idx.out" 2>&1 ) & active=$((active+1)) idx=$((idx+1)) if [[ $active -ge $PARALLEL ]]; then wait -n 2>/dev/null || true active=$((active-1)) fi done wait # 收集结果 for ((i=0; i