跳至正文
Ende Sun
返回博客

运维与安全

用 GitHub Actions 在共享主机上实现原子化部署

阅读需 8 分钟

用 GitHub Actions 在共享主机上实现原子化部署

这个网站是一个带 Filament 后台的 Laravel 12 应用,运行在一台普通的共享主机上:有 SSH,没有 root,没有容器,也没有托管式部署服务。有一段时间,部署就是把项目打成压缩包再手动上传。我想要的是三件事:推送到 master 后自动上线;访客永远不会看到复制到一半的版本;出问题的版本能在我不在电脑前的时候自己撤回。

约束条件

对设计影响最大的不是工具选择,而是共享主机本身:

  • 服务器上禁用了 PHP 的 exec() 和 symlink(),所以 php artisan storage:link 无法使用,所有链接都必须在 shell 里创建。
  • 网站根目录是主机面板管理的固定目录。应用放在它之外,公开入口只是通过一个固定路径加载应用自己的 index.php。
  • 我不想在生产环境依赖 Node.js,所以前端资源必须在到达服务器时就已经构建好。
  • CI 使用的密钥绝不能在服务器上获得一个通用 shell。

流水线

GitHub Actions 工作流有两个任务。test 在每次推送和每个拉取请求时运行:用 PHP 8.2 安装 Composer 依赖,根据示例文件生成 .env,然后跑完整的测试套件。deploy 依赖 test,并且只在 master 上运行:

  1. 执行 pnpm install --frozen-lockfile 和 pnpm run build,让编译后的资源在 CI 中生成。
  2. 打包:对当前提交执行 git archive,再加上生成的 public/build 目录,用 gzip 压缩。
  3. 通过一次 SSH 调用,把压缩包经标准输入传到服务器。
git archive --format=tar -o release.tar HEAD
tar -rf release.tar public/build
gzip release.tar

ssh -i ~/.ssh/deploy deploy@server "deploy ${GITHUB_SHA::12}" < release.tar.gz

连接之前,任务会先确认密钥机密里存的确实是一把有效的私钥;服务器的主机密钥来自预先固定的 known_hosts 机密,而不是第一次连接时盲目接受。

一把只能用来部署的密钥

部署密钥在服务器上以强制命令的方式授权:

restrict,command="~/bin/site-receive" ssh-ed25519 AAAA... github-actions

restrict 关闭了端口转发、代理转发和终端分配。command= 的意思是:不管客户端请求执行什么,服务器都只运行接收脚本;客户端请求的命令只会以 SSH_ORIGINAL_COMMAND 环境变量的形式传进来。脚本只接受一种格式,即 deploy <提交哈希>,其他一律拒绝:

id=$(printf '%s' "${SSH_ORIGINAL_COMMAND:-}" | sed -nE 's/^deploy ([0-9a-f]{7,40})$/\1/p')
[ -n "$id" ] || { echo "用法: deploy <sha>" >&2; exit 2; }

REL="$HOME/apps/site-releases/$(date +%Y%m%d-%H%M%S)-$id"
mkdir -p "$REL"
tar -xzf - -C "$REL"
exec bash "$REL/deploy/activate.sh" "$REL"

即使这把密钥泄露,最坏的结果也只是部署一个压缩包,而这正是它存在的目的。

版本目录、共享状态与符号链接切换

每次部署都解压到独立的目录。需要在版本之间保留的内容,也就是 .env 文件和 storage/,放在一个共享目录里;当前运行的应用则是一个符号链接:

~/apps/site            -> ~/apps/site-releases/20261023-235857-abc1234
~/apps/site-shared/    .env, storage/
~/apps/site-releases/  每次部署一个目录

激活脚本会在任何请求能访问新目录之前把它完全准备好:链接 .env 和 storage,执行 composer install --no-dev --optimize-autoloader,把 public/storage 链接到共享的上传目录,运行数据库迁移和内容同步,并缓存配置、路由和视图。这些都完成后才切换版本:

ln -s "$REL" "$APP.next" && mv -Tf "$APP.next" "$APP"

这一步我不用 ln -sfn,因为它会先删除旧链接再创建新链接,中间会有一个短暂的无链接时刻。先建一个临时链接再把它重命名覆盖旧链接,依靠的是 rename(),它在一次操作中完成替换:任何请求看到的要么是旧版本,要么是新版本。

切换之后,脚本把应用的 .htaccess 复制到网站根目录,并为 public/ 下网站根目录中还没有的新文件创建链接。

健康检查与自动回滚

切换两秒后,脚本依次请求西班牙语、英语和中文首页、后台登录页以及站点地图,期望每个都返回 200。只要有一个返回其他状态码,它就把符号链接指回上一个版本并以错误退出,GitHub Actions 的这次运行随之变红。如果全部通过,就清理旧版本:保留最近的三个,并且从不删除当前版本和上一个版本。

一次真正派上用场的回滚

我之所以信任这套机制,是因为一次较大规模内容重构的首次部署。那个版本引入了项目分类和多语言内容。CI 全部通过,服务器上的迁移也都顺利执行。然后对 /es 的检查收到了 500。

新代码要求至少存在一个项目分类,而生产环境里还一个都没有:分类由一个单独的 Artisan 命令根据准备好的内容导入,我原本打算在部署后手动执行它。健康检查发现了错误,脚本切回了上一个版本,这次运行以失败告终,提示新版本响应异常、迁移没有被撤销。出问题的版本只在切换和检查之间的几秒钟内处于上线状态。

解决办法不是提醒自己下次更小心,而是调整顺序:内容同步现在在激活过程中执行,紧跟在迁移之后、健康检查之前。我从中得到的教训很简单:新代码渲染页面所需要的一切,都必须在检查之前完成,绝不能作为事后的手动步骤。

需要注意:迁移不会被撤销

回滚只切换代码,数据库仍然保持迁移后的状态,脚本自己的错误信息里也写明了这一点。只有当上一个版本在新表结构下仍能正常工作时,这才是安全的。而且迁移在切换之前执行,那时旧代码还在处理请求,所以这个条件本来就必须满足。

在上面那次事故中,这个条件成立,因为所有改动都是增量式的:新增表和允许为空的新列。这也是我现在遵循的规则:先新增;重命名或删除放到之后的部署中,等到没有任何运行中的代码再依赖旧结构时再做。对于任何会改写现有数据的改动,我都会在推送前先备份数据库。我特意没有把 migrate:rollback 自动化:在生产数据上撤销表结构变更,是我希望自己来做的决定。

总结

这一切都不需要特殊的基础设施,只是一个工作流文件、三个 shell 脚本、一把受限的 SSH 密钥和一套目录结构。换来的是:每次部署要么完整上线,要么完整回退;如果出了问题,我是从 CI 的红色运行结果里得知,而不是从访客那里。