CI/CD 持续集成与部署实战
- 1Docker 入门与实战
- 2Docker Compose 多容器编排实战
- 3Kubernetes 核心概念与实战入门
- 4CI/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 文件定义的自动化流程 | 一条流水线 |
| job | workflow 内的一组 step,跑在同一个 runner 上 | 流水线里的一个阶段 |
| step | job 内的最小执行单元,一条命令或一个 action | 命令行的一行 |
| action | 可复用的封装单元,本地目录或远程仓库 | 一个函数库 |
| runner | 执行 job 的机器,GitHub 托管或自托管 | 一台构建机 |
repo/ .github/workflows/ deploy.yml # 主部署流水线 ci.yml # PR 质量门禁 docker-publish.yml # 镜像构建推送每个 .yml 文件就是一个独立 workflow,彼此默认并行、互不阻塞。命名建议动词开头、语义化。多个 workflow 的协作有三种方式:靠触发条件天然分工(ci.yml 跑 pull_request,deploy.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 提供手动按钮,适合“一键回滚”“手动发布生产”这类需要人决策的场景。release 与 push: 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 会等 lint 与 test 都成功才开始。也能用 needs.lint.outputs.xxx 在 job 间传数据。
5.3 条件执行
常用条件:github.ref、github.event_name、success() / failure() / always()、needs.job.result。failure() 常用来做“失败了才发通知”“失败了才上传日志”,形如 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 或做差异分析时要显式设 0。setup-node 的 cache 支持 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=maxcache-from: type=gha 用的是 Actions 自带的缓存后端,专为 Docker 层缓存设计,比手工管 actions/cache 更贴合。
七、环境变量与密钥管理
7.1 env、vars、secrets 的区别与优先级
| 类型 | 定义位置 | 是否加密 | 典型用途 | 能否出现在日志 |
|---|---|---|---|---|
env | workflow / job / step 三级 | 否 | 普通配置、路径、版本号 | 可以 |
vars | 仓库/组织/环境变量面板 | 否 | 非敏感部署参数(域名、区域) | 可以 |
secrets | 仓库/组织/环境变量面板 | 是 | 密码、token、私钥 | 会被自动打码 |
作用域覆盖顺序由窄到宽生效:step 级 env > job 级 env > workflow 级 env。secrets 与 vars 需要用 ${{ }} 表达式取值注入。
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 里创建 staging、production,可配置必需审批人(人工点击才继续)、允许部署的分支、以及该环境专属 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-credentials 传 role-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 里打印 secrets。
echo ${{ secrets.X }}会写进日志,虽然 GitHub 会打码,但打码可以被编码、切片绕过,不要依赖它。 pull_request_target的陷阱。这个触发器能在有 secrets 的上下文里运行。一旦你在这个 workflow 里 checkout 并执行 PR 的代码(比如装了对方改过的依赖),就等于把 secrets 交给任意陌生人。要用必须配环境审批,且绝不执行 PR 提供的代码。- 把 secrets 传给第三方 action。第三方 action 能读走你上下文里的所有东西,用之前先看源码。
八、实战一:静态博客自动部署
目标:git push origin master 之后,站点自动构建并同步到自建服务器,无需人工介入。
8.1 完整 workflow
name: Deploy Blogon: 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: false8.2 secrets 配置与增量同步
在 Settings -> Secrets and variables -> Actions 新建三个 Repository secrets:
| Secret 名 | 含义 | 示例 |
|---|---|---|
DEPLOY_HOST | 服务器地址或域名 | sakura-hu.top |
DEPLOY_USER | SSH 用户名 | deploy |
DEPLOY_PASS | SSH 密码 | 强密码 |
更稳妥的是用 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 短缓存。
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 builderWORKDIR /appRUN corepack enableCOPY package.json pnpm-lock.yaml ./RUN pnpm install --frozen-lockfileCOPY . .RUN pnpm buildFROM nginx:1.27-alpine AS runnerCOPY --from=builder /app/dist /usr/share/nginx/htmlHEALTHCHECK --interval=30s --timeout=3s --retries=3 \ CMD wget -qO- http://127.0.0.1/ || exit 1CMD ["nginx", "-g", "daemon off;"]构建工具链只存在于构建阶段,运行镜像里完全没有,体积更小、攻击面更小。不变的层放前面(先 COPY 锁文件装依赖,最后才 COPY . .),改业务代码时依赖层能命中缓存。
9.2 构建并推送的 workflow
name: Build and Push Imageon: 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.io、images 换成 用户名/仓库名,密码用 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 bashset -euo pipefailIMAGE="ghcr.io/owner/blog:sha-${1:?usage: deploy.sh <sha>}"echo "$GHCR_TOKEN" | docker login ghcr.io -u owner --password-stdindocker 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 2done
echo "健康检查失败,回滚"PREV=$(cat /opt/blog/.previous-image)sed -i "s|image:.*|image: ${PREV}|" docker-compose.ymldocker compose up -d --no-deps --force-recreate blogexit 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: CIon: 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 让四个检查并行,总耗时约等于最慢的那个而不是四个之和(每个组合仍独立串行);comment 用 if: always(),某个检查失败时评论依然会贴上结果,而不是什么都不说;deploy 用 needs: [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 不能。三条原则:
- 向前兼容。先上兼容新旧两版的 schema 变更,再上使用新结构的代码。
- 先迁移后发布。迁移脚本独立执行,且要能在旧版本应用还在跑时执行而不出错(新增可空列、新增表、加索引都符合)。
- 可回滚的迁移。删表、删列、改类型必须拆成多次发布。回滚应用代码时数据库不应该需要回滚,因为迁移本身向前兼容。
发布 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: Rollbackon: 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 }})\"}}"通知里一定要带跳转到运行日志的链接,否则收到消息的人还要自己去翻,等于没通知。另一个原则是只在状态变化时通知,失败就一直刷屏的机器人很快会被所有人静音。
十三、流水线优化
- 提高缓存命中率。key 分层、稳定因子放前、缓存包管理器 store 而不是
node_modules。命中率能在日志里看到(Cache restored from key: xxx),低于 70% 基本可以判断 key 设计有问题。 - 并行化拆分。把线性串联的检查改成并行 job。但每次拆分会多一次 checkout + install,单个 job 只有 30 秒时拆开反而更慢——用装依赖那一步的耗时来权衡。
- 只在必要时跑重任务。
paths过滤让文档改动不触发构建;if让 PR 只跑测试不跑部署。 - 用 matrix 代替重复 YAML。同一套步骤跑 4 个 Node 版本写一份 matrix 就行,不要复制粘贴四遍 job。
- 用 composite action 抽公共步骤。把“安装 pnpm + setup-node + install”这段每个 job 都重复的样板抽成本地 action。
- 自托管 runner 的取舍。省钱的场景(私有仓库按分钟计费)和内网访问场景值得上,但要考虑维护成本、单点故障与安全风险。个人项目用托管 runner 的免费额度通常足够。
- 减少不必要的 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: 2updates: - { 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 # 部署类改 false17.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 报错 | 默认浅克隆 | checkout 加 fetch-depth: 0 |
| 上传 artifact 报 name 重复 | 多 job 用了同一个 name | name 里带 ${{ matrix.x }} 区分 |
| 部署后页面 404 | 全量上传缺文件,或 rsync 删了构建不完整的内容 | 构建后先校验 index.html 存在再同步 |
latest 回滚不了 | 标签被覆盖 | 生产用 sha- 或语义化版本 |
| 自托管 runner 卡死 | 磁盘满 / 无清理 | 定期清 docker 与 workspace,设 timeout-minutes |
| 权限报 403 | GITHUB_TOKEN 默认只读 | 显式写 permissions: packages: write 等 |
文章分享
如果这篇文章对你有帮助,欢迎分享给更多人!













