Etcd Consistency: Linearizability Guarantees, Watch Caching
Vì Sao Quan Trọng Ở Production
Mọi state của Kubernetes cluster — trừ khi GKE dùng backend Spanner thay thế (xem Chapter 5, mục etcd vs Spanner) — cuối cùng đều quy về một record trong etcd. Nhưng phần lớn kỹ sư vận hành Kubernetes có mental model rất mơ hồ về việc etcd thực sự đảm bảo tính nhất quán gì, và quan trọng hơn: API server không đọc trực tiếp từ etcd cho mọi request — nó có một lớp watch cache riêng nằm giữa client và etcd. Hai lớp consistency chồng lên nhau này (etcd's Raft-backed consistency, và API server's watch cache) có ngữ nghĩa khác nhau, và việc lẫn lộn giữa chúng là nguồn gốc của nhiều giả định sai trong logic controller — đặc biệt các giả định về tính "mới nhất" của dữ liệu đọc được.
Lớp 1: Raft Consensus — Nền Tảng Đảm Bảo Tính Nhất Quán Của etcd
etcd là một key-value store phân tán, sao chép dữ liệu qua giao thức đồng thuận Raft giữa các thành viên trong cụm (thường 3 hoặc 5 node). Raft đảm bảo mọi ghi đã được xác nhận (committed) đã được sao chép tới đa số (quorum) thành viên trước khi coi là hoàn tất — đây là cơ chế nền tảng cho phát biểu chính thức của etcd: "mọi operation hoàn tất đều bền vững (durable), và toàn bộ dữ liệu truy cập được cũng là dữ liệu bền vững". Nói cách khác, một khi client nhận phản hồi "ghi thành công" từ etcd, dữ liệu đó không thể biến mất kể cả khi node etcd hiện đang giữ leadership bị sập ngay sau đó — vì đã có ít nhất một quorum khác cùng giữ bản sao.
Strict Serializability — Mức Đảm Bảo Mặc Định
etcd cung cấp strict serializability cho mọi thao tác KV theo mặc định: các thao tác là atomic và xảy ra theo một thứ tự toàn cục (total order), nhất quán với thứ tự thời gian thực (real-time order) mà chúng thực sự xảy ra. Đây là mức đảm bảo mạnh nhất trong các mô hình consistency phân tán — mạnh hơn eventual consistency, và mạnh hơn cả linearizability thông thường (vì strict serializability còn ràng buộc cả thứ tự giữa các transaction đa-key, không chỉ đơn lẻ từng key).
Linearizable Read vs Serializable Read — Đánh Đổi Latency Lấy Consistency
Đây là điểm quan trọng nhất cho việc tune hiệu năng: etcd cho phép client chọn giữa hai mức đọc:
- Linearized read (mặc định): đảm bảo "một read xảy ra sau một write hoàn tất sẽ luôn quan sát được write đó" — về bản chất, read phải đi qua cùng cơ chế đồng thuận Raft như write để chắc chắn nó phản ánh trạng thái mới nhất đã được quorum xác nhận. Cái giá phải trả là latency — read linearized tốn chi phí tương đương một round-trip đồng thuận, dù bản thân nó không ghi gì cả.
- Serializable read: bỏ qua bước xác nhận qua Raft, đọc thẳng từ local state của node etcd đang phục vụ request — nhanh hơn đáng kể nhưng có thể trả về dữ liệu cũ hơn so với quorum nếu node đó tạm thời tụt lại phía sau leader (do độ trễ sao chép, dù rất nhỏ trong điều kiện bình thường).
Đây chính xác là sự đánh đổi mà tham số resourceVersion của Kubernetes API phơi bày ra cho client ở tầng cao hơn (xem phần Watch Mechanism): resourceVersion không đặt (hoặc GET không kèm tham số) tương ứng với linearized read; còn resourceVersion=0 cho LIST cho phép API server phục vụ từ watch cache trong bộ nhớ — tương tự về mặt tinh thần với serializable read, đánh đổi độ mới lấy hiệu năng.
MVCC: Mô Hình Revision Là Đồng Hồ Logic Toàn Cục
etcd dùng Multi-Version Concurrency Control (MVCC) — mỗi thao tác thay đổi store (kể cả trong một transaction đa-key) nhận một revision tăng dần duy nhất. Revision đóng vai trò đồng hồ logic (logical clock) cho toàn bộ key-value store: giá trị revision càng cao thể hiện thay đổi càng gần đây, và vì nó tăng đơn điệu toàn cục (không phải theo từng key riêng lẻ), nó cho phép so sánh thứ tự thời gian giữa các thay đổi trên các key hoàn toàn khác nhau — đây chính là nền tảng để resourceVersion của Kubernetes có thể dùng chung một giá trị baseline cho một LIST trải trên nhiều loại resource khác nhau (dù trong thực tế Kubernetes thường tách namespace theo key riêng cho từng resource type, nhưng cơ chế revision nền tảng vẫn dùng chung một bộ đếm logic).
Ba loại revision cần phân biệt trên mỗi key:
create_revision— revision tại thời điểm key được tạo lần đầu (hoặc tạo lại sau khi bị xóa).mod_revision— revision của lần ghi gần nhất trên chính key đó. Đây chính là giá trị mà cơ chế optimistic concurrency của Kubernetes so khớp khi thực hiệnUPDATE(xem phần Resource Versioning).version— số lần key đã bị ghi kể từ lần tạo gần nhất (reset về 0 khi key bị xóa rồi tạo lại).
Vì MVCC giữ lại lịch sử các phiên bản trước của một key (không ghi đè ngay lập tức), etcd có thể phục vụ đọc tại một revision cụ thể trong quá khứ — miễn là revision đó còn nằm trong "cửa sổ lịch sử" chưa bị compact.
Compaction: Vì Sao Lịch Sử Không Vô Hạn
Nếu etcd giữ mọi phiên bản của mọi key mãi mãi, dung lượng lưu trữ sẽ tăng tuyến tính vô hạn theo số lần ghi — không khả thi cho một hệ thống chạy liên tục nhiều năm. Compaction định kỳ loại bỏ các revision cũ hơn một ngưỡng, chỉ giữ lại trạng thái mới nhất của mỗi key cộng với một cửa sổ lịch sử gần đây (thường tính bằng giờ trong cấu hình mặc định, tùy chỉnh theo --auto-compaction-retention). Sau compaction, mọi request đọc hoặc watch tại một revision đã bị compact sẽ thất bại — đây chính là nguồn gốc kỹ thuật của lỗi 410 Gone mô tả ở phần Watch Mechanism: khi resourceVersion mà client đang giữ tương ứng với một etcd revision đã bị compaction xóa bỏ, không có cách nào phục hồi ngoài relist từ đầu.
Compaction là lý do etcd (và do đó cả Kubernetes) không phải một hệ thống lưu trữ lịch sử đầy đủ — nó chỉ đảm bảo tính nhất quán cho trạng thái hiện tại cộng một cửa sổ gần đây, không phải một audit log vĩnh viễn. Với nhu cầu audit trail đầy đủ, hệ thống đúng cần dùng là Kubernetes Audit Logging (ghi ra hệ thống ngoài etcd), không phải trông cậy vào lịch sử revision của etcd.
Watch Guarantees Của etcd — Sáu Đảm Bảo, Nhưng Không Bao Gồm Linearizability
Tài liệu chính thức etcd liệt kê rõ sáu đảm bảo cho watch stream: có thứ tự (ordered) theo đúng revision; duy nhất (unique), không lặp sự kiện; đáng tin cậy (reliable), không bỏ sót sự kiện trung gian trong cửa sổ lịch sử còn giữ; atomic, nhiều thay đổi trong cùng một transaction không bị tách rời qua nhiều watch event riêng lẻ; có thể resume (resumable) sau khi ngắt kết nối, miễn là revision còn nằm trong lịch sử; và có bookmark (bookmarkable) qua progress notification.
Điều quan trọng và dễ bị bỏ sót nhất: etcd tự nói rõ nó không đảm bảo linearizability cho watch operation. Watch về bản chất là một luồng thông báo bất đồng bộ — nó không đi qua cùng cơ chế xác nhận quorum như một linearized read. Hệ quả thực hành: không nên coi việc nhận được một watch event là bằng chứng tuyệt đối rằng mọi client khác trong cluster đã hoặc sẽ ngay lập tức thấy đúng trạng thái đó nếu chúng thực hiện linearized read tại đúng thời điểm đó — trong hầu hết các trường hợp thực tế điều này vẫn đúng vì độ trễ cực nhỏ, nhưng về mặt hình thức đây không phải một đảm bảo cứng của hệ thống.
Lớp 2: Watch Cache Của kube-apiserver — Lớp Đệm Xếp Chồng Lên etcd
Đây là điểm hay bị nhầm lẫn nhất: client của Kubernetes API (kubectl, client-go, mọi Operator) không bao giờ nói chuyện trực tiếp với etcd. Chúng chỉ nói chuyện với kube-apiserver, và kube-apiserver tự duy trì một watch cache riêng trong bộ nhớ tiến trình — về bản chất, chính API server cũng chạy một Reflector nội bộ giữ một bản sao gần như toàn bộ resource của cluster trong RAM, đồng bộ liên tục qua đúng một watch stream duy nhất mở tới etcd.
etcd (nguồn sự thật, Raft-backed, MVCC)
│ một watch stream duy nhất từ API server tới etcd
▼
kube-apiserver Watch Cache (trong bộ nhớ, ring-buffer theo revision)
│ N watch stream, mỗi client một stream riêng, phục vụ từ cache này
▼
Client (kubectl, client-go Reflector, mọi Operator)Lý do kiến trúc này tồn tại là hiệu năng: nếu mỗi client watch riêng lẻ (có thể lên tới hàng nghìn trong một cluster lớn) đều mở watch stream trực tiếp tới etcd, etcd sẽ phải fan-out cùng một luồng sự kiện ra hàng nghìn kết nối — tốn tài nguyên etcd theo cấp số nhân với số lượng client. Bằng cách đặt watch cache ở tầng API server, etcd chỉ cần phục vụ một watch stream cho mỗi API server instance (thường một cluster có vài instance API server), và chính watch cache đó mới đảm nhận việc fan-out ra hàng nghìn client — công việc fan-out này rẻ hơn nhiều vì hoàn toàn diễn ra trong bộ nhớ cùng một tiến trình, không tốn round-trip mạng hay chi phí đồng thuận Raft.
Watch Cache Cũng Là Nguồn Phục Vụ Cho resourceVersion=0
Khi một request LIST gửi resourceVersion=0, API server không hỏi etcd — nó trả lời trực tiếp từ watch cache trong bộ nhớ. Đây là lý do việc informer luôn LIST với resourceVersion=0 khi khởi tạo (đã đề cập ở phần Informer Pattern) mang lại lợi ích hiệu năng thực sự: nó tránh hoàn toàn một lượt đọc quorum-consistent (chi phí Raft) tới etcd, đổi lấy khả năng dữ liệu trả về "hơi cũ" tính bằng thời gian đồng bộ giữa watch cache và etcd (thường dưới một giây trong điều kiện bình thường).
Watch cache của API server cũng có kích thước hữu hạn theo revision (một ring-buffer giữ một số lượng sự kiện gần đây giới hạn) — độc lập với cửa sổ compaction của chính etcd. Điều này có nghĩa 410 Gone có thể xảy ra vì hai lý do khác nhau về tầng: hoặc vì cửa sổ lịch sử của watch cache API server đã trôi qua điểm resourceVersion client giữ (phổ biến hơn, vì watch cache thường nhỏ hơn cửa sổ compaction của etcd), hoặc (hiếm hơn) vì chính etcd đã compact revision đó.
Ràng Buộc Và Failure Mode Ở Scale
- Nhầm lẫn "watch event đã nhận" với "mọi client khác đã thấy trạng thái mới" dẫn tới race condition trong logic phân tán tự viết bên ngoài phạm vi một cluster — ví dụ hai controller ở hai cluster khác nhau đồng bộ qua watch event rồi giả định linearizability xuyên cluster, điều mà etcd chưa từng cam kết ngay cả trong nội bộ một cluster.
- Cluster có write rate rất cao (nhiều Pod tạo/xóa liên tục, phổ biến ở batch workload hoặc CI runner tự động scale) khiến cả cửa sổ compaction của etcd lẫn watch cache của API server "trôi" nhanh hơn theo revision, làm tăng tần suất
410 Gonecho bất kỳ client nào có độ trễ xử lý sự kiện hoặc gián đoạn kết nối dù chỉ trong thời gian ngắn — đây là lý do cùng một cấu hình timeout mạng có thể an toàn ở cluster ít biến động nhưng gây lỗi liên tục ở cluster có write rate cao. - Chọn sai giữa read nhất quán và read từ cache cho logic nhạy cảm (ví dụ kiểm tra "resource này đã được người khác tạo chưa" trước khi tạo, nhằm tránh trùng lặp) là lỗi thiết kế nghiêm trọng nếu vô tình dùng
resourceVersion=0— vì watch cache có thể chưa kịp thấy bản ghi vừa được tạo bởi một client khác cực gần về thời gian. Với logic cần đảm bảo tính đúng đắn tuyệt đối tại thời điểm quyết định, bắt buộc phải dùng GET/LIST không kèmresourceVersion(buộc đọc nhất quán qua etcd), chấp nhận đánh đổi latency. - GKE với backend Spanner (thay vì self-managed etcd) thay đổi một số đặc tính vận hành (không còn giới hạn 8GB truyền thống của etcd, không cần tự vận hành compaction/defragmentation) nhưng ngữ nghĩa consistency phơi ra cho client qua Kubernetes API không đổi —
resourceVersion, watch guarantee, và mô hình optimistic concurrency vẫn hoạt động theo đúng hợp đồng API đã mô tả ở trên, vì đây là lớp trừu tượng do chính kube-apiserver duy trì, độc lập với backend lưu trữ thực sự bên dưới. Chi tiết so sánh nằm ở Chapter 5, mục etcd vs Spanner.