4.9 基于 Git 的 CI/CD 持续部署方案
预计阅读时间:16 分钟
📖 目录
学习目标
学完本章后,你将能够:
- 理解 CI/CD 的核心概念和流水线架构
- 使用 GitHub Actions 配置自动化测试和部署工作流
- 使用 Git Hooks 实现自托管服务器的自动化部署
- 实现 Blue-Green 零停机部署方案
- 制定回滚策略和部署检查清单
核心知识
- CI(持续集成)——开发人员频繁将代码合并到主干,每次合并自动触发构建和测试,尽早发现集成问题
- CD(持续部署/交付)——CI 通过后自动将代码部署到生产环境(持续部署)或准备就绪等待人工确认(持续交付)
- GitHub Actions——GitHub 内置的 CI/CD 平台,通过 YAML 定义工作流,支持事件触发(push、PR 等)
- Git Hooks——Git 版本控制事件触发的本地脚本,如
post-receive在代码推送到远程仓库后执行 - Blue-Green 部署——维护两套生产环境(蓝=当前在线,绿=新版本),切换流量完成更新,实现零停机
- CI 流水线(Pipeline)——代码从提交到部署的自动化流程,通常包括:代码检查→单元测试→构建→集成测试→部署
- 回滚(Rollback)——部署失败时恢复到上一个已知正常版本的能力
知识关联
- 前置知识:2.9:Git 版本控制 Git 版本控制、5.2:Docker 生产实践 Docker 生产实践、3.1:Docker 容器入门 Docker 容器入门
- 后续影响:CI/CD 是 DevOps 文化的核心实践,衔接 6.7:GitOps 入门 GitOps 和 6.2:Kubernetes 入门 Kubernetes 部署
- 配套工具:4.1:Ansible 自动化 Ansible(配置管理)、4.4:Shell 自动化实战 Shell 自动化脚本(部署脚本模板)
原理讲解
为什么需要 CI/CD
传统部署流程:开发在本地写完代码 → 压缩 → scp 到服务器 → 停服务 → 替换文件 → 重启。这种方式的问题包括:手动操作容易出错、部署窗口需要停机、回滚困难、缺乏测试验证。CI/CD 将整个过程自动化:代码推到 Git 即触发 CI 流水线(自动运行测试→构建镜像→推送到镜像仓库→自动部署到目标环境),整个过程无人干预,可重复、可审计、可回滚。
流水线架构
完整的 CI/CD 流水线通常包含以下阶段: ① 触发——代码 push 或 PR merge 到主分支; ② CI 阶段——代码检查(lint)、单元测试、构建产物(如 Docker 镜像); ③ 制品管理——将构建产物推送到制品仓库(Docker Registry、Nexus 等); ④ CD 阶段——将制品部署到目标环境,执行健康检查和烟雾测试; ⑤ 完成——发送通知(Slack/邮件),记录部署版本。
Blue-Green 部署原理
Blue-Green 部署维护两套相同的生产环境(Blue 和 Green)。任意时刻只有一套环境服务用户流量。部署新版本时,将新版本部署到非活跃环境(如 Green),在 Green 上运行测试验证,验证通过后将负载均衡器从 Blue 切换到 Green。如果新版本有问题,只需将流量切回 Blue——回滚秒级完成,且零停机。
流水线设计模式
成熟的 CI/CD 流水线普遍遵循几个可复用的设计模式:Build Once, Deploy Many——CI 只构建一次产物(镜像/制品),所有环境部署同一个 sha 的产物,杜绝"每个环境各自构建"导致的版本漂移;Pipeline as Code——流水线定义文件随代码入库、走 Code Review,任何环境都能重建出同样的流水线;环境门禁递进——dev 自动部署 → staging 自动部署+自动冒烟 → production 人工审批(或 GitOps 合并门禁);失败即止——任一步骤失败立即中止后续阶段并通知,避免坏版本继续向后传播;产物不可变——镜像 tag 用 commit sha 而非 latest,保证每个部署可追溯、可回滚。这些模式与具体平台无关,GitHub Actions、GitLab CI、Jenkins 只是不同的语法外壳。
为什么 CI/CD 要分流水线阶段
将流水线拆分为 lint → test → build → deploy 多个阶段,而非写成一个长脚本,核心原因有三:快速反馈——lint 和单元测试在几秒内完成,如果代码风格有问题或测试失败,开发者在 30 秒内就能得到反馈,而不需要等完整构建完成;资源隔离——不同阶段可以运行在不同类型的 Runner 上(lint/test 用轻量级 runner,build 用大内存 GPU runner),避免资源争抢;失败隔离——test 阶段失败不会触发 build 和 deploy,节省计算资源和时间,同时防止有缺陷的代码进入构建产物。阶段之间的依赖关系(needs)形成有向无环图(DAG),支持并行执行无依赖的阶段,进一步缩短流水线总耗时。
Jenkins vs GitLab CI vs GitHub Actions:平台对比
| 维度 | Jenkins | GitLab CI | GitHub Actions |
|---|---|---|---|
| 部署 | 自托管(Java 服务) | 自托管 / SaaS | SaaS(GitHub 托管 Runner) |
| 配置 | Jenkinsfile(Groovy DSL) | .gitlab-ci.yml(YAML) | .github/workflows/*.yml(YAML) |
| 生态 | 1800+ 插件 | 内置功能为主 | Marketplace |
| 维护成本 | 高(需维护 Jenkins 服务+插件兼容性) | 中(GitLab 升级同步) | 低(全托管) |
| 自托管 Runner | 天然支持 | 支持 | 支持(self-hosted runner) |
| 适用场景 | 遗留系统、深度定制 | 已有 GitLab 的团队 | GitHub 项目首选 |
选型建议:代码在 GitHub → GitHub Actions;代码在 GitLab → GitLab CI;需要深度定制且团队有运维能力 → Jenkins。
为什么推荐 Declarative Pipeline 而不是 Scripted
Jenkins 提供两种 Pipeline 语法:Declarative(声明式)和 Scripted(脚本式)。官方推荐 Declarative 的原因:结构化——强制 pipeline {} / stages {} / steps {} 三层结构,一目了然;可读性——非 Jenkins 专家也能读懂流水线在做什么;内置功能——agent、environment、when、input、post 等声明式指令直接可用,Scripted 需要手动编写 Groovy 代码;Blue Ocean 兼容——Declarative Pipeline 在 Jenkins 的 Blue Ocean 可视化界面中能正确渲染阶段视图。Scripted 语法更灵活(本质是 Groovy 脚本),适合需要复杂条件逻辑的极端场景,但 95% 的流水线用 Declarative 即可覆盖。
示例代码
1. GitHub Actions 完整工作流
# .github/workflows/deploy.yml
name: CI/CD Pipeline
on:
push:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run tests
run: |
npm ci
npm test
build-and-push:
needs: test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build Docker image
run: docker build -t myapp:${{ github.sha }} .
- name: Push to registry
run: |
docker tag myapp:${{ github.sha }} ghcr.io/myorg/myapp:latest
docker push ghcr.io/myorg/myapp:latest
deploy:
needs: build-and-push
runs-on: ubuntu-latest
steps:
- name: Deploy to server via SSH
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ secrets.DEPLOY_HOST }}
username: ${{ secrets.DEPLOY_USER }}
key: ${{ secrets.DEPLOY_KEY }}
script: |
cd /var/www/app
docker compose pull
docker compose up -d --force-recreate
sleep 10
curl -f http://localhost:8080/health
2. 自建 Git Hooks 部署
# 在服务器上创建 bare 仓库并配置 post-receive hook
sudo mkdir -p /var/repo/app.git
cd /var/repo/app.git
sudo git init --bare # 输出: Initialized empty Git repository in /var/repo/app.git/
# 创建 post-receive hook
sudo tee /var/repo/app.git/hooks/post-receive << 'HOOK'
#!/bin/bash
set -euo pipefail
DEPLOY_DIR="/var/www/app"
GIT_DIR="/var/repo/app.git"
echo "=== 开始部署: $(date) ==="
# 检出代码到工作目录
git --work-tree="$DEPLOY_DIR" --git-dir="$GIT_DIR" checkout -f main
# 构建和重启
cd "$DEPLOY_DIR"
docker compose build
docker compose up -d --force-recreate
# 健康检查
sleep 5
if curl -sf http://localhost:8080/health > /dev/null 2>&1; then
echo "=== 部署成功 ==="
else
echo "=== 健康检查失败,触发回滚 ==="
git --work-tree="$DEPLOY_DIR" --git-dir="$GIT_DIR" checkout -f HEAD~1
docker compose up -d --build --force-recreate
exit 1
fi
HOOK
sudo chmod +x /var/repo/app.git/hooks/post-receive
# 本地开发机添加远程仓库
git remote add production ssh://user@server/var/repo/app.git
git push production main # 输出: To ssh://user@server/var/repo/app.git
3. Blue-Green 部署脚本
#!/bin/bash
# blue-green-deploy.sh
set -euo pipefail
APP_NAME="myapp"
BLUE_PORT="8081"
GREEN_PORT="8082"
NGINX_CONF="/etc/nginx/sites-available/${APP_NAME}"
# 确定当前活跃环境
if curl -sf http://localhost:$BLUE_PORT/health > /dev/null 2>&1; then
ACTIVE="blue"
IDLE="green"
IDLE_PORT=$GREEN_PORT
else
ACTIVE="green"
IDLE="blue"
IDLE_PORT=$BLUE_PORT
fi
echo "当前活跃: $ACTIVE,部署到: $IDLE (端口 $IDLE_PORT)"
# 部署新版本到非活跃环境
docker compose -f docker-compose.$IDLE.yml up -d --build
# 健康检查(重试 10 次)
for i in $(seq 1 10); do
if curl -sf http://localhost:$IDLE_PORT/health > /dev/null 2>&1; then
echo "健康检查通过"
break
fi
if [ $i -eq 10 ]; then
echo "健康检查失败,回滚"
docker compose -f docker-compose.$IDLE.yml down
exit 1
fi
sleep 3
done
# 切换 Nginx 流量到新环境
sed -i "s/server 127.0.0.1:$BLUE_PORT/server 127.0.0.1:$IDLE_PORT/" $NGINX_CONF
nginx -t && systemctl reload nginx
echo "部署完成,环境已切换: $IDLE 上线"
echo "旧环境 ($ACTIVE) 保留,如需回滚执行切换脚本"
4. 部署检查清单脚本
#!/bin/bash
# deploy-checklist.sh —— 部署前置检查
set -euo pipefail
echo "=== 部署前置检查 ==="
# 1. 代码通过 CI 测试
echo "[ ] CI 测试已通过"
# 2. 备份当前版本
BACKUP_DIR="/var/backups/deploy/$(date +%Y%m%d_%H%M%S)"
mkdir -p "$BACKUP_DIR"
cp -r /var/www/app "$BACKUP_DIR/app"
echo "[✓] 当前版本已备份到 $BACKUP_DIR"
# 3. 数据库迁移检查
echo "[?] 数据库迁移是否需要?(确认 schema 变更)"
# 如需要,先执行备份
# mysqldump --single-transaction mydb > "$BACKUP_DIR/mydb.sql"
# 4. 部署新版本
echo "[?] 开始部署..."
# 5. 健康检查
sleep 5
if curl -sf http://localhost:8080/health; then
echo "[✓] 健康检查通过"
else
echo "[✗] 健康检查失败"
exit 1
fi
# 6. 烟雾测试
if curl -sf http://localhost:8080/api/status; then
echo "[✓] 烟雾测试通过"
else
echo "[✗] 烟雾测试失败"
exit 1
fi
# 7. 监控验证
echo "[?] 查看 Grafana 确认各项指标正常"
# 8. 回滚方案已就绪
echo "[✓] 回滚方案: git revert 或 docker compose down && cp backup"
echo "=== 部署完成 ==="
5. 回滚策略
# 方案 A:Git 回滚(版本回退)
git revert HEAD --no-edit # 输出: [main abcd123] Revert "previous commit"
git push origin main # 输出: To github.com:myorg/myapp.git
# 方案 B:Docker 回滚(使用之前的镜像标签)
docker compose down # 输出: [+] Running 2/2
# 将 compose.yml 中的 image 标签改回上一个版本
docker compose up -d
# 方案 C:Blue-Green 回滚(秒级切换)
# 重新执行 blue-green-deploy.sh 切换回旧环境
# 方案 D:文件级别回滚
cp -r /var/backups/deploy/20260730_143000/app /var/www/app
systemctl restart myapp
6. GitLab CI 完整流水线示例
# .gitlab-ci.yml
stages: # 阶段顺序执行,同阶段 job 并行
- lint
- test
- build
- deploy
variables:
IMAGE_TAG: $CI_COMMIT_SHORT_SHA # GitLab 预置变量
REGISTRY: registry.example.com/myapp
workflow:
rules: # 流水线级别开关
- if: $CI_PIPELINE_SOURCE == "merge_request_event" # MR 只跑检查
- if: $CI_COMMIT_BRANCH == "main" # main 全量发布
lint-job:
stage: lint
image: node:22-alpine
script:
- npm ci
- npm run lint
cache: # 依赖缓存: key 随 lock 文件变化,命中后秒级还原
key:
files:
- package-lock.json
paths:
- node_modules/
test-job:
stage: test
image: node:22-alpine
script:
- npm ci
- npm test -- --coverage
coverage: '/All files[^|]*\|[^|]*\s+([\d.]+)/' # 解析覆盖率
build-job:
stage: build
image: docker:27
services:
- docker:27-dind # Docker-in-Docker 服务
script:
- docker build -t $REGISTRY:$IMAGE_TAG .
- docker push $REGISTRY:$IMAGE_TAG
only:
- main
deploy-prod:
stage: deploy
image: alpine:3.19
script:
- apk add --no-cache openssh-client
- echo "$DEPLOY_SSH_KEY" | tr -d '\r' | ssh-add -
- ssh -o StrictHostKeyChecking=no deploy@prod-server \
"docker pull $REGISTRY:$IMAGE_TAG && docker compose up -d"
environment:
name: production
url: https://app.example.com
rules:
- if: $CI_COMMIT_BRANCH == "main"
when: manual # 生产部署需人工点击确认
7. GitHub Actions 工作流语法详解
# .github/workflows/ci.yml
name: CI
on: # 触发事件
push:
branches: [main]
paths-ignore: ['docs/**'] # docs 目录变更不触发
pull_request:
branches: [main]
schedule: # 定时触发(夜间全量构建)
- cron: '0 2 * * *'
workflow_dispatch: # 手动触发按钮
jobs:
build:
runs-on: ubuntu-latest
strategy: # 矩阵构建: 多版本并行
matrix:
node: [20, 22, 24]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}
cache: npm # 内置依赖缓存,命中后秒级还原
- run: npm ci
- run: npm test
- uses: actions/upload-artifact@v4 # 制品传递给后续 job
with:
name: test-report-${{ matrix.node }}
path: coverage/
deploy:
needs: build # 依赖: build 全部成功后执行
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
environment: production # 环境保护规则(可挂人工审批)
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- run: |
docker build -t ghcr.io/${{ github.repository }}:${{ github.sha }} .
docker push ghcr.io/${{ github.repository }}:${{ github.sha }}
# 关键概念: 事件(on) → 作业(jobs) → 步骤(steps) → 动作(uses/run)
# secrets 存于仓库 Settings → Secrets and variables,运行时以 ${{ secrets.X }} 引用
8. Jenkins 声明式 Pipeline 示例
// Jenkinsfile(声明式语法,存于仓库根目录,随代码版本管理)
pipeline {
agent { docker { image 'node:20-alpine' } } // 使用容器作为执行环境
options {
timeout(time: 30, unit: 'MINUTES') // 超时保护
buildDiscarder(logRotator(numToKeepStr: '20')) // 保留 20 次构建
disableConcurrentBuilds() // 同分支禁止并发
}
environment {
REGISTRY = 'registry.example.com/myapp'
IMAGE_TAG = "${env.GIT_COMMIT.take(8)}"
}
stages {
stage('Lint & Test') {
steps {
sh 'npm ci'
sh 'npm run lint'
sh 'npm test'
}
}
stage('Build Image') {
steps {
sh 'docker build -t ${REGISTRY}:${IMAGE_TAG} .'
sh 'docker push ${REGISTRY}:${IMAGE_TAG}'
}
}
stage('Deploy Staging') {
steps {
sh 'ansible-playbook -i inventory/staging deploy.yml -e app_image=${REGISTRY}:${IMAGE_TAG}'
}
}
stage('Deploy Production') {
input { // 人工确认门禁
message '确认部署到生产环境?'
ok '开始部署'
}
steps {
sh 'ansible-playbook -i inventory/prod deploy.yml -e app_image=${REGISTRY}:${IMAGE_TAG}'
}
}
}
post { // 结果通知
success { emailext subject: "构建成功: ${env.JOB_NAME}", to: 'dev@example.com' }
failure { emailext subject: "构建失败: ${env.JOB_NAME}", to: 'dev@example.com' }
}
}
常见错误
| 错误表现 | 根因 | 正确做法 |
|---|---|---|
| CI 流水线通过但生产部署失败 | CI 环境与生产环境不一致(操作系统版本、依赖差异) | 使用 Docker 构建确保环境一致性;CI 和生产使用相同的 Dockerfile |
| Git Hook 部署后网站 502 | 构建耗时过长,健康检查超时 | 增加健康检查等待时间;使用 Blue-Green 部署避免停机 |
| 数据库迁移导致数据丢失 | migration 脚本有 DROP TABLE 或 ALTER COLUMN 破坏性操作 | 迁移前备份数据库;使用向前兼容的迁移(先加列再删列分两步) |
| GitHub Actions 中 SSH 连接失败 | 部署服务器的 SSH 密钥未添加到 GitHub Secrets,或目标主机 key 变更 | 在 GitHub 仓库 Settings → Secrets 中添加 DEPLOY_KEY;确认 ssh-keyscan 已更新 |
| 回滚后数据不一致 | 回滚了代码但数据库迁移不可逆 | 数据库迁移始终向前兼容;回滚代码时不同时回滚数据库 |
最佳实践
| 实践 | 原理 | 示例 |
|---|---|---|
| CI 阶段运行完整的自动化测试 | 在部署前发现回归问题 | 单元测试 + 集成测试 + 代码风格检查(lint) |
| 使用 Docker 确保环境一致性 | 消除"在我机器上能跑"的问题 | CI 中构建镜像,部署时拉取同一镜像 |
| 采用不可变部署(Immutable Deploy) | 不原地更新,始终替换整个实例 | 每次部署创建新容器而非在旧容器内修改 |
| 部署前备份数据库和配置文件 | 回滚时能恢复到精确的状态 | mysqldump + cp -r config |
| 逐步发布(Canary Release) | 先让一小部分用户使用新版本,观察指标后再全量 | Kubernetes 中设置 strategy: rollingUpdate 控制 maxSurge |
| 每次部署打 Git Tag | 精确定位每个版本的代码状态 | git tag v1.2.3 && git push --tags |
| CI 依赖缓存分层策略 | node_modules/依赖缓存命中可节省 60% 以上构建时间 | GitHub Actions 用 setup-node cache: npm;GitLab 用 cache: key: files: package-lock.json |
| Dockerfile 层缓存优先 | 依赖安装层放在 COPY 源码之前,代码变更不触发依赖重装 | 先 COPY package*.json 再 RUN npm ci,最后 COPY 源码 |
| 构建与部署分离,产物不可变 | 部署失败可重复拉取同一产物重试,不重新构建 | build job 产出 tag=sha 的镜像,deploy job 只拉取不构建 |
练习题
- (概念)CI 和 CD 的区别是什么?持续交付和持续部署又有什么区别?
- (概念)Blue-Green 部署如何实现零停机?它的回滚为什么比原地部署快?
- (实操)在 GitHub 仓库中创建一个
.github/workflows/ci.yml,实现:当 push 到main分支时,执行npm test和docker build。在仓库中随便放一个 Node.js 项目(含 package.json 和 一个测试)测试流水线运行。 - (实操)在本地用 Git 仓库模拟自托管部署:创建 bare 仓库、配置 post-receive hook 将代码检出到
/tmp/deploy-test目录;从另一个目录 push 代码并验证文件自动更新。 - (🔍 挑战)设计并实现一个完整的 Blue-Green 部署实验环境:使用 Docker Compose 启动两套应用实例(不同端口)和一个 Nginx 反向代理;编写切换脚本;模拟部署新版本(修改页面内容)并通过切换 Nginx upstream 完成零停机更新。验证在切换过程中 curl 始终返回 200 而非 502。
点击查看答案
- CI(持续集成)是频繁合并代码并自动构建测试,尽早发现集成问题。CD 分为持续交付(自动构建+测试,手动确认部署)和持续部署(自动部署到生产,无人干预)。
- Blue-Green 维护两套独立环境,流量通过负载均衡器切换。部署新版本到非活跃环境,验证后一键切换流量。回滚只需将流量切回旧环境,秒级完成。
- 创建
.github/workflows/ci.yml:on: push: branches: [main]→jobs: test: runs-on: ubuntu-latest → steps: checkout → npm ci → npm test; build: needs: test → docker build。在仓库根目录放package.json+ 简单 test 文件验证。 - 服务器创建 bare 仓库并配置
post-receivehook 执行git --work-tree=/tmp/deploy-test --git-dir=/var/repo/app.git checkout -f main。本地git push后检查/tmp/deploy-test是否更新。 - Docker Compose 启动两个 Nginx 实例(不同端口)+ 一个 Nginx 反向代理(upstream 指向活跃环境)。切换脚本修改 upstream 并 reload。用
curl -I http://localhost持续监测,切换过程中始终返回 200。
学习检查点
学完本章后,请检验自己是否掌握以下内容:
| 检查项 | 自测问题 | 验证方法 |
|---|---|---|
| 概念理解 | 能用自己的话解释 CI/CD 的核心价值和流水线阶段 | 尝试向他人讲解 |
| 命令操作 | 能不查文档完成 CI/CD 工具配置、流水线编写 | 在终端实际执行 |
| 原理掌握 | 能说出持续集成、持续交付、持续部署的区别 | 画出流程图 |
| 故障排查 | 能独立排查流水线失败、测试不通过、部署回滚问题 | 模拟故障并修复 |
| 最佳实践 | 能说明为什么需要为 CI/CD 配置环境隔离和密钥管理 | 对比不同方案 |
本章总结
CI/CD 是现代软件工程的核心实践,将代码从提交到部署的全流程自动化。GitHub Actions 是云上最便捷的 CI/CD 方案,适合大多数项目起步。自建 Git Hooks 适合有独立服务器且需要精细控制的场景。Blue-Green 部署实现了零停机更新和秒级回滚。无论选择哪种方案,核心原则不变:自动化测试保障质量、Docker 确保环境一致性、部署可回滚、全过程可审计。每次部署都是一个可重复、可验证的标准化流程。
速查表
| 命令/配置 | 用途 |
|---|---|
.github/workflows/*.yml | GitHub Actions 工作流定义 |
git push production main | 推送到自建服务器触发部署 |
post-receive hook | Git 接收推送后触发的脚本 |
git revert HEAD | 撤销最后一次提交(Git 回滚) |
docker compose up -d --build | 构建并启动新版本 |
curl -f http://localhost/health | 健康检查 |
git tag v1.2.3 | 标记部署版本 |
学习路径建议
- 学完本章后建议阅读 cicd01 CI/CD 生产实践(多环境、审批门禁、交付流水线)
- 学完本章后建议阅读 6.7:GitOps 入门 GitOps 入门(以 Git 为单一事实来源的部署模式)
- 容器编排场景可看 6.2:Kubernetes 入门 Kubernetes 入门(K8s 原生 Rolling Update 和 Canary 部署)
- 进阶可学习 Jenkins/GitLab CI/CD 等更多 CI/CD 平台
延伸阅读
- GitHub Actions 官方文档
- Git Hooks 官方文档
- Martin Fowler — BlueGreenDeployment
- 推荐书籍:《持续交付:发布可靠软件的系统方法》