Files
frpc-console/Docs/deployment-engine-design.md

17 KiB

部署引擎设计

部署不是执行命令。

部署是让一个未知环境变得可预测的过程。

不理解环境的部署脚本,只能算是穿着风衣的命令。


为什么需要部署引擎

deploy.sh 不是安装脚本。它是部署引擎。

安装脚本解决的是“怎么把文件放到正确的位置”。部署引擎解决的是“如何让一个未知环境进入可运行状态”。

用户的机器从来都不是一致的。在实际部署中,可能遇到:

  • Docker 未安装
  • Docker 已安装但未启动
  • daemon.json 不存在
  • daemon.json 存在但为空
  • daemon.json 存在但格式非法
  • registry-mirrors 不存在
  • registry-mirrors 存在但全部不可用
  • registry-mirrors 存在但部分不可用
  • jq 未安装
  • Git 未安装
  • frpc-console 容器已存在
  • frpc-console 容器正在运行
  • 当前版本高于目标版本(降级)
  • 当前版本低于目标版本(升级)
  • LTS 与 Preview 通道混用

因此:部署不是执行命令。部署首先是环境诊断。

deploy.sh 的设计目标:

  1. 探测 —— 了解当前环境的状态
  2. 分析 —— 判断当前状态与目标状态之间的差距
  3. 规划 —— 生成最小必要变更的执行计划
  4. 确认 —— 让用户在执行前理解即将发生的变化
  5. 执行 —— 按照计划执行变更
  6. 验证 —— 确认变更生效且系统正常
  7. 清理 —— 移除临时产物,保持系统整洁

部署生命周期

deploy.sh 的执行流程可以用以下状态流转图表示:

┌─────────────────────────────────────────────────────────────────┐
│                     Environment Detection                      │
│  OS / ARCH / Git / Curl / Wget / Docker / jq / Container       │
└─────────────────────────────────────────────────────────────────┘
                              │
                              ▼
┌─────────────────────────────────────────────────────────────────┐
│                       State Analysis                           │
│  镜像源状态 / 容器状态 / 版本状态 / 通道状态                    │
└─────────────────────────────────────────────────────────────────┘
                              │
                              ▼
┌─────────────────────────────────────────────────────────────────┐
│                     Deployment Planning                        │
│  生成变更清单:镜像源配置 / 工具安装 / 代码拉取 / 镜像构建     │
└─────────────────────────────────────────────────────────────────┘
                              │
                              ▼
┌─────────────────────────────────────────────────────────────────┐
│                      User Confirmation                         │
│  展示计划 → 等待确认 → 允许自定义配置                          │
└─────────────────────────────────────────────────────────────────┘
                              │
                              ▼
┌─────────────────────────────────────────────────────────────────┐
│                         Execution                              │
│  备份(降级时)→ 安装工具 → 拉取代码 → 配置镜像源 → 构建镜像   │
│  → 清理旧容器 → 启动新容器 → 写入版本                          │
└─────────────────────────────────────────────────────────────────┘
                              │
                              ▼
┌─────────────────────────────────────────────────────────────────┐
│                        Verification                            │
│  镜像内容 / 容器状态 / 数据库可用性 / 版本一致性 / frpc PID     │
└─────────────────────────────────────────────────────────────────┘
                              │
                              ▼
┌─────────────────────────────────────────────────────────────────┐
│                          Cleanup                               │
│  清理旧镜像(保留最近 N 个)/ 清理临时文件                      │
└─────────────────────────────────────────────────────────────────┘

每个阶段都是独立的、可观测的、可失败的。任何一个阶段失败,系统都不会进入下一个阶段。


状态机设计

deploy.sh 的核心不是“执行命令”,而是“状态转换”。

以 Docker 镜像源检测为例:

┌─────────────────────────────────────────────────────────────────┐
│                      初始状态                                    │
│                daemon.json 存在性未知                            │
└─────────────────────────────────────────────────────────────────┘
                              │
                              ▼
┌─────────────────────────────────────────────────────────────────┐
│                     daemon.json 不存在                          │
│                         状态: no_file                           │
│                         动作: 创建并写入                        │
└─────────────────────────────────────────────────────────────────┘
                              │
                              ▼
┌─────────────────────────────────────────────────────────────────┐
│                  daemon.json 存在但格式非法                     │
│                     状态: (检测到非法)                          │
│                  动作: 备份 → 重建为 {}                         │
└─────────────────────────────────────────────────────────────────┘
                              │
                              ▼
┌─────────────────────────────────────────────────────────────────┐
│               daemon.json 存在但无 registry-mirrors             │
│                        状态: no_key                             │
│                    动作: 写入可用镜像源                         │
└─────────────────────────────────────────────────────────────────┘
                              │
                              ▼
┌─────────────────────────────────────────────────────────────────┐
│           registry-mirrors 存在且全部可用                       │
│                   状态: has_valid_ordered                       │
│                      动作: 跳过(无变更)                       │
└─────────────────────────────────────────────────────────────────┘
                              │
                              ▼
┌─────────────────────────────────────────────────────────────────┐
│        registry-mirrors 存在但可用源排在不可用源之后            │
│               状态: has_valid_reorder_needed                    │
│                   动作: 重排(可用源前置)                      │
└─────────────────────────────────────────────────────────────────┘
                              │
                              ▼
┌─────────────────────────────────────────────────────────────────┐
│              registry-mirrors 全部不可用                        │
│                     状态: all_invalid                           │
│              动作: 插入可用默认源(如存在)                     │
└─────────────────────────────────────────────────────────────────┘
                              │
                              ▼
┌─────────────────────────────────────────────────────────────────┐
│                   默认镜像源全部不可用                          │
│                 状态: default_unavailable                       │
│              动作: 跳过(提示用户手动配置)                     │
└─────────────────────────────────────────────────────────────────┘

这个状态机的核心原则是:

只在需要时修改。只在确认后执行。每次修改都是可逆的或有备份的。

所有状态都通过 jq 检测,所有修改都通过 jq 执行,所有原始配置都被备份。


幂等设计

deploy.sh 是幂等的。多次执行的结果与一次执行相同。

幂等性通过以下方式保证:

1. 条件执行

# 只在容器存在时执行删除
if docker ps -a | grep -q frpc-console; then
    docker stop frpc-console
    docker rm frpc-console
fi

2. 状态检测前置

# 检测到用户配置全部可用时跳过修改
if [ "$MIRROR_STATUS" = "has_valid_ordered" ]; then
    WRITE_DAEMON=false
    RESTART_DOCKER=false
fi

3. 版本标记

# 每次构建使用唯一标签,不覆盖已有镜像
IMAGE_TAG="${SEMVER}-lts-$(date +%Y%m%d)"

4. 增量修改而非覆盖

镜像源配置采用“插入到最前面”而非“替换整个文件”的策略,保留用户原有配置。

幂等性的意义在于:

用户可以在任何时候重新运行 deploy.sh,而不必担心系统被破坏。


事务完整性

deploy.sh 采用类似数据库事务的设计原则:

1. 变更前备份

降级操作会触发数据库备份:

if [ "$TGT_SEMVER" \< "$CUR_SEMVER" ]; then
    BACKUP_DIR="/opt/frpc-console-backups/${TIMESTAMP}_${CHANNEL}"
    cp -r "$DEPLOY_DIR" "$BACKUP_DIR/frpc-console"
fi

修改 daemon.json 前也会备份原文件:

cp "$DAEMON_JSON" "${DAEMON_JSON}.bak.$(date +%Y%m%d-%H%M%S)"

2. 变更后验证

每次写入后都验证目标状态是否达成:

  • 写入镜像源后验证 JSON 有效性
  • 启动容器后验证容器状态
  • 写入版本后验证版本一致性
  • 启动后验证 frpc 进程是否存在

3. 失败不继续

任何关键步骤失败都会中断部署,不会进入下一阶段:

if [ $? -ne 0 ]; then
    print_error "Docker 构建失败"
    exit 1
fi

验证不是可选项

大多数安装脚本在“安装完成”后即结束。deploy.sh 在“安装完成”后才开始验证。

验证链:

构建完成
    │
    ▼
验证镜像内容 ──→ 失败则退出
    │
    ▼
启动容器
    │
    ▼
验证容器状态 ──→ 失败则退出
    │
    ▼
验证数据库可用性
    │
    ▼
验证版本一致性 ──→ 警告但不退出
    │
    ▼
验证 frpc 子进程 ──→ 警告但不退出

验证的目的是区分“部署完成”和“部署成功”。

  • 部署完成:脚本执行完毕
  • 部署成功:所有组件正常运行

deploy.sh 提供明确的信号来区分这两种状态。


边界条件处理

边界条件是部署脚本最大的不确定性来源。deploy.sh 在处理边界时遵循一个原则:先假设它可能出问题,再设计应对方式。

以下边界条件已被识别并处理:

已处理的边界

  • daemon.json 不存在 → 创建
  • daemon.json 为空 → 写入 {}
  • daemon.json 格式非法 → 备份并重建
  • jq 未安装 → 尝试安装,失败则降级处理
  • registry-mirrors 不存在 → 写入可用源
  • registry-mirrors 全部不可用 → 插入可用默认源
  • 用户镜像源全部失效 → 插入可用默认源到最前面
  • 默认镜像源全部不可用 → 跳过配置,提示用户手动处理
  • 旧版本文件在根目录 → 自动迁移到 data/
  • 容器已存在 → 停止并删除
  • 容器正在运行 → 优雅停止
  • 降级操作 → 自动备份,用户确认
  • LTS ↔ Preview 通道切换 → 备份数据库,用户确认
  • 旧镜像积累 → 保留最近 N 个,自动清理

处理原则

每新增一个边界条件,都是在扩展一个同一个决策表:当 环境变量 X 处于 状态 Y 时,引擎应该执行 动作 Z。这些条目不相互覆盖,而是叠加在同一个状态机之上,彼此通过前置条件互锁。

这种方式的优点是:边界条件越多,系统对环境的适应能力越强,但状态机的核心结构不需要跟着膨胀。


与工程设计哲学的关系

deployment-engine-design.mdengineering-philosophy.md 的关系是:

文档 回答的问题
engineering-philosophy.md 为什么这样设计?
deployment-engine-design.md 这些设计如何落地?
deploy.sh 落地后的最终产物

三者形成一条完整的链路:

哲学 → 架构 → 部署引擎 → 代码

哲学文档定义“为什么”,部署引擎文档定义“如何实现”,deploy.sh 是“实现的最终形态”。


结语

deploy.sh 不是安装脚本。

它是 frpc-console 的部署引擎。

它负责:环境探测、状态分析、部署规划、用户确认、执行动作、结果验证、环境清理。

它的设计目标是:在尽量不打扰用户已有环境的前提下,把部署成功率提高到接近 100%。

这份文档记录了它为什么长成这样。