antony@notes:~/kubernetes$ cat "etcd-寫入一筆資料後-Leader-和-Follower-做了哪些事.md"
etcd 寫入一筆資料後,Leader 和 Follower 做了哪些事?
etcd 寫入一筆資料後,Leader 和 Follower 做了哪些事?
前置準備
1. 建立 tkha HA 叢集
本實驗需要一個 3 control-plane 的 etcd HA 叢集。如果還沒建立,先用 tk 工具建立:
~/tk/bin/kto tkhakto 會建立叢集並初始化第一個 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 table3. 安裝 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/
}容器內的
etcdctl、crictl、sed是 tkha 叢集自帶的,不需要額外安裝。
實驗環境
| 項目 | 值 |
|---|---|
| 叢集 | tkha(3 control-plane HA) |
| etcd 版本 | 3.6.5 |
| Leader | 172.22.64.1(tkha-control-plane) |
| Follower | 172.22.64.2、172.22.64.3 |
| Heartbeat 間隔 | 250ms |
| Election Timeout | 2500ms |
先搞懂這些術語
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"執行結果:
OKtcpdump 抓到的封包
執行結果(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 會不同,重點是看到
applyEntryNormal和put:<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 請求 | 驗證 3 | etcdctl put | :white_check_mark: |
| (2) | Leader 寫入 WAL | 驗證 5 | WAL dump | :white_check_mark: |
| (3) | Leader 發送 AppendEntries | 驗證 3 | tcpdump: 639B 封包 | :white_check_mark: |
| (4) | Follower 寫入 WAL | 驗證 4+5 | debug log + WAL dump | :white_check_mark: |
| (5) | Follower 回覆 ACK | 驗證 3 | tcpdump: 75B 封包, 2.040ms | :white_check_mark: |
| (6) | Leader commit | 驗證 3 | Client 收到 “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 之間各需要一次單程傳輸 |