给 VPS 配一套 push tag 自动部署:self-hosted runner 踩坑实录

给 VPS 配一套 push tag 自动部署:self-hosted runner 踩坑实录

目标很朴素:每次发版打一个 v1.2.3 的 tag,服务器自动把这个 tag 的代码拉下来、构建、重启,我不用再 SSH 上去手敲。

我原来的部署流程是纯手动的——上服务器 git pullpnpm buildpm2 start ecosystem.config.js。这篇把「从手动到 push tag 自动部署」的完整过程、以及中间真正卡住我的三个点记录下来。

项目是一个 Next.js 16 的服务,跑在一台国内 VPS 上,用 pm2 守护。下面的命令和路径我都做了脱敏(换成 <org>/<repo>/root/projects/myapp 这种占位),你照着套自己的就行。

一、先想清楚:构建到底在哪台机器上跑

动手之前我纠结的第一个问题不是「怎么写 workflow」,而是一个更底层的问题:构建这一步,到底应该发生在哪台机器上? 想明白这个,后面所有选择都顺了。

先看一眼托管平台(Cloud Run / Cloudflare / Vercel 这类)是怎么做的。我一开始以为它们是「在 GitHub 上构建好再推过来」,其实都不是:

官方的范式(以 Cloud Run 为例):git push 触发 webhook → 平台自带的、独立的构建集群构建出一个不可变产物(immutable artifact,镜像 / 静态包 / 函数包,带版本号)→ 部署成新 revision → 健康检查通过后原子切流量 → 旧 revision 留着可秒级回滚。

关键点:运行你服务的那台机器,自己从不参与构建。 构建和运行是两个彻底分离的环节。

而我现在的 VPS + pm2 是另一种模型——原地构建:同一台机器既构建又服务,git pull 之后 pnpm build 直接覆盖 .next

两种部署模型的对比:左边是托管平台,git push tag 后由独立的构建集群产出不可变产物,再原子切流量到运行时,运行机从不参与构建,回滚是秒级指回旧版本;右边是 VPS 原地构建,同一台机器既构建又服务,self-hosted runner 只是把手动命令自动化,回滚要 checkout 旧 tag 重新 build

原地构建的代价我清楚:构建中途失败可能留下半坏状态、回滚得重新 build 不是秒级、构建还吃服务器的 CPU/内存(这台机器之前就被 Turbopack 的持久缓存把 CPU 打满过)。

但我最后还是选了原地构建,理由很简单:MVP 阶段、单机、国内服务器,「秒级回滚」这个收益还撑不起「写 Dockerfile + 开 output: 'standalone' + 建镜像仓库 + 跨境推拉镜像」这一整套复杂度。等以后要多机/灰度/频繁回滚,再升级到镜像方案不迟。

这是我自己的取舍,不是普适结论。如果你的项目已经容器化、或者团队对回滚速度敏感,直接上不可变产物那套更省心。别为了「跟上最佳实践」给一个单机 MVP 套上它撑不起的复杂度。

二、GitHub runner 的两种写法:遥控器 vs 搬运工

定了「原地构建」,接着我才发现一个特别容易混淆的点:「用 GitHub runner」和「在 GitHub 上构建」根本是两回事。

GitHub runner 只是 GitHub 分配的一台跑 workflow 的机器,它具体干什么是你写的。同样是部署,有两种截然不同的写法:

写法 A —— 构建在你服务器(runner 当遥控器)
  runner 启动
     └─ 只做一件事:让你的服务器执行
        git checkout tag && pnpm install && pnpm build && pm2 reload
        ↑ 拉代码、装依赖、build、重启 全在你服务器本地跑

写法 B —— 构建在 GitHub(runner 当搬运工)
  runner 启动
     ├─ 本地 pnpm install && pnpm build   ← 构建在 GitHub 的机器上
     └─ rsync/scp 把 .next 等产物推到你服务器
        你服务器只负责接收文件 + 重启

对我这个项目,写法 A 明显更合适:我没开 output: 'standalone',构建产物是完整的 .next + node_modules,体积很大;GitHub 的 runner 在海外,把这么大的产物跨境 rsync 到国内服务器又慢又容易断。让服务器自己 build(代码从 git 拉、依赖从它自己的国内 npm 镜像装)反而更快更稳。

三、为什么是 self-hosted runner,而不是 GitHub 托管 + SSH

写法 A 还能再细分:用 GitHub 托管的 runner 通过 SSH 远程喊命令,还是干脆把 runner 装在自己服务器上?两者构建都在服务器本地跑,区别只在「谁发起连接、连接方向」。

我选了 self-hosted runner,原因是跨境连接的稳定性:

  • GitHub 托管 + SSH:海外 runner 要 SSH 连进国内服务器,并保持这条连接直到 build 跑完(可能好几分钟)。这条跨境 SSH 长连接偶尔会断,断了这次部署就失败。
  • self-hosted runner:runner 直接装在服务器上,按官方说法,它只通过出站 HTTPS 向 GitHub long-poll 拉任务,没有任何跨境 SSH 长连接。GitHub 端连不进来,是 runner 主动往外够。

这条对国内服务器尤其值。我自己手动 SSH 上服务器时跨境连接就时好时坏,不想让自动部署也吊在这根不稳的线上。

四、workflow 怎么写:在原手动流程上加的几处改进

既然要自动化,我顺手把原来手动流程里几个一直将就的地方一并修了。下面是最终的 .github/workflows/deploy.yml:

name: Deploy on tag

# 推送形如 v1.2.3 的 tag 时自动部署。例:git tag v0.2.0 && git push origin v0.2.0
on:
  push:
    tags:
      - "v*"

# 同一时刻只允许一个生产部署在跑;新 tag 来了排队,不并发踩踏。
concurrency:
  group: production-deploy
  cancel-in-progress: false

jobs:
  deploy:
    # 跑在装在生产服务器上的 self-hosted runner(注册时打了 --labels myapp-prod)。
    runs-on: [self-hosted, myapp-prod]
    env:
      DEPLOY_PATH: /root/projects/myapp        # pm2 describe 里的 exec cwd
      HEALTHCHECK_URL: http://127.0.0.1:3000   # 端口与 ecosystem.config.js 一致
    steps:
      - name: Build & deploy ${{ github.ref_name }}
        shell: bash
        run: |
          set -euo pipefail
          echo "node: $(node -v)  pnpm: $(pnpm -v)  pm2: $(pm2 -v)"
          cd "$DEPLOY_PATH"
          TAG="${{ github.ref_name }}"
          git fetch --tags --force origin
          git -c advice.detachedHead=false checkout --force "refs/tags/$TAG"
          pnpm install --frozen-lockfile
          pnpm build
          pm2 startOrReload ecosystem.config.js --update-env
          pm2 save
      - name: Health check
        shell: bash
        run: |
          set -euo pipefail
          for i in $(seq 1 15); do
            if curl -fsS -o /dev/null "$HEALTHCHECK_URL"; then echo "✅ ok"; exit 0; fi
            sleep 2
          done
          echo "❌ health check failed"; exit 1

对照我原来的手动流程,改动是这几处:

原手动流程改成为什么
git pullgit fetch --tags + 精确 checkout refs/tags/$TAG部署版本与 tag 严格一致,可回滚到任意旧 tag;不再跟着分支头漂
(漏了装依赖)pnpm install --frozen-lockfile手动时全靠记性,依赖一变 build 就挂;--frozen-lockfile 还能保证锁文件一致
pm2 startpm2 startOrReload --update-envstart 在服务已在跑时会报错或起重复进程;startOrReload 是「首启 + 热重载」二合一,接近零停机
(无)build 失败即 set -e 终止,不碰 pm2线上继续跑旧版本,不会留半坏状态
(无)部署后 health check 探活起不来能立刻发现,而不是等用户报错
(无)concurrency 串行连推两个 tag 不会并发互相踩

两个细节值得单独说:

  • git checkout --force 会丢弃已跟踪文件的本地改动,但不删未跟踪文件。 所以服务器上 gitignore 掉的 .env.productionlogs/ 都安全——前提是你没在服务器上手改过被 git 跟踪的文件还没提交。部署后这个目录会处于 detached HEAD(停在 tag 上),这是 tag 部署的正常状态。
  • workflow 必须先合进默认分支才生效。 tag 触发时,GitHub 读的是「该 tag 所指 commit 里的 workflow」。所以得先把 deploy.yml 合进 main,再在包含它的 commit 上打 tag,否则旧 tag 不会触发。这个坑我在 上一篇让 Claude 自动开 PR 里也踩过,GitHub Actions 的 workflow 解析逻辑就是这样。

五、卡点一:跑 runner 的用户,必须等于跑 app 的用户

workflow 写完只是纸面工作,真正费劲的是在服务器上把 runner 装对。这一节是整篇里最该记住的。

按 GitHub 给的命令下载、./config.sh 注册之后,装 systemd 服务这步翻车了:

$ sudo ./svc.sh install
runner is not in the sudoers file.

我当时第一反应是「那把 runner 用户加进 sudo 不就行了」。这个想法是错的,而且会把事情带沟里。我停下来想清楚了才发现,加 sudo 只解决了「svc.sh install 要写 /etc/systemd/ 需要 root」这一个表面问题,真正会让部署失败的是另外三件事,它们都不是 sudo 能解决的:

我的 app 用 root 跑(pm2 list 里 user 一列是 root):

  ① PATH       node/pnpm/pm2 装在 root 下(/root/.nvm、/root/.bun)
               runner 用户的 PATH 里根本没有 → command not found

  ② 文件权限    app 目录是 root 的,runner 用户往 .next 写构建产物 → 被拒

  ③ pm2 守护进程  pm2 是「按用户隔离」的,每个用户一个独立的 pm2 daemon
               runner 用户跑 pm2 startOrReload → 起的是 runner 名下另一个
               全新的 pm2,跟 root 那个跑着的 app 互不相干 → 端口冲突 / 跑两份

第三点是最隐蔽的。pm2 是按用户隔离的——这点想不到,排查能耗半天。结论提炼成一句话:

运行 runner 的用户,必须等于现在实际跑 app 的那个用户。 否则 PATH、文件权限、pm2 守护进程会三处全对不上,加 sudo 也救不回来,你只会得到「root 权限暴露 + 三处错位」两头不讨好。

我的 app 跑在 root 下(VPS 默认 root 部署),所以正解就是让 runner 也以 root 跑,node / app 目录 / pm2 daemon 天然全对齐。具体两个坑:

1)config.sh 默认禁止 root 运行,会报 Must not run with sudo。这是它的安全护栏,要显式解除:

export RUNNER_ALLOW_RUNASROOT="1"
./config.sh --url https://github.com/<org>/<repo> --token <REGISTRATION_TOKEN> --labels myapp-prod

2)装 systemd 服务时,svc.sh install 后面要跟运行用户。我第一次手滑装成了 ./svc.sh install runner,服务就以 runner 用户(uid 1000)跑了——正好踩中上面三个坑。改对的方式:

# 卸掉「以 runner 用户运行」的服务(只删本机 systemd 服务,不影响 GitHub 上的注册)
./svc.sh stop && ./svc.sh uninstall

# 让服务以 root 运行:把变量写进 .env,runner 以 root 启动时会校验它
echo "RUNNER_ALLOW_RUNASROOT=1" >> .env

# 以 root 重新安装并启动
./svc.sh install root
./svc.sh start
./svc.sh status     # 关键看 Run as user: root、active (running)、Connected to GitHub

svc.sh uninstall 只删本机的 systemd 服务,不会把 runner 从 GitHub 注销,所以不用重新 config.sh、也不用新 token。

装完务必确认 runner 进程的 PATH 里能看到三个工具(systemd 托管的 runner 不是登录 shell,这点最容易漏):

$ whereis node pnpm pm2
node: /root/.nvm/versions/node/v24.15.0/bin/node
pnpm: /root/.nvm/versions/node/v24.15.0/bin/pnpm
pm2:  /root/.bun/bin/pm2

只要这三个目录都在 runner 启动日志的 .path 里,workflow 步骤就能直接调用,不用在 yaml 里再写一堆 source ~/.nvm/nvm.sh 的引导。

安全权衡说在前面:runner 以 root 跑,意味着 workflow 里的命令都有 root 权限。私有仓库 + 提交者可信的前提下,MVP 阶段我接受这个权衡;以后想收紧,得把整个 app 迁到一个非特权用户下跑(目录 chown、给它单独装 node/pm2、pm2 startup 注册它的开机自启)——那是另一摊活,跟「给 runner 加 sudo」不是一回事。

六、卡点二:第一次真部署就炸——pnpm 把未声明的 build 脚本变成了硬错误

runner 装好、workflow 合进 main、打 tag,满心欢喜看 Actions——结果 pnpm install 这步直接红了:

[ERR_PNPM_IGNORED_BUILDS] Ignored build scripts: @google/genai, esbuild,
msw, protobufjs, sharp, unrs-resolver
Run "pnpm approve-builds" to pick which dependencies should be allowed to run scripts.
Error: Process completed with exit code 1.

诡异的是本地构建一直好好的,只有服务器上炸。根因不在 workflow,在版本漂移:

服务器上的 pnpm 是 11.5.1(很新),而我本地是旧版本。较新的 pnpm 把「装了带 postinstall/build 脚本的依赖、但你没明确表态要不要让它跑」这件事,从一条 warning 升级成了硬错误(exit 1);我本地的旧版本只是警告,所以从没拦下来过。

我的 pnpm-workspace.yaml 当时只对 sharpunrs-resolver 两个表了态(放进 ignoredBuiltDependencies),报错里其余几个没归类,于是新版 pnpm 直接判失败。

那能不能用 corepack 绕过?

我第一个念头是 corepack——它能用 package.jsonpackageManager 字段把 pnpm 版本钉死,让本地和服务器一致。但想清楚后发现 corepack 解决的是「版本漂移」,不是这个报错本身,这俩是两层事:

  • 钉到 pnpm 11(对齐服务器):仍然要改那个 yaml——11 就是会强制你给 build 脚本分类,钉死版本也躲不掉。corepack 在这只是顺手保证可复现。
  • 钉到 pnpm 10(退回「只警告」的旧版本):这是唯一能「不改 yaml」绕过报错的路,但代价不小——得让 corepack 真的在服务器上接管 pnpm(现在服务器是直接装的 pnpm 11 在 PATH 上,光加 packageManager 字段没用),还得为了躲新版的报错把整条工具链开倒车。而且就算钉到 10,那些 build 脚本照样不会跑,效果和直接声明 ignoredBuiltDependencies 一模一样,只是换成「靠旧版本的宽松行为」而不是「显式声明」。

所以我没绕。最小、最稳、跟 pnpm 版本无关的修法,就是把报错里那几个包全部补进 ignoredBuiltDependencies——它们在我的项目里都不需要真的跑 build 脚本(sharpesbuild 现在都通过 optional dependencies 装预编译二进制,msw 的脚本只是拷贝浏览器 mock worker,生产用不上):

# pnpm-workspace.yaml
ignoredBuiltDependencies:
  - "@google/genai"
  - esbuild
  - msw
  - protobufjs
  - sharp
  - unrs-resolver

这样无论服务器以后是 pnpm 10 还是 12 都不会再炸。corepack 该不该用是另一回事(为了版本一致,值得做),但它替代不了这行声明。

顺带一提:如果某个被 ignore 的包其实在构建链路里真的要跑脚本,build 那步会报新错——那就把它从 ignoredBuiltDependencies 挪到 onlyBuiltDependencies(明确允许跑)。「忽略」和「允许」是 pnpm 给你的两个相反的显式表态,关键是别让它停在「没表态」

补完这行,重新打 v0.1.1、push,Actions 一路绿到 health check,pm2 里 app 的重启计数 +1、状态仍 online。整条链路终于跑通。

七、保留的几点判断

  • root 跑 runner 不是终态,是 MVP 阶段的取舍。 私有仓库 + 可信提交者下我接受;公开仓库或多人协作,务必迁到非特权用户,别图省事。
  • 原地构建省了复杂度,但也没有秒级回滚。 回滚得 checkout 旧 tag 重新 build。我赌的是「发版不频繁、回滚是低频事件」,这个前提哪天不成立了,就该上不可变产物 / 镜像方案了。
  • output: 'standalone' 我故意没开。 开了能让产物自包含、更适合写法 B(GitHub 构建后推产物),但会改变现有部署形态。当前原地构建用不上,等真要做镜像时再开。
  • pnpm 那几个包我是「按它们生产不需要跑脚本」判的,不是逐个去读了 install 脚本源码。如果你的项目里某个依赖的 native 编译是必须的(比如真要本机编 sharp),别照抄我的 ignore 列表,该放 onlyBuiltDependencies

小结

把一台 VPS 从「手动 git pull + build + pm2」升级到「push tag 自动部署」,真正的难点其实不在 workflow 的 YAML——那部分很短。卡住我的是三个更底层的判断:

  1. 构建在哪台机器跑 → 想清楚托管平台的「不可变产物」和 VPS「原地构建」是两种模型,按自己的阶段选,别盲目套最佳实践;
  2. runner 用哪个用户跑 → 必须等于跑 app 的用户,否则 PATH / 文件权限 / pm2 守护进程三处全错位,加 sudo 也救不回来;
  3. pnpm 为什么本地过、服务器炸 → 版本漂移,新版把「未声明的 build 脚本」从警告变成硬错误,显式声明 ignoredBuiltDependencies 而不是用 corepack 退回旧版去躲。

三个点都不是「查文档抄命令」能直接过的,得先把背后的模型(产物不可变性、pm2 的用户隔离、pnpm 的 build 脚本审批)想明白,命令才落得对。CI/CD 这东西,YAML 是表皮,模型才是里子。