我手上这台跑了好几年的Linux云服务器,买回来之后第一年其实干的是最原始的活:手动部署。
每次给Node.js项目加完新功能,流程都是相似的——登录服务器、进入项目目录、git pull、npm install、pm2 restart。一开始项目少,我还觉得挺顺手的,毕竟代码不多,依赖也不怎么折腾。等维护的项目从1个变成3个,我发现自己一天里最烦躁的时间都花在了ssh窗口里:改完代码忘了pull、pull下来依赖报错、pm2起来之后接口又502……这些破事随便撞上一个,都能让人在半夜里抓狂。
后来我把部署改成自动化:代码一推到GitHub,就自动完成服务器上的拉取、依赖安装和进程重启。整个过程不需要我打开终端,我只需要去看一眼构建日志够不够绿。今天这篇文章就围绕这套方案完整拆解一次,覆盖服务器准备、GitHub Actions工作流、环境变量的处理,以及我用下来踩过的一堆坑。适合手里有一台云服务器、跑着Node服务、但部署方式还停留在“人肉ssh”阶段的开发者。
1. 为什么我把部署优先级看得比业务代码还高
1.1 手动部署的隐性成本:人脑是不可靠的部署工具
手动部署表面上只有几步,真正的问题不在步骤本身,而在“每一步都依赖人记得去做”。举个例子,我有个接口服务依赖一份配置文件,这份配置放在服务器上、没有进Git仓库。手动部署时,我偶尔会忘记同步服务器上的配置,结果本地测试一切正常,一到生产环境就缺配置导致启动失败。这类问题定位起来非常折磨人,因为你根本不知道是哪一次部署漏了哪一步。
另一个隐蔽问题是依赖版本。npm install在package-lock和package.json不完全一致时,会按照package.json的语义化版本范围重新解析依赖树,不同时间安装出来的node_modules可能完全不同。手动部署时,很少有人会意识到自己这台服务器上的依赖树,跟本地、跟测试环境已经不是同一棵树了。自动化之后,至少依赖安装这一环每一步都是可复现的。
手动部署还有一个成本是“切换上下文”。你在写业务代码的时候,脑子里装的是接口逻辑,突然插进来一个部署任务,需要把思路切到“服务器、目录、进程”。这个切换过程很消耗注意力,尤其是晚上加班时,一次部署可能打断半小时的编码状态。自动化部署把这块从日常操作里摘掉之后,整个开发节奏会顺很多。
1.2 方案对比:GitHub Actions、Webhook和Jenkins怎么选
一开始我也纠结过用什么方案,前前后后接触过的有下面这几种:
| 方案 | 维护成本 | 上手难度 | 适用场景 |
|---|---|---|---|
| GitHub Actions | 低,仓库自带 | 低 | 个人/小团队,代码托管在GitHub |
| 自建Webhook服务 | 中 | 中 | 需要深度定制部署逻辑,或不想绑定平台 |
| Jenkins | 高 | 高 | 大团队、复杂流水线、多环境统一管理 |
| 云厂商流水线产品 | 低,但绑定特定平台 | 低 | 不想折腾且确定一直用同一家云平台 |
我最终选了GitHub Actions,核心原因有三点:第一,它跟GitHub仓库天然集成,push事件、分支过滤、PR合并这些触发条件都是现成的;第二,构建日志可以随时回看,部署失败时不用自己去服务器翻历史命令;第三,不需要额外维护一台CI服务器。个人和小团队项目用这个性价比最高。
自建Webhook服务我后来也试过,思路是写一个Node服务接收GitHub的webhook请求,收到后执行shell脚本。这种方式胜在灵活,部署逻辑可以完全自己控制,但缺点也很明显:这个webhook服务本身也需要部署和守护,等于多了一个需要维护的线上服务,个人项目有点得不偿失。Jenkins对我来说就太重型了,除非团队里已经有专门的运维人力,否则光升级插件就够喝一壶。
1.3 自动化部署到底自动了什么
很多人一听自动化部署就觉得是把git pull和pm2 restart写进脚本里,其实完整的链路还包含触发、构建、通知这几个环节:
- push事件监听:只有推到指定分支才触发,避免无意义的部署。
- 代码检出与依赖安装:在GitHub的runner上先跑一遍
npm ci,确认依赖能装上、构建能通过。 - SSH远程执行部署命令:连接到服务器,执行拉取、安装生产依赖、重启进程。
- 部署后健康检查与失败回滚:请求一下接口的健康检查路由,失败了自动回退到上一个版本。
这篇文章重点讲的是第二个和第三个环节,因为这两个地方最容易出问题。下面进入正题。
2. 服务器端准备:把环境收拾干净再谈自动化
2.1 单独建一个deploy用户,别用root裸奔
很多人的服务器上所有操作都是root账户完成的,图省事。但自动化部署意味着会有一个SSH密钥长期保存在GitHub那边,如果这把密钥对应的是root账户,一旦泄露,等于把整台服务器的控制权交了出去。我强烈建议单独建一个低权限部署用户,只授予项目目录的读写权限。
sudo useradd -m -s /bin/bash deploy sudo mkdir -p /var/www/myproject sudo chown -R deploy:deploy /var/www/myproject sudo passwd deploy目录路径用/var/www/myproject是Linux服务器上的常见惯例,一个站点一个目录,以后机器上项目多了,光看目录结构就能分清谁是谁。项目初始化时,先用deploy用户登录,把Git仓库clone到这个目录里,后续自动化部署就都基于这个路径操作。
2.2 Node.js与PM2安装:nvm还是apt,我的最终选择
Node.js环境的安装方式会影响后面自动化部署的稳定性。有两种常见方式,我踩过坑之后现在比较推荐直接走NodeSource的apt仓库。
sudo apt update curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash - sudo apt install -y nodejs node -v npm -v这种方式装出来的node命令在系统全局路径里,任何SSH会话都能直接找到,对自动化部署最友好。不建议在部署目标服务器上用nvm,因为nvm的初始化脚本写在~/.bashrc里,而GitHub Actions用SSH执行远程命令时,加载的是非交互式shell,未必会加载.bashrc,很容易出现“手动登录能用、自动化脚本里node not found”的诡异问题。如果你确实需要多版本Node,更推荐用Docker隔离,而不是在物理机上折腾nvm。
PM2的安装和守护配置也很关键:
sudo npm install -g pm2 sudo pm2 startup systemd -u deploy --hp /home/deploy pm2 savepm2 startup systemd会把PM2注册成systemd服务,这样服务器重启之后,PM2管理的进程能自动拉起。pm2 save则是把当前进程列表固化下来,配合startup实现开机自启。这一步不做的话,服务器意外重启后,你的Node服务只能干等人工上去启动。
2.3 两把SSH密钥:谁连谁必须分清楚
自动化部署里涉及两把密钥,方向不同,很多人容易混。我在部署文档里特意画了一条线来澄清:
第一把,供GitHub Actions连接服务器用。在服务器上生成:
ssh-keygen -t ed25519 -f ~/.ssh/actions_key -N "" cat ~/.ssh/actions_key.pub >> ~/.ssh/authorized_keys chmod 600 ~/.ssh/authorized_keys这把密钥的私钥(~/.ssh/actions_key)需要复制到GitHub仓库的Secrets里,公钥留在服务器上。GitHub Actions运行时会用私钥连接服务器,服务器检查公钥匹配后放行。
第二把,供服务器连接GitHub拉代码用。也在服务器上生成:
ssh-keygen -t ed25519 -f ~/.ssh/github_key -N ""然后把公钥(~/.ssh/github_key.pub)添加到GitHub仓库的Deploy keys里。为了让git pull不报错,还需要在服务器的~/.ssh/config里指定:
Host github.com HostName github.com User git IdentityFile ~/.ssh/github_key配好后可以先验证链路:
ssh -T git@github.com ssh -i ~/.ssh/actions_key deploy@服务器IP两边都通了,服务器端的准备才算完成。有一个容易忽略的点是,~/.ssh目录和密钥文件的权限不能太宽松,OpenSSH会拒绝加载权限过大的私钥文件,这个等会儿在踩坑章节里细说。
3. GitHub Actions工作流:把“手动三部曲”变成一次Push
3.1 触发条件:让工作流只在需要的时候跑
GitHub Actions的工作流文件放在仓库的.github/workflows目录下,文件名随意,我习惯叫deploy.yml。第一步是定义触发条件,我用的是push事件加分支和路径过滤:
name: Deploy Node.js Project on: push: branches: [ "main" ] paths-ignore: - 'docs/**' - 'README.md' permissions: contents: read jobs: deploy: runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@v4branches: [ "main" ]确保只有推到main分支才触发,开发分支的提交不会误部署到生产服务器。paths-ignore的作用是过滤掉纯文档变更,比如改README、改docs目录下的说明文件,都不需要触发一次完整的部署。这个配置看起来很细节,实际体验中很实用,能少跑很多无意义的流水线。
3.2 安装依赖与构建:为什么我坚持用npm ci
部署的runner环境是全新的Ubuntu,需要先安装Node.js和依赖:
- name: Setup Node.js uses: actions/setup-node@v4 with: node-version: 20 cache: npm - name: Install dependencies run: npm ci这里有个很多人不注意的点:用npm ci而不是npm install。npm ci会严格按照package-lock.json里锁定的版本安装,不会自己去解析范围版本,因此CI环境里的依赖树和本地开发环境完全一致。“本地跑得好好的,一部署就报错”这种问题,很大程度上就是因为不同的机器用了不同版本的间接依赖。setup-node里加cache: npm可以让GitHub缓存依赖,第二次以后运行流水线时会快不少。
如果你的Node项目有构建步骤,比如TypeScript编译或者前端资源打包,可以在这里加上npm run build:
npm ci npm run build --if-present构建产物如果打进包里,后面部署到服务器后就不需要再构建。但需要注意,服务器上的npm ci还是要执行一次,因为生产依赖和开发依赖不一样,构建只是确认代码能编译。
3.3 关键一步:SSH远程执行部署命令
依赖安装和构建通过后,进入真正的部署环节。这里我没有用第三方SSH Action,而是直接用GitHub runner自带的ssh命令,逻辑透明,排查问题也直观:
- name: Deploy via SSH env: DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }} SERVER_HOST: ${{ secrets.SERVER_HOST }} SERVER_USER: ${{ secrets.SERVER_USER }} run: | mkdir -p ~/.ssh echo "$DEPLOY_KEY" > ~/.ssh/deploy_key chmod 600 ~/.ssh/deploy_key ssh -i ~/.ssh/deploy_key \ -o StrictHostKeyChecking=no \ -o ConnectTimeout=10 \ ${SERVER_USER}@${SERVER_HOST} " cd /var/www/myproject && \ git pull origin main && \ npm ci --omit=dev && \ pm2 startOrRestart ecosystem.config.js --env production "这段脚本里有几个细节值得解释一下。
为什么要把密钥先写成文件而不是直接用ssh -i配合进程替换?因为在GitHub的runner默认shell环境下,进程替换方式不一定可靠,落地成文件最稳。chmod 600是为了满足OpenSSH对私钥文件权限的强制要求,权限太松会直接报错拒绝加载。
StrictHostKeyChecking=no的作用是跳过首次连接时的host key确认。如果不加这个参数,第一次SSH连接会卡在“是否确认指纹”的交互提示上,而GitHub Actions的runner是非交互式的,卡在这里整个部署就僵死了。出于安全考虑,正式环境建议先用ssh-keyscan把服务器公钥指纹记录下来,再把StrictHostKeyChecking=yes配合已知hosts一起用,我当时为了图省事直接用no,后来总觉得心里不踏实,完善版本还是应该把host key验证加上。
部署命令里用了pm2 startOrRestart ecosystem.config.js --env production。这个写法是幂等的:如果PM2里还没有这个进程,就启动它;如果已经存在,就重启。比pm2 restart好在不会因为进程不存在而报错。后面踩坑部分还会展开讲。
3.4 敏感信息:别把密钥和密码写进workflow文件
上面的workflow里没有出现任何真实的服务器IP、用户名和私钥内容,它们都通过${{ secrets.XXX }}引用。GitHub仓库的Settings -> Secrets and variables -> Actions里可以添加加密的Secrets,流水线运行时这些值会以环境变量的形式注入,但不会出现在日志里。
我习惯把部署相关的敏感信息分成三类,分别存放:
- 服务器地址、端口、用户名:放进Secrets。
- 用于SSH连接的私钥:放进Secrets。
- 项目运行时需要的环境变量(数据库地址、密钥、外部接口凭证等):不放进Git仓库,留在服务器端。
Secrets本身没有类型校验,名字用大写字母加下划线,比如DEPLOY_KEY、SERVER_HOST。还有一个经验反馈:如果你的仓库是public的,任何看到仓库的人都可以提交PR,虽然PR流水线不会自动拿到Secrets,但为了稳妥,跟部署直接相关的仓库最好设成private。
4. 部署中踩过的坑:权限、路径与进程管理
4.1 UNPROTECTED PRIVATE KEY FILE:密钥权限报错的完整排查链路
第一次把workflow推到GitHub时,部署直接失败。Actions日志最后一行写着:
Permissions 0644 for '/home/runner/work/_temp/xxx' are too open.这句话的意思是,OpenSSH认为私钥文件权限太宽松,拒绝加载。我当时的排查过程是这样的:
先怀疑workflow语法写错了,但本地act模拟测试时命令能执行,语法没问题。后来把日志往翻了很多行,才看到真正的问题出在密钥文件的权限上。GitHub Actions里echo "$DEPLOY_KEY" > ~/.ssh/deploy_key创建出来的文件,默认权限是644,也就是所有用户都可读。OpenSSH出于安全考虑,检测到私钥文件权限超过600就会拒绝加载。
修复方式就是在写入密钥后立即执行chmod 600 ~/.ssh/deploy_key,把权限收紧。这个问题给我一个教训:Actions日志很长,不要只看最后一行就急着改代码,要从下往上把关键步骤都过一遍。报错信息往往只是因为别的地方没检查,而真正的原因藏在上游的某一步。
4.2 PM2进程“假死”:显示online但接口不响应
有段时间部署日志全绿,但部署完接口就是访问不了。pm2 list里进程状态显示online,curl却一直超时。这种“假死”状态非常隐蔽,排查链路也比较长。
第一步看PM2日志:
pm2 logs myproject --lines 50发现进程启动后并没有报错,但监听端口一直没起来。这时我检查了一下启动命令,发现问题出在部署脚本里写了pm2 restart app.js,而项目实际采用了ecosystem.config.js来管理进程。pm2 restart app.js这种方式会重建一个基于命令行参数的进程,完全绕过了ecosystem配置文件里设置的cwd、env、exec_mode等关键参数,导致PM2以为重启成功了,实际起来的进程却不是正确的启动方式。
解决办法是统一使用pm2 startOrRestart ecosystem.config.js,并把启动入口、工作目录、环境变量全部固化在ecosystem.config.js里:
module.exports = { apps: [ { name: 'myproject', script: './bin/www', cwd: '/var/www/myproject', instances: 1, exec_mode: 'fork', env: { NODE_ENV: 'production', PORT: 3000 } } ] }这个坑的本质是:启动进程的参数如果不固化,PM2每次“重启”都可能用到一个跟预期不一致的参数组合。写死之后,至少部署脚本和手动执行的行为是一致的,排查问题也有了确定性的基准。
4.3 nvm装的Node在SSH会话中找不到:非交互shell的PATH问题
另一个让我头疼的报错是GitHub Actions的部署脚本里执行node -v时报command not found,但手动SSH登录服务器执行同样的命令却完全正常。第一次遇到时我怀疑是服务器环境坏了,后来才定位到是nvm的锅。
nvm是在用户目录下通过shell函数生效的,它的初始化脚本通常写在~/.bashrc里。手动登录时,交互式bash会加载.bashrc,所以一切正常。但GitHub Actions的SSH远程执行是非交互式shell,加载的是~/.bash_profile或~/.profile,这两个文件不一定回去读取.bashrc,于是nvm环境根本没被加载,node自然就找不到了。
这个问题有两种解法。一是在部署脚本里手动初始化nvm:
export NVM_DIR="$HOME/.nvm" [ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"二是在服务器端直接改用全局安装Node,让node命令存在于所有shell会话都能找到的系统路径。我最终选了第二种,因为部署服务器本来就不需要频繁切换Node版本,直接用系统的LTS版本最省心。nvm更适合本地开发环境,而不是作为自动化部署目标的环境方案。
4.4 .env文件没进Git仓库,部署后环境变量从哪来
还有一个典型的坑:项目的.env文件被.gitignore排除,它本来就不在Git仓库里。每次git pull只会拉代码,不会同步.env。如果是一台全新服务器第一次做自动化部署,代码拉下来了,但配置缺失,服务根本起不来。
我的处理方式是分两部分。首先,在服务器端手动创建一份.env或config/production.json,保证代码在服务器上能跑起来,这份文件保持600权限,只有deploy用户可读。其次,把不能进Git的敏感模板放在env.example里,部署脚本可以根据模板生成配置,再用Secrets里的值去覆盖具体字段:
cp /var/www/myproject/env.example /var/www/myproject/.env echo "DATABASE_URL=$DATABASE_URL" >> /var/www/myproject/.env这个方案比把所有环境变量都塞进GitHub Secrets更清晰。塞多了之后workflow会非常臃肿,而且服务器的本地配置和Git里的模板容易“各说各话”,模板和真实配置之间的偏差让人很难排查。我的原则是:部署链路的参数(IP、用户、路径、密钥)进Secrets,应用运行时的配置(端口、超时时间、数据库地址)留在服务器本地env文件里,并通过模板加Secrets填充的方式生成。这样既安全,也方便在服务器上直接修改应急。
5. 从“能跑”到“好用”:健康检查、回滚与部署通知
5.1 部署后先做健康检查再宣布成功
流水线跑到部署完成,只是说“命令执行完了”,不代表“服务真的可用了”。所以我在部署命令后面加了一个健康检查步骤,请求一下项目的健康检查接口:
- name: Health Check run: | for i in {1..10}; do if curl -fsS http://${SERVER_HOST}:3000/health; then echo "Health check passed" exit 0 fi sleep 3 done echo "Health check failed" exit 1这个循环重试很有必要。Node服务启动通常需要几秒,立即curl大概率会失败。用循环而不是curl --retry的原因是循环逻辑更直白,超时时间、重试次数自己控制,日志也更容易读。我的健康检查路由返回的数据很简单,就是一个{"status":"ok"}的JSON,加上当前版本号,这样如果部署后版本号不是预期值,说明代码没生效,可以及时发现问题。
5.2 回滚机制:失败的时候能不能快速退回去
健康检查一旦失败,不应该只发个失败通知就完事,最好是能自动回滚到上一个可用版本。我的做法是在服务器上放一个deploy.sh脚本,把部署和回滚的逻辑都收在服务器端,workflow只负责触发它:
#!/bin/bash set -e cd /var/www/myproject PREV_COMMIT=$(git rev-parse HEAD) git pull origin main npm ci --omit=dev pm2 startOrRestart ecosystem.config.js --env production if ! curl -fsS http://localhost:3000/health; then echo "Health check failed, rolling back" git reset --hard "$PREV_COMMIT" npm ci --omit=dev pm2 startOrRestart ecosystem.config.js --env production echo "Rolled back to $PREV_COMMIT" exit 1 fi部署前先把当前commit记下来,部署后如果健康检查不过,直接git reset --hard回滚到上一个版本。这个脚本的价值在于:部署逻辑放在离服务器最近的地方,排查问题时可以直接在服务器手动执行,不用跑一遍完整的CI流水线。workflow那边简化成一行远程执行bash /var/www/myproject/deploy.sh即可。
需要注意的是,npm ci在回滚时也要重新执行一遍,因为新版本可能改了依赖,直接reset代码后node_modules可能跟旧版本不匹配。这个过程稍微慢一点,但换来的是回滚后的环境确实可用。
5.3 部署结果自动通知
部署完成或失败之后,加一个通知步骤,把结果推到常用IM的群机器人。实现方式很简单,就是用一个webhook的POST请求:
- name: Notify deploy result if: always() run: | curl -s -X POST "$NOTIFY_URL" \ -H 'Content-Type: application/json' \ -d '{"msg_type":"text","content":{"text":"部署成功: main branch"}}'if: always()保证无论是成功还是失败都会执行通知。其实“通知”这个环节的价值不在于仪式感,而在于你不需要时刻盯着Actions页面。部署失败时,手机上能第一时间收到推送,而不是等用户反馈接口不通了才去查。
5.4 多环境部署的扩展思路
如果你的项目不止生产环境,还有测试环境,可以用同一个workflow配合不同的Secrets来区分。比如在workflow里加一个输入参数,手动触发时可以选环境,push触发时默认走production:
on: workflow_dispatch: inputs: environment: description: 'Deploy target' required: true default: 'staging'然后把不同环境对应的服务器地址、用户、目录都放进不同的Secrets。这套思路同样可以延伸到多个Node项目:每个项目建一个deploy用户,共用一个GitHub Actions workflow模板,只是Secrets不同。项目多了之后,维护成本依然可控。
最后再分享一点个人经验
这套自动化部署流程我用到现在大半年,最大的感受是:它省下的不只是每次那几分钟操作,而是把部署变成了一件“确定性”的事情。手动部署出问题,你可能要花很长时间回忆是哪一步漏了;自动化部署失败,日志会明明白白告诉你失败在哪一步,原因是什么,修复也基本是对症的。
如果你的Node项目还停留在手动部署,我的建议是不要急着先写workflow。第一步先把服务器环境标准化:目录固定、启动命令固定、环境变量有模板,这些做扎实之后,接GitHub Actions其实就是一个不到50行的yaml文件的事。真正遇到问题时,按我上面写的几个方向去排查——密钥权限、非交互shell的PATH、PM2启动参数、环境变量来源——大部分坑都能快速绕过去。