分拆部署
将控制平面放在容器托管平台、节点集群放在自有主机,并将数据库、根密钥和对象存储放在云区域,介绍各部分的部署成本。
自托管指南把所有组件放在一台机器上,在需求增长到不再适合之前,这通常是正确选择。本页介绍另一种布局:控制平面运行在容器托管平台,工作节点集群运行在你自己的机器上,持久化组件放在靠近控制平面的云区域。
这也是 Warmbly 自身架构描述的部署方式。控制平面持有关系型状态,运行在方便使用的容器平台;执行平面由你租用的一组机器组成,因为工作节点的价值就在于它是某处的一台独立机器。
container host machines you own cloud region
────────────── ──────────────── ────────────
backend worker Postgres
consumer worker KMS key
realtime nats + redis S3 bucket
tracking SES
forms
web / admin这里所有内容都通过环境变量和 deploy/split-cloud/ 中的清单配置完成,无需改代码或维护分支。
各组件的部署位置及原因
| 服务 | 平面 | 部署理由 |
|---|---|---|
| backend | 控制 | 持有 Postgres 访问并在启动时应用迁移 |
| consumer | 控制 | 直接连接 Postgres,因此应靠近数据库 |
| realtime | 控制 | WebSocket 分发,需要 Redis 和公开主机名 |
| tracking | 控制 | 在独立域名上提供公开像素和点击端点 |
| forms | 控制 | 仅在使用托管表单时需要 |
| web、admin | 控制 | 静态构建,在容器启动时配置 |
| worker | 执行 | 从你控制的机器发送和同步 |
| NATS、Redis | 执行侧 | 两个平面都要访问;靠近节点集群可以让频繁通信留在本地 |
| Postgres、KMS、S3、SES | 云 | 提供持久性、信任根和共享 Blob 存储 |
消费者最容易被放错位置。它不是工作节点:它更新关系型状态,需要数据库 DSN,而工作节点被有意禁止获得该值。将消费者放在后端旁,可避免每个事件都让数据库连接跨越互联网。
让三个区域尽量接近
后端每个请求都会访问 Postgres 和 Redis,因此三个服务商之间的往返延迟,构成整个产品延迟的下限。请选择彼此邻近的容器托管区域、云区域和机器数据中心,并在搭建其他内容之前验证距离和延迟。
两项必备条件
RDS 需要 AWS 证书包
Amazon RDS 提供的证书链以 RDS CA 为根,该 CA 不在公共信任库中,因此仅使用系统证书包时,sslmode=verify-full 会报 x509: certificate signed by unknown authority。后端、消费者和实时服务镜像在 /etc/ssl/rds/global-bundle.pem 中包含 AWS 信任库,请将 sslrootcert 指向它。实时服务使用 Elixir,不读取 DSN 参数,因此改用 DATABASE_SSL_CA_FILE 指定相同证书包。
无法这样配置时,可以回退到 sslmode=require。它仍然加密,服务端的 rds.force_ssl=1 也仍强制 TLS,但不会验证服务器身份,因此调用方连到错误主机也无法察觉。
这种部署中的 Redis 必须使用 TLS
缓存会在条目有效期内保存每个组织已解密的数据密钥。通过互联网明文连接会暴露密钥材料,密码也无法改变这一点。在单台机器上,回环绑定提供保护;控制平面迁到别处后,保护必须由 TLS 提供。
Blob 必须使用对象存储。 工作节点需要读取后端写入的邮件正文。使用 BLOB_PROVIDER=filesystem 时,它既没有对应磁盘,也没有权限;其他组件看似健康,却会在发送最后一步失败。warmbly join 会警告这一点,但仍启动节点;这就是系统给出的全部提醒。
节点不持有云服务凭据
节点集群中的机器需要两种特权操作:解开组织密封的数据密钥,以及读写邮件正文。通常这意味着每台机器都要保存 AWS 凭据,而且放在 warmbly join 每次运行都会重写的文件中。
实际并非如此。控制平面使用 KMS_PROVIDER=aws 或 BLOB_PROVIDER=s3 时,加入节点获得的是代理形式:
| 控制平面 | 节点获得的配置 |
|---|---|
KMS_PROVIDER=aws | KMS_PROVIDER=brokered |
KMS_PROVIDER=local | KMS_PROVIDER=local,并附带主密钥 |
BLOB_PROVIDER=s3 | BLOB_PROVIDER=brokered |
BLOB_PROVIDER=filesystem | BLOB_PROVIDER=filesystem,并显示警告 |
代理提供程序使用节点已有的内部 API 令牌验证身份,再请求实例执行一次特权操作:解封这个密钥,或为这个 Blob 操作签名。实例令牌作用域仅限一个部署,并可从该部署撤销;租用机器上的 IAM 访问密钥不具备这种边界。
Blob 字节不经过代理。控制平面签发 URL,节点直接与对象存储传输,因此邮箱同步每个对象只给后端带来一次小请求,而不是传输全部字节。
代价是每次打开 DEK 需要一次 HTTPS 调用,Redis 会缓存结果,每次 Blob 操作也需要一次。如果希望节点直接访问 AWS,可将 KMS_PROVIDER、BLOB_PROVIDER 和凭据放入 /etc/warmbly/node.local.env,重新加入时不会覆盖该文件。
需要了解两个限制。签名 URL 只覆盖一个键上的一种操作,并且仅限节点需要访问的前缀:传输正文、营销活动附件,以及同步保存的邮箱正文。头像、表单资源和工作区导出归档完全不能签名,因此节点持有的凭据并不是整个存储桶的访问凭据。
该凭据是 NODE_BROKER_TOKEN,覆盖所有仅节点调用的内部路由:密钥和 Blob 代理、Warmbly Cloud 托管邮箱的服务商访问令牌、数据密钥、邮件映射、同步查询、工作节点配置和心跳。它可以回退到 INTERNAL_API_TOKEN,但此处应设为独立值:跟踪和表单服务面向互联网并持有 INTERNAL_API_TOKEN;分开节点令牌后,它们持有的值无法访问上述任何路由。设置后加入的节点会收到新值,替代共享令牌。
升级已设置 NODE_BROKER_TOKEN 的节点集群,无需额外步骤。旧版节点可能仍发送 INTERNAL_API_TOKEN;其心跳响应只包含应运行版本,除此之外没有其他内容,因此它会自行更新,再使用 NODE_BROKER_TOKEN 返回;加入流程已将该值写入 node.env。更新所需的几分钟内,同步查询会被拒绝。工作节点期间仍被视为存活,因为 Redis 心跳无需令牌,因此不会迁移其邮箱。在设置 NODE_BROKER_TOKEN 前已加入的节点没有这个值,只会被告知版本,后端会记录其节点 ID;请重新加入该节点。若要让旧节点在升级期间继续同步,先在控制平面设置 NODE_ACCEPT_INTERNAL_TOKEN=true,待所有节点更新后移除。
搭建步骤
云资源
scripts/aws-bootstrap.sh --domain example.com --region eu-central-1这会创建 KMS 密钥及别名、启用加密的私有存储桶、Postgres 实例、SES 域身份,以及具有最小权限策略的控制平面 IAM 用户。操作幂等,--dry-run 可打印准备执行的内容。
有五件事有意留给你决定,因为它们不应由默认值替代:控制平面用户的访问密钥、数据库安全组、参数组中的 rds.force_ssl=1、DNS 中的 DKIM 记录,以及 SES 生产访问权限。
SES 初始处于沙盒模式
申请生产访问权限前,SES 只向已验证地址投递,因此其他人实际上收不到邀请和密码重置邮件。SES 仅用于平台邮件:营销活动和预热通过客户连接的邮箱发送,绝不经过 SES。
事件总线与缓存
先将 A 记录指向机器,再在机器上执行:
curl -fsSLO https://raw.githubusercontent.com/warmbly/warmbly/main/deploy/split-cloud/bus/setup.sh
sh setup.sh --domain bus.example.com脚本会把其余文件下载到自身旁边,安装缺失的 Docker 和 certbot,创建可读取私钥的组,生成两个凭据,获取证书,安装续期钩子,启动两个服务并等待健康,然后验证真实客户端能使用各自凭据通过 TLS 连接、错误凭据会被拒绝,最后打印控制平面所需的两行配置。
Cloudflare 记录必须设为仅 DNS
使用灰云,不使用橙云。Cloudflare 代理只转发固定端口上的 HTTP 和 HTTPS,其他流量不转发,因此代理记录会完全阻断 4222 和 6380。脚本会在消耗一次 Let's Encrypt 尝试前确认名称能解析,但无法判断答案是否来自代理。
脚本是幂等的。第二次运行会沿用已有内容,绝不轮换秘密值、不重新签发有效证书,也不覆盖 .env。重新运行就是获取变更的方法。
三个常用参数:--verify 对已经运行的机器重新执行连接检查,--print-env 再次打印控制平面配置,--refresh 重新下载 Compose 文件和配置以获取修复,不触碰秘密值。
安装之后最重要的是续期钩子。两个服务都不会自行重新读取证书,没有钩子就会先正常工作 90 天,然后停止。Redis 仅公开 TLS 端口,明文端口留在容器网络中,供同机工作节点使用。
控制平面
deploy/split-cloud/control-plane.env.example 包含带注释的全部设置。以下配置决定节点集群能否正常存在:
PRIMARY_DB=postgres://warmbly:[email protected]:5432/warmbly?sslmode=verify-full&sslrootcert=/etc/ssl/rds/global-bundle.pem
REDIS=rediss://:<password>@bus.example.com:6380
NATS_URL=tls://<token>@bus.example.com:4222
ENCRYPTED_KEYS_BACKEND_URL=https://api.example.com
BLOB_PROVIDER=s3节点会继承这些地址
warmbly join 根据后端自身环境渲染节点配置。如果值只能在容器网络内解析,节点会顺利登记,却无法访问任何服务。添加节点前,先将它们设为其他机器可以访问的地址。
先部署后端,让它应用迁移;随后部署消费者、实时服务、跟踪服务和两个前端。
节点集群
warmblyctl fleet join-token
# on each machine, as root
curl -fsSL https://api.example.com/join.sh | sh -s -- \
--url https://api.example.com \
--token <join-token> \
--role worker \
--region eu-central此时及以后,都不会有服务反向连接这台机器。它无需入站端口或 SSH 密钥。重新运行同一命令会以相同身份重新加入,并保留邮箱。
deploy/split-cloud/node/ 提供同样功能的 Compose 文件,适合希望把节点配置纳入版本控制的情况。以此方式运行的节点通过 docker compose pull 更新;warmblyctl fleet version 只更新通过加入流程登记的节点。
将控制台和管理面板部署到静态托管平台
web 和 admin 是静态构建,因此不需要容器。通过 CDN 提供它们,比在容器平台运行两个 nginx 容器更便宜、更快。
需要注意的是,两者都读取 window.__WARMBLY_ENV__,其来源为 /config.js,容器入口脚本在启动时根据环境生成该文件。静态托管没有容器启动过程,因此改为让同一脚本在构建时生成。
Build command: pnpm build:pages
Build output: dist
Root directory: web (or admin)build:pages 先运行正常构建,再通过 WARMBLY_CONFIG_OUT=dist/config.js 调用应用自己的入口脚本。两条路径共用一份键集合定义,因此容器和静态托管配置不会偏离。
在托管平台设置以下构建环境变量:
web | admin |
|---|---|
WARMBLY_API_URL | WARMBLY_API_URL |
WARMBLY_APP_URL | WARMBLY_DASHBOARD_URL |
WARMBLY_TURNSTILE_KEY | WARMBLY_ENV_LABEL |
WARMBLY_POSTHOG_KEY | WARMBLY_POSTHOG_KEY |
WARMBLY_POSTHOG_SESSION_REPLAY | WARMBLY_POSTHOG_SESSION_REPLAY |
WARMBLY_SENTRY_DSN | WARMBLY_SENTRY_DSN |
WARMBLY_COMPANY_LOGOS |
两个应用都提供 _redirects
构建根目录会复制 /* /index.html 200。没有它,刷新根目录以下的路由或打开分享链接时都会返回 404,因为对应文件确实不存在,只有应用知道该路由。这相当于静态托管版本的 nginx try_files。
无论从哪个来源提供前端,都必须将其加入后端 CORS_ALLOW_ORIGINS,否则控制台可以加载,但每次 API 调用都会在预检时失败。
仅在发布新版本时部署静态前端
使用 Cloudflare Pages 时,保留现有项目和自定义域名。仓库的 Release 工作流在成功发布稳定 GitHub Release 后,可以部署任意已配置的控制台、管理面板、营销站点和文档项目列表。普通合并和预发布标签不会运行这些部署作业。该工作流需主动启用,因此分支和自托管无需 Cloudflare 凭据。
每个项目都有独立构建作业:检出精确发布提交,从 Cloudflare API 读取该项目的生产分支及公开生产变量,构建所选应用,再用 Wrangler 上传静态输出。上传使用项目生产分支名,而不是发布标签作为分支名,因此更新生产而非创建预览。前端配置仍以 Cloudflare 为准,无需在 GitHub 重复保存 API URL、Turnstile 密钥或分析设置。
| 应用 | 构建 | 输出 | GitHub 部署环境 |
|---|---|---|---|
web | pnpm build:pages | web/dist | dashboard-production |
admin | pnpm build:pages | admin/dist | admin-production |
site | pnpm build | site/dist | site-production |
docs | pnpm build | docs/out | docs-production |
先配置 GitHub,再关闭自动部署
各作业从对应项目的 Pages > Settings > Environment variables > Production(Pages > 设置 > 环境变量 > 生产) 读取公开设置。控制台要求 WARMBLY_API_URL、WARMBLY_APP_URL 和 WARMBLY_TURNSTILE_KEY;管理面板要求 WARMBLY_API_URL、WARMBLY_DASHBOARD_URL 和 WARMBLY_TURNSTILE_KEY。其余运行时键,包括分析设置,从各应用入口脚本读取。营销站点导入 PUBLIC_* 变量,文档导入 NEXT_PUBLIC_* 变量。这些值会发送到浏览器,必须是 Pages 明文变量,不能是加密秘密值。缺少必填设置或公开值被加密时,会在构建前失败。无关变量和秘密值不会导入。
-
在 GitHub 打开 Settings > Secrets and variables > Actions(设置 > 秘密值与变量 > Actions)。在 Secrets(秘密值) 中添加
CLOUDFLARE_API_TOKEN。创建 Cloudflare 自定义 API 令牌,赋予 Pages Read 和 Pages Write,也称 Account > Cloudflare Pages > Edit(账户 > Cloudflare Pages > 编辑),并限定到包含这些项目的账户。无需 DNS 或 Workers 权限。 -
在 Variables(变量) 中添加:
变量 值 CLOUDFLARE_ACCOUNT_ID包含现有项目的 Cloudflare 账户 ID CLOUDFLARE_PAGES_PROJECTS下方所示的应用/项目映射 JSON 数组 [ { "app": "web", "project": "your-dashboard-project" }, { "app": "admin", "project": "your-admin-project" }, { "app": "site", "project": "your-marketing-project" }, { "app": "docs", "project": "your-docs-project" } ]使用真实 Pages 项目名,不是自定义域名。只加入希望部署的项目。多个条目可以为不同项目构建同一个应用,每个项目使用自己的生产设置。一个项目只能出现一次。GitHub 最多允许 256 个矩阵条目。列表必须是仓库级变量,因为目标列表会在加载部署环境前解析。
现有单控制台配置也可以继续使用仓库变量
CLOUDFLARE_PAGES_PROJECT_NAME。设置了CLOUDFLARE_PAGES_PROJECTS时,后者优先。 -
可选:在 Settings > Environments(设置 > 环境) 中配置上表的 GitHub 部署环境,需要时添加审批。如果限制部署分支和标签,应允许
v*等发布标签。账户变量和令牌也可设置在环境作用域,否则共享仓库级值。可选源码映射上传使用SENTRY_ORG、应用专属的SENTRY_PROJECT_WEB或SENTRY_PROJECT_ADMIN、POSTHOG_CLI_PROJECT_ID和POSTHOG_CLI_HOST,以及秘密值SENTRY_AUTH_TOKEN和POSTHOG_CLI_API_KEY。环境级值允许每个应用使用不同上传项目。Sentry 发布标识就是发布标签。参见源码映射。 -
对列表中的每个 Cloudflare 项目,进入 Workers & Pages > your project > Settings > Builds & deployments > Configure Production deployments(Workers 和 Pages > 项目 > 设置 > 构建与部署 > 配置生产部署),取消勾选 Enable automatic production branch deployments(启用生产分支自动部署) 并保存。需要拉取请求预览时,保留预览分支部署和现有 Pages 构建配置。不要切换或重建为 Direct Upload 项目;Git 集成项目也接受 Wrangler 上传。
-
在 GitHub 的 Settings > Secrets and variables > Actions > Variables(设置 > 秘密值与变量 > Actions > 变量) 中,将仓库级变量
CLOUDFLARE_PAGES_DEPLOY_ENABLED设置为true。此开关必须在仓库级,因为 GitHub 在加载环境级变量之前就会判断作业条件。 -
按正常版本标签流程发布下一个稳定版本。在 Actions > Release 中确认各个 Deploy ... to Pages 作业成功,并且 Pages 中每个生产部署都显示该发布提交。
部署属于现有发布工作流,不是单独的 release: published 工作流。使用 GITHUB_TOKEN 创建的发布,本工作流正是如此,不会触发另一个发布事件工作流。
Pages 部署钩子也会复用 Cloudflare 构建配置,但它构建的是配置分支的最新提交。这里的作业有意构建标签对应的发布提交,避免发布构建期间合入的变更混入生产。重试使用相同发布提交和当前 Pages 生产配置。构建使用上表中仓库脚本及输出路径,不执行 Cloudflare 中保存的任意构建命令。
各项目独立部署,不是原子整体:某个构建或上传失败,会让该项目继续运行旧部署,不取消其他项目。修复配置后,对该次发布运行选择 Re-run failed jobs(重新运行失败作业),即可重试相同提交,无需新建标签或重建已成功作业。除非打算回滚,不要在新版本已部署后重试旧发布。若要暂停发布上传,将 CLOUDFLARE_PAGES_DEPLOY_ENABLED 设置为 false;现有部署继续在线,也不会重新启用 Cloudflare 自动部署。此工作流仅部署列出的静态前端,不部署后端服务。
控制平面无法获知的配置
warmbly join 根据控制平面响应写入 /etc/warmbly/node.env,每次加入都会重写。旁边的 /etc/warmbly/node.local.env 只创建一次,之后不再写入;容器后读取它,因此重复名称以该文件为准。
这里适合保存实例自身没有的值,例如存放在秘密管理器而非环境中的 DSN、自有基础设施凭据,或按机器调整的参数。
printf 'PRIMARY_DB=%s\n' "postgres://..." >> /etc/warmbly/node.local.env
systemctl restart warmbly-consumer规划节点集群规模
工作节点默认规划目标为 100 个已分配邮箱。每个已分配邮箱计为一个,与服务商和是否启用预热无关。
该目标是本地规划值,不是发送配额或经过测试的最大容量。Google 和 Microsoft 按邮箱、租户或 API 项目执行发送及 API 限额。基准测试表明主机应承载高于或低于保守默认值的数量时,设置 WARMBLY_WORKER_CAPACITY,将其写入机器的 /etc/warmbly/node.local.env,然后重启节点。分配逻辑会平衡邮箱数量、限制服务商和租户集中度,并在容量允许时让邮箱留在原工作节点,使服务商看到稳定登录地址。
拥有多个工作节点后,应让 NATS 和 Redis 使用独立机器。否则控制平面依赖的机器还运行工作节点,重启该工作节点会同时影响总线。
部署成本
起步实例使用一台机器运行总线和第一个工作节点:
| 项目 | 每月 | |
|---|---|---|
| 机器 | 一台小型 VPS:总线、缓存、第一个工作节点 | 约 5 美元 |
| 云 | 最小突发型 Postgres 实例,20 GB | 13–15 美元 |
| KMS:一个密钥和请求,Redis 缓存可减少请求 | 约 1 美元 | |
| 对象存储:几 GB | 约 0.20 美元 | |
| 平台邮件:数百封 | 约 0.05 美元 | |
| 容器托管 | 控制平面实际用量 | 5–10 美元 |
| 总计 | 约 25–32 美元 |
两件事会改变这个数字。使用不到十二个月的云账户可以免费获得最小数据库实例,约减少 14 美元。启用备用实例会让数据库费用翻倍,因此起步时可以不开启,改为保留快照。
容易悄悄增长的是出站流量。每个查询结果和节点读取的邮件正文都会跨越互联网,每月前 100 GB 之外开始计费。刚上线时几乎看不见,但邮箱同步达到实际规模后,可能成为第二大的云费用项。
资源瓶颈
一台总线机器就是一个故障域。 它停机时,所有发送都会停止。在这种规模下,可靠的应对是快照和书面重建流程;当实例价值高于重建所需半天时间时,再考虑 NATS 集群。
数据库可从互联网访问。 这使容器托管平台和节点集群能够共享它。安全组应只允许确实需要访问的地址,强制 TLS,并按重要凭据保护主密码。
自动更新覆盖节点,不覆盖控制平面。 后端负责告诉每个节点应运行哪个版本,因此它按容器平台的常规方式升级。先升级后端,节点集群随后跟进。