给博客或小型服务做部署时,最容易出问题的环节往往不是构建,而是 SSH 登录服务器后手动拉代码、运行构建命令,再把某个目录切成线上版本。命令执行到一半、拉到的分支发生变化,或者新版本刚切过去就启动失败,都会让线上目录处在一个不容易判断的状态。

aynur-deploy v0.2.0 做的是一条更窄的部署链路:接收 Gitee Tag Push WebHook,把任务写入 SQLite 队列,取 Tag 对应的精确提交构建发布目录,再用项目配置中的 currentPath 软链完成切换。新版本的健康检查失败时,它会把软链切回上一版。

WebHook 先过几道校验

服务为每个项目提供一个 WebHook 入口。收到请求后,它会检查 X-Gitee-Token,再确认事件是 tag_push_hooks,仓库全名与项目配置一致,ref 确实是 Tag,且 Tag 名称匹配配置中的正则表达式。Tag 的 after 必须是 40 位小写十六进制 Git 对象 ID。

删除 Tag、没有创建 Tag,或者 Tag 名称不符合规则的事件会被明确忽略。真正进入队列前,服务还会用 Git 获取这个 Tag,并确认获取到的 Tag 对象与 WebHook 里的 after 完全一致,然后再解析它指向的 commit。这里没有“部署默认分支最新提交”的路径,任务绑定的是这次事件携带的对象。

同一个项目、同一个 Tag 引用和同一个 after 会生成相同的事件键。SQLite 对事件键做唯一约束,因此 Gitee 重复投递时不会创建第二个部署任务。第一次接收返回 202 Accepted,重复事件返回 200 OK,响应中会标明 duplicate

队列按顺序执行

WebHook 处理器只负责校验和入队,真正的部署由后台 worker 执行。任务状态会持久化为 queuedfetchingbuildingactivatinghealthCheckingrollingBack,worker 每次按创建时间取最早的一条待处理任务。

服务可以管理多个项目,但同一时刻只处理一个待处理任务。进程重启后,数据库里仍处于中间状态的任务会被重新检查。比如已经完成软链切换、但还没有写入成功状态的任务,服务会先判断 current 指向哪里,再决定继续健康检查、标记中断失败,还是执行回滚。

先准备 release,再切换 current

部署任务会在状态目录中维护一个裸 Git 镜像。获取 Tag 时使用精确的 Tag ref,不拉取其他 Tag;随后为解析出的 commit 创建 detached worktree。构建产物先写入临时 release 目录,验证通过后再以 commit SHA 命名,例如 releases/<commit-sha>

静态发布会复制 worktree 中的文件,并检查配置的入口文件存在。复制完成后,发布目录不包含 .git。已经编译好的程序可以用 binary 类型,配置一个仓库内的相对路径;服务会检查它是可执行的普通文件,再复制到 release 目录。

Rust 项目可以用 rust 类型。它执行固定的 Cargo 参数:build --release --locked,再指定 --manifest-path--package--bin,构建结果复制到 release 目录,临时的 Cargo target 目录随后清理。构建过程不会通过 Shell 拼接命令。

release 目录准备好以后,服务先创建一个临时软链,再把它重命名为 current,最后同步父目录。这个切换动作不需要覆盖线上目录里的文件,读请求只会看到旧 release 或新 release。数据库同时记录本次 release 和切换前的上一版路径,后续回滚靠的就是这条记录。

三种部署类型各有边界

static 适合 Zola、React/Vite 这类已经产出最终静态目录的项目。它只负责发布现有文件,不运行 Node 构建脚本;构建应在其他机器或 CI 中完成。

binary 适合仓库里已经有预构建可执行文件的服务。它只复制配置指定的文件,并保留可执行权限。rust 则把固定的 Rust 构建流程放在部署机上执行,适合已经明确 Cargo package 和 binary 名称的项目。

这种区分让配置直接表达部署输入是什么。静态文件、预构建二进制和源码构建需要的依赖不同,混在一个“自动猜测项目类型”的流程里,失败时很难知道究竟是哪一层出了问题。

reload 命令不经过 Shell

发布切换后,如果服务需要重新加载进程,可以配置可选的 [reload] command

[reload]
command = ["aynur", "reload", "my-service", "--update-env"]

命令数组的第一个元素是程序,后面是固定参数。aynur-deploy 直接按 argv 启动它,不经过 Shell,因此不会把配置内容当作 Shell 脚本解释。Aynur、PM2 或其他固定命令行接口的进程管理器都可以接入,前提是命令本身和参数已经在项目配置中明确写好。

reload 发生在健康检查之前。如果 reload 启动失败,服务会按同一套回滚流程处理,不会把一个已经切换、但进程没有成功加载的版本当成成功部署。

健康检查失败就回到上一版

切换 current 并执行 reload 后,服务请求配置中的 healthCheck.url。响应状态为 2xx 时视为通过,其他状态、请求错误或超时都会记录为一次失败。attempts 控制重试次数,intervalMs 控制两次尝试之间的间隔,timeoutMs 控制单次请求的超时。

所有尝试都失败时,部署进入 rollingBack。如果存在上一版 release,服务会把 current 切回去,再执行一次 reload 和健康检查。回滚成功后,原部署记录为 failed,错误信息会保留原始失败原因;回滚后的健康检查也失败,或者没有上一版可用时,部署会进入 rollbackFailed,项目会被阻塞,等待人工执行 unblock

这条路径解决的是“新版本已经切过去,但它是否真的能提供服务”这个问题。只检查构建命令的退出码不够,HTTP 健康检查把进程重载、配置加载和线上入口一起纳入了发布结果。

最小配置

先安装已经发布的 crate:

cargo install aynur-deploy --locked

内存较小的机器可以把 Cargo 并行度设为 1:

CARGO_BUILD_JOBS=1 cargo install aynur-deploy --locked

使用 aynur-deploy init 初始化配置,再用 aynur-deploy add <project-id> 生成项目配置和随机 WebHook 密码。新服务器可以使用状态目录下的默认发布路径;已有服务器需要保留原来的 Nginx root 时,通过 --current-path 指定现有项目路径:

aynur-deploy init
aynur-deploy add my-static-site --current-path /var/www/my-static-site

currentPath 必须不存在或已经是软链,服务不会覆盖普通文件和目录。项目配置完成后,可以用 aynur-deploy list 查看项目 ID、仓库全名和发布路径。下面是一个静态项目的最小项目配置示例,WebHook 密码只放占位值,不要把真实密码写进公开文档:

projectId = "my-static-site"
currentPath = "/var/www/my-static-site"
repositoryFullName = "owner/repository"
repositoryUrl = "https://gitee.com/owner/repository.git"
webhookToken = "replace-with-the-token-generated-by-add"
tagPattern = "^deploy-[0-9]{8}-[0-9]{6}$"
retainReleases = 3

[healthCheck]
url = "https://your-service.example/healthz"
attempts = 5
intervalMs = 2000
timeoutMs = 5000

[deployment]
type = "static"
entryFile = "index.html"

项目 TOML 中的路径和部署字段会被严格校验。entryFilebinaryPathcargoManifest 必须是安全的相对路径,Rust 部署的 packagebinary 也必须分别填写。Tag 正则必须带有首尾锚点,这样配置不会意外接受更宽的 Tag 范围。

项目地址:github.com/menzil/aynur-deploy,安装包:aynur-deploy on crates.io