Skip to content

antony@notes:~/kubernetes$ cat "etcd-寫入一筆資料後-Leader-和-Follower-做了哪些事.md"

etcd 寫入一筆資料後,Leader 和 Follower 做了哪些事?

2026-04-13· kubernetes ·etcd ·raft ·kubernetes ·distributed-systems

etcd 寫入一筆資料後,Leader 和 Follower 做了哪些事?

前置準備

1. 建立 tkha HA 叢集

本實驗需要一個 3 control-plane 的 etcd HA 叢集。如果還沒建立,先用 tk 工具建立:

~/tk/bin/kto tkha

kto 會建立叢集並初始化第一個 control-plane(tkha-control-plane),但不會自動加入另外兩個 control-plane

2. 加入另外兩個 Control-plane 節點

確認三個 control-plane 是否都已加入叢集:

sudo podman exec tkha-control-plane etcdctl member list -w table

如果只有 1 個 member,需要手動加入另外兩個:

# 產生 certificate key 和 join 指令
CERT_KEY=$(sudo podman exec tkha-control-plane bash -c \
  'kubeadm init phase upload-certs --upload-certs' 2>/dev/null | tail -1 | tr -d '\r')

JOIN_CMD=$(sudo podman exec tkha-control-plane bash -c \
  'kubeadm token create --print-join-command' | tr -d '\r')

IGNORE='--ignore-preflight-errors=SystemVerification'

# 加入第二個 control-plane
sudo podman exec tkha-control-plane1 bash -c \
  "$JOIN_CMD --control-plane --certificate-key $CERT_KEY $IGNORE"

# 等待加入完成
sleep 30

# 加入第三個 control-plane
sudo podman exec tkha-control-plane2 bash -c \
  "$JOIN_CMD --control-plane --certificate-key $CERT_KEY $IGNORE"

# 等待加入完成
sleep 30

加入後再次確認,應該看到 3 個 member:

sudo podman exec tkha-control-plane etcdctl member list -w table

3. 安裝 host 上的工具

# tcpdump — 抓取網路封包
which tcpdump || sudo apk add tcpdump

# etcd-dump-logs — 解析 etcd WAL 檔案(需要 Go 環境)
which etcd-dump-logs || {
  go install go.etcd.io/etcd/tools/etcd-dump-logs@latest
  sudo cp $(go env GOPATH)/bin/etcd-dump-logs /usr/local/bin/
}

容器內的 etcdctlcrictlsed 是 tkha 叢集自帶的,不需要額外安裝。


實驗環境

項目
叢集tkha(3 control-plane HA)
etcd 版本3.6.5
Leader172.22.64.1(tkha-control-plane)
Follower172.22.64.2、172.22.64.3
Heartbeat 間隔250ms
Election Timeout2500ms

先搞懂這些術語

  • Leader / Follower

    • 原文:At any given time, one server is the leader and the others are followers. The leader handles all client requests.
    • 白話:老大和跟班。叢集中只有一個老大(Leader),其他都是跟班(Follower)。所有寫入都必須先經過老大,由老大負責分發給跟班。
  • AppendEntries

    • 原文:AppendEntries RPCs are initiated by the leader to replicate log entries and to provide a form of heartbeat.
    • 白話:老大把「要做的事」抄送給跟班的動作。像是老大寫了一張便條紙,影印後發給每個跟班說:「照著做,做完回報。」
  • WAL(Write Ahead Log)

    • 原文:The write ahead log is a persistent ordered record of every change made to etcd’s data.
    • 白話:「先寫日記再做事」的日記本。不管是老大還是跟班,收到一筆操作後都要先寫進這本日記。這樣即使突然當機,重開機後看日記就能恢復。
  • fsync

    • 原文:fsync() transfers all modified in-core data of the file to the disk device, ensuring that all changed data is physically written to disk.
    • 白話:把資料真正刷到磁碟上。一般寫檔案只是寫到記憶體的快取,斷電就沒了。fsync 強制把快取刷到磁碟,確保斷電也不丟。這是整個流程中最花時間的地方之一。
  • ACK(Acknowledgement)

    • 原文:A response from a follower indicating that it has successfully written the entry to its log.
    • 白話:跟班回報「收到,我已經記在日記裡了」。老大要等到夠多跟班回報 ACK 才算寫入成功。
  • Quorum

    • 原文:The minimum number of members that must agree for a change to be committed. For a cluster of N members, quorum is (N/2)+1.
    • 白話:多數決。超過半數的節點都寫好日記了,才算數。3 個節點需要 2 個同意(老大自己算 1 個,只要再有 1 個跟班 ACK 就夠了)。
  • Commit

    • 原文:A log entry is committed once the leader that created the entry has replicated it on a majority of the servers.
    • 白話:老大確認多數決通過後,把這筆操作正式生效——寫入真正的資料庫(BoltDB),而不只是日記。
  • RAFT INDEX

    • 原文:Each log entry stores a state machine command along with the term number and log index. The log index is an integer that identifies its position in the log.
    • 白話:每筆操作的全域流水號。老大和跟班共用同一組編號,同一個 index 在所有節點上代表的是同一筆操作。
  • RAFT TERM

    • 原文:Each term begins with an election. Terms act as a logical clock in Raft and allow servers to detect obsolete information.
    • 白話:老大的「任期編號」。每次選出新老大,term 就加 1。可以把它想成「第幾屆」。

結果:完整寫入流程

當 Client 執行 etcdctl put /hello world 時,etcd 內部會經過以下 7 個步驟,Client 才會收到 “OK”:

步驟誰做的做了什麼影響因素
(1)Client發送 put 請求給 Leader(port 2379)
(2)Leader寫入自己的 WAL,fsync 到磁碟:floppy_disk: 磁碟延遲
(3)Leader發送 AppendEntries 給 Follower(port 2380):globe_with_meridians: 網路延遲
(4)Follower寫入自己的 WAL,fsync 到磁碟:floppy_disk: 磁碟延遲
(5)Follower回覆 ACK 給 Leader:globe_with_meridians: 網路延遲
(6)Leader確認 Quorum(2/3),commit,回覆 Client “OK”:floppy_disk: 磁碟延遲
(7)Leader非同步通知 Follower commit(Client 已收到 OK)

時序圖:

 Client                    Leader (172.22.64.1)              Follower (172.22.64.2)
   |                              |                                |
   |  (1) put /hello world        |                                |
   |  --------------------------> |                                |
   |       (port 2379)            |                                |
   |                              |                                |
   |                     (2) Leader 寫入自己的 WAL                  |
   |                        (fsync 到磁碟)                         |
   |                              |                                |
   |                              |  (3) AppendEntries             |
   |                              |  ----------------------------> |
   |                              |     (port 2380, 639 bytes)     |
   |                              |                                |
   |                              |                       (4) Follower 寫入 WAL
   |                              |                          (fsync 到磁碟)
   |                              |                                |
   |                              |            (5) ACK             |
   |                              |  <---------------------------- |
   |                              |     (port 2380, 75 bytes)      |
   |                              |     (間隔 2.040ms)             |
   |                              |                                |
   |                     (6) Quorum 達成 (2/3)                     |
   |                        Leader commit                          |
   |                              |                                |
   |  "OK"                        |                                |
   |  <-------------------------- |                                |
   |                              |                                |
   |                              |  (7) 通知 commit(非同步)      |
   |                              |  ----------------------------> |
   |                              |                       Follower commit
   |                              |                                |

:::warning Client 收到 “OK” 的時間點是在步驟 (6),不是 (7)。Follower commit 是非同步的,不影響 Client 感受到的延遲。 :::

以下用 5 個實驗,一步一步驗證上述流程是對的。


驗證 1:辨識誰是 Leader

對應步驟 (1):Client 要把 put 請求送給 Leader,所以我們先確認誰是 Leader。

執行命令

sudo podman exec tkha-control-plane etcdctl \
  --endpoints=https://172.22.64.1:2379,https://172.22.64.2:2379,https://172.22.64.3:2379 \
  endpoint status -w table

執行結果(節錄關鍵欄位):

+--------------------------+------------------+---------+-----------+-----------+
|         ENDPOINT         |        ID        | VERSION | IS LEADER | RAFT TERM |
+--------------------------+------------------+---------+-----------+-----------+
| https://172.22.64.1:2379 | f49dafb8a84b142e |   3.6.5 |      true |         2 |
| https://172.22.64.2:2379 | bc789b7376e525b5 |   3.6.5 |     false |         2 |
| https://172.22.64.3:2379 | b8399ab2942e1696 |   3.6.5 |     false |         2 |
+--------------------------+------------------+---------+-----------+-----------+

實際輸出會包含更多欄位(DB SIZE、QUOTA 等),以上只列出關鍵欄位。

:::success 這證明了什麼?

  • 172.22.64.1 是 Leader(IS LEADER = true
  • 三個節點的 RAFT TERM 都是 2,代表目前是「第 2 屆」Leader :::

驗證 2:開啟 Follower 的 debug log

為了在後面的驗證中看到 Follower 收到 AppendEntries 的日誌,需要先開啟 debug log。這個步驟必須在寫入資料之前完成。

執行命令

sudo podman exec tkha-control-plane1 bash -c '
  sed -i "/- etcd$/a\\    - --log-level=debug" /etc/kubernetes/manifests/etcd.yaml
'

kubelet 偵測到 manifest 變更,會自動重啟 Follower 的 etcd pod。等約 1~2 分鐘後確認叢集恢復健康(如果還是 unhealthy,再等 30 秒重試):

sudo podman exec tkha-control-plane etcdctl \
  --endpoints=https://172.22.64.1:2379,https://172.22.64.2:2379,https://172.22.64.3:2379 \
  endpoint health -w table

執行結果(三個 endpoint 都要是 healthy 才能繼續):

+--------------------------+--------+
|         ENDPOINT         | HEALTH |
+--------------------------+--------+
| https://172.22.64.1:2379 |   true |
| https://172.22.64.2:2379 |   true |
| https://172.22.64.3:2379 |   true |
+--------------------------+--------+

如果 172.22.64.2 還是 unhealthy,每隔 30 秒重試一次,直到三個都是 true。


驗證 3:tcpdump 觀察 Leader 發封包給 Follower

對應步驟 (3) 和 (5):Leader 透過 port 2380 發送 AppendEntries 給 Follower,Follower 回覆 ACK。

準備:在 host 上啟動 tcpdump

執行命令(Terminal 1):

sudo tcpdump -i any host 172.22.64.1 and host 172.22.64.2 and port 2380 -nn -ttt -q -c 100

觸發:寫入一筆資料

執行命令(Terminal 2):

sudo podman exec tkha-control-plane etcdctl put /hello "world"

執行結果

OK

tcpdump 抓到的封包

執行結果(Terminal 1,節錄重點):

Heartbeat 封包(背景流量,大小固定 ~84-85 bytes):

00:00:00.000000 IP 172.22.64.1.2380 > 172.22.64.2.43206: tcp 85
00:00:00.000034 IP 172.22.64.2.43206 > 172.22.64.1.2380: tcp 0
00:00:00.000157 IP 172.22.64.2.2380 > 172.22.64.1.58336: tcp 84
00:00:00.000027 IP 172.22.64.1.58336 > 172.22.64.2.2380: tcp 0

資料寫入觸發的封包:

00:00:00.005596 IP 172.22.64.1.2380 > 172.22.64.2.43192: tcp 639   <-- AppendEntries
00:00:00.000034 IP 172.22.64.2.43192 > 172.22.64.1.2380: tcp 0     <-- TCP ACK
00:00:00.002040 IP 172.22.64.2.2380 > 172.22.64.1.58336: tcp 75    <-- Follower ACK

實際的 port 號和封包大小會略有不同,重點是觀察 Leader→Follower 出現一個比 heartbeat 大很多的封包,以及 Follower→Leader 的回覆。

:::success 這證明了什麼?

步驟 (3) 已驗證 — Leader (172.22.64.1) 發了一個 639 bytes 的封包給 Follower (172.22.64.2),比 heartbeat 的 85 bytes 大很多。這就是包含 /hello = world 的 AppendEntries。

步驟 (5) 已驗證 — Follower 在 2.040ms 後回覆了一個 75 bytes 的小封包(ACK),比 request 小很多,只是在說「我記好了」。2.040ms 的間隔就是 Follower 處理 + 寫入 WAL (fsync) 的時間。 :::


驗證 4:debug log 觀察 Follower 收到資料

對應步驟 (4):Follower 收到 AppendEntries 後,寫入自己的 WAL。

執行命令

sudo podman exec tkha-control-plane1 bash -c '
  crictl logs $(crictl ps -q --name etcd) 2>&1 | grep "hello"
'

執行結果

{"level":"debug","ts":"2026-04-13T08:17:16.219400Z",
 "caller":"etcdserver/server.go:1971",
 "msg":"applyEntryNormal",
 "raftReq":"header:<ID:1454272925698278610 username:\"kube-apiserver-etcd-client\" auth_revision:1 > put:<key:\"/hello\" value:\"world\" > "}

實際的 timestamp 和 ID 會不同,重點是看到 applyEntryNormalput:<key:"/hello" value:"world">

:::success 這證明了什麼?

步驟 (4) 已驗證 — Follower 的 applyEntryNormal 日誌明確顯示收到了 put:<key:"/hello" value:"world">。這就是 Leader 透過 AppendEntries 送過來的資料,Follower 寫入 WAL 後 apply 到狀態機。 :::


驗證 5:WAL dump 比對 Leader 和 Follower

對應步驟 (2) 和 (4):Leader 和 Follower 都會把操作寫入 WAL,兩邊的 WAL 應該包含相同的 entry。

寫入測試資料

執行命令

sudo podman exec tkha-control-plane etcdctl put /wal-test "raft-replicated-data"

複製 WAL 到 host 並用 etcd-dump-logs 解析

執行命令

# 複製 WAL
sudo podman cp tkha-control-plane:/var/lib/etcd/member /tmp/leader-member
sudo podman cp tkha-control-plane1:/var/lib/etcd/member /tmp/follower-member

# 建立 etcd-dump-logs 需要的目錄結構
sudo mkdir -p /tmp/leader-data/member /tmp/follower-data/member
sudo ln -sf /tmp/leader-member/wal /tmp/leader-data/member/wal
sudo ln -sf /tmp/leader-member/snap /tmp/leader-data/member/snap
sudo ln -sf /tmp/follower-member/wal /tmp/follower-data/member/wal
sudo ln -sf /tmp/follower-member/snap /tmp/follower-data/member/snap

# Dump Leader WAL
sudo etcd-dump-logs /tmp/leader-data | grep "wal-test"

# Dump Follower WAL
sudo etcd-dump-logs /tmp/follower-data | grep "wal-test"

執行結果

Leader WAL:

   2     41857  norm  header:<ID:1454272925698278720 username:"kube-apiserver-etcd-client" auth_revision:1 > put:<key:"/wal-test" value:"raft-replicated-data" >

Follower WAL:

   2     41857  norm  header:<ID:1454272925698278720 username:"kube-apiserver-etcd-client" auth_revision:1 > put:<key:"/wal-test" value:"raft-replicated-data" >

實際的 index 和 ID 會不同,重點是兩邊的 term、index、data 完全一致。

:::success 這證明了什麼?

步驟 (2) 和 (4) 已驗證 — Leader 和 Follower 的 WAL 中都有完全一致的 entry。term、index、data 一模一樣,證明 Leader 複製給 Follower 的是原封不動的 Raft entry。 :::


驗證 6:從 Follower 讀取確認 commit

對應步驟 (7):Leader 非同步通知 Follower commit 後,Follower 應該也能讀到這筆資料。

執行命令

sudo podman exec tkha-control-plane etcdctl --endpoints=https://172.22.64.2:2379 get /hello

執行結果

/hello
world

:::success 這證明了什麼?

步驟 (7) 已驗證 — 從 Follower(172.22.64.2)的 endpoint 直接讀取,確認 /hello = world 已經 commit 並 apply 到 Follower 的 BoltDB。整個 Raft 寫入流程走完了。 :::


清理

# 刪除測試 key
sudo podman exec tkha-control-plane etcdctl del /hello
sudo podman exec tkha-control-plane etcdctl del /wal-test

# 還原 Follower 的 log level
sudo podman exec tkha-control-plane1 bash -c \
  'sed -i "/--log-level=debug/d" /etc/kubernetes/manifests/etcd.yaml'

# 清理 host 上的暫存檔
sudo rm -rf /tmp/leader-member /tmp/follower-member /tmp/leader-data /tmp/follower-data

驗證結果總表

步驟理論描述驗證編號驗證方式結果
(1)Client 發送 put 請求驗證 3etcdctl put:white_check_mark:
(2)Leader 寫入 WAL驗證 5WAL dump:white_check_mark:
(3)Leader 發送 AppendEntries驗證 3tcpdump: 639B 封包:white_check_mark:
(4)Follower 寫入 WAL驗證 4+5debug log + WAL dump:white_check_mark:
(5)Follower 回覆 ACK驗證 3tcpdump: 75B 封包, 2.040ms:white_check_mark:
(6)Leader commit驗證 3Client 收到 “OK”:white_check_mark:
(7)Follower commit驗證 6從 Follower 讀到 world:white_check_mark:

寫入延遲的組成

Client 感受到的延遲 = (2) Leader fsync + (3) 網路傳輸 + (4) Follower fsync + (5) 網路回覆 + (6) Leader commit

實測:(3) -> (5) 的時間差 = 2.040ms(包含網路 + Follower fsync)
因素影響的步驟說明
:floppy_disk: 磁碟延遲(fsync)(2)、(4)、(6)每個步驟各需要一次 fsync,把資料刷到磁碟
:globe_with_meridians: 網路延遲(peer 通訊)(3)、(5)Leader 和 Follower 之間各需要一次單程傳輸