跳到主要内容
0%
约 6 分钟

从 SSH 到 Webhook:让服务器自己部署自己


起点:SSH 推送方案

之前搭建了 GitHub Actions 自动部署到阿里云,架构很直接:CI 构建 → tar 打包 → SSH 传到服务器 → 解压 → 重载 Nginx。

这套方案跑了一段时间,直到我做了一件事:把 SSH 端口限制为本机 IP

然后 CI 就挂了。

问题一:IP 白名单不可行

GitHub Actions 跑在微软云上,IP 段是动态的。我查了一下 GitHub 官方 API,Actions 的 IPv4 段有 5218 条。阿里云安全组单条规则上限 100 条,根本加不完。

即使能加完,维护成本也极高——GitHub 不定期增删 IP,每次变更都要手动同步。

问题二:SSH 密钥格式坑

在排查过程中还发现一个隐藏问题:GitHub Secrets 中存储的 SSH 私钥,通过 echo "$SSH_KEY" > file 写入时会丢失末尾换行符,导致 SSH 报 Load key ... invalid format

这玩意儿排查起来极其痛苦——本地测是好的,CI 里就挂。最终用 printf '%s\n' 替代 echo 解决,但心理上已经对 SSH 方案产生了不信任。

换个思路:让服务器主动拉取

与其让 CI 费劲巴拉地推送到服务器,不如反过来:

GitHub push → CI 发一个 HTTP POST → 服务器收到 → git pull → build → deploy

CI 只负责通知,服务器自己完成拉取和部署。好处:

  • 不需要 SSH:一个 HTTP 请求就够了
  • 不需要开端口:Nginx 已经在监听 80/443
  • 安全组不用改:现有的 Web 端口复用
  • 服务器完全掌控部署逻辑:不在 CI 里写复杂的 shell

实现

1. 服务端:新增 /api/deploy 端点

在之前写的 API Bridge 服务(Express, 端口 3001)上加了一个 webhook 端点,带密钥校验:

app.post('/api/deploy', async (req, res) => {
  const { secret } = req.body;

  if (secret !== DEPLOY_SECRET) {
    return res.status(403).json({ error: '密钥错误' });
  }

  // 先返回 200,部署在后台异步执行
  res.json({ status: 'deploying' });

  runDeploy().catch((err) => {
    console.error('[deploy] 部署失败:', err.message);
  });
});

部署逻辑拆成清晰的五步:

async function runDeploy() {
  // 1. 拉取最新代码
  run('git fetch origin main && git reset --hard origin/main', REPO_DIR);

  // 2. 先更新 API Bridge 自身依赖(只装不重启,避免杀进程)
  run('npm install --omit=dev', '/opt/api-bridge');

  // 3. 安装博客依赖 + 构建
  run('npm ci', REPO_DIR);
  run('BASE_URL=/ npm run build', REPO_DIR);  // systemd 环境需显式传入

  // 4. Nginx 直接指向构建目录,仅需 SELinux 修复 + 重载
  run('restorecon -R /var/www/kaitohub/dist', REPO_DIR);
  run('systemctl reload nginx', REPO_DIR);

  // 5. 最后才重启 API Bridge(部署全部完成,不怕杀进程)
  run('systemctl restart api-bridge', REPO_DIR);
}

注意几个细节:

  • BASE_URL=/:systemd 启动的服务没有这个环境变量,必须显式传入,否则 Astro 不知道 base 该用啥
  • 步骤顺序:API Bridge 依赖先装(不重启),博客构建部署完成后最后才 restart,避免中途杀进程
  • 不用 cp:因为服务器上 git clone 的仓库就在 /var/www/kaitohub,Nginx 的 root 直接指向它的 dist/ 子目录,构建产物天然就在正确位置,不需要额外复制

2. CI 端:一个 curl 搞定

GitHub Actions 的部署步骤从 40 行 SSH 命令简化为 8 行:

- name: Trigger Alibaba Cloud deploy
  env:
    HOST: ${{ secrets.ALIYUN_HOST }}
    DEPLOY_SECRET: ${{ secrets.DEPLOY_SECRET }}
  run: |
    curl -X POST "http://$HOST/api/deploy" \
      -H 'Content-Type: application/json' \
      -d "{\"secret\":\"$DEPLOY_SECRET\"}"

只需要两个 Secret(ALIYUN_HOST + DEPLOY_SECRET),不再需要 SSH 私钥。

3. Nginx 代理

API Bridge 监听 127.0.0.1:3001,Nginx 把 /api/* 代理过去:

location /api/ {
    proxy_pass http://127.0.0.1:3001;
}

/api/deploy 自然也在代理范围内,不需要额外开端口。

实战中踩的三个坑

坑一:Shiki 不认识 vbnet

旧博客里有一段代码标记为 vbnet,但实际是 Go 代码。之前在 GitHub Actions 上构建时 Shiki 只是警告,换到服务器本地构建后,新版 Shiki 对未知语言处理变严格,直接导致构建失败。

教训:代码块的语言标记要写对的,别指望工具一直包容你。

坑二:systemd 环境没有 BASE_URL

astro.config.mjs 里写了 base: process.env.BASE_URL || '/',本意是本地开发默认 /,CI 构建时注入。但 systemd 启动的服务压根没有这个环境变量,构建出来的页面路径全乱了。

教训:别假设环境变量一定存在,关键变量在命令中显式传入最稳妥。

坑三:多余的 cp 操作

最开始从 SSH 方案迁移时,顺手保留了 rm -rf + cp -r 的步骤。结果因为构建目录和 Nginx 目录是同一个,cpsame file 错误。删掉后才发现——根本不需要复制,构建产物已经在正确位置了。

教训:换方案时不要把旧逻辑无脑搬过来,想清楚每一步是否还有必要。

对比

SSH 推送方案Webhook 方案
CI 配置~40 行 SSH + tar~8 行 curl
需要 SecretsHOST + SSH_KEYHOST + DEPLOY_SECRET
安全组需开放 22 端口复用 80/443
IP 白名单5000+ 条不可行不需要
SSH 密钥维护生成/分发/轮换不需要
部署逻辑写在 CI 里服务器端控制
故障排查CI 日志有限journalctl -u api-bridge -f

总结

这个方案的思路转变很简单:从”推”变成”拉”

CI 不再负责把文件传到服务器,只发一个信号。服务器收到信号后自己 git pull、自己构建、自己部署。少了 SSH 这一层,也少了一大堆麻烦。

而且这个端点本身就架在 API Bridge 上,和 AI 聊天共用同一套基础设施——Nginx 代理、systemd 管理、限流保护。没有任何额外组件。

如果你的项目也在用 GitHub Actions 部署到自有服务器,不妨试试这个思路。只要服务器能访问 GitHub(能 clone 代码),就不需要 SSH。

向 AI 提问(基于本文内容)

评论