跳到主要内容Web 触发离线升级:Systemd 异步机制与 A/B 状态机切换详解 | 极客日志Python
Web 触发离线升级:Systemd 异步机制与 A/B 状态机切换详解
综述由AI生成一种基于 Systemd Path 单元实现可靠系统升级的方案。通过 HTTP 接口接收升级包,后端仅负责保存文件并创建触发标记,真正的升级逻辑由 systemd 独立管理,确保即使 Web 服务进程退出或重启,升级任务仍能继续执行。方案结合了原子性软链接切换的 A/B 双环境热升级策略,实现了零停机部署、快速回滚及高可靠性,适用于嵌入式设备、边缘计算节点等场景。
技术博主33 浏览 1. 背景与目标
在许多嵌入式或远程管理场景中,系统需支持通过 HTTP 接口(如 FastAPI、Flask)接收升级包并触发升级流程。但存在一个关键问题:
Web 服务进程(如 Python 进程)若在升级过程中被终止(例如因重启、崩溃或主动退出),如何确保升级任务不中断?
本文将介绍一种完全解耦的方案:
- 用户通过 HTTP 接口上传升级包;
- 后端仅负责校验、保存文件并'发出信号';
- 真正的升级逻辑由 systemd 管理,与 Web 进程无依赖关系;
- 即使 Web 服务进程退出、被 kill 或系统重启前短暂运行,升级仍能继续完成。
核心机制:Systemd Path 单元 + 原子性触发文件 + 独立升级脚本。
2. 核心设计原则
| 原则 | 说明 |
|---|
| 触发与执行分离 | Web 接口只负责'写一个文件',不执行任何升级逻辑 |
| 升级进程独立于 Web 服务 | 升级由 systemd 启动的新进程执行,生命周期不受 Web 进程影响 |
| 原子性切换 | 使用软链接或配置切换,避免中间状态 |
| 幂等与可重试 | 触发文件可重复创建,升级脚本具备幂等性 |
| 日志可追溯 | 所有操作记录到文件和 journal,便于审计 |
3. 系统架构概览
[HTTP Client] ↓ (POST /upgrade with .tar file) [FastAPI App] ↓ 1. 校验文件格式 2. 保存到 /opt/upgrade/package.tar 3. 清理旧触发标记 4. systemctl enable --now upgrade-trigger.path 5. 在后台任务中:touch /tmp/upgrade.trigger ↓(立即返回响应) [systemd] ↓ 监听到 /tmp/upgrade.trigger 存在 [upgrade-runner.service] ↓ 启动独立进程 /opt/run-upgrade.sh [run-upgrade.sh] → 解压包 → 验证 → AB 切换 → 重启服务/系统
关键点:从 touch /tmp/upgrade.trigger 开始,后续所有操作均由 systemd 管理,与 FastAPI 进程无关。
4. 详细实现
4.1 升级脚本(独立执行单元)
set -e
LOG_FILE="/var/log/system-upgrade.log"
PACKAGE_DIR="/opt/upgrade"
PACKAGE_PATH="$PACKAGE_DIR/package.tar"
ACTIVE_LINK="/opt/app/active"
RELEASES_DIR="/opt/app/releases"
() {
>>
}
[ ! -f ];
1
TMP_DIR=$( -d)
tar -xf -C
VERSION_FILE=
[ ! -f ];
-rf
1
VERSION=$( | -d )
TARGET_DIR=
[ -d ];
-p
-sfn
systemctl daemon-reload
systemctl restart myapp.service
-f /tmp/upgrade.trigger
( -1t | -n +3) | oldver;
[ -n ] && -rf
log
echo
"[$(date '+%Y-%m-%d %H:%M:%S')] $*"
"$LOG_FILE"
log
"=== 升级流程开始 ==="
if
"$PACKAGE_PATH"
then
log
"错误:升级包不存在 ($PACKAGE_PATH)"
exit
fi
mktemp
"$PACKAGE_PATH"
"$TMP_DIR"
"$TMP_DIR/version.txt"
if
"$VERSION_FILE"
then
log
"错误:升级包中缺少 version.txt"
rm
"$TMP_DIR"
exit
fi
cat
"$VERSION_FILE"
tr
'[:space:]'
"$RELEASES_DIR/$VERSION"
if
"$TARGET_DIR"
then
log
"版本 $VERSION 已存在,跳过解压"
else
log
"部署新版本:$VERSION"
mkdir
"$RELEASES_DIR"
mv
"$TMP_DIR"
"$TARGET_DIR"
fi
log
"切换 active 指向 $VERSION"
ln
"$TARGET_DIR"
"$ACTIVE_LINK"
rm
ls
"$RELEASES_DIR"
tail
while
read
do
"$oldver"
rm
"$RELEASES_DIR/$oldver"
done
log
"=== 升级流程完成 ==="
chmod +x /opt/run-upgrade.sh
4.2 Systemd 单元配置
服务单元 /etc/systemd/system/upgrade-runner.service:
[Unit]
Description=System Upgrade Runner
After=network.target
[Service]
Type=oneshot
ExecStart=/opt/run-upgrade.sh
User=root
Group=root
StandardOutput=journal
StandardError=journal
TimeoutSec=300
[Install]
WantedBy=multi-user.target
路径监控单元 /etc/systemd/system/upgrade-trigger.path:
[Unit]
Description=Monitor upgrade trigger file
After=multi-user.target
[Path]
PathExists=/tmp/upgrade.trigger
[Install]
WantedBy=multi-user.target
注意:PathExists 表示只要文件存在就触发一次。触发后 .path 单元会进入 inactive 状态,直到下次 reload 或重新 enable。
4.3 Web 接口实现(FastAPI 示例)
from fastapi import FastAPI, File, UploadFile, BackgroundTasks, HTTPException
from pydantic import BaseModel
import subprocess
import os
from pathlib import Path
app = FastAPI()
UPGRADE_DIR = "/opt/upgrade"
UPGRADE_PACKAGE = os.path.join(UPGRADE_DIR, "package.tar")
TRIGGER_FILE = "/tmp/upgrade.trigger"
PATH_UNIT = "upgrade-trigger.path"
def trigger_upgrade():
"""后台任务:创建触发文件"""
os.makedirs("/tmp", exist_ok=True)
Path(TRIGGER_FILE).touch()
print(f"[Trigger] Created {TRIGGER_FILE}")
@app.post("/upgrade")
async def upgrade_system(
background_tasks: BackgroundTasks,
file: UploadFile = File(...)
):
if not file.filename.endswith('.tar'):
raise HTTPException(status_code=400, detail="仅支持 .tar 文件")
os.makedirs(UPGRADE_DIR, exist_ok=True)
with open(UPGRADE_PACKAGE, "wb") as f:
content = await file.read()
f.write(content)
if os.path.exists(TRIGGER_FILE):
os.remove(TRIGGER_FILE)
subprocess.run(["systemctl", "daemon-reload"], check=True)
subprocess.run(["systemctl", "enable", "--now", PATH_UNIT], check=True)
background_tasks.add_task(trigger_upgrade)
return {"status": "success", "message": "升级任务已提交,系统将在后台执行"}
关键说明:trigger_upgrade() 仅调用 Path(TRIGGER_FILE).touch(),不涉及任何耗时操作。该函数在 FastAPI 的事件循环中异步执行,即使 Web 服务随后退出,/tmp/upgrade.trigger 文件已存在。systemd 的 upgrade-trigger.path 会独立检测到该文件,并启动 upgrade-runner.service。此时升级进程是 systemd 的子进程,与 Python 进程无任何关联。
5. 为何父进程退出不影响升级?
进程关系图
systemd (PID 1)
├─ fastapi-app (PID 1234)
│ └─ (background task: touch /tmp/upgrade.trigger)
└─ upgrade-runner.service (PID 5678) ← 由 systemd 直接 fork
- FastAPI 进程(PID 1234)在
touch 完成后即可退出。
/tmp/upgrade.trigger 是一个普通文件,其存在与否与创建者进程无关。
- systemd 内核级 inotify 监听器持续监控该路径。
- 一旦文件存在,systemd 主动 fork 新进程执行
run-upgrade.sh(PID 5678),该进程直接由 PID 1 管理。
因此,无论 FastAPI 是否存活,升级都会继续。
补充:即使系统在 touch 后立即断电,只要文件写入磁盘(或 tmpfs 持久化),重启后 systemd 仍会触发升级(取决于文件系统是否保留 /tmp 内容)。对于关键场景,可将触发文件放在持久化目录(如 /var/lib/upgrade/trigger)。
6. 增强可靠性建议
6.1 使用持久化触发目录
[Path]
PathExists=/var/lib/upgrade/trigger
TRIGGER_FILE = "/var/lib/upgrade/trigger"
os.makedirs("/var/lib/upgrade", exist_ok=True)
6.2 添加升级状态查询接口
@app.get("/upgrade/status")
def get_upgrade_status():
if os.path.exists("/var/log/system-upgrade.log"):
with open("/var/log/system-upgrade.log") as f:
lines = f.readlines()[-10:]
return {"log": lines}
return {"log": []}
6.3 升级脚本增加锁机制
LOCK_FILE="/var/run/upgrade.lock"
if [ -f "$LOCK_FILE" ]; then
log "升级已在进行中,退出"
exit 0
fi
touch "$LOCK_FILE"
trap "rm -f $LOCK_FILE" EXIT
7. 总结
本文提供了一套完整的、生产可用的系统升级方案,其核心优势在于:
- 完全解耦:Web 接口仅负责'发信号',不参与执行;
- 进程隔离:升级由 systemd 管理,不受 Web 服务生命周期影响;
- 高可靠:基于 systemd 的原生监控机制,无需额外守护进程;
- 可扩展:易于集成 AB 切换、版本回滚、远程 OTA 等高级功能。
AB 升级详解:基于软链接与配置切换的双环境热升级方案
1. 什么是 AB 升级?
AB 升级(也称 A/B 分区升级、双副本升级)是一种高可靠系统更新策略:
- 系统维护 两个完全独立的运行环境:A 和 B。
- 当前系统运行在其中一个(如 A),升级时将新版本写入另一个(B)。
- 升级完成后,下次启动或服务重载时切换到 B。
- 若 B 启动失败,可自动或手动回退到 A。
虽然理想 AB 升级依赖双分区(如 Android 的 A/B OTA),但在无分区支持的通用 Linux 系统(如 Ubuntu、Debian、嵌入式设备)中,可通过目录隔离 + 软链接切换实现类似效果。
2. 适用场景
- 嵌入式 Linux 设备(树莓派、工控机、网关)
- 无法修改分区表的虚拟机或容器宿主机
- 需要'不停服'升级的后台服务(如 API 网关、数据采集器)
- 资源受限但要求高可用的边缘节点
3. 核心设计
3.1 目录结构
/opt/myapp/
├── active ────────────────> /opt/myapp/releases/v20251201
├── releases/
│ ├── v20251201/
│ │ ├── app.bin
│ │ ├── config.yaml
│ │ └── version.txt
│ └── v20260104/
│ ├── app.bin
│ ├── config.yaml
│ └── version.txt
├── config/
│ └── ab_state.conf
└── backup/
└── ab_state.conf.bak
3.2 状态管理
- 每次升级前,先备份该文件。
- 切换时,更新
active 字段并指向新版本。
- 若新版本启动失败,恢复备份文件并重启服务即可回滚。
active=A current_version=v20251201
注意:active=A/B 是逻辑标识,实际路径通过 current_version 映射到具体目录。
4. 升级流程(详细步骤)
- 接收新版本包
上传 myapp-v20260104.tar,包含 app.bin、config.yaml、version.txt。
- 解压到 releases 目录
解压为 /opt/myapp/releases/v20260104。
- 确定目标环境
- 若当前
active=A,则新版本部署到 B(即 v20260104)。
- 重载或重启主服务
主程序始终从 /opt/myapp/active/app.bin 启动,因此自动使用新版本。
- 验证与提交
- 若服务正常运行一段时间(如 5 分钟),视为升级成功。
- 可选:清理旧版本(保留最近两个)。
- 失败回滚(可选自动)
- 若服务崩溃或健康检查失败,恢复
ab_state.conf.bak。
- 切换软链接回旧版本。
- 重启服务。
ln -sfn /opt/myapp/releases/v20260104 /opt/myapp/active
active=B current_version=v20260104
5. 完整可执行示例
5.1 升级脚本:/opt/ab-upgrade.sh
#!/bin/bash
set -e
LOG="/var/log/ab-upgrade.log"
APP_ROOT="/opt/myapp"
RELEASES_DIR="$APP_ROOT/releases"
ACTIVE_LINK="$APP_ROOT/active"
STATE_FILE="$APP_ROOT/config/ab_state.conf"
BACKUP_STATE="$APP_ROOT/config/ab_state.conf.bak"
PACKAGE_PATH="$APP_ROOT/package.tar"
log() {
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" >> "$LOG"
}
log "=== AB 升级开始 ==="
LOCK="/var/run/ab-upgrade.lock"
if [ -f "$LOCK" ]; then
log "升级已在进行中,退出"
exit 1
fi
touch "$LOCK"
trap "rm -f $LOCK" EXIT
if [ -f "$STATE_FILE" ]; then
cp "$STATE_FILE" "$BACKUP_STATE"
CURRENT_ACTIVE=$(grep -oP 'active=\K\w+' "$STATE_FILE")
CURRENT_VERSION=$(grep -oP 'current_version=\K\w+' "$STATE_FILE")
else
log "状态文件不存在,初始化为 A"
CURRENT_ACTIVE="A"
CURRENT_VERSION="v0"
fi
log "当前环境:$CURRENT_ACTIVE ($CURRENT_VERSION)"
TMP_DIR=$(mktemp -d)
tar -xf "$PACKAGE_PATH" -C "$TMP_DIR"
VERSION_FILE="$TMP_DIR/version.txt"
if [ ! -f "$VERSION_FILE" ]; then
log "错误:升级包中缺少 version.txt"
rm -rf "$TMP_DIR"
exit 1
fi
NEW_VERSION=$(cat "$VERSION_FILE" | tr -d '[:space:]')
NEW_DIR="$RELEASES_DIR/$NEW_VERSION"
if [ ! -d "$NEW_DIR" ]; then
log "部署新版本:$NEW_VERSION"
mkdir -p "$RELEASES_DIR"
mv "$TMP_DIR" "$NEW_DIR"
else
log "版本 $NEW_VERSION 已存在,跳过解压"
rm -rf "$TMP_DIR"
fi
if [ "$CURRENT_ACTIVE" = "A" ]; then
NEXT_ACTIVE="B"
else
NEXT_ACTIVE="A"
fi
cat > "$STATE_FILE" <<EOF
active=$NEXT_ACTIVE
current_version=$NEW_VERSION
EOF
log "切换到环境 $NEXT_ACTIVE,版本 $NEW_VERSION"
ln -sfn "$NEW_DIR" "$ACTIVE_LINK"
systemctl daemon-reload
systemctl restart myapp.service
rm -f /tmp/upgrade.trigger
(cd "$RELEASES_DIR" && ls -1t | tail -n +4 | xargs -r rm -rf)
log "=== AB 升级完成 ==="
5.2 回滚脚本(可选):/opt/ab-rollback.sh
#!/bin/bash
LOG="/var/log/ab-rollback.log"
APP_ROOT="/opt/myapp"
ACTIVE_LINK="$APP_ROOT/active"
STATE_FILE="$APP_ROOT/config/ab_state.conf"
BACKUP_STATE="$APP_ROOT/config/ab_state.conf.bak"
log() {
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" >> "$LOG"
}
if [ ! -f "$BACKUP_STATE" ]; then
log "无备份状态,无法回滚"
exit 1
fi
cp "$BACKUP_STATE" "$STATE_FILE"
ACTIVE_ENV=$(grep -oP 'active=\K\w+' "$STATE_FILE")
VERSION=$(grep -oP 'current_version=\K\w+' "$STATE_FILE")
TARGET_DIR="/opt/myapp/releases/$VERSION"
if [ ! -d "$TARGET_DIR" ]; then
log "回滚目标目录不存在:$TARGET_DIR"
exit 1
fi
ln -sfn "$TARGET_DIR" "$ACTIVE_LINK"
systemctl restart myapp.service
log "已回滚到 $ACTIVE_ENV ($VERSION)"
5.3 systemd 单元(与前文一致)
ab-upgrade-trigger.path 监听 /tmp/upgrade.trigger
ab-upgrade-runner.service 执行 /opt/ab-upgrade.sh
5.4 Web 接口(FastAPI)保持不变
仅需确保上传的 .tar 包包含 version.txt,例如:
6. 关键技术点解析
6.1 原子性软链接切换
-f:强制覆盖已有链接
-n:不将 link_name 解析为目录(避免创建 link_name/target)
- 整个操作是原子的,应用在任何时刻读取
active 都指向一个完整版本
6.2 环境轮换逻辑
通过 active=A/B 实现逻辑轮换,而非硬编码路径。即使未来扩展为多版本(A/B/C),也可通过状态文件灵活管理。
6.3 与主服务解耦
主服务的 systemd 配置应始终指向 active:
[Service]
ExecStart=/opt/myapp/active/app.bin
7. 优势与局限
优势
- 无需分区支持,适用于任意 Linux 系统
- 切换瞬间完成,服务中断时间 ≈ 服务重启时间
- 回滚只需恢复配置 + 切换链接,秒级完成
- 升级过程可中断(断电后重启仍可继续或回滚)
局限
- 需要双倍存储空间(两个完整版本)
- 不适用于内核或底层驱动升级(需 reboot 且可能涉及 bootloader)
- 需要主程序支持从指定路径加载(不能硬编码路径)
8. 总结
本文提供的 AB 升级方案,通过状态文件 + 软链接 + 目录隔离,在通用 Linux 系统上实现了接近工业级的可靠升级能力。结合 systemd path 单元与 Web 接口,可构建出一套完全自动化、进程解耦、支持回滚的升级体系,特别适合边缘计算、IoT 设备和无人值守服务器。
相关免费在线工具
- curl 转代码
解析常见 curl 参数并生成 fetch、axios、PHP curl 或 Python requests 示例代码。 在线工具,curl 转代码在线工具,online
- Base64 字符串编码/解码
将字符串编码和解码为其 Base64 格式表示形式即可。 在线工具,Base64 字符串编码/解码在线工具,online
- Base64 文件转换器
将字符串、文件或图像转换为其 Base64 表示形式。 在线工具,Base64 文件转换器在线工具,online
- Markdown转HTML
将 Markdown(GFM)转为 HTML 片段,浏览器内 marked 解析;与 HTML转Markdown 互为补充。 在线工具,Markdown转HTML在线工具,online
- HTML转Markdown
将 HTML 片段转为 GitHub Flavored Markdown,支持标题、列表、链接、代码块与表格等;浏览器内处理,可链接预填。 在线工具,HTML转Markdown在线工具,online
- JSON 压缩
通过删除不必要的空白来缩小和压缩JSON。 在线工具,JSON 压缩在线工具,online