nextxch 跨多家服务商实现高可用
当整个服务商下线时,邮件应继续流转:无需昂贵的 Anycast,无需企业级负载均衡器,可从 Git 部署。本指南展示基于 Hetzner、netcup 和 STRATO 低成本服务器的三种配置,逐秒说明故障期间发生的情况,并计算资源和成本。每项关于服务商的说法都链接到注明核查日期的来源,每项关于 nextxch 的说法都注明代码中的源文件。
01 · nextxch 为高可用提供了什么(已在代码中核实)
nextxch 支持双活:每个节点都接收 SMTP、IMAP、JMAP、ActiveSync 和 HTTPS,没有被动节点,也没有粘性会话。PostgreSQL 保存共享状态,邮件本身以 SHA-256 寻址的 .eml 对象存储在 Blob 存储中。
| 能力 | 方式 | 代码中的出处 |
|---|---|---|
| 无需 Redis 的集群背板 | PostgreSQL LISTEN/NOTIFY(通道 nextxch_cluster_events)用于新邮件、标记、expunge、队列等事件;UNLOGGED 表 cluster_ephemeral 和 cluster_nodes;每 15 秒发送一次心跳,节点 60 秒无心跳即被移除 | src/Nextxch.Storage/Cluster/PostgresClusterCoordinator.cs |
| Leader 任务 | pg_try_advisory_lock:只有一个节点续订 ACME 证书、发送许可证心跳、同步 LDAP、发送日历提醒 | AcmeCertificateManager.cs, ClusterLicenseService.cs, LdapSyncService.cs, CalendarReminderPushService.cs |
| 跨节点的 SMTP 队列 | FOR UPDATE SKIP LOCKED:每个节点领取不同的队列条目,不会重复投递 | src/Nextxch.Storage/Postgres/PostgresMailStore.cs |
| 支持集群的数据库 | PostgreSQL(集群默认)和 SQL Server (sp_getapplock);SQLite 仅限单节点 | src/Nextxch.Storage/StorageServiceCollectionExtensions.cs |
| Blob 存储 | FileSystem、S3(自有 SigV4 客户端,带 Content-MD5)或 Hybrid(本地 NVMe 缓存 + S3)。Object Lock 模式:Normal、Governance、Compliance 以及法律保留 (legal hold) | src/Nextxch.Storage/Blobs/S3BlobStore.cs, HybridBlobStore.cs |
| 证书与 DKIM | ACME 证书和 DKIM 密钥存放在数据库中;HTTP-01 质询在整个集群内共享,任何节点都能应答;续期时保留叶证书密钥(DANE 3 1 1 保持有效) | src/Nextxch.Security/Tls/AcmeCertificateManager.cs |
| 健康检查端点 | /health/live(不检查数据库)、/health/ready(数据库可达,否则返回 503)、/health(概览,含集群对等节点) | src/Nextxch.Server/Program.cs |
| 真实客户端 IP | SMTP、IMAP、ManageSieve 的 PROXY 协议 v1/v2(Cluster:EnableProxyProtocol),以及来自 TrustedProxyCidrs 的 HTTP X-Forwarded-For | src/Nextxch.Core/Abstractions/IClusterCoordinator.cs |
| 部署产物 | docker-compose.yml、Kubernetes 清单(3 个副本、HPA 2 至 10、PDB、探针)、Helm chart、deb/rpm、Windows MSI | deploy/kubernetes/, deploy/helm/nextxch/, deploy/packages/ |
| 许可证 | 按活动邮箱计数,而非节点或 Pod;许可证状态通过 cluster_kv 共享 | src/Nextxch.Server/Services/ClusterLicenseService.cs, 价格 |
每个节点的端口:25、465、587 (SMTP),143、993 (IMAP),4190 (ManageSieve),80/443(HTTPS、ACME、Webmail、ActiveSync、JMAP)。POP3 110/995 默认关闭(Pop3Settings.cs 中 Pop3:Enabled=false)。
/health/ready 返回 503,节点无法验证收件人。因此,跨服务商的高可用意味着在各站点之间复制 PostgreSQL 和 Blob 存储并自动切换。这正是这三种方案所构建的。02 · 让高可用变得简单的邮件特有规则
入站:MX 自带故障转移
发件方按优先级对 MX 记录排序,并且必须尝试每个地址;优先级相同时,必须随机选择。[1]如果目标不可达,重试间隔应至少为 30 分钟,放弃时间应至少为 4 至 5 天。[1]
出站:每个 IP 的 SPF 和 PTR
SPF 必须列出每个站点的每个出站 IP;ip4/ip6 不计入 10 次 DNS 查询的限制。[2] Gmail 要求有效的正向和反向 DNS;PTR 需在每个服务商处分别设置。[8][14][27][31]
; Zone example.com (TTL in seconds). IPs are documentation ranges: replace them.
; Site A = Hetzner 192.0.2.10, site B = netcup 198.51.100.20, site C (medium setup) = 203.0.113.30
; Inbound SMTP: equal preference, senders MUST randomize (RFC 5321 section 5.1).
example.com. 3600 IN MX 10 mx1.example.com.
example.com. 3600 IN MX 10 mx2.example.com.
; Static per-site names: never touched by the failover job (stable FCrDNS for PTR).
mx1.example.com. 3600 IN A 192.0.2.10
mx2.example.com. 3600 IN A 198.51.100.20
; Client name (IMAP, submission, HTTPS, webmail, ActiveSync, autodiscover):
; short TTL, kept up to date by the health-check job. Start with all healthy nodes.
mail.example.com. 60 IN A 192.0.2.10
mail.example.com. 60 IN A 198.51.100.20
; SPF: every outbound IP of every site. ip4/ip6 do not count against the 10-lookup limit (RFC 7208 4.6.4).
example.com. 3600 IN TXT "v=spf1 ip4:192.0.2.10 ip4:198.51.100.20 ip4:203.0.113.30 -all"
; DKIM: nextxch keeps the keys in the cluster database, so one record set serves all nodes.
; Publish the selectors shown under /admin/ (Domains > DKIM).
; MTA-STS: the policy must list every MX host (see the note on gaps).
_mta-sts.example.com. 3600 IN TXT "v=STSv1; id=20261102"
; DANE (optional, needs a DNSSEC-signed zone): one TLSA record per MX host.
; nextxch reuses the leaf key on renewal, so "3 1 1" stays stable.
_25._tcp.mx1.example.com. 3600 IN TLSA 3 1 1 <sha256-of-spki>
_25._tcp.mx2.example.com. 3600 IN TLSA 3 1 1 <sha256-of-spki>
; PTR (set in the provider console, not in this zone):
; 192.0.2.10 -> mx1.example.com (Hetzner Console / Robot)
; 198.51.100.20 -> mx2.example.com (netcup SCP)
Smtp:HeloHostname,或在 /admin/ 的“主机名、IPv6 & 端口”下设置(每个节点的 EHLO 名称,并按节点进行 PTR 检查)。该名称用于 220 欢迎横幅、EHLO 应答和出站 EHLO。请将每个 IP 的 PTR 指向其固定名称 mx1/mx2;DNS 顾问会为每个节点显示一条 HELO SPF 记录。此外,RFC 5321 第 4.1.4 节禁止仅因 EHLO 名称不匹配而拒收邮件。[1][8]# appsettings.Production.json per node (or admin: Hostname, IPv6 & Ports > EHLO name per node)
# Site A (Hetzner):
{ "Smtp": { "ServerHostName": "mail.example.com", "HeloHostname": "mx1.example.com" } }
# Site B (netcup):
{ "Smtp": { "ServerHostName": "mail.example.com", "HeloHostname": "mx2.example.com" } }
# PTR 192.0.2.10 -> mx1.example.com, PTR 198.51.100.20 -> mx2.example.com
# Served by nextxch itself at https://mta-sts.example.com/.well-known/mta-sts.txt
# (admin: Domains > domain > Transport > MTA-STS). Empty MX list = automatic:
# the domain's MX records from DNS + server host name + EHLO names of all nodes.
# Wildcards like *.example.com are allowed. Every change changes the policy id;
# the DNS advisor shows the matching _mta-sts TXT record.
version: STSv1
mode: enforce
mx: mx1.example.com
mx: mx2.example.com
max_age: 604800
03 · 客户端如何被重定向:方案对比
服务商的故障转移 IP 只在单一服务商内部有效。若整个服务商发生故障,DNS 仍是简单且低成本的手段。
| 选项 | 范围 | 成本 | 评估 |
|---|---|---|---|
| 多个优先级相同的 MX | 跨服务商 | 0 € | 入站邮件必需,已标准化 [1] |
| 自建健康检查任务 + DNS API(Cloudflare、Hetzner、deSEC、netcup) | 跨服务商 | 0 € | 推荐。Cloudflare:TTL 最低 60 秒(Enterprise 30 秒),auto = 300 秒。Hetzner DNS:免费,TTL 最低 60 秒,API 位于 Hetzner Console 中。deSEC:每个域有最低 TTL,示例为 3600 秒,仅可按申请降低。netcup:提供 CCP DNS API,最低 TTL 未有文档说明。[38][39][21][40][29] |
| Cloudflare Load Balancing(仅 DNS) | 跨服务商 | 每月 5 美元起 | Enterprise 以外的方案每 15 秒执行一次健康检查(含 TCP、SMTP);A/AAAA/CNAME 可用,MX/SRV 仅限 Enterprise。可替代 mail.example.com 的自有作业。[33][34][35][36] |
| Cloudflare Spectrum | 跨服务商 | Enterprise | 任意 TCP 端口(25、465、587、993)仅作为 Enterprise 附加组件提供;Pro/Business 仅支持 SSH、Minecraft、RDP。[37] |
| Hetzner Floating IP(Cloud) | 一个项目,一个网络区域 | IPv4 €3.00,IPv6 €1.00/月 | 仅限 Hetzner 内部 [9] |
| Hetzner 故障转移 IP(Robot,独立服务器) | Hetzner 数据中心 | €4.20/月 + €4.90 开通费 | 通过 Robot API 在 90 至 110 秒内切换,仅限 Hetzner 内部 [11] |
| netcup 故障转移 IP | 同一 netcup 机房 | €5.00/月,含增值税 | 可随时在 SCP 中免费重新路由,仅限 netcup 内部 [24][25] |
| Akamai/Linode IP 共享 | 一个数据中心,两台 Linode | USD 5 起(Nanode) | 基于 BGP (lelastic),仅限同一数据中心;NodeBalancer Basic USD 10,TCP/HTTP [41][42] |
| Anycast / 自有 BGP | 全局 | 高 | 自有 ASN(通过成员资格或赞助 LIR 签订 RIPE 合同)以及自有地址空间(在 Vultr 至少 /24 IPv4 或 /48 IPv6)。对大多数 nextxch 部署而言得不偿失。[43][44] |
04 · 配置 S:两个站点加见证节点
适用于中小型企业。站点 A(例如 Hetzner Cloud)和站点 B(例如 netcup VPS)各自运行 nextxch、由 Patroni 管理的 PostgreSQL、一个 etcd 成员和一个 Garage 区域。位于第三家服务商(例如 STRATO)的小型见证节点 C 承载第三个 etcd 成员、第三份 Garage 副本以及 DNS 故障转移检查程序。三者之间通过 WireGuard 组成网状互联。
flowchart TB
U["Users: IMAP, SMTP submission, HTTPS"] -->|"mail.example.com TTL 60"| DNS["DNS with health-check job"]
S["Remote mail servers"] -->|"MX 10 mx1 / MX 10 mx2"| A1
S --> B1
DNS --> A1
DNS --> B1
subgraph A["Site A: Hetzner"]
A1["nextxch"] --> AP[("PostgreSQL primary")]
A1 --> AG["Garage zone A"]
AE["etcd"]
end
subgraph B["Site B: netcup"]
B1["nextxch"] --> BP[("PostgreSQL replica")]
B1 --> BG["Garage zone B"]
BE["etcd"]
end
subgraph C["Witness C: STRATO"]
CE["etcd"]
CG["Garage zone C"]
CF["Failover checker"]
end
B1 -.->|"WireGuard"| AP
AP ==>|"synchronous replication"| BP
AG <-.-> BG
BG <-.-> CG
AE <-.-> BE
BE <-.-> CE
为什么选择 Garage 作为 blob 存储?
单个服务商处的 S3 存储桶会随该服务商一起消失,而 nextxch 目前只能写入一个 S3 目标。Garage 是一款兼容 S3 的存储(AGPL-3.0),专为通过互联网连接的多个数据中心而设计(项目要求:延迟最多 200 ms,带宽至少 50 Mbit/s)。当 replication_factor = 3 时,每个区域各有一份副本;只要仅有一个区域故障,读写就能继续(写入法定人数为 2)。如果只有两个区域,失去一个区域将使存储变为只读,因此见证节点也保存一份完整副本。[60][61]
分步说明
- 订购服务器:A = Hetzner Cloud(端口 25/465 默认被封锁;满一个月且首张账单付款后可申请解封,587 开放),B = netcup VPS(在 SCP 中移除防火墙策略 "netcup Mail block"),C = STRATO VPS 作为见证节点(见证节点不需要端口 25)。[13][26]
- 设置 PTR:IP A 指向 mx1.example.com(Hetzner Console),IP B 指向 mx2.example.com(netcup SCP)。[14][27]
- 通过 cloud-init 进行基础安装(Debian 13):deploy 用户、Docker、WireGuard,禁止密码登录。
- A、B、C 之间的 WireGuard 网状网络 10.88.0.0/24;仅对对端 IP 开放 UDP 51820。[47]
- 三个站点都运行 etcd(选举超时至少为 10 × RTT),然后在 A 和 B 上运行 Patroni + PostgreSQL 18。[50][53]
- 在三个站点上都部署 Garage,应用包含三个区域的布局,并创建存储桶和密钥。[61]
- 在 A 和 B 上通过 Docker Compose 运行 nextxch(Cluster:Enabled=true、多主机连接字符串、S3 指向本地 Garage)。在 /admin/ 中设置主机名 mail.example.com,并为 mail、mx1、mx2 签发 ACME 证书。
- 按第 02 章配置 DNS,在 /admin/ 中检查 MTA-STS MX 列表,并在 /admin/ 的“域”下启用 DNS 故障转移(仅集群主节点执行),或者改用在 etcd 锁下运行的脚本。
- 按第 09 章进行备份并测试还原。然后演练一次故障:强制停止站点 A,并按第 08 章操作。
#cloud-config
# Hetzner Cloud / netcup: paste as user data when creating the VM.
users:
- name: deploy
groups: [sudo, docker]
shell: /bin/bash
sudo: "ALL=(ALL) NOPASSWD:ALL"
ssh_authorized_keys:
- ssh-ed25519 AAAA... ci-deploy
package_update: true
packages: [wireguard, curl, jq, docker.io, docker-compose-v2, unattended-upgrades]
runcmd:
- [ systemctl, enable, --now, docker ]
- [ sed, -i, "s/^#\\?PasswordAuthentication.*/PasswordAuthentication no/", /etc/ssh/sshd_config ]
- [ systemctl, restart, ssh ]
# /etc/wireguard/wg0.conf on site A (Hetzner). Same pattern on B and C.
# Full mesh: every site has one [Peer] block per other site.
[Interface]
Address = 10.88.0.1/24
ListenPort = 51820
PrivateKey = <private-key-of-site-A> # wg genkey, never commit it
[Peer] # site B (netcup)
PublicKey = <public-key-of-site-B>
Endpoint = 198.51.100.20:51820
AllowedIPs = 10.88.0.2/32
PersistentKeepalive = 25
[Peer] # site C (witness or third site)
PublicKey = <public-key-of-site-C>
Endpoint = 203.0.113.30:51820
AllowedIPs = 10.88.0.3/32
PersistentKeepalive = 25
# systemctl enable --now wg-quick@wg0
# Firewall: UDP 51820 open only for the two peer IPs.
# /etc/default/etcd on site A (Debian package etcd-server). Sites B and C analogue.
# Three members = quorum of two: any single site may fail.
ETCD_NAME="site-a"
ETCD_DATA_DIR="/var/lib/etcd/default"
ETCD_LISTEN_PEER_URLS="http://10.88.0.1:2380"
ETCD_LISTEN_CLIENT_URLS="http://10.88.0.1:2379,http://127.0.0.1:2379"
ETCD_INITIAL_ADVERTISE_PEER_URLS="http://10.88.0.1:2380"
ETCD_ADVERTISE_CLIENT_URLS="http://10.88.0.1:2379"
ETCD_INITIAL_CLUSTER="site-a=http://10.88.0.1:2380,site-b=http://10.88.0.2:2380,site-c=http://10.88.0.3:2380"
ETCD_INITIAL_CLUSTER_STATE="new"
ETCD_INITIAL_CLUSTER_TOKEN="nextxch-ha"
# WAN tuning (etcd tuning guide: election timeout at least 10x the round trip).
# Example for up to ~25 ms RTT between providers:
ETCD_HEARTBEAT_INTERVAL="250"
ETCD_ELECTION_TIMEOUT="2500"
# Traffic stays inside WireGuard. For production add TLS (ETCD_CERT_FILE etc.).
# /etc/patroni/config.yml on site A (Debian/PGDG package "patroni").
# Site B: name/addresses analogue. The witness C runs etcd only, no PostgreSQL.
scope: nextxch
name: pg-site-a
restapi:
listen: 10.88.0.1:8008
connect_address: 10.88.0.1:8008
etcd3:
hosts: 10.88.0.1:2379,10.88.0.2:2379,10.88.0.3:2379
bootstrap:
dcs:
ttl: 30 # defaults; rule: loop_wait + 2 * retry_timeout <= ttl
loop_wait: 10
retry_timeout: 10
maximum_lag_on_failover: 1048576
synchronous_mode: on # a 250 OK to a sender is only given after the commit reached a 2nd site
synchronous_mode_strict: false # if the replica site is gone, keep accepting mail (falls back to async)
failsafe_mode: true
postgresql:
use_pg_rewind: true
parameters:
wal_level: replica
hot_standby: "on"
max_wal_senders: 10
max_replication_slots: 10
wal_log_hints: "on"
archive_mode: "on"
archive_command: "pgbackrest --stanza=nextxch archive-push %p"
initdb: [ {encoding: UTF8}, data-checksums ]
postgresql:
listen: 10.88.0.1:5432
connect_address: 10.88.0.1:5432
data_dir: /var/lib/postgresql/18/main
bin_dir: /usr/lib/postgresql/18/bin
authentication:
replication: { username: replicator, password: "${PATRONI_REPLICATION_PASSWORD}" }
superuser: { username: postgres, password: "${PATRONI_SUPERUSER_PASSWORD}" }
pg_hba:
- host replication replicator 10.88.0.0/24 scram-sha-256
- host nextxch nextxch 10.88.0.0/24 scram-sha-256
# /etc/garage.toml, identical on all three sites except rpc_public_addr.
metadata_dir = "/var/lib/garage/meta"
data_dir = "/var/lib/garage/data"
db_engine = "lmdb"
replication_factor = 3 # one copy per zone (site); writes need 2 of 3
consistency_mode = "consistent"
rpc_bind_addr = "10.88.0.1:3901"
rpc_public_addr = "10.88.0.1:3901"
rpc_secret = "<openssl rand -hex 32, same on all nodes>"
[s3_api]
s3_region = "garage"
api_bind_addr = "127.0.0.1:3900" # each nextxch node talks to its local Garage
# After the first start (node ids from `garage node id`):
# garage layout assign <id-a> -z hetzner -c 500G
# garage layout assign <id-b> -z netcup -c 500G
# garage layout assign <id-c> -z witness -c 500G
# garage layout show && garage layout apply --version 1
# garage bucket create nextxch
# garage key create nextxch-app
# garage bucket allow --read --write --owner nextxch --key nextxch-app
# docker-compose.yml on site A and site B (nextxch only; PostgreSQL/Patroni, etcd,
# Garage and WireGuard run as system services on the same VM).
# Secrets come from .env (never committed): NEXTXCH_LICENSE, PG_PASSWORD, S3_KEY, S3_SECRET
name: nextxch
services:
nextxch:
image: nextxch/server:${NEXTXCH_TAG}
restart: unless-stopped
network_mode: host # real client IPs for SPF/DMARC, direct WireGuard reach
environment:
ASPNETCORE_URLS: "http://*:80;https://*:443"
License__Token: "${NEXTXCH_LICENSE}"
Cluster__Enabled: "true"
Cluster__NodeId: "${SITE_NAME}" # hetzner-a / netcup-b
Storage__Provider: "Postgres"
# Npgsql multi-host: always connects to the current Patroni primary
Storage__PostgresConnectionString: "Host=10.88.0.1,10.88.0.2;Port=5432;Database=nextxch;Username=nextxch;Password=${PG_PASSWORD};Target Session Attributes=primary;Host Recheck Seconds=5"
Storage__BlobBackend: "S3"
Storage__Compression: "Zstd"
Storage__S3__EndpointUrl: "http://127.0.0.1:3900"
Storage__S3__Region: "garage"
Storage__S3__BucketName: "nextxch"
Storage__S3__ForcePathStyle: "true"
Storage__S3__AccessKey: "${S3_KEY}"
Storage__S3__SecretKey: "${S3_SECRET}"
Smtp__ServerHostName: "mail.example.com"
Imap__ServerHostName: "mail.example.com"
ManageSieve__ServerHostName: "mail.example.com"
Acme__Enabled: "true"
Acme__ContactEmail: "postmaster@example.com"
volumes:
- nextxch-data:/app/data
healthcheck:
test: ["CMD", "curl", "-fsS", "http://127.0.0.1/health/ready"]
interval: 15s
timeout: 5s
retries: 3
volumes:
nextxch-data:
连接字符串使用 Target Session Attributes=primary 指定两个 PostgreSQL 主机。nextxch 通过 NpgsqlDataSource.Create (PostgresMailStore.cs) 创建连接;指定多个主机时,Npgsql 会自动构建多主机数据源,只连接当前主节点,并每隔 Host Recheck Seconds(默认 10 秒)重新检查状态。Patroni 故障转移后,各节点无需任何配置更改即可重新连接;集群 LISTEN 连接每 2 秒重试一次 (PostgresClusterCoordinator.cs)。[63]
mail.example.com 的自动 DNS 故障转移
nextxch 自带 DNS 故障转移代理(需主动启用,默认关闭;位于 /admin/ 的“域”下,卡片“DNS failover”)。每个节点每 30 秒通过公网 IP 检查所有节点端口 25 上的 SMTP 横幅以及 HTTPS /health。只有集群主节点执行操作:它持有 PostgreSQL advisory lock,按多数原则汇总所有节点的视图,并通过 Hetzner DNS (Cloud API) 或 Cloudflare API 写入所配置名称的 A/AAAA 记录。滞后机制:连续 N 轮失败后节点从 DNS 中移除,连续 M 轮正常后重新加入;最后一个节点永远不会被移除。每次变更都会记入事件日志,告警规则“DNS failover: node taken out”会通过运维告警发出通知。API 令牌使用服务器密钥环加密,且不会再次显示。
# Admin: Domains > DNS failover (cluster). Stored cluster-wide; the token is encrypted
# with the server keyring and never shown again. Equivalent settings:
Provider: Hetzner DNS (Cloud API, api.hetzner.cloud) | Cloudflare DNS
Zone: example.com
Names: mail.example.com # A/AAAA follow the healthy nodes
TTL: 60 # resolvers keep old answers this long
Interval: 30 s
Failures (N): 3 # 3 x 30 s before a node leaves DNS
Successes (M): 5 # 5 x 30 s before it returns
Checks: SMTP 25 (220 banner), HTTPS /health
Nodes: fsn1, 192.0.2.10, 2001:db8::10
nbg1, 198.51.100.20
# Alert: rule "DNS failover: node taken out" (Alerts) fires when a node is out of DNS.
Target Session Attributes=primary(见上文第 04 章)后,被隔离的一方连不到主节点,拿不到锁,保持被动。所有观察者的多数投票可防止视图异常的单个节点移除健康节点。替代方案:在 etcd 锁下运行脚本
对于没有内置连接器的 DNS 服务商(例如 deSEC),或希望由 etcd 做出决定时,脚本仍是等效的替代方案。不要针对相同名称同时运行代理和脚本。该脚本每 20 秒通过公网 IP 检查每个节点的 /health/ready,并通过 Cloudflare API 同步 A 记录(对 dns_records 执行 PATCH/POST/DELETE)。它在每个站点上以 etcdctl lock 运行:只有一个副本执行操作,且仅在拥有 etcd 多数票的一侧执行。因此,被隔离的站点无法再更改 DNS。对于 Hetzner DNS 或 deSEC,只需更改三个 curl 调用。[39]
#!/usr/bin/env bash
# /usr/local/bin/nextxch-dns-failover.sh
# ALTERNATIVE to the built-in agent (admin: Domains > DNS failover), e.g. for DNS
# providers without a built-in connector (deSEC) or when etcd decides anyway.
# Keeps the A records of mail.example.com equal to the set of healthy nodes.
# Runs on every site under an etcd lock, so only one copy acts, and only on the
# side that still has etcd quorum (a cut-off minority site can never change DNS).
# ExecStart=etcdctl --endpoints=http://127.0.0.1:2379 lock nextxch-dns /usr/local/bin/nextxch-dns-failover.sh
# Needs: curl, jq. Secrets via systemd EnvironmentFile (CF_API_TOKEN, CF_ZONE_ID).
set -euo pipefail
NAME="mail.example.com"
TTL=60
NODES=("192.0.2.10" "198.51.100.20") # public IPs of the nextxch nodes
FAILS_BEFORE_REMOVE=3 # 3 x 20 s = 1 minute
STATE=/run/nextxch-failover; mkdir -p "$STATE"
API="https://api.cloudflare.com/client/v4/zones/${CF_ZONE_ID}/dns_records"
AUTH=(-H "Authorization: Bearer ${CF_API_TOKEN}" -H "Content-Type: application/json")
healthy() { # HTTPS readiness through the public IP, with SNI for the real name
curl -fsS --max-time 5 --resolve "${NAME}:443:$1" "https://${NAME}/health/ready" \
| jq -e '.status == "ready"' >/dev/null
}
while true; do
want=()
for ip in "${NODES[@]}"; do
f="$STATE/$ip"; n=$(cat "$f" 2>/dev/null || echo 0)
if healthy "$ip"; then echo 0 > "$f"; want+=("$ip")
else n=$((n+1)); echo "$n" > "$f"; [ "$n" -lt "$FAILS_BEFORE_REMOVE" ] && want+=("$ip"); fi
done
# Never publish an empty set: if everything looks down, the checker is probably the problem.
[ "${#want[@]}" -eq 0 ] && { sleep 20; continue; }
have=$(curl -fsS "${AUTH[@]}" "${API}?type=A&name=${NAME}" | jq -r '.result[] | "\(.id) \(.content)"')
for ip in "${want[@]}"; do # add missing healthy IPs
grep -q " ${ip}$" <<<"$have" || curl -fsS "${AUTH[@]}" -X POST "$API" \
--data "{\"type\":\"A\",\"name\":\"${NAME}\",\"content\":\"${ip}\",\"ttl\":${TTL},\"proxied\":false}" >/dev/null
done
while read -r id ip; do # remove IPs that are no longer healthy
[ -z "${id:-}" ] && continue
printf '%s\n' "${want[@]}" | grep -qx "$ip" || curl -fsS "${AUTH[@]}" -X DELETE "${API}/${id}" >/dev/null
done <<<"$have"
sleep 20
done
# /etc/systemd/system/nextxch-dns-failover.service (install on every site)
[Unit]
Description=nextxch DNS failover (runs only while holding the etcd lock)
After=network-online.target etcd.service wg-quick@wg0.service
Wants=network-online.target
[Service]
EnvironmentFile=/etc/nextxch/dns-failover.env
ExecStart=/usr/bin/etcdctl --endpoints=http://127.0.0.1:2379 lock nextxch-dns /usr/local/bin/nextxch-dns-failover.sh
Restart=always
RestartSec=10
[Install]
WantedBy=multi-user.target
05 · M 方案:三个完整站点(Proxmox 或普通虚拟机)
与 S 方案相同,但三个站点都运行 nextxch 和一个 PostgreSQL 副本。一个站点故障时,仍保留两个数据库和两个 nextxch 节点,借助 synchronous_mode 甚至可保持同步复制。三条优先级相同的 MX 记录分摊入站负载。
flowchart TB
S["Remote mail servers"] -->|"MX 10 mx1, mx2, mx3"| N
U["Users"] -->|"mail.example.com"| N
subgraph N["WireGuard mesh 10.88.0.0/24"]
direction LR
subgraph A["Hetzner"]
A1["nextxch"] --- A2[("Patroni + PG")] --- A3["etcd + Garage"]
end
subgraph B["netcup"]
B1["nextxch"] --- B2[("Patroni + PG")] --- B3["etcd + Garage"]
end
subgraph C["Third provider"]
C1["nextxch"] --- C2[("Patroni + PG")] --- C3["etcd + Garage"]
end
end
N --> BK["Backups: Storage Box + S3 at another provider"]
分步说明
- 三家服务商的三台虚拟机(或 Proxmox 客户机),Debian 版本相同,PTR 分别指向 mx1/mx2/mx3。
- 包含三个主机的 Ansible 清单,一个 playbook 部署全部组件(WireGuard、etcd、Patroni、Garage、nextxch)。
- 沿用方案 S 的 Patroni 配置并增加第三台主机;synchronous_mode: on,三个数据库时可选 quorum。[54]
- 包含三个主机的连接字符串、包含三个区域的 Garage 布局、三条 MX 记录,SPF 和 DNS 故障转移节点列表中各有三个 IP。
- Proxmox Backup Server 可选用于虚拟机镜像:数据存储位于本地或 S3(自 PBS 4.2 起官方支持 S3 后端);Proxmox 并未将通过 CIFS 挂载的 Storage Box 等网络共享列为数据存储类型。[46]
# inventory/ha.yml: three sites, one VM (or Proxmox guest) per site
all:
vars:
ansible_user: deploy
wg_network: 10.88.0.0/24
pg_major: 18
children:
nextxch_sites:
hosts:
hetzner-a: { ansible_host: 192.0.2.10, wg_ip: 10.88.0.1, garage_zone: hetzner }
netcup-b: { ansible_host: 198.51.100.20, wg_ip: 10.88.0.2, garage_zone: netcup }
third-c: { ansible_host: 203.0.113.30, wg_ip: 10.88.0.3, garage_zone: third }
# site.yml: idempotent base for every site (same playbook for Hetzner, netcup, Strato ...)
- hosts: nextxch_sites
become: true
tasks:
- name: Base packages (Debian 13)
ansible.builtin.apt:
name: [wireguard, etcd-server, etcd-client, patroni, pgbackrest, docker.io, docker-compose-v2, jq, curl]
update_cache: true
# PostgreSQL 18 itself comes from the PGDG apt repository (apt.postgresql.org),
# Garage as the static binary or the dxflrs/garage image.
- name: WireGuard config
ansible.builtin.template: { src: wg0.conf.j2, dest: /etc/wireguard/wg0.conf, mode: "0600" }
notify: restart wg
- name: etcd, Patroni and Garage config
ansible.builtin.template: { src: "{{ item.src }}", dest: "{{ item.dest }}", mode: "0640" }
loop:
- { src: etcd.env.j2, dest: /etc/default/etcd }
- { src: patroni.yml.j2, dest: /etc/patroni/config.yml }
- { src: garage.toml.j2, dest: /etc/garage.toml }
- name: nextxch compose file
ansible.builtin.template: { src: docker-compose.yml.j2, dest: /opt/nextxch/docker-compose.yml }
- name: Start nextxch
ansible.builtin.command: docker compose up -d
args: { chdir: /opt/nextxch }
changed_when: false
handlers:
- name: restart wg
ansible.builtin.service: { name: wg-quick@wg0, state: restarted }
06 · L 方案:在三家服务商上运行 Kubernetes
适用于大量邮箱和横向扩展。首先要注意:k3s 明确不支持在跨云分布的集群中使用内嵌 etcd;服务器节点应位于同一地点。因此:每家服务商一个 Kubernetes 集群(各用 k3s 或 Talos),每个集群都运行基于随附 Helm chart 的 nextxch 部署。数据层(Patroni + etcd + Garage)与配置 M 一样通过 WireGuard 运行在独立的虚拟机上,由三个集群共同使用。[49][48]
flowchart TB
DNS["DNS: MX per provider LB, mail.example.com with health checks"]
subgraph K1["k3s/Talos cluster Hetzner"]
L1["Load balancer"] --> P1["nextxch pods (HPA)"]
end
subgraph K2["k3s/Talos cluster netcup"]
L2["Load balancer"] --> P2["nextxch pods (HPA)"]
end
subgraph K3["k3s/Talos cluster STRATO"]
L3["Load balancer"] --> P3["nextxch pods (HPA)"]
end
DNS --> L1
DNS --> L2
DNS --> L3
subgraph D["Data layer on VMs, WireGuard"]
PG[("Patroni: primary + 2 replicas")]
GA["Garage: 3 zones"]
ET["etcd: 3 members"]
end
P1 --> PG
P2 --> PG
P3 --> PG
P1 --> GA
P2 --> GA
P3 --> GA
为什么不跨集群使用 CloudNativePG?
CloudNativePG 可以在其他 Kubernetes 集群中运行副本集群,但集群间的提升和降级是声明式的(字段 primary、promotionToken),即不会自动进行。若要实现自动的跨服务商故障转移,在三个站点上使用 Patroni 加 etcd 是更直接的方案。[52]
分步说明
- 按照配置 M 的方式构建数据层(三台运行 Patroni、etcd、Garage 的虚拟机)。
- 每家服务商一个 k3s 或 Talos 集群;通过 WireGuard(或 KubeSpan)与数据层建立网络连接。
- 入站端口:使用 LoadBalancer 类型的服务并设置
externalTrafficPolicy: Local(与存储库中一致),以便为 SPF/DMARC 保留发件人 IP;如果负载均衡器支持 PROXY protocol,请改用Cluster__EnableProxyProtocol=true。Hetzner Cloud Load Balancer 支持 PROXY protocol 和任意 TCP 服务。[19] - 出站端口 25:Pod 出口流量经由具有匹配 PTR 和 SPF 条目的固定 IP(出口网关,参见 deploy/kubernetes/README.md)。
- 按服务商设置 Helm values,通过流水线依次发布(第 07 章)。HPA(CPU 70% 时 2 到 10 个 Pod)和 PodDisruptionBudget 随代码库提供。
# values-hetzner.yaml (one file per provider cluster; netcup/strato analogue)
# Helm chart: deploy/helm/nextxch in the nextxch repository.
replicaCount: 3
mail:
hostname: mail.example.com
defaultDomain: example.com
postgres:
# Patroni data layer outside Kubernetes, reached through WireGuard / KubeSpan
connectionString: "Host=10.88.0.1,10.88.0.2,10.88.0.3;Database=nextxch;Username=nextxch;Password=<from secret>;Target Session Attributes=primary"
s3:
endpointUrl: "http://garage.nextxch-data.svc:3900" # Garage zone of this provider
bucketName: "nextxch"
07 · 从 Git 部署和更新
所有重要内容都是存储库中的文件:compose 文件或 Helm values、Ansible 清单、DNS 故障转移脚本。流水线每次只部署一个站点,并等待 /health/ready 再次报告“ready”后才继续。机密信息(许可证、密码、API 令牌、SSH 密钥)只存放在 GitHub/GitLab 机密存储或服务器上的 .env 中,绝不放入存储库。
flowchart TB
G["git push to main"] --> V["Validate: compose config / helm lint"]
V --> A["Roll out site A"]
A --> HA{"/health/ready = ready?"}
HA -->|"yes"| B["Roll out site B"]
HA -->|"no"| X["Abort, B stays on old version"]
B --> HB{"/health/ready = ready?"}
HB -->|"yes"| C["Roll out site C"]
HB -->|"no"| X
C --> OK["Done"]
# .github/workflows/deploy.yml: rolling update of the compose sites, one site at a time.
name: deploy-nextxch
on:
push:
branches: [main]
paths: ["sites/**", "compose/**"]
workflow_dispatch:
concurrency: { group: deploy-nextxch, cancel-in-progress: false }
jobs:
deploy:
runs-on: ubuntu-latest
strategy:
max-parallel: 1 # never update two sites at once
matrix:
site: [hetzner-a, netcup-b]
environment: ${{ matrix.site }}
steps:
- uses: actions/checkout@v4
- name: Write SSH key
run: |
install -m 700 -d ~/.ssh
echo "${{ secrets.DEPLOY_SSH_KEY }}" > ~/.ssh/id_ed25519 && chmod 600 ~/.ssh/id_ed25519
echo "${{ vars.HOST_KEY }}" >> ~/.ssh/known_hosts
- name: Upload compose file and roll the container
env:
HOST: ${{ vars.SSH_HOST }}
run: |
scp compose/docker-compose.yml "deploy@${HOST}:/opt/nextxch/docker-compose.yml"
scp "sites/${{ matrix.site }}.env.tag" "deploy@${HOST}:/opt/nextxch/tag.env"
ssh "deploy@${HOST}" 'cd /opt/nextxch && docker compose --env-file .env --env-file tag.env pull && docker compose --env-file .env --env-file tag.env up -d'
- name: Wait until the node is ready again
env:
HOST: ${{ vars.SSH_HOST }}
run: |
for i in $(seq 1 30); do
ssh "deploy@${HOST}" 'curl -fsS http://127.0.0.1/health/ready' && exit 0
sleep 10
done
echo "node did not become ready" >&2; exit 1
# .gitlab-ci.yml: the same rolling update with GitLab CI
stages: [validate, deploy-a, deploy-b]
variables:
NEXTXCH_TAG: "latest" # pin a released version in production
validate:
stage: validate
image: docker:27
script:
- docker compose -f compose/docker-compose.yml config -q
.deploy: &deploy
image: alpine:3.22
before_script:
- apk add --no-cache openssh-client curl
- install -m 700 -d ~/.ssh && echo "$DEPLOY_SSH_KEY" > ~/.ssh/id_ed25519 && chmod 600 ~/.ssh/id_ed25519
- echo "$HOST_KEY" >> ~/.ssh/known_hosts
script:
- scp compose/docker-compose.yml "deploy@$SSH_HOST:/opt/nextxch/"
- ssh "deploy@$SSH_HOST" "cd /opt/nextxch && NEXTXCH_TAG=$NEXTXCH_TAG docker compose pull && NEXTXCH_TAG=$NEXTXCH_TAG docker compose up -d"
- for i in $(seq 1 30); do ssh "deploy@$SSH_HOST" 'curl -fsS http://127.0.0.1/health/ready' && exit 0; sleep 10; done; exit 1
resource_group: production # serializes deployments
rules: [ { if: '$CI_COMMIT_BRANCH == "main"' } ]
deploy-hetzner-a:
<<: *deploy
stage: deploy-a
environment: { name: hetzner-a }
deploy-netcup-b:
<<: *deploy
stage: deploy-b
environment: { name: netcup-b }
# .github/workflows/deploy-k8s.yml: one Kubernetes cluster per provider, rolled one after another.
name: deploy-nextxch-k8s
on:
push: { branches: [main], paths: ["k8s/**"] }
workflow_dispatch:
concurrency: { group: deploy-k8s, cancel-in-progress: false }
jobs:
helm:
runs-on: ubuntu-latest
strategy:
max-parallel: 1
matrix:
cluster: [hetzner, netcup, strato]
environment: k8s-${{ matrix.cluster }}
steps:
- uses: actions/checkout@v4
- uses: azure/setup-helm@v4
- name: Kubeconfig
run: |
mkdir -p ~/.kube
echo "${{ secrets.KUBECONFIG_B64 }}" | base64 -d > ~/.kube/config
chmod 600 ~/.kube/config
- name: Helm upgrade (waits for readiness probes on /health/ready)
run: |
helm upgrade --install nextxch ./deploy/helm/nextxch \
--namespace nextxch --create-namespace \
-f k8s/values-common.yaml -f k8s/values-${{ matrix.cluster }}.yaml \
--set image.tag=${{ vars.NEXTXCH_TAG }} \
--wait --timeout 10m --atomic
nextxch-cli config plan 将计划作为评论发布到拉取请求,config apply --prune --yes 在合并后应用它。附现成 GitHub Actions 和 GitLab CI 文件的指南:配置即代码 (GitOps)。08 · 故障场景:具体会发生什么
Hetzner(站点 A,当前主节点)完全离线
sequenceDiagram participant S as Remote mail server participant A as Site A (Hetzner) participant B as Site B (netcup) participant C as Witness C participant D as DNS Note over A: Provider outage S->>A: SMTP to mx1 A--xS: no connection S->>B: SMTP to mx2 (or retry) B--xS: without a primary the session fails, sender retries later Note over B,C: etcd majority B + C, leader key of A expires (ttl 30 s) B->>B: Patroni promotes PostgreSQL B to primary B->>B: nextxch reconnects (Target Session Attributes=primary) S->>B: retry B-->>S: 250 OK (commit synchronous or asynchronous) C->>D: failover job removes IP A from mail.example.com Note over D: clients follow after the TTL expires
| 时间 | 会发生什么 | 用户会察觉到什么 |
|---|---|---|
| 0 s | A 已失效。发往 mx1 的入站邮件失败,发件方改用 mx2(优先级相同)或稍后重试。[1] | A 上的客户端会断开连接。 |
| 最长约 30 秒 | A 在 etcd 中的 leader 键在 ttl = 30 秒后过期;B 和 C 拥有多数票。[53] | 节点 B 在 /health/ready 上返回 503,写入操作等待。 |
| 约 30 至 60 秒 | Patroni 提升 B 为主节点(loop_wait 10 秒)。B 上的 nextxch 通过多主机连接字符串找到新的主节点(重新检查间隔 5 至 10 秒)。[63] | B 重新接收邮件。 |
| 约 60 至 90 秒 | DNS 故障转移(代理:3 轮失败 × 30 秒,脚本:3 × 20 秒)会从 mail.example.com 中移除 IP A。 | TTL(60 秒)过期后,新连接转到 B。 |
| 1 至 5 分钟 | 设有自身最小 TTL 的解析器和带 DNS 缓存的客户端会随后跟进。Garage 继续使用区域 B 和 C 运行(法定人数 3 取 2)。[61] | 客户端会重新连接;Webmail 会话保持有效(签名的无状态会话)。 |
RPO
启用 synchronous_mode 后,PostgreSQL 只有在第二个站点也收到提交后才确认该提交;以 250 OK 确认的邮件此时已存在于两个站点。代价是:每次提交至少要等待站点之间的一次往返。不启用 synchronous_mode 时,故障前最后几秒的数据可能丢失。设置 synchronous_mode_strict: false 时,如果没有剩余副本,Patroni 会回退到异步模式,从而继续接收邮件。[56][54]
RTO
数据库约 30 到 60 秒(Patroni 默认值),DNS 切换约 1 分钟加 TTL,入站邮件通过第二个 MX 或发件方重试,无需等待。这些时间由默认值推算而来,并非实测:请在您自己的测试中验证(硬停一个站点)。
netcup(站点 B,副本)离线
A 上的主节点继续运行。没有同步伙伴时,Patroni 切换为异步模式(strict = false),故障转移作业移除 IP B,入站邮件经由 mx1 流入。Garage 以 A 和 C 继续运行。
仅 A 与 B 之间的链路中断
仍能连通见证节点 C 的一方保有 etcd 多数票,保持或成为主节点。另一方失去多数票,Patroni 会将该侧的主节点降级(failsafe_mode 仅在仍能连通所有成员时生效),DNS 故障转移在该侧也不会动作:内置代理连不到主节点,拿不到 leader 锁;脚本拿不到 etcd 锁。不会发生脑裂。[55]
故障恢复(Failback)
sequenceDiagram participant A as Site A participant B as Site B (primary) participant G as Garage participant D as DNS job A->>A: server back online, WireGuard up A->>B: Patroni: pg_rewind, then replica of B G->>A: missing objects are re-replicated A->>A: /health/ready = ready D->>D: successful checks D->>A: IP A back in mail.example.com Note over A,B: optional, planned: patronictl switchover back to A
A 恢复后,B 仍为主节点。Patroni 通过 pg_rewind(use_pg_rewind: true)将 A 作为副本重新接入,Garage 重新同步缺失的对象,一旦 A 上的 /health/ready 报告“ready”,故障转移作业便重新添加 IP A。无需将主节点切回;如有需要,请在业务低峰时使用 patronictl switchover 有计划地进行。
09 · 备份、还原与 GoBD
复制可防范故障,却无法防范误操作:误删的邮箱会在所有站点同时消失。因此请按照 3-2-1 规则补充备份:三份副本、两种介质、一份异地存放。[59]
flowchart LR
PG[("PostgreSQL primary or replica")] -->|"pgBackRest, continuous WAL"| R1["Repo 1: Hetzner Storage Box (SFTP)"]
PG -->|"pgBackRest"| R2["Repo 2: S3 at another provider"]
GA["Garage (3 zones)"] -->|"rclone copy hourly"| AR["Object Storage with Object Lock (GoBD)"]
GA -->|"rclone copy"| R1
R1 --> T["Quarterly restore test"]
R2 --> T
- PostgreSQL:pgBackRest 配合连续 WAL 归档(时间点恢复)。存储库 1 通过 SFTP 位于 Hetzner Storage Box(pgBackRest 自 2.46 起支持 SFTP;Storage Box 提供 SFTP、SCP、rsync、BorgBackup、Restic、Rclone、SMB、WebDAV),存储库 2 位于另一家服务商的 S3。两者均加密。[58][15]
- Blob:.eml 对象以 SHA-256 命名,且从不修改。使用
rclone copy(而非 sync,因此绝不删除)作为每小时一次的复制即可。 - GoBD 归档:Hetzner Object Storage 支持 GOVERNANCE 和 COMPLIANCE 模式的 Object Lock 以及版本控制;Object Lock 必须在创建存储桶时启用。创建存储桶时设置默认保留期 (compliance),之后每个复制进来的对象都会自动锁定。也可以让 nextxch 直接写入该存储桶(S3 或 Hybrid 后端),但这样数据块的可用性取决于 Hetzner。在这种情况下,一旦配置了 WORM 模式(或
Storage:S3:ObjectLockEnabled),nextxch 会在启动时创建缺失的、启用 Object Lock 的存储桶;GoBD 归档中的“检查 S3 存储桶”会显示可达性、版本控制、Object Lock 模式和默认保留期。[18][17] - 测试还原:至少每季度将 pgBackRest 还原到测试虚拟机中,并从 blob 副本中抽样打开。
# /etc/pgbackrest/pgbackrest.conf on every PostgreSQL site.
# Repo 1: Hetzner Storage Box over SFTP (pgBackRest 2.46+), repo 2: S3 at another provider.
[global]
repo1-type=sftp
repo1-path=/pgbackrest
repo1-sftp-host=uXXXXXX.your-storagebox.de
repo1-sftp-host-port=23
repo1-sftp-host-user=uXXXXXX
repo1-sftp-private-key-file=/var/lib/postgresql/.ssh/id_ed25519
repo1-sftp-host-key-hash-type=sha256
repo1-retention-full=2
repo1-cipher-type=aes-256-cbc
repo1-cipher-pass=<long random secret, stored outside the repo>
repo2-type=s3
repo2-path=/pgbackrest
repo2-s3-endpoint=<s3 endpoint of the second provider>
repo2-s3-bucket=nextxch-pg-backup
repo2-s3-region=<region>
repo2-s3-uri-style=path
repo2-retention-full=4
repo2-cipher-type=aes-256-cbc
repo2-cipher-pass=<second secret>
archive-async=y
process-max=2
start-fast=y
[nextxch]
pg1-path=/var/lib/postgresql/18/main
pg1-port=5432
# Weekly full, daily differential (cron on every node; pgBackRest runs only where it can,
# use --repo and the "backup-standby" option to take backups from a replica).
# 0 2 * * 0 pgbackrest --stanza=nextxch --type=full backup
# 0 2 * * 1-6 pgbackrest --stanza=nextxch --type=diff backup
# Blob copy (all .eml objects) from Garage to an independent target, every hour.
# ~/.config/rclone/rclone.conf (keys are placeholders)
[garage]
type = s3
provider = Other
endpoint = http://127.0.0.1:3900
region = garage
access_key_id = <garage key>
secret_access_key = <garage secret>
[archive]
type = s3
provider = Other
endpoint = <object storage endpoint>
access_key_id = <key>
secret_access_key = <secret>
# Content-addressed (SHA-256 names, never modified): "copy" is enough and never deletes.
# rclone copy garage:nextxch archive:nextxch-archive --immutable --transfers 8 --checksum
# For a GoBD copy create the target bucket WITH Object Lock and a default retention
# (COMPLIANCE, e.g. 10 years) so every copied object is locked automatically.
10 · 扩展:用户、存储、Pod
flowchart LR S1["Start: 2 sites + witness"] --> S2["More mailboxes: add nextxch nodes per site"] S2 --> S3["DB grows: scale PostgreSQL VMs vertically (RAM, NVMe)"] S3 --> S4["More storage: add Garage nodes per zone"] S4 --> S5["Many mailboxes: Kubernetes per provider, HPA"]
| 组件 | 扩展方式 | 注意 |
|---|---|---|
| nextxch 节点 / Pod | 水平 | 无状态;每个站点增加节点或增加 Pod (HPA)。无需会话粘滞。 |
| PostgreSQL 主节点 | 纵向 | 所有写入都进入同一个主节点。应增加内存和更快的磁盘 (IOPS),而不是增加节点;shared_buffers 初始值为内存的 25%。nextxch 不使用只读副本。[57] |
| Blob 存储 | 水平 | 为每个区域添加更多带容量的 Garage 节点(garage layout assign -c),数据会重新平衡。SHA-256 去重和 Zstd 压缩可降低这一需求。 |
| SMTP | 通过 DNS | 更多优先级相同的 MX 记录可分摊负载;外发队列通过 SKIP LOCKED 分布到所有节点。 |
| IMAP | 通过 DNS / LB | 长连接的 IDLE 会话保留在其所在节点上;来自其他节点的新邮件通过 LISTEN/NOTIFY 到达。添加节点后,只有在客户端重新连接时负载才会分散。[7] |
| HTTPS(Webmail、ActiveSync、JMAP) | 通过 DNS / LB | 无状态 HMAC 会话;任何请求都可以到达任意节点。 |
11 · 成本:哪里可以节省,哪里不能
保存
- nextxch 和见证节点使用共享 vCPU 套餐;只有在数据库 CPU 负载确有需要时才使用独享核心。
- 在 netcup 签约 12 个月(据价格页面比月付价格低 13%)。[22]
- 使用内置代理(或您自己的脚本)进行 DNS 故障转移,无需付费负载均衡器。
- Blob 的 Zstd 压缩(Storage:Compression)和 SHA-256 去重。
不要节省的地方
- PostgreSQL 节点的内存:凡是未被缓存的数据,每次查看文件夹都会产生磁盘 IO。
- PostgreSQL 的磁盘 IOPS (NVMe);WAL 写入决定每次投递的延迟。
- 第三个站点:没有它就无法实现安全的自动故障转移。
- 在独立目标位置进行经过测试的备份。
已录入的价格(每月净价,截至 2026 年 11 月 2 日)。netcup 和 STRATO 公布的是含 19% 增值税的总价,此处已除以 1.19。Hetzner 另加主 IPv4 费用 €0.50。在核查当日,Hetzner 将所有云套餐显示为“currently unavailable”;无法确定这是库存问题还是显示问题。Hetzner 卷的价格无法从官方页面读取,因此计算器中未包含。价格会变动:订购前请向服务商核实。[12][10][22][23][30][15][16]
| 服务商 | 方案 | vCPU | 内存 | 磁盘 | € 净价 / 月 |
|---|---|---|---|---|---|
| Hetzner Cloud | CX23 | 2 | 4 GB | 40 GB | €6.49 |
| Hetzner Cloud | CX33 | 4 | 8 GB | 80 GB | €9.49 |
| Hetzner Cloud | CX43 | 8 | 16 GB | 160 GB | €16.99 |
| Hetzner Cloud | CX53 | 16 | 32 GB | 320 GB | €30.49 |
| netcup VPS | VPS 500 G12.5 | 2 | 4 GB | 64 GB | €6.94 |
| netcup VPS | VPS 1000 G12.5 | 4 | 8 GB | 128 GB | €12.18 |
| netcup VPS | VPS 2000 G12.5 | 8 | 16 GB | 256 GB | €22.62 |
| netcup VPS | VPS 4000 G12.5 | 12 | 32 GB | 512 GB | €38.12 |
| netcup VPS | VPS 8000 G12.5 | 16 | 64 GB | 1024 GB | €56.39 |
| netcup Root Server | RS 1000 G12.5 | 4 | 8 GB | 128 GB | €18.26 |
| netcup Root Server | RS 2000 G12.5 | 8 | 16 GB | 256 GB | €34.20 |
| netcup Root Server | RS 4000 G12.5 | 12 | 32 GB | 512 GB | €65.40 |
| netcup Root Server | RS 8000 G12.5 | 16 | 64 GB | 1024 GB | €124.72 |
| netcup Root Server | RS 12000 G12.5 | 20 | 96 GB | 1536 GB | €182.26 |
| netcup Root Server | RS 16000 G12.5 | 24 | 128 GB | 2048 GB | €243.24 |
| STRATO VPS | VPS S | 1 | 2 GB | 60 GB | €3.36 |
| STRATO VPS | VPS M | 2 | 4 GB | 120 GB | €8.40 |
| STRATO VPS | VPS L | 4 | 8 GB | 240 GB | €16.81 |
| STRATO VPS | VPS XL | 8 | 16 GB | 480 GB | €32.77 |
| STRATO VPS | VPS XXL | 12 | 24 GB | 720 GB | €47.06 |
| Hetzner Storage Box | BX11 | - | - | 1 TB | €3.20 |
| Hetzner Storage Box | BX21 | - | - | 5 TB | €10.90 |
| Hetzner Storage Box | BX31 | - | - | 10 TB | €20.80 |
| Hetzner Storage Box | BX41 | - | - | 20 TB | €40.60 |
| Hetzner Object Storage | 基础价格,含 1 TB 存储 + 1 TB 出站流量 | - | - | 1 TB | €6.49 |
12 · 计算器:资源与成本
完全在浏览器中运行,无需外部服务。所用假设公开列在结果下方。
假设(未经测量,请用您自己的数据核对)
- 邮件数据量 = 邮箱数 × 平均大小 × (1 + 增长率)^年数。不扣除去重或压缩的节省(保守估算)。
- PostgreSQL 容量 = 邮件量的 3%(元数据、标记、全文索引),至少 2 GB。实际值可通过
SELECT pg_database_size('nextxch')获得。 - PostgreSQL 内存 = 数据库大小的 25%,至少 2 GB,向上取整到 2/4/8/16/32/64/96/128 GB。PostgreSQL 磁盘 = 2 × 数据库大小(数据、WAL、余量)。
- nextxch 进程:1 GB + 每开始 1,000 个邮箱增加 1 GB(代码库中的 Kubernetes 限制为每个 Pod 1 GiB)。etcd + Garage + 操作系统:1 GB。见证节点:2 GB 内存。操作系统:20 GB 磁盘。
- Garage replication_factor 3:每个区域(站点或见证节点)都存储完整的邮件数据量。
- 方案 L:每个服务商至少 2 个 Pod,每 1,500 个邮箱 1 个 Pod,工作节点 8 GB;数据虚拟机承载 PostgreSQL、etcd 和 Garage 区域。
- 备份需求 = 3 × 数据库大小(2 份完整备份 + WAL)+ 邮件量;目标:选择能容纳的最小 Hetzner Storage Box。
- 许可证价格取自本网站当前价目表(阶梯价格适用于所有邮箱,年价 / 12)。Cloudflare 以美元显示,不计入欧元合计。
13 · 添加另一家服务商(以 STRATO 为例)
- 下单前确认:能否设置 PTR?STRATO:VPS 的反向 DNS 在客户登录的“域”下设置,必须先存在 A/AAAA 记录,最长需 24 小时。端口 25:对于独立服务器,STRATO 文档说明出站被阻止,可通过支持解封;对于 VPS 未有相关规定的文档。STRATO 不提供故障转移 IP。[31][32][30]
- 将新主机加入 Ansible 清单,并为其分配独立的 WireGuard IP 和 Garage 区域,在所有站点上添加对等节点,然后运行 playbook。
- etcd:用
etcdctl member add添加成员,并以 ETCD_INITIAL_CLUSTER_STATE=existing 启动。有四个数据库站点时,请保持 etcd 成员数为奇数。[48] - Patroni 以副本身份启动并自动复制。Garage:使用新区域执行
garage layout assign,然后执行garage layout apply。 - DNS:将 IP 添加到 SPF,新增一条优先级相同的 MX,将节点加入 DNS 故障转移列表(代理或脚本),为新的 MX 名称添加 TLSA 记录,设置新节点的 EHLO 名称和 PTR,并在证书中添加该名称。MTA-STS 策略会自动包含新的 MX(自动 MX 列表);随后发布 DNS 顾问给出的新 _mta-sts TXT 值。
- 将该站点加入流水线矩阵,并对新站点的故障进行一次测试。
14 · 限制与不足(坦诚说明)
在编写本指南时于代码中发现。其中四个缺口目前已修复:多个 MX 名称的 MTA-STS、每个节点的 EHLO 名称、自动创建的 Object Lock 存储桶以及内置 DNS 故障转移代理。其余问题本指南按所述方式规避。
| 不足 | 影响 | 解决方法 |
|---|---|---|
| DNS 故障转移代理(已解决) | 内置,需手动启用:每个节点都执行健康检查,只有集群主节点通过 Hetzner DNS 或 Cloudflare 更改 A/AAAA 记录。 | 其他 DNS 服务商(例如 deSEC):在 etcd 锁下运行脚本(第 04 章)。 |
| 多个 MX 的 MTA-STS(已解决) | 按域策略列出所有 MX 名称(DNS MX、服务器和节点名称,或带通配符的自定义列表);策略 id 随列表变化。 | 每次更改 MX 后,请发布 DNS 顾问给出的 _mta-sts TXT 值。 |
| 每个节点的 EHLO 名称(已解决) | Smtp:HeloHostname 或在 /admin/ 中按节点设置,并按节点检查 PTR;服务器主机名在整个集群内保持一致。 | - |
| 只有一个 S3 目标 | 不会双写到不同服务商的两个 S3 存储;Object Lock 功能需要支持 Object Lock 的 S3。 | Garage 跨三个站点部署,另用 rclone 复制到 Object Lock 存储桶。 |
| Object Lock 存储桶(已锁定) | 配置 WORM 模式后,服务器会在启动时创建缺失的、启用 Object Lock 的存储桶。已有但未启用 Object Lock 的存储桶会以警告和告警形式报告,因为 Object Lock 只能在创建时启用。[18] | 可随时在 GoBD 归档中通过“Check S3 bucket”进行检查。 |
| 无内置备份 | 服务器中没有针对数据库和 Blob 的备份服务。 | pgBackRest 和 rclone(第 09 章)。 |
| 无只读副本 | 所有查询都发往主节点;只读副本在开发计划中仅列为“按需提供”。 | 对 PostgreSQL 主节点进行垂直扩展。 |
| 没有实测的容量规划基准 | 计算器中的内存和数据库大小均为假设值。 | 可用您自己的测量值覆盖。 |
常见问题
nextxch 能否跨多个服务商以双活方式运行?
可以。启用 Cluster:Enabled=true 并使用 PostgreSQL 后,任意数量的 nextxch 节点通过 LISTEN/NOTIFY、UNLOGGED 表和咨询锁共享状态。所有节点都必须能访问同一个 PostgreSQL 主节点和同一个 S3 Blob 存储;跨服务商时通过 WireGuard 网络实现。
我需要负载均衡器或 Anycast 吗?
不需要。入站邮件只需多条优先级相同的 MX 记录(RFC 5321 第 5.1 节)。IMAP、提交和 HTTPS 需要一个 TTL 较短的名称,其 A 记录由健康检查任务维护。Anycast/BGP 也可行,但较为复杂。
我至少需要多少个站点?
三票:两个完整站点加一个小型见证节点(etcd、Garage 区域、故障转移检查程序),或三个完整站点。只有两个站点时,任何一方都无法可靠判断对方是故障了还是仅仅链路中断(脑裂)。
服务商下线时邮件会丢失吗?
发件方会持续重试至少 4 至 5 天(RFC 5321 第 4.5.4.1 节)。借助 Patroni synchronous_mode 和 Garage replication_factor 3,以 250 OK 确认的邮件已经存储在第二个站点上。
15 · 参考来源
所有外部事实均已于 2026 年 11 月 2 日对照以下官方来源进行核查。无法核实的内容已在正文中注明。
- RFC 5321(SMTP):4.1.4 EHLO、4.5.4.1 重试、5.1 MX:https://www.rfc-editor.org/rfc/rfc5321.html
- RFC 7208 (SPF):4.6.4 DNS 查询限制,5.6 ip4/ip6:https://www.rfc-editor.org/rfc/rfc7208.html
- RFC 6376 (DKIM):3.1 选择器:https://www.rfc-editor.org/rfc/rfc6376.html
- RFC 8461(MTA-STS):3.2、4.1 mx 模式:https://www.rfc-editor.org/rfc/rfc8461.html
- RFC 7672(DANE for SMTP):3.1 TLSA 3 1 1:https://www.rfc-editor.org/rfc/rfc7672.html
- RFC 8314(隐式 TLS 465/993/995):https://www.rfc-editor.org/rfc/rfc8314.html
- RFC 2177(IMAP IDLE,每 29 分钟重新发出):https://www.rfc-editor.org/rfc/rfc2177.html
- Google:电子邮件发件人指南(正向和反向 DNS):https://support.google.com/a/answer/81126
- Hetzner 文档:Floating IPs:https://docs.hetzner.com/cloud/floating-ips/overview
- Hetzner 文档:IPv4 价格:https://docs.hetzner.com/general/others/ipv4-pricing
- Hetzner 文档:Robot 故障转移 IP:https://docs.hetzner.com/robot/dedicated-server/ip/failover
- Hetzner Cloud:成本优化套餐:https://www.hetzner.com/cloud/cost-optimized/
- Hetzner 文档:Cloud server FAQ(端口 25/465):https://docs.hetzner.com/cloud/servers/faq/
- Hetzner Docs:PTR 记录:https://docs.hetzner.com/networking/dns/record-types/ptr-record/
- Hetzner Storage Box(价格、协议):https://www.hetzner.com/storage/storage-box/
- Hetzner Object Storage(价格):https://www.hetzner.com/storage/object-storage/
- Hetzner Docs:Object Storage 概述:https://docs.hetzner.com/storage/object-storage/overview
- Hetzner Docs:Object Lock 与保留:https://docs.hetzner.com/storage/object-storage/howto-protect-objects/protect-object-lock-retention/
- Hetzner 文档:Load Balancer 常见问题:https://docs.hetzner.com/cloud/load-balancers/faq
- Hetzner 文档:Networks FAQ:https://docs.hetzner.com/cloud/networks/faq
- Hetzner Docs:DNS(控制台和 API):https://docs.hetzner.com/networking/dns/overview/
- netcup:VPS G12.5 价格:https://www.netcup.com/de/server/vps
- netcup:Root Server G12.5 价格:https://www.netcup.com/de/server/root-server
- netcup Helpcenter:IP 地址与故障转移:https://www.netcup.com/en/helpcenter/documentation/server/ip
- netcup:服务器扩展(故障转移 IP 价格):https://www.netcup.com/de/server/kvm-server-erweiterungen
- netcup 帮助中心:防火墙(策略"netcup Mail block"):https://www.netcup.com/en/helpcenter/documentation/server/firewall
- netcup Helpcenter:网络 / rDNS:https://www.netcup.com/en/helpcenter/documentation/server/network-server
- netcup:Storage Space (NFS):https://www.netcup.com/en/server/storage-space
- netcup Helpcenter:DNS API:https://www.netcup.com/en/helpcenter/documentation/domain/our-api
- STRATO:VPS Linux 价格:https://www.strato.de/server/linux-vserver/
- STRATO 常见问题:VPS 的反向 DNS:https://www.strato.de/faq/server/wie-kann-ich-reverse-dns-fuer-meinen-vps-vds-einrichten/
- STRATO 常见问题:独立服务器网络防火墙(端口 25):https://www.strato.de/faq/server/wie-stelle-ich-die-netzwerk-firewall-bei-meinem-windows-root-server-ein/
- Cloudflare:Load Balancing 附加组件价格:https://www.cloudflare.com/plans/network-cdn/
- Cloudflare 文档:Load Balancing 限制(健康检查间隔):https://developers.cloudflare.com/load-balancing/reference/limitations/
- Cloudflare 文档:Load Balancing DNS 记录(MX/SRV 仅限 Enterprise):https://developers.cloudflare.com/load-balancing/load-balancers/dns-records/
- Cloudflare 文档:代理模式(DNS-only):https://developers.cloudflare.com/load-balancing/understand-basics/proxy-modes/
- Cloudflare 文档:各套餐支持的 Spectrum 协议:https://developers.cloudflare.com/spectrum/protocols-per-plan/
- Cloudflare Docs:DNS TTL:https://developers.cloudflare.com/dns/manage-dns-records/reference/ttl/
- Cloudflare API:编辑 DNS 记录:https://developers.cloudflare.com/api/resources/dns/subresources/records/methods/edit/
- deSEC:域与最小 TTL:https://desec.readthedocs.io/en/latest/dns/domains.html
- Akamai/Linode:IP 故障转移(BGP,同一数据中心):https://techdocs.akamai.com/cloud-computing/docs/configure-failover-on-a-compute-instance
- Akamai:NodeBalancer 价格:https://www.akamai.com/cloud/pricing/nodebalancers
- Vultr:BGP 要求:https://docs.vultr.com/configuring-bgp-on-vultr
- RIPE NCC:申请 AS 号:https://www.ripe.net/manage-ips-and-asns/as-numbers/request-an-as-number/
- Proxmox VE:Cluster Manager(corosync 延迟、QDevice):https://pve.proxmox.com/wiki/Cluster_Manager
- Proxmox Backup Server:数据存储(含 S3):https://pbs.proxmox.com/docs/storage.html
- WireGuard:https://www.wireguard.com/
- k3s:使用嵌入式 etcd 的高可用:https://docs.k3s.io/datastore/ha-embedded
- k3s:分布式混合或多云集群:https://docs.k3s.io/networking/distributed-multicloud
- etcd:调优(心跳、选举超时):https://etcd.io/docs/v3.5/tuning/
- Talos Linux:KubeSpan:https://docs.siderolabs.com/talos/v1.11/networking/kubespan
- CloudNativePG:副本集群:https://cloudnative-pg.io/docs/current/replica_cluster
- Patroni:动态配置(ttl、loop_wait、retry_timeout):https://patroni.readthedocs.io/en/latest/dynamic_configuration.html
- Patroni:复制模式(synchronous_mode、strict):https://patroni.readthedocs.io/en/latest/replication_modes.html
- Patroni:DCS failsafe 模式:https://patroni.readthedocs.io/en/latest/dcs_failsafe_mode.html
- PostgreSQL 18:同步复制:https://www.postgresql.org/docs/current/warm-standby.html#SYNCHRONOUS-REPLICATION
- PostgreSQL 18:shared_buffers:https://www.postgresql.org/docs/current/runtime-config-resource.html
- pgBackRest(S3 和 SFTP 存储库):https://pgbackrest.org/
- CISA:数据备份选项 (3-2-1):https://www.cisa.gov/sites/default/files/publications/data_backup_options.pdf
- Garage:项目主页(多数据中心,200 ms / 50 Mbps):https://garagehq.deuxfleurs.fr/
- Garage:配置(replication_factor、仲裁):https://garagehq.deuxfleurs.fr/documentation/reference-manual/configuration/
- Garage:S3 兼容性(无 Object Lock,无版本控制):https://garagehq.deuxfleurs.fr/documentation/reference-manual/s3-compatibility/
- Npgsql:故障转移与负载均衡(多主机):https://www.npgsql.org/doc/failover-and-load-balancing.html
- Mermaid 12.2.0 (MIT),随本站一同托管:https://www.npmjs.com/package/mermaid