運用ガイド Hetzner · netcup · STRATO · 2026 年 11 月 2 日時点

複数のプロバイダーにまたがる高可用性の 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
証明書と DKIMACME 証明書と 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
実際のクライアント 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 チャート、deb/rpm、Windows MSIdeploy/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)。

唯一の必須要件
すべてのノードには、常に書き込み可能な PostgreSQL プライマリが1 つと、共有 BLOB ストアが 1 つだけ必要です。データベースに到達できない場合、/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]

DKIM、MTA-STS、DANE

DKIM キーはクラスター データベースに格納され、1 組の DNS 設定ですべてのノードに対応します。セレクターにより複数のキーを並行して使用できます。[3] MTA-STS ポリシーにはすべての MX ホストを記載する必要があります。ワイルドカードは左端のラベルのみを対象とします。[4] DANE には MX ホストごとに 1 つの TLSA レコード (3 1 1 を推奨) と、DNSSEC 署名済みのゾーンが必要です。[5]

クライアント: IMAP、送信、HTTPS

メール クライアントには MX のようなフェイルオーバーがありません。クライアントは 1 つの名前 (ここでは mail.example.com) に接続し、その A レコードはヘルス チェック ジョブが管理します。IDLE 接続は少なくとも 29 分ごとに再発行され、その際に正常なノードに接続されます。[7][6]

2 サイト用の 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 が役立つのは 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.90Robot API による切り替えは 90 から 110 秒、Hetzner 内のみ [11]
netcup フェイルオーバー IP同じ netcup ロケーション月額 €5.00 (税込)SCP でいつでも無料で再ルーティング可能。ただし netcup 内に限る [24][25]
Akamai/Linode の IP 共有1 つのデータセンター、2 台の LinodeUSD 5 から (Nanode)BGP ベース (lelastic)、同一データ センター内のみ。NodeBalancer Basic USD 10、TCP/HTTP [41][42]
Anycast / 独自 BGPglobal高独自の ASN (会員資格またはスポンサー LIR 経由の RIPE 契約) と独自のアドレス空間 (Vultr では最低 /24 IPv4 または /48 IPv6)。ほとんどの nextxch 環境には過大です。[43][44]
witness が必要な理由
拠点が 2 つの場合、どちらの拠点も、相手が障害を起こしたのか回線が切れただけなのかを判別できません。両方が自らを昇格させると、2 つの食い違ったデータベースが生じます (スプリット ブレイン)。etcd は多数決で判断し、3 メンバーのうち 2 つで十分です。したがって、3 つの事業者に 3 つの投票権を置き、3 つ目は小規模な witness でもかまいません。[48][53]

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
アーキテクチャ 構成 S

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]

未テスト、Object Lock なし
Garage は S3 Object Lock にもバージョニングにも対応していません。WORM 保持 (governance/compliance) が設定されたドメインでは nextxch が保持ヘッダーを送信するため、Object Lock 対応の S3 が必要です。GoBD コピーについては第 09 章を参照してください。このガイドのために Garage 上での nextxch の負荷テストは実施していません。本番運用の前にテスト データで検証してください。[62]

ステップ バイ ステップ

  1. サーバーを注文: A = Hetzner Cloud (ポート 25/465 は既定でブロック。1 か月経過し初回請求の支払い後にブロック解除を申請。587 は開放済み)、B = netcup VPS (SCP でファイアウォール ポリシー "netcup Mail block" を削除)、C = witness としての STRATO VPS (witness にはポート 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。UDP 51820 はピアの IP に対してのみ開放します。[47]
  5. 3 拠点すべてに etcd を導入し (選出タイムアウトは最低 10 × RTT)、次に A と B に Patroni + PostgreSQL 18 を導入します。[50][53]
  6. 3 拠点すべてに Garage を導入し、3 ゾーンのレイアウトを適用して、バケットとキーを作成します。[61]
  7. A と B で Docker Compose により nextxch を実行します (Cluster:Enabled=true、マルチホスト接続文字列、S3 はローカルの Garage を指定)。/admin/ でホスト名 mail.example.com を設定し、mail、mx1、mx2 の ACME 証明書を発行します。
  8. DNS は第 02 章のとおりに設定し、/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 フェイルオーバー" カード)。各ノードは 30 秒ごとに、すべてのノードのポート 25 の SMTP バナーと HTTPS /health をパブリック IP 経由でチェックします。動作するのはクラスター リーダーのみです。リーダーは PostgreSQL のアドバイザリ ロックを保持し、全ノードの判定を多数決で統合して、設定された名前の A/AAAA レコードを Hetzner DNS (Cloud API) または Cloudflare API 経由で書き込みます。ヒステリシス: N 回連続で失敗するとノードは DNS から外れ、M 回連続で正常になると復帰します。最後のノードが削除されることはありません。すべての変更はイベント ログに記録され、アラート ルール "DNS フェイルオーバー: ノードを除外" が運用アラート経由で通知します。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 を変更しない理由
リーダー ロックは PostgreSQL プライマリへの接続を通じてのみ存在します。接続文字列に 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]

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: 完全な 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"]
アーキテクチャ 構成 M
Proxmox: プロバイダーをまたぐクラスターは不可
Proxmox によると、corosync には 5 ms 未満のレイテンシ (LAN レベル) が必要で、約 10 ms を超え、かつノードが 3 つを超えると動作する可能性は低くなります。そのため、Hetzner、netcup、STRATO にまたがる単一の Proxmox VE クラスターは推奨されません。適切なパターンは、プロバイダーごとに独立した Proxmox ホストまたはクラスターを置き、高可用性はアプリケーション (Patroni、Garage、nextxch クラスター) で実現する構成です。QDevice (corosync-qnetd) は 1 拠点の 2 ノード クラスター向けで、レイテンシ制限の対象外です。Proxmox Datacenter Manager (2025-12-04 から 1.0) は複数のクラスターをまとめて管理します。[45]

ステップ バイ ステップ

  1. 3 つのプロバイダーに 3 台の VM (または Proxmox ゲスト)。Debian のバージョンを揃え、PTR はそれぞれ mx1/mx2/mx3 に設定。
  2. 3 つのホストを含む Ansible インベントリと、すべて (WireGuard、etcd、Patroni、Garage、nextxch) に対応する 1 つの Playbook。
  3. 構成 S の Patroni 設定に 3 台目のホストを追加。synchronous_mode: on、データベースが 3 つの場合は任意でクォーラム。[54]
  4. 3 つのホストを含む接続文字列、3 つのゾーンを持つ Garage レイアウト、3 つの MX レコード、SPF と DNS フェイルオーバー ノード リストに 3 つの IP。
  5. VM イメージ用に Proxmox Backup Server を任意で利用可能: データストアはローカルまたは S3 (S3 バックエンドは PBS 4.2 以降で正式サポート)。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: 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
アーキテクチャ構成 L

クラスターをまたぐ CloudNativePG を使わない理由

CloudNativePG は他の Kubernetes クラスターでレプリカ クラスターを実行できますが、クラスター間の昇格と降格は宣言的 (フィールド primary、promotionToken) であり、自動ではありません。プロバイダー間の自動フェイルオーバーには、3 拠点にまたがる etcd と Patroni の方がより直接的な解決策です。[52]

すべてのプロバイダーにまたがる 1 つのクラスター?

Talos は拠点間の WireGuard フルメッシュである KubeSpan を提供し、k3s は flannel バックエンド wireguard-native を提供します。ただし、コントロール プレーン (etcd) は WAN の遅延の影響を受けやすくなります。小規模なクラスターを複数運用する方が簡単で、それぞれ独立して障害が発生します。[51][49][50]

ステップ バイ ステップ

  1. データ層は構成 M と同様に構築します (Patroni、etcd、Garage を備えた 3 台の VM)。
  2. プロバイダーごとに 1 つの k3s または Talos クラスター。データ層へのネットワーク接続は WireGuard (または KubeSpan) 経由。
  3. 受信ポート: 種類 LoadBalancer のサービスを externalTrafficPolicy: Local で使用し (リポジトリと同様)、SPF/DMARC のために送信元 IP を保持します。PROXY プロトコルに対応したロード バランサーを使用する場合は、代わりに Cluster__EnableProxyProtocol=true を使用します。Hetzner Cloud Load Balancer は PROXY プロトコルと任意の TCP サービスに対応しています。[19]
  4. 送信ポート 25: PTR と SPF エントリが一致する固定 IP 経由での Pod の Egress (deploy/kubernetes/README.md に記載の Egress ゲートウェイ)。
  5. プロバイダーごとの Helm 値、パイプラインで順番にロールアウト (第 07 章)。HPA (CPU 70% で 2-10 ポッド) と 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 フェイルオーバー スクリプト。パイプラインは常に 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 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 秒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 の復帰

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 コピーからサンプルを開いてください。
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 · スケーリング: ユーザー、ストレージ、ポッド

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 圧縮により、その必要性は低くなります。
SMTPDNS 経由優先度が同じ MX レコードを増やすと負荷が分散されます。送信キューは SKIP LOCKED によりすべてのノードに分散されます。
IMAPDNS / 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]

プロバイダープランvCPURAMディスク€ (税抜) / 月
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 ルート サーバーRS 1000 G12.548 GB128 GB€18.26
netcup ルート サーバーRS 2000 G12.5816 GB256 GB€34.20
netcup ルート サーバーRS 4000 G12.51232 GB512 GB€65.40
netcup ルート サーバーRS 8000 G12.51664 GB1024 GB€124.72
netcup ルート サーバーRS 12000 G12.52096 GB1536 GB€182.26
netcup ルート サーバーRS 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 の 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)

  1. 注文前に: PTR は設定できますか? STRATO: VPS の逆引き DNS はカスタマー ログインのドメイン設定で行い、事前に A/AAAA レコードが必要で、反映まで最大 24 時間かかります。ポート 25: ルート サーバーについて STRATO は送信がブロックされており、サポート経由で解除すると記載しています。VPS については規定の記載がありません。STRATO はフェイルオーバー IP を提供していません。[31][32][30]
  2. 新しいホストを独自の WireGuard IP と Garage ゾーンとともに Ansible インベントリに追加し、すべてのサイトにピアを追加して、Playbook を実行します。
  3. etcd: etcdctl member add でメンバーを追加し、ETCD_INITIAL_CLUSTER_STATE=existing で起動します。データベース サイトが 4 つの場合は、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 · 制限とギャップ (率直に)

このガイドの執筆中にコード内で見つかったものです。現在までに 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 日に以下の公式ソースと照合しました。確認できなかった点は本文中にその旨を記載しています。

  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 (SMTP 向け DANE): 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 Docs: Floating IP: https://docs.hetzner.com/cloud/floating-ips/overview
  10. Hetzner Docs: IPv4 の価格: https://docs.hetzner.com/general/others/ipv4-pricing
  11. Hetzner Docs: Robot フェイルオーバー IP: https://docs.hetzner.com/robot/dedicated-server/ip/failover
  12. Hetzner Cloud: コスト最適化プラン: https://www.hetzner.com/cloud/cost-optimized/
  13. Hetzner Docs: クラウド サーバー 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 Docs: Load Balancer FAQ: https://docs.hetzner.com/cloud/load-balancers/faq
  20. Hetzner Docs: Networks FAQ: https://docs.hetzner.com/cloud/networks/faq
  21. Hetzner Docs: DNS (Console と 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 ヘルプセンター: IP アドレスとフェイルオーバー: https://www.netcup.com/en/helpcenter/documentation/server/ip
  25. netcup: サーバー拡張 (フェイルオーバー IP の価格): https://www.netcup.com/de/server/kvm-server-erweiterungen
  26. netcup Helpcenter: ファイアウォール (ポリシー "netcup Mail block"): https://www.netcup.com/en/helpcenter/documentation/server/firewall
  27. netcup ヘルプセンター: ネットワーク / 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 ヘルプセンター: 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 FAQ: VPS のリバース DNS: https://www.strato.de/faq/server/wie-kann-ich-reverse-dns-fuer-meinen-vps-vds-einrichten/
  32. STRATO FAQ: ルート サーバーのネットワーク ファイアウォール (ポート 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 Docs: Load Balancing の制限 (ヘルス チェック間隔): https://developers.cloudflare.com/load-balancing/reference/limitations/
  35. Cloudflare Docs: Load Balancing の DNS レコード (MX/SRV は Enterprise のみ): https://developers.cloudflare.com/load-balancing/load-balancers/dns-records/
  36. Cloudflare Docs: プロキシ モード (DNS のみ): https://developers.cloudflare.com/load-balancing/understand-basics/proxy-modes/
  37. Cloudflare Docs: プラン別の 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 による HA: 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: Data Backup Options (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