以前发布这个博客,我需要登录服务器,拉取生成站点的代码,确认分支和提交,再把新目录放到线上,最后打开网页检查。每一步都不复杂,但它们必须按顺序完成。中间任何一步出错,都要重新判断服务器当前到底是哪一版。

现在博客源码和生成站点分别放在两个仓库里。源码构建完成后,我只需要把生成站点推送到 Gitee,再创建一个符合规则的 Tag。aynur-deploy 接收 Tag Push WebHook,取得这次 Tag 对应的精确提交,准备新的 release,切换 currentPath,然后执行健康检查。正常发布不需要再登录服务器。

它解决的不是“有没有办法执行部署命令”,而是把一次发布中容易重复、容易遗漏的动作固定下来。真正省下来的时间,来自少做几次确认、少开几个终端、少处理几次半完成状态。

我们实际发布的流程

这个博客的源码仓库和生成站点仓库分开保存。前者包含 Markdown、Zola 配置和主题,后者只包含可以被 Nginx 直接提供的静态文件。aynur-deploy 只处理后者,它不负责猜测怎么构建博客,也不在服务器上运行 Node 或 Zola 命令。

一次正常发布现在只有三步:

  1. 在本地构建博客,确认生成站点内容正确。
  2. 把生成站点的提交推送到 Gitee。
  3. 在这个提交上创建并推送一个发布 Tag。
releaseTag="deploy-$(date +%Y%m%d-%H%M%S)"
git tag "$releaseTag"
git push origin "refs/tags/$releaseTag"

Tag 名称必须匹配项目配置中的正则表达式。Gitee 发送 WebHook 后,aynur-deploy 会校验项目、仓库、Tag 和提交对象,然后把任务放进队列。后面的获取代码、准备 release、切换线上目录和健康检查都由服务完成。

每次发布省下了什么

不用为每次发布登录服务器

以前的发布动作都围绕 SSH 展开:登录、进入目录、拉代码、构建、替换目录、检查页面。现在 SSH 主要用于第一次安装和故障处理,正常发布由 WebHook 触发。

这减少了重复输入命令,也减少了“这次到底登录了哪台机器、在哪个目录执行”的确认。多个项目共用一个 aynur-deploy 实例时,WebHook URL 只通过 projectId 区分项目,不需要为每个仓库单独维护一套服务。

不再依赖默认分支的最新代码

WebHook 中携带的是这次 Tag 的对象 ID。服务会用 Git 获取精确的 Tag,并确认它和 WebHook 中的 after 完全一致,再解析 Tag 指向的 commit。部署任务因此绑定到一个确定的提交,不会因为默认分支在排队期间继续变化而发布了另一份代码。

重复投递也不会变成重复部署。同一个项目、Tag 引用和提交对象会生成相同的事件键,SQLite 的唯一约束会拒绝重复任务。Gitee 重试请求时,服务可以返回已有部署的信息,而不是再次拉取和切换线上目录。

不再覆盖正在提供服务的目录

新的 release 会先写入以 commit SHA 命名的目录。静态文件复制完成、入口文件检查通过后,服务才创建新的 current 软链,并用一次重命名完成切换。

线上路径始终指向某个完整的 release。发布过程中不会把旧目录清空后再逐个复制新文件,所以不会留下新旧文件混杂的中间状态。部署服务还会记录切换前的 release,回滚时不需要重新从 Git 猜测应该恢复哪一版。

把“发布成功”从命令退出码扩展到真实请求

部署命令成功退出,只能说明代码已经拉取或构建完成,不能说明应用已经能响应请求。切换 release 后,aynur-deploy 可以按配置执行固定的 reload 命令,再访问 healthCheck.url。

健康检查只把 2xx 响应视为成功。请求失败、超时、返回其他状态码都会按配置重试。所有尝试都失败时,服务会把 current 切回上一版,再执行 reload 和健康检查。

回滚本身也会被检查。如果上一版恢复正常,原部署会被标记为失败,但线上服务回到可用状态;如果回滚也失败,项目会进入阻塞状态,需要处理原因后显式执行 unblock。这比发布结束后才发现网站打不开,更早地把问题停在部署流程里。

WebHook 不只是一个触发器

服务收到请求后会依次检查以下内容:

  • X-Gitee-Token 是否匹配项目自己的 WebHook 密码。
  • 事件是否为 tag_push_hooks,并且确实是创建 Tag。
  • Gitee 仓库全名是否和项目配置一致。
  • ref 是否是 Tag,Tag 名称是否匹配正则表达式。
  • after 是否是合法的 Git 对象 ID,并且和远程取得的 Tag 对象一致。

删除 Tag、没有创建 Tag、Tag 不符合命名规则的事件不会进入部署队列。处理器只做校验和入队,真正的部署由后台 worker 串行执行。任务状态会持久化为 queued、fetching、building、activating、healthChecking 和 rollingBack 等阶段,进程重启后仍然可以根据数据库和 current 的实际指向恢复判断。

队列按创建时间处理,同一时刻只执行一个部署任务。这样做牺牲了一点并行速度,但换来了简单的线上状态:同一个项目不会同时切换多个 release,也不会让多个构建过程争抢同一组发布目录。

三种输入,三种部署方式

aynur-deploy 不会根据文件名猜测项目该怎么部署。配置直接说明输入是什么,失败时也更容易定位:

类型输入适合场景
static仓库中已经生成好的静态文件Zola、React/Vite 等静态站点
binary仓库中的预构建可执行文件已在构建机或 CI 产出二进制的服务
rustRust 源码、Cargo 参数和目标二进制确实需要在部署机编译的 Rust 项目

这个博客使用 static。构建发生在本地或其他构建环境,部署机只负责取得 Tag 对应的文件并发布。对于二进制服务,可以使用 binary 复制指定文件;对于 Rust 项目,rust 类型会使用固定的 cargo build --release --locked 参数,构建结果和配置的附加文件一起放进 release。

生产机不应该把安装 aynur-deploy 自身和每次业务发布混在一起。aynur-deploy 可以在已有 Rust 环境的构建机上通过 Cargo 安装,生产机只需要安装已经在兼容 Linux 环境编译好的服务二进制。业务代码是否在部署机编译,则由项目的部署类型决定。

配置一次,之后只推 Tag

第一次安装时,先初始化全局配置,再为博客创建项目配置:

aynur-deploy init
aynur-deploy add orhan-blog --current-path /var/www/blog_public
aynur-deploy check

add 会生成项目配置和随机 WebHook 密码。currentPath 指向 Nginx 原来使用的路径,因此接入时不需要修改网站的根目录。已有目录会被保留为 bootstrap 路径,服务随后在原路径创建软链。配置和密码文件应保持严格权限,真实密码不要写入公开文章或仓库。

博客项目的核心配置可以写成这样:

projectId = "orhan-blog"
currentPath = "/var/www/blog_public"
repositoryFullName = "aynurcn/blog_public"
repositoryUrl = "git@gitee.com:aynurcn/blog_public.git"
webhookToken = "由 add 生成的随机密码"
tagPattern = "^deploy-[0-9]{8}-[0-9]{6}$"
retainReleases = 3

[healthCheck]
url = "https://orhan.cn/"
attempts = 5
intervalMs = 2000
timeoutMs = 5000

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

项目配置使用严格字段校验。路径、仓库名、Tag 正则、健康检查 URL 和部署类型都必须明确填写,未知字段会直接导致配置检查失败。这种约束让一次错误配置尽早暴露,不会等到 WebHook 到达后才发现服务不知道该怎么发布。

公网只暴露一个入口

aynur-deploy 默认只监听本机地址,公网请求由 Nginx 转发。一个实例可以为多个项目共用同一个域名和同一份 Nginx 配置:

location = /healthz {
    proxy_pass http://127.0.0.1:9091/healthz;
}

location ^~ /v1/hooks/gitee/ {
    proxy_pass http://127.0.0.1:9091;
    proxy_http_version 1.1;
}

location / {
    return 404;
}

Gitee WebHook URL 只需要把项目 ID 放到路径中,例如:

https://deploy.example.com/v1/hooks/gitee/orhan-blog

这样新增项目时只需要新增项目 TOML,并在 Gitee 填入对应的 WebHook 密码和路径,不需要复制一份 Nginx 服务块,也不需要为每个项目启动一个部署进程。

出问题时看状态,不靠猜

正常发布不需要人工干预,但自动化的价值也包括让异常更容易处理。可以查看项目和部署状态:

aynur-deploy list
aynur-deploy status orhan-blog

服务会把部署命令的标准输出和错误输出按行写入结构化日志。由 Aynur 管理服务时,可以直接查看:

aynur logs aynur-deploy

构建失败可以重试,确定某个旧 release 需要恢复时可以显式回滚,部署历史和残留 worktree 则可以用 clean 按类型清理。每个动作都有明确的命令和状态,不需要再次登录服务器手动翻目录。

aynur-deploy retry <deploymentId>
aynur-deploy rollback orhan-blog <commitSha>
aynur-deploy unblock orhan-blog
aynur-deploy clean orhan-blog --keep 20 --type failed

这条链路适合什么项目

它适合有明确发布产物、希望用 Git Tag 作为发布边界的小型网站和服务。项目规模不大时,没有必要为了部署引入一整套复杂平台,但也不应该把每次发布都交给一串手工命令。

对我来说,aynur-deploy 的效率提升集中在几个很具体的地方:

  • 发布动作从登录服务器、拉代码、替换目录、检查页面,缩成一次 Tag Push。
  • 每次发布绑定精确提交,不再临时确认服务器上的分支和工作区。
  • 线上目录只在完整 release 准备好后切换,失败时有上一版可以恢复。
  • 健康检查、reload、回滚和部署状态由同一个服务记录,不需要靠聊天记录记住现场。
  • 多个仓库共用一个服务和一个公网入口,新增项目只需要补项目配置。

这些节省没有必要用一个夸张的数字来衡量。每次发布少几个手工动作,长时间维护下来,减少的是重复确认和处理意外的时间。

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