CI/CD:自动构建与发布流水线
手动发布的典型流程是:本地 git pull、pnpm build、scp 上传、ssh 重启、再刷新页面看一眼。这个过程本身没错,问题在它不可重复——漏跑测试、传错目录、忘了 reload,都会在某次深夜发布里同时发生。CI/CD 的价值不是「炫技」,而是把这条命令序列固化成谁执行都一样的脚本,并且在前一步失败时自动停下。
要解决的问题
- 最小可用的自动化流水线该包含哪几个阶段?
- GitHub Actions 怎么写:触发条件、缓存、测试、产物?
- 怎么把产物安全地送到服务器(密钥与
known_hosts怎么管)? - GitLab CI 的等价写法是什么,测试失败怎么阻断发布?
流水线的五个阶段
| 阶段 | 做什么 | 失败后怎么办 |
|---|---|---|
| 检出 | checkout 指定 commit,确保产物可追溯 | 直接失败,通知 |
| 依赖安装 | 用 lockfile 精确安装,并挂缓存加速 | 报告 lockfile 冲突 |
| 检查与测试 | lint、类型检查、单元测试 | 阻断发布,不允许「先发再修」 |
| 构建 | 产出 dist/ 或镜像,命名带 commit 短哈希 | 失败即终止 |
| 部署 | 增量同步 + 原子切换 + reload + 健康检查 | 自动切回上一版本 |
GitHub Actions 完整示例
# .github/workflows/deploy.yml
name: build-and-deploy
on:
push:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm # 自动缓存包管理器目录
- run: corepack enable && pnpm install --frozen-lockfile
- run: pnpm lint && pnpm test # 失败则整个流水线停止
- run: pnpm build
- uses: actions/upload-artifact@v4
with:
name: dist-${{ github.sha }} # 产物名带 commit,可追溯可回滚
path: dist/
retention-days: 30
deploy:
needs: build # 依赖 build,测试没过就不会部署
runs-on: ubuntu-latest
steps:
- uses: actions/download-artifact@v4
with:
name: dist-${{ github.sha }}
path: dist
- name: 准备 SSH
run: |
mkdir -p ~/.ssh
printf '%s\n' "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
printf '%s\n' "${{ secrets.SSH_KNOWN_HOSTS }}" > ~/.ssh/known_hosts
- name: 增量同步 + 原子切换
run: |
TARGET=/srv/www/site-${{ github.sha }}
rsync -avz --delete -e "ssh -i ~/.ssh/id_ed25519 -o StrictHostKeyChecking=yes" \
dist/ "${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }}:$TARGET/"
ssh -i ~/.ssh/id_ed25519 "${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }}" \
"ln -sfn $TARGET /srv/www/site-current && sudo nginx -t && sudo systemctl reload nginx"
两个容易出事的地方:known_hosts 的内容要用 Secret 存(在 runner 上临时执行 ssh-keyscan 属于「首次信任即接受」,可被中间人冒充),私钥只放 Secrets 并使用只授权到目标目录的部署专用密钥。
GitLab CI 的等价写法
# .gitlab-ci.yml
stages: [test, build, deploy]
test:
stage: test
image: node:22
script:
- corepack enable
- pnpm config set store-dir .pnpm-store
- pnpm install --frozen-lockfile
- pnpm lint && pnpm test
rules:
- if: '$CI_COMMIT_BRANCH == "main"' # 用 rules 替代已过时的 only
deploy:
stage: deploy
image: alpine:3.20
needs: [test] # 测试不过不部署
rules:
- if: '$CI_COMMIT_BRANCH == "main"'
script:
- apk add --no-cache rsync openssh-client
- mkdir -p ~/.ssh
- printf '%s\n' "$SSH_PRIVATE_KEY" > ~/.ssh/id_ed25519 && chmod 600 ~/.ssh/id_ed25519
- printf '%s\n' "$SSH_KNOWN_HOSTS" > ~/.ssh/known_hosts
- rsync -avz --delete -e ssh dist/ "$SSH_USER@$SSH_HOST:/srv/www/site-$CI_COMMIT_SHORT_SHA/"
- ssh "$SSH_USER@$SSH_HOST" "ln -sfn /srv/www/site-$CI_COMMIT_SHORT_SHA /srv/www/site-current"
两者结构完全一致:阶段(stages)+ 规则(rules,旧写法是 only)+ 脚本(script)+ 依赖(needs),把 GitHub Actions 搬过来只是换关键字;构建产物在 GitLab 里用 artifacts 声明(paths: [dist/] 加 expire_in: 30 days),作用等价于 upload-artifact。
部署用户的权限最小化
# 1) 建专用部署用户,不用 root,也不给 sudo 全权
sudo useradd -m -s /bin/bash deploy
sudo mkdir -p /srv/www && sudo chown deploy:www-data /srv/www
# 2) 只授权一条 sudo 命令:reload nginx
echo 'deploy ALL=(root) NOPASSWD: /bin/systemctl reload nginx' | sudo tee /etc/sudoers.d/deploy-reload
sudo chmod 440 /etc/sudoers.d/deploy-reload
# 3) 部署密钥单独生成,只加进 deploy 用户的 authorized_keys
ssh-keygen -t ed25519 -C 'ci-deploy' -f ~/.ssh/ci_deploy -N ''
ssh-copy-id -i ~/.ssh/ci_deploy.pub deploy@your-server
条件允许时再进一步:限制该密钥只能从 CI 出口 IP 登录(安全组或 sshd_config 的 Match Address),或只允许执行固定命令(command="...")。
落地顺序
| 顺序 | 动作 | 完成标志 |
|---|---|---|
| 1 | 本地手工完整发布一次,把每条命令记下来 | 有一个能贴进脚本的命令序列 |
| 2 | 把命令序列写成服务器上的 deploy.sh | 在服务器上执行成功 |
| 3 | 把 deploy.sh 的调用放进 CI,手动触发 | 点一次按钮能发布成功 |
| 4 | 加 lint、测试、构建阶段 | 提交坏代码时流水线变红 |
| 5 | 加产物归档、回滚脚本、通知 | 能一条命令切回上一版本 |
「先手动跑通,再自动化」——反过来做,大量时间会浪费在调试流水线而不是交付功能。
常见坑
| 坑 | 现象 | 做法 |
|---|---|---|
| 测试失败仍继续部署 | 坏代码上生产 | 用 needs/stages 强制串行依赖 |
ssh-keyscan 放在流水线里 | 首次信任可被中间人利用 | known_hosts 内容存 Secret |
| 私钥写进仓库或日志 | 密钥泄露,服务器被入侵 | 只放 Secrets,禁止 echo 整份私钥 |
| CI 用 root 部署 | 一把钥匙等于整机权限 | 专用用户 + 只允许写目标目录 + 单条 sudo |
| 每次都全量上传 | 慢,且网络抖动容易传一半 | rsync -avz --delete,传到新目录再切换 |
| 直接覆盖线上目录 | 用户访问到半成品 | 部署到带版本的新目录,软链原子切换 |
小结:CI/CD 的最小闭环是「触发条件 + 依赖缓存 + lint/测试 + 构建产物 + rsync 增量部署 + 软链切换 + 健康检查」,GitHub Actions 与 GitLab CI 只是关键字不同,结构可以照搬;密钥一律走 Secrets,known_hosts 也用 Secret 存,CI 部署用专用用户并只授权写目标目录与 reload Nginx;落地顺序一定是「手工跑通 → 服务器脚本化 → CI 调用 → 加测试与回滚」,先要可靠,再要快。