中文 English

Ubuntu 22.04 硬升 26.04 实录:6KB/s 的下载、缩水的镜像,和一块“暂停营业”的牌子

发布时间: 2026-09-25 · 阅读量 --
Ubuntu Linux LTS 升级 do-release-upgrade Docker NodeSource AI Agent

先说结论

Ubuntu 22.04 到 26.04 没有直达车,官方只支持逐级接力:22.04 到 24.04,再从 24.04 到 26.04。我最近把一台跑了 Portainer、博客导航和小工具的小服务器这样升了上去,全程两次重启、约 1.5GB 下载,但真正花时间的不是下载,而是三个意外:一个 37.8MB 的第三方软件包以 6KB/s 的速度龟速爬行;一个正在同步的镜像把升级工具包“切”成了两个大小,直接被拒收;以及最隐蔽的一个——26.04 明明已经发布,升级器却说“没有可用的 LTS”,因为元数据里一个标志位没有翻转。这篇文章把三个坑的表现、分析、根因和解法一次讲透,最后给出一键接力升级脚本,支持人工执行和交给 AI Agent 执行两种方式。

Ubuntu 22.04 直升 26.04:三个坑的封面图

图 1:本文封面。三次卡住,三种沉默,一根接力棒。

一、问题背景:一台 22.04 老机器和一条“接力赛道”

先交代环境。出问题的机器是一台小服务器,2022 年装的 Ubuntu 22.04 LTS,上面用 Docker 跑着 Portainer、导航页、笔记服务和几个小工具,属于“挂了会心疼、但有三分钟维护窗口”的角色。机器一直跟着 apt upgrade 打补丁,所以系统本身是健康的 22.04.5。

而终点线那头,Ubuntu 26.04 LTS 已经在 2026 年 4 月发布,官方下载页早就挂出了新版镜像。

Ubuntu 官方下载页已提供 26.04 LTS

图 2:Ubuntu 官网桌面版下载页,26.04 LTS 已经是默认推荐。

关键规则只有一条:LTS 之间的升级必须逐级走。Ubuntu 官方的生命周期页面写得很清楚,每个 LTS 支持五年,升级器只认“相邻的下一个 LTS”。

Ubuntu 官方发布周期页面

图 3:Ubuntu 官方发布周期说明。22.04 与 26.04 之间隔着 24.04,升级路径必须经过它。

打个比方,LTS 升级是一场接力赛。你是第一棒(22.04),终点是第三棒(26.04),但棒必须先交到第二棒(24.04)手里,跑完属于它的那一程,才能继续往前传。你不能站在起点把棒直接扔向终点——规则不允许,掉棒的风险也最大。

LTS 升级接力赛示意

图 4:接力赛比喻。每一棒都要完整跑完“全量升级、重启、验证”,do-release-upgrade 只接“相邻的下一棒”。

所以整个计划很朴素:22.04 全量升级,第一次 do-release-upgrade,重启;24.04 全量升级,第二次 do-release-upgrade,重启;验收。听起来半小时的事,实际跑了两个多小时——多出来的时间全是学费,也就是下面这三个坑。

二、问题表现:三次卡住,三种沉默

整个过程里系统“卡住”了三次,每一次的表现都不一样,而且都很沉默:

第一次,apt full-upgrade 在 22.04 上跑得好好的,突然停在一个 37.8MB 的软件包上,十多分钟纹丝不动,终端没有任何报错。

第二次,do-release-upgrade 刚开头就结束了,只留下一句“获取升级工具失败,可能是网络问题”。没有重试次数,没有失败明细,日志里才找得到真凶。

第三次最隐蔽:升级器干脆说“没有可用的 LTS 版本”。可 26.04 明明已经发布半年多了,官方下载页都挂在首页。这不是网络问题,不是权限问题,甚至不报错——它就是“看不见”。

三个坑的共同点是:都在升级链路的最前端就把你拦下,而且默认输出都极简。如果我是深夜手动操作,很可能在第二步就放弃,或者更糟——用“直接改 sources.list”的野路子硬来。所以下面每个坑,我都按“表现、分析、根因、解法”来拆。

三、坑一:第三方源,把全量升级拖进 6KB/s 的泥潭

表现

升级前的例行全量升级(apt update 加 apt full-upgrade)进行到下载阶段时,日志停在了 NodeSource 的 nodejs 包上。十分钟后我用另一只终端看了一眼:

apt 卡在 NodeSource 37.8MB 的包上

图 5:真实日志。同一个 37.8MB 的 deb 包,十分钟只落了 3.8MB,折合大约 6KB/s。

分析

国内访问 Ubuntu 官方镜像和华为云、阿里云这类国内镜像通常很快,但 NodeSource、Docker 官方源这类第三方仓库走的是海外 CDN,某些网络环境下的高峰期速度可能低到没法看。apt 是串行下载的:它卡在第三个包,后面哪怕有一百个包来自快镜像,也得排队等它。

这就像小区团购: majority 的货来自本地仓库,当晚就到,但有一单是海外直邮,物流卡在海关,整批配送就得陪它一起等。

根因

根因不是“apt 慢”,而是“升级路径上混入了速度不可控的外部依赖”。22.04 全量升级本身只需要从国内镜像拉 1GB 左右,5 分钟内能完成;但第三方源里一个 37.8MB 的包就把整条流水线堵死了。

解法

系统发行版升级本来就会临时禁用第三方源(do-release-upgrade 会把 sources.list.d 里的第三方仓库注释掉),那不如我自己来:先备份、再移走、跑完再恢复。这样全量升级只走国内镜像,几分钟结束:

禁用第三方源后全量升级完成

图 6:把 nodesource 和 docker 的源改名为 .disabled 暂时退场,全量升级干净利落地跑完。

22.04 全量升级完成的退出码

图 7:update、upgrade、autoremove 三步退出码全为 0,且无需重启,第一棒准备就绪。

注意细节:这里是“mv 暂存”而不是“删除”,文件原样保留,升级结束后改回原名即可。Node 和 Docker 的软件本体不受影响,只是这几分钟里不检查它们的更新。

四、坑二:镜像同步了一半,升级工具包直接被拒收

表现

第一棒交出去,do-release-upgrade 启动,却在几十秒内就退出了:

升级工具包下载失败

图 8:真实报错。升级工具包期望 1267251 字节,镜像只给得出 1275049 字节,大小对不上直接 Err。

分析

do-release-upgrade 的工作方式分两步:先从 changelogs.ubuntu.com 拿一份“版本目录”(meta-release),得知下一个版本是什么、升级工具包在哪;再把几 MB 的升级工具包(noble.tar.gz)从你配置的软件镜像下载下来,验签后执行。卡住的是第二步。

报错里的关键句是 “File has unexpected size … Mirror sync in progress?”。意思是:apt 记录的索引说这个包应该是 1267251 字节,但镜像实际发过来的是 1275049 字节。两个数字都不像乱码,更像同一个包的两个不同版本——镜像正在同步,一半文件是旧的,一半是新的,你恰好抓到了不一致的那份。

根因

我的 sources.list 指向某国内镜像。镜像同步上游时不是原子操作,索引和包文件之间存在一个时间窗,赶上窗口就会拿到“索引与实体不匹配”的组合。这属于镜像侧的暂时性故障,但对你来说就是升级流程整条堵死。

解法

既然镜像在“换货”,那就换一家已经同步完的店。把 sources.list 临时切换到另一个大镜像(我用的是阿里云),重新 apt update 后再跑 do-release-upgrade,升级工具包秒下,验签通过。升级完成后我保留了这个更快的镜像,把原配置备份成了 .bak。

一个判断技巧送给你:如果错误信息里出现 “unexpected size” 或 “Hash Sum mismatch”,九成是镜像同步问题,等半小时或换镜像都比反复重试更省时间;如果是超时(Timeout),那才是你自己的网络问题。

五、坑三:26.04 早已发布,升级器却说“没有可用版本”

表现

这是最有意思的一个坑。第二阶段一切就绪,24.04.5 加新内核 6.8,do-release-upgrade 却给出了这样的回答:

升级器声称没有可用的 LTS

图 9:真实输出。“There is no development version of an LTS available”,退出码 1。

到这一步没有任何报错可查——网络是通的,镜像刚换过,24.04 也是刚升完的新系统。升级器只是“不认为”存在一个可以去的 LTS。

分析

上一篇文章(Ubuntu 26.04 升级指南,见文末相关阅读)提过:LTS 到 LTS 的常规升级提示,通常要等新版本的第一个点版本(26.04.1)发布后才对普通用户开放。现在已经是 26.04.1 发布之后,常规通道理应打开。那升级器在看什么?

答案是 /etc/update-manager/meta-release 配置指向的“版本目录”。把这个目录拉下来看,resolute(26.04 的开发代号)的条目就在里面:

meta-release-lts 中的 resolute 条目

图 10:官方 meta-release-lts 页面。resolute 条目就在列表末尾,版本 26.04.1 LTS。

条目里有一个字段叫 Supported,我抓到的值是 0。这就是全部真相:升级器判断“要不要推荐这个升级”时,看的不是版本号有多新,而是这个标志位。Supported 为 0,在它眼里等同于“暂停营业”。

根因

打个比方:meta-release 是软件源门口挂的“营业时间牌”,升级器是个只认牌子的顾客。店里货满满当当(26.04.1 已发布、镜像上有全套包),但牌子写着“暂停营业”(Supported: 0),顾客扭头就走,一句废话都不多说。我遇到的这份元数据里,resolute 的牌子没有翻过来——无论是镜像同步滞后,还是官方灰度放开的节奏,总之在我的网络视角下,它就是 0。

解法

解法不是闯进没开门的店,而是:确认货真的到了之后,自己立一块牌子。具体三步:

  1. 把 meta-release-lts 拉一份副本到本地;
  2. 在副本里把 resolute 的 Supported 从 0 改成 1;
  3. 把 /etc/update-manager/meta-release 里的 URI_LTS 指向这份本地副本(file:// 协议),清掉升级器自己的缓存标记,重新运行。

给 meta-release 打本地补丁

图 11:真实执行过程。三行 Python 翻转标志位,URI_LTS 指向本地副本。

营业牌比喻示意

图 12:Supported 标志就像店铺门口的营业牌。升级器不看你多想去,只看牌子上写什么。

有两条安全边界必须说清楚。第一,这个补丁只翻转“推荐开关”,不伪造任何软件包,升级工具包和所有 deb 仍然走官方 GPG 验签,签名对不上照样拒绝执行。第二,它是临时桥,不是永久改装——升级完成后必须把 URI_LTS 换回官方地址,否则你的系统会永远读一份过期的“营业时间表”。文末的一键脚本里,这个补丁是自动打、自动撤的。

六、冲线:两次重启之后

补丁打完,第二棒顺利交出。24.04 阶段完成、重启进新内核:

24.04 阶段完成

图 13:第一棒终点。Ubuntu 24.04.5,内核 6.8.0,开机 0 分钟。

第二阶段跑了约 45 分钟,其中 1.2GB 的包下载占了三分之二,剩下是解包和配置。最后:

26.04 阶段完成

图 14:终点线。Ubuntu 26.04.1 LTS,内核 7.0.0,release_rc=0。

最关心的 Docker 全家桶在重启后自动归位,六个容器全部自愈,node 也在恢复源之后升到了新版本:

容器自动恢复

图 15:重启后 docker ps 一览,六个容器全部 Up,无需任何人工干预。

顺手把升级前的两个第三方源恢复:Docker 的源代号从旧版本改成 26.04 的代号,NodeSource 用的是发行版无关的 nodistro 通道,原样启用即可。收尾再做一次全量升级确认干净,整个迁移结束。

七、一键全自动:人工执行与 Agent 执行

把上面的经验固化成一个脚本。它只依赖 Ubuntu 自带工具(bash、apt、do-release-upgrade、systemd、curl),不依赖任何第三方服务;每一阶段落盘日志、状态可续跑、重启自动接续;第三方源自动暂存恢复;meta-release 补丁只在“确认需要”时自动打、结束自动撤。

人工执行:把脚本保存为 ubuntu-lts-relay.sh,然后 sudo bash ubuntu-lts-relay.sh。可以加 --target 24.04 只升一级。中途随时可以 Ctrl-C 观察,重跑脚本会从断点继续。

#!/usr/bin/env bash
# ubuntu-lts-relay.sh —— Ubuntu LTS 接力升级一键脚本(仅官方路径,逐级升级)
# 依赖:bash / apt / do-release-upgrade / systemd / curl,均系统自带
set -euo pipefail

TARGET_VER="${2:-26.04}"                       # 目标版本号,如 24.04 或 26.04
STATE=/var/lib/lts-relay.state                 # 断点状态文件
UNIT=/etc/systemd/system/lts-relay.service     # 重启后续跑用的 systemd 单元
LOG=/var/log/lts-relay.log
META_SRC=https://changelogs.ubuntu.com/meta-release-lts
declare -A CODE=( ["22.04"]=jammy ["24.04"]=noble ["26.04"]=resolute )
declare -A NEXT=( [jammy]=noble [noble]=resolute [focal]=jammy )

log(){ echo "[$(date '+%F %T')] $*" | tee -a "$LOG"; }
die(){ log "FATAL: $*"; exit 1; }

preflight(){
  [[ $(id -u) -eq 0 ]] || die "请用 root 运行"
  command -v do-release-upgrade >/dev/null || die "缺少 update-manager-core"
  local cur; cur=$(lsb_release -rs)
  [[ -n "${CODE[$cur]:-}" ]] || die "未收录的版本 $cur"
  local free; free=$(df --output=avail -BG / | tail -1 | tr -dc 0-9)
  (( free >= 8 )) || die "根分区仅 ${free}G,建议至少 8G"
  if [[ -f /var/run/reboot-required ]]; then die "有待生效重启,请先重启再运行"; fi
  log "preflight 通过:$cur -> $TARGET_VER,剩余空间 ${free}G"
}

stash_third_party(){                 # 第三方源暂存,升级期间不参与
  shopt -s nullglob
  local f
  for f in /etc/apt/sources.list.d/*.list /etc/apt/sources.list.d/*.sources; do
    mv "$f" "$f.stash"; log "暂存第三方源 $f"
  done
}

restore_third_party(){               # 升级完成后原样恢复
  shopt -s nullglob
  local f
  for f in /etc/apt/sources.list.d/*.stash; do mv "$f" "${f%.stash}"; done
  log "第三方源已恢复"
}

full_upgrade(){                      # 当前版本内的全量升级
  export DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a
  apt-get update -qq
  apt-get -y -q -o Dpkg::Options::=--force-confdef \
    -o Dpkg::Options::=--force-confold full-upgrade
  apt-get -y -q autoremove
}

bridge_meta_release(){               # 坑三补丁:目标已发布但 Supported 仍为 0 时
  local cur next codename block      # 自动打补丁;结束时 revert_meta_release 撤销
  cur=$(lsb_release -rs); next=${NEXT[${CODE[$cur]}]}
  [[ "$next" == "${CODE[$TARGET_VER]}" ]] || return 0
  codename=$next
  curl -fsSL "$META_SRC" -o /etc/update-manager/meta-release-lts.local
  if ! grep -A5 "^Dist: $codename" /etc/update-manager/meta-release-lts.local \
      | head -6 | grep -q "Supported: 0"; then
    rm -f /etc/update-manager/meta-release-lts.local
    log "目标版本已对普通用户开放,无需桥接"
    return 0
  fi
  python3 - "$codename" <<'PY'
import sys
codename = sys.argv[1]
path = "/etc/update-manager/meta-release-lts.local"
out = []
for b in open(path).read().split("Dist: "):
    if b.startswith(codename):
        b = b.replace("Supported: 0", "Supported: 1", 1)
    out.append(b)
open(path, "w").write("Dist: ".join(out))
PY
  cp -n /etc/update-manager/meta-release /etc/update-manager/meta-release.bak
  sed -i "s|^URI_LTS = .*|URI_LTS = file:///etc/update-manager/meta-release-lts.local|" \
    /etc/update-manager/meta-release
  rm -f /var/lib/ubuntu-release-upgrader/release-upgrade-available
  echo "bridge=on" >> "$STATE"
  log "meta-release 桥接已启用(结束后自动撤销)"
}

revert_meta_release(){               # 撤销桥接,恢复官方元数据地址
  grep -q "^bridge=on" "$STATE" 2>/dev/null || return 0
  if [[ -f /etc/update-manager/meta-release.bak ]]; then
    mv /etc/update-manager/meta-release.bak /etc/update-manager/meta-release
  fi
  rm -f /etc/update-manager/meta-release-lts.local
  sed -i '/^bridge=on/d' "$STATE"
  log "meta-release 桥接已撤销"
}

install_resume_unit(){               # 重启后自动续跑
  cat > "$UNIT" <<UNIT
[Unit]
Description=LTS relay upgrade resume
After=network-online.target

[Service]
Type=oneshot
ExecStart=$PWD/$(basename "$0") --resume
TimeoutSec=7200

[Install]
WantedBy=multi-user.target
UNIT
  systemctl daemon-reload && systemctl enable lts-relay.service
}

run_release_stage(){                 # 一棒:do-release-upgrade 非交互执行
  export DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a
  do-release-upgrade -f DistUpgradeViewNonInteractive </dev/null \
    | tee -a "$LOG"
}

main(){
  [[ "${1:-}" == "--resume" ]] || { : > "$STATE"; preflight; }
  while true; do
    local cur; cur=$(lsb_release -rs)
    [[ "$cur" == "$TARGET_VER" ]] && break
    local stage; stage=${CODE[$cur]}
    if ! grep -q "^done_$stage" "$STATE" 2>/dev/null; then
      full_upgrade
      stash_third_party
      bridge_meta_release
      log "开始发行版升级:$cur"
      run_release_stage
      echo "done_$stage" >> "$STATE"
    fi
    install_resume_unit
    log "$stage 阶段完成,10 秒后重启续跑"
    sleep 10 && systemctl reboot
  done
  restore_third_party
  revert_meta_release
  systemctl disable --now lts-relay.service 2>/dev/null || true
  rm -f "$UNIT"; systemctl daemon-reload
  log "升级完成:$(lsb_release -ds),内核 $(uname -r),请验收业务服务"
}
main "$@"

脚本几处设计说明:bridge_meta_release 只在“目标版本已发布但标志位仍为 0”时才动手,如果官方已经放开,它什么都不改;systemd 单元负责重启后自动续跑,全部完成后自删;所有日志集中在 /var/log/lts-relay.log。

交给 AI Agent 执行时,重点不是让它“会敲命令”,而是把验收标准和边界写进提示词。可以直接复制下面这段:

请把主机 <HOST> 按官方路径升级到 Ubuntu 26.04 LTS:
1. 先运行预检(版本、磁盘、reboot-required、Docker 业务状态),把结果给我确认;
2. 确认后执行 ubuntu-lts-relay.sh,用 nohup 或 tmux 托管,全程记录日志;
3. 每个阶段重启后检查续跑状态,读取 /var/log/lts-relay.log 确认推进;
4. 结束后验收:lsb_release、uname -r、docker ps 全部健康、apt update 无报错;
5. 全程不得删除业务数据、容器卷、配置文件;第三方源只允许暂存和恢复,不得删除;
6. 如果 do-release-upgrade 报错,先贴出原始错误,再按日志分析,不得直接改源跳级。

八、Q&A

问:为什么不直接把 sources.list 改成 26.04 再 full-upgrade?

问过自己很多次这个问题。答案是:do-release-upgrade 里躺着上百条“接力交接动作”——库文件改名、配置合并策略、引导更新、过渡包清理,这些在裸改源的方案里全都得你自己扛。接力赛的例子再讲一遍:跨栏可以练,隔棒扔接力棒只能靠祈祷。

问:Supported 标志位的补丁安全吗?要不要长期保留?

它只影响“升级器是否推荐这个目标”,不触碰包、不绕过签名。但它的确让系统绕过了官方的放开节奏,所以仅用于你确认 26.04.1 已正式发布、且你自愿承担灰度期风险的情况。脚本用完即撤,URI_LTS 恢复官方地址,不留后患。

问:升级中途 SSH 断线,会升到一半变砖吗?

不会。apt/dpkg 的事务机制保证包要么装完要么没装,重连后先跑 dpkg --configure -a 就能接上。本文的真实经历里,升级过程中 SSH 服务自己被升级重启了一次,连接断了十分钟,但后台的升级进程毫发无损地继续跑——这也是为什么脚本坚持“日志落盘加状态文件加重启续跑”三件套。

问:Docker、Node.js 这类第三方软件会丢吗?

不会。源只是暂存,软件本体不动,容器数据都在卷里。恢复源时注意把源代号改成新版本对应的(Docker 需要,NodeSource 不需要),然后 apt update 即可。这次升级里六个容器重启后 38 秒内全部自愈。

问:26.04.1 之前能不能照这篇文章升?

能,但请先读上一篇(Ubuntu 26.04 升级指南,相关阅读里有链接):常规通道没开时,生产机建议继续等,测试机再用桥接方式提前。本文的脚本对此做了区分——桥接只在标志位为 0 且目标已发布时触发。

问:怎么确认升级真的成功了?

四条验收线:lsb_release -ds 显示目标版本;uname -r 是新内核且重启过;docker ps(或你的核心服务)全部健康;apt update 无报错、/var/run/reboot-required 不存在。四条都绿,才算冲线。

相关阅读

本文阅读量 --