一、先算一笔账:十堰团队的一次上线到底花多少时间
在十堰做网站建设和软件外包,项目交付节奏往往很紧:客户上午提一个文案修改,下午就想在线上看到。我们统计过本地几个开发团队的真实发布流程,一次普通上线的耗时大致是这样的:
- 本地打包构建、确认产物目录:5 到 8 分钟(老项目 npm install 慢的时候更久)
- 用 FTP 或 scp 上传 dist 目录:3 到 6 分钟(图片多的站点更慢)
- SSH 登录服务器,备份旧版本、解压覆盖:4 到 5 分钟
- 重启 Node 服务、刷页面检查有没有白屏:3 分钟
- 发现传错了文件、漏了环境变量,回滚再来一遍:再来 10 分钟
也就是说,一次顺利的发布要 15 到 20 分钟,一次不顺的发布要半小时以上。更要命的是这件事必须由某个人守着做,晚上十点客户催着改价格,你就得爬起来开电脑。十堰不少十几人的小团队,一周发五六次版,光花在“搬文件”上的时间就超过两小时。
这篇文章讲的 CI/CD,本质就是把上面这套动作写成一份可重复执行的脚本,交给机器去做。做完之后,发布动作简化成一次 git push,构建和上线在云端跑,人只需要看结果。
二、CI、CD 到底指什么,别被缩写绕晕
三个词经常被混用,先一句话分清:
- CI(持续集成):代码合并到主干后,自动跑依赖安装、构建、单元测试、代码检查。目的是尽早发现“你改的代码把别人搞挂了”。
- CD(持续交付):在 CI 基础上,自动把产物打包成一个随时可发布的版本,但是否上线由人点一下确认。
- CD(持续部署):连那一下确认也省掉,测试通过就直接上生产。
对大多数十堰的中小团队来说,做到“持续交付”就已经足够:主干合并自动构建、自动部署到测试环境,生产环境保留一次人工确认。这样既省掉了重复劳动,又不会因为一次误提交把客户官网搞崩。
三、整体架构:一次 push 之后发生了什么
我们给本地客户落地的方案是这样的链路:
- 开发者在本地执行
git push origin main; - GitHub 检测到 push,启动一个 Actions Runner(云端虚拟机);
- Runner 拉取代码,用
npm ci安装依赖并执行npm run build; - 构建成功后,通过 SSH 把产物增量同步(rsync)到服务器的 releases 目录;
- 服务器上切换软链接、重载进程管理器,完成上线;
- 脚本自动请求一次首页,HTTP 200 才算发布成功,否则自动回滚。
整条链路里,人只做第一步。剩下的步骤全部写进一个 YAML 文件,跟着代码一起版本管理——这一点很重要,部署流程本身也应该是可回滚、可审查的代码。
四、准备阶段:服务器上的四个动作
4.1 创建专用部署用户
不要用 root 部署。新建一个只负责收文件的用户,权限最小化:
sudo adduser --disabled-password --gecos "" deploy
sudo mkdir -p /home/deploy/ydunet/releases && sudo chown -R deploy:deploy /home/deploy/ydunet
4.2 生成一对专用 SSH 密钥
在本地执行 ssh-keygen -t ed25519 -C "github-actions-deploy" -f ~/.ssh/deploy_key,把公钥追加到服务器的 /home/deploy/.ssh/authorized_keys,私钥内容粘贴到 GitHub 仓库的 Settings → Secrets and variables → Actions 里,命名为 SSH_PRIVATE_KEY。同时把服务器 IP、端口、用户名存成 SSH_HOST、SSH_PORT、SSH_USER。
4.3 目录结构约定
采用“每次发布一个独立目录 + 一个软链接指向当前版本”的结构,这是实现零停机和秒级回滚的关键:
/home/deploy/ydunet/
releases/20261024093012/
releases/20261023181540/
current -> releases/20261024093012
current 是一个符号链接,Nginx 或 PM2 永远只认这个路径。切换版本时只改链接指向,原子操作,不存在“文件传到一半用户看到白屏”的窗口期。
4.4 确认 Node 版本一致
本地 Node 20、服务器 Node 16,是最常见也最难查的构建事故来源。在项目根目录放一个 .nvmrc 写死 20,Actions 里用 node-version-file 读取,服务器上也装同一版本。
五、完整 workflow:一份可以直接抄的配置
在项目里新建 .github/workflows/deploy.yml:
name: deploy
on:
push:
branches: [main]
concurrency:
group: production-deploy
cancel-in-progress: false
jobs:
build-and-deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version-file: .nvmrc
cache: npm
- run: npm ci
- run: npm run build
env:
NODE_OPTIONS: --max_old_space_size=3072
- name: upload
run: |
echo "$SSH_PRIVATE_KEY" > key && chmod 600 key
STAMP=$(date +%Y%m%d%H%M%S)
rsync -az --delete -e "ssh -i key -p $SSH_PORT -o StrictHostKeyChecking=no" \
./ $SSH_USER@$SSH_HOST:/home/deploy/ydunet/releases/$STAMP/
ssh -i key -p $SSH_PORT $SSH_USER@$SSH_HOST \
"ln -sfn /home/deploy/ydunet/releases/$STAMP /home/deploy/ydunet/current && pm2 reload ydunet"
几个细节值得单独说明。concurrency 是保命配置:两个人几乎同时 push 时,如果不加这个,两个发布任务会同时改软链接,结果可能是旧版本覆盖新版本。cancel-in-progress: false 表示排队执行而不是中途打断,避免半成品上线。rsync -az --delete 是增量同步,第二次发布通常只传几个改动的 chunk 文件,几秒钟就完事,比整包 scp 快一个数量级。pm2 reload 而不是 restart,前者是平滑重载,新进程起来之后才杀掉旧进程,用户请求不会中断。
六、零停机:软链接切换与一键回滚
上面已经有软链接切换了。回滚要更简单——服务器上留一个 rollback.sh:
#!/bin/bash
cd /home/deploy/ydunet/releases
PREV=$(ls -1t | sed -n '2p')
[ -z "$PREV" ] && echo "没有可回滚版本" && exit 1
ln -sfn /home/deploy/ydunet/releases/$PREV /home/deploy/ydunet/current
pm2 reload ydunet && echo "已回滚到 $PREV"
原理就是按修改时间列出目录,取第二个(第一个是当前版本)。执行 bash rollback.sh,三秒内回到上一个版本。我们在给十堰一家做本地生活服务的客户上线时用过一次:新版首页的一个接口字段名写错,页面列表空白,从发现到回滚完成不到两分钟,客户甚至没来得及打电话过来。
再进一步,可以在 workflow 最后加一步健康检查,失败自动触发回滚:
- run: |
sleep 5
CODE=$(curl -s -o /dev/null -w "%{http_code}" https://www.example.com/)
if [ "$CODE" != "200" ]; then ssh -i key -p $SSH_PORT $SSH_USER@$SSH_HOST "bash /home/deploy/ydunet/rollback.sh"; exit 1; fi
七、四个真实踩过的坑
7.1 报错 “pm2: command not found”
SSH 执行非交互式命令时不会加载 .bashrc,nvm 安装的全局包路径自然找不到。两种解法:用绝对路径 ~/.nvm/versions/node/v20.x/bin/pm2,或者在服务器上执行 sudo ln -s $(which pm2) /usr/local/bin/pm2。后者更省心。
7.2 rsync 报 Permission denied
九成是目标目录属主不对——root 曾经用 scp 传过文件,目录里混了 root 所有的文件,deploy 用户无法覆盖。执行 sudo chown -R deploy:deploy /home/deploy/ydunet 一次性解决,之后坚持只用 deploy 发布。
7.3 构建成功但页面是旧的
多半是 Nginx 的 root 指向了写死的旧绝对路径,而不是 current。把配置改成 root /home/deploy/ydunet/current; 并 reload 一次即可。另外 CDN 或浏览器缓存也会造成“看着没更新”的错觉,验证时加个 ?v=时间戳 参数。
7.4 缓存没生效,每次构建还是要七八分钟
actions/setup-node 的 cache: npm 只缓存下载的包,不缓存构建产物。Next.js 项目可以额外缓存 .next/cache 目录,命中之后二次构建通常只要一分半。注意 package-lock.json 有变化时缓存 key 会失效,这是正常的。
八、效果对比与落地建议
同一套项目,改造前后的数据:
- 单次发布耗时:从平均 19 分钟降到 3 分 12 秒(其中构建 2 分 40 秒,同步 6 秒,切换与重载 4 秒)
- 发布频率:从每周 1 到 2 次提升到每周 6 到 8 次,小步快跑,出问题更容易定位
- 人为失误:漏传文件、忘改环境变量这类事故从每月两三次降到零
- 回滚时间:从“翻备份、重传半小时”变成三秒
给十堰开发团队的建议是分三步走,不要一上来就追求全自动:第一步先把构建脚本固化下来(npm run build 能在任何机器上稳定跑通);第二步接上自动部署到测试环境,让团队习惯“push 就能看效果”;第三步再放开生产环境,并配上健康检查和回滚。整个过程通常两三天就能完成,收益却会持续好几年。
九、写在最后
CI/CD 听起来像个大厂词汇,实际上它的核心只有一句话:把上线这件事从“靠人记得住的操作”变成“机器必定执行的流程”。对十堰的中小企业和开发团队来说,它带来的不只是省下的那十几分钟,而是敢改、敢发的底气——需求来了随时能上线,出了问题几秒钟能退回。这种从容,比任何技术名词都更有价值。
如果你的团队还在用 FTP 手工传文件、靠记忆做备份,不妨从今天这篇里的 workflow 文件开始,挑一个不重要的项目先跑通一遍。
