架构

介绍 Warmbly 控制平面与执行平面的协作方式,以及加密模型和工作节点模型。

Warmbly 分为两个平面:

  • 控制平面:后端 API、消费者、跟踪、实时服务和 Web。运行在单一区域的容器托管平台上,生产环境使用 Railway。
  • 执行平面:分布在多个服务商和 IP 上的一组工作节点进程,每台 VPS 一个。每台 VPS 以 systemd 监管的 Docker 容器运行工作节点。

这样划分有两个原因。首先,冷邮件的送达能力与 IP 有关,因此工作节点需要分布在不同的机器级网络身份上。其次,控制平面持有关系型状态,而工作节点集群应当可以随时替换。

服务

服务语言平面说明
后端Go(Gin)控制REST API、身份验证、业务逻辑、工作节点编排
消费者Go控制处理事件总线消息并写入 Postgres
跟踪Rust(Axum)控制打开/点击像素与重定向,将事件写入总线
表单Go(Gin)+ React(TanStack)控制托管表单页面(forms/ 应用)、嵌入内容和公开提交,通过后端内部 API 处理
实时服务Elixir(Phoenix)控制WebSocket 事件分发
工作节点Go执行每台 VPS 一个;订阅按工作节点划分的事件总线主题;从不连接 Postgres
WebReact(Vite)不适用控制台前端
管理端React(Vite)不适用平台管理面板

数据流

前端通过 REST + JWT 与后端通信,通过 WebSocket 与实时服务通信。后端将业务状态写入 Postgres。后端、跟踪服务和工作节点向事件总线发布消息:默认使用 JSON 编码的 NATS JetStream,也可选择构建使用 Avro 和 Schema Registry 的 Kafka 版本。消费者读取总线并更新 Postgres 中的分析、禁止发送和送达能力数据。工作节点订阅以自身 UUID 命名的主题(w.<uuid>),并将结果发布到 jobs.worker-events;完整主题映射见事件系统。唯一例外是保存邮箱前的凭据检查:用户正在等待结果,而工作节点对主题中的命令逐条处理,因此后端会改为将请求发到工作节点的 Redis 渠道 email_validation_request:<uuid>,并通过 Redis 接收答复。未订阅该渠道的旧版工作节点,仍通过其主题接收检查请求。

实时分发使用独立渠道:后端和消费者通过 Redis 发布/订阅发布 JSON 事件;当 PUBSUB_ENABLED=true 时使用 Google Cloud Pub/Sub。Elixir 实时服务订阅事件并推送给已连接的 WebSocket 客户端。

对象存储:加密邮件正文以 EMSG 格式保存在 Blob 存储中,默认使用文件系统,也可选择兼容 S3 的存储。

数据存储

存储用途
Postgres用户、组织、营销活动、邮箱、工作节点、凭据、预热状态、按组织加密的 DEK、Message-ID 映射和 Gmail 历史 ID
Redis缓存,包括已解密的 DEK;速率限制、实时桥接和临时状态
Blob 存储邮件正文 Blob(EMSG);文件系统或任意兼容 S3 的存储桶
主密钥信封加密的信任根;使用本地 AES 密钥或 AWS KMS

加密模型

Warmbly 对敏感数据全程使用信封加密。

KMS 持有主密钥。每个组织获得一个由 KMS 生成的 32 字节数据加密密钥(DEK),加密后以组织 ID 为键保存在 Postgres 的 organization_encrypted_keys 表中;工作节点通过后端内部 API 访问,不直接接触 Postgres。DEK 仅在使用时解密,并带 TTL 缓存在 Redis 中,以分摊成本。

应用层秘密信息使用 DEK 通过 AES-256-GCM 密封,再进行 base64 编码。这适用于:

  • 邮箱账户凭据,包括 IMAP/SMTP 密码和 OAuth 令牌。
  • 保存在 S3 中的邮件正文内容,采用 EMSG 格式。

不属于任何组织的实例级秘密信息,使用平台 DEK 加密,密钥 ID 为 uuid.Nil:与组织秘密信息使用相同的信封和信任边界,只是身份不同。参见 internal/app/cipher/。

邮箱凭据是按组织 DEK 规则的一个例外。它们使用 CREDENTIALS_ENCRYPTION_KEY(KeyDomainInstance)密封,因为工作节点读取它们时没有组织上下文。

工作节点模型

每个运行在你自有机器上的 Warmbly 进程都是一个节点:worker 负责发送和同步邮件,consumer 负责处理事件。两者共用一个注册表(fleet_nodes)和同一套生命周期。workers 是该注册表上的分配扩展,仅保存 account_count、health_state 和 load_score;workers.id 就是节点 ID。

节点集群采用拉取方式。节点通过携带实例加入令牌的一条命令加入,随后持续发送心跳;系统不会向节点主动推送部署操作:

  1. 运维人员通过 warmblyctl fleet join-token 签发加入令牌。
  2. 在机器上执行 curl -fsSL https://api.example.com/join.sh | sh -s -- --url ... --token ... --role worker 登记节点。脚本嵌入后端,由 GET /join.sh 提供,因此自托管节点集群不依赖供应商主机,并始终获得与自身后端匹配的脚本。
  3. 后端返回节点 ID、根据自身环境渲染的配置,以及应运行的版本。脚本写入 /etc/warmbly/node.env,安装 systemd 服务和更新定时器,然后启动节点容器。
  4. 节点订阅以自身 ID 命名的事件总线主题并发送心跳,依据服务器返回的 models.NodeLivenessWindow 值,以该窗口的三分之一作为心跳间隔。

心跳响应只携带一条指令:desired_version。节点将其写入文件,由 systemd 定时器拉取镜像并重启,因此被替换的进程不会负责替换自己。空的 desired_version 表示“没有指定意见”,绝不表示“降级为空版本”。

机器无需开放入站端口,也不需要 SSH 密钥或云账户。工作节点需要能够出站访问邮件服务器的 465、587 和 993 端口;邮箱使用 STARTTLS IMAP 时,还需要 143。许多云服务商会默认封锁新服务器的出站 25 和 465,需要申请开放。工作节点发现自身 465 被封锁后,会在每次心跳中报告,并在管理面板的工作节点页面显示,可通过存在节点错误筛选找到。控制台不提供重启、日志、重启主机或卸载操作,因为系统不会远程进入机器。运维操作入口是 warmblyctl fleet,包含 join-token、list、show、remove、pin、version 和 channel,以及管理面板的节点集群部分。

为何按 VPS 部署,而非使用 Kubernetes DaemonSet

此前采用 k8s DaemonSet。后来移除了这种方式,因为 k8s 节点通常通过少量出口 IP 对所有 Pod 做 NAT;Pod 频繁变动,IP 却不随之变化。邮箱服务商记住的是账户登录来源地址,因此身份单位必须是 IP,而不是 Pod:如果邮箱客户端地址每次部署都变,就会无故触发登录风险挑战。工作节点也不依赖 Postgres,因此集群级服务发现和 RBAC 没有带来额外价值。

节点身份

节点 ID 在登记时分配并持久化到机器上,因此重新执行加入命令会以相同身份重新加入,保留历史和邮箱。

拥有多个公网地址的机器,也可以为每个地址运行一个节点:设置 WORKER_BIND_IP 且未设置 WORKER_ID 时,工作节点根据 UUIDv5(URL_namespace, public_ipv4) 派生 ID。同一个 IP 对应同一个工作节点,恢复它无需控制平面保存额外状态,使每个地址都能成为面对服务商的稳定登录身份。

工作节点分配

工作节点只有一种。安装后发送心跳,由控制平面决定其上运行什么。工作节点没有等级、类型、风险池或出口类别,邮箱属性也无需与机器属性匹配。

这是由实际发送身份所在的位置决定的。工作节点从不与收件人的 MX 直接通信;它向客户自己的邮箱服务商(Gmail、Microsoft Graph 或客户 SMTP 主机)进行身份验证,再由该服务商通过自己的出站池投递邮件。整个模型由以下两个结果决定:

  • 收件人的垃圾邮件过滤器看不到工作节点 IP。 Google 会从出站邮件中移除提交客户端 IP,Microsoft 也已在多年前弃用 X-Originating-IP。因此,将容易产生垃圾邮件的邮箱与健康邮箱放在一起,不会污染健康邮箱的发送信誉;按客户等级隔离工作节点也没有收益。
  • 邮箱服务商能清楚看到工作节点 IP。 该 IP 会影响登录风险挑战、按 IP 的身份验证限流(454 4.7.0)和速率限制(421 4.7.28)。

因此,优化重点是让每个邮箱的 IP 保持稳定,而不是追求 IP 多样性;迁移会带来成本,不是天然收益。

选择工作节点

分配逻辑会为每个存活工作节点评分并选择最优者,见 internal/app/worker/placement.go。硬约束仅用于判断能否执行工作:工作节点必须持续发送心跳,状态为 healthy 或 watch。其余因素都是偏好:

因素原因
剩余容量均匀使用节点集群
保留原节点留在原处可让服务商始终看到同一个客户端 IP,权重最高
区域匹配从服务商预期的位置登录,更少触发挑战
租户故障影响范围将同一客户分散到多个工作节点,避免单点故障停止其全部发送
服务商集中度同一服务商的大量账户从一个地址登录,容易触发按 IP 限流
节点运行资历刚加入几分钟的工作节点尚未证明稳定性,因此会逐步试探分配,而不是因其空闲就把所有任务交给它
其他租户仅用于享有独立出口的组织

容量是每个工作节点的已分配邮箱规划目标,因为小型共享虚拟机与专用主机的余量不同。默认采用保守的 100,可通过 WARMBLY_WORKER_CAPACITY 为每台机器设置。每个已分配邮箱都计为一个。该值指导分配,不是经过测试的最大容量,也不是邮箱服务商的发送配额。服务商限额仍绑定到邮箱、租户或 API 项目。

管理面板的节点集群视图将该目标与测得的 CPU、内存分开显示。一个达到 400 个邮箱规划目标的工作节点,不一定已经耗尽机器资源。Linux 节点报告两次心跳之间的平均 CPU、相对于有限容器上限的已用内存(存在该上限时;否则使用主机内存并排除可回收缓存),以及 Warmbly 进程的常驻内存。CPU 按可用核心数归一化;受 CPU 配额或 CPU 集合约束时标为 container,否则标为 host。只有一种资源受到限制时,CPU 和内存的统计范围可能不同。Go 运行时预留内存是独立的分配指标,不是常驻内存或主机内存。CPU 需要两个样本;缺失的操作系统遥测表示不可用,不是零。已停止或不可达节点显示的是过期读数。这些快照随心跳到达,不会在每次刷新控制台时更新。

邮箱占比表示每个工作节点分配的邮箱数占整个节点集群已分配邮箱的比例。发送占比表示它在最近 60 分钟滚动窗口中占服务商发送尝试的比例。发送成功率是成功交给服务商的次数除以尝试次数,不是收件箱到达率或最终投递率。没有尝试时,就没有可计算的成功率。额外列和 CSV 导出会提供这些指标,以及退信、投诉、身份验证错误和最后心跳时间。

节点独立于后端代理报告公网 IPv4。工作节点优先使用公开的 WORKER_PUBLIC_IP;否则,代理通过 HTTPS 访问 api.ipify.org 发现 IPv4 出口地址,配置了本地出站地址时会绑定该地址,每十分钟刷新一次。后端只使用节点报告的公网地址,不使用自己观察到的客户端 IP,因此反向代理不会替换工作节点身份。如果发现从未成功,地址显示为不可用。之后的发现失败会在当前进程运行期间保留最后一次成功结果。重启后,在再次发现成功之前,地址保持不可用,不会信任后端过去观察到的地址。私有和保留地址不是公网身份。如果邮件和 HTTPS 使用不同的 NAT 网关或路由,应显式将 WORKER_PUBLIC_IP 设为邮件出口 IPv4;HTTPS 发现无法验证 SMTP 路由。

容量是目标,不是硬上限。 系统不会因超过目标而拒绝分配。超量代价高于候选节点可能获得的任何加分,因此仅靠保留原节点的偏好,不能让邮箱继续留在超目标节点。当所有工作节点都没有余量时,会综合区域、故障影响范围和服务商集中度,选择超负载程度最低者。新工作节点仍显示完整目标,只是节点资历因素会减慢填充速度。收件人退信和投诉仍是邮箱及营销活动信号,不会减少机器容量或隔离工作节点。

迁移邮箱

轮换逻辑(internal/app/worker/rotation.go)有意保持谨慎。邮箱结果绝不会把机器标为不健康。轮换循环响应存活状态、工作量、独立出口偏离,以及可测量的服务商或工作区集中度:

紧急程度触发条件最短驻留时间目标节点要求
立即工作节点未激活、不发送心跳,或被外部标为封禁或隔离无任意符合条件的节点
较高工作节点被外部标为限流,或邮箱位于其他工作区预留的节点上6 小时任意符合条件的节点
机会性工作节点利用率超过 85%、独立出口偏离,或分配过于集中72 小时评分必须明显更好

由外部管理的健康标签描述机器或运维状态,不由邮箱投递结果设置。

工作节点停止响应使用独立处理路径,见 internal/app/consumer/dead_worker.go,且采取措施前会等待。心跳键存活三分钟,版本发布替换容器时可能超过这一时长,因此仅心跳键缺失不代表节点死亡:只有注册表中也已缺席达到 MailboxEvacuationGrace(10 分钟),才会迁移邮箱。两个信号缺一不可,因为缓存故障时所有心跳键可能同时消失,而注册表仍保持最新。迁移邮箱也确有成本,并非中性的重新平衡:它会改变邮箱服务商看到的地址,可能触发登录挑战。

隔离的网络出口

预留出口的套餐会将组织绑定到工作节点,记录在 dedicated_worker_assignments 中。这是一项强分配偏好,不是固定锁定:工作节点本身没有特殊标记,因此节点故障时,组织的邮箱会正常重新分配,不会被困住;替代节点可用后,轮换循环会将它们迁入。同一循环也会让其他租户的邮箱逐步离开已预留节点。

节点配置

节点不携带硬编码凭据,也无需手动同步配置。加入端点根据后端自身环境渲染 /etc/warmbly/node.env,键列表为 nodeEnvKeys,定义在 internal/api/handler/fleet_nodes.go 中,因此节点使用的基础设施与控制平面完全一致:事件总线、Redis、KMS、Blob 存储、内部 API 令牌和邮箱 OAuth 客户端。

PRIMARY_DB 被有意排除。工作节点只能通过内部 API 访问关系数据;如果在这里分发 DSN,就会悄悄破坏这一边界。

结构定义:fleet_nodes 是所有角色共用的注册表,workers 是其分配扩展,见 internal/infrastructure/db/migrations/000141_fleet_nodes.up.sql;000142_drop_push_provisioning.up.sql 移除了被它替代的推送式配置路径。

根据 GitHub 发布版本自动更新

节点集群以一个发布渠道为目标,保存在 admin_settings 的 fleet.release 下:

  • stable:最新的非预发布 GitHub Release。
  • dev:最新发布,包括预发布版本。

internal/app/releases 解析该渠道的最新版本并写入标签,本身不执行更新。心跳响应携带 desired_version,节点将其写入文件,再由加入脚本安装的 systemd 定时器 warmbly-node-update 拉取并重启,因此被替换的进程不会负责替换自己。

空的 desired_version 表示“未指定”,绝不能理解为“降级为空版本”:如果无法告诉节点应运行什么,它会继续运行现有版本。每个节点的 pinned_version 可以覆盖节点集群目标,用于金丝雀发布或暂缓某台机器升级。

后端有意排除在这一机制之外。它负责告诉其他服务应使用的版本,如果自更新失败,就没有可用于恢复的控制者。

在使用 Avro 编码总线的实例上,节点集群还会等待后端。后端启动时会注册其版本发布的所有 Schema;只有后端已运行目标版本,且 Schema Registry 已接受这些 Schema,节点集群才会切换。注册被拒绝时,节点集群保留现有版本,并在“节点集群”部分显示问题,避免将无法上报任何事件的版本部署到工作节点。

版本检查在后端启动时运行一次,且仅在 RELEASES_ENABLED=true 时执行。默认关闭,因此自托管节点集群不会自动切换到供应商镜像。运维人员可从管理面板的“节点集群”部分手动更新,或使用 warmblyctl fleet version 和 warmblyctl fleet channel 直接设置渠道或固定标签。配置由环境变量驱动,包括 RELEASES_ENABLED、RELEASES_GITHUB_REPO、RELEASES_WORKER_IMAGE_REPO 和 RELEASES_GITHUB_TOKEN,因此自托管用户可以指向自己的分支和镜像仓库。

参见 internal/app/releases/service.go。

工作节点安全策略

冷邮件吞吐量以邮箱为中心,而不是以工作节点为中心。工作节点的安全出站量是其已分配邮箱预算之和,不是统一的全局上限。internal/config/constants.go 中的默认值如下:

  • 每个邮箱的默认冷邮件营销活动上限:每天 50 封。
  • 每个邮箱的默认最短间隔:600 秒。
  • 每个邮箱的默认预热起始量:每天 10 封。
  • 每个邮箱的默认预热上限:每天 40 封。
  • 每个邮箱的默认预热增量:每天增加 1 封。

预热池模型:预热流量分为 free 和 premium 两个池,相关 warmup_pools 表定义在 internal/infrastructure/db/migrations/000001_baseline.up.sql 中。预热池成员关系属于邮箱属性,与当前替它发送的工作节点无关。

预热内容生成属于离线控制平面工作。自主控制器每六小时运行一次,根据七天发送需求计算共享内容库目标,维持至少 200 个会话,最多扩展至 5,000 个,每批最多提交 250 个替换会话,每天最多 1,000 个。它有意不按客户邮箱标签拆分生成,否则会产生无界队列,以及更小、更重复的内容群组。定时补充通过持久化服务商批处理任务使用 gpt-6-luna,不做同步模型调用;数据库唯一性保护会阻止多个后端副本同时产生重复的在途定时批次。当内容已有至少 20 次发送样本,其中至少 3 次进入垃圾邮件且比例达到 15% 或更高时,会自动归档。发送路径通过带索引的查询,原子地选择使用最少的活跃会话并增加其使用次数;没有生成内容可用时,回退到已审核的静态内容库。因此,模型可用性不会阻塞预热发送。

多层反滥用机制

系统没有单一的机器学习反欺诈引擎,而是使用分层控制:

  • 在身份验证敏感流程中使用 CAPTCHA(Cloudflare Turnstile)。
  • 基于 Redis 的按用户、按类别 API 速率限制。
  • 实时服务中按 WebSocket 的加入、消息和事件速率限制。
  • 预热令牌验证:一次性且绑定收件人,防止预热邮件被重放或重定向。
  • 跟踪事件去重,结合内存和持久化存储。
  • 幂等处理邮件送达事件。
  • 工作节点侧邮箱同步的合理使用控制:同步调节器 internal/app/worker/wmail/governor.go 提供优先、实时和历史回填通道,采用延期而非丢弃,并在洪泛或长期超额时升级处理。
  • 对退信、投诉和退订收件人使用禁止发送名单。
  • 管理员封禁和人工覆盖入口。

Kafka 事件见事件系统参考,事件处理器见代码库中的 internal/app/consumer/。

源代码位置索引

以下文件最便于快速重建相关上下文:

  • README.md
  • docs/content/docs/development/deployment-guide.mdx
  • cmd/worker/main.go
  • internal/app/worker/assignment.go
  • internal/app/worker/placement.go
  • internal/app/fleetnode/service.go
  • internal/app/releases/service.go
  • internal/app/cipher/cipher.go
  • internal/tasks/email_task.go
  • internal/repository/pg_worker.go
  • internal/repository/pg_worker_placement.go
  • internal/repository/pg_warmup.go

本页目录