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 实现自动化构建、测试和部署
  • 理解蓝绿部署原理并能实现零停机回滚

前置知识

项目背景与需求分析

假设你正在开发一个团队内部的待办事项 API 服务。每次提交代码后,系统自动完成:

  1. 运行代码质量检查和单元测试
  2. 构建 Docker 镜像并推送到 GitHub Container Registry(GHCR)
  3. 自动部署到测试服务器(main 分支)
  4. 打版本 tag 后自动部署到生产服务器
  5. 部署后健康检查,失败自动回滚
  6. 通过 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/PRflake8 代码检查~30s
testlint 通过pytest + 覆盖率~1min
buildtest 通过 + push构建镜像推送 GHCR~3min
deploy-stagingmain 分支部署测试服务器~1min
deploy-productionv* tag部署生产 + 回滚 + 通知~2min
密钥安全 所有敏感信息通过 GitHub Secrets 管理,永不硬编码在工作流文件中。确保 Secrets 只在需要的 Job 中暴露。

GitHub Secrets 配置清单

Secret用途
STAGING_HOST / PROD_HOST服务器 IP 或域名
STAGING_USER / PROD_USERSSH 用户名
STAGING_SSH_KEY / PROD_SSH_KEYSSH 私钥
DEPLOY_PATH服务器项目路径,如 /opt/todo-api
SLACK_WEBHOOKSlack 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 deniedWorkflow 权限不足Settings → Actions → Workflow permissions → Read and write
no space left on deviceRunner 磁盘满build 前加 docker system prune -f
SSH 连接超时防火墙或密钥问题检查安全组规则,确保使用 ED25519 密钥
健康检查持续失败启动时间不足增大 start_periodsleep 时间
蓝绿切换后 502新环境未就绪健康检查通过后再切换流量

最佳实践

  • 镜像标签——使用语义化版本或 Git SHA,生产中不用 latest
  • 密钥管理——敏感信息只通过 Secrets 传递,永不硬编码
  • 健康检查必配——Docker healthcheck + 部署后检查是第一道防线
  • 构建缓存——利用 GHA 缓存缩短镜像构建时间
  • 回滚能力——保留上一版本镜像,确保分钟级回滚
  • 非 root 运行——Dockerfile 创建专用用户,遵循最小权限
  • 渐进发布——先 staging 验证,再 production,降低故障范围

练习题

  1. 为 Todo API 添加 JWT 认证功能,编写单元测试,确保 CI 通过。
  2. 将部署从单机 Docker 改为 Docker Swarm,实现多副本负载均衡和滚动更新。
  3. 配置 Nginx 反向代理和 HTTPS(Let's Encrypt),实现 SSL 终止。
  4. 搭建 Prometheus + Grafana,采集请求量、响应时间和错误率指标。
  5. 实现 Canary 发布:10% 流量导到新版本,观察 5 分钟后全量切换。

学习检查点

学完本章后,请检验自己是否掌握以下内容:

检查项自测问题验证方法
概念理解能用自己的话解释 CI/CD 流水线的各个阶段和自动化原理尝试向他人讲解
命令操作能不查文档完成 GitHub Actions 工作流编写和 Docker 多阶段构建在终端实际执行
原理掌握能说出蓝绿部署和滚动更新的实现原理和区别画出流程图
故障排查能独立排查 CI/CD 流水线构建失败或部署回滚的问题模拟故障并修复
最佳实践能说明为什么需要为 CI/CD 流水线配置密钥管理和审批流程对比不同方案

本章总结

本项目从零搭建完整 CI/CD 流水线:Flask 单元测试 → Docker 多阶段构建 → Compose 编排 → GitHub Actions 自动化 → 蓝绿部署零停机发布。掌握这条流水线后,可将其模板化复用到任何语言和框架的项目中。

延伸阅读

↑ 回到顶部