1. 背景与目标
在许多嵌入式或远程管理场景中,系统需支持通过 HTTP 接口(如 FastAPI、Flask)接收升级包并触发升级流程。但存在一个关键问题:
Web 服务进程(如 Python 进程)若在升级过程中被终止(例如因重启、崩溃或主动退出),如何确保升级任务不中断?
本文将介绍一种完全解耦的方案:
- 用户通过 HTTP 接口上传升级包;
- 后端仅负责校验、保存文件并'发出信号';
- 真正的升级逻辑由 systemd 管理,与 Web 进程无依赖关系;
一种基于 Systemd Path 单元实现可靠系统升级的方案。通过 HTTP 接口接收升级包,后端仅负责保存文件并创建触发标记,真正的升级逻辑由 systemd 独立管理,确保即使 Web 服务进程退出或重启,升级任务仍能继续执行。方案结合了原子性软链接切换的 A/B 双环境热升级策略,实现了零停机部署、快速回滚及高可靠性,适用于嵌入式设备、边缘计算节点等场景。
在许多嵌入式或远程管理场景中,系统需支持通过 HTTP 接口(如 FastAPI、Flask)接收升级包并触发升级流程。但存在一个关键问题:
Web 服务进程(如 Python 进程)若在升级过程中被终止(例如因重启、崩溃或主动退出),如何确保升级任务不中断?
本文将介绍一种完全解耦的方案:
核心机制:Systemd Path 单元 + 原子性触发文件 + 独立升级脚本。
| 原则 | 说明 |
|---|---|
| 触发与执行分离 | Web 接口只负责'写一个文件',不执行任何升级逻辑 |
| 升级进程独立于 Web 服务 | 升级由 systemd 启动的新进程执行,生命周期不受 Web 进程影响 |
| 原子性切换 | 使用软链接或配置切换,避免中间状态 |
| 幂等与可重试 | 触发文件可重复创建,升级脚本具备幂等性 |
| 日志可追溯 | 所有操作记录到文件和 journal,便于审计 |
[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 进程无关。
# /opt/run-upgrade.sh
#!/bin/bash
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"
log() {
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" >> "$LOG_FILE"
}
log "=== 升级流程开始 ==="
# 1. 检查升级包是否存在
if [ ! -f "$PACKAGE_PATH" ]; then
log "错误:升级包不存在 ($PACKAGE_PATH)"
exit 1
fi
# 2. 创建新版本目录(假设包内有 version.txt)
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
VERSION=$(cat "$VERSION_FILE" | tr -d '[:space:]')
TARGET_DIR="$RELEASES_DIR/$VERSION"
# 3. 若版本已存在,跳过部署(幂等)
if [ -d "$TARGET_DIR" ]; then
log "版本 $VERSION 已存在,跳过解压"
else
log "部署新版本:$VERSION"
mkdir -p "$RELEASES_DIR"
mv "$TMP_DIR" "$TARGET_DIR"
fi
# 4. 切换软链接(原子操作)
log "切换 active 指向 $VERSION"
ln -sfn "$TARGET_DIR" "$ACTIVE_LINK"
# 5. 重载 systemd 并重启主服务
systemctl daemon-reload
systemctl restart myapp.service
# 6. 清理触发文件(避免重复触发)
rm -f /tmp/upgrade.trigger
# 7. 可选:清理旧版本(保留最近两个)
(ls -1t "$RELEASES_DIR" | tail -n +3) | while read oldver; do
[ -n "$oldver" ] && rm -rf "$RELEASES_DIR/$oldver"
done
log "=== 升级流程完成 ==="
赋予执行权限:
chmod +x /opt/run-upgrade.sh
服务单元 /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。
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(...)
):
# 1. 校验文件类型
if not file.filename.endswith('.tar'):
raise HTTPException(status_code=400, detail="仅支持 .tar 文件")
# 2. 创建升级目录
os.makedirs(UPGRADE_DIR, exist_ok=True)
# 3. 保存文件
with open(UPGRADE_PACKAGE, "wb") as f:
content = await file.read()
f.write(content)
# 4. 清理旧触发文件(避免误触发)
if os.path.exists(TRIGGER_FILE):
os.remove(TRIGGER_FILE)
# 5. 重载并启用 path 单元(确保监听器就绪)
subprocess.run(["systemctl", "daemon-reload"], check=True)
subprocess.run(["systemctl", "enable", "--now", PATH_UNIT], check=True)
# 6. 提交后台任务:仅创建触发文件(不执行升级!)
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 进程无任何关联。
systemd (PID 1)
├─ fastapi-app (PID 1234)
│ └─ (background task: touch /tmp/upgrade.trigger)
└─ upgrade-runner.service (PID 5678) ← 由 systemd 直接 fork
touch 完成后即可退出。/tmp/upgrade.trigger 是一个普通文件,其存在与否与创建者进程无关。run-upgrade.sh(PID 5678),该进程直接由 PID 1 管理。因此,无论 FastAPI 是否存活,升级都会继续。
补充:即使系统在
touch后立即断电,只要文件写入磁盘(或 tmpfs 持久化),重启后 systemd 仍会触发升级(取决于文件系统是否保留/tmp内容)。对于关键场景,可将触发文件放在持久化目录(如/var/lib/upgrade/trigger)。
将触发文件改为:
[Path]
PathExists=/var/lib/upgrade/trigger
并在脚本中:
TRIGGER_FILE = "/var/lib/upgrade/trigger"
os.makedirs("/var/lib/upgrade", exist_ok=True)
避免 /tmp 在某些系统重启后清空的问题。
@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:] # 返回最后 10 行
return {"log": lines}
return {"log": []}
在 run-upgrade.sh 开头加入:
LOCK_FILE="/var/run/upgrade.lock"
if [ -f "$LOCK_FILE" ]; then
log "升级已在进行中,退出"
exit 0
fi
touch "$LOCK_FILE"
trap "rm -f $LOCK_FILE" EXIT
防止并发触发。
本文提供了一套完整的、生产可用的系统升级方案,其核心优势在于:
AB 升级(也称 A/B 分区升级、双副本升级)是一种高可靠系统更新策略:
目标:零停机部署、快速回滚、升级过程可中断且安全
虽然理想 AB 升级依赖双分区(如 Android 的 A/B OTA),但在无分区支持的通用 Linux 系统(如 Ubuntu、Debian、嵌入式设备)中,可通过目录隔离 + 软链接切换实现类似效果。
/opt/myapp/
├── active ────────────────> /opt/myapp/releases/v20251201 # 当前生效版本(软链接)
├── releases/
│ ├── v20251201/ # A 环境(当前运行)
│ │ ├── app.bin
│ │ ├── config.yaml
│ │ └── version.txt
│ └── v20260104/ # B 环境(待切换)
│ ├── app.bin
│ ├── config.yaml
│ └── version.txt
├── config/
│ └── ab_state.conf # 记录当前激活环境(active=A 或 active=B)
└── backup/
└── ab_state.conf.bak # 上一次状态备份(用于回滚)
active 字段并指向新版本。ab_state.conf 内容示例:
active=A current_version=v20251201
注意:
active=A/B是逻辑标识,实际路径通过current_version映射到具体目录。
myapp-v20260104.tar,包含 app.bin、config.yaml、version.txt。/opt/myapp/releases/v20260104。active=A,则新版本部署到 B(即 v20260104)。/opt/myapp/active/app.bin 启动,因此自动使用新版本。ab_state.conf.bak。原子切换软链接
执行:
ln -sfn /opt/myapp/releases/v20260104 /opt/myapp/active
更新 ab_state.conf:
active=B current_version=v20260104
/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
# 确定目标环境(A/B 轮换)
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
# 清理触发文件(若使用 systemd path 触发)
rm -f /tmp/upgrade.trigger
# 清理旧版本(保留最近 3 个)
(cd "$RELEASES_DIR" && ls -1t | tail -n +4 | xargs -r rm -rf)
log "=== AB 升级完成 ==="
/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)"
ab-upgrade-trigger.path 监听 /tmp/upgrade.triggerab-upgrade-runner.service 执行 /opt/ab-upgrade.sh仅需确保上传的 .tar 包包含 version.txt,例如:
# version.txt
v20260104
ln -sfn target link_name
-f:强制覆盖已有链接-n:不将 link_name 解析为目录(避免创建 link_name/target)active 都指向一个完整版本通过 active=A/B 实现逻辑轮换,而非硬编码路径。即使未来扩展为多版本(A/B/C),也可通过状态文件灵活管理。
主服务的 systemd 配置应始终指向 active:
# /etc/systemd/system/myapp.service
[Service]
ExecStart=/opt/myapp/active/app.bin
这样无需修改 service 文件即可切换版本。
本文提供的 AB 升级方案,通过状态文件 + 软链接 + 目录隔离,在通用 Linux 系统上实现了接近工业级的可靠升级能力。结合 systemd path 单元与 Web 接口,可构建出一套完全自动化、进程解耦、支持回滚的升级体系,特别适合边缘计算、IoT 设备和无人值守服务器。

微信公众号「极客日志」,在微信中扫描左侧二维码关注。展示文案:极客日志 zeeklog
解析常见 curl 参数并生成 fetch、axios、PHP curl 或 Python requests 示例代码。 在线工具,curl 转代码在线工具,online
将字符串编码和解码为其 Base64 格式表示形式即可。 在线工具,Base64 字符串编码/解码在线工具,online
将字符串、文件或图像转换为其 Base64 表示形式。 在线工具,Base64 文件转换器在线工具,online
将 Markdown(GFM)转为 HTML 片段,浏览器内 marked 解析;与 HTML 转 Markdown 互为补充。 在线工具,Markdown 转 HTML在线工具,online
将 HTML 片段转为 GitHub Flavored Markdown,支持标题、列表、链接、代码块与表格等;浏览器内处理,可链接预填。 在线工具,HTML 转 Markdown在线工具,online
通过删除不必要的空白来缩小和压缩JSON。 在线工具,JSON 压缩在线工具,online