TT Lab
Blog

한국어 English 日本語 中文

Get started
Blog

Blog

Why 2 Servers Are Not Enough for HA 1: Practical Measurements for PostgreSQL, Redis, OpenSearch, ClickHouse, and SeaweedFS

Status: Measured (2026-10-31). These values were measured in Docker on three TT Lab practice VMs (KubeVirt, Ubuntu 24.04). Each test was run once, and VM power-off tests were not performed (not measured). All numbers are extracted from raw data (write logs left per attempt and cross-checked after recovery). Writing rules: Measured data is the default; unmeasured items are marked as "Unmeasured", and interpretations whose causes could not be verified are marked as "Estimated".

Table of Contents

1. Question: Wouldn't 2 servers be enough even if one dies?

When asked to set up database high availability in an on-premise environment, the very first mental image that comes to mind is 2 servers. One acts as the primary and receives writes, while the other holds a replica and takes over if the primary dies. Two servers are also the cheapest option in terms of hardware cost and rack space.

This series is a record of directly verifying with five services that this setup does not work automatically. Starting with the conclusion:

2. Why a Majority Requires 3

2.1 Impossible to Distinguish Between Dead and Disconnected

Let us assume db1 and db2 monitor each other. From db1's perspective, responses from db2 have stopped. There are two possibilities:

  1. db2 is truly dead.
  2. db2 is completely fine, but the network between them is cut (network partition).

db1 alone has no way to distinguish between these two because both appear simply as "no response." If db1 believes it is case 1 and becomes the primary when it was actually case 2, db2 will also still consider itself the primary and accept writes. A split brain occurs, and when the network recovers, the data on both sides has already diverged. The writes on one side must be discarded.

2.2 Therefore, "Only the Side with the Majority Becomes the Master"

The standard rule to avoid this problem is majority (quorum) . Only the side that obtains consent from more than half of the N voting members (⌊N/2⌋ + 1) can become the primary. No matter how the network is partitioned, there is at most one side with a majority, so there cannot be two masters.

과반의 산수: 2대와 2대 + 증인 둘 사이의 선이 끊겼을 때(분할), 각 쪽이 셀 수 있는 표 데이터 노드 2대 db1 표 1 db2 표 1 멤버 2, 과반 = 2 어느 쪽도 1표뿐 양쪽 모두 쓰기 정지 주인 쪽도 스스로 내려온다(두 주인 방지) 2대 + 증인 db1 표 1 db2 표 1 증인 표 1 db1 + 증인 = 2/3 과반. 이쪽이 계속한다 멤버 수 과반 견디는 장애 1 1 0 2 2 0 3 2 1 4 3 1 5 3 2 2대는 1대와 똑같이 장애를 하나도 견디지 못한다
Figure: Majority Math: 2 Servers vs. 2 Servers + Witness

The problem arises when there are 2 servers. If the number of members is 2, the majority required is 2. If the connection drops, both sides have only 1 vote, so neither side can become primary, and the original primary even steps down voluntarily. This is because otherwise, it would accept writes without knowing what is happening on the other side. As shown in the table below the figure, a 2-server setup cannot tolerate a single failure, just like a 1-server setup. To tolerate a single failure, the number of voting members must be 3.

2.3 The Third Node Can Just Cast Votes Without Holding Data

The third member does not necessarily have to be a server holding data. All that is needed for majority calculation is a vote. Data is kept on the two servers, and a small process that only votes is placed in the third spot. This is called a witness or tie-breaker. The witness VM in this experiment had 2 vCPU and 2 GiB of memory, and the actual memory usage right after installation was 1.5 GiB.

3. How Each of the Five Services Determines Its Master

All five services use a majority, but the entity counting the majority differs for each service. This is why what needs to be placed on the witness also varies.

Service HA Method Master Determination Mechanism Witness Component
PostgreSQL 17 Patroni 4.1 + streaming replication, synchronous replication(synchronous_mode: true, strict: false), pg_rewind etcd 3 members (mTLS) One etcd member
Redis 8.8 Primary 1 + replica 1, AOF everysec 3 Sentinels, quorum 2 One Sentinel
OpenSearch 3.9 Data nodes 2, index replica 1, security plugin enabled cluster_manager voting nodes 3 Data-less cluster_manager-only node
ClickHouse 26.8 Shard 1 × replicas 2, ReplicatedMergeTree 3 ClickHouse Keepers (Raft) One Keeper
SeaweedFS 4.51 masters 3 (Raft), volumes 2 (replication 001), filers 2 (shared PostgreSQL), S3 gateways 2 master Raft 3 One master
실습 구성: 데이터 노드 2대와 증인 1대 증인에는 데이터가 없다. 표를 던지는 투표 멤버만 둔다 db1 4 vCPU · 6 GiB db2 4 vCPU · 6 GiB 증인 2 vCPU · 2 GiB PostgreSQL PG 주, Patroni etcd PG 복제, Patroni etcd etcd (투표만) Redis Redis 주 Sentinel Redis 복제 Sentinel Sentinel (투표만) OpenSearch 데이터 + 투표 데이터 + 투표 투표 전용 데이터 없음 ClickHouse 복제본 Keeper 복제본 Keeper Keeper (투표만) SeaweedFS master, volume filer, s3 master, volume filer, s3 master (투표만) 증인 VM 은 앱 서버 자리도 겸했다: 시험 클라이언트와 HAProxy(주 서버를 찾아 보내는 앞단)
Figure: Lab Setup: 2 Data Nodes and 1 Witness

The reasons why each service was chosen and the trade-offs are as follows.

The total container memory limit for the data nodes was approximately 4.6 GiB (OpenSearch 1.4g, ClickHouse 1.2g, PostgreSQL 768m, Redis 384m, etc.), and right after installation, actual usage was 2.5 GiB for data nodes and 1.5 GiB for the witness.

4. Configuration Excerpts

Passwords and keys are omitted (in reality, they were placed only in the node's .env file with 600 permissions). Addresses were replaced with node names or variables. All services were bound only to the cluster network address and 127.0.0.1.

etcd (Patroni's consensus store). Place the same compose file on all three nodes and change only the node name. The last line is for manual recovery when there are only 2 servers.

services:
  etcd:
    image: gcr.io/etcd-development/etcd:v3.6.16
    network_mode: host
    command:
      - etcd
      - --name=${NODE_NAME}
      - --initial-cluster=${ETCD_INITIAL_CLUSTER}   # db1, db2, witness 세 멤버
      - --client-cert-auth                          # 클라이언트·피어 모두 상호 TLS
      - --peer-client-cert-auth
      - --heartbeat-interval=100
      - --election-timeout=1000
      - --force-new-cluster=${ETCD_FORCE_NEW:-false} # 살아남은 한 대를 단일 멤버로 다시 세울 때만 true
    mem_limit: 256m

Patroni. Leader key TTL ttl determines the upper limit of time from "primary disappearance until promotion".

bootstrap:
  dcs:
    ttl: 30
    loop_wait: 10
    retry_timeout: 10
    synchronous_mode: true          # 커밋이 복제본에 닿은 뒤 확인
    synchronous_mode_strict: false  # 복제본이 죽으면 동기 요구를 내리고 계속
    failsafe_mode: false
    postgresql:
      use_pg_rewind: true
      use_slots: true
watchdog:
  mode: off                         # 컨테이너에 /dev/watchdog 이 없다

Redis and Sentinel. min-replicas-* These two lines are the settings that exacted the highest cost in this series (Part 2).

# redis.conf
appendonly yes
appendfsync everysec
min-replicas-to-write 1     # 10초 안에 응답한 복제본이 1개 이상일 때만 쓰기를 받는다
min-replicas-max-lag 10

# sentinel.conf
sentinel monitor mymaster <db1> 6379 2
sentinel down-after-milliseconds mymaster 5000
sentinel failover-timeout mymaster 60000

OpenSearch. The three nodes gather once at the beginning to form the voting configuration. Roles are provided via environment variables (see the trap in Part 2).

# opensearch.yml
cluster.initial_cluster_manager_nodes: ["db1", "db2", "witness"]
# docker-compose.yml 의 environment
node.roles: cluster_manager,data,ingest   # 증인은 cluster_manager 만

ClickHouse Keeper. One of the three Raft members is the witness. The 30-second session timeout connects to the trap in Part 2.

<coordination_settings>
    <operation_timeout_ms>10000</operation_timeout_ms>
    <session_timeout_ms>30000</session_timeout_ms>
</coordination_settings>
<raft_configuration>
    <server><id>1</id><hostname>db1</hostname><port>9234</port></server>
    <server><id>2</id><hostname>db2</hostname><port>9234</port></server>
    <server><id>3</id><hostname>witness</hostname><port>9234</port></server>
</raft_configuration>

SeaweedFS master. Only the master is run on the witness.

  master:
    profiles: ["data", "witness"]
    command:
      - master
      - -peers=${DB1_IP}:9333,${DB2_IP}:9333,${WITNESS_IP}:9333
      - -defaultReplication=001
    mem_limit: 192m            # 128m 에서 89MiB 까지 오르는 것을 보고 늘렸다(실측)

HAProxy (application side). The application only knows 127.0.0.1:5000. When the primary changes, HAProxy follows using two checks spaced 1 second apart.

listen pg_primary
    bind 127.0.0.1:5000
    option httpchk GET /primary
    http-check expect status 200
    default-server inter 1s fall 2 rise 2 on-marked-down shutdown-sessions
    server db1 <db1>:5432 check port 8008
    server db2 <db2>:5432 check port 8008

5. Test Methodology

6. When There Are Exactly 2 Nodes

6.1 How We Reduced It to 2 Nodes

From a setup with a witness, we removed the witness side.

6.2 When Disconnecting One Node for 60 Seconds

At that time, the master for both PostgreSQL and Redis was db2. Therefore, isolating db1 is "losing the replica side," and isolating db2 is "losing the master side."

2대뿐일 때 한 대를 잃으면: 최장 쓰기 중단 증인을 뺀 2대 구성, 한 대의 세션망을 60초 끊었다(한 번씩) 복제본 쪽(db1)을 60초 고립 주인 쪽(db2)을 60초 고립 0 30 60 90 120 초 PostgreSQL 108.4 107.8 Redis 50.3 61.1 OpenSearch 34.2 62.3 ClickHouse 60.5 60.9 SeaweedFS S3 108.9 107.9 점선 = 고립 60초. 막대가 점선보다 길면 복구 뒤에도 더 멈춰 있었다는 뜻
Figure: When losing one node out of only 2: Max Write Interruption
Service 60-sec isolation of replica side (db1) 60-sec isolation of master side (db2) Why
PostgreSQL 108.4s interruption. The active primary server demoted itself at +23s 107.8s interruption. No promotion The majority of 2 etcd members is 2. Losing the peer makes it impossible to renew the leader key, so the master side also goes down. It took an additional 47 seconds waiting for the key to expire even after recovery
Redis 50.3s. The master remained, but starting from +11s NOREPLICAS 61.1s. MasterNotFound, no switchover 1 out of 2 Sentinels cannot obtain failover authorization (majority)
OpenSearch Continued after 34.2s (db2 alone in voting config) 62.3s, stopped until recovery Voting configuration consists of only a single db2 instance. Losing db2 means no cluster exists
ClickHouse 60.5s (inserts hung, 0 read failures) 60.9s Only 1 out of 3 Keepers is reachable
SeaweedFS S3 108.9s 107.9s No master leader from +18s, and replication 001

There are three takeaways.

7. Manual Promotion: A Human Declaring "The Peer Is Dead"

To restart stopped services in a 2-node configuration, human intervention is required. This involves declaring, "The peer is dead, so the remaining single node constitutes a majority." While keeping db2 isolated, we performed this sequentially for each service on db1. The procedure was scripted in advance.

Service Procedure From procedure start to write resumption From isolation to write resumption
PostgreSQL ① etcd was --force-new-cluster=true re-established as a single member (healthy at 8.7s) ② Patroni waited for the old leader key to expire (ttl 30) and then promoted db1 (synchronous replica) 44.2 seconds 60.2 seconds
Redis REPLICAOF NO ONE → CONFIG SET min-replicas-to-write 0 → CONFIG REWRITE. Sentinel SENTINEL REMOVE after which db1 was re-established with quorum 1 SENTINEL MONITOR 0.9 seconds 59.9 seconds
OpenSearch Container stopped → bin/opensearch-node unsafe-bootstrap(same data volume) → started. Cluster status became red (indices that had primaries on db2) 27.0 seconds 87.3 seconds
ClickHouse Keeper configured as a single server + --force-recovery started → standalone at 2.5s → tables is_readonly=0 at 9s 98.1 seconds
SeaweedFS Master -peers=none restarted as a single instance (leader at 1.4s) → fs.configure -locationPrefix=/buckets/ -replication=000 -apply lowered replication requirements 24.2 seconds 121.2 seconds

The procedures were performed service by service, starting with PostgreSQL 15.9 seconds after isolation. Therefore, services later in the sequence have larger "from isolation" values. The actual time taken during an outage is closer to "time to detect and decide + second column from the right." There were zero confirmed write losses across all five services.

The reason PostgreSQL took the longest is that even after re-establishing etcd, it has to wait for the old leader key to expire. This 30 seconds is precisely ttl .

7.1 Cautions for Manual Promotion

8. Conclusion

Question Answer (Measured)
Does automatic HA work with 2 nodes? No. Losing one node stopped writes across all five services.
Does it stop even if only a replica is lost? PostgreSQL (2 etcd members) had the primary server step down on its own, causing a 108.4-second outage.
Was any data lost? Zero acknowledged writes were lost. It only stops; data is not lost.
To survive with 2 nodes Manual promotion. 0.9 to 44.2 seconds per service, with fencing first.
To enable automatic failover Set voting members to 3. The third can be a small witness with no data.

How a configuration with an added witness behaves under each failure type, and the pitfalls left by the condition that "there are still 2 data nodes," will be covered in Part 2.

Titi's Word. When two people try to majority-vote, it's always a tie of 1 to 1. That's why both stop, wondering, "Can I decide this myself?" You just need one more friend to cast a vote. That friend doesn't even need to carry any luggage (data)!

References

Sign in to like

Comments

No comments yet.

Sign in to leave a comment