从 0 到 1:一套 GitHub + GHCR + k3s 的全自动 CI/CD 流水线(Flask 项目实战)

基于一台 轻量云服务器(k3s 单节点集群)上的真实部署经验总结。

覆盖两个 Flask 项目:纯 Flask 无数据库和 Flask + MySQL 含数据库迁移。

真正的"push 到 master 即自动上线",无需任何手动操作。


一、为什么做这套流水线

我有一台轻量云服务器,上面跑了 k3s 集群,里面已经部署了多个业务服务(Prometheus、Grafana 以及自己的 Flask 应用)。

最初部署是手动流程:本地开发 → git push → ssh 上服务器 → 手动 build 镜像 → 手动改 k8s 配置 → 手动 rollout。这套流程既慢又容易出错,尤其当项目开始有数据库迁移需求后,手动部署的风险更高。

目标很明确:

  • push 代码到 master,一切自动
  • 自动构建镜像
  • 自动推送到镜像仓库
  • 自动部署到 k3s
  • 需要数据库迁移的项目,自动先迁移再更新
  • 任一环节失败,不影响线上旧版本

二、整体架构(方案:GitHub Actions → GHCR → k3s)

核心思路是彻底摒弃"在服务器上手动构建",改为:

复制代码
你 push 代码到 GitHub master
        │
        ▼
GitHub Actions 构建镜像    (云端构建,不占本地资源)
推送到 GHCR
        ▼
ghcr.io/<owner>/<app>:<git-sha>
        ▼
服务器 webhook 监听器       (nginx 反代 → 端口 8080)
按仓库名分发到部署脚本
        ▼
k3s: kubectl set image
(先跑 DB 迁移再更新)

为什么用 GHCR(GitHub Container Registry)而不是 Docker Hub?

我在国内服务器实测:

  • GitHub 主站 github.com 直连不稳定(有时直接卡死 90 秒)
  • Docker Hub registry-1.docker.io 超时连不上
  • 但 GHCR ghcr.io 稳定约 0.5s,能正常拉取
    所以选 GHCR 作为镜像仓库,绕开了大陆服务器的网络痛点。

为什么让 GitHub Actions 构建,而不是服务器本地构建?

  • 云端构建不占CPU,服务器只负责拉镜像 + 部署
  • 避免在服务器上拉 git 源码(github.com 不稳)+ 本地 build 的脆弱链路
  • Actions 和环境天然干净

三、目录结构

复制代码
项目根目录
├── .github/workflows/deploy.yml   # GitHub Actions 流水线定义
├── deploy/webhook/
│   ├── webhook-listener.py        # 服务器端共用监听器(多仓库分发)
│   └── <app>-deploy.sh            # 各项目的部署脚本
└── Dockerfile                     # 应用镜像构建文件

四、关键组件逐个讲解

4.1 GitHub Actions 流水线(.github/workflows/deploy.yml)

每个仓库一份,触发条件为 push 到 master/main:

复制代码
name: Build & Push

on:
  push:
    branches: [master, main]
  workflow_dispatch: {}   # 支持手动触发

env:
  IMAGE_NAME: <你的应用名>   #

jobs:
  build-and-push:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write            # 推 GHCR 必须有
    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: 获取 git 短 sha
        id: meta
        run: echo "sha=$(git rev-parse --short HEAD)" >> "$GITHUB_OUTPUT"

      - name: 登录 GHCR
        uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: 构建并推送
        run: |
          # GHCR 镜像名必须全小写(GitHub 用户名可能含大写,需转小写)
          OWNER_LC="$(echo "${{ github.repository_owner }}" | tr '[:upper:]' '[:lower:]')"
          IMG="ghcr.io/${OWNER_LC}/${{ env.IMAGE_NAME }}"
          docker build -t "$IMG:latest" -t "$IMG:${{ steps.meta.outputs.sha }}" .
          docker push "$IMG:latest"
          docker push "$IMG:${{ steps.meta.outputs.sha }}"

要点:

  • 镜像打两个 tag:latest + <git-sha>。用 sha 作为不可变版本号,latest 便于快速回滚到最新。
  • github.repository_owner 可能含大写字母,但 GHCR 镜像名必须小写,所以用 tr 转小写。(我踩过这个坑:不处理会报 repository name must be lowercase)

4.2 服务器端 webhook 监听器(多仓库共用)

用 Python 标准库写的 HTTP 服务器,零第三方依赖,监听 127.0.0.1:8080(不对外,经 nginx 反代)。

核心能力:

  • 双重鉴权:URL 路径带 secret + 校验 GitHub 的 X-Hub-Signature-256(HMAC-SHA256)
  • repository.name 分发到不同项目的部署脚本
  • 只处理 master/main 分支的 push,其他分支忽略
  • 后台线程异步执行部署,不阻塞 webhook 响应

代码核心:

复制代码
仓库名 -> 部署脚本映射(新增项目只需加一行)
REPO_SCRIPTS = {
    "web1": "/usr/local/bin/iweb1.sh",
    "web2":      "/usr/local/bin/iweb2.sh",
}

def verify_signature(payload: bytes, header_sig: str) -> bool:
    """校验 GitHub X-Hub-Signature-256 (HMAC-SHA256)"""
    expected = "sha256=" + hmac.new(
        SECRET.encode(), payload, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, header_sig)

分发逻辑(do_POST 里):

复制代码
repo = (data.get("repository") or {}).get("name", "")
branch = data.get("ref", "").replace("refs/heads/", "")
if branch not in ("master", "main", ""):
    return  # 忽略非主分支
if repo not in REPO_SCRIPTS:
    return  # 忽略未登记的仓库
run_deploy(repo, commit_id[:7])

4.3 部署脚本(按项目区分)

无数据库项目------只滚动更新:

复制代码
#!/usr/bin/env bash
set -euo pipefail
NS="web1"; DEPLOY="web1-web"; CONTAINER="web"

COMMIT="${1:-}"
IMAGE="ghcr.io/<owner>/<app>:${COMMIT:-latest}"

kubectl -n "$NS" set image "deployment/$DEPLOY" "$CONTAINER=$IMAGE"
if ! kubectl -n "$NS" rollout status "deployment/$DEPLOY" --timeout=240s; then
  kubectl -n "$NS" rollout undo "deployment/$DEPLOY"   # 失败自动回滚
  exit 1
fi

有数据库迁移的项目(如 qmt,Flask + SQLAlchemy + Alembic)------先迁移再更新:

复制

复制代码
#!/usr/bin/env bash
set -euo pipefail
NS="web2"; DEPLOY="web2-web"; CONTAINER="web"

COMMIT="${1:-}"
IMAGE="ghcr.io/<owner>/<app>:${COMMIT:-latest}"
MIGRATE_JOB="qmt-db-migrate"

# 第 1 步:用新镜像跑数据库迁移 Job(flask db upgrade)
kubectl -n "$NS" delete job "$MIGRATE_JOB" --ignore-not-found   # Job 不可原地更新
kubectl apply -f - <<YAML
apiVersion: batch/v1
kind: Job
metadata:
  name: $MIGRATE_JOB
  namespace: $NS
spec:
  backoffLimit: 1
  template:
    spec:
      restartPolicy: Never
      imagePullSecrets: [{name: ghcr-pull}]
      containers:
        - name: migrate
          image: $IMAGE
          imagePullPolicy: Always
          command: ["flask", "db", "upgrade"]
          envFrom: [{configMapRef: {name: web1-web}}, {secretRef: {name: web1-web-secret}}]
YAML

# 迁移失败立即中止,不动线上 web
kubectl -n "$NS" wait --for=condition=complete "job/$MIGRATE_JOB" --timeout=180s

# 第 2 步:迁移成功后滚动更新 web
kubectl -n "$NS" set image "deployment/$DEPLOY" "$CONTAINER=$IMAGE"
kubectl -n "$NS" rollout status "deployment/$DEPLOY" --timeout=240s

为什么先迁移再更新?

新代码可能依赖新表/新字段,若先更新代码表结构还没变,容器一启动就崩。反过来先跑迁移建好表,再上新代码,平滑无感。

4.4 systemd 托管监听器(开机自启 + 崩溃重启)

复制代码
[Unit]
Description=GitHub webhook auto-deploy listener
After=network-online.target

[Service]
Type=simple
EnvironmentFile=/etc/webhook-secret.env   # 密钥独立存放,不写死
Environment="LISTEN_HOST=127.0.0.1"
Environment="LISTEN_PORT=8080"
ExecStart=/usr/bin/python3 /usr/local/bin/webhook-listener.py
Restart=always
RestartSec=3
# 安全硬化
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=full

[Install]
WantedBy=multi-user.target

4.5 k3s 拉取 GHCR 私有镜像:imagePullSecret

GHCR 镜像默认私有时,k3s 拉取需要凭证。给每个命名空间建 ghcr-pull secret:

复制代码
kubectl -n <ns> create secret docker-registry ghcr-pull \
  --docker-server=ghcr.io \
  --docker-username=<你的GitHub用户名> \
  --docker-password=<GitHub经典Token, 需 write:packages 权限> \
  --docker-email=<邮箱>

然后让 Deployment 引用它:

复制代码
spec:
  template:
    spec:
      imagePullSecrets:
        - name: ghcr-pull
      containers:
        - name: web
          image: ghcr.io/<owner>/<app>:latest
          imagePullPolicy: Always   # 保证每次拉最新

4.6 nginx 反代

监听器只监听本地 8080,通过 nginx 把 HTTPS 请求转发进去:

复制代码
location /webhook/ {
    proxy_pass http://127.0.0.1:8080;
    proxy_set_header Host $host;
    # ... 其他 proxy 头
}

然后在 GitHub 仓库 Settings → Webhooks 里配置:

  • Payload URL: https://<你的域名>/webhook/<SECRET>
  • Content type: application/json
  • Secret: 与 /etc/webhook-secret.env 里一致的 SECRET
  • 事件:选择 Just the push event

五、GitHub Webhook 配置步骤

  1. 仓库 → Settings → Webhooks → Add webhook
  2. Payload URL 填:https://<域名>/webhook/<你的SECRET>
  3. Content type:application/json
  4. Secret 填:与服务器上 SECRET 一致
  5. Which events:选 Just the push event(只响应 push)
  6. 保存后 GitHub 会发一个 ping 事件,服务器返回 pong 即配置成功

提示:服务器监听器里处理了 ping 事件(返回 pong),用来验证 webhook 通不通。


六、完整上线流程(一杯咖啡的时间)

修改代码 → git add → git commit -m "..." → git push origin master

然后就没了,剩下的全自动:

步骤 谁在做 结果
1. 构建镜像 GitHub Actions 推送到 GHCR
2. 触发 webhook GitHub → 服务器 200
3. 迁移数据库(仅 qmt) deploy.sh → k8s Job 成功或中止
4. 滚动更新 deploy.sh → kubectl set image 成功
5. 验证 监听器日志 + curl 成功

部署日志实时看:tail -f /var/log/<app>-webhook.log(监听器统一写入该日志文件)


七、踩过的坑(重点)

坑 1:GHCR 镜像名必须小写

github.repository_owner 返回的 GitHub 用户名可能含大写,直接拼进镜像名会报错:

复制

复制代码
repository name must be lowercase

解决 :用 tr '[:upper:]' '[:lower:]' 转小写再拼镜像名。

坑 2:push workflow 文件需要额外的权限

一开始用的 Token 有 repo 但没有 workflow 权限,push .github/workflows 被拒:

复制

复制代码
refusing to allow a Personal Access Token to create or update workflow ... without workflow scope

解决 :Token 需要 repo + workflow + write:packages 三个 scope。

坑 3:kubectl rollout undo 会回滚掉 imagePullSecret

我先给 Deployment 加了 imagePullSecret,后来又 rollout undo 回滚版本,结果 imagePullSecret 也被回滚没了 ,导致从 GHCR 拉镜像 401 Unauthorized

解决:回滚后要重新 patch 挂上 imagePullSecret,或者把它写进 Deployment 的正式 manifest。

坑 4:本地无前缀镜像名 + IfNotPresent 会去 Docker Hub 拉

如果 Deployment 里镜像写的是web1-web:latest(无 registry 前缀),且 imagePullPolicy 是 IfNotPresent,一旦改成 Always,k8s 会去 docker.io/library/web1-qmt-web:latest 拉------而国内服务器连 Docker Hub 是 403/超时。

解决:本地测试镜像不要用无前缀标签部署;生产统一引导到 GHCR 带完整前缀的镜像。

坑 5:从大陆服务器直连 GitHub 主站不稳定

git push/git fetchgithub.com 偶尔报 Empty reply from server

解决

  • 镜像拉取走 GHCR(稳定)
  • 推送 workflow 等文件时,如果 git push 偶发失败,改用 GitHub APIapi.github.com 稳定)创建文件:

复制

复制代码
curl -X PUT "https://api.github.com/repos/<owner>/<repo>/contents/<path>" \
  -H "Authorization: token <TOKEN>" \
  -d '{"message":"...","content":"<base64>","branch":"master"}'

坑 6:imagePullPolicy 不一致

web1用 Always,web2 当时是 IfNotPresentIfNotPresent 遇到相同 tag 不会重新拉(虽然 sha tag 每次都不同,一般能拉到,但 latest 这种不定 tag 就不可靠)。

建议 :统一用 Always,配合 sha tag 最稳妥。

相关推荐
u1301304 小时前
GitHub 热榜项目:日榜(2026-08-31)
github
Justinsky4 小时前
DeepSeek Harness 开源一个月,我把它整个嵌进了 Electron 桌面软件
github
小宋10214 小时前
MCP 是什么:从零编写一个可供 AI 调用的工具服务
人工智能·github
蓝鸟19744 小时前
Python Flask + Oracle 接口开发 小白完整版笔记(入参/查库/批量/JSON/避坑)
python·oracle·flask
小弥儿5 小时前
GitHub今日热榜 | 2026-09-01:迷你小模型登场
学习·microsoft·开源·github
QUOR6 小时前
Zorv AI 内置浏览器技术架构与开发指南(新版)
架构·github
u1301306 小时前
GitHub 热榜项目:日榜(2026-09-01)
github
younuo36556 小时前
广州网站搭建费用明细:域名、服务器与开发成本全解析
服务器·前端·github
百变梦仔6 小时前
Codex 启动回复合格后,我会用三类证据验收前端改动
前端·github