从零搭一套轻量 CI/CD,并让它托管自己:Gitee WebHook → 校验/构建 → 人工批准蓝绿发布
从零搭一套轻量 CI/CD,并让它托管自己
本文由 AI 读取 D:\hym 各仓库的 git commit 记录自动生成(覆盖 2026-10-10 ~ 2026-10-13)
署名说明:
e-commerce-frontend里Heyiming、xlagent里hym/Heyiming均为本人提交;同事wenhai03(why)的提交不并入本文统计。本文只讲我这两天从零搭起来的这套 xlagent-ci,以及它最后怎么把「自己」也接进了流水线。
0. 一句话概括
用零第三方依赖的一个 Node 脚本(
server.mjs)做 Gitee WebHook 接收器,把「校验 → 构建 → 人工批准 → 部署」拆成可审计的阶段;一个 receiver 服务托管五个项目;最后让这套 CI 引擎托管它自己(自举),并用 Go-Zero 的蓝绿方案把后端发布做成了「一翻 nginx 标记就切流」。
两天里前后端一共落了几十个提交,主线其实是三件事:
- 把发版从”人肉 scp”收编成流水线——先在推送时把「环境变量/产物口径」这类低级错误卡死;
- 多项目化——一个 9090 服务,前端、两个后端仓库、再加上 CI 引擎自身、GPU 编排器,五条流水线各管各;
- 自举与治理——CI 管自己(自举部署最危险的坑就是”重启会杀掉正在部署的进程”),并把整套只读/批准能力收敛进平台管理页。
1. 缘起:一次「裸端口」事故
导火索很朴素:apps/pc/.env.local 里
1 | VITE_AGENT_API_BASE_URL=47.76.197.179:9012 # ← 丢了 http:// |
构建产物里全站 URL 被拼成 http://...:9012/47.76.197.179:9012/api/...,大面积 404。这类「环境变量 / 产物口径」错误,在构建阶段完全能自动发现——只要有人把它写成一条门禁。
这就是第一次提交的动机(97a29bde):
ci: 新增轻量 CI/CD(Gitee WebHook→校验/构建→人工批准部署)+ 修 env 裸端口
顺带把 .env.local 里 VITE_AGENT_API_BASE_URL、PUBLIC_BASE_URL、AGG_CHAT_URL 三处裸 host:port 补回 http:// 前缀。
为什么不用现成的 CI(Jenkins/GitLab CI/Drone)?
- 目标机只有一台 14G 内存的云主机,还要和生产容器挤着跑;
- 需求很窄:一个 push 钩子、串行构建、人工点一下部署,不需要 runner 集群 / 秘钥管理 / 缓存矩阵那一整套;
- 自建 = 全部逻辑都在自己眼皮底下,每条门禁都能讲清楚为什么存在,出问题能当场改。
于是就有了 e-commerce-frontend/scripts/ci/ 这一个目录——它的全部内容就是这 12 个文件(外加服务端 config.json,含密钥、不入库)。
2. 整体设计:一条 push 的旅程
1 | Gitee push ──WebHook(密码)──▶ POST http://47.76.197.179:9090/hook/<proj> |
两条铁律:
- CI 红了不能 CD;
- CI 绿了也不会自动 CD——部署永远要人工发一次
POST /deploy/<proj>/<sha>。
第二条是刻意的:服务器上跑着真实生产,自动部署带来的收益远小于一次误触发的代价。所以这套系统的定位是「把错误挡在推送时,把部署变成一次有回滚锚的点击」。
3. 触发面:WebHook → 队列 → 单 sha 流水线
3.1 接收器
server.mjs 只用 Node 内置模块(http/child_process/fs),监听 0.0.0.0:9090。它不解析、不执行 payload 里的任何命令,sha / ref / project 都要过严格字符白名单才允许传给子进程:
1 | const PROJECT_RE = /^[a-z0-9][a-z0-9_-]{0,31}$/i; |
密码校验走 X-Gitee-Token 头或表单 password。校验通过立即 200 {queued:true},把真正的构建丢进队列。
3.2 为什么全局串行
前端 pnpm build 和后端 go build platform(链接期吃 >4G)都不能并行——一台 14G 的机器,两个构建一起上就 OOM 了。所以整个 receiver 只有一个 running 标志位,队列串行:
1 | function pump() { |
3.3 阶段与门禁(前端)
| 阶段 | 命令 | 默认门禁 | 说明 |
|---|---|---|---|
| install | pnpm install --frozen-lockfile |
硬 | 依赖没装,后面全挂 |
| env | check-env.mjs --strict-prod apps/pc/.env.local |
硬 | 裸 host:port / 双重 URL / 必填缺失 → 直接红 |
| typecheck | pnpm --filter pc type-check |
报告 | 存量类型错误多,先只报不挡 |
| lint | npx eslint … --quiet |
报告 | 只报不挡 |
| unit | npx vitest run --exclude **/tests/e2e/** |
报告 | 存量失败用例待清;e2e 要浏览器,不在 CI 跑 |
| build | pnpm build:pc(NODE_OPTIONS=--max-old-space-size=3072) |
硬 | 防 terser OOM |
| artifact | check-build.mjs dist |
硬 | 产物完整性 + bundle 内坏 URL / 旧主机 |
「报告 / 硬」这个区分很关键:存量问题先只报不挡,等清理得差不多了再逐个转硬门禁(hardStages 在 config.json 里可调)。否则一上来就全门禁,团队会发现「推什么都红」,于是集体绕过 CI——那还不如不做。
4. 多项目化:一个 receiver 服务五个项目
到第二天,需求从”一个前端仓库”扩到了五个:
| 项目 | 仓库 | 分支 | 产物 → CD |
|---|---|---|---|
frontend |
e-commerce-frontend | ai-assistant | dist → deploy.sh(增量 staged) |
ec-backend |
e-commerce-backend | xlagent | 四枚 Go 二进制 → deploy-bg.sh(蓝绿) |
xlagent-backend |
xlagent | main | xlagent-linux-amd64 → deploy-xlagent.sh(单容器) |
cicd |
e-commerce-frontend | ai-assistant | 仅 scripts/ci/** → deploy-cicd.sh(自举重启) |
orchestrator |
scripts-related-to-autodl | main | orchestrator/*.py → deploy-orchestrator.sh |
改造(b56dad33)的要点:
- 配置从「单项目平铺」改成
projects.<name>(repoUrl/workDir/pipeline/deployScript/hardStages…),顶层只留port/deployPassword/stateDir/nodeBinDir/feishuWebhook; - 路由统一带可选项目段,省略时落到
defaultProject(向后兼容旧单项目 webhook):POST /hook/<proj>、GET /reports/<proj>、/report/<proj>/<sha>、新增/log/<proj>/<sha>、POST /deploy/<proj>/<sha>; - 每个项目的运行态目录 =
<stateDir>/<project>/{logs,reports,artifacts},构建队列仍全局串行; - 旧单项目配置自动合成为
project=frontend——这样新server.mjs可以安全地先于config.json转换上线。
4.1 一个当时被 review 抓出来的鉴权洞
如果 pickProject 对未知项目名也回落到默认项目,那 GET /reports/unknownproj 就会读到默认项目的数据。修法是「只认已登记的项目名」:
1 | function pickProject(segs) { |
4.2 watchPaths:让 CI 引擎只对自己目录的改动触发
cicd 项目和 frontend 项目是同一个仓库(里面放着 scripts/ci/)。如果不做过滤,前端页面每次推送都会连带重跑一遍 CI 引擎自己的流水线,纯浪费。
所以给项目加了 watchPaths(024c9d71):
1 | function shouldTrigger(project, payload) { |
两处刻意的保守选择:前缀匹配足够(不必精确到文件),路径信息缺失时 fail-open(宁多跑一次,也别漏掉一次该跑的)。
注意
cicd项目用了独立 clone 目录(repo-cicd),和frontend的repo分开——否则两个项目共用一个工作树,会互相checkout -f打架。
5. 后端流水线:容器内 protoc 重生成 + 串行交叉编译
后端(ec-backend)的流水线 run-pipeline-backend.sh(857e1034)有几个 Go-Zero 专属的门道:
- 构建全在容器里(
build-go:latest= Go1.26 + protoc + goctl + protoc-gen-{go,go-grpc,validate}),宿主不装 Go; - generate 是硬门禁:仓库
.gitignore了gateway/pb和platform/rpc_server,所以 CI 必须现场重跑go run ./proto,否则网关 proto 解析 400 / crash-loop(这是 2026-09-10 血泪教训); - build 硬门禁、串行编译四枚
linux/amd64二进制:
1 | dkr() { docker run --rm --cpus=4 --memory=6g -e GOMEMLIMIT=4GiB \ |
内存参数是调出来的(26d8901e):--memory=4g 会被 OOM killed,因为 platform 链接期吃 >4G;最终定 --memory=6g + GOMEMLIMIT=4GiB(宿主 14G,留余量给生产)。
5.1 两个不太显眼的修复
| tail会吞掉退出码(73e92bed):go vet ./... | tail -50里,管道的退出码是tail的(恒 0),于是 vet 有错也判 success。改成先落文件再取rc;- detached HEAD 判不出分支(
c06c7148):检出指定 commit 后git rev-parse --abbrev-ref HEAD恒返回HEAD。改为优先用 webhook 的ref(refs/heads/<branch>),缺失时用git branch -r --contains <sha>反查。
1 | BRANCH="" |
6. 人工批准的 CD
6.1 前端:增量 staged 部署
deploy.sh 的核心顺序是「资源先进、最后切 index.html」——先备份,再把新资源落到线上目录,最后一步才覆盖 index.html,然后探针。这样即使中途挂掉,线上仍是旧的 index.html 指向旧资源,不会出现”新页面引用还没传上去的资源” 的中间态。
6.2 后端:蓝绿(deploy-bg.sh)
Go-Zero 的四个服务用 pro1 / pro2 两套色,deploy-bg.sh 的流程是:
1 | 判 idle 色 → 备份 idle 二进制/compose → 解包安装 + 同步 gateway/pb |
「唯一状态来源」就是 nginx 配置里的 down; 标记:
1 | idle_color() { grep -oE 'server[[:space:]]+127\.0\.0\.1:[0-9]+[[:space:]]+down;[[:space:]]*#[A-Za-z0-9_-]+' "$NGINX_CONF" | grep -oE '#[A-Za-z0-9_-]+' | tr -d '#\r' | head -1; } |
健康粗筛刻意不用 curl(nginx 会剥 /v1,curl 得到的 404 说明不了问题),而是看 docker inspect 的 RestartCount/Running + docker logs 里有没有 panic|fatal|auth upstream unavailable。粗筛过了仍要人工确认稳定 1~2 分钟再切流。
6.3 一个被推翻又修正的设计:怎么做到「色隔离」
两套色如果共用一套容器网络,platform:8001 这种裸服务名会解析到哪一色是歧义的。第一次尝试(26d8901e)想用「颜色专属容器名 Target」——给 pro2 的配置把上游目标改成 platform2:8001 之类。
后来发现更干净的解法(73e92bed):每色挂各自的网络——
- pro1 →
xlagent网络,pro2 →xlagent2网络; - redis 同时接入两张网(别名
redis); - 于是每色内部的裸服务名
platform:8001色内唯一,互不歧义; - pro2 的配置就是 pro1 的直接拷贝,一行 Target 都不用改,
#pro1/#pro2 down标记成为唯一的切换开关。
教训:一个「看似要改一堆配置」的问题,往往是因为网络拓扑没分层;把隔离放到网络层,配置层就自然干净了。
7. 自举:让 CI 引擎托管它自己
这是这两天最有意思的一块(024c9d71 + run-pipeline-cicd.sh / deploy-cicd.sh)。
动机:CI 引擎(server.mjs 和一堆脚本)之前是手工 scp 到新机 ci/bin 再 systemctl restart 的——绕过 CD、无版本校验、无日志。把 scripts/ci/** 纳入流水线后,改引擎也能走「推送 → 校验 → 人工批准 → 发布」。
run-pipeline-cicd.sh 的门禁很朴素但有效:
- syntax(硬):对每个
.mjs跑node --check,每个.sh跑bash -n——引擎正确性的最低门槛; - artifact(硬):把
scripts/ci整目录打包,并断言产物里含server.mjs。
7.1 自举的死结:重启会杀掉正在部署的进程
xlagent-ci 由 systemd 管理,默认 KillMode=control-group。而 deploy-cicd.sh 是 server.mjs 的子进程,和它同一个 cgroup。于是:
systemctl restart xlagent-ci会连同 deploy 脚本自己一起杀掉——重启之后的「健康验证 / 失败回滚」永远跑不到。
解法(写在脚本顶部注释里):
- 正常模式下
deploy-cicd.sh只做「备份 + 安装」,绝不在这条路径里重启本服务; - 把「重启 + 验证 + 回滚」交给一个
systemd-run派生的独立瞬态单元(独立 cgroup,不受本服务重启影响),靠环境变量DEPLOY_CICD_MODE=verify二次进入:
1 | UNIT="xlagent-ci-selfdeploy-$TS" |
- verify 模式里再
systemctl restart+ 探针 + 失败还原:
重启后等/healthz起来(最多 15 次、每次 1s),200 则保留新脚本,否则rm -rf bin && cp -a 备份再重启。
7.2 重启后状态怎么还能看见?
自举重启会清空 server.mjs 的内存态,管理页的 /deploy-status 只能回退读落盘日志。而 deploy 脚本的成功路径里会打印一行「回滚:…」的提示语(不是失败),如果只看 ❌/✅ 会误判。
所以 verify 阶段往 deploy-<sha>.log 追加一行权威结论:
1 | finish() { echo "$1 self-deploy=$( [ "$1" = "✅" ] && echo success || echo failed ) by deploy-cicd.sh(verify)" >> "$DEPLOY_LOG"; } |
解析时优先采信这个 self-deploy=(success|failed)(829fa22a):
1 | let state = 'success'; |
7.3 又一个坑:脚本被常驻子进程「挂住」
deploy-orchestrator.sh 要重启一个常驻 daemon(python3 orchestrator/daemon.py)。最初的写法 ( … & ) 被 bash 优化成了直接子进程,于是本 shell 会一直 wait 这个永不退出的 daemon → 部署脚本永不返回 → server.mjs 收不到 close 事件 → 部署进度卡在 running。
修法(193d8d97):用 setsid 起新会话,再 disown 把后台作业移出本 shell 的作业表,并把 stdout/stderr 全部重定向到日志文件,避免 daemon 持有 server.mjs 的 stdout 管道:
1 | setsid python3 "$APP_DIR/daemon.py" >> "$LOG_DIR/error.log" 2>&1 < /dev/null & |
自举这一块,本质上是分布式系统的经典自伤问题:一个进程试图重启它所属的那个服务。所有修法(独立 cgroup / 新会话 / disown / 权威结论行)都在绕开同一个约束——重启者不能被被重启者带走。
8. orchestrator 接入:py_compile 门禁 + 常驻 daemon 重启
GPU 编排器(autodl-orchestrator)的流水线 run-pipeline-orchestrator.sh / deploy-orchestrator.sh:
- syntax 门禁 =
python3 -m py_compile orchestrator/*.py; - CD:备份
.py→ 解包 → 就地安装.py→ 重启 daemon(pkill→setsid起新实例)→ 探针http://127.0.0.1:8787/health= 200 → 失败回滚。
一个关键约束:.env(密钥)和 data/(队列 db)绝不随产物覆盖——部署脚本只搬 .py 源码。这也是”部署”和”初始化”要分开的典型场景:现场数据不属于制品。
9. 暴露到平台:反代 + 管理页
9090 只有一个裸 HTTP API,且只带「共享密码」鉴权,不适合直接对外。所以把它收敛进 xlagent 的登录态之后(c6251ea / 465d9bb / fa0e42a):
- 浏览器只访问
/api/v1/admin/cicd/<project>/*; - 由 xlagent 后端反向代理到
[cicd].projects.<project>.base_url(线上 = 宿主host.docker.internal:9090)——于是 9090 不必对外放行; - 角色门严格限定
ADMIN / SUPER_ADMIN / DEVELOPER / DEV(即”只 admin/dev 可见”),不带knowledge:*之类的权限兜底。
前端页面在 apps/pc/src/pages/platform/developer/cicd(6f7459a9 / b56dad33 / 024c9d71)。
9.1 一个路径形状不匹配的坑
平台对外路径是 REST 语义的「项目段在前」,而 CI 侧 server.mjs 是「动词在前、项目段在后」:
1 | 平台对外: /admin/cicd/<project>/reports/<sha>/log |
第一次直拼就成了 /<project>/reports,而 server.mjs 认的是 /reports/<project> → 上游 404 → 管理页显示「未找到对应构建记录」、报告列表空。
修法是在反代里按下表显式重排(cicd_routes.go 顶部注释把它固化成契约):
1 | GET /admin/cicd/<project>/reports → GET /reports/<project> |
sha 白名单和 CI 侧 SHA_RE 同口径(7~40 位十六进制),避免把任意路径透传上游。
9.2 让”进行中的构建”可见
构建报告要等流水线结束才落盘,所以运行期间管理页看不到任何行。两个补丁:
/healthz暴露current(正在跑的 sha/ref/项目)——内存态,只有它知道”现在在跑什么”;/reports在返回列表时,若有当前作业没落盘,就补一条临时行{ok: null, running: true}置顶(同 sha 去重,避免和旧报告重复)。
1 | if (currentJob && currentJob.project === project.name && !list.some((r) => r.sha === currentJob.sha)) { |
再配前端 5s 轮询 +「进行中」Tag + 详情禁用 + 健康卡显示在跑 sha。
9.3 部署进度
部署不产 report,进度只体现在 deploy 脚本的 stdout。所以 server.mjs 在流水线侧解析各 deploy 脚本已知的步骤前缀(1/6 备份 →、1/4 解包 →、5/6 切流…),落成有序的 steps,避免前端硬编码任何项目语义:
1 | const DEPLOY_STAGES = [ |
step 只前进不回退,避免同一 step 重跑时把完整态改回 running。管理页还有个细节:报告列表按开始时间倒序、时间统一东八区(上游 /reports 按 sha 文件名字典序返回,是乱序的)——e5cbfed5。
10. 踩坑清单(速查)
| # | 坑 | 现象 | 修法 |
|---|---|---|---|
| 1 | env 裸 host:port |
构建产物全站 404 | check-env.mjs --strict-prod 硬门禁 |
| 2 | go vet | tail 吞退出码 |
vet 有错也判 success | 先落文件再取 rc |
| 3 | detached HEAD 判分支 | abbrev-ref 恒返回 HEAD |
用 webhook ref / branch -r --contains 反查 |
| 4 | 容器内存不够 | platform 链接期 OOM killed | --memory=6g + GOMEMLIMIT=4GiB |
| 5 | proto 未重生成 | 网关 proto 解析 400 / crash-loop | generate 设为硬门禁 |
| 6 | 未知项目回落默认 | /reports/unknownproj 读到默认数据 |
只认已登记项目名,未知 → 404 |
| 7 | 共用工作树 | 两项目互相 checkout -f 打架 |
每项目独立 clone 目录 |
| 8 | 自举重启自杀 | 部署脚本被 systemctl restart 带走 |
systemd-run 独立 cgroup 瞬态单元 |
| 9 | 常驻 daemon 挂住脚本 | 部署进度卡 running |
setsid + disown + 输出重定向 |
| 10 | 成功路径打印”回滚:” | 解析误判为 failed | verify 阶段追加 self-deploy= 权威结论行 |
| 11 | 上游路径形状 | 管理页「未找到构建记录」 | 动词/项目段显式重排 |
| 12 | 色隔离靠改 Target | 配置面到处改 | 每色独立网络,服务名色内唯一 |
11. 现状与后续
已上生产(新机 47.76.197.179,与 9012 同机):
- Gitee WebHook 已配、
ci/bin已发、config.json含五项目; - server.mjs 已就地换挂载二进制并重启(含
/deploy-status与报告列表倒序); - 五项目冒烟全绿。
已知缺口 / 后续:
- Gitee 状态回写不可用(API 实测 405)——目前静默降级,提交状态绿/红点看不到;
- 后端
test阶段(go vet)仍是报告态——存量问题清完再转硬门禁; - CD 不同步
config.json(含密钥,不入库)——改了配置得手工上机; - 前端
typecheck/unit同理,是「待清理的存量」。
写在最后
两天时间,把这套东西从「一次裸端口事故的补救」做成了一个能托管自己、能管五个仓库、一次点击带回滚锚的轻量发布系统。回头看,最有价值的不是那些脚本本身,而是三个判断:
- 门禁要分「硬 / 报告」——一次性全硬会把团队推到”绕过 CI”的一侧,反而更糟;
- 自动化的边界要保守——CI 必须自动且严格,CD 必须人工且可回滚;
- 自举类系统的一切复杂度都来自一个约束——重启者不能被被重启者带走。想清楚这一点,
systemd-run/ 新会话 / 权威结论行就都是它的自然推论了。
一点反省:这两天为了赶进度,「蓝绿色隔离」的方案改了两轮(先”颜色专属 Target”、后”每色独立网络”),说明我在动手前对网络拓扑的分层设计想得不够深。下次遇到”看起来要改一堆配置”的问题,应该先问:「是不是某一层没隔离干净?」
下一步想把 Gitee 状态回写换成别的方式补上(哪怕发飞书也行),再把这套流水线的失败用例沉淀成回归清单。




