Github Action实践练习5_自动镜像打包及部署

背景

项目代码:https://github.com/rusthx/fastapi-cicd-demo fastapi-cicd-demo 是一个用于演示 Python(FastAPI)项目 CI/CD 流水线的练习项目,我用这个项目来练习自动镜像构建和部署。

在workflow里实现代码格式审查、代码自动测试、Docker 镜像自动构建、阿里云服务器自动部署,以及部署失败后的自动回退老版本。

用阿里云是因为阿里云有免费额度,任何可以拉取GitHub 镜像的远程服务器都可以。如果不能拉取GitHub镜像,也可以改造我的workflow,将镜像推到harbor仓库里,再从harbor仓库拉取镜像。(这才是实际生产环境的流程,只是我懒得部署harbor加上没有多余的服务器,所以直接把镜像放在GitHub里。)

自动部署到阿里云的服务器里是用docker部署的,如果有k8s集群,也可以改造我的workflow,部署到k8s集群里。

前置准备

开启阿里云服务器的指定端口

在阿里云的实例控制台里,网络与安全组->入方向->访问来源任何(0.0.0.0),访问端口8000。开启端口后才能从本地访问到后面部署的fastapi服务。 后续引入Prometheus和Grafana监控时需要开启9090和3000端口。

服务器部署用户创建以及日志配置

详见安全改造里需要在服务器上做的步骤。

Github仓库添加密钥和变量

在GitHub代码仓库里,setting->Secrets and variables->Actions,添加密钥DEPLOY_SSH_KEY(ssh连接的私钥); 添加变量DEPLOY_HOST(服务器的公网ip),这个本来也该是密钥的,但是go在解析http://${{ vars.DEPLOY_HOST }}:8000的时候会把大括号解析为模板内容,就没法部署了。不过公网ip保密级别也没那么高,弄成变量也能接受。

Github仓库创建环境

这个项目我在初步构想的时候想的是创建测试和生产环境,发布到生产环境之后经过审批之后才能发布。 不过后面实际练习的时候都直接发在生产环境了,还是留下记录,当个参考。这一步不做也可以。

创建环境的步骤为:GitHub仓库-> Settings->Enviroment->New enviroment。

workflow解析

代码规范检查

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
  lint:
    name:  Lint
    runs-on: ubuntu-22.04

    steps:
      - name: Checkout 代码
        uses: actions/checkout@v6

      - name: 设置 Python ${{ env.PYTHON_VERSION }}
        uses: actions/setup-python@v6
        with:
          python-version: ${{ env.PYTHON_VERSION }}
          cache: 'pip'

      - name: 安装 pre-commit
        run: python -m pip install --upgrade pip && pip install pre-commit

      # 缓存 pre-commit 的虚拟环境
      - name: 缓存 pre-commit 环境
        uses: actions/cache@v5
        with:
          path: ~/.cache/pre-commit
          key: pre-commit|${{ env.PYTHON_VERSION }}|${{ hashFiles('.pre-commit-config.yaml') }}

      - name: 运行 pre-commit
        run: pre-commit run --all-files

本地和ci引入pre-commit,pre-commit配置文件.pre-commit-config.yaml,配置了ruff。本地git commit时用ruff检查代码格式和规范。

本地引入pre-commit流程:

  1. 编辑配置文件。在项目根目录下创建.pre-commit-config.yaml
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
# pre-commit 钩子配置
# 文档:https://pre-commit.com/

repos:
  # ─── 通用格式检查 ───
  - repo: https://github.com/pre-commit/pre-commit-hooks
    rev: v5.0.0
    hooks:
      - id: trailing-whitespace        # 删除行尾空白
      - id: end-of-file-fixer          # 确保文件末尾有换行
      - id: check-yaml                 # YAML 语法检查
      - id: check-added-large-files    # 防止提交大文件
        args: ['--maxkb=500']
      - id: check-merge-conflict       # 检查合并冲突标记

  # ─── ruff 代码规范检查 + 自动格式化 ───
  - repo: https://github.com/astral-sh/ruff-pre-commit
    rev: v0.15.12
    hooks:
      - id: ruff                  # 代码规范检查 + 自动修复
        args: [--fix]
      - id: ruff-format           # 自动格式化

  # ─── 排除不检查的文件 ───
exclude: '^( migrations/|\.github/ )'
  1. 下载pre-commit。pip install pre-commit
  2. 安装Git钩子脚本。pre-commit install。成功执行后,会看到类似 pre-commit installed at .git/hooks/pre-commit 的提示
  3. git commit时,pre-commit 会自动对暂存区的文件运行所有配置好的检查。 也可以手动执行命令pre-commit run --all-files,这个命令会立即执行检查,如果Ruff自动修复了格式或语法问题,你需要用git add重新暂存这些被修改的文件,然后再次尝试提交。

单元测试

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
  test:
    name:  Test
    runs-on: ubuntu-22.04
    needs: lint                        # 依赖 lint 通过后才执行

    steps:
      - name:  Checkout 代码
        uses: actions/checkout@v6

      - name: 设置 Python ${{ env.PYTHON_VERSION }}
        uses: actions/setup-python@v6
        with:
          python-version: ${{ env.PYTHON_VERSION }}
          cache: 'pip'

      - name: 安装依赖
        run: |
          python -m pip install --upgrade pip
          pip install -r requirements.txt          

      - name: 运行 pytest
        run: |
          echo "========================================="
          echo "  Pytest 单元测试 + 覆盖率报告"
          echo "========================================="
          pytest          


      #    使用 actions/upload-artifact 保留生成的pytest 测试报告文件
      - name:  上传测试结果 (JUnit XML)
        if: always()                    # 即使测试失败也要上传
        uses: actions/upload-artifact@v7
        with:
          name: test-results-junit
          path: test-results.xml
          retention-days: 30

运行pytest进行python项目测试,将测试报告上传到制品(artifact)里。制品上传下载可以参考缓存、制品与邮件

Docker构建镜像

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
  build:
    name:  Build Image
    runs-on: ubuntu-22.04
    needs: test                        # 依赖测试通过后才构建

    # 只有 push(非 PR)才构建
    if: github.event_name == 'push'

    outputs:
      image_tag: ${{ steps.single-tag.outputs.tag }}

    steps:
      - name: Checkout 代码
        uses: actions/checkout@v6

      - name:  登录 GitHub Container Registry
        uses: docker/login-action@v4
        with:
          registry: ${{ env.DOCKER_REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      #  自动根据 Git Tag / SHA 生成 Docker 镜像标签
      - name:  生成镜像标签元数据
        id: meta
        uses: docker/metadata-action@v6
        with:
          images: ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}
          tags: |
            # 如果是 tag 推送:v1.0.0 → 1.0.0, latest
            type=semver,pattern={{version}}
            type=semver,pattern={{major}}.{{minor}}
            # 如果是 branch 推送:main → sha-xxxxxx
            type=sha,prefix=
            # 始终添加 latest 标签(仅 main 分支)
            type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }}            

      - name: 提取第一个镜像标签
        id: single-tag
        run: |
          ALL_TAGS="${{ steps.meta.outputs.tags }}"
          # 提取第一个非空标签行
          FIRST_TAG=$(echo "$ALL_TAGS" | head -n 1 | xargs)
          echo "提取的部署标签: $FIRST_TAG"
          echo "tag=$FIRST_TAG" >> "$GITHUB_OUTPUT"          

      - name:  设置 Docker Buildx
        uses: docker/setup-buildx-action@v4

      - name:  构建并推送镜像
        uses: docker/build-push-action@v7
        with:
          context: .
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          cache-from: type=gha           # 使用 GitHub Actions 缓存加速构建
          cache-to: type=gha,mode=max
          platforms: linux/amd64

      - name:  输出镜像信息
        run: |
          echo "========================================="
          echo "  镜像构建成功!"
          echo "========================================="
          echo "标签: ${{ steps.meta.outputs.tags }}"
          echo "标签 JSON:"
          echo "${{ steps.meta.outputs.tags }}" | tr ',' '\n'
          echo ""
          echo "拉取命令:"
          echo "  docker pull ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:latest"          

镜像构建的核心步骤构建和推送镜像都是复用的docker官方的action,这个job的步骤如下:

  1. Checkout,拉取项目代码。
  2. 复用docker官方发的login-action,登录ghcr(GitHub Container Repository,Github容器仓库)。
  3. 复用docker官方发的metadata-action,根据提交的tag和分支生成容器的元数据。
  4. 生成的标签可能有两个,这里会报错,所以提取出第一个标签。
  5. 复用docker官方发的setup-buildx-action,设置buildx,它支持更高级的构建特性(多平台构建、缓存导出等)。
  6. 复用docker官方发的build-push-action,构建并推送镜像,利用 GHA 缓存层加速构建,在依赖不变时让每次推送的反馈时间缩短到秒级。
  7. 构建推送完成,输出镜像信息。

如果要推送到自己的harbor仓库或者阿里云的镜像仓库,步骤如下:

  1. env里DOCKER_REGISTRY修改harbor仓库或者阿里云镜像仓库。DOCKER_REGISTRY改为harbor.example.comregistry.cn-hangzhou.aliyuncs.com
  2. env里IMAGE_NAME改为harbor.example.com/项目名/镜像名 或 <你的ACR仓库地址>/命名空间/镜像名
  3. 创建Secrets存储用户名/密码(Harbor)或 RAM 账号密码/临时令牌(ACR)
  4. 在login-action里传入刚创建的用户名和密码。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    
    - name: 登录 Harbor
      uses: docker/login-action@v4
      with:
        registry: ${{ env.DOCKER_REGISTRY }}
        username: ${{ secrets.HARBOR_USERNAME }}
        password: ${{ secrets.HARBOR_PASSWORD }}
        # ACR还可以使用临时令牌来登录,安全性更高,但需要额外调用 aliyun-cli 或使用 aliyun-acr-login-action(有第三方 Action)。
        # username: ${{ secrets.ACR_USERNAME }}   # 固定密码的账号
        # password: ${{ secrets.ACR_PASSWORD }}   # 固定密码的密码
    

SSH连接到服务器自动部署及回退

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
  deploy:
    name: Deploy to Aliyun ECS
    runs-on: ubuntu-22.04
    needs: build
    if: github.event_name == 'push'
    environment: production

    steps:
      - name: 通过专用脚本安全部署
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ vars.DEPLOY_HOST }}
          username: deployer
          key: ${{ secrets.DEPLOY_SSH_KEY_DEPLOYER }}
          script: |
            if [ -z "${{ needs.build.outputs.image_tag }}" ]; then
              echo "错误:未获取到有效的镜像标签!"
              exit 1
            fi
            echo "即将部署镜像: ${{ needs.build.outputs.image_tag }}"
            sudo /usr/local/bin/deploy-fastapi-app.sh ${{ needs.build.outputs.image_tag }}            

      - name:  部署摘要
        run: |
          echo "##  部署完成" >> $GITHUB_STEP_SUMMARY
          echo "" >> $GITHUB_STEP_SUMMARY
          echo "| 项目 | 值 |" >> $GITHUB_STEP_SUMMARY
          echo "|------|-----|" >> $GITHUB_STEP_SUMMARY
          echo "| 版本 | \`${{ needs.build.outputs.image_tag }}\` |" >> $GITHUB_STEP_SUMMARY
          echo "| 触发者 | @${{ github.actor }} |" >> $GITHUB_STEP_SUMMARY
          echo "| Commit | \`${{ github.sha }}\` |" >> $GITHUB_STEP_SUMMARY
          echo "| 服务器 | \`${{ vars.DEPLOY_HOST }}\` |" >> $GITHUB_STEP_SUMMARY
          echo "" >> $GITHUB_STEP_SUMMARY
          echo "[查看服务](http://${{vars.DEPLOY_HOST }}:8000/docs)" >> $GITHUB_STEP_SUMMARY          

这里只复用了ssh连接的action,运行项目部署脚本/usr/local/bin/deploy-fastapi-app.sh,自动部署及回退。脚本内容、创建用户、日志配置在安全改造里。

最开始的一版代码没有用自动部署的脚本,而是用一个有docker权限的用户直接ssh连接到服务器上执行docker操作,进行部署及回滚。 但是这样很不安全。安全改造后将部署回退的逻辑封装成了脚本。部署用户只有执行这个脚本的权限。

监控引入

云原生项目自然也该有云原生的监控。这里引入了Prometheus+Grafana来监控。

  1. 新增app/prometheus_metrics.py,封装 prometheus-fastapi-instrumentator,定义setup_metrics()函数。
  2. 编辑requirement.txt,新增prometheus-fastapi-instrumentator==7.1.0依赖。
  3. 新增prometheus-yml,Prometheus抓取配置文件。
  4. 新增grafana\provisioning\datasources\prometheus.yml,grafana的数据源配置文件。
  5. 新增docker-compose.monitor.yml,监控容器的docker compose文件。
  6. 3、4、5新增的三个文件放在服务器的/usr/local/bin下(生产环境不该这么放,不过这是我练手的项目,文件没多少,就随便放了)。
  7. 服务器监控网络和监控容器初始化。服务器上执行docker compose -f /usr/local/bin/docker-compose.monitor.yml up -d。监控架构如下图
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
┌─────────────────────────────────────────────┐
│              Docker monitor 网络           │
│  ┌──────────────┐    ┌───────────────┐      │
│  │  prometheus  │◄───│    grafana    │      │
│  │   (9090)     │    │   (3000)      │      │
│  └──────┬───────┘    └───────────────┘      │
│         │ 抓取 /metrics                       │
│         ▼                                     │
│  ┌──────────────┐                            │
│  │  fastapi-app │  ← deploy-fastapi-app.sh   │
│  │   (8000)     │    蓝绿部署 + 自动回退      │
│  └──────────────┘                            │
└─────────────────────────────────────────────┘

后续 CI/CD 发布时,GitHub Actions 的 SSH 部署命令完全不变,脚本会自动处理网络接入。Prometheus 和 Grafana 作为常驻基础设施,只在初始化时启动一次。

运行成功

验证fastapi项目

验证 Prometheus

  1. 浏览器访问:http://IP:9090
  2. 进入 Status->Targets
  3. 应该看到 fastapi-app 状态是 UP
  4. 点击 fastapi-app 的 Endpoint 链接,能直接看到 /metrics 的原始数据
  5. 可以在搜索框里输入几个指标查看情况。点击table或graph查看数据表或者展示图。
1
2
3
4
5
6
7
8
# http总请求数
http_requests_total

# 总请求数(按状态码分组)
sum by (status_code) (http_requests_total)

# 请求延迟分布
histogram_quantile(0.95, rate(http_request_duration_seconds_bucket[5m]))

验证 Grafana

  1. 浏览器访问:http://IP:3000
  2. 账号admin,密码admin
  3. 左侧Connections->Data Sources,确认Prometheus已自动配置好
  4. 进入Explore,选择数据源 Prometheus
  5. 在Metric下拉框里应该能看到http_requests_total等指标
  6. 输入http_requests_total,点击 Run query,能看到折线图

安全改造

创建部署用户

添加GitHub Action部署用的用户

1
2
sudo useradd -m -s /bin/bash deployer      # 创建用户
id deployer                              # 确认 groups 中无 docker

部署用户ssh密钥

生产Github Action部署用户的ssh密钥

1
2
3
4
5
6
# 在服务器上,将公钥添加到 deployer 用户的 authorized_keys 中
sudo mkdir -p /home/deployer/.ssh
sudo cp .ssh/deployer_key.pub /home/deployer/.ssh/authorized_keys
sudo chown -R deployer:deployer /home/deployer/.ssh
sudo chmod 700 /home/deployer/.ssh
sudo chmod 600 /home/deployer/.ssh/authorized_keys

将生成的私钥内容(cat deployer_key)复制到 GitHub 仓库的 Settings → Secrets → Actions 中,命名为 DEPLOY_SSH_KEY_DEPLOYER。

封装部署容器逻辑为脚本

将docker部署回退镜像的逻辑封装成脚本。 sudo vim /usr/local/bin/deploy-fastapi-app.sh

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
#!/bin/bash

set -euo pipefail

##############################
# 安全部署 fastapi-app 容器(含自动回退)
# 用法: sudo deploy-fastapi-app.sh <镜像完整TAG>
##############################

APP_NAME="fastapi-app"
NETWORK_NAME="monitor"
HEALTH_URL="http://localhost:8000/health"
HEALTH_RETRIES=10
HEALTH_DELAY=3
BACKUP_CONTAINER="${APP_NAME}-backup"
DOCKER="/usr/bin/docker"
CURL="/usr/bin/curl"
LOG_FILE="/var/log/deploy-app.log"

touch "$LOG_FILE"
chmod 600 "$LOG_FILE"
log() {
    local msg="[$(date '+%Y-%m-%d %H:%M:%S')] $*"
    # 写入独立日志文件
    echo "$msg" >> "$LOG_FILE"
    # 同时继续写入系统日志(用于审计)
    /usr/bin/logger -t deploy-app "$*"
}

# ============ 参数严格校验 ============
if [ $# -ne 1 ]; then
    log "错误: 需要传入镜像 TAG"
    exit 1
fi

IMAGE_TAG="$1"
# 只拒绝包含常见 Shell 注入字符的字符串,其余镜像标签放行
if echo "$IMAGE_TAG" | grep -q "[;&\$\`|*?(){}!<>\\'\"]" ; then
    log "错误: 镜像 TAG 包含危险字符,禁止执行"
    exit 1
fi

log "===== 开始部署应用,镜像: $IMAGE_TAG ====="

# 拉取镜像
log "拉取新镜像..."
$DOCKER pull "$IMAGE_TAG"

# 确保监控网络存在(用于 Prometheus/Grafana 连通)
if ! $DOCKER network inspect "$NETWORK_NAME" >/dev/null 2>&1; then
    log "创建 Docker 网络: $NETWORK_NAME"
    $DOCKER network create "$NETWORK_NAME"
fi

log "检查并清理可能冲突的旧容器..."

#  清理之前部署失败的残留容器(status=Created/Exited/Dead)
FAILED_CONTAINER_ID=$($DOCKER ps -aq --filter "name=^${APP_NAME}$" --filter "status=created" --filter "status=exited" --filter "status=dead" 2>/dev/null || true)
if [ -n "$FAILED_CONTAINER_ID" ]; then
    log "发现残留的失败容器: $FAILED_CONTAINER_ID,强制移除..."
    $DOCKER rm -f "$FAILED_CONTAINER_ID" 2>/dev/null || true
fi

#  检查是否已有同名容器在运行,如果有,则停止并重命名为备份
RUNNING_CONTAINER_ID=$($DOCKER ps -q --filter "name=^${APP_NAME}$" || true)
if [ -n "$RUNNING_CONTAINER_ID" ]; then
    log "停止并备份当前运行的容器: $RUNNING_CONTAINER_ID"
    # 清理可能存在的旧备份容器
    $DOCKER rm -f "$BACKUP_CONTAINER" 2>/dev/null || true
    # 停止当前容器
    $DOCKER stop "$APP_NAME" >/dev/null || true
    # 重命名为备份容器
    $DOCKER rename "$APP_NAME" "$BACKUP_CONTAINER" || {
        log "重命名容器失败,强制移除后继续"
        $DOCKER rm -f "$APP_NAME" 2>/dev/null || true
    }
fi

#  最后确认,8000端口当前没有被占用(双重保险)
if ss -tuln | grep -q ':8000'; then
    log "警告: 8000端口仍被占用,尝试清理..."
    # 查找并强制移除占用8000端口的其他容器
    PORT_CONTAINER_ID=$($DOCKER ps -q --filter "publish=8000")
    if [ -n "$PORT_CONTAINER_ID" ]; then
        log "移除占用8000端口的容器: $PORT_CONTAINER_ID"
        $DOCKER rm -f "$PORT_CONTAINER_ID" 2>/dev/null || true
    fi
fi

# 启动新容器(只读根文件系统、限制内存、非特权,加入监控网络)
log "启动新容器..."
$DOCKER run -d \
    --name "$APP_NAME" \
    --network "$NETWORK_NAME" \
    --restart unless-stopped \
    -p 8000:8000 \
    --memory 256m \
    --read-only \
    --tmpfs /tmp \
    "$IMAGE_TAG"

# 健康检查
log "等待健康检查通过,最多 ${HEALTH_RETRIES} 次..."
SUCCESS=false
for i in $(seq 1 $HEALTH_RETRIES); do
    if $CURL -sSf "$HEALTH_URL" > /dev/null 2>&1; then
        log "新容器健康检查通过"
        SUCCESS=true
        break
    fi
    log "健康检查未通过,重试 $i/$HEALTH_RETRIES ..."
    sleep $HEALTH_DELAY
done

# 根据健康检查结果决定后续动作
if $SUCCESS; then
    log "部署成功,清理备份容器"
    $DOCKER rm -f "$BACKUP_CONTAINER" 2>/dev/null || true
else
    log "错误: 新容器健康检查失败,开始自动回退..."
    $DOCKER stop "$APP_NAME" || true
    $DOCKER rm "$APP_NAME" || true
    if $DOCKER inspect "$BACKUP_CONTAINER" >/dev/null 2>&1; then
        # 回退时需要把备份容器加回监控网络
        $DOCKER rename "$BACKUP_CONTAINER" "$APP_NAME"
        $DOCKER network connect "$NETWORK_NAME" "$APP_NAME" 2>/dev/null || true
        $DOCKER start "$APP_NAME"
        log "回退成功,旧容器已恢复运行"
    else
        log "严重: 没有备份容器可用,服务中断!"
        exit 1
    fi
fi

log "===== 部署流程结束 ====="

现在的日志在系统日志和/var/log/deploy-app.log各存了一份,防止系统日志过多,配合logrotate进行自动轮转。 在 /etc/logrotate.d/deploy-app 中创建:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
vim /etc/logrotate.d/deploy-app

# 填入下面的内容,每月轮转一次,保留 12 个月,旧的压缩
/var/log/deploy-app.log {
    monthly
    rotate 12
    compress
    missingok
    notifempty
    create 600 root root
}

脚本权限控制

赋予脚本正确的权限和归属。

1
2
sudo chown root:root /usr/local/bin/deploy-fastapi-app.sh
sudo chmod 755 /usr/local/bin/deploy-fastapi-app.sh

配置sudo白名单,只允许deploy用户执行这个脚本

1
2
3
4
sudo vim /etc/sudoers.d/deployer

# 填入如下内容
deployer ALL=(root) NOPASSWD: /usr/local/bin/deploy-fastapi-app.sh *

旧版容器部署代码参考

旧版的容器部署回退是放在ci里用一个有docker权限的用户做的,这样不太安全,后面做了安全改造。不过这里还是放下原版代码,以作参考。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
- name:  SSH 部署到服务器
  uses: appleboy/ssh-action@v1.0.3
  with:
    host: ${{ vars.DEPLOY_HOST }}
    username: ${{ secrets.DEPLOY_USER }}
    key: ${{ secrets.DEPLOY_SSH_KEY }}
    port: 22
    command_timeout: 10m
    script: |
      echo "========================================="
      echo "  开始部署 fastapi-cicd-demo"
      echo "  版本: ${{ steps.version.outputs.version }}"
      echo "========================================="
      # 1. 登录 GitHub Container Registry
      echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin || { echo "docker login 失败"; exit 1; }
      # 2. 拉取新镜像
      IMAGE="${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.version }}"
      docker pull "$IMAGE" || { echo "docker pull 失败"; exit 1; }
      echo "镜像拉取成功: $IMAGE"
      # 3. 检查旧容器是否存在(用于回退)
      OLD_CONTAINER_EXISTS=false
      if docker inspect fastapi-cicd-demo > /dev/null 2>&1; then
        OLD_CONTAINER_EXISTS=true
        echo "发现旧容器"
      else
        echo "未发现旧容器,首次部署"
      fi
      # 4. 停止旧容器(保留,不删除,用于回退)
      docker stop fastapi-cicd-demo 2>/dev/null || true
      echo "旧容器已停止"
      # 5. 启动新容器(临时名称)
      docker run -d \
        --name fastapi-cicd-demo-new \
        --restart unless-stopped \
        -p 8000:8000 \
        -e ENVIRONMENT=production \
        "$IMAGE" || { echo "docker run 失败"; exit 1; }
      echo "新容器已启动"
      # 6. 等待新容器启动并做健康检查
      echo "等待新服务启动..."
      sleep 5
      HEALTH=$(curl -sf http://localhost:8000/health || echo "FAILED")
      echo "健康检查结果: $HEALTH"
      if echo "$HEALTH" | grep -q "healthy"; then
        #  部署成功:清理旧容器,重命名新容器 
        echo " 新容器健康检查通过!"
        docker rm fastapi-cicd-demo 2>/dev/null || true
        docker rename fastapi-cicd-demo-new fastapi-cicd-demo
        docker image prune -f
        echo "部署成功!"
      else
        # ── 部署失败:回退到旧版本 ──
        echo "新容器健康检查失败!"
        docker logs fastapi-cicd-demo-new 2>/dev/null || true
        # 删除失败的新容器
        docker stop fastapi-cicd-demo-new 2>/dev/null || true
        docker rm fastapi-cicd-demo-new 2>/dev/null || true
        # 回退:重新启动旧容器
        if [ "$OLD_CONTAINER_EXISTS" = true ]; then
          echo " 正在回退到旧版本..."
          docker start fastapi-cicd-demo
          sleep 3
          ROLLBACK_HEALTH=$(curl -sf http://localhost:8000/health || echo "FAILED")
          echo "回退后健康检查: $ROLLBACK_HEALTH"
          echo "已回退到旧版本"
        fi
        exit 1
      fi      

其他安全改造思路

  1. 只给deploy用户docker相关操作的sudo权限。不过这样用通配符还是有命令注入的风险,而且不太好管理。
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
sudo vim /etc/sudoers.d/deployer

# 填入如下内容
# 允许 deployer 用户无密码执行特定的 Docker 命令
deployer ALL=(root) NOPASSWD: /usr/bin/docker pull ghcr.io/*/*
deployer ALL=(root) NOPASSWD: /usr/bin/docker stop fastapi-app
deployer ALL=(root) NOPASSWD: /usr/bin/docker rm fastapi-app
deployer ALL=(root) NOPASSWD: /usr/bin/docker run --name fastapi-app *
deployer ALL=(root) NOPASSWD: /usr/bin/docker start fastapi-app
deployer ALL=(root) NOPASSWD: /usr/bin/docker inspect fastapi-app
deployer ALL=(root) NOPASSWD: /usr/bin/docker ps *
  1. 使用argoCD来拉取镜像,部署容器
  2. 使用rootless Docker或者Podman运行容器

git提交触发

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
# 1. 添加修改后的 CI 配置文件
git add .github/workflows/ci-cd.yml

# 2. 使用明确的提交信息
git commit -m "fix: 修复 Deploy Job 中镜像标签多行传递导致命令执行异常的问题"

# 3. 推送到 main 分支
git push origin main

# 4. 创建一个新的 Tag(例如 v1.0.2)
git tag -a v1.0.2 -m "release v1.0.2: 尝试修复部署标签问题"

# 5. 推送该 Tag 以触发完整 CI/CD 流程(包括部署)
git push origin v1.0.2

完整ci-cd.yml代码

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
# ============================================================
# FastAPI CI/CD 流水线
# ============================================================
# 触发条件:
#   1. push tag(如 v1.0.0,执行 Lint + Test + Build + Deploy,使用 tag 作为版本号)
#   2. Pull Request(仅执行 Lint + Test)
# ============================================================

name: CI/CD Pipeline

# ──────────────────────────────────────
# 触发条件
# ──────────────────────────────────────
on:
  push:
    tags:
      - 'v*'          # 匹配 v1.0.0, v2.1.3 等
  pull_request:
    branches:
      - main

# ──────────────────────────────────────
# 全局环境变量
# ──────────────────────────────────────
env:
  DOCKER_REGISTRY: ghcr.io                  # GitHub Container Registry
  IMAGE_NAME: ${{ github.repository }}     # 镜像名 = 用户名/仓库名
  PYTHON_VERSION: '3.10'

# ──────────────────────────────────────
# 权限
# ──────────────────────────────────────
permissions:
  contents: read
  packages: write
  security-events: write

# ============================================================
# Jobs
# ============================================================
jobs:

  # ──────────────────────────────────────
  # Job 1: Lint(代码规范检查)
  # ──────────────────────────────────────
  lint:
    name:  Lint
    runs-on: ubuntu-22.04

    steps:
      - name: Checkout 代码
        uses: actions/checkout@v6

      - name: 设置 Python ${{ env.PYTHON_VERSION }}
        uses: actions/setup-python@v6
        with:
          python-version: ${{ env.PYTHON_VERSION }}
          cache: 'pip'

      - name: 安装 pre-commit
        run: python -m pip install --upgrade pip && pip install pre-commit

      # 缓存 pre-commit 的虚拟环境
      - name: 缓存 pre-commit 环境
        uses: actions/cache@v5
        with:
          path: ~/.cache/pre-commit
          key: pre-commit|${{ env.PYTHON_VERSION }}|${{ hashFiles('.pre-commit-config.yaml') }}

      - name: 运行 pre-commit
        run: pre-commit run --all-files

  # ──────────────────────────────────────
  # Job 2: Test(单元测试 + 覆盖率)
  # ──────────────────────────────────────
  test:
    name:  Test
    runs-on: ubuntu-22.04
    needs: lint                        # 依赖 lint 通过后才执行

    steps:
      - name:  Checkout 代码
        uses: actions/checkout@v6

      - name: 设置 Python ${{ env.PYTHON_VERSION }}
        uses: actions/setup-python@v6
        with:
          python-version: ${{ env.PYTHON_VERSION }}
          cache: 'pip'

      - name: 安装依赖
        run: |
          python -m pip install --upgrade pip
          pip install -r requirements.txt          

      - name: 运行 pytest
        run: |
          echo "========================================="
          echo "  Pytest 单元测试 + 覆盖率报告"
          echo "========================================="
          pytest          


      #    使用 actions/upload-artifact 保留生成的pytest 测试报告文件
      - name:  上传测试结果 (JUnit XML)
        if: always()                    # 即使测试失败也要上传
        uses: actions/upload-artifact@v7
        with:
          name: test-results-junit
          path: test-results.xml
          retention-days: 30


  # ──────────────────────────────────────
  # Job 3: Build(构建 Docker 镜像)
  # ──────────────────────────────────────
  build:
    name:  Build Image
    runs-on: ubuntu-22.04
    needs: test                        # 依赖测试通过后才构建

    # 只有 push(非 PR)才构建
    if: github.event_name == 'push'

    outputs:
      image_tag: ${{ steps.single-tag.outputs.tag }}

    steps:
      - name: Checkout 代码
        uses: actions/checkout@v6

      - name:  登录 GitHub Container Registry
        uses: docker/login-action@v4
        with:
          registry: ${{ env.DOCKER_REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      #  自动根据 Git Tag / SHA 生成 Docker 镜像标签
      - name:  生成镜像标签元数据
        id: meta
        uses: docker/metadata-action@v6
        with:
          images: ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}
          tags: |
            # 如果是 tag 推送:v1.0.0 → 1.0.0, latest
            type=semver,pattern={{version}}
            type=semver,pattern={{major}}.{{minor}}
            # 如果是 branch 推送:main → sha-xxxxxx
            type=sha,prefix=
            # 始终添加 latest 标签(仅 main 分支)
            type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }}            

      - name: 提取第一个镜像标签
        id: single-tag
        run: |
          ALL_TAGS="${{ steps.meta.outputs.tags }}"
          # 提取第一个非空标签行
          FIRST_TAG=$(echo "$ALL_TAGS" | head -n 1 | xargs)
          echo "提取的部署标签: $FIRST_TAG"
          echo "tag=$FIRST_TAG" >> "$GITHUB_OUTPUT"          

      - name:  设置 Docker Buildx
        uses: docker/setup-buildx-action@v4

      - name:  构建并推送镜像
        uses: docker/build-push-action@v7
        with:
          context: .
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          cache-from: type=gha           # 使用 GitHub Actions 缓存加速构建
          cache-to: type=gha,mode=max
          platforms: linux/amd64

      - name:  输出镜像信息
        run: |
          echo "========================================="
          echo "  镜像构建成功!"
          echo "========================================="
          echo "标签: ${{ steps.meta.outputs.tags }}"
          echo "标签 JSON:"
          echo "${{ steps.meta.outputs.tags }}" | tr ',' '\n'
          echo ""
          echo "拉取命令:"
          echo "  docker pull ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:latest"          

  # ──────────────────────────────────────
  # Job 4: Deploy(部署到服务器)
  # ──────────────────────────────────────
  deploy:
    name: Deploy to Aliyun ECS
    runs-on: ubuntu-22.04
    needs: build
    if: github.event_name == 'push'
    environment: production

    steps:
      - name: 通过专用脚本安全部署
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ vars.DEPLOY_HOST }}
          username: deployer
          key: ${{ secrets.DEPLOY_SSH_KEY_DEPLOYER }}
          script: |
            if [ -z "${{ needs.build.outputs.image_tag }}" ]; then
              echo "错误:未获取到有效的镜像标签!"
              exit 1
            fi
            echo "即将部署镜像: ${{ needs.build.outputs.image_tag }}"
            sudo /usr/local/bin/deploy-fastapi-app.sh ${{ needs.build.outputs.image_tag }}            

      - name:  部署摘要
        run: |
          echo "##  部署完成" >> $GITHUB_STEP_SUMMARY
          echo "" >> $GITHUB_STEP_SUMMARY
          echo "| 项目 | 值 |" >> $GITHUB_STEP_SUMMARY
          echo "|------|-----|" >> $GITHUB_STEP_SUMMARY
          echo "| 版本 | \`${{ needs.build.outputs.image_tag }}\` |" >> $GITHUB_STEP_SUMMARY
          echo "| 触发者 | @${{ github.actor }} |" >> $GITHUB_STEP_SUMMARY
          echo "| Commit | \`${{ github.sha }}\` |" >> $GITHUB_STEP_SUMMARY
          echo "| 服务器 | \`${{ vars.DEPLOY_HOST }}\` |" >> $GITHUB_STEP_SUMMARY
          echo "" >> $GITHUB_STEP_SUMMARY
          echo "[查看服务](http://${{vars.DEPLOY_HOST }}:8000/docs)" >> $GITHUB_STEP_SUMMARY          
网站总访客数:Loading
网站总访问量:Loading
使用 Hugo 构建
主题 StackJimmy 设计