Hình dung kube-apiserver như bàn lễ tân của một toà nhà chỉ có đúng một cửa ra vào. Cư dân (kubelet), ban quản lý (các controller), khách vãng lai (kubectl) — tất cả đều phải đi qua nó. Nó không bao giờ được rời bàn, và vì mọi thứ đi qua nó, nó phải nhớ trong đầu trạng thái của cả toà nhà. Đó là lý do nó là thành phần nặng nhất.

kube-apiserver là cửa duy nhất vào cụm — phần 43 đo được nó ngốn 265 MB, gấp năm lần etcd. Bài này đo xem nó làm gì để đáng chừng đó.

Một lệnh kubectl tốn gì

Tổng Phần máy chủ
kubectl version --client (không gọi mạng) 24 ms —
kubectl get --raw /healthz 28 ms 4 ms
kubectl get pods 32 ms 8 ms
kubectl create configmap 29 ms 5 ms

Kết quả này làm tôi phải nhìn lại hai lần. Binary kubectl khởi động tốn 24 mili giây. Phần việc thật của apiserver — xác thực, phân quyền, đọc hoặc ghi etcd — chỉ 4 đến 8 mili giây, tức 15–25% thời gian một lệnh. Tức là đa số thời gian bạn chờ một lệnh kubectl không phải chờ cụm, mà chờ chính cái binary mặc quần áo đi tới cửa.

Hệ quả thực dụng: một kịch bản gọi kubectl một nghìn lần tốn 24 giây chỉ để khởi động tiến trình, trước khi cụm làm bất cứ việc gì.

Cách xử lý thì đơn giản:

# thay vì lặp kubectl
for f in *.yaml; do kubectl apply -f $f; done

# gộp thành một lời gọi
kubectl apply -f ./thu-muc/

Với công cụ chạy thường xuyên, dùng thư viện client (client-go, kubernetes cho Python) thay vì gọi binary — nó giữ kết nối và bỏ hẳn 24 ms đó.

watch: cách Kubernetes không phải hỏi liên tục

Mở kubectl get pods -w, rồi tạo một pod:

sự kiện đến sau 59 ms   (đã gồm cả thời gian chạy kubectl run)

Và bốn sự kiện cho một vòng đời pod:

wtest   Pending
wtest   Pending
wtest   ContainerCreating
wtest   Running

Mỗi lần trạng thái đổi là một sự kiện được đẩy tới, không phải hỏi tới.

Đây là cơ chế nền của toàn bộ Kubernetes. kube-scheduler không hỏi "có pod nào chưa xếp chỗ không" mỗi giây — nó mở một watch và ngồi đợi. kube-controller-manager cũng vậy, kubelet cũng vậy.

Nếu mọi thành phần đều hỏi định kỳ, apiserver sẽ phải phục vụ hàng nghìn truy vấn mỗi giây trên một cụm lớn. Với watch, nó chỉ gửi khi có gì đổi. Chi phí tỷ lệ với số thay đổi, không tỷ lệ với số người hỏi nhân tần số hỏi — và đó là khác biệt sống còn khi cụm lớn lên.

Kỹ thuật này cũng là lý do apiserver tốn bộ nhớ: nó giữ một bộ nhớ đệm của mọi object và một danh sách người đang theo dõi cho từng loại tài nguyên.

Cụm rỗng vẫn gọi ba lượt mỗi giây

Đo apiserver_request_total trên cụm không có ai dùng:

89 lượt trong 30 giây  =  3,0 lượt/giây

Không ai chạy kubectl, không ai triển khai gì. Ba lượt mỗi giây đó là:

  • kubelet báo cáo trạng thái node và pod
  • các vòng lặp điều hoà của controller-manager
  • scheduler cập nhật thông tin node
  • các bộ kiểm tra sức khoẻ và gia hạn khoá lãnh đạo

apiserver không bao giờ rảnh. Trên cụm 100 node, con số này nhân lên tương ứng — và đó là lý do nó chiếm 265 MB.

Chuỗi một yêu cầu đi qua

xác thực
   -> phân quyền (RBAC)
      -> admission (mutating)
         -> kiểm tra hợp lệ
            -> admission (validating)
               -> ghi etcd

Sáu bước cho mỗi lời gọi ghi.

Xác thực — bạn là ai. Chứng chỉ client, token của ServiceAccount, hoặc nhà cung cấp OIDC.

Phân quyền — bạn được làm gì. RBAC so hành động với các Role và ClusterRole đã gắn.

Admission — chỗ thú vị nhất, và cũng nguy hiểm nhất. Đây là nơi các bộ điều khiển có thể sửa object (mutating) hoặc từ chối nó (validating). Lưới dịch vụ chèn sidecar ở đây; các công cụ chính sách chặn manifest không đạt chuẩn ở đây.

Điểm nguy hiểm: webhook admission nằm ngay giữa đường ghi. Webhook chậm hoặc chết làm mọi lệnh apply chậm hoặc hỏng theo — kể cả với object nó không quan tâm, nếu failurePolicy: Fail và bộ lọc quá rộng.

Đây là một trong những cách làm cụm ngừng hoạt động hoàn toàn mà không thành phần lõi nào hỏng. Khi kubectl apply bỗng treo 30 giây rồi lỗi, danh sách webhook là chỗ nhìn đầu tiên:

kubectl get validatingwebhookconfigurations
kubectl get mutatingwebhookconfigurations

Giới hạn tốc độ ở hai phía

Phía client, client-go mặc định QPS 50, Burst 100. Vượt là kubectl tự chờ, không báo lỗi. Dấu hiệu là dòng Throttling request khi chạy với -v=6.

Phía máy chủ, API Priority and Fairness chia yêu cầu vào các mức ưu tiên. Lưu lượng của kubelet và leader-election được ưu tiên hơn lệnh kubectl của con người — nên khi cụm quá tải, thứ chậm trước là công cụ của bạn, còn cụm vẫn tự vận hành.

Đây là thiết kế đúng, và cũng là lý do "cụm chậm" nhiều khi chỉ có nghĩa là "kubectl của tôi chậm".

Hai lệnh để nhìn vào bên trong

# xem kubectl thật sự gọi những gì
kubectl get pods -v=8 2>&1 | grep -E "^I.*(GET|POST|PUT)"

# xem apiserver đang làm gì
kubectl get --raw /metrics | grep apiserver_request_duration_seconds_bucket | head

Lệnh đầu rất hữu ích khi gỡ lỗi phân quyền: nó cho bạn đúng đường dẫn API mà kubectl gọi, và đó là thứ cần khai trong Role.

Cùng một lằn ranh "đẩy vs hỏi", đổi ngăn xếp

Cái ý hay nhất ở đây — "đừng hỏi liên tục, hãy đăng ký rồi chờ được đẩy tới" — không phải phát minh của Kubernetes. Nó là một trong những đánh đổi kiến trúc lặp đi lặp lại nhiều nhất trong nghề.

Trong thế giới Java, đó là khác biệt giữa một vòng lặp while(true){ select ...; sleep(1s); } quét bảng, với một consumer Kafka/JMS ngồi chờ message được đẩy tới, hay mẫu Observer/reactive streams phát sự kiện khi state đổi. Spring Data thậm chí còn không cần bạn hỏi — entity listener phát event khi có thay đổi.

Trong Go, chính client-go dựng watch lên trên channel và select: goroutine chặn chờ trên channel, được đánh thức đúng lúc có sự kiện, thay vì bận rộn thăm dò.

Còn ở cơ sở dữ liệu, đây là đúng cuộc chiến giữa polling một bảng (SELECT ... WHERE updated_at > ? chạy mỗi vài giây — đơn giản nhưng tải tăng theo tần số và số client) với LISTEN/NOTIFY của PostgreSQL hoặc CDC/logical replication (Debezium đọc WAL) — hệ chỉ phát tín hiệu khi thật sự có thay đổi.

Mẫu số chung: khi nhiều bên cần biết trạng thái của một nguồn chung đổi lúc nào, mô hình "hỏi định kỳ" có chi phí tỷ lệ với số bên × tần số hỏi (phần lớn là hỏi không công), còn mô hình "đăng ký rồi được đẩy" có chi phí tỷ lệ với số thay đổi thật — nên chỉ mô hình sau mới co giãn nổi khi hệ lớn lên. Nhưng cái giá của việc tập trung mọi thứ qua một cửa là: bất cứ thứ gì đồng bộ bạn cắm vào đúng đường đi chung đó — một webhook admission giữa đường ghi, một trigger BEFORE INSERT gọi ra ngoài, một servlet filter trên mọi request — đều biến thành điểm hỏng dùng chung cho tất cả. Nên câu hỏi đúng khi thiết kế không chỉ là "hỏi hay được đẩy" mà còn là "trên con đường mà mọi yêu cầu đều phải đi qua, tôi đã đặt thứ gì có thể chậm hoặc chết chưa".

Muốn tự tách chi phí client khỏi chi phí máy chủ trên cụm của bạn, chạy thử:

time (for i in $(seq 1 20); do kubectl version --client >/dev/null; done)
time (for i in $(seq 1 20); do kubectl get --raw /healthz >/dev/null; done)

Hiệu hai con số chia cho 20 là thời gian thật của một vòng gọi tới apiserver. Nếu nó vượt vài chục mili giây trên cụm không tải, etcd hoặc webhook admission là chỗ đáng xem.

Phần sau đo Deployment và ReplicaSet: cơ chế đứng sau việc cập nhật không gián đoạn.