355 lines
17 KiB
Markdown
355 lines
17 KiB
Markdown
# 部署引擎设计
|
|
|
|
> *部署不是执行命令。*
|
|
>
|
|
> *部署是让一个未知环境变得可预测的过程。*
|
|
>
|
|
> *不理解环境的部署脚本,只能算是穿着风衣的命令。*
|
|
|
|
---
|
|
|
|
## 为什么需要部署引擎
|
|
|
|
`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. 条件执行
|
|
|
|
```bash
|
|
# 只在容器存在时执行删除
|
|
if docker ps -a | grep -q frpc-console; then
|
|
docker stop frpc-console
|
|
docker rm frpc-console
|
|
fi
|
|
```
|
|
|
|
### 2. 状态检测前置
|
|
|
|
```bash
|
|
# 检测到用户配置全部可用时跳过修改
|
|
if [ "$MIRROR_STATUS" = "has_valid_ordered" ]; then
|
|
WRITE_DAEMON=false
|
|
RESTART_DOCKER=false
|
|
fi
|
|
```
|
|
|
|
### 3. 版本标记
|
|
|
|
```bash
|
|
# 每次构建使用唯一标签,不覆盖已有镜像
|
|
IMAGE_TAG="${SEMVER}-lts-$(date +%Y%m%d)"
|
|
```
|
|
|
|
### 4. 增量修改而非覆盖
|
|
|
|
镜像源配置采用“插入到最前面”而非“替换整个文件”的策略,保留用户原有配置。
|
|
|
|
幂等性的意义在于:
|
|
|
|
> **用户可以在任何时候重新运行 `deploy.sh`,而不必担心系统被破坏。**
|
|
|
|
---
|
|
|
|
## 事务完整性
|
|
|
|
`deploy.sh` 采用类似数据库事务的设计原则:
|
|
|
|
### 1. 变更前备份
|
|
|
|
降级操作会触发数据库备份:
|
|
|
|
```bash
|
|
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` 前也会备份原文件:
|
|
|
|
```bash
|
|
cp "$DAEMON_JSON" "${DAEMON_JSON}.bak.$(date +%Y%m%d-%H%M%S)"
|
|
```
|
|
|
|
### 2. 变更后验证
|
|
|
|
每次写入后都验证目标状态是否达成:
|
|
|
|
- 写入镜像源后验证 JSON 有效性
|
|
- 启动容器后验证容器状态
|
|
- 写入版本后验证版本一致性
|
|
- 启动后验证 frpc 进程是否存在
|
|
|
|
### 3. 失败不继续
|
|
|
|
任何关键步骤失败都会中断部署,不会进入下一阶段:
|
|
|
|
```bash
|
|
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.md` 与 `engineering-philosophy.md` 的关系是:
|
|
|
|
| 文档 | 回答的问题 |
|
|
|:---|:---|
|
|
| `engineering-philosophy.md` | 为什么这样设计? |
|
|
| `deployment-engine-design.md` | 这些设计如何落地? |
|
|
| `deploy.sh` | 落地后的最终产物 |
|
|
|
|
三者形成一条完整的链路:
|
|
|
|
```
|
|
哲学 → 架构 → 部署引擎 → 代码
|
|
```
|
|
|
|
哲学文档定义“为什么”,部署引擎文档定义“如何实现”,`deploy.sh` 是“实现的最终形态”。
|
|
|
|
---
|
|
|
|
## 结语
|
|
|
|
> `deploy.sh` 不是安装脚本。
|
|
>
|
|
> 它是 `frpc-console` 的部署引擎。
|
|
>
|
|
> 它负责:环境探测、状态分析、部署规划、用户确认、执行动作、结果验证、环境清理。
|
|
>
|
|
> 它的设计目标是:**在尽量不打扰用户已有环境的前提下,把部署成功率提高到接近 100%。**
|
|
|
|
这份文档记录了它为什么长成这样。 |