十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

WeKan 多租户架构深度指南:从每租户一进程到「组织即租户」的 Meteor 3 实现

WeKan 多租户架构深度指南:从每租户一进程到「组织即租户」的 Meteor 3 实现 WeKan 多租户架构深度指南从每租户一进程到「组织即租户」的 Meteor 3 实现【免费下载链接】wekanThe Open Source kanban, built with Meteor. GitHub issues/PRs are only for FLOSS Developers, not for support, support is at https://wekan.fi/commercial-support/ . PR source translation to imports/i18n/data/en.i18n.json, other translations at https://app.transifex.com/wekan/wekan项目地址: https://gitcode.com/GitHub_Trending/we/wekan导读本文围绕 WeKan 官方多租户问答docs/Features/Login/Multitenancy.md展开系统讲解这套开源看板系统基于 Meteor 构建在多域名、多客户场景下的两种可行拓扑基线方案每个租户一个进程以及已在仓库中落地实现、通过MULTITENANCYtrue开启的**组织即租户Option D**方案。读完本文你将掌握 WeKan 多租户的六类核心问题请求归属、客户端ROOT_URL、服务端绝对 URL、数据隔离、账号体系、实例级单例配置理解models/lib/tenants.js、server/lib/tenantResolver.js、models/lib/tenantAdmin.js、models/lib/tenantBackup.js四个模块的分工与权限边界并能直接按 docker-compose-multitenancy.yml 部署或按环境变量开启组织级租户。说明仓库中多租户的设计全貌沉淀在 docs/Design/Multitenancy/Multitenancy.md本指南以其为主体骨架辅以源码与测试佐证。一、问题起点一个 Meteor 应用能否服务多个 ROOT_URL官方 FAQ 的问答非常简短Q多租户是否意味着同一个 Meteor 应用可配置多个ROOT_URL或支持相对 URLA是的。这个是的背后仓库给出了两份具体材料docker-compose-multitenancy.yml在同一条 Linux 主机上为每个客户/域名运行一个独立的 WeKan 实例每租户一个进程的完整示例docs/Design/Multitenancy/Multitenancy.md在同一个 WeKan 进程内服务多个域名、每个域名拥有自己的ROOT_URL与数据的完整设计其中Option D组织作为租户已经实现。需要先厘清一个关键前提当前主推且被文档支持的拓扑仍是每租户一个 Node.js 进程——n个客户 n个 Node 进程、n个ROOT_URL、n个数据库、n次升级docs/Platforms/FOSS/Container/Docker/Meteor3/multitenancy.md。组织即租户则是在此之上的第二种选择适合同一组织内的部门、或接受共享运维的客户而必须独占进程的客户仍然走每进程拓扑。二、基线拓扑每租户一个进程Option A这是 WeKan 出厂即支持、也是官方推荐的默认形态。架构为Caddy或其他反向代理把每个客户域名路由到对应容器每个容器拥有独立的ROOT_URL、独立的MONGO_URL数据库与内部uws.port所有租户共享同一套 MongoDB。2.1 成本与收益收益进程级完全隔离不需要任何应用层租户代码——因为不存在租户隔离代码也就不会存在租户隔离的 bug某个租户的崩溃、迁移、恢复、CPU 峰值都只影响自己升级按租户进行坏版本可以只回滚一个租户。代价内存 ×nMeteor 服务端并不小、每个租户都要分配一个公网端口与内部uws.port、n次升级、n份备份、n套需要保持同步的配置。这正是整套多租户设计被驱动的直接原因。2.2 关键部署细节sockjs与uws.port在同一网络命名空间network_mode: host内运行多个 WeKan 服务时有一个容易被踩的坑uwsDDP 传输会在自己的内部 TCP 端口默认127.0.0.1:5001上运行一个内部 WebSocket 代理服务器把公网PORT收到的 WS 升级转发进去。两个服务共享一个内核 netns 且都默认127.0.0.1:5001时后启动者会因监听套接字被独占而绑定失败。因此每个租户都要通过METEOR_SETTINGS指定各自不同的内部端口environment: - PORT8081 # 公网 HTTP 端口 - ROOT_URLhttps://tenant1.example.com - MONGO_URLmongodb://127.0.0.1:27017/wekan_tenant1?replicaSetrs0 - MONGO_OPLOG_URLmongodb://127.0.0.1:27017/local?replicaSetrs0 - DDP_TRANSPORTsockjs - METEOR_REACTIVITY_ORDERchangeStreams,oplog,polling - METEOR_SETTINGS{packages:{ddp-server:{uws:{port:5001,host:127.0.0.1}}}} - WRITABLE_PATH/data - WITH_APItrue租户 2 依次递增PORT8082、ROOT_URLhttps://tenant2.example.com、数据库名wekan_tenant2、uws.port5002docker-compose-multitenancy.yml。注意WeKan 发布包内置的是SockJS 而非 uWebSockets.js所以实际上没有独立的内部uws.port需要分配上面的METEOR_SETTINGS写法是为同时支持两种传输而保留的完整形态docs/Platforms/FOSS/Container/Docker/Meteor3/multitenancy.md。官方推荐组合是sockjschangeStreamsMETEOR_REACTIVITY_ORDERchangeStreams,oplog,polling吞吐最高、反应延迟最低如果反向代理/负载均衡无法保证 WebSocket 升级透传或部分客户端网络屏蔽原生 WebSocket可退化为sockjsoplogMETEOR_REACTIVITY_ORDERoplog,pollingSockJS 会在这些客户端上自动回退到 HTTP 长轮询代价是 DDP 吞吐略低。启动与拆除docker compose -f docker-compose-multitenancy.yml up -d docker compose -f docker-compose-multitenancy.yml down -v2.3 验证内部监听端口隔离从宿主机或共享 netns 的任意容器执行cat /proc/net/tcp | awk $4 0A {print $2} | sort | uniq -c期望看到每个 uws 端口各一个监听者——十六进制地址0100007F:13895001与0100007F:138A5002各出现一次若出现2 0100007F:1389说明两个服务在争用默认端口需修改METEOR_SETTINGS.uws.port后重启。三、单进程多租户要解决的六类问题如果坚持一个 Node 进程服务许多域名设计文档docs/Design/Multitenancy/Multitenancy.md按咬合顺序列出六个问题并在 Meteor 3 下逐一给出评估这个请求属于哪个租户只有Host头能区分a.example.com与b.example.com。HTTP 侧用WebApp.connectHandlers.use((req,res,next))读req.headers.hostDDP 侧用Meteor.onConnection与this.connection——连接携带id、clientAddress和httpHeaders白名单化的请求头cookies 被刻意排除host在其中。反向代理背后的真实头是X-Forwarded-Host但客户端也能伪造它——从可伪造的头选择租户就是跨租户数据泄露所以这个决定必须只在一个地方、只做一次。客户端 bundle 内置了ROOT_URL。Meteor 会把__meteor_runtime_config__含ROOT_URL、DDP_DEFAULT_CONNECTION_URL烤进它返回的 HTML。一个进程服务两个域名会把同一个值交给两个客户端。服务端绝对 URL。Meteor.absoluteUrl()从环境读取ROOT_URL但支持每次调用传入{ rootUrl }覆盖每个调用点都必须知道自己在为哪个租户应答。数据隔离。这是最贵的一项WeKan 有46 个集合、66 个 publication、62 个方法块每一条都要做租户作用域处理漏掉一个选择器就等于一个客户读到另一个客户的看板。账号体系。Meteor.users是单一集合两个客户若有相同邮箱就是一个账号密码重置会跨租户Accounts.emailTemplates与 OAuth 回调 URL 目前都是实例级的。要么账号按租户隔离一个用户属于两个租户就有两个账号要么账号保持全局、租户只作用于内容——这是一个产品决策而不是部署决策。实例级单例。Settings、AccountSettings、AccessibilitySettings、Announcements、AttachmentStorageSettings、InviteToBoardRolesSettings、LockoutSettings、TableVisibilityModeSettings等都是每实例一份文档统一经ReactiveCache.getCurrentSetting()imports/reactiveCache.js读取。产品名、Logo、SMTP、锁定策略、公告——管理员在管理面板设置的一切都是实例级的。文件存储根目录、MAIL_URL与约 102 处process.env读取同样是每进程形态。四、五种备选方案对比设计文档给出了 A–E 五种路线核心对比可浓缩为下表A. 每租户一进程B. 租户字段C. 每租户一库单进程D. 组织即租户状态受支持设计设计已实现新增 WeKan 代码无46 集合、66 publication集合构建 路由三个纯模块 resolver、品牌、租户管理员、备份隔离进程选择器数据库权限最坏故障某租户宕机跨租户泄露跨租户泄露跨租户泄露内存×n× 1× 1 进程、×n连接池× 1升级×n× 1× 1× 1每租户恢复简单困难简单限定为该租户的看板方案 B 即社区包mizzao:partitionerMeteor-Community-Packages的经典做法改写分区集合上每个find/insert/update/remove的选择器应用代码仿佛自己独占。其代价是每个集合必须刻意分区且必须逐个决定哪些集合不能分区设置单例、账号若保持全局则Meteor.users以名称命名的 publication 需要把租户 ID 放进名称Meteor.publish(posts-tenantId)否则同一浏览器打开两个租户会产生订阅冲突索引要加前导租户键最坏失败模式是静默跨租户泄露。方案 C 在数据库侧与现有部署完全一致每个租户一个库mongodb://127.0.0.1:27017/wekan_tenant1?replicaSetrs0只是把n个进程各连一个库改成一个进程连所有库——new MongoInternals.RemoteCollectionDriver(url, { oplogUrl })可持有多个 Mongo 连接new Mongo.Collection(name, { _driver: driver })把集合绑定到对应驱动。代价是每个租户还要付出自己的连接池与 oplog/changeStreams 监听器内存节省没有看上去那么大且集中在一个进程里。方案 D已实现的取舍后文详述方案 E共享 Web 层 每租户数据进程本质是更漂亮前门的 A没有移除最昂贵的n个 Node 进程。五、Option D组织即租户已实现方案 D 复用 WeKan 内部已有的组织结构models/org.js、models/team.js、用户属于组织/团队、共享模板/成员传播/来自认证提供方的同步等并把它认真当成租户模型一个 Organization 认领域名、携带自己的品牌、拥有自己的租户级 Global Admin、可单独备份与恢复已有的成员规则server/lib/orgTeamRestriction.js即仅允许从同一组织/团队添加看板成员在邀请路径与用户搜索 typeahead 中服务端强制负责隔离。收益新增机制最少——分组、成员关系、管理界面、限制规则都已存在且已测试。什么都不用分区因为看板本来就只对成员可见。代价这是软租户。一个数据库、一个用户命名空间、一套实例设置站点管理员能看到一切看板权限的一个 bug 就是跨租户 bug。适合同一组织的部门、或接受共享运维的客户不能共享进程的客户请用方案 AA 仍然受支持。5.1 如何开启默认关闭除非部署明确开启因此从未听说过租户的实例行为完全不变不论其组织文档里有什么。环境变量默认值含义MULTITENANCY未设置关闭true开启 host → Organization 解析、租户品牌与租户备份范围MULTITENANCY_TRUST_PROXY_HOST未设置关闭true表示信任X-Forwarded-Host。仅当受信代理Caddy、nginx在每个请求上都设置该头时才开启ROOT_URL保持不变它仍是实例自身地址也是任何未被组织认领的 host 的回退。isTruthyEnv接受true/yes/1/on大小写不敏感见 models/lib/tenants.js。5.2 请求归属判定纯函数 Meteor 胶水所有判定集中在纯模块models/lib/tenants.js无任何 Meteor/Mongo 依赖因此可被node tests/tenants.test.cjs直接单元测试normalizeHost()小写化、去掉 scheme、userinfo、路径、端口与末尾根点于是HTTPS://A.Example.com:443/与a.example.com是同一个 hostIPv6 字面量保留方括号。parseHostList()组织字段orgDomains是自由文本逗号、分号或空白分隔均可返回规范化、去重后的列表。requestHost(headers, { trustProxy })仅当设置了MULTITENANCY_TRUST_PROXY_HOST才读X-Forwarded-Host否则读Host并取代理链第一项。正如源码注释所强调的——THE HOST IS A SECURITY DECISION租户决定只在这里做一次。findTenantOrg(orgs, host)认领该 host 的第一个激活组织停用组织即可让租户下线而不丢失其配置。conflictingHosts()/duplicateTenantHosts()两个组织认领同一 host 会让其中一个静默获得另一个的品牌保存路径拒绝此类冲突并指名是哪个 host。Meteor 侧胶水在 server/lib/tenantResolver.jshost → org 缓存由org集合的 observer 全量重建TENANT_FIELDS只取orgDomains、orgIsActive、orgDisplayName与品牌字段tenantForHeaders()同时服务 HTTP 请求与 DDP 连接this.connection.httpHeaders数据库不可达时降级为无租户即实例自身品牌而不是错误页。5.3 客户端 bundle 的 ROOT_URL 重写Meteor 提供专为此场景设计的钩子WebApp.addRuntimeConfigHook(({ arch, request, encodedCurrentConfig, updated }) { // 返回字符串以替换编码后的 config返回假值则保持原样 });server/lib/tenantResolver.js的installRuntimeConfigHook()在租户功能开启且宿主是租户 host 时用WebApp.decodeRuntimeConfig/WebApp.encodeRuntimeConfig把ROOT_URL与DDP_DEFAULT_CONNECTION_URL都改写为tenantRootUrl()计算出的值使加载自b.example.com的客户端回连b.example.com而不是进程启动时那一个ROOT_URLtenantRootUrl()保留实例自身ROOT_URL的 scheme 与子路径。钩子只对不认识的 host 返回假值即保持 Meteor 原始编码逐字节不变因此没有租户的实例或访问实例自身ROOT_URL的请求完全不受影响。一个值得注意的历史坑该钩子在 Meteor 3.0-alpha/beta/rc 中损坏过bindEnvironment返回的 Promise 被字符串化为[object Promise]写入配置导致页面空白见 meteor#13156WeKan 运行的是已包含修复的 Meteor 3 版本代码中也显式检查了addRuntimeConfigHook/decodeRuntimeConfig/encodeRuntimeConfig是否存在。5.4 服务端绝对 URL 与已完成的相对 URL 化tenantRootUrl(host, ROOT_URL)给出应作为Meteor.absoluteUrl(path, { rootUrl })传入的租户级值models/lib/tenants.js。WeKan 大部分代码不需要改动附件与头像 URL 早已刻意构造成相对地址models/lib/universalUrlGenerator.js这是为子路径部署所做的既有工作恰好构成多域名服务所需的大部分server/routes/universalFileServer.js 以/cdn/storage/…为任意 host 提供文件其中仅剩的两处Meteor.absoluteUrl()调用是文件里仅存的ROOT_URL依赖。需要按租户构造绝对 URL 的调用点很少且可枚举看板与卡片 URLmodels/boards.js、邀请邮件server/models/settings.js、OIDC 登出跳转。5.5 数据隔离刻意不分区方案 D 的要点就是什么都不分区看板只对成员可见已存在的组织级看板成员限制server/lib/orgTeamRestriction.js把租户看板留在租户内。这是该方案的设计边界而非疏漏——权限 bug 在这里就是跨租户 bug所以必须独占进程的客户仍建议方案 A。方案 D 真正做作用域的是管理面非看板内容Admin Panel 的 People/People 与 People/Organizations通过 models/lib/tenantAdmin.js 的peopleScopeSelector()与orgScopeSelector()实现作用于people、org两个 publication 及为其分页的 count 方法。其中andQuery()用$and把调用方查询与强制限制合并即使查询已提到同一字段也追加限制杜绝构造查询绕过无权限调用者拿到的是MATCH_NOTHING选择器永不匹配返回空集而非全部数据。5.6 账号保持全局账号保持全局一个邮箱地址就是一个用户该用户可属于多个 Organization。这是方案 D 的产品决策其余设计随之而来——尤其是租户备份不携带账号见 5.8且租户级 Global Admin永远不能授予站点级isAdmin标志见 5.7。5.7 租户级 Global Admin这是第二种、更小的管理员只管理一个 Organization的人。它只是用户既有成员关系上的一个标志user.orgs [ { orgId, orgDisplayName, isAdmin: true } ]不新增集合、不新增第二份成员列表所有已读取user.orgs的代码照常工作。models/lib/tenantAdmin.js 持有全部规则且同一套函数同时跑在客户端决定画哪些菜单项与服务端真正生效的一方——客户端检查只是便利每个方法与 publication 都会再次调用这些函数。能力边界矩阵站点管理员 vs 租户管理员问题站点管理员租户管理员打开管理面板可以可以顶部标签页Settings、People、Attachments、ProblemsPeople、Attachments、Settings一个面板People 菜单全部面板People、OrganizationsAttachments 菜单全部面板BackupSettings 菜单全部面板Visibility其中仅Change color可见的人所有人其管理组织的成员可见的组织全部其管理的组织授予站点级isAdmin可以永远不行管理站点管理员可以永远不行任命租户管理员任意组织仅自己的组织备份范围整个实例或任意组织仅自己的组织关键安全规则源码可验证models/lib/tenantAdmin.jscanManageUser()站点管理员可管理任何人租户管理员可管理自己管理组织的成员但绝不能是站点管理员——否则租户管理员可锁定或接管实例所有者这是权限提升而非租户隔离。sanitizeUserFields()租户管理员的用户更新会被剔除isAdmin字段站点管理员的更新原样通过。任命/撤销复用既有菜单Admin Panel → People → Organizations → 行内⋯→Organization admins带复选框的成员列表。5.8 租户级备份与恢复Admin Panel → Attachments → Backup 新增一个Scope控件列出整个实例仅站点管理员与查看者可管理的每个 Organization。其余全部由纯模块 models/lib/tenantBackup.js 决定唯一调用者是 server/methods/backup.js。一个租户归档包含该租户的看板及其附属内容列表、泳道、卡片、评论与反应、清单及清单项、自定义字段、活动、规则及其触发器和动作、集成、附件记录以及这些看板使用的附件与头像文件。不包含账号——方案 D 是单一命名空间5.6归档不得把密码哈希或邮箱带出实例恢复也绝不能改写账号设置单例——产品名、SMTP、锁定策略是实例级属于整实例备份org 与 team 文档本身——恢复它们可能复活已删除的租户或改写另一租户的成员关系。归档按组织分目录存放使该管理员可见哪些归档变成一个路径问题files/backup/2026/07/26/12_00_00/backup.zip instance整实例 files/backup/org/orgId/2026/07/26/12_00_00/backup.zip tenant租户恢复是危险方向因此被双重防护可写入的看板 ID 是归档声明与租户真实拥有两者的交集allowedRestoreBoardIds随后每个文档再逐一校验docBelongsToTenant——与租户外看板共享的自定义字段同样被拒绝因为写入它等于改写另一租户看板所读的文档。租户管理员永远不能恢复整实例归档它包含所有租户请求一个你无权拥有的 scope 会被拒绝而不是被静默缩小范围。集合作用域方式一览TENANT_COLLECTIONS匹配方式含义集合示例ids文档本身就是看板_idboardsboardId普通boardId字段lists、swimlanes、cards、card_comments、activities、rules、integrations等boardIds一个数组自定义字段属于多个看板customFieldsmetaBoardIdMeteor-Files 记录看板 ID 在meta.boardIdattachmentsruleRef仅经由某看板的规则可达触发器与动作本身无看板triggers、actionsTENANT_FORBIDDEN_COLLECTIONS显式列明归档永不携带的集合users、org、team、orgUser、settings、accountSettings、accessibilitySettings、announcements、attachmentStorageSettings、inviteToBoardRolesSettings、lockoutSettings、tableVisibilityModeSettings、translation、invitation_codes、impersonatedUsers、sessiondata、presences、backupSettings。5.9 实例级单例与租户品牌租户品牌复用Admin Panel → Settings → Visibility 已有的字段org 文档携带每个字段的org前缀副本非空值替换该租户请求的实例值tenantBranding()见 models/lib/tenants.js因此没有任何渲染代码改动——客户端读取的仍是它一直读的currentSetting字段只是按 host 发布的文档不同。Organization 字段覆盖orgProductNameproductNameorgThemeColor/orgThemeCustomColors站点主题themeColor/themeCustomColorsorgCustomLoginLogoImageUrl/orgCustomLoginLogoLinkUrl登录 Logo 及其链接orgTextBelowCustomLoginLogo登录 Logo 下方文本orgCustomTopLeftCornerLogoImageUrl/orgCustomTopLeftCornerLogoLinkUrl左上角 Logo 及其链接orgCustomHelpLinkUrl自定义帮助链接orgLegalNotice法律声明 URL站点主题是唯一由组织自己的管理员而非组织行在管理面板设置的品牌字段Admin Panel → Settings → Visibility →Change color与看板设置、成员设置共用同一选择器docs/Features/Theme/Theme.md。写到哪里由服务端的themeTarget()models/lib/tenantAdmin.js决定绝不由客户端决定站点管理员写入实例设置文档租户管理员写入其组织的org文档。主题层级为 1) WeKan 默认主题2) 站点主题3) 用户自己的覆盖。其余单例SMTP、锁定策略、公告、存储配置保持实例级若将来需要租户化模式相同——再加一个org前缀字段与BRANDING_FIELDS中的一行。5.10 测试套件隔离本身就是功能隔离是功能本身因此测试测的是隔离而非管道tests/tenants.test.cjshost 规范化大小写、端口、整 URL、根点、IPv6、伪造的X-Forwarded-Host、非激活组织、重复 host、品牌回退、根 URLtests/tenantAdmin.test.cjs作用域选择器、权限提升尝试、菜单过滤tests/tenantBackup.test.cjs导出选择器、跨租户恢复尝试、禁止集合、归档归属tests/tenantWiring.test.cjspublication、方法、面板确实调用了上述规则而不是自行重判。每个都是纯 Node 测试node tests/tenants.test.cjs纯模块用动态import()加载与其它.cjs单测一致。六、取舍结论与实施顺序建议必须独占进程的客户仍选方案 A每租户一进程受支持同一组织内的部门或接受共享运维的客户用已实现的方案 D组织即租户。B、C 都是可构建的Meteor 也支持但它们的代价是把仅仅是繁琐的运维成本n个进程、n次升级换成静默的安全失败模式只有当配套了能证明隔离的逐租户测试套件时这笔交易才值得——而这套测试才是真正的工作量而非管道本身这也是方案 D 即使不分区也自带 D.10 测试的原因。若未来真要尝试 B 或 C文档给出了保持诚实的顺序在唯一一处解析租户来源必须是受信代理设置的头其余一切从这里读取先让设置单例租户化——它们是最小的集合能端到端验证模式品牌、邮件、锁定然后才是内容集合每个 publication 配一个测试租户 A 的订阅不得返回任何属于租户 B 的数据账号最后处理或干脆不处理先决策一个邮箱的人是一个用户还是n个。七、相关仓库路径速查用途路径多租户问答原文本文主题docs/Features/Login/Multitenancy.md多租户完整设计Option A–Edocs/Design/Multitenancy/Multitenancy.md每租户一进程部署指南docs/Platforms/FOSS/Container/Docker/Meteor3/multitenancy.md可运行 compose 示例docker-compose-multitenancy.yml纯 host 解析 / 品牌 / 根 URLmodels/lib/tenants.jsMeteor 胶水与运行时配置钩子server/lib/tenantResolver.js租户管理员权限规则models/lib/tenantAdmin.js租户备份/恢复作用域models/lib/tenantBackup.js租户方法currentTenant、主题等server/methods/tenant.js既有组织级成员限制server/lib/orgTeamRestriction.js相对 URL 化先例models/lib/universalUrlGenerator.js测试套件tests/tenants.test.cjs、tests/tenantAdmin.test.cjs、tests/tenantBackup.test.cjs、tests/tenantWiring.test.cjs【免费下载链接】wekanThe Open Source kanban, built with Meteor. GitHub issues/PRs are only for FLOSS Developers, not for support, support is at https://wekan.fi/commercial-support/ . PR source translation to imports/i18n/data/en.i18n.json, other translations at https://app.transifex.com/wekan/wekan项目地址: https://gitcode.com/GitHub_Trending/we/wekan创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
返回列表