Kiến Trúc Kubernetes Nhìn Sâu: Vòng Lặp, Watch và API Server

K
Kai··10 min read

Ở series minikube ta đã có bức tranh tổng thể: control plane là bộ não, node là nơi ứng dụng chạy, và mọi thứ đi qua api-server. Đó là mức bản đồ. Bài này phóng to vào cơ chế — không phải các thành phần là gì (bạn đã biết), mà chúng phối hợp với nhau ra sao, dù không thành phần nào gọi trực tiếp đến thành phần khác. Hiểu được cơ chế này, mọi việc ta làm về sau — bootstrap từng binary rồi ghép lại — sẽ có một khung để đặt vào.

Trạng thái mong muốn và vòng lặp điều khiển

Tư duy nền của Kubernetes là declarative: bạn nộp một đối tượng mô tả trạng thái mong muốn ("muốn 3 bản sao nginx"), rồi Kubernetes tự kéo thực tế về cho khớp. Ở series trước ta dừng ở đó. Giờ thêm một tầng. Cái kéo thực tế về khớp là một control loop (vòng lặp điều khiển), và nó hoạt động theo kiểu level-triggered chứ không phải edge-triggered.

Phân biệt này quan trọng. Edge-triggered là phản ứng với sự kiện: "có một pod vừa chết, tạo lại". Level-triggered là phản ứng với trạng thái hiện tại: "đang có 2 pod, muốn 3, tạo thêm 1", bất kể vì sao lại thành ra như vậy.

   EDGE-triggered                    LEVEL-triggered (Kubernetes dùng cái này)
   ───────────────────────          ─────────────────────────────────────────
   nghe "pod chết" → +1             nhìn "có 2, muốn 3" → +1
   nếu lỡ MẤT sự kiện?              lỡ mất gì cũng không sao:
   → mãi mãi thiếu 1 pod            lần lặp sau vẫn thấy "2 ≠ 3" → sửa

Cách làm level-triggered khiến hệ thống tự phục hồi được kể cả khi chính nó vừa lỗi. Controller có thể chết, bỏ lỡ vài sự kiện rồi khởi động lại, và vẫn không sao, vì lần đối chiếu kế tiếp nó nhìn vào trạng thái hiện tại và sửa. Không có sự kiện nào bị mất mà để lại hậu quả lâu dài. Đây là một trong những lý do Kubernetes bền: nó không dựa vào trí nhớ về các sự kiện đã qua, mà liên tục so trạng thái hiện tại với mong muốn.

   ┌──────────────── CONTROL LOOP (chạy liên tục) ───────────────┐
   │                                                             │
   │   OBSERVE  ──►  trạng thái hiện tại = ?   (đọc từ api-server)│
   │      │                                                      │
   │      ▼                                                      │
   │   COMPARE  ──►  hiện tại  vs  mong muốn                     │
   │      │                                                      │
   │      ▼                                                      │
   │   ACT      ──►  nếu lệch: hành động để khớp (ghi qua api)    │
   │      │                                                      │
   │      └──────────────────────► lặp lại                       │
   └─────────────────────────────────────────────────────────────┘

controller-manager gom nhiều controller nhỏ

Thứ ta quen gọi "controller-manager" thật ra là một tiến trình gom hàng chục controller nhỏ, mỗi cái lo một loại đối tượng và chạy đúng vòng lặp trên:

  • Deployment controller thấy Deployment thì tạo hoặc cập nhật ReplicaSet cho khớp.
  • ReplicaSet controller thấy ReplicaSet muốn N bản sao thì đếm pod thực tế, tạo hoặc xóa cho đủ N.
  • Node controller theo dõi node; node mất tín hiệu thì đánh dấu và đuổi pod đi nơi khác.
  • EndpointSlice controller nối một Service tới danh sách pod đang đứng sau nó.
  • ServiceAccount controller, Job controller, CronJob controller, mỗi cái một việc nhỏ.

Các controller này không gọi nhau. Deployment controller không ra lệnh cho ReplicaSet controller; nó chỉ tạo một đối tượng ReplicaSet qua api-server rồi thôi. ReplicaSet controller, vốn đang theo dõi các ReplicaSet, thấy có cái mới và bắt tay làm việc của mình. Chúng giao tiếp gián tiếp qua trạng thái lưu trong cluster, không qua lời gọi trực tiếp. Nhờ vậy, thêm một controller mới không phải sửa controller cũ; nó chỉ cần theo dõi loại đối tượng nó quan tâm.

   kubectl tạo Deployment
        │  (ghi vào cluster qua api-server)
        ▼
   [Deployment ctrl] thấy Deployment mới ──► tạo ReplicaSet
        │
        ▼
   [ReplicaSet ctrl] thấy ReplicaSet mới ──► tạo 3 Pod (chưa có node)
        │
        ▼
   [scheduler] thấy Pod chưa gán node ──► gán node cho từng Pod
        │
        ▼
   [kubelet trên node đó] thấy Pod gán cho mình ──► bảo containerd chạy

Không khâu nào gọi khâu nào; mỗi thành phần chỉ theo dõi và phản ứng với trạng thái.

Cơ chế list-watch

Nếu mỗi controller cứ vài giây lại hỏi api-server "có gì mới không", thì với hàng nghìn đối tượng, api-server sẽ quá tải. Kubernetes giải bằng một cơ chế gọn hơn là list-watch.

  • List: khi mới khởi động, controller hỏi api-server toàn bộ đối tượng loại X hiện có, và nhận về kèm một con số resourceVersion đánh dấu thời điểm ảnh chụp đó.
  • Watch: sau đó controller mở một kết nối HTTP giữ mở lâu (long-lived, kiểu streaming), nói rằng từ resourceVersion này trở đi có thay đổi nào thì đẩy cho nó. api-server không bắt nó hỏi lại; nó chủ động đẩy từng thay đổi (thêm, sửa, xóa) qua kết nối đó.
   Controller                          api-server
      │                                    │
      │── LIST loại X (lấy toàn bộ) ──────►│
      │◄── danh sách + resourceVersion ────│
      │                                    │
      │── WATCH từ resourceVersion ───────►│   (kết nối giữ mở)
      │                                    │
      │◄═══ thay đổi: Pod A bị xóa ════════│   ┐
      │◄═══ thay đổi: Pod B thêm vào ══════│   ├ đẩy theo thời gian thực
      │◄═══ thay đổi: Pod C cập nhật ══════│   ┘

Để khỏi gọi api-server cho mỗi lần đối chiếu, mỗi controller giữ một bản cache cục bộ (informer cache) được cơ chế watch cập nhật liên tục. Vòng lặp đối chiếu đọc từ cache này, vốn nhanh và rẻ, và chỉ ghi về api-server khi cần hành động. Đó là lý do một cluster với hàng nghìn pod vẫn chạy mượt: phần lớn việc đọc xảy ra trên cache trong bộ nhớ, api-server không bị hỏi dồn dập.

Watch cũng là cách kubelet và kube-proxy trên mỗi node biết việc của mình: kubelet watch các Pod được gán cho node của nó, kube-proxy watch các Service và Endpoint. Không ai gửi lệnh xuống node; node tự theo dõi phần liên quan và phản ứng.

Vì sao mọi thứ đi qua api-server

Đến đây thì rõ vì sao api-server là cổng vào duy nhất, và vì sao chỉ một mình nó được nói chuyện với etcd:

        kubectl ─┐
        kubelet ─┤
      scheduler ─┼──►  kube-apiserver  ──►  etcd
     controllers ┤      (cổng vào)          (kho trạng thái)
     kube-proxy ─┘
                         ▲
                  authn → authz → admission → validate → ghi

Mỗi yêu cầu đi vào api-server đều qua một dây chuyền cố định trước khi chạm etcd:

  1. Authentication (authn) — bạn là ai? Xác thực bằng client certificate, token, hoặc cơ chế khác. Đây là lý do cả series ta phải tạo certificate cho từng thành phần: để chúng chứng minh danh tính khi gọi api-server.
  2. Authorization (authz) — bạn được phép làm việc này không? Thường qua RBAC.
  3. Admission control — yêu cầu này có hợp lệ hoặc cần chỉnh không? Các admission controller có thể từ chối, hoặc sửa (gắn giá trị mặc định, tiêm sidecar).
  4. Validation và ghi — kiểm tra đối tượng đúng schema, rồi ghi vào etcd.

Đặt cả bốn chặng này vào một nơi mang lại ba thứ ta không có được nếu mỗi thành phần tự ý đọc ghi etcd: xác thực và phân quyền nhất quán, một điểm ghi audit log duy nhất, và một nguồn trạng thái duy nhất. Nếu kubelet được phép ghi thẳng vào etcd thì cả ba đảm bảo đó mất. Nên quy tắc là chỉ api-server chạm etcd, còn mọi thành phần khác chạm api-server.

Một lệnh kubectl apply đi qua đâu

Ghép tất cả bằng một ví dụ. Bạn chạy kubectl apply -f nginx-deployment.yaml, với Deployment muốn 3 bản sao. Diễn biến như sau:

 1. kubectl  ──POST /apis/apps/v1/.../deployments──►  api-server
              (kèm client cert để xác thực)

 2. api-server: authn → authz (RBAC) → admission → validate
              → ghi đối tượng Deployment vào etcd
              → trả 201 Created cho kubectl   (lệnh của bạn kết thúc ở đây)

 3. Deployment controller (đang WATCH Deployment) thấy cái mới
              → tạo một ReplicaSet (ghi qua api-server → etcd)

 4. ReplicaSet controller (đang WATCH ReplicaSet) thấy "muốn 3, có 0"
              → tạo 3 đối tượng Pod, spec.nodeName còn TRỐNG

 5. scheduler (đang WATCH Pod chưa gán node) thấy 3 pod chưa có node
              → chấm điểm các node, ghi spec.nodeName cho từng pod

 6. kubelet trên mỗi node (đang WATCH Pod gán cho mình) thấy pod mới
              → gọi containerd (qua CRI) kéo image, chạy container
              → báo status "Running" ngược lên api-server → etcd

Để ý bước 2: lệnh kubectl của bạn trả về thành công ngay khi Deployment được ghi vào etcd, tức là trước khi có container nào chạy. Phần còn lại diễn ra bất đồng bộ, do các control loop xử lý dần. Đó là bản chất của mô hình declarative: bạn không tạo container, bạn ghi lại một mong muốn, rồi một loạt vòng lặp biến mong muốn đó thành hiện thực. Khi bạn gõ kubectl get pods vài giây sau và thấy ContainerCreating rồi Running, đó là lúc bạn đang nhìn các vòng lặp làm việc.

HA: ba bản control plane không giẫm chân nhau

Series này dựng ba control plane, nên có một câu hỏi đặt ra: nếu ba controller-manager cùng chạy, liệu cả ba có cùng tạo pod, thành ra gấp ba lần không?

Không, nhờ leader election. controller-manager và scheduler ở chế độ HA tranh nhau giữ một "khóa" (một đối tượng Lease trong cluster). Chỉ kẻ giữ được khóa mới hoạt động; hai kẻ còn lại chạy không tải và chờ. Nếu leader chết, một trong hai kẻ kia giành được khóa và tiếp quản trong vài giây. Vậy là có dự phòng mà không có xung đột.

   controller-mgr-0   controller-mgr-1   controller-mgr-2
        │                  │                  │
        └──── tranh giữ Lease "kube-controller-manager" ────┘
                           │
                  chỉ MỘT thắng = leader (làm việc)
                  hai kẻ kia chờ; leader chết → bầu lại

api-server thì khác: cả ba bản cùng phục vụ song song, vì chúng không giữ trạng thái riêng nào — mọi trạng thái nằm ở etcd. Đó là lý do ta đặt một load balancer phía trước ba api-server (Bài 9): client chỉ thấy một địa chỉ, tải rải đều, một api-server chết thì hai cái kia gánh tiếp. etcd lại khác nữa, nó cần quorum (đa số) để đồng thuận, và đó là chủ đề của Bài 6.

Bức tranh để giữ lại

Từ bài sau ta bắt đầu dựng. Mỗi khi cài một binary, bạn có thể tự hỏi nó đứng ở đâu trong bức tranh này:

   ┌──────────────────── CONTROL PLANE (×3, HA) ────────────────────┐
   │  etcd ◄──(chỉ mình nó)── api-server ──► authn/authz/admission   │
   │                              ▲   ▲                              │
   │            (leader) scheduler┘   └controller-manager (leader)   │
   └──────────────────────────────┬─────────────────────────────────┘
                          (mọi thành phần nói chuyện qua api-server)
              ┌─────────────────────┴─────────────────────┐
              ▼                                            ▼
        ┌── worker ──┐                              ┌── worker ──┐
        │ kubelet ───┼── watch Pod của mình         │ kubelet    │
        │ kube-proxy ┼── watch Service/Endpoint     │ kube-proxy │
        │ containerd ┴── chạy container (qua CRI)    │ containerd │
        └────────────┘                              └────────────┘

Ở các bài tới, mỗi mũi tên trong sơ đồ này trở thành một dòng cấu hình thật: một cờ --etcd-servers, một đường dẫn tới certificate, một địa chỉ load balancer. Lúc đó kiến trúc không còn là lý thuyết nữa, mà là những tiến trình được chỉ cho biết địa chỉ và danh tính của nhau.

Có một thứ xuất hiện ở khắp sơ đồ trên mà ta chưa mổ xẻ: certificate. api-server cần biết kubelet đúng là kubelet; kubelet cần biết nó đang nói chuyện với api-server thật chứ không phải kẻ giả mạo; etcd cần biết chỉ api-server mới được đọc nó. Tất cả dựa trên một hệ thống PKI/TLS mà ta phải tự dựng. Bài 2 giải thích vì sao một cluster Kubernetes cần nhiều certificate đến vậy, và ai ký cho ai — nền cho mọi thứ ta gõ từ Bài 4 trở đi.

Related Posts