CI/CD 持续集成与部署实战

8358 字
42 分钟
CI/CD 持续集成与部署实战

本站就是一条 CI/CD 流水线的产物:本地 git push origin master,GitHub Actions 拉代码、装依赖、构建静态站点,再通过 SFTP 把产物推到自建服务器,全程不登服务器、不手敲部署命令。这篇文章把这条真实链路拆开讲透,再抽象成通用的 CI/CD 方法论。

一、概念理清:CI、CD 与流水线#

1.1 三个概念的区别#

三个词都带 CD,含义完全不同,是最容易答错的面试题之一。

概念英文核心动作触发时机产物去向人工介入
持续集成Continuous Integration频繁合并代码回主干,自动跑检查与测试每次 push / PR测试报告、构建产物
持续交付Continuous Delivery在 CI 之上自动部署到类生产环境并验证每次合并通过可随时发布的制品上线前点一次批准
持续部署Continuous Deployment制品通过全部门禁后自动发布到生产每次合并通过生产环境
开发提交 push
-> CI:代码检查 -> 单元测试 -> 构建
-> 产出制品(artifact / 镜像)
-> 自动部署测试环境 -> 自动化验收测试
持续交付:... -> 等待人工批准 -> 生产
持续部署:... -> 门禁全绿 -> 直接进生产

一句话概括:CI 关注“代码合进去能不能用”,持续交付关注“随时可发布”,持续部署关注“已经发出去了”。持续部署的前提是自动化测试足够可靠、监控和回滚足够快,否则就是裸奔。

1.2 流水线解决什么问题#

  • 人为失误。手工部署是 SSH 登录、git pull、装依赖、构建、重启进程,中间任何一步打错字、走错目录、忘清缓存都会变成事故。人类在重复劳动面前的错误率是稳定的,交给机器即可。
  • 环境差异。“在我机器上是好的”本质是构建环境不可复现:Node 版本、系统库、本地全局包、.env 里别人没有的一行配置。流水线把构建固定在干净的、声明式的机器上。
  • 反馈太慢。一次提交等一整天才发现编译不过,修复成本指数上升。CI 把发现问题的时间从小时级压到分钟级。

顺带解决的还有:交付过程可审计(谁、何时、把哪个 commit 发上了线)、部署可重复(同一个 commit 结果一致)、新人上手成本低(部署方式写在 YAML 里,而不是老员工脑子里)。

二、一条健康流水线的组成部分#

2.1 阶段划分与耗时预算#

阶段目的常见耗时失败时的动作
代码检查 lint / typecheck拦住低级错误与风格分歧10s ~ 2min立即失败,不进入测试
单元测试验证逻辑正确性,覆盖率兜底30s ~ 5min失败即停
构建 build产出可部署制品1min ~ 10min失败即停
制品归档 artifact让后续阶段与回滚有据可依10s ~ 1min极少失败
部署测试环境验证能否在真实环境跑起来30s ~ 5min自动回滚到上一版
自动化测试 E2E / Smoke端到端验证关键链路2min ~ 20min阻断生产部署
部署生产发布30s ~ 5min触发回滚与告警

理想状态下,从 push 到“知道有没有问题”应在 10 分钟以内。超过这个阈值,开发者就会切去做别的事,反馈循环断裂。

2.2 设计原则#

  • 快速反馈优先:最快、最容易失败的检查放最前。lint 挂了没必要跑 5 分钟单测。
  • 失败即停 fail fast:默认行为即如此;除非检查彼此独立且并行成本低,不要用 continue-on-error 吞掉失败。
  • 可重复 reproducible:同一 commit 任何时候跑出的制品应一致。锁文件必须提交,基础镜像用精确 tag 而不是 latest
  • 幂等 idempotent:部署脚本跑一次和跑三次结果一样。“先删目录再建”这种半途失败会留下残骸的写法要避免。
  • 一切代码化 pipeline as code:流水线定义进仓库、跟着分支走,能 review、能回滚。网页上点出来的 Jenkins Job 是反面教材。
  • 最小权限:流水线默认只拿它能拿到的权限,多余的显式关掉。

三、GitHub Actions 核心概念#

概念含义类比
workflow一个 YAML 文件定义的自动化流程一条流水线
jobworkflow 内的一组 step,跑在同一个 runner 上流水线里的一个阶段
stepjob 内的最小执行单元,一条命令或一个 action命令行的一行
action可复用的封装单元,本地目录或远程仓库一个函数库
runner执行 job 的机器,GitHub 托管或自托管一台构建机
repo/
.github/workflows/
deploy.yml # 主部署流水线
ci.yml # PR 质量门禁
docker-publish.yml # 镜像构建推送

每个 .yml 文件就是一个独立 workflow,彼此默认并行、互不阻塞。命名建议动词开头、语义化。多个 workflow 的协作有三种方式:靠触发条件天然分工(ci.ymlpull_requestdeploy.yml 只跑 push: branches: [master]);用 workflow_run 在一个 workflow 完成后触发另一个;用 workflow_call 把公共逻辑抽成可复用 workflow。

四、触发器详解#

4.1 常用事件#

on:
push:
branches: [master, "release/**"]
pull_request:
branches: [master]
types: [opened, synchronize, reopened]
schedule:
- cron: "0 2 * * *"
workflow_dispatch:
inputs:
environment: { type: choice, options: [staging, production] }
release:
types: [published]

push 最常用,往往配合 branches 限定主干或发布分支。pull_request 用于质量门禁,注意来自 fork 的 PR 拿不到仓库 secrets(这是安全设计,不是 bug)。schedule 用 cron 跑定时任务,比如每天凌晨的依赖漏洞扫描,GitHub 用的是 UTC,写 0 2 * * * 实际是北京时间上午 10 点。workflow_dispatch 提供手动按钮,适合“一键回滚”“手动发布生产”这类需要人决策的场景。releasepush: tags 用于版本发布。

4.2 分支与路径过滤#

on:
push:
branches-ignore: ["dependabot/**"]
paths: ["src/**", "package.json", "pnpm-lock.yaml"]
paths-ignore: ["**.md", "docs/**"]

paths 是白名单、paths-ignore 是黑名单,二者不能在同一事件下同时使用。一个坑:一次提交同时包含代码和文档改动时,paths-ignore 不会拦下这次运行——只要有任何一条改动命中 paths 就触发。路径过滤是省成本的第一手段,文档、图片的改动不该烧构建时长。

4.3 并发控制与防重复触发#

concurrency.group 是并发锁的键,写法是 concurrency: { group: deploy-blog, cancel-in-progress: false }。同一 group 内默认排队等待前一个结束;加上 cancel-in-progress: true 则直接取消正在跑的那个。

两种情况要分开处理:CI 类(lint / test)用 cancel-in-progress: true,连续 push 三次只跑最后一次,前两次已无人关心;部署类必须用 false,部署跑到一半被取消很可能留下半新半旧的状态,同时用固定的 group: deploy-production 保证同一环境串行部署。其他防重复手段:if: github.actor != 'dependabot[bot]' 跳过机器人,github.event.pull_request.draft == false 跳过草稿 PR。

五、Job 与 Step#

5.1 runs-on 选型#

  • ubuntu-latest:最便宜、启动最快(通常 10 秒内)、生态最全,绝大多数场景首选。
  • windows-latest:构建 .NET、MSI、原生 Windows 应用时用,启动慢,计费是 Linux 的 2 倍。
  • macos-latest:构建 iOS / macOS 必需,计费是 Linux 的 10 倍,能不用就不用。
  • 自托管 runner:需要访问内网、需要更强硬件、或想省成本时用。代价是自己维护(系统更新、磁盘清理),以及最要命的一条:fork 来的 PR 能在你的机器上执行任意代码,除非配了环境与审批。

5.2 needs 依赖与并行执行#

jobs:
deploy:
needs: [lint, test] # lint / test 两个 job 并行跑,都成功才轮到 deploy
runs-on: ubuntu-latest
steps: [{ run: echo deploy }]

没有 needs 关系的 job 默认并行;加上 needs 就形成 DAG,deploy 会等 linttest 都成功才开始。也能用 needs.lint.outputs.xxx 在 job 间传数据。

5.3 条件执行#

常用条件:github.refgithub.event_namesuccess() / failure() / always()needs.job.resultfailure() 常用来做“失败了才发通知”“失败了才上传日志”,形如 if: failure();只在主干部署用 if: github.ref == 'refs/heads/master' && github.event_name == 'push'

5.4 matrix 矩阵构建#

jobs:
test:
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest]
node: [20, 22]
exclude: [{ os: windows-latest, node: 20 }]
include: [{ os: ubuntu-latest, node: 22, coverage: true }]

matrix 把组合自动展开成多个并行 job,用一份 YAML 覆盖“多 Node 版本 x 多操作系统”。exclude 剔除不需要的组合,include 追加额外变量。fail-fast 默认 true(任一组合失败就取消其余),跑兼容性验证时设为 false,一次性看到全部平台的失败情况。

六、常用官方 action 实战#

6.1 checkout 与 setup-node#

- uses: actions/checkout@v4
with: { fetch-depth: 0 } # 需要 git 历史(如生成 changelog)时设为 0
- uses: actions/setup-node@v4
with: { node-version: 22, cache: pnpm }

checkout 默认 fetch-depth: 1 浅克隆,速度快;需要 git describe、生成 changelog 或做差异分析时要显式设 0setup-nodecache 支持 npm / yarn / pnpm,缓存包管理器全局 store,key 默认基于锁文件哈希,这一行通常能省掉 30% 到 70% 的安装时间。

6.2 actions/cache 自定义缓存#

- uses: actions/cache@v4
with:
path: |
node_modules/.vite
~/.local/share/pnpm/store
key: ${{ runner.os }}-build-${{ hashFiles('pnpm-lock.yaml') }}-${{ github.sha }}
restore-keys: ${{ runner.os }}-build-

缓存 key 的设计核心是分层降级:第一级带 github.sha,精确命中“这个 commit 曾构建过”(重跑场景);第二级 restore-keys 去掉 sha,命中“同一锁文件的任意一次构建”;第三级只带操作系统兜底。提升命中率的关键:把稳定的因子放前面(runner.os -> 语言版本 -> 锁文件哈希 -> sha);锁文件是缓存的锚点,它一变依赖缓存必然失效,这是正确行为,不要绕过;缓存路径要精确,跨平台缓存整个 node_modules 经常因二进制平台相关而出错,更稳妥的是缓存包管理器 store 或构建工具自己的缓存目录。缓存有 10GB 上限,且 7 天不访问会被清理。

6.3 artifact 上传与下载#

- uses: actions/upload-artifact@v4
with:
name: dist-${{ github.sha }}
path: dist/
retention-days: 30
# 另一个 job 中
- uses: actions/download-artifact@v4
with: { name: "dist-${{ github.sha }}", path: dist }

artifact 是跨 job 传递文件的唯一官方方式(同一 job 内的 step 共享文件系统,不需要它)。retention-days 可以用来保存“上一版制品”以实现回滚,是最省事的方案。

6.4 发布与镜像构建#

- uses: softprops/action-gh-release@v2
if: startsWith(github.ref, 'refs/tags/v')
- uses: docker/build-push-action@v6
with:
context: .
push: true
platforms: linux/amd64,linux/arm64
tags: ${{ steps.meta.outputs.tags }}
cache-from: type=gha
cache-to: type=gha,mode=max

cache-from: type=gha 用的是 Actions 自带的缓存后端,专为 Docker 层缓存设计,比手工管 actions/cache 更贴合。

七、环境变量与密钥管理#

7.1 env、vars、secrets 的区别与优先级#

类型定义位置是否加密典型用途能否出现在日志
envworkflow / job / step 三级普通配置、路径、版本号可以
vars仓库/组织/环境变量面板非敏感部署参数(域名、区域)可以
secrets仓库/组织/环境变量面板密码、token、私钥会被自动打码

作用域覆盖顺序由窄到宽生效:step 级 env > job 级 env > workflow 级 envsecretsvars 需要用 ${{ }} 表达式取值注入。

env: { NODE_ENV: production } # workflow 级,被下面 job 级覆盖
jobs:
deploy:
env: { TARGET: production } # job 级,覆盖同名 workflow 变量
steps:
- run: echo "部署到 $TARGET"
env: { DEPLOY_HOST: "${{ secrets.DEPLOY_HOST }}" } # step 级

7.2 GITHUB_TOKEN、权限与审批门禁#

每次运行都会自动获得 GITHUB_TOKEN,默认权限在仓库设置里可配(历史默认读写全开,现在新仓库默认只读)。显式声明权限是最佳实践,也是安全审计最容易挑出来的问题。

permissions: { contents: read } # workflow 顶层:保守基线
jobs:
publish:
permissions: { contents: read, packages: write, id-token: write }

在 Settings -> Environments 里创建 stagingproduction,可配置必需审批人(人工点击才继续)、允许部署的分支、以及该环境专属 secrets。这是用 GitHub 原生能力实现“持续交付”的那道人工闸门。

部署 job 上声明环境即接入这道门禁:

deploy-prod:
environment: { name: production, url: https://sakura-hu.top }

7.3 OIDC 免密钥访问云厂商#

把云厂商 AccessKey 存成长期 secret 是常见反模式:一旦泄露,攻击者可以一直用。OIDC 让 GitHub 给 job 签发短期 token,云平台验证签发者与仓库身份后临时发放凭证,仓库里不再存任何云凭证。

用法是 aws-actions/configure-aws-credentialsrole-to-assume(形如 arn:aws:iam::123456789012:role/github-actions-deploy)与 aws-region,并给 job 声明 permissions: { id-token: write }

AWS、GCP、Azure、阿里云、腾讯云都已支持。

7.4 常见误用#

  • 把密钥硬编码进 YAML。即使删掉,它也已躺在 git 历史里。密钥一旦入库,唯一正确的处理是立即轮换。
  • 在 PR 里打印 secretsecho ${{ secrets.X }} 会写进日志,虽然 GitHub 会打码,但打码可以被编码、切片绕过,不要依赖它。
  • pull_request_target 的陷阱。这个触发器能在有 secrets 的上下文里运行。一旦你在这个 workflow 里 checkout 并执行 PR 的代码(比如装了对方改过的依赖),就等于把 secrets 交给任意陌生人。要用必须配环境审批,且绝不执行 PR 提供的代码。
  • 把 secrets 传给第三方 action。第三方 action 能读走你上下文里的所有东西,用之前先看源码。

八、实战一:静态博客自动部署#

目标:git push origin master 之后,站点自动构建并同步到自建服务器,无需人工介入。

8.1 完整 workflow#

name: Deploy Blog
on:
push: { branches: [master] }
workflow_dispatch:
concurrency: { group: deploy-blog, cancel-in-progress: false }
permissions: { contents: read }
jobs:
build-and-deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with: { version: 9 }
- uses: actions/setup-node@v4
with: { node-version: 22, cache: pnpm }
- run: pnpm install --frozen-lockfile
env: { CI: "1" }
- run: pnpm build
env: { CI: "1" }
- name: 校验产物
run: |
test -d dist || { echo "dist 不存在"; exit 1; }
test -f dist/index.html || { echo "缺少 index.html"; exit 1; }
- name: 通过 SFTP 上传到服务器
uses: wlixcc/SFTP-Deploy-Action@v1.2.5
with:
server: ${{ secrets.DEPLOY_HOST }}
username: ${{ secrets.DEPLOY_USER }}
password: ${{ secrets.DEPLOY_PASS }}
port: 22
local_path: ./dist/*
remote_path: /www/wwwroot/sakura-hu.top/
sftp_only: true
delete_remote_files: false

8.2 secrets 配置与增量同步#

在 Settings -> Secrets and variables -> Actions 新建三个 Repository secrets:

Secret 名含义示例
DEPLOY_HOST服务器地址或域名sakura-hu.top
DEPLOY_USERSSH 用户名deploy
DEPLOY_PASSSSH 密码强密码

更稳妥的是用 SSH 密钥:公钥放服务器 ~/.ssh/authorized_keys,私钥存成 DEPLOY_KEY,action 里换成 key: ${{ secrets.DEPLOY_KEY }};密码方案配置简单但没法轮换和精细授权。给部署单独建受限用户、只授予站点目录写权限,比用 root 安全得多。

delete_remote_files: false 表示只覆盖同名文件、不删服务器上多余文件:好处是本地产物缺了某文件时旧文件还在,不会硬性 404;坏处是删掉的文章会一直留在服务器上。对带 hash 文件名的静态站点(Astro 产出 index.a1b2c3.js 这类名字),全量上传代价可观——每次构建资源 hash 都可能变,几百个小文件逐个走 SFTP,单次部署要几分钟。要做增量同步,rsync 更合适,它按“大小 + 修改时间”判断差异:

- name: 写入部署私钥并增量同步
run: |
mkdir -p ~/.ssh && chmod 700 ~/.ssh
printf '%s' "${{ secrets.DEPLOY_KEY }}" > ~/.ssh/key && chmod 600 ~/.ssh/key
ssh-keyscan ${{ secrets.DEPLOY_HOST }} >> ~/.ssh/known_hosts 2>/dev/null
rsync -avz --delete --exclude='.well-known/' -e "ssh -i ~/.ssh/key" \
./dist/ ${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}:/www/wwwroot/sakura-hu.top/

--delete 是开启的:用了 rsync 就该做完整镜像同步,保证“服务器状态 = 构建产物”,避免残留文件造成诡异问题(比如旧 service worker 缓存住旧页面)。--exclude 用来保护服务器上证书校验之类的目录;-z 开压缩,静态站点压缩率很高。

8.3 变体:同步到对象存储与 CDN#

托管在对象存储(COS / OSS / S3 / R2)时把最后一步换掉即可。要点是静态资源长缓存、HTML 短缓存

Terminal window
rclone sync ./dist/_astro oss:my-bucket/_astro \
--header-upload "Cache-Control: public, max-age=31536000, immutable"
rclone sync ./dist oss:my-bucket --exclude "_astro/**" \
--header-upload "Cache-Control: public, max-age=300"

sync 会删除目标端多余文件(双向对齐,保证远端与构建产物一致),copy 只增不删,远端还存着别的东西时用 copy

九、实战二:构建 Docker 镜像并推送镜像仓库#

9.1 多阶段 Dockerfile#

FROM node:22-alpine AS builder
WORKDIR /app
RUN corepack enable
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
COPY . .
RUN pnpm build
FROM nginx:1.27-alpine AS runner
COPY --from=builder /app/dist /usr/share/nginx/html
HEALTHCHECK --interval=30s --timeout=3s --retries=3 \
CMD wget -qO- http://127.0.0.1/ || exit 1
CMD ["nginx", "-g", "daemon off;"]

构建工具链只存在于构建阶段,运行镜像里完全没有,体积更小、攻击面更小。不变的层放前面(先 COPY 锁文件装依赖,最后才 COPY . .),改业务代码时依赖层能命中缓存。

9.2 构建并推送的 workflow#

name: Build and Push Image
on:
push: { branches: [master], tags: ["v*"] }
permissions: { contents: read, packages: write }
jobs:
docker:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository }}
tags: |
type=semver,pattern={{version}}
type=sha,prefix=sha-,format=short
type=raw,value=latest,enable={{is_default_branch}}
- uses: docker/build-push-action@v6
with:
context: .
push: true
platforms: linux/amd64,linux/arm64
tags: ${{ steps.meta.outputs.tags }}
cache-from: type=gha
cache-to: type=gha,mode=max

推 Docker Hub 只需把 registry 换成 docker.ioimages 换成 用户名/仓库名,密码用 DOCKERHUB_TOKEN

9.3 标签策略#

标签生成规则用途是否适合回滚
sha-abc1234每次提交精确锁定某个 commit 的镜像是(最可靠)
1.4.2打 tag 时语义化版本发布
1.4打 tag 时跟随次版本是(会移动)
latest默认分支方便本地拉取否(会被覆盖)

生产部署永远不要用 latest,用 sha- 或语义化版本,回滚时把 tag 换回上一版即可。注意跨平台构建(linux/amd64,linux/arm64)走 QEMU 模拟,耗时是单平台的好几倍;只有 x86 服务器时去掉 platforms 能显著提速。

9.4 服务器拉取新镜像并重启#

最轻量的做法是服务器上放一个脚本,由流水线通过 SSH 触发:

#!/usr/bin/env bash
set -euo pipefail
IMAGE="ghcr.io/owner/blog:sha-${1:?usage: deploy.sh <sha>}"
echo "$GHCR_TOKEN" | docker login ghcr.io -u owner --password-stdin
docker pull "$IMAGE"
docker compose up -d --no-deps --force-recreate blog
for i in $(seq 1 15); do
curl -fsS -m 3 http://127.0.0.1:8080/healthz >/dev/null && exit 0
sleep 2
done
echo "健康检查失败,回滚"
PREV=$(cat /opt/blog/.previous-image)
sed -i "s|image:.*|image: ${PREV}|" docker-compose.yml
docker compose up -d --no-deps --force-recreate blog
exit 1
- uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.DEPLOY_HOST }}
username: ${{ secrets.DEPLOY_USER }}
key: ${{ secrets.DEPLOY_KEY }}
script: /opt/blog/deploy.sh ${{ github.sha }}

脚本把成功部署的镜像名写进 .previous-image,回滚时读出来用——这就是最简单可靠的一键回滚。

十、实战三:Node/前端项目的质量门禁#

目标:PR 上自动跑 lint / 类型检查 / 单测 / 构建,结果以评论回贴,只有全绿才允许部署。

name: CI
on:
pull_request: { branches: [master] }
push: { branches: [master] }
concurrency: { group: "ci-${{ github.ref }}", cancel-in-progress: true }
permissions: { contents: read, pull-requests: write }
jobs:
check:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix: { task: [lint, typecheck, test, build] }
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with: { version: 9 }
- uses: actions/setup-node@v4
with: { node-version: 22, cache: pnpm }
- run: pnpm install --frozen-lockfile
- run: pnpm ${{ matrix.task }}
- if: matrix.task == 'test'
uses: codecov/codecov-action@v5
with: { files: ./coverage/lcov.info, fail_ci_if_error: false }
comment:
needs: [check]
if: always() && github.event_name == 'pull_request'
runs-on: ubuntu-latest
steps:
- uses: marocchino/sticky-pull-request-comment@v2
with: { header: ci-result, message: "CI 结果:${{ needs.check.result }}" }
deploy:
needs: [check]
if: github.ref == 'refs/heads/master' && github.event_name == 'push'
runs-on: ubuntu-latest
steps: [{ run: echo "全部检查通过,开始部署" }]

设计要点:用 matrix 让四个检查并行,总耗时约等于最慢的那个而不是四个之和(每个组合仍独立串行);commentif: always(),某个检查失败时评论依然会贴上结果,而不是什么都不说;deployneeds: [check] 卡住——check 矩阵中任一组合失败,部署 job 直接跳过,这是“质量门禁”最直接的实现;覆盖率上传用 fail_ci_if_error: false,覆盖率服务偶发抽风不该阻断主流程,若把覆盖率当硬指标就改成 true 并加阈值检查。

十一、部署策略#

11.1 四种策略对比#

策略原理停机回滚速度资源成本适用场景
停机部署停旧版,发新版有(秒级到分钟级)1 倍内部系统、可接受短暂不可用
滚动更新逐批替换实例,新的就绪后再下线旧的1 倍多K8s 默认,无状态服务
蓝绿部署两套完整环境,流量一次性切换极快(切回旧环境)2 倍关键业务、需要秒级回滚
金丝雀发布新版本只接 1% ~ 10% 流量,观察后逐步放量快(流量切回旧版)1.x 倍大流量、降低发布风险

蓝绿的核心是两套环境 + 一个流量开关:新版部署到绿色环境 -> 跑冒烟测试 -> 负载均衡把流量从蓝色切到绿色 -> 观察后保留蓝色作为回滚目标,回滚就是再切一次。

金丝雀的核心是按比例放量 + 指标自动决策:导入 1% 流量 -> 观察错误率、延迟、核心业务指标 -> 正常则 5% -> 25% -> 50% -> 100%,任一步恶化就自动回滚。相比蓝绿,它多了“在小流量下发现测试覆盖不到的问题”的能力,代价是需要完善的指标监控与流量切分。实践中两者组合:蓝绿做底层发布单元,金丝雀做流量层策略。

11.2 健康检查与就绪探针#

切换流量的前提是新版本真的能服务。存活探针(liveness)判断进程是否还在,失败就重启;就绪探针(readiness)判断能否接流量,失败就从负载均衡摘除但不重启。

readinessProbe:
httpGet: { path: /healthz, port: 8080 }
initialDelaySeconds: 5
periodSeconds: 5
livenessProbe:
httpGet: { path: /livez, port: 8080 }

/healthz 不应只检查进程存活,还应检查下游依赖(数据库、缓存)。流水线的部署脚本必须把健康检查当门禁:部署完成 -> 轮询健康端点最多 N 次 -> 全部失败就自动回滚并让 job 失败。没有这一步,所谓自动部署只是自动把故障推上线。

11.3 数据库迁移的处理原则#

数据库是 CI/CD 里最难原子化的部分——代码可以两套并存,schema 不能。三条原则:

  1. 向前兼容。先上兼容新旧两版的 schema 变更,再上使用新结构的代码。
  2. 先迁移后发布。迁移脚本独立执行,且要能在旧版本应用还在跑时执行而不出错(新增可空列、新增表、加索引都符合)。
  3. 可回滚的迁移。删表、删列、改类型必须拆成多次发布。回滚应用代码时数据库不应该需要回滚,因为迁移本身向前兼容。
发布 1:新增 new_status 列(可空)-> 代码双写 status 和 new_status
发布 2:数据迁移脚本回填 new_status
发布 3:代码只读 new_status
发布 4:删除旧列 status

用了 ORM 自动建表的话,务必关掉生产环境的 synchronize / auto-migrate,迁移必须是一次性脚本且能被审批。

十二、回滚与故障处理#

回滚能多快,取决于上一版制品还在不在。容器镜像每个版本有独立 tag,回滚就是改 tag 重启;静态站点在服务器保留 releases/current 两个目录,用软链接切换;artifact 配合 retention-days: 90,需要时手动下载重新部署。

一键回滚的最小可行版本是三条命令:拉上一个镜像 tag -> 重启容器 -> 健康检查。做成独立 workflow 用 workflow_dispatch 触发:

name: Rollback
on:
workflow_dispatch:
inputs:
version: { description: "回滚到的镜像 tag(留空则回滚上一版)", default: "" }
jobs:
rollback:
runs-on: ubuntu-latest
environment: production
steps:
- uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.DEPLOY_HOST }}
username: ${{ secrets.DEPLOY_USER }}
key: ${{ secrets.DEPLOY_KEY }}
script: /opt/blog/rollback.sh ${{ github.event.inputs.version }}

它挂在 environment: production 上,只有被授权的审批人能触发——回滚是高权限操作,值得单独一道门。

失败通知的价值在于缩短“故障被发现”的时间。邮件是 GitHub 原生能力、零成本但容易被忽略;企业微信 / 钉钉机器人群 webhook 一个 curl 就能打通;Slack 用官方 action 或 webhook。

- name: 失败通知
if: failure()
run: |
curl -sS -X POST "${{ secrets.WECOM_WEBHOOK }}" \
-H "Content-Type: application/json" \
-d "{\"msgtype\":\"markdown\",\"markdown\":{\"content\":\"**部署失败** ${{ github.repository }} @ ${{ github.sha }} [日志](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})\"}}"

通知里一定要带跳转到运行日志的链接,否则收到消息的人还要自己去翻,等于没通知。另一个原则是只在状态变化时通知,失败就一直刷屏的机器人很快会被所有人静音。

十三、流水线优化#

  1. 提高缓存命中率。key 分层、稳定因子放前、缓存包管理器 store 而不是 node_modules。命中率能在日志里看到(Cache restored from key: xxx),低于 70% 基本可以判断 key 设计有问题。
  2. 并行化拆分。把线性串联的检查改成并行 job。但每次拆分会多一次 checkout + install,单个 job 只有 30 秒时拆开反而更慢——用装依赖那一步的耗时来权衡。
  3. 只在必要时跑重任务paths 过滤让文档改动不触发构建;if 让 PR 只跑测试不跑部署。
  4. 用 matrix 代替重复 YAML。同一套步骤跑 4 个 Node 版本写一份 matrix 就行,不要复制粘贴四遍 job。
  5. 用 composite action 抽公共步骤。把“安装 pnpm + setup-node + install”这段每个 job 都重复的样板抽成本地 action。
  6. 自托管 runner 的取舍。省钱的场景(私有仓库按分钟计费)和内网访问场景值得上,但要考虑维护成本、单点故障与安全风险。个人项目用托管 runner 的免费额度通常足够。
  7. 减少不必要的 setup。不需要 Node 的 job 别写 setup-node,纯粹拖慢启动。

十四、其他 CI/CD 工具对比#

工具上手成本生态自托管难度适合场景
GitHub Actions极大(Marketplace 上万 action)中(需自托管 runner)代码在 GitHub,开源首选
GitLab CI/CD低(与 GitLab 一体化)代码在 GitLab,要完整 DevOps 平台
Jenkins极大但老化高(自己维护 Master + Agent)老牌企业、强定制、内网环境
CircleCI不支持(纯 SaaS)追求构建速度,配置简洁
Drone低(容器化,很轻)想自建一套轻量流水线
Argo CD低(K8s 原生)K8s 集群的 GitOps 声明式部署
Tekton低(K8s CRD)想把 CI/CD 也当 K8s 资源的平台团队

Jenkins Pipeline as Code:用 Jenkinsfile(声明式或脚本式 Groovy)把流水线定义进仓库,思路与 Actions 的 YAML 一致,但语法更灵活也更难写对。优势是插件生态和“什么都能干”,劣势是插件质量参差、升级容易炸、维护成本高。

Argo CD 与 GitOps:核心思想是以 Git 仓库中的声明式配置为唯一事实来源,集群实际状态由控制器持续向 Git 对齐。它和传统“CI 推部署”相反——CI 只负责构建镜像并更新 Git 里的 manifest(改镜像 tag),Argo CD 监测到变化后主动拉取并同步。好处是部署状态可审计(Git 历史即部署历史)、回滚就是 revert 一个 commit、集群被手动改乱也能自动纠偏。

传统 push 模式:CI -> 直接调用 K8s API 部署
GitOps pull 模式:CI -> 改 Git manifest 里的镜像 tag -> Argo CD 监测差异 -> 同步到集群

Tekton:把每一步定义成 K8s CRD(Task、Pipeline、PipelineRun),跑在 Pod 里。灵活性极高,代价是 YAML 冗长、学习曲线陡,适合已有成熟 K8s 平台的团队。

选型建议:默认选代码托管平台自带的 CI/CD,只有当它满足不了需求(内网、特殊硬件、合规)时才考虑 Jenkins 或自建方案;K8s 环境上 GitOps 值得单独评估。

十五、安全与合规#

依赖漏洞扫描。Dependabot 配置即可自动开 PR 升级有漏洞的依赖:

version: 2
updates:
- { package-ecosystem: npm, directory: /, schedule: { interval: weekly } }
- { package-ecosystem: github-actions, directory: /, schedule: { interval: weekly } }

镜像层面用 Trivy,SAST 用免费的 CodeQL:

- uses: aquasecurity/trivy-action@0.28.0
with:
image-ref: ghcr.io/${{ github.repository }}:sha-${{ github.sha }}
severity: "CRITICAL,HIGH"
exit-code: "1"
- uses: github/codeql-action/init@v3
with: { languages: javascript-typescript }
- uses: github/codeql-action/analyze@v3

供应链安全。第三方 action 本质上是能在你流水线里执行任意代码的程序,几条硬规则:固定版本(actions/checkout@v4 里的 v4 是可移动 tag,严格做法是固定到完整 commit SHA,Dependabot 会自动帮你更新);只引入可信 action,用前看一眼 action.yml 和源码;限制 GITHUB_TOKEN 权限;不要把 secrets 暴露给 fork PR。

审计日志。Actions 的运行记录本身就是审计日志:谁触发的、什么时间、部署了哪个 commit。把它同步到内部系统,或在部署后往 release 里写一条记录,能让“线上这个版本对应哪个 commit”随时可查。

十六、面试高频问题#

Q1:CI 和 CD 的区别? CI 是持续集成:频繁把代码合并到主干,每次合并自动跑构建与测试,尽早发现集成问题。CD 有两个含义——持续交付是每次合并后自动部署到类生产环境、验证通过后等待人工批准上线;持续部署则连人工批准也省掉。三者是递进关系。

Q2:如何保证部署的原子性? 分层次答。制品层面:每次构建产出唯一标识的不可变制品(如 sha 镜像),部署时整体替换而不是原地改文件。切换层面:用蓝绿或滚动更新,让“新版本完全就绪”和“流量切过去”之间没有中间态;K8s 的 RollingUpdate 靠 readiness 探针保证新 Pod 就绪才接流量。数据库层面做不到严格原子,靠向前兼容的多次发布规避。

Q3:数据库迁移怎么在 CI/CD 里做? 核心是“代码和 schema 不能同时大改”。原则是向前兼容、先迁移后发代码、不可逆操作拆多次发布。具体用 expand-contract 模式:先加新列并双写,回填数据,切读,最后删旧列。迁移脚本单独执行且可审计,禁止应用启动时自动同步 schema。

Q4:密钥怎么管? 不用明文、不入库、不打印。具体:仓库或环境级 secrets 存储,通过环境变量注入;云上优先用 OIDC 换短期凭证而不是长期 AccessKey;最小权限,每个环境独立密钥;定期轮换;CI 日志里避免 echo 密钥。如果不小心提交进 git,立即轮换而不是只删 commit。

Q5:Jenkins 和 GitHub Actions 的差异? Actions 是托管 SaaS,配置是仓库内 YAML,与代码一起版本化,生态是 Marketplace 的复用 action,上手快、维护成本低,缺点是内网资源场景要自托管 runner、深度定制能力有限。Jenkins 是自建服务,用 Groovy 写 Pipeline,插件生态庞大、几乎能做任何事,适合内网和高度定制,但插件兼容、版本升级、Master 高可用都要自己扛。一句话:新项目默认 Actions,内网或强定制才考虑 Jenkins。

Q6:怎么做灰度发布? 三步说。一是流量切分:在网关或 Service Mesh 层按比例 / 按用户特征把一部分流量路由到新版本。二是部署与观察:先放 1%~10% 流量,观察错误率、P95 延迟和核心业务指标,设定自动回滚阈值。三是渐进放量:指标正常就按 5% -> 25% -> 50% -> 100% 扩大,任一步异常立刻切回旧版本。前提是可观测性到位——没有指标就没有灰度的判断依据。

十七、速查表#

17.1 触发器与并发#

on:
push:
branches: [master, "release/**"]
tags: ["v*"]
paths: ["src/**", "package.json"]
pull_request: { branches: [master] }
schedule:
- cron: "0 2 * * *" # UTC,北京时间为 10:00
workflow_dispatch:
inputs:
env: { type: choice, options: [staging, production] }
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true # 部署类改 false

17.2 Job 骨架#

permissions: { contents: read }
jobs:
build:
runs-on: ubuntu-latest
strategy: { matrix: { node: [20, 22] } }
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: "${{ matrix.node }}", cache: npm }
- run: npm ci && npm run build
env: { API_BASE: "${{ vars.API_BASE }}", TOKEN: "${{ secrets.TOKEN }}" }
- uses: actions/upload-artifact@v4
with: { name: "dist-${{ matrix.node }}", path: dist/ }
deploy:
needs: build
if: github.ref == 'refs/heads/master'
environment: production
runs-on: ubuntu-latest
steps:
- uses: actions/download-artifact@v4
with: { name: dist-22, path: dist }

17.3 常见坑一览#

现象原因解法
多行 run 被压成一行YAML 里 run: 没加 |写成 run: | 后接缩进的多行内容
secrets 在 PR 里是空的fork PR 不传 secrets用环境审批,或谨慎使用 pull_request_target
部署脚本莫名被取消concurrency 配了 cancel-in-progress: true部署类改为 false,并用独立 group
缓存永远不命中key 里混了每次都变的因子(sha、时间戳)hashFiles 放前段,sha 只做精确匹配层
改文档也跑全量流水线没配 paths / paths-ignore加上路径过滤
定时任务时间不对cron 用 UTC按 +8 小时换算
git describe 报错默认浅克隆checkoutfetch-depth: 0
上传 artifact 报 name 重复多 job 用了同一个 namename 里带 ${{ matrix.x }} 区分
部署后页面 404全量上传缺文件,或 rsync 删了构建不完整的内容构建后先校验 index.html 存在再同步
latest 回滚不了标签被覆盖生产用 sha- 或语义化版本
自托管 runner 卡死磁盘满 / 无清理定期清 docker 与 workspace,设 timeout-minutes
权限报 403GITHUB_TOKEN 默认只读显式写 permissions: packages: write

文章分享

如果这篇文章对你有帮助,欢迎分享给更多人!

CI/CD 持续集成与部署实战
https://sakura-hu.top/posts/devops/cicd持续集成与部署实战/
作者
Sakura
发布于
2026-09-11
许可协议
CC BY-NC-SA 4.0

评论区

Profile Image of the Author
Sakura
Hello, I'm Sakura.
公告
欢迎来到我的博客! 我是Sakura,祝你拥有美好的一天~
分类
标签
最新动态
站点统计
文章
21
分类
7
标签
35
总字数
85,101
运行时长
0
最后活动
0 天前
站点信息
构建平台
GitHub Actions
博客版本
Firefly v6.16.7
文章许可
CC BY-NC-SA 4.0
文章目录