4.22 项目实战:从代码到生产的完整 CI/CD 流水线
预计阅读时间:15 分钟
📖 目录
前面的章节分别讲解了 Git、Docker、GitHub Actions 等工具。本项目将这些知识串联,从零搭建端到端的 CI/CD 流水线:提交代码后自动测试、构建镜像、推送仓库、部署服务器,附带健康检查和通知。完成后你将拥有可复用的生产级交付模板。
CI/CD(Continuous Integration / Continuous Delivery or Deployment):持续集成/持续交付或部署。CI 指开发者频繁将代码合并到主分支并自动测试;CD 指通过自动化流水线将通过测试的代码交付或部署到生产环境。GHCR(GitHub Container Registry):GitHub 提供的容器镜像仓库服务,用于存储和分发 Docker 镜像。Gunicorn:Python WSGI HTTP 服务器,用于在生产环境中运行 Python Web 应用(如 Flask/Django)。
学习目标
- 理解 CI/CD 流水线完整生命周期:代码提交 → 测试 → 构建 → 部署 → 验证
- 能用 Flask 编写带单元测试的示例应用
- 能编写多阶段 Dockerfile,优化镜像大小并提升安全性
- 能用 Docker Compose 编排服务并配置健康检查
- 能配置 GitHub Actions 实现自动化构建、测试和部署
- 理解蓝绿部署原理并能实现零停机回滚
前置知识
- 2.9:Git 版本控制 Git 版本控制——分支模型、commit 规范与远程仓库操作
- 3.1:Docker 容器入门 Docker 容器入门——镜像构建、容器管理与基础命令
- 3.3:实战:搭建个人网站 Dockerfile 编写指南——指令、构建上下文与多阶段构建
- 4.9:CI/CD 持续部署 CI/CD 持续部署入门——GitHub Actions 基础流水线
- 3.14:LAMP/LEMP 环境 Docker Compose 多容器编排——服务定义、网络与数据卷
项目背景与需求分析
假设你正在开发一个团队内部的待办事项 API 服务。每次提交代码后,系统自动完成:
- 运行代码质量检查和单元测试
- 构建 Docker 镜像并推送到 GitHub Container Registry(GHCR)
- 自动部署到测试服务器(main 分支)
- 打版本 tag 后自动部署到生产服务器
- 部署后健康检查,失败自动回滚
- 通过 Slack 通知团队
技术架构
开发者 → Git Push → GitHub Actions
│
┌─────┴─────┐
│ Lint & Test │
└─────┬─────┘
▼
┌───────────┐
│ Build Image│
└─────┬─────┘
▼
┌───────────┐
│ Push GHCR │
└─────┬─────┘
┌──────────┴──────────┐
▼ ▼
┌──────────────┐ ┌──────────────┐
│ Staging 部署 │ │ Prod 部署 │
│ (main push) │ │ (tag v*) │
└──────┬───────┘ └──────┬───────┘
▼ ▼
┌──────────────┐ ┌──────────────┐
│ 健康检查 │ │ 健康检查 │
└──────┬───────┘ └──────┬───────┘
▼ ▼
┌──────────────┐ ┌──────────────┐
│ Slack 通知 │ │ Slack 通知 │
└──────────────┘ └──────────────┘
项目目录结构
todo-api/
├── app/
│ ├── __init__.py
│ ├── main.py # Flask 应用入口
│ ├── models.py # 数据模型
│ └── routes.py # 路由定义
├── tests/
│ ├── __init__.py
│ ├── test_models.py # 模型单元测试
│ └── test_routes.py # 路由单元测试
├── .github/workflows/
│ └── ci-cd.yml # GitHub Actions 工作流
├── Dockerfile # 多阶段构建
├── docker-compose.yml # 本地开发编排
├── docker-compose.prod.yml # 生产环境编排
├── requirements.txt # 生产依赖
├── requirements-dev.txt # 测试依赖
├── .dockerignore
└── .gitignore
环境准备
# 检查必要工具
git --version # 需要 2.30+
docker --version # 需要 24.0+
docker compose version # 需要 v2.20+
python3 --version # 需要 3.10+
# 安装 Docker(如未安装)
curl -fsSL https://get.docker.com -o get-docker.sh
sudo sh get-docker.sh
sudo usermod -aG docker $USER
GitHub 配置
# 1. 创建 GitHub 仓库 todo-api
# 2. 仓库 Settings → Actions → General → Workflow permissions → Read and write permissions
# 3. Settings → Secrets and variables → Actions 添加:
# DEPLOY_HOST - 服务器 IP
# DEPLOY_USER - SSH 用户名
# DEPLOY_KEY - SSH 私钥
# DEPLOY_PATH - /opt/todo-api
# SLACK_WEBHOOK - Slack Webhook URL
部署服务器准备
# 在远程服务器执行
curl -fsSL https://get.docker.com | sudo sh
sudo usermod -aG docker deploy
sudo mkdir -p /opt/todo-api && sudo chown deploy:deploy /opt/todo-api
# 从本地配置 SSH 密钥
ssh-keygen -t ed25519 -C "ci-deploy" -f ~/.ssh/ci_deploy_key
ssh-copy-id -i ~/.ssh/ci_deploy_key.pub deploy@your-server
第一步:创建示例应用
1.1 初始化项目
mkdir todo-api && cd todo-api && git init
mkdir -p app tests .github/workflows
python3 -m venv venv && source venv/bin/activate
1.2 依赖文件
# requirements.txt
flask==3.0.3
gunicorn==22.0.0
# requirements-dev.txt
-r requirements.txt
pytest==8.2.2
pytest-cov==5.0.0
flake8==7.1.0
1.3 应用代码
# app/models.py
from datetime import datetime
class Todo:
"""待办事项模型。"""
def __init__(self, title: str, description: str = ""):
self.id: int = 0
self.title = title
self.description = description
self.completed: bool = False
self.created_at: str = datetime.utcnow().isoformat()
def to_dict(self) -> dict:
return {
"id": self.id, "title": self.title,
"description": self.description,
"completed": self.completed,
"created_at": self.created_at,
}
class TodoStore:
"""内存增删改查存储。"""
def __init__(self):
self._todos: dict[int, Todo] = {}
self._next_id: int = 1
def add(self, title: str, description: str = "") -> Todo:
todo = Todo(title, description)
todo.id = self._next_id
self._next_id += 1
self._todos[todo.id] = todo
return todo
def get(self, todo_id: int) -> Todo | None:
return self._todos.get(todo_id)
def list_all(self) -> list[Todo]:
return list(self._todos.values())
def update(self, todo_id: int, **kwargs) -> Todo | None:
todo = self.get(todo_id)
if todo is None:
return None
for key, value in kwargs.items():
if hasattr(todo, key) and key not in ("id", "created_at"):
setattr(todo, key, value)
return todo
def delete(self, todo_id: int) -> bool:
if todo_id in self._todos:
del self._todos[todo_id]
return True
return False
store = TodoStore()
# app/routes.py
from flask import Blueprint, jsonify, request
from app.models import store
api = Blueprint("api", __name__)
@api.route("/health")
def health():
"""健康检查端点。"""
return jsonify({"status": "ok"}), 200
@api.route("/todos", methods=["GET"])
def list_todos():
todos = store.list_all()
return jsonify([t.to_dict() for t in todos]), 200
@api.route("/todos", methods=["POST"])
def create_todo():
data = request.get_json()
if not data or "title" not in data:
return jsonify({"error": "title is required"}), 400
todo = store.add(title=data["title"], description=data.get("description", ""))
return jsonify(todo.to_dict()), 201
@api.route("/todos/<int:todo_id>", methods=["GET"])
def get_todo(todo_id):
todo = store.get(todo_id)
if todo is None:
return jsonify({"error": "not found"}), 404
return jsonify(todo.to_dict()), 200
@api.route("/todos/<int:todo_id>", methods=["PUT"])
def update_todo(todo_id):
data = request.get_json()
if not data:
return jsonify({"error": "no data"}), 400
todo = store.update(todo_id, **data)
if todo is None:
return jsonify({"error": "not found"}), 404
return jsonify(todo.to_dict()), 200
@api.route("/todos/<int:todo_id>", methods=["DELETE"])
def delete_todo(todo_id):
if store.delete(todo_id):
return "", 204
return jsonify({"error": "not found"}), 404
# app/main.py
import os
from flask import Flask
from app.routes import api
def create_app():
app = Flask(__name__)
app.register_blueprint(api)
return app
app = create_app()
if __name__ == "__main__":
port = int(os.environ.get("PORT", 5000))
app.run(host="0.0.0.0", port=port)
1.4 单元测试
# tests/test_models.py
from app.models import Todo, TodoStore
class TestTodo:
def test_create(self):
todo = Todo("买菜")
assert todo.title == "买菜"
assert todo.completed is False
def test_to_dict(self):
todo = Todo("测试")
d = todo.to_dict()
assert "id" in d and "created_at" in d
class TestTodoStore:
def setup_method(self):
self.store = TodoStore()
def test_add(self):
todo = self.store.add("任务一")
assert todo.id == 1
assert self.store.get(1).title == "任务一"
def test_list_all(self):
self.store.add("A")
self.store.add("B")
assert len(self.store.list_all()) == 2
def test_update(self):
self.store.add("原始")
updated = self.store.update(1, title="新标题", completed=True)
assert updated.title == "新标题"
assert updated.completed is True
def test_delete(self):
self.store.add("待删除")
assert self.store.delete(1) is True
assert self.store.get(1) is None
def test_delete_nonexistent(self):
assert self.store.delete(999) is False
# tests/test_routes.py
import json
from app.main import create_app
class TestRoutes:
def setup_method(self):
self.client = create_app().test_client()
def test_health(self):
resp = self.client.get("/health")
assert resp.status_code == 200
assert resp.get_json()["status"] == "ok"
def test_create_todo(self):
resp = self.client.post("/todos",
data=json.dumps({"title": "写文档"}),
content_type="application/json")
assert resp.status_code == 201
assert resp.get_json()["title"] == "写文档"
def test_create_missing_title(self):
resp = self.client.post("/todos",
data=json.dumps({}),
content_type="application/json")
assert resp.status_code == 400
def test_get_todo(self):
self.client.post("/todos",
data=json.dumps({"title": "测试"}),
content_type="application/json")
resp = self.client.get("/todos/1")
assert resp.status_code == 200
assert resp.get_json()["title"] == "测试"
def test_get_not_found(self):
assert self.client.get("/todos/999").status_code == 404
def test_delete_todo(self):
self.client.post("/todos",
data=json.dumps({"title": "删除我"}),
content_type="application/json")
assert self.client.delete("/todos/1").status_code == 204
1.5 运行测试
pip install -r requirements-dev.txt
pytest tests/ -v --cov=app --cov-report=term-missing
# 预期:19 passed,覆盖率 100%
# flake8 app/ tests/ (无输出表示通过)
第二步:编写 Dockerfile
# Dockerfile —— 多阶段构建
# === 阶段 1:构建 ===
FROM python:3.12-slim AS builder
WORKDIR /build
RUN apt-get update && apt-get install -y --no-install-recommends gcc && rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN python -m venv /opt/venv && \
/opt/venv/bin/pip install --no-cache-dir -r requirements.txt
# === 阶段 2:运行 ===
FROM python:3.12-slim AS runtime
COPY --from=builder /opt/venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
# 非 root 用户
RUN groupadd -r appuser && useradd -r -g appuser -d /app -s /sbin/nologin appuser
WORKDIR /app
COPY app/ ./app/
RUN chown -R appuser:appuser /app
USER appuser
EXPOSE 5000
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 PORT=5000
CMD ["gunicorn", "--bind", "0.0.0.0:5000", "--workers", "2", \
"--timeout", "30", "--access-logfile", "-", "app.main:app"]
# .dockerignore
.git .gitignore venv/ .venv/ tests/ .pytest_cache/ .coverage
htmlcov/ .flake8 *.pyc __pycache__/ docker-compose*.yml Dockerfile
.dockerignore *.md LICENSE .env .env.*
构建并测试
docker build -t todo-api:dev .
docker run -d --name todo-test -p 5000:5000 todo-api:dev
curl http://localhost:5000/health # {"status":"ok"}
curl -X POST http://localhost:5000/todos \
-H "Content-Type: application/json" -d '{"title":"Docker 中的任务"}'
docker stop todo-test && docker rm todo-test
docker images todo-api:dev # 约 120-150MB
镜像大小对比 不使用多阶段构建时 Python 镜像约 900MB。多阶段构建后仅含运行时依赖,约 150MB,拉取速度提升 5 倍以上。
第三步:编写 docker-compose.yml
3.1 本地开发编排
# docker-compose.yml
services:
web:
build:
context: .
target: runtime
ports:
- "5000:5000"
environment:
- FLASK_DEBUG=1
volumes:
- ./app:/app/app:ro # 开发时挂载代码
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:5000/health')"]
interval: 10s
timeout: 5s
retries: 3
start_period: 5s
restart: unless-stopped
networks:
- app-network
3.2 生产环境编排
# docker-compose.prod.yml
services:
web:
image: ghcr.io/your-username/todo-api:${IMAGE_TAG:-latest}
ports:
- "5000:5000"
environment:
- FLASK_DEBUG=0
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:5000/health')"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
deploy:
resources:
limits: { cpus: "1.0", memory: 256M }
reservations: { cpus: "0.5", memory: 128M }
restart: always
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
networks:
- app-network
networks:
app-network:
driver: bridge
3.3 本地测试
docker compose up -d --build
docker compose ps # STATUS: Up ...
docker compose logs -f web
docker compose exec web pytest tests/ -v
docker compose down
第四步:配置 GitHub Actions
这是流水线核心,定义从提交到部署的完整自动化流程。
# .github/workflows/ci-cd.yml
name: CI/CD Pipeline
on:
push:
branches: [main]
tags: ['v*']
pull_request:
branches: [main]
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
permissions:
contents: read
packages: write
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.12'
cache: 'pip'
- run: |
python -m pip install --upgrade pip
pip install -r requirements-dev.txt
- run: flake8 app/ tests/ --max-line-length=100
test:
needs: lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.12'
cache: 'pip'
- run: |
python -m pip install --upgrade pip
pip install -r requirements-dev.txt
- run: pytest tests/ -v --cov=app --cov-report=xml
- uses: actions/upload-artifact@v4
if: always()
with:
name: coverage
path: coverage.xml
build:
needs: test
runs-on: ubuntu-latest
if: github.event_name == 'push'
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/metadata-action@v5
id: meta
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=raw,value=sha-${{ github.sha }}
type=semver,pattern={{version}}
type=raw,value=latest,enable={{is_default_branch}}
- uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
deploy-staging:
needs: build
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
environment:
name: staging
url: https://staging.example.com
steps:
- uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ secrets.STAGING_HOST }}
username: ${{ secrets.STAGING_USER }}
key: ${{ secrets.STAGING_SSH_KEY }}
script: |
# 前置准备:部署机需已 clone 仓库到 DEPLOY_PATH,并在仓库配置
# SSH deploy key(写入 GitHub 仓库 Settings → Deploy keys),
# 否则 git pull 无法拉取私有仓库
cd ${{ secrets.DEPLOY_PATH }} && git pull origin main
export IMAGE_TAG=sha-${{ github.sha }}
docker compose -f docker-compose.prod.yml pull web
docker compose -f docker-compose.prod.yml up -d web
sleep 10
curl -sf http://localhost:5000/health || { echo "❌ 健康检查失败"; docker compose -f docker-compose.prod.yml down; exit 1; }
echo "✅ Staging 部署成功"
deploy-production:
needs: build
if: startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-latest
environment:
name: production
url: https://api.example.com
concurrency:
group: production
cancel-in-progress: false
steps:
- uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ secrets.PROD_HOST }}
username: ${{ secrets.PROD_USER }}
key: ${{ secrets.PROD_SSH_KEY }}
script: |
cd ${{ secrets.DEPLOY_PATH }}
export IMAGE_TAG=${GITHUB_REF#refs/tags/v}
docker compose -f docker-compose.prod.yml pull web
docker compose -f docker-compose.prod.yml up -d web
sleep 15
for i in 1 2 3; do
curl -sf http://localhost:5000/health && echo "✅ 生产部署成功 v$IMAGE_TAG" && exit 0
sleep 5
done
echo "❌ 部署失败,执行回滚" && docker compose -f docker-compose.prod.yml down && exit 1
- name: Notify Slack
if: always()
uses: slackapi/slack-github-action@v1.26.0
with:
payload: '{"text":"${{ job.status == ''success'' && ''✅'' || ''❌'' }} 生产部署 ${{ github.ref_name }} - ${{ job.status }}"}'
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK }}
流水线阶段说明
| 阶段 | 触发条件 | 操作 | 耗时 |
|---|---|---|---|
| lint | 所有 push/PR | flake8 代码检查 | ~30s |
| test | lint 通过 | pytest + 覆盖率 | ~1min |
| build | test 通过 + push | 构建镜像推送 GHCR | ~3min |
| deploy-staging | main 分支 | 部署测试服务器 | ~1min |
| deploy-production | v* tag | 部署生产 + 回滚 + 通知 | ~2min |
密钥安全 所有敏感信息通过 GitHub Secrets 管理,永不硬编码在工作流文件中。确保 Secrets 只在需要的 Job 中暴露。
GitHub Secrets 配置清单
| Secret | 用途 |
|---|---|
| STAGING_HOST / PROD_HOST | 服务器 IP 或域名 |
| STAGING_USER / PROD_USER | SSH 用户名 |
| STAGING_SSH_KEY / PROD_SSH_KEY | SSH 私钥 |
| DEPLOY_PATH | 服务器项目路径,如 /opt/todo-api |
| SLACK_WEBHOOK | Slack Incoming Webhook URL |
第五步:配置部署服务器
#!/bin/bash
# scripts/init-server.sh —— 服务器初始化
set -euo pipefail
sudo apt-get update && sudo apt-get upgrade -y
command -v docker || { curl -fsSL https://get.docker.com | sudo sh; sudo usermod -aG docker "$USER"; }
sudo mkdir -p /opt/todo-api && sudo chown "$(whoami):$(whoami)" /opt/todo-api
echo "✅ 初始化完成"
#!/bin/bash
# scripts/deploy.sh —— 部署脚本(GitHub Actions 调用)
set -euo pipefail
IMAGE="${REGISTRY:-ghcr.io}/your-username/todo-api:${IMAGE_TAG:-latest}"
cd "${DEPLOY_PATH:-/opt/todo-api}"
docker pull "$IMAGE"
docker compose -f docker-compose.prod.yml stop web 2>/dev/null || true
docker compose -f docker-compose.prod.yml rm -f web 2>/dev/null || true
export IMAGE_TAG
docker compose -f docker-compose.prod.yml up -d web
# 等待并检查健康
for i in $(seq 1 10); do
curl -sf http://localhost:5000/health && { echo "✅ 部署成功"; exit 0; }
sleep 3
done
echo "❌ 启动超时" && exit 1
第六步:配置监控和通知
6.1 增强健康检查
# app/routes.py 中添加
@api.route("/health/ready")
def readiness():
"""就绪检查,验证依赖服务可用。"""
checks = {"app": "ok"}
all_ok = all(v == "ok" for v in checks.values())
return jsonify({"status": "ready" if all_ok else "degraded", "checks": checks}), 200 if all_ok else 503
@api.route("/health/live")
def liveness():
"""存活检查,仅验证进程运行。"""
return jsonify({"status": "alive"}), 200
6.2 日志管理
# 生产环境日志配置(已在 docker-compose.prod.yml 中设置)
# 查看日志
docker logs -f todo-api-web
docker logs --since="1h" todo-api-web 2>&1 | grep ERROR
# 导出日志
docker logs todo-api-web > /var/log/todo-api.log 2>&1
第七步:测试完整流水线
# 1. 首次提交
git add -A && git commit -m "feat: 初始化 Todo API"
git remote add origin https://github.com/your-username/todo-api.git
git push -u origin main
# → Actions 自动执行:lint → test → build → deploy-staging
# 2. 生产发布
git tag -a v1.0.0 -m "Release v1.0.0"
git push origin v1.0.0
# → Actions 自动执行:lint → test → build → deploy-production
# → Slack 收到成功通知
# 3. 验证
curl https://api.example.com/health # {"status":"ok"}
# 4. 模拟失败
echo "Syntax Error" >> app/main.py
git add -A && git commit -m "test: 触发失败" && git push origin main
# → lint/test 失败,不会部署
git checkout app/main.py
git add -A && git commit -m "fix: 撤销" && git push origin main
进阶:蓝绿部署
原理
维护蓝/绿两套环境。部署新版本到非活跃环境,健康检查通过后切换流量,旧环境保留用于秒级回滚。
# 正常状态:流量 → 蓝(v1.0.0)
# 部署:绿启动 v1.1.0 → 健康检查 → 切换流量 → 蓝 → 关闭
# 回滚:切换流量 → 蓝(仍运行 v1.0.0)
蓝绿部署脚本
#!/bin/bash
# scripts/blue-green-deploy.sh <image_tag>
set -euo pipefail
IMAGE_TAG="${1:?用法: $0 <image_tag>}"
IMAGE="ghcr.io/your-username/todo-api:${IMAGE_TAG}"
STATE_FILE="/opt/todo-api/.active-color"
docker network create todo-network 2>/dev/null || true # 幂等创建自定义网络
ACTIVE=$(cat "$STATE_FILE" 2>/dev/null || echo "blue")
[ "$ACTIVE" = "blue" ] && { NEW="green"; PORT=5002; } || { NEW="blue"; PORT=5001; }
echo "活跃: $ACTIVE → 部署: $NEW"
docker pull "$IMAGE"
docker rm -f "todo-api-${NEW}" 2>/dev/null || true
docker run -d --name "todo-api-${NEW}" --network todo-network \
-p "${PORT}:5000" -e FLASK_DEBUG=0 "$IMAGE"
# 健康检查
for i in $(seq 1 10); do
curl -sf "http://localhost:${PORT}/health" && break
sleep 3
done || { docker rm -f "todo-api-${NEW}"; echo "❌ 健康检查失败"; exit 1; }
# 切换流量
sudo sed -i "s/127.0.0.1:[0-9]*/127.0.0.1:${PORT}/" /etc/nginx/conf.d/upstream.conf
sudo nginx -s reload
echo "$NEW" > "$STATE_FILE"
docker stop "todo-api-${ACTIVE}" 2>/dev/null || true
echo "✅ 蓝绿部署完成:$IMAGE_TAG ($NEW)"
回滚脚本
#!/bin/bash
# scripts/blue-green-rollback.sh
set -euo pipefail
STATE_FILE="/opt/todo-api/.active-color"
ACTIVE=$(cat "$STATE_FILE" 2>/dev/null || echo "blue")
[ "$ACTIVE" = "blue" ] && { RB="green"; PORT=5002; } || { RB="blue"; PORT=5001; }
docker inspect "todo-api-${RB}" > /dev/null 2>&1 || { echo "❌ 回滚目标不存在"; exit 1; }
curl -sf "http://localhost:${PORT}/health" || { echo "❌ 回滚目标不健康"; exit 1; }
sudo sed -i "s/127.0.0.1:[0-9]*/127.0.0.1:${PORT}/" /etc/nginx/conf.d/upstream.conf
sudo nginx -s reload
echo "$RB" > "$STATE_FILE"
echo "✅ 回滚完成:$RB"
常见错误
| 错误 | 原因 | 解决 |
|---|---|---|
GHCR permission denied | Workflow 权限不足 | Settings → Actions → Workflow permissions → Read and write |
no space left on device | Runner 磁盘满 | build 前加 docker system prune -f |
| SSH 连接超时 | 防火墙或密钥问题 | 检查安全组规则,确保使用 ED25519 密钥 |
| 健康检查持续失败 | 启动时间不足 | 增大 start_period 和 sleep 时间 |
| 蓝绿切换后 502 | 新环境未就绪 | 健康检查通过后再切换流量 |
最佳实践
- 镜像标签——使用语义化版本或 Git SHA,生产中不用
latest - 密钥管理——敏感信息只通过 Secrets 传递,永不硬编码
- 健康检查必配——Docker
healthcheck+ 部署后检查是第一道防线 - 构建缓存——利用 GHA 缓存缩短镜像构建时间
- 回滚能力——保留上一版本镜像,确保分钟级回滚
- 非 root 运行——Dockerfile 创建专用用户,遵循最小权限
- 渐进发布——先 staging 验证,再 production,降低故障范围
练习题
- 为 Todo API 添加 JWT 认证功能,编写单元测试,确保 CI 通过。
- 将部署从单机 Docker 改为 Docker Swarm,实现多副本负载均衡和滚动更新。
- 配置 Nginx 反向代理和 HTTPS(Let's Encrypt),实现 SSL 终止。
- 搭建 Prometheus + Grafana,采集请求量、响应时间和错误率指标。
- 实现 Canary 发布:10% 流量导到新版本,观察 5 分钟后全量切换。
学习检查点
学完本章后,请检验自己是否掌握以下内容:
| 检查项 | 自测问题 | 验证方法 |
|---|---|---|
| 概念理解 | 能用自己的话解释 CI/CD 流水线的各个阶段和自动化原理 | 尝试向他人讲解 |
| 命令操作 | 能不查文档完成 GitHub Actions 工作流编写和 Docker 多阶段构建 | 在终端实际执行 |
| 原理掌握 | 能说出蓝绿部署和滚动更新的实现原理和区别 | 画出流程图 |
| 故障排查 | 能独立排查 CI/CD 流水线构建失败或部署回滚的问题 | 模拟故障并修复 |
| 最佳实践 | 能说明为什么需要为 CI/CD 流水线配置密钥管理和审批流程 | 对比不同方案 |
本章总结
本项目从零搭建完整 CI/CD 流水线:Flask 单元测试 → Docker 多阶段构建 → Compose 编排 → GitHub Actions 自动化 → 蓝绿部署零停机发布。掌握这条流水线后,可将其模板化复用到任何语言和框架的项目中。
延伸阅读
- 4.9:CI/CD 持续部署 CI/CD 持续部署入门——GitHub Actions 基础流水线
- 3.14:LAMP/LEMP 环境 Docker Compose 多容器编排——服务定义、网络与数据卷
- cicd01 CI/CD 生产实践——多环境、审批与交付流水线
- container_ops01 Docker Compose 生产部署实战
- GitHub Actions 官方文档
- Docker 多阶段构建文档