运维指南 Hetzner · netcup · STRATO · 截至 2026 年 11 月 2 日

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
证书与 DKIMACME 证书和 DKIM 密钥存放在数据库中;HTTP-01 质询在整个集群内共享,任何节点都能应答;续期时保留叶证书密钥(DANE 3 1 1 保持有效)src/Nextxch.Security/Tls/AcmeCertificateManager.cs
健康检查端点/health/live(不检查数据库)、/health/ready(数据库可达,否则返回 503)、/health(概览,含集群对等节点)src/Nextxch.Server/Program.cs
真实客户端 IPSMTP、IMAP、ManageSieve 的 PROXY 协议 v1/v2(Cluster:EnableProxyProtocol),以及来自 TrustedProxyCidrs 的 HTTP X-Forwarded-Forsrc/Nextxch.Core/Abstractions/IClusterCoordinator.cs
部署产物docker-compose.yml、Kubernetes 清单(3 个副本、HPA 2 至 10、PDB、探针)、Helm chart、deb/rpm、Windows MSIdeploy/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)。

唯一的硬性要求
所有节点在任何时候都需要恰好一个可写的 PostgreSQL 主节点和一个共享 Blob 存储。如果数据库不可达,/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]

DKIM、MTA-STS、DANE

DKIM 密钥存放在集群数据库中,一套 DNS 记录服务所有节点;选择器允许多个密钥并行。[3] MTA-STS 策略必须列出每一个 MX 主机;通配符只覆盖最左侧的标签。[4] DANE 要求每个 MX 主机一条 TLSA 记录,推荐 3 1 1,并且区域必须经过 DNSSEC 签名。[5]

客户端:IMAP、提交、HTTPS

邮件客户端没有类似 MX 的故障转移机制。它们连接到一个名称(此处为 mail.example.com),其 A 记录由健康检查作业维护。IDLE 连接至少每 29 分钟重新发起一次,随后会落到健康的节点上。[7][6]

两个站点的 DNS 区域(模板)
; 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)
EHLO 名称与 PTR
服务器主机名(mail.example.com,用于客户端、autodiscover 和证书)在整个集群范围内生效。每个节点设置自己的 EHLO/HELO 名称:在配置中设置 Smtp:HeloHostname,或在 /admin/ 的“主机名、IPv6 & 端口”下设置(每个节点的 EHLO 名称,并按节点进行 PTR 检查)。该名称用于 220 欢迎横幅、EHLO 应答和出站 EHLO。请将每个 IP 的 PTR 指向其固定名称 mx1/mx2;DNS 顾问会为每个节点显示一条 HELO SPF 记录。此外,RFC 5321 第 4.1.4 节禁止仅因 EHLO 名称不匹配而拒收邮件。[1][8]
每个节点的 EHLO 名称
# 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
包含所有 MX 主机的 MTA-STS 策略(由 nextxch 按域提供)
# 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 共享一个数据中心,两台 LinodeUSD 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]
为什么需要见证节点
只有两个站点时,任何一方都无法判断对方是故障了还是仅仅链路中断。如果双方都把自己提升为主节点,就会产生两个分叉的数据库(脑裂)。etcd 按多数票决定:三个成员中有两个即可。因此:在三家服务商处设置三票,其中第三票可以是小型见证节点。[48][53]

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
架构配置 S

为什么选择 Garage 作为 blob 存储?

单个服务商处的 S3 存储桶会随该服务商一起消失,而 nextxch 目前只能写入一个 S3 目标。Garage 是一款兼容 S3 的存储(AGPL-3.0),专为通过互联网连接的多个数据中心而设计(项目要求:延迟最多 200 ms,带宽至少 50 Mbit/s)。当 replication_factor = 3 时,每个区域各有一份副本;只要仅有一个区域故障,读写就能继续(写入法定人数为 2)。如果只有两个区域,失去一个区域将使存储变为只读,因此见证节点也保存一份完整副本。[60][61]

未经测试,且无 Object Lock
Garage 既不支持 S3 Object Lock,也不支持版本控制。启用 WORM 保留(governance/compliance)的域会使 nextxch 发送保留头,因此需要支持 Object Lock 的 S3;GoBD 副本请参见第 09 章。本指南未对在 Garage 上运行 nextxch 进行负载测试:投入生产前请先用测试数据验证。[62]

分步说明

  1. 订购服务器:A = Hetzner Cloud(端口 25/465 默认被封锁;满一个月且首张账单付款后可申请解封,587 开放),B = netcup VPS(在 SCP 中移除防火墙策略 "netcup Mail block"),C = STRATO VPS 作为见证节点(见证节点不需要端口 25)。[13][26]
  2. 设置 PTR:IP A 指向 mx1.example.com(Hetzner Console),IP B 指向 mx2.example.com(netcup SCP)。[14][27]
  3. 通过 cloud-init 进行基础安装(Debian 13):deploy 用户、Docker、WireGuard,禁止密码登录。
  4. A、B、C 之间的 WireGuard 网状网络 10.88.0.0/24;仅对对端 IP 开放 UDP 51820。[47]
  5. 三个站点都运行 etcd(选举超时至少为 10 × RTT),然后在 A 和 B 上运行 Patroni + PostgreSQL 18。[50][53]
  6. 在三个站点上都部署 Garage,应用包含三个区域的布局,并创建存储桶和密钥。[61]
  7. 在 A 和 B 上通过 Docker Compose 运行 nextxch(Cluster:Enabled=true、多主机连接字符串、S3 指向本地 Garage)。在 /admin/ 中设置主机名 mail.example.com,并为 mail、mx1、mx2 签发 ACME 证书。
  8. 按第 02 章配置 DNS,在 /admin/ 中检查 MTA-STS MX 列表,并在 /admin/ 的“域”下启用 DNS 故障转移(仅集群主节点执行),或者改用在 etcd 锁下运行的脚本。
  9. 按第 09 章进行备份并测试还原。然后演练一次故障:强制停止站点 A,并按第 08 章操作。
cloud-init(用户数据)
#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 ]
WireGuard:/etc/wireguard/wg0.conf
# /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.
etcd:/etc/default/etcd
# /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.).
Patroni:/etc/patroni/config.yml
# /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
Garage:/etc/garage.toml
# /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
nextxch:每个站点一个 docker-compose.yml
# 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 令牌使用服务器密钥环加密,且不会再次显示。

内置 DNS 故障转移代理
# 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.
为什么被隔离的节点不会更改 DNS
leader 锁只能通过与 PostgreSQL 主节点的连接获得。在连接字符串中设置 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]

nextxch-dns-failover.sh
#!/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
systemd 单元
# /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"]
架构配置 M
Proxmox:不支持跨服务商集群
根据 Proxmox 的说明,corosync 需要低于 5 ms 的延迟(局域网级别);超过约 10 ms 且节点多于三个时,基本无法正常工作。因此不建议组建横跨 Hetzner、netcup 和 STRATO 的单个 Proxmox VE 集群。正确的模式是:每家服务商各有一台独立的 Proxmox 主机或一个集群,高可用由应用层提供(Patroni、Garage、nextxch 集群)。QDevice (corosync-qnetd) 用于单站点的双节点集群,不受延迟限制。Proxmox Datacenter Manager(自 2025-12-04 起为 1.0 版)可统一管理多个集群。[45]

分步说明

  1. 三家服务商的三台虚拟机(或 Proxmox 客户机),Debian 版本相同,PTR 分别指向 mx1/mx2/mx3。
  2. 包含三个主机的 Ansible 清单,一个 playbook 部署全部组件(WireGuard、etcd、Patroni、Garage、nextxch)。
  3. 沿用方案 S 的 Patroni 配置并增加第三台主机;synchronous_mode: on,三个数据库时可选 quorum。[54]
  4. 包含三个主机的连接字符串、包含三个区域的 Garage 布局、三条 MX 记录,SPF 和 DNS 故障转移节点列表中各有三个 IP。
  5. Proxmox Backup Server 可选用于虚拟机镜像:数据存储位于本地或 S3(自 PBS 4.2 起官方支持 S3 后端);Proxmox 并未将通过 CIFS 挂载的 Storage Box 等网络共享列为数据存储类型。[46]
Ansible:inventory/ha.yml
# 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 }
Ansible:site.yml
# 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
架构配置 L

为什么不跨集群使用 CloudNativePG?

CloudNativePG 可以在其他 Kubernetes 集群中运行副本集群,但集群间的提升和降级是声明式的(字段 primary、promotionToken),即不会自动进行。若要实现自动的跨服务商故障转移,在三个站点上使用 Patroni 加 etcd 是更直接的方案。[52]

一个集群覆盖所有服务商?

Talos 提供 KubeSpan(跨站点的 WireGuard 全网状互联),k3s 提供 flannel 后端 wireguard-native。但控制平面 (etcd) 对广域网延迟很敏感。多个小型集群更易于运维,且各自独立发生故障。[51][49][50]

分步说明

  1. 按照配置 M 的方式构建数据层(三台运行 Patroni、etcd、Garage 的虚拟机)。
  2. 每家服务商一个 k3s 或 Talos 集群;通过 WireGuard(或 KubeSpan)与数据层建立网络连接。
  3. 入站端口:使用 LoadBalancer 类型的服务并设置 externalTrafficPolicy: Local(与存储库中一致),以便为 SPF/DMARC 保留发件人 IP;如果负载均衡器支持 PROXY protocol,请改用 Cluster__EnableProxyProtocol=true。Hetzner Cloud Load Balancer 支持 PROXY protocol 和任意 TCP 服务。[19]
  4. 出站端口 25:Pod 出口流量经由具有匹配 PTR 和 SPF 条目的固定 IP(出口网关,参见 deploy/kubernetes/README.md)。
  5. 按服务商设置 Helm values,通过流水线依次发布(第 07 章)。HPA(CPU 70% 时 2 到 10 个 Pod)和 PodDisruptionBudget 随代码库提供。
Helm:k8s/values-hetzner.yaml
# 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 Actions:Compose 站点
# .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
# .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 Actions:Kubernetes(L 方案)
# .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
故障转移作业不应放在 CI 中
计划的 CI 运行既不精确到秒,也不保证一定执行。因此 DNS 故障转移在各站点本地运行:作为 nextxch 内置代理,或作为运行脚本的 systemd 服务(第 04 章),流水线只负责部署。
邮件配置同样来自 Git
域、邮箱、列表、规则、SMTP 及其他设置同样以 YAML 形式保存在代码仓库中: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
站点 A 故障时的处理顺序
时间会发生什么用户会察觉到什么
0 sA 已失效。发往 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 恢复

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 副本中抽样打开。
pgBackRest:/etc/pgbackrest/pgbackrest.conf
# /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
rclone:Blob 复制和 GoBD 存储桶
# 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 CloudCX2324 GB40 GB€6.49
Hetzner CloudCX3348 GB80 GB€9.49
Hetzner CloudCX43816 GB160 GB€16.99
Hetzner CloudCX531632 GB320 GB€30.49
netcup VPSVPS 500 G12.524 GB64 GB€6.94
netcup VPSVPS 1000 G12.548 GB128 GB€12.18
netcup VPSVPS 2000 G12.5816 GB256 GB€22.62
netcup VPSVPS 4000 G12.51232 GB512 GB€38.12
netcup VPSVPS 8000 G12.51664 GB1024 GB€56.39
netcup Root ServerRS 1000 G12.548 GB128 GB€18.26
netcup Root ServerRS 2000 G12.5816 GB256 GB€34.20
netcup Root ServerRS 4000 G12.51232 GB512 GB€65.40
netcup Root ServerRS 8000 G12.51664 GB1024 GB€124.72
netcup Root ServerRS 12000 G12.52096 GB1536 GB€182.26
netcup Root ServerRS 16000 G12.524128 GB2048 GB€243.24
STRATO VPSVPS S12 GB60 GB€3.36
STRATO VPSVPS M24 GB120 GB€8.40
STRATO VPSVPS L48 GB240 GB€16.81
STRATO VPSVPS XL816 GB480 GB€32.77
STRATO VPSVPS XXL1224 GB720 GB€47.06
Hetzner Storage BoxBX11--1 TB€3.20
Hetzner Storage BoxBX21--5 TB€10.90
Hetzner Storage BoxBX31--10 TB€20.80
Hetzner Storage BoxBX41--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 为例)

  1. 下单前确认:能否设置 PTR?STRATO:VPS 的反向 DNS 在客户登录的“域”下设置,必须先存在 A/AAAA 记录,最长需 24 小时。端口 25:对于独立服务器,STRATO 文档说明出站被阻止,可通过支持解封;对于 VPS 未有相关规定的文档。STRATO 不提供故障转移 IP。[31][32][30]
  2. 将新主机加入 Ansible 清单,并为其分配独立的 WireGuard IP 和 Garage 区域,在所有站点上添加对等节点,然后运行 playbook。
  3. etcd:用 etcdctl member add 添加成员,并以 ETCD_INITIAL_CLUSTER_STATE=existing 启动。有四个数据库站点时,请保持 etcd 成员数为奇数。[48]
  4. Patroni 以副本身份启动并自动复制。Garage:使用新区域执行 garage layout assign,然后执行 garage layout apply。
  5. DNS:将 IP 添加到 SPF,新增一条优先级相同的 MX,将节点加入 DNS 故障转移列表(代理或脚本),为新的 MX 名称添加 TLSA 记录,设置新节点的 EHLO 名称和 PTR,并在证书中添加该名称。MTA-STS 策略会自动包含新的 MX(自动 MX 列表);随后发布 DNS 顾问给出的新 _mta-sts TXT 值。
  6. 将该站点加入流水线矩阵,并对新站点的故障进行一次测试。

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 日对照以下官方来源进行核查。无法核实的内容已在正文中注明。

  1. RFC 5321(SMTP):4.1.4 EHLO、4.5.4.1 重试、5.1 MX:https://www.rfc-editor.org/rfc/rfc5321.html
  2. RFC 7208 (SPF):4.6.4 DNS 查询限制,5.6 ip4/ip6:https://www.rfc-editor.org/rfc/rfc7208.html
  3. RFC 6376 (DKIM):3.1 选择器:https://www.rfc-editor.org/rfc/rfc6376.html
  4. RFC 8461(MTA-STS):3.2、4.1 mx 模式:https://www.rfc-editor.org/rfc/rfc8461.html
  5. RFC 7672(DANE for SMTP):3.1 TLSA 3 1 1:https://www.rfc-editor.org/rfc/rfc7672.html
  6. RFC 8314(隐式 TLS 465/993/995):https://www.rfc-editor.org/rfc/rfc8314.html
  7. RFC 2177(IMAP IDLE,每 29 分钟重新发出):https://www.rfc-editor.org/rfc/rfc2177.html
  8. Google:电子邮件发件人指南(正向和反向 DNS):https://support.google.com/a/answer/81126
  9. Hetzner 文档:Floating IPs:https://docs.hetzner.com/cloud/floating-ips/overview
  10. Hetzner 文档:IPv4 价格:https://docs.hetzner.com/general/others/ipv4-pricing
  11. Hetzner 文档:Robot 故障转移 IP:https://docs.hetzner.com/robot/dedicated-server/ip/failover
  12. Hetzner Cloud:成本优化套餐:https://www.hetzner.com/cloud/cost-optimized/
  13. Hetzner 文档:Cloud server FAQ(端口 25/465):https://docs.hetzner.com/cloud/servers/faq/
  14. Hetzner Docs:PTR 记录:https://docs.hetzner.com/networking/dns/record-types/ptr-record/
  15. Hetzner Storage Box(价格、协议):https://www.hetzner.com/storage/storage-box/
  16. Hetzner Object Storage(价格):https://www.hetzner.com/storage/object-storage/
  17. Hetzner Docs:Object Storage 概述:https://docs.hetzner.com/storage/object-storage/overview
  18. Hetzner Docs:Object Lock 与保留:https://docs.hetzner.com/storage/object-storage/howto-protect-objects/protect-object-lock-retention/
  19. Hetzner 文档:Load Balancer 常见问题:https://docs.hetzner.com/cloud/load-balancers/faq
  20. Hetzner 文档:Networks FAQ:https://docs.hetzner.com/cloud/networks/faq
  21. Hetzner Docs:DNS(控制台和 API):https://docs.hetzner.com/networking/dns/overview/
  22. netcup:VPS G12.5 价格:https://www.netcup.com/de/server/vps
  23. netcup:Root Server G12.5 价格:https://www.netcup.com/de/server/root-server
  24. netcup Helpcenter:IP 地址与故障转移:https://www.netcup.com/en/helpcenter/documentation/server/ip
  25. netcup:服务器扩展(故障转移 IP 价格):https://www.netcup.com/de/server/kvm-server-erweiterungen
  26. netcup 帮助中心:防火墙(策略"netcup Mail block"):https://www.netcup.com/en/helpcenter/documentation/server/firewall
  27. netcup Helpcenter:网络 / rDNS:https://www.netcup.com/en/helpcenter/documentation/server/network-server
  28. netcup:Storage Space (NFS):https://www.netcup.com/en/server/storage-space
  29. netcup Helpcenter:DNS API:https://www.netcup.com/en/helpcenter/documentation/domain/our-api
  30. STRATO:VPS Linux 价格:https://www.strato.de/server/linux-vserver/
  31. STRATO 常见问题:VPS 的反向 DNS:https://www.strato.de/faq/server/wie-kann-ich-reverse-dns-fuer-meinen-vps-vds-einrichten/
  32. STRATO 常见问题:独立服务器网络防火墙(端口 25):https://www.strato.de/faq/server/wie-stelle-ich-die-netzwerk-firewall-bei-meinem-windows-root-server-ein/
  33. Cloudflare:Load Balancing 附加组件价格:https://www.cloudflare.com/plans/network-cdn/
  34. Cloudflare 文档:Load Balancing 限制(健康检查间隔):https://developers.cloudflare.com/load-balancing/reference/limitations/
  35. Cloudflare 文档:Load Balancing DNS 记录(MX/SRV 仅限 Enterprise):https://developers.cloudflare.com/load-balancing/load-balancers/dns-records/
  36. Cloudflare 文档:代理模式(DNS-only):https://developers.cloudflare.com/load-balancing/understand-basics/proxy-modes/
  37. Cloudflare 文档:各套餐支持的 Spectrum 协议:https://developers.cloudflare.com/spectrum/protocols-per-plan/
  38. Cloudflare Docs:DNS TTL:https://developers.cloudflare.com/dns/manage-dns-records/reference/ttl/
  39. Cloudflare API:编辑 DNS 记录:https://developers.cloudflare.com/api/resources/dns/subresources/records/methods/edit/
  40. deSEC:域与最小 TTL:https://desec.readthedocs.io/en/latest/dns/domains.html
  41. Akamai/Linode:IP 故障转移(BGP,同一数据中心):https://techdocs.akamai.com/cloud-computing/docs/configure-failover-on-a-compute-instance
  42. Akamai:NodeBalancer 价格:https://www.akamai.com/cloud/pricing/nodebalancers
  43. Vultr:BGP 要求:https://docs.vultr.com/configuring-bgp-on-vultr
  44. RIPE NCC:申请 AS 号:https://www.ripe.net/manage-ips-and-asns/as-numbers/request-an-as-number/
  45. Proxmox VE:Cluster Manager(corosync 延迟、QDevice):https://pve.proxmox.com/wiki/Cluster_Manager
  46. Proxmox Backup Server:数据存储(含 S3):https://pbs.proxmox.com/docs/storage.html
  47. WireGuard:https://www.wireguard.com/
  48. k3s:使用嵌入式 etcd 的高可用:https://docs.k3s.io/datastore/ha-embedded
  49. k3s:分布式混合或多云集群:https://docs.k3s.io/networking/distributed-multicloud
  50. etcd:调优(心跳、选举超时):https://etcd.io/docs/v3.5/tuning/
  51. Talos Linux:KubeSpan:https://docs.siderolabs.com/talos/v1.11/networking/kubespan
  52. CloudNativePG:副本集群:https://cloudnative-pg.io/docs/current/replica_cluster
  53. Patroni:动态配置(ttl、loop_wait、retry_timeout):https://patroni.readthedocs.io/en/latest/dynamic_configuration.html
  54. Patroni:复制模式(synchronous_mode、strict):https://patroni.readthedocs.io/en/latest/replication_modes.html
  55. Patroni:DCS failsafe 模式:https://patroni.readthedocs.io/en/latest/dcs_failsafe_mode.html
  56. PostgreSQL 18:同步复制:https://www.postgresql.org/docs/current/warm-standby.html#SYNCHRONOUS-REPLICATION
  57. PostgreSQL 18:shared_buffers:https://www.postgresql.org/docs/current/runtime-config-resource.html
  58. pgBackRest(S3 和 SFTP 存储库):https://pgbackrest.org/
  59. CISA:数据备份选项 (3-2-1):https://www.cisa.gov/sites/default/files/publications/data_backup_options.pdf
  60. Garage:项目主页(多数据中心,200 ms / 50 Mbps):https://garagehq.deuxfleurs.fr/
  61. Garage:配置(replication_factor、仲裁):https://garagehq.deuxfleurs.fr/documentation/reference-manual/configuration/
  62. Garage:S3 兼容性(无 Object Lock,无版本控制):https://garagehq.deuxfleurs.fr/documentation/reference-manual/s3-compatibility/
  63. Npgsql:故障转移与负载均衡(多主机):https://www.npgsql.org/doc/failover-and-load-balancing.html
  64. Mermaid 12.2.0 (MIT),随本站一同托管:https://www.npmjs.com/package/mermaid