TT Lab
Blog

한국어 English 日本語 中文

Get started
Blog

Blog

Why Two Servers Cannot Achieve HA 2: What a Single Witness Changes and Remaining Pitfalls

Status: Measured (2026-10-31). Values measured in Docker on three TT Lab practice VMs (KubeVirt, Ubuntu 24.04). Each test was run once, and VM power-offs were not performed (unmeasured). Configurations and test methods are in Part 1. Writing rule: 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. Overview at a Glance

In Part 1, we saw that with only two data nodes, all five services stop when a single node is lost. This time, we injected three types of failures into a configuration containing a voting-only witness VM (data nodes db1 and db2 + witness).

First, the results.

2. Maximum Write Outage by Failure Type

Maximum write outage is the longest gap (in seconds) between a successful write and the next successful write. Values in parentheses are failure count / trial count. ClickHouse is basic insertion, quorum=2 is insertion via insert_quorum=2 , and S3 represents two SeaweedFS S3 gateways behind HAProxy.

증인이 있을 때: 장애 종류별 최장 쓰기 중단 장애는 모두 90초. 고립 = 세션망 차단, 정지 = 컨테이너 전부 docker stop db1 고립 db1 정상 정지 증인 정지 0 30 60 90 120 150 초 PostgreSQL 23.7 3.7 0.1 Redis 105 134.8 0.1 OpenSearch 66.3 0.7 0.1 ClickHouse 5.3 2.4 5.4 ClickHouse quorum=2 60.6 123.6 5.3 SeaweedFS S3 95.7 131.1 27.6 점선 = 장애 90초. 0.1 은 끊김 없음(쓰기 간격 수준)
Figure: With Witness Present: Maximum Write Outage by Failure Type
Service db1 Isolation 90s db2 Isolation 90s db1 Isolation 40s db2 Graceful Shutdown 90s db1 Graceful Shutdown 90s Witness Shutdown 90s
PostgreSQL 23.7 (400/5910) 28.3 (491/5915) 33.7 (601/3120) 3.4 (25/5944) 3.7 (34/5943) 0.09 (0/4780)
Redis 105.0 (471/4364) 105.1 (1964/5855) 55.2 (371/2463) 268.8 ※ (5349/5968) 134.8 (2673/5970) 0.09 (0/4782)
OpenSearch 66.3 (16/2347) 64.7 (15/2364) 40.2 (9/1196) 0.3 (0/2990) 0.7 (0/2986) 0.13 (0/2392)
ClickHouse 5.3 (1/1476) 5.2 (1/1476) 5.2 (1/776) 2.5 (2/1491) 2.4 (2/1490) 5.4 (1/1175)
ClickHouse quorum=2 60.6 (303/1475) 59.9 (300/1451) 10.0 (51/752) 122.9 (601/1488) 123.6 (602/1486) 5.3 (1/1175)
SeaweedFS S3 95.7 (19/898) 111.2 (11/955) 61.2 (11/505) 122.4 (11/898) 131.1 (12/856) 27.6 (2/1055)

※ The 268.8-second Redis value for db2 graceful shutdown occurred because the restarted Redis fell into a restart loop due to an official image bug and never recovered (Section 4.5). The second column from the right shows the same test run on db1 after the fix. Write intervals are 0.05 seconds for PostgreSQL and Redis, so 0.09 seconds means there was no interruption.

Read (query) failures were almost nonexistent. OpenSearch _count was 0 to 1 times per test, and ClickHouse and S3 were 0 to 1 times across all tests in the witness configuration.

2.1 Until the Master Changes

Seconds measured from the moment the failure was injected.

db1 Isolation 90s db2 Isolation 90s db1 Isolation 40s db2 Graceful Shutdown db1 Graceful Shutdown
Patroni New Primary 22.6 27.4 32.7 3.1 2.8
Sentinel New Master 6.5 6.6 7.0 7.9 7.8
OpenSearch Node Removal (yellow) 64.8 65.2 Recovery Before Removal 0.6 0.5
SeaweedFS New Master Leader 12.1 Leader was not the isolated node Leader was not the isolated node No impact 15.0
Rejoined after recovery PG 15.5 (including pg_rewind and restart), OpenSearch green 5.1 PG 6.6, OpenSearch green 2.8 PG 14.2, OpenSearch green 6.9 OpenSearch green 23.2 (from start) OpenSearch green 26.3

2.2 Reading by Service

2.3 Even When Shutting Down the Witness

PostgreSQL Redis OpenSearch ClickHouse Basic / quorum=2 S3
Failures 0 / 4780 Failures 0 / 4782 Failures 0 / 2392 5.4s 1 case / 5.3s 1 case 27.6s, 2 cases

The brief hiccups in ClickHouse and S3 occurred because at that moment, the Keeper leader and SeaweedFS master leader were on the witness. Electing new leaders took about 5 seconds and 12 seconds respectively, and S3 experienced additional interruption until volume servers reattached to the new leader. It is worth remembering that although a witness is just there to "cast a vote," in Raft it can also become a leader.

3. Partition: Does the Isolated Former Master Accept Writes?

Automatic failover working is not the end. If the old master on the severed side accepts writes in the meantime and discards them later, the app loses data despite receiving a successful response. We isolated db1 (the master at the time) for 40 seconds, and inside db1 , wrote directly to its local service every 0.5 seconds. After recovery, we cross-checked whether those writes survived.

Service Writes Accepted by Isolated Former Master What Survived After Recovery What Prevented It
PostgreSQL 0. Failed immediately after isolation N/A Synchronous replication (waits for replica acknowledgment) + demotion if consensus store is lost
Redis 22 cases between 0s and 10.7s after isolation 0 cases (all 22 cases lost) min-replicas-max-lag 10 passed, NOREPLICAS. That 10 seconds is the loss window.
OpenSearch 100 cases 100 cases ※ This was a newly created index whose replica was not yet in the in-sync set (writes were blocked for normal indices). There was no split-brain.
ClickHouse 0. Inserts hung due to loss of Keeper majority N/A Keeper majority
SeaweedFS filer 0. Timeout N/A Master majority

PostgreSQL, ClickHouse, and SeaweedFS, which strictly follow majority rules, had the isolated side reject writes. Data loss occurred only in Redis.

4. Pitfalls Operators Must Know

From here on are issues actually experienced during these measurements.

4.1 Redis min-replicas-max-lag Is Directly the Loss Window During Partitions

Redis 분할: 고립된 옛 주인이 받은 22건 db1(주인)의 세션망을 끊고, db1 안에서 0.5초마다 직접 썼다 0 +5 +10 +15 +20초 db1 옛 주인(고립) 쓰기 성공 22건 NOREPLICAS db2 새 주인 복제본 +7.0초 Sentinel 이 승격 min-replicas-max-lag 10 복구하자 db1 은 db2 의 복제본이 되어 db2 의 데이터로 덮였다. 성공 응답을 받은 22건이 복구 뒤 0건(전부 소실) 그 사이 db2 쪽의 확인받은 쓰기 손실은 0건
Figure: Redis Partition: 22 Cases Accepted by Isolated Former Master

Redis replication is asynchronous. When the master receives a write, it immediately returns success and sends it to replicas later. min-replicas-to-write 1 and min-replicas-max-lag 10 mean "accept writes only if there is at least 1 replica that responded within the last 10 seconds." The isolated master continues to accept writes until the replica's last response exceeds 10 seconds.

In our measurements, that window opened precisely like that. The isolated db1 successfully accepted 22 writes during the first 10.7 seconds, while Sentinel promoted db2 as the new master at +7.0 seconds. After recovery, db1 became a replica of db2 and was overwritten by db2's data, and not a single one of the 22 writes survived. From the app's perspective, these are writes that received a "success" response.

There are three choices, and none of them are free.

This configuration corresponded to the first setup (1, 10), and its cost was measured as both "up to about 10 seconds of lost writes during a split" and "a write freeze when a single data node is lost."

4.2 Writes Saved Despite Failure Responses, and Idempotency Keys

실패로 응답했는데 저장된 쓰기 시간 초과·정족수 미달 응답이 와도 한쪽에는 이미 써졌다 앱 id=42 삽입 ① 삽입 ③ 실패 응답(시간 초과) DB ② 복제본 하나에 저장 다시 보내면 멱등 키 없음 id=42 가 두 줄 (같은 행이 중복된다) 다시 보내면 멱등 키 있음 id=42 가 한 줄 OpenSearch: _id 지정 ClickHouse: 중복 제거 토큰 실측(증인 있는 구성): OpenSearch 고립 시험마다 9–16건, ClickHouse quorum=2 실패 응답의 거의 전부, S3 2–4건
Figure: Writes Saved Despite Failure Responses

While there were zero instances of lost acknowledged writes, there was a discrepancy in the opposite direction: writes that received a failure response but were actually saved.

Witness-enabled Configuration Test OpenSearch ClickHouse quorum=2 S3
db1 isolated 90s 16 / failed 16 303 / failed 303 4 / failed 19
db2 isolated 90s 15 / failed 15 300 / failed 300 3 / failed 11
db1 isolated 40s 9 / failed 9 51 / failed 51 4 / failed 11
db2 gracefully stopped 90s 0 / failed 0 601 / failed 601 2 / failed 11
db1 gracefully stopped 90s 0 / failed 0 601 / failed 602 2 / failed 12

(Values in the table are "Number of saved writes with a failure response / Number of failure responses")

If the client retries upon failure, the same row gets inserted twice. Therefore, retried writes require an idempotency key. OpenSearch lets the app specify and include a document _id , while ClickHouse attaches insert_deduplication_token . First, you need to check whether your app's retry logic assumes that "it failed, so it does not exist."

4.3 Quorum Window Immediately Following ClickHouse Keeper Force Recovery

This was covered in Part 1, Section 7.1. In a two-node setup, for about 20 seconds immediately after reviving the Keeper as a single server using --force-recovery , even while the peer replica remained isolated, insert_quorum=2 98 insert operations reported success. Following that, TOO_FEW_LIVE_REPLICAS 1,346 operations failed with . It is presumed that the peer's old "alive" status remained on the recovered Keeper until the session timeout (30 seconds) expired (estimated). After a force recovery, do not trust the success of quorum inserts until the session timeout has passed.

4.4 OpenSearch Took 65 Seconds to Detect an Isolated Node

During a graceful stop, the node leaves the cluster voluntarily, resulting in less than 1 second of write interruption. Isolation was different. It took 64.8 and 65.2 seconds for the node to be dropped (yellow), during which writes to indices with primary shards on that node were halted. Searches (_count) failed only 0 to 1 time per isolation test.

OpenSearch monitors nodes using default failure detection settings (a 10-second timeout per check, requiring 3 consecutive failures to judge a node dead). Since isolation goes through a two-step process for this determination, it is believed to take around 65 seconds. cluster.fault_detection.* Lowering this value can reduce the time, but it was not measured with a lower value (unmeasured). Because temporary network flickers could cause nodes to be evicted, re-measurement would be necessary if the value is reduced.

4.5 Official redis:8 Image and Sentinel Restart Loop

This accounts for the 268.8 seconds in the table from Section 2. Causes were intertwined as follows:

  1. The entrypoint of the official redis:8 image appends all .so in the modules folder with --loadmodule to launch Redis.
  2. During a failover, Sentinel sends CONFIG REWRITE to Redis. This rewrites the running configuration to the file, embedding those loadmodule lines directly into redis.conf .
  3. On the next startup, the modules appended by the entrypoint overlap with the modules embedded in the file, attempting to load the same module twice and causing it to crash with Can't load module ... server aborting . The container then restarts and crashes again.

Because this only manifests when using Sentinel, experiencing a failover once, and then restarting that Redis instance, it is not caught by post-installation tests. If you do not use modules, the fix is simple: overwrite the modules folder with an empty tmpfs so they are never loaded. Remove the already embedded lines.

  redis:
    image: redis:8.8.4
    command: ["redis-server", "/conf/redis.conf"]
    tmpfs:
      - /usr/local/lib/redis/modules   # 진입점이 붙일 모듈이 없게 한다
sed -i '/^loadmodule /d' /path/to/redis.conf   # 이미 박힌 줄 지우기

After the fix, in the same test (db1 graceful stop), Redis returned to normal, and the remaining 134.8-second outage was the cost of min-replicas from Section 4.1. This issue has already been reported upstream (redis/docker-library-redis#468, redis/redis#14178), and the fix on the Redis core side (redis/redis#16009) was merged on 2026-10-29 and is scheduled to be released in the next version.

There is one more small pitfall along the same lines. Sentinel rewrites requirepass into the ACL hash line. Because health checks that read passwords from the configuration file break, we made pre-authentication responses (NOAUTH) count as "alive" this time.

4.6 Place Witnesses in a Third Failure Domain

In this test, the witness VM was on the same physical server as db2. Since we only introduced VM-level failures, this did not affect the results, but if that physical server dies, db2 and the witness are lost together, wiping out 2 out of 3 votes. The system then stops just like a two-node configuration.

The value of a witness comes from the fact that it "never dies together with either data node." In production, place the witness in a third failure domain, such as a different rack, a different building, a different power supply and switch, or a small VM in the cloud. Since a witness only casts votes, a small VM is sufficient. Our witness used 2 vCPU and 2 GiB of memory, consuming 1.5 GiB right after installation. However, as seen in Section 2.3, a witness can also become a Raft leader, so if the round-trip latency to the data nodes is too high, the downtime during leader re-election could be prolonged (unmeasured).

4.7 SeaweedFS: It cannot be used with a single volume server, but it does not fail immediately either

In replication 001 , when only one volume server remains, there is nowhere to place new writes. Up to this point, it behaves as configured. The problem was that failures only appeared after hanging for about 10 seconds per request. Two factors overlapped.

Both issues were submitted upstream. For the problem of volume servers being slow to find the new leader, we submitted a fix PR (seaweedfs#12259, reducing the reproduction time from 25 seconds to 4 seconds), and for the problem of the returning master demoting the leader, we filed an issue (seaweedfs#12260). In the same experiment, running with -raftHashicorp(Raft with PreVote enabled) prevented the leader from being disrupted. If you use SeaweedFS with two data nodes in production, keep in mind that uploads will hang rather than fail immediately when one node is down, and reflect this in your application's timeouts. When an entire volume server disk was lost, volume.fix.replication -apply matched all 24 volumes in 30.7 seconds, during which there was 1 S3 write failure and 0 read failures. In a test where a single filer was paused for 60 seconds, there were 0 failures out of 748 S3 writes.

4.8 Collection of Short Pitfalls

Pitfall Symptom Mitigation
OpenSearch node.roles: ${OS_ROLES}(Environment variable substitution in yml) It becomes a single string instead of a list, preventing nodes from forming a cluster. Visible only in DEBUG logs Provide as an environment variable node.roles=cluster_manager,data,ingest(comma-separated list)
OpenSearch 3.9 witness heap 320m, limit 700m OOM restart loop Heap 384m, Limit 1100m
The SeaweedFS image runs with uid 1000 S3 crashes because it cannot read the root 600 S3 configuration file Set file ownership to 1000
SeaweedFS master limit 128m Rises up to 89 MiB 192m
ClickHouse 26.8 default for HTTP inserts async_insert=1, wait_for_async_insert=1 came out as For the quorum test, async_insert=0 separately as
ClickHouse Keeper Raft port listen_host even when provided, it opened on all addresses (9234). Blocking the external interface with a host firewall
iptables -m multiport Up to 15 ports per rule ("too many ports specified") Split the rule into two
The container does not have /dev/watchdog . Patroni fencing relies solely on the consensus store TTL. If the primary server hangs without dying and exceeds the TTL, there can briefly be two masters. Passing the host watchdog or softdog (unmeasured)
docker stop 30-second grace period A container using up the entire grace period caused the entire node shutdown to take 30 seconds. Include it in the time budget for the graceful shutdown procedure.

5. Limitations of This Measurement

Please check the conditions before using these numbers.

6. Conclusion: Checklist for 2 Nodes + Witness

Checklist Reason (Actual measurements in this post)
Place the witness in a different fault domain from data nodes If they fail together in the same location, it becomes equivalent to a 2-node-only setup
Choose Redis's min-replicas-* with awareness of its behavior (1, 10) lost 22 records during a split, and losing a single node caused a 105-second write outage
Use idempotency keys for retried writes A failure response was returned, but the write was actually stored in OpenSearch, ClickHouse, and S3
Do not trust quorum for the session duration after a forced ClickHouse recovery 98 records were false successes
Account for OpenSearch isolation detection time in your budget 65 seconds with default values
Official redis:8 + Sentinel means clearing the module folder Restart loop after failover
Write down manual promotion procedures in advance and start with fencing The only exit when there are only 2 nodes. 0.9 to 44.2 seconds depending on the service

A word from TT. Having a witness friend makes majority voting work well. However, since there are still only two friends carrying the load, some services say, "I'll put the load down only when my partner arrives" if one takes a break. And when you retry after hearing "It failed," make sure to attach a name tag (idempotency key). It gets troublesome if the same load comes in twice!

Sign in to like

Comments

No comments yet.

Sign in to leave a comment