基于一台 轻量云服务器(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 配置步骤
- 仓库 → Settings → Webhooks → Add webhook
- Payload URL 填:https://<域名>/webhook/<你的SECRET>
- Content type:application/json
- Secret 填:与服务器上 SECRET 一致
- Which events:选 Just the push event(只响应 push)
- 保存后 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 fetch 到 github.com 偶尔报 Empty reply from server。
解决:
- 镜像拉取走 GHCR(稳定)
- 推送 workflow 等文件时,如果 git push 偶发失败,改用 GitHub API (
api.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 当时是 IfNotPresent。IfNotPresent 遇到相同 tag 不会重新拉(虽然 sha tag 每次都不同,一般能拉到,但 latest 这种不定 tag 就不可靠)。
建议 :统一用 Always,配合 sha tag 最稳妥。