一次搞定前端部署方案的核心技术实践
项目初期的技术选型
这个项目是个后台管理系统,用户量不算大,但部署频率挺高,基本一天三四次迭代。一开始我们用的是传统的 Nginx + 手动 scp 上传静态文件的方式,每次改完代码打包,再通过脚本推到服务器上。开始还挺顺,直到某天凌晨发版,我手一抖传错了 build 文件,直接导致线上白屏半小时——那一刻我就决定,不能再这么玩了。
调研了一圈,CI/CD 方案当然首选 GitHub Actions,毕竟项目在 GitHub 上,不用额外搭 GitLab CI 或 Jenkins。而且团队人少,不想维护复杂的部署流水线。最后定了个轻量方案:push 到 main 分支自动构建,生成的产物通过 SSH 同步到目标服务器,配合 pm2 管理服务重启。听着简单,可真踩的坑一点不少。
最大的坑:SSH 连接不稳定
刚开始写的 workflow 是这样的:build 完之后用 appleboy/scp-action 把文件传上去,再用 appleboy/ssh-action 执行重启命令。本地测试没问题,一进 CI 就各种超时。最离谱的一次,scp 传了 80%,然后连接断了,workflow 显示成功,结果线上一半是新代码一半是旧的,页面直接报错。
查了半天才发现,GitHub Actions 的 runner 和我们的服务器网络有时候会抖,尤其是高峰期。后来加了重试机制也不靠谱,因为 scp 不支持断点续传。折腾了两天,最后换成了 rsync over ssh,自己写了个小脚本包装一下,至少能增量同步,失败了也能重来。
#!/usr/bin/env bash
# sync.sh
RSYNC_CMD="rsync -avz --delete -e 'ssh -o StrictHostKeyChecking=no -i /root/.ssh/deploy_key' dist/ user@server:/var/www/html"
retries=0
max_retries=3
while [ $retries -lt $max_retries ]; do
if $RSYNC_CMD; then
echo "Sync succeeded."
exit 0
else
retries=$((retries + 1))
echo "Sync failed. Attempt $retries of $max_retries."
sleep 5
fi
done
echo "All sync attempts failed."
exit 1
这个脚本跑在 action 的 container 里,私钥通过 secrets 注入,路径挂载进去。虽然不是最优解(比如没做指纹验证),但至少比原来稳定多了。这里注意我踩过好几次坑:StrictHostKeyChecking 必须关,否则第一次连不上;私钥权限要是 600,不然 ssh 直接拒绝读取。
核心代码就这几行
最终的 GitHub Actions 配置文件精简后长这样:
name: Deploy Frontend
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '18'
- name: Install dependencies and build
run: |
npm install
npm run build
- name: Upload artifact (for debugging)
uses: actions/upload-artifact@v3
with:
name: dist
path: dist/
- name: Sync files via rsync
uses: appleboy/ssh-action@v1.0.0
with:
host: ${{ secrets.HOST }}
username: ${{ secrets.USER }}
key: ${{ secrets.DEPLOY_KEY }}
script: |
cd /tmp && mkdir -p sync_script
cat > sync.sh << 'EOF'
# (上面那个 rsync 脚本内容)
EOF
chmod +x sync.sh
./sync.sh
env:
DIST_PATH: dist
其实关键就是把 rsync 脚本通过 here-doc 传到远程服务器去执行,避免中间网络中断导致文件不一致。虽然有点土,但亲测有效。另外 upload-artifact 那一步本来是调试用的,后来发现偶尔能帮上忙,就没删。
谁更灵活?谁更省事?
其实还有个方案是用 FTP 或直接 API 触发构建,但我们服务器没开 FTP,也不想为了部署多一个服务入口。也想过用 CDN + OSS 的方式,比如 build 完上传到七牛或 S3,再刷新缓存。理论上更稳定,但成本和复杂度都上去了,对我们这种小项目有点杀鸡用牛刀。
现在这套流程跑了几个月,大概发布了 70 多次,失败率从原来的 15% 降到了不到 3%。剩下的问题主要是服务器磁盘满导致同步失败,但这属于运维监控范畴了,不在本次解决范围。说到底,没有银弹,只有适合当前阶段的方案。
又踩坑了:环境变量污染
有次发版后登录接口 404,排查半天发现是 nginx 配置被覆盖了。原因是 rsync 命令用了 –delete,而我在本地 build 的时候不小心把 nginx.conf 也放进 dist 目录了……对,你没看错,是因为开发环境误引入了一个配置文件模板。
改法很简单,在 .gitignore 和 build 脚本里都加了清理逻辑:
// build.js
const fs = require('fs')
const path = require('path')
// 构建结束后清理敏感或多余文件
function cleanDist() {
const distPath = path.resolve(__dirname, 'dist')
const filesToRemove = ['nginx.conf', 'env.local', 'backup']
filesToRemove.forEach(file => {
const filePath = path.join(distPath, file)
if (fs.existsSync(filePath)) {
fs.rmSync(filePath, { recursive: true })
console.log(Removed: ${file})
}
})
}
这事儿提醒我:自动化越强,破坏力也越大。现在每次上线前都会先看一眼 dist 目录结构,虽然麻烦,但心里踏实。
回顾与反思
这个部署方案谈不上优雅,也没有用 Kubernetes 或 Serverless 那种高大上的东西。但它解决了最实际的问题:让发布变得可预期、可重复、少出错。我现在敢在晚上十点发版,而不是非得等到凌晨三点没人用的时候。
做得好的地方:
- 全流程自动化,除了 merge request 通过,不需要人工介入
- 失败能快速重试,不会卡住整个流程
- 产物保留一份在 GitHub artifacts,方便回滚对比
还能优化的点:
- 应该加个 pre-sync 校验,比如检查 dist 是否包含 html 文件,避免传空目录
- 目前没有版本标记,不知道当前线上是哪个 commit 构建的,考虑加个 version.json
- 未来可以接入 webhook,发个钉钉通知告诉团队“已发布成功”
最后有个没完全解决的小问题:如果 build 过程中依赖了 jztheme.com 的某个接口返回数据,而那个接口临时不可用,会导致构建失败。目前只能等接口恢复后手动 rerun workflow。这不是大问题,一个月大概遇到一次,影响不大,所以暂时搁置了。
以上是我的踩坑总结
这套部署方案不是最优的,但足够简单、够用。如果你也在用 GitHub Actions 部署前端项目,希望这些经验能让你少熬两个夜。如果有更优雅的做法,比如怎么安全地处理 rsync 权限、或者如何优雅回滚,欢迎评论区交流。我已经快被部署搞怕了,能省一次心是一次。

暂无评论