从 4 个 URL 到 1 个入口:Peaks-Loop 驱动的微前端聚合实践
当你的团队维护着 5 个独立 Git 仓库、4 个不同的业务平台(Dify、RAGFlow、Higress Console、Hermes Studio),每个都要独立部署、独立发布,但用户需要在一个统一的界面里操作它们------你会怎么做?

一、背景:为什么会有这个需求
事情要从我们团队的现状说起。
我们手上有几个业务平台:Dify (LLM 应用开发平台)、RAGFlow (知识库检索)、Higress Console (网关管理)、Hermes Studio(内部工具链)。它们原本是各自独立的系统,部署在不同的域名下,用户需要在多个标签页之间来回切换。
痛点很直接:
- 用户要记 4 个 URL,每次切换都要重新登录
- 视觉风格不统一,每个平台有自己的 UI 组件和主题
- 跨平台跳转靠跳转链接,体验割裂
- 共享代码靠复制粘贴,类型定义、工具函数、SDK 调用散落在各个仓
我们决定做一件事:把这 4 个业务平台合成一个微前端体系,让它们同域部署、按需加载、一次登录全平台可用。
最终落地成果:5 个独立 Git 仓(1 主 + 4 子),~80 个 commit,主仓源码仅 ~950 行,一次 tag 发布全量镜像,总 wall-time 15-30 分钟。
二、技术选型:为什么是 qiankun 不是 Module Federation
选型阶段,我们在 qiankun 和 Module Federation 之间犹豫过。
我们的约束条件
- 子应用必须能独立开发、独立部署、独立发布------每个子仓有自己的 GitLab CI、自己的 tag 流程
- 子应用必须是完全独立的 Git 仓------不能合并到一个 monorepo 里
- 运行时隔离------子应用之间的样式、全局变量不能互相污染
- 团队使用 umijs/max------框架内置了 qiankun 集成
为什么没选 Module Federation
Module Federation 的强项是运行时共享模块,但它有一个致命问题:它会破坏子应用的独立部署。MF 要求子应用在构建时就知道彼此的模块依赖关系,一旦某个子应用更新了共享模块,所有消费方都要重新构建。
而我们的场景是:4 个子仓由不同的团队维护,它们有自己的发布节奏。用 MF 意味着每次发布都要协调所有子仓的版本------这恰恰是我们想避免的。
qiankun 的沙箱隔离机制天然契合我们的诉求:子应用作为独立 SPA 构建,qiankun 在运行时通过 beforeLoad → bootstrap → mount 生命周期加载它们,样式隔离由 antd v6 的 cssVar + @layer 方案解决。
最终技术栈
| 类别 | 选型 | 版本 |
|---|---|---|
| 应用框架 | @umijs/max | 4.7.8 |
| UI 库 | antd | 6.6.1 |
| 微前端 | qiankun | 2.10.16 |
| 包管理 | pnpm | 9.15.0 |
| CI/CD | GitLab CI | --- |
| 容器化 | Docker + BuildKit | docker:24 |
三、5 仓架构:主仓 + 4 子仓的布局
物理目录结构
platform-web/ ← 工作根目录(不建 git)
├── platform-web-main/ # 主仓(qiankun Master)
│ ├── apps/shared-mf/ # MF producer
│ ├── packages/shared/ # 共享 SDK 源码
│ │ ├── shared-types/
│ │ ├── shared-utils/
│ │ ├── shared-sdk/
│ │ ├── shared-router/
│ │ └── shared-ui/
│ ├── src/ # 主应用源码
│ ├── docker/ # Dockerfile + 辅助脚本
│ └── .gitlab-ci.yml # 主仓 4-stage pipeline
├── platform-web-agent/ # 子仓 1
├── platform-web-governance/ # 子仓 2
├── platform-web-higress/ # 子仓 3
└── platform-web-rag/ # 子仓 4
#mermaid-svg-pzwLX1ANrtfRmte2{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-pzwLX1ANrtfRmte2 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-pzwLX1ANrtfRmte2 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-pzwLX1ANrtfRmte2 .error-icon{fill:#552222;}#mermaid-svg-pzwLX1ANrtfRmte2 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-pzwLX1ANrtfRmte2 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-pzwLX1ANrtfRmte2 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-pzwLX1ANrtfRmte2 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-pzwLX1ANrtfRmte2 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-pzwLX1ANrtfRmte2 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-pzwLX1ANrtfRmte2 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-pzwLX1ANrtfRmte2 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-pzwLX1ANrtfRmte2 .marker.cross{stroke:#333333;}#mermaid-svg-pzwLX1ANrtfRmte2 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-pzwLX1ANrtfRmte2 p{margin:0;}#mermaid-svg-pzwLX1ANrtfRmte2 .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-pzwLX1ANrtfRmte2 .cluster-label text{fill:#333;}#mermaid-svg-pzwLX1ANrtfRmte2 .cluster-label span{color:#333;}#mermaid-svg-pzwLX1ANrtfRmte2 .cluster-label span p{background-color:transparent;}#mermaid-svg-pzwLX1ANrtfRmte2 .label text,#mermaid-svg-pzwLX1ANrtfRmte2 span{fill:#333;color:#333;}#mermaid-svg-pzwLX1ANrtfRmte2 .node rect,#mermaid-svg-pzwLX1ANrtfRmte2 .node circle,#mermaid-svg-pzwLX1ANrtfRmte2 .node ellipse,#mermaid-svg-pzwLX1ANrtfRmte2 .node polygon,#mermaid-svg-pzwLX1ANrtfRmte2 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-pzwLX1ANrtfRmte2 .rough-node .label text,#mermaid-svg-pzwLX1ANrtfRmte2 .node .label text,#mermaid-svg-pzwLX1ANrtfRmte2 .image-shape .label,#mermaid-svg-pzwLX1ANrtfRmte2 .icon-shape .label{text-anchor:middle;}#mermaid-svg-pzwLX1ANrtfRmte2 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-pzwLX1ANrtfRmte2 .rough-node .label,#mermaid-svg-pzwLX1ANrtfRmte2 .node .label,#mermaid-svg-pzwLX1ANrtfRmte2 .image-shape .label,#mermaid-svg-pzwLX1ANrtfRmte2 .icon-shape .label{text-align:center;}#mermaid-svg-pzwLX1ANrtfRmte2 .node.clickable{cursor:pointer;}#mermaid-svg-pzwLX1ANrtfRmte2 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-pzwLX1ANrtfRmte2 .arrowheadPath{fill:#333333;}#mermaid-svg-pzwLX1ANrtfRmte2 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-pzwLX1ANrtfRmte2 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-pzwLX1ANrtfRmte2 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-pzwLX1ANrtfRmte2 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-pzwLX1ANrtfRmte2 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-pzwLX1ANrtfRmte2 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-pzwLX1ANrtfRmte2 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-pzwLX1ANrtfRmte2 .cluster text{fill:#333;}#mermaid-svg-pzwLX1ANrtfRmte2 .cluster span{color:#333;}#mermaid-svg-pzwLX1ANrtfRmte2 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-pzwLX1ANrtfRmte2 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-pzwLX1ANrtfRmte2 rect.text{fill:none;stroke-width:0;}#mermaid-svg-pzwLX1ANrtfRmte2 .icon-shape,#mermaid-svg-pzwLX1ANrtfRmte2 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-pzwLX1ANrtfRmte2 .icon-shape p,#mermaid-svg-pzwLX1ANrtfRmte2 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-pzwLX1ANrtfRmte2 .icon-shape .label rect,#mermaid-svg-pzwLX1ANrtfRmte2 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-pzwLX1ANrtfRmte2 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-pzwLX1ANrtfRmte2 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-pzwLX1ANrtfRmte2 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 子仓 4
子仓 3
子仓 2
子仓 1
主仓 platform-web-main
webpack alias 引用
webpack alias 引用
webpack alias 引用
webpack alias 引用
qiankun 运行时加载
qiankun 运行时加载
qiankun 运行时加载
qiankun 运行时加载
apps/shared-mf
packages/shared
src
docker
platform-web-agent
platform-web-governance
platform-web-higress
platform-web-rag
关键设计:共享代码的管理
这是这个架构中最"反直觉"的部分------5 个仓没有用 pnpm workspace 跨仓。
每个子仓是完全独立 的 Git 仓,有自己的 tag 和 release 流程。共享代码放在主仓的 packages/shared/ 下,子仓在 dev 期间通过 webpack alias 直接引用主仓的源码路径:
ts
// platform-web-main/.umirc.ts
const sharedRepoRoot = join(__dirname, "packages", "shared");
export default defineConfig({
alias: {
"@platform/shared-types": join(sharedRepoRoot, "shared-types", "src"),
"@platform/shared-utils": join(sharedRepoRoot, "shared-utils", "src"),
"@platform/shared-sdk": join(sharedRepoRoot, "shared-sdk", "src"),
"@platform/shared-router": join(sharedRepoRoot, "shared-router", "src"),
"@platform/shared-ui": join(sharedRepoRoot, "shared-ui", "src"),
},
});
dev 工作流:所有应用必须在主仓同目录的兄弟位置。修改 shared SDK 源码 → 主仓 dev server 自动热重载 → 所有子应用立刻看到新代码------不需要 publish、不需要 install、不需要 restart。
生产工作流 :子仓 pnpm max build 只输出自己的 /out/dist,不包含 shared 代码 。shared 由主仓的 build-shared job 构建为独立镜像,最终在 assemble-main 阶段通过 buildx --build-context 拼装到一起。
这里有个坑(踩过了)
这个方案的先决条件是:所有开发者在同一台机器上,把所有 5 个仓 clone 到兄弟目录。如果远程开发、或者 CI 环境,这套 webpack alias 就失效了。
我们的解法是:只在 dev 环境用 alias,CI 构建完全隔离。子仓 CI 只构建自己的源码,shared 的注入点是主仓的 assemble 阶段。这不是一个"优雅"的方案,但它在 5 仓独立发布的约束下工作得很好。
四、CI/CD:最复杂的部分
微前端方案本身不复杂,真正复杂的是 5 个独立仓的 CI/CD 编排。
Pipeline 流程
打 tag → fan-out 4 子仓 → 4 子仓并行 build → wait-and-pull → build-shared → assemble-main
核心挑战:错峰调度
我们的 GitLab Runner 是单 host 部署,concurrent=4。这意味着同时只能跑 4 个 job。如果 fan-out 同时触发 4 个子仓的 build,每个子仓还要同时 build amd64 和 arm64 两个架构,那就是 8 个 job 同时竞争 4 个 slot------直接死锁。
解法:错峰 120s。fan-out 依次触发 4 个子仓,每个间隔 120 秒,让前一组的 job 先占住 slot 再触发下一个。这样 4 个子仓的 build 串行排队,但每个子仓内部的 amd64 + arm64 可以并行。
镜像命名约定
统一命名空间是解决"多仓镜像管理"的关键:
${REGISTRY}/platform-web/${BRANCH_PATH}/${app}:${tag}-${arch}
这个约定让 wait-and-pull 阶段的镜像探活逻辑简化为简单的字符串拼接。
关键变量:SUB_APPS
这是整个设计中最灵活的部分------通过 GitLab CI 变量 SUB_APPS 来控制要构建哪些子应用:
bash
# 场景 A:只构建 agent 做集成测试
SUB_APPS=agent
COMBO_NAME=agent-only-test
# 场景 B:生产灰度(3 子应用,从 release 分支拉)
SUB_APPS=agent,governance,higress
SUB_APP_BRANCH=release-v2.3
# 场景 C:紧急跳过某个子应用(它 build 坏了)
SUB_APPS=agent,higress,rag # 跳过 governance
这意味着不需要改任何代码,直接在 GitLab UI 的 "Run pipeline" 页面填变量,就能控制发布内容。
五、Phase 2 实战:10 个真实问题
Phase 2 的目标是把子仓构建从"主仓 inline git clone"改为"子仓独立 CI"。这个改动涉及 5 个仓的 CI 配置、Dockerfile、GitLab 权限的综合改造。以下是我们在 code-image-server 上实际跑通时遇到的 10 个问题,按出现顺序排列。
1. heredoc 变量展开(最隐蔽的根因)
症状 :pnpm max build 报 No package.json found in /src
根因 :一个辅助脚本用 echo "name=$1" 写入,$1 在 echo 时被 expand 成空字符串。case statement 永远不 match,所有子仓都走了 else 分支创建空目录。
修复 :改用 cat > script <<'EOF'(quoted heredoc),$1 在运行时才 expand。
感悟:写 Shell 脚本时,单引号 heredoc 和双引号 heredoc 的区别经常被忽略,但它在 CI 环境里能让你排查半小时。
2. GitLab CI token 用户名格式
症状 :fatal: Authentication failed
根因 :oauth2: 前缀只对 Personal Access Token 有效;CI_JOB_TOKEN 必须是 gitlab-ci-token:CI_JOB_TOKEN 格式。
3. 内网 DNS 解析
症状 :Could not resolve host: gitlab.bigmodel.local
根因:BuildKit 容器内无法解析内网 GitLab 域名。
修复 :.gitlab-ci.yml 的 docker buildx build 命令加 --add-host "gitlab.bigmodel.local:172.20.130.30"。
4. Alpine 镜像源限速
症状 :apk add 卡 5+ 分钟
修复 :RUN sed -i 's|dl-cdn.alpinelinux.org|mirrors.aliyun.com|g' /etc/apk/repositories
5. DockerHub 基础镜像限速
症状:metadata 拉取 100+ 秒
修复 :改 FROM 为 docker.m.daocloud.io/library/node:22-alpine(道客云 DockerHub mirror)。
6. esbuild 辅助函数冲突
症状 :[esbuildHelperChecker] Found conflicts in esbuild helpers
修复 :每个子仓 .umirc.ts 加 esbuildMinifyIIFE: true。
7. fan-out API token header 用错
症状 :POST /repository/tags 返回 404 Project Not Found(GitLab 用 404 隐藏 401)
根因 :PAT 必须用 PRIVATE-TOKEN: header,不是 JOB-TOKEN:。
8. 分支名 URL 编码
症状 :POST /repository/tags?ref=feat/pro-template-migration 返回 404
根因 :分支名里的 / 没编码,被 GitLab 当 URL 分隔符解析。
修复 :SUB_APP_REF_ENCODED="${SUB_APP_REF//\//%2F}"
9. Runner 超时设置覆盖
症状 :Job failed: execution took longer than 10m0s
根因 :GitLab Runner config.toml 的 script_timeout = 600 覆盖 job-level 的 timeout: 30 minutes。
修复 :管理员改 /etc/gitlab-runner/config.toml,每个 [[runners]] 段加 script_timeout = 3600。
10. docker pull 浪费 2 分钟
症状:wait-and-pull job 超 10 分钟
根因 :docker pull 在 docker:dind 下 daemon 跨 job 不共享,pull 出来没意义。
修复 :只做 docker manifest inspect,不 pull。assemble-main 用 --build-context docker-image://... 重新解析时 BuildKit 会按需拉取。
六、新业务接入:3 步走
这个架构设计时就把"可扩展性"作为一等公民。未来接入新业务(比如再接入一个 Dify Console),只需要 3 步:
- 子仓 :复制任意子仓模板,改名为
platform-web-<新业务> - 子仓 :
.gitlab-ci.yml只改一行变量APP: "<新业务>" - 主仓 :
.gitlab-ci.yml的SUB_APPS默认值追加"<新业务>"
不需要改主仓的 webpack 配置、CI 编排、Dockerfile。所有新业务自动获得:
- ✅ 构建:fan-out 自动触发新子仓 build
- ✅ 运行时:qiankun 自动加载新子应用
- ✅ 共享 SDK:新子仓加同样 webpack alias 即可
- ✅ 回滚:主仓
SUB_APPS去掉新业务 → 下次 tag 自动剔除
七、量化成果
最后用数据说话:
| 维度 | 数值 |
|---|---|
| 仓库总数 | 5(1 主 + 4 子) |
| 总 commit 数 | ~80 |
| 主仓源码行数 | ~950 行 |
| 子应用并行 | concurrent=4 |
| fan-out 错峰间隔 | 120s |
| 总 wall-time | 15-30 min |
| Phase 2 实战修复 | 10 个 |
Phase 1 的时候,一次发布要 1.5 小时以上,DNS 解析失败、网络限速、超时重试轮番上演。Phase 2 改完后,稳定在 15-30 分钟------速度提升了 3-6 倍。
八、总结与思考
回看这个项目,最值得分享的不是技术方案本身,而是我们在"独立仓 vs monorepo"这个经典难题上的取舍。
我们既没有走 monorepo(把所有代码合并到一个仓),也没有走纯微服务(每个服务完全独立没有任何共享)。我们走了一条中间路线:5 个独立 Git 仓 + 主仓内部 monorepo + qiankun 运行时组装。
这个方案不完美------webpack alias 跨仓引用、dev 环境依赖目录结构、shell 脚本里的坑------但它在一个关键指标上赢了:团队可以独立开发、独立发布、互不阻塞。
如果你也在做类似的微前端聚合,希望这 10 个踩坑记录能帮你少走一些弯路。