複数のプロバイダーにまたがる高可用性の nextxch
プロバイダー全体がオフラインになっても、メールは流れ続けるべきです。高価なエニーキャストも、エンタープライズ向けロード バランサーも不要で、Git からデプロイできます。このガイドでは、Hetzner、netcup、STRATO の低コスト サーバー上の 3 つの構成を紹介し、障害時に何が起こるかを秒単位で説明し、リソースとコストを算出します。プロバイダーに関する記述にはすべて確認日付きの出典へのリンクを付け、nextxch に関する記述にはすべてコード内のソース ファイル名を記載しています。
01 · nextxch が高可用性のために提供するもの (コードで確認済み)
nextxch はアクティブ / アクティブ構成に対応しています。すべてのノードが SMTP、IMAP、JMAP、ActiveSync、HTTPS を受け付け、パッシブ ノードもスティッキー セッションもありません。共有状態は PostgreSQL が保持し、メッセージ自体は SHA-256 でアドレス指定された .eml オブジェクトとして BLOB ストアに保存されます。
| 機能 | 方法 | コード内のソース |
|---|---|---|
| Redis 不要のクラスター バックプレーン | 新着メール、フラグ、expunge、キューなどのイベントに PostgreSQL の LISTEN/NOTIFY (チャネル nextxch_cluster_events) を使用。UNLOGGED テーブル cluster_ephemeral と cluster_nodes。ハートビートは 15 秒ごと、ハートビートが 60 秒ないノードは除外 | src/Nextxch.Storage/Cluster/PostgresClusterCoordinator.cs |
| リーダー タスク | pg_try_advisory_lock: ちょうど 1 つのノードが 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 (Content-MD5 対応の独自 SigV4 クライアント)、またはハイブリッド (ローカル NVMe キャッシュ + S3)。Object Lock モードは Normal、Governance、Compliance と訴訟ホールド | 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 (DB なし)、/health/ready (DB に到達可能、それ以外は 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 チャート、deb/rpm、Windows MSI | deploy/kubernetes/, deploy/helm/nextxch/, deploy/packages/ |
| ライセンス | カウントされるのはアクティブなメールボックスで、ノードやポッドではありません。ライセンスの状態は 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 ストアを拠点間でレプリケートし、自動的に切り替えることを意味します。3 つの構成はまさにそれを実現します。02 · 高可用性を容易にするメール特有のルール
受信: MX には標準でフェイルオーバーが組み込まれています
送信側は MX レコードを優先度順に並べ、すべてのアドレスを試行する必要があります。優先度が同じ場合は、順序をランダム化しなければなりません。[1] 宛先に到達できない場合、再試行間隔は最低 30 分、断念までの時間は最低 4~5 日とすべきです。[1]
送信: IP ごとに SPF と PTR
SPF には、すべてのサイトのすべての送信 IP を記載する必要があります。ip4/ip6 は DNS ルックアップ 10 回の上限にカウントされません。[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 が役立つのは 1 つのプロバイダーの内部だけです。プロバイダー全体の障害に対しては、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 のみ) | プロバイダーをまたいで | 月額 USD 5 から | ヘルス チェック (TCP、SMTP を含む) は Enterprise 以外で 15 秒ごと。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) | 1 プロジェクト、1 ネットワーク ゾーン | 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 共有 | 1 つのデータセンター、2 台の Linode | USD 5 から (Nanode) | BGP ベース (lelastic)、同一データ センター内のみ。NodeBalancer Basic USD 10、TCP/HTTP [41][42] |
| Anycast / 独自 BGP | global | 高 | 独自の ASN (会員資格またはスポンサー LIR 経由の RIPE 契約) と独自のアドレス空間 (Vultr では最低 /24 IPv4 または /48 IPv6)。ほとんどの nextxch 環境には過大です。[43][44] |
04 · 構成 S: 2 サイト + witness
中小企業向けの構成です。サイト A (例: Hetzner Cloud) とサイト B (例: netcup VPS) では、それぞれ nextxch、Patroni 配下の PostgreSQL、etcd メンバー 1 つ、Garage ゾーン 1 つが稼働します。3 社目のプロバイダー (例: STRATO) に置く小規模な witness C が、3 つ目の etcd メンバー、3 つ目の Garage コピー、DNS フェイルオーバー チェッカーを保持します。3 拠点はすべて 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
BLOB ストアに Garage を選ぶ理由
単一プロバイダーの S3 バケットは、そのプロバイダーとともに失われます。また、nextxch が現在書き込めるのは1 つの S3 ターゲットだけです。Garage は、インターネット経由で複数のデータ センターにまたがって使うために作られた S3 互換ストア (AGPL-3.0) です (プロジェクトの要件: レイテンシー最大 200 ms、帯域幅最低 50 Mbit/s)。replication_factor = 3 ではゾーンごとに 1 つのコピーを持ち、障害が 1 ゾーンだけなら読み取りと書き込みは継続します (書き込みクォーラム 2)。ゾーンが 2 つしかない場合、1 ゾーンを失うとストアが読み取り専用になるため、witness も完全なコピーを保持します。[60][61]
ステップ バイ ステップ
- サーバーを注文: A = Hetzner Cloud (ポート 25/465 は既定でブロック。1 か月経過し初回請求の支払い後にブロック解除を申請。587 は開放済み)、B = netcup VPS (SCP でファイアウォール ポリシー "netcup Mail block" を削除)、C = witness としての STRATO VPS (witness にはポート 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。UDP 51820 はピアの IP に対してのみ開放します。[47]
- 3 拠点すべてに etcd を導入し (選出タイムアウトは最低 10 × RTT)、次に A と B に Patroni + PostgreSQL 18 を導入します。[50][53]
- 3 拠点すべてに Garage を導入し、3 ゾーンのレイアウトを適用して、バケットとキーを作成します。[61]
- A と B で Docker Compose により nextxch を実行します (Cluster:Enabled=true、マルチホスト接続文字列、S3 はローカルの Garage を指定)。/admin/ でホスト名 mail.example.com を設定し、mail、mx1、mx2 の ACME 証明書を発行します。
- DNS は第 02 章のとおりに設定し、/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 フェイルオーバー" カード)。各ノードは 30 秒ごとに、すべてのノードのポート 25 の SMTP バナーと HTTPS /health をパブリック IP 経由でチェックします。動作するのはクラスター リーダーのみです。リーダーは PostgreSQL のアドバイザリ ロックを保持し、全ノードの判定を多数決で統合して、設定された名前の A/AAAA レコードを Hetzner DNS (Cloud API) または Cloudflare API 経由で書き込みます。ヒステリシス: N 回連続で失敗するとノードは DNS から外れ、M 回連続で正常になると復帰します。最後のノードが削除されることはありません。すべての変更はイベント ログに記録され、アラート ルール "DNS フェイルオーバー: ノードを除外" が運用アラート経由で通知します。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 秒ごとに各ノードの /health/ready をパブリック IP 経由でチェックし、Cloudflare API (dns_records に対する PATCH/POST/DELETE) で A レコードを調整します。スクリプトは各サイトで etcdctl lock の下で実行されるため、動作するのは 1 つのコピーのみで、しかも etcd の過半数を持つ側だけです。そのため、切り離されたサイトは DNS を変更できなくなります。Hetzner DNS や deSEC の場合は、3 つの 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: 完全な 3 拠点 (Proxmox または素の VM)
構成 S と同様ですが、3 拠点すべてで nextxch と PostgreSQL レプリカが稼働します。1 拠点に障害が発生しても 2 つのデータベースと 2 つの nextxch ノードが残り、synchronous_mode を使えば同期レプリケーションも維持されます。優先度が同じ 3 つの 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"]
ステップ バイ ステップ
- 3 つのプロバイダーに 3 台の VM (または Proxmox ゲスト)。Debian のバージョンを揃え、PTR はそれぞれ mx1/mx2/mx3 に設定。
- 3 つのホストを含む Ansible インベントリと、すべて (WireGuard、etcd、Patroni、Garage、nextxch) に対応する 1 つの Playbook。
- 構成 S の Patroni 設定に 3 台目のホストを追加。synchronous_mode: on、データベースが 3 つの場合は任意でクォーラム。[54]
- 3 つのホストを含む接続文字列、3 つのゾーンを持つ Garage レイアウト、3 つの MX レコード、SPF と DNS フェイルオーバー ノード リストに 3 つの IP。
- VM イメージ用に Proxmox Backup Server を任意で利用可能: データストアはローカルまたは S3 (S3 バックエンドは PBS 4.2 以降で正式サポート)。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: 3 社のプロバイダーでの Kubernetes
多数のメールボックスと水平スケーリング向けの構成です。まず重要な点として、k3s は複数のクラウドにまたがるクラスターでの組み込み etcd を明示的にサポートしていません。サーバー ノードは 1 か所にとどめるべきです。そのため、プロバイダーごとに 1 つの Kubernetes クラスター (それぞれ k3s または Talos) を用意し、各クラスターで付属の Helm チャートから nextxch のデプロイを実行します。データ層 (Patroni + etcd + Garage) は、構成 M と同様に WireGuard で接続された専用 VM 上で稼働し、3 つのクラスターすべてから利用されます。[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) であり、自動ではありません。プロバイダー間の自動フェイルオーバーには、3 拠点にまたがる etcd と Patroni の方がより直接的な解決策です。[52]
ステップ バイ ステップ
- データ層は構成 M と同様に構築します (Patroni、etcd、Garage を備えた 3 台の VM)。
- プロバイダーごとに 1 つの k3s または Talos クラスター。データ層へのネットワーク接続は WireGuard (または KubeSpan) 経由。
- 受信ポート: 種類 LoadBalancer のサービスを
externalTrafficPolicy: Localで使用し (リポジトリと同様)、SPF/DMARC のために送信元 IP を保持します。PROXY プロトコルに対応したロード バランサーを使用する場合は、代わりにCluster__EnableProxyProtocol=trueを使用します。Hetzner Cloud Load Balancer は PROXY プロトコルと任意の TCP サービスに対応しています。[19] - 送信ポート 25: PTR と SPF エントリが一致する固定 IP 経由での Pod の Egress (deploy/kubernetes/README.md に記載の Egress ゲートウェイ)。
- プロバイダーごとの Helm 値、パイプラインで順番にロールアウト (第 07 章)。HPA (CPU 70% で 2-10 ポッド) と 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 フェイルオーバー スクリプト。パイプラインは常に 1 サイトだけをロールアウトし、/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 秒 | etcd 内の A のリーダー キーは 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 は 2 つ目のサイトがコミットを受け取った時点で初めてコミットを確認します。つまり 250 OK で確認されたメッセージは 2 つのサイトに存在します。その代償として、各コミットはサイト間で少なくとも 1 往復を待ちます。synchronous_mode がない場合、障害発生直前の数秒間が失われる可能性があります。synchronous_mode_strict: false の場合、レプリカが残っていないと Patroni は非同期にフォールバックするため、メールの受け入れは継続されます。[56][54]
RTO
データベースは約 30~60 秒 (Patroni の既定値)、DNS の切り替えは約 1 分 + TTL、受信メールは 2 つ目の MX または送信側の再試行により待ち時間なしで処理されます。これらの時間は既定値から導いたもので、実測ではありません。ご自身のテスト (サイトを強制停止) で確認してください。
netcup (サイト B、レプリカ) がオフラインになる
A のプライマリは稼働を続けます。同期パートナーがない場合、Patroni は非同期に切り替え (strict = false)、フェイルオーバー ジョブが IP B を削除し、受信メールは mx1 経由で流れます。Garage は A と C で稼働を続けます。
A と B の間のリンクだけがダウンしている
witness C に到達できる側が etcd の過半数を維持し、プライマリのまま残るか、プライマリになります。もう一方の側は過半数を失い、Patroni がそこにあるプライマリを降格させます (failsafe_mode はすべてのメンバーにまだ到達できる場合にのみ有効)。また、そちら側では DNS フェイルオーバーは動作しません。組み込みエージェントはプライマリに到達できずリーダー ロックを取得できず、スクリプトは etcd ロックを取得できません。スプリット ブレインは発生しません。[55]
フェイルバック
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 ルール (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: 継続的な WAL アーカイブ (ポイントインタイム リカバリー) を備えた pgBackRest。リポジトリ 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 で命名され、変更されることはありません。1 時間ごとのコピーには
rclone copy(sync ではないため削除は行わない) で十分です。 - GoBD アーカイブ: Hetzner Object Storage は GOVERNANCE モードと COMPLIANCE モードの Object Lock およびバージョン管理に対応しています。Object Lock はバケットの作成時に有効にする必要があります。既定の保持期間 (compliance) を指定してバケットを作成すると、コピーされたすべてのオブジェクトが自動的にロックされます。または、nextxch からこのバケットに直接書き込むこともできます (S3 またはハイブリッド バックエンド) が、その場合 BLOB の可用性は Hetzner に依存します。この場合、WORM モードが構成されている (または
Storage:S3:ObjectLockEnabled) と、nextxch は起動時に存在しないバケットを Object Lock 付きで作成します。GoBD アーカイブの「S3 バケットを確認」で、到達性、バージョン管理、Object Lock モード、既定の保持期間を確認できます。[18][17] - 復元テスト: 少なくとも四半期ごとに pgBackRest をテスト VM に復元し、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 · スケーリング: ユーザー、ストレージ、ポッド
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 | 水平方向に | ステートレス。サイトあたりのノードまたはポッドを増やします (HPA)。スティッキー セッションは不要です。 |
| PostgreSQL プライマリ | 垂直 | すべての書き込みは 1 台のプライマリに送られます。ノードを増やすのではなく、RAM を増やし、より高速なディスク (IOPS) を使用してください。shared_buffers の初期値は RAM の 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 と witness には共有 vCPU プランを使用します。専用コアは、データベースの CPU 負荷で必要性が示された時点で導入してください。
- netcup での 12 か月契約 (価格ページによると月額料金より 13% 安い)。[22]
- 有料のロード バランサーの代わりに、組み込みエージェント (または独自のスクリプト) による DNS フェイルオーバー。
- BLOB 向けの Zstd 圧縮 (Storage:Compression) と SHA-256 重複排除。
ここは節約しない
- PostgreSQL ノードの RAM: キャッシュされていないものはすべて、フォルダー表示のたびにディスク IO を発生させます。
- PostgreSQL 用のディスク IOPS (NVMe)。WAL の書き込みがすべての配信のレイテンシを左右します。
- 3 つ目の拠点: これがないと安全な自動フェイルオーバーは実現できません。
- 独立した保存先でテスト済みのバックアップ。
登録済みの価格 (月額税抜、2026 年 11 月 2 日時点)。netcup と STRATO は 19% の付加価値税込みの価格を掲載しているため、ここでは 1.19 で割っています。Hetzner ではプライマリ IPv4 の €0.50 を加算しています。確認日に Hetzner はすべてのクラウド プランを「現在利用不可」と表示していましたが、在庫の問題か表示上の問題かは判断できませんでした。Hetzner ボリュームの価格は公式ページから読み取れなかったため、計算ツールには含まれていません。価格は変動します: 注文前にプロバイダーに確認してください。[12][10][22][23][30][15][16]
| プロバイダー | プラン | vCPU | RAM | ディスク | € (税抜) / 月 |
|---|---|---|---|---|---|
| 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 ルート サーバー | RS 1000 G12.5 | 4 | 8 GB | 128 GB | €18.26 |
| netcup ルート サーバー | RS 2000 G12.5 | 8 | 16 GB | 256 GB | €34.20 |
| netcup ルート サーバー | RS 4000 G12.5 | 12 | 32 GB | 512 GB | €65.40 |
| netcup ルート サーバー | RS 8000 G12.5 | 16 | 64 GB | 1024 GB | €124.72 |
| netcup ルート サーバー | RS 12000 G12.5 | 20 | 96 GB | 1536 GB | €182.26 |
| netcup ルート サーバー | 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 の RAM = DB サイズの 25%、最低 2 GB、2/4/8/16/32/64/96/128 GB に切り上げ。PostgreSQL のディスク = DB サイズの 2 倍 (データ、WAL、余裕分)。
- nextxch プロセス: 1 GB + 開始済み 1,000 メールボックスごとに 1 GB (リポジトリ内の Kubernetes の制限は Pod あたり 1 GiB)。etcd + Garage + OS: 1 GB。witness: RAM 2 GB。オペレーティング システム: ディスク 20 GB。
- Garage replication_factor 3: すべてのゾーン (拠点または witness) がメール全量を保存します。
- 構成 L: プロバイダーごとに少なくとも 2 ポッド、1,500 メールボックスあたり 1 ポッド、ワーカーは 8 GB。データ VM が PostgreSQL、etcd、Garage ゾーンを保持します。
- 必要なバックアップ容量 = DB サイズの 3 倍 (フル バックアップ 2 回分 + WAL) + メール容量。目安: 収まる最小の Hetzner Storage Box。
- ライセンス価格は、このサイトの現行価格表に基づきます (該当する段階の価格がすべてのメールボックスに適用、年額 / 12)。Cloudflare は USD で表示しており、ユーロの合計には加算していません。
13 · 別のプロバイダーの追加 (例: STRATO)
- 注文前に: PTR は設定できますか? STRATO: VPS の逆引き DNS はカスタマー ログインのドメイン設定で行い、事前に A/AAAA レコードが必要で、反映まで最大 24 時間かかります。ポート 25: ルート サーバーについて STRATO は送信がブロックされており、サポート経由で解除すると記載しています。VPS については規定の記載がありません。STRATO はフェイルオーバー IP を提供していません。[31][32][30]
- 新しいホストを独自の WireGuard IP と Garage ゾーンとともに Ansible インベントリに追加し、すべてのサイトにピアを追加して、Playbook を実行します。
- etcd:
etcdctl member addでメンバーを追加し、ETCD_INITIAL_CLUSTER_STATE=existing で起動します。データベース サイトが 4 つの場合は、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 · 制限とギャップ (率直に)
このガイドの執筆中にコード内で見つかったものです。現在までに 4 つのギャップが解消されています: 複数の 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 保存先が 1 つのみ | 異なるプロバイダーの 2 つの S3 ストアへの二重書き込みは行いません。Object Lock 機能には Object Lock 対応の S3 が必要です。 | 3 拠点にまたがる Garage と、Object Lock バケットへの rclone copy。 |
| Object Lock バケット (解決済み) | WORM モードが構成されている場合、サーバーは起動時に存在しないバケットを Object Lock 付きで作成します。Object Lock は作成時にしか有効にできないため、Object Lock のない既存のバケットは警告およびアラートとして報告されます。[18] | GoBD アーカイブの "S3 バケットを確認" でいつでも確認できます。 |
| 組み込みのバックアップなし | サーバーには、データベースと BLOB のバックアップ サービスはありません。 | pgBackRest と rclone (第 09 章)。 |
| リード レプリカなし | すべてのクエリはプライマリに送られます。リード レプリカは開発計画で「要望に応じて」とされているだけです。 | PostgreSQL のプライマリを垂直方向にスケールします。 |
| 実測によるサイジング ベンチマークはありません | 計算ツールの RAM と DB のサイズは想定値です。 | ご自身の測定値で上書きしてください。 |
FAQ
nextxch は複数のプロバイダーにまたがってアクティブ/アクティブで運用できますか?
はい。Cluster:Enabled=true と PostgreSQL を使用すると、任意の数の nextxch ノードが LISTEN/NOTIFY、UNLOGGED テーブル、アドバイザリー ロックを通じて状態を共有します。すべてのノードが同じ PostgreSQL プライマリと同じ S3 BLOB ストアに到達できる必要があり、プロバイダーをまたぐ場合は WireGuard ネットワーク経由で接続します。
ロード バランサーやエニーキャストは必要ですか?
いいえ。受信メールには、同じ優先度を持つ複数の MX レコードがあれば十分です (RFC 5321 セクション 5.1)。IMAP、送信、HTTPS には TTL の短い名前が 1 つ必要で、その A レコードはヘルス チェック ジョブが管理します。Anycast/BGP も可能ですが複雑です。
最低いくつのサイトが必要ですか?
3 票: 2 つの完全なサイトと小規模な witness (etcd、Garage ゾーン、フェイルオーバー チェッカー)、または 3 つの完全なサイト。サイトが 2 つだけでは、相手側が障害を起こしたのか回線が切れただけなのかを、どちらの側も安全に判断できません (スプリット ブレイン)。
プロバイダーがオフラインになるとメールは失われますか?
送信側は少なくとも 4 ~ 5 日間再試行を続けます (RFC 5321 セクション 4.5.4.1)。Patroni synchronous_mode と Garage replication_factor 3 により、250 OK で受領確認されたメッセージはすでに 2 つ目の拠点に保存されています。
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 (SMTP 向け DANE): 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 Docs: Floating IP: https://docs.hetzner.com/cloud/floating-ips/overview
- Hetzner Docs: IPv4 の価格: https://docs.hetzner.com/general/others/ipv4-pricing
- Hetzner Docs: Robot フェイルオーバー IP: https://docs.hetzner.com/robot/dedicated-server/ip/failover
- Hetzner Cloud: コスト最適化プラン: https://www.hetzner.com/cloud/cost-optimized/
- Hetzner Docs: クラウド サーバー 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 Docs: Load Balancer FAQ: https://docs.hetzner.com/cloud/load-balancers/faq
- Hetzner Docs: Networks FAQ: https://docs.hetzner.com/cloud/networks/faq
- Hetzner Docs: DNS (Console と 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 ヘルプセンター: IP アドレスとフェイルオーバー: https://www.netcup.com/en/helpcenter/documentation/server/ip
- netcup: サーバー拡張 (フェイルオーバー IP の価格): https://www.netcup.com/de/server/kvm-server-erweiterungen
- netcup Helpcenter: ファイアウォール (ポリシー "netcup Mail block"): https://www.netcup.com/en/helpcenter/documentation/server/firewall
- netcup ヘルプセンター: ネットワーク / rDNS: https://www.netcup.com/en/helpcenter/documentation/server/network-server
- netcup: Storage Space (NFS): https://www.netcup.com/en/server/storage-space
- netcup ヘルプセンター: DNS API: https://www.netcup.com/en/helpcenter/documentation/domain/our-api
- STRATO: VPS Linux の価格: https://www.strato.de/server/linux-vserver/
- STRATO FAQ: VPS のリバース DNS: https://www.strato.de/faq/server/wie-kann-ich-reverse-dns-fuer-meinen-vps-vds-einrichten/
- STRATO FAQ: ルート サーバーのネットワーク ファイアウォール (ポート 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 Docs: Load Balancing の制限 (ヘルス チェック間隔): https://developers.cloudflare.com/load-balancing/reference/limitations/
- Cloudflare Docs: Load Balancing の DNS レコード (MX/SRV は Enterprise のみ): https://developers.cloudflare.com/load-balancing/load-balancers/dns-records/
- Cloudflare Docs: プロキシ モード (DNS のみ): https://developers.cloudflare.com/load-balancing/understand-basics/proxy-modes/
- Cloudflare Docs: プラン別の 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 による HA: 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: Data Backup Options (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