Skip to content

Resource Versioning: Optimistic Locking, Conflict Resolution

Vì Sao Quan Trọng Ở Production

Kubernetes không dùng pessimistic locking (khóa object trước khi sửa, giữ khóa cho tới khi hoàn tất) cho bất kỳ write path nào. Mọi update đều là optimistic concurrency control — cho phép nhiều client cùng đọc và tính toán thay đổi song song, chỉ phát hiện xung đột tại thời điểm ghi. Đây là quyết định thiết kế nền tảng, không phải chi tiết vặt: nó là lý do một cluster có hàng trăm controller cùng lúc đọc và ghi vào cùng object (ví dụ nhiều controller cùng patch status của một Pod) vẫn hoạt động đúng mà không cần một cơ chế lock tập trung nào — nhưng đổi lại, mọi client bắt buộc phải tự xử lý conflict, và xử lý sai là nguồn gốc của rất nhiều bug tinh vi trong Operator tự viết.

resourceVersion Là Token Optimistic Concurrency, Không Phải Timestamp

Điều đầu tiên cần loại bỏ khỏi mental model: resourceVersion không phải một con số thời gian, không thể so sánh lớn/nhỏ có ý nghĩa ngữ nghĩa giữa hai resource khác nhau, và không được phép "đoán" hay "tính toán" giá trị tiếp theo. Nó là một chuỗi opaque trực tiếp bắt nguồn từ mod_revision trong etcd (xem phần etcd Consistency) — giá trị duy nhất có ý nghĩa là: hai object có cùng resourceVersion là cùng một trạng thái; resourceVersion khác nhau nghĩa là đã có ít nhất một lần ghi xảy ra ở đâu đó giữa hai lần đọc.

Mọi object Kubernetes mang theo metadata.resourceVersion phản ánh đúng lần ghi gần nhất trên chính object đó. Khi client gửi một UPDATE (không phải PATCH — phân biệt ở phần sau) kèm object có resourceVersion cụ thể, storage layer thực hiện đúng một so sánh duy nhất trước khi ghi:

Nếu resourceVersion trong request == mod_revision hiện tại của key trong etcd:
    Cho phép ghi, tăng mod_revision lên giá trị mới
Ngược lại:
    Từ chối, trả về 409 Conflict

Về bản chất, đây chính là một thao tác compare-and-swap (CAS) cấp thấp — API server dịch nó thành một etcd transaction (Txn) với điều kiện so khớp revision, tận dụng trực tiếp cơ chế transaction nguyên tử của etcd thay vì tự cài đặt locking ở tầng ứng dụng.

Vòng Đời Điển Hình Của Một Conflict

Controller A: Get(obj)   → resourceVersion = "100"
Controller B: Get(obj)   → resourceVersion = "100"   (cùng đọc, chưa ai ghi)

Controller A: sửa obj.spec.foo, Update(obj với resourceVersion="100")
              → API server: 100 == mod_revision hiện tại (100) → CHO PHÉP
              → etcd tăng mod_revision lên "101"
              → obj mới trả về có resourceVersion = "101"

Controller B: sửa obj.spec.bar, Update(obj với resourceVersion="100")  ← vẫn giữ giá trị cũ
              → API server: 100 != mod_revision hiện tại (101) → TỪ CHỐI
              → 409 Conflict

Controller B bắt buộc phải xử lý lỗi này bằng cách đọc lại object mới nhất, áp lại thay đổi mong muốn lên phiên bản mới, rồi thử ghi lại — đây gọi là retry-on-conflict, và nó là lý do vì sao mọi logic Reconcile đúng chuẩn phải idempotent và có thể chạy lại toàn bộ từ đầu mà không gây tác dụng phụ sai lệch, thay vì giả định "chỉ chạy đúng một lần thành công".

Vì Sao Không Dùng Pessimistic Lock

Nếu Kubernetes dùng lock kiểu truyền thống (giữ khóa trong lúc client tính toán thay đổi), một client bị treo hoặc chết giữa chừng trong lúc giữ khóa sẽ khiến object đó bị khóa vĩnh viễn — cần thêm cơ chế lease/timeout phức tạp để tự giải phóng, và vẫn tồn tại cửa sổ thời gian object không thể sửa bởi bất kỳ ai khác dù client giữ khóa hoàn toàn không hoạt động. Optimistic concurrency loại bỏ hoàn toàn vấn đề này: không có khái niệm "giữ khóa", nên không có khái niệm "khóa bị treo" — chi phí đánh đổi là một số lần ghi bị từ chối và phải thử lại, chấp nhận được vì trong phần lớn hệ thống, xác suất hai client cùng ghi đúng một object trong cùng một khoảnh khắc là thấp, và retry là thao tác rẻ.

Các Loại Patch — Không Phải Tất Cả Đều Kiểm Tra resourceVersion

Đây là điểm dễ gây nhầm lẫn: Kubernetes hỗ trợ nhiều "kiểu ghi" khác nhau (PUT cho Update, và ba biến thể PATCH), và không phải kiểu nào cũng bắt buộc kiểm tra optimistic concurrency theo cùng một cách.

LoạiContent-TypeCơ chếKiểm tra conflict
Update (PUT)Thay thế toàn bộ object bằng object client gửi lênBắt buộc: resourceVersion trong object gửi lên phải khớp hiện tại
JSON Patchapplication/json-patch+jsonDanh sách thao tác tường minh theo RFC 6902 (add, remove, replace theo path cụ thể)Không tự động — client phải tự thêm test operation nếu cần
JSON Merge Patchapplication/merge-patch+jsonObject con chứa các field cần ghi đè, theo RFC 7386Không kiểm tra resourceVersion mặc định
Strategic Merge Patchapplication/strategic-merge-patch+jsonTương tự merge patch nhưng hiểu ngữ nghĩa Kubernetes (ví dụ merge theo key trong list thay vì thay thế toàn bộ mảng)Không kiểm tra resourceVersion mặc định

JSON Patch là chuẩn RFC tổng quát, thao tác trên đường dẫn JSON cụ thể ({"op": "replace", "path": "/spec/replicas", "value": 3}) — không có khái niệm gì đặc thù Kubernetes, hoạt động trên bất kỳ tài liệu JSON nào.

Strategic Merge Patch là phát minh riêng của Kubernetes để giải quyết một vấn đề mà JSON Merge Patch chuẩn không xử lý tốt: khi một field là mảng (ví dụ containers trong PodSpec), JSON Merge Patch chuẩn sẽ thay thế toàn bộ mảng nếu patch chứa field đó — không có khái niệm "merge từng phần tử theo key". Strategic Merge Patch dùng metadata gắn trong Go struct tag (patchMergeKey, patchStrategy) để biết cách merge đúng theo từng phần tử dựa trên một key định danh (ví dụ name trong danh sách container) thay vì ghi đè toàn bộ mảng — đây là lý do kubectl patch mặc định dùng strategic merge patch cho built-in resource, nhưng CRD không có struct tag Go nên bắt buộc phải dùng JSON Merge Patch hoặc JSON Patch, không hỗ trợ strategic merge.

Vì Sao Patch Không Tự Động Kiểm Tra Conflict Là Con Dao Hai Lưỡi

Việc PATCH không mặc định kiểm tra resourceVersion là chủ đích thiết kế: nó cho phép nhiều client cùng patch các field khác nhau trên cùng object mà không xung đột giả tạo — ví dụ một controller patch status.phase trong khi một controller khác patch metadata.labels cùng lúc, cả hai đều thành công vì chúng không thực sự tranh chấp cùng dữ liệu. Nhưng đây cũng chính là nguồn gốc của lost update nếu dùng sai: nếu logic client đọc toàn bộ object, tính toán thay đổi dựa trên field A, rồi gửi patch chỉ chứa field B nhưng dựa trên giả định sai về giá trị hiện tại của field A, một client khác vừa sửa field A ngay trước đó có thể bị âm thầm ghi đè kết quả tính toán mà không có 409 Conflict nào cảnh báo — vì patch method này không được thiết kế để phát hiện loại xung đột đó.

Server-Side Apply: Giải Pháp Cho Multi-Writer Field Ownership

Server-Side Apply (SSA) ra đời để giải quyết một lớp vấn đề mà cả Update lẫn Patch truyền thống đều xử lý kém: nhiều tác nhân (kubectl của người dùng, một Operator, HPA, VPA) cùng "sở hữu" các phần khác nhau của cùng một object, và cần một cơ chế biết rõ ai đang quản lý field nào để tránh việc một tác nhân vô tình xóa field do tác nhân khác quản lý khi áp dụng cấu hình đầy đủ của chính nó.

Mỗi lần ghi qua SSA, API server ghi lại trong metadata.managedFields ai quản lý field nào:

yaml
metadata:
  managedFields:
  - manager: my-controller
    operation: Apply
    fieldsV1:
      f:spec:
        f:replicas: {}
  - manager: kubelet
    operation: Update
    fieldsV1:
      f:status: {}

Khi hai manager cùng cố gắng áp dụng giá trị cho cùng một field, SSA phát hiện xung đột quyền sở hữu và trả về lỗi conflict, liệt kê rõ manager nào đang giữ quyền — khác về bản chất với 409 Conflict của optimistic concurrency (vốn dựa trên resourceVersion, không quan tâm field nào thay đổi): xung đột SSA là xung đột về quyền sở hữu field, không phải xung đột về thời điểm đọc-ghi. Client cần chủ động dùng --force-conflicts (hoặc field tương đương trong client-go) để giành quyền sở hữu field đó, một hành động phải được cân nhắc kỹ vì nó ghi đè quyết định của manager khác — nếu làm sai, hai controller có thể rơi vào vòng lặp giành quyền sở hữu field liên tục lẫn nhau, mỗi lần reconcile lại force-conflict field mà bên kia vừa ghi.

Nguyên tắc thiết kế Operator đúng chuẩn với SSA: mỗi Operator chỉ nên khai báo (declare) đúng những field nó thực sự chịu trách nhiệm quản lý, không copy nguyên toàn bộ spec object rồi apply lại — làm vậy vô tình tuyên bố quyền sở hữu trên mọi field, kể cả field vốn thuộc về tác nhân khác, tạo xung đột không cần thiết ngay cả khi giá trị thực chất giống hệt nhau.

Ràng Buộc Và Failure Mode Ở Scale

  • Bỏ qua xử lý 409 Conflict và coi nó như lỗi cần alert khẩn cấp là hiểu sai bản chất — trong hệ thống có nhiều writer đồng thời, conflict là kết quả mong đợi của thiết kế, không phải bất thường. Retry với backoff nhỏ (không cần exponential dài như xử lý lỗi hạ tầng) là phản ứng đúng; alert dựa trên tần suất 409 bất thường tăng đột biến mới là tín hiệu đáng chú ý, không phải sự tồn tại của 409 nói chung.
  • Dùng Update (PUT) thay vì Patch cho controller chỉ cần sửa một vài field buộc client phải Get toàn bộ object trước, làm tăng cửa sổ thời gian có thể xảy ra conflict (thời gian giữa Get và Update càng dài, xác suất object bị ghi bởi bên khác trong lúc đó càng cao) — và tăng khả năng vô tình ghi đè field do tác nhân khác vừa sửa nếu object trong tay đã stale dù chỉ một chút. Patch (đặc biệt Strategic Merge Patch hoặc SSA) nên là lựa chọn mặc định cho mọi controller chỉ cần sửa một phần object.
  • Nhiều controller cùng patch cùng một field mà không dùng SSA (ví dụ cả hai đều dùng JSON Merge Patch thô lên spec.replicas) sẽ không bao giờ nhận được cảnh báo xung đột nào từ hệ thống — chúng đơn giản ghi đè lẫn nhau theo thứ tự request tới trước, tạo ra hành vi "flapping" khó chẩn đoán (giá trị field liên tục nhảy qua lại giữa hai giá trị mong muốn của hai controller). SSA được thiết kế chính xác để phơi bày loại xung đột này ra ánh sáng thay vì để nó âm thầm xảy ra.
  • CRD không hỗ trợ Strategic Merge Patch là hạn chế dễ bị quên khi port logic từ built-in resource sang custom resource — code dùng kubectl patch --type=strategic hoặc tương đương trong client-go sẽ thất bại hoặc rơi về hành vi merge patch thông thường (ghi đè toàn bộ mảng) trên CRD, một khác biệt hành vi tinh vi có thể gây mất dữ liệu nếu không được kiểm tra kỹ khi viết Operator cho custom resource có field kiểu mảng.

References