Một quầy tính tiền siêu thị vẫn quét một món hàng nhanh như nhau dù trong kho có nghìn hay triệu sản phẩm — thao tác điểm không đổi. Nhưng việc kiểm kê toàn bộ kho thì lâu dần theo số hàng, và tệ hơn tuyến tính: nhiều hàng hơn nghĩa là nhiều lối đi hơn, nhiều lần đối chiếu hơn, nhiều trang giấy phải lật hơn. "Siêu thị chậm" gần như luôn có nghĩa "việc kiểm kê chậm", không phải "quầy tính tiền chậm" — và biết phân biệt hai cái đó đưa thẳng tới chỗ cần sửa. Kubernetes ở quy mô lớn hành xử đúng vậy, và bài này đo cụm thật bắt đầu chậm ở đâu, và chậm ở cái gì.

Tài liệu nói Kubernetes hỗ trợ 5.000 node và 150.000 pod. Ta đo chỗ nó thật sự bắt đầu chậm.

Thời gian LIST tăng nhanh hơn số đối tượng

Tôi tạo ConfigMap theo từng lô và đo lại sau mỗi lô.

Số ConfigMap etcd DB LIST
1.001 13,7 MB 0,08 giây
3.001 16,7 MB 0,23 giây
6.001 34,7 MB 0,81 giây
10.001 58,9 MB 2,53 giây
20.001 119,1 MB 6,79 giây

20 lần số đối tượng cho 85 lần thời gian. Quan hệ không tuyến tính. Sáu giây rưỡi cho một lệnh kubectl get đủ để mọi thứ dùng nó trở nên khó chịu — và mọi controller trong cụm đều làm đúng việc đó lúc khởi động.

Nhưng tạo pod thì không chậm đi

0,7 / 1,0 / 1,0 giây với 20.000 đối tượng trong cụm

Đây là chỗ tôi trông đợi sẽ thấy suy giảm và không thấy. Cụm không chậm nói chung. Cái chậm là LIST và WATCH — tức là kubectl, bảng điều khiển, và mọi controller phải giữ một bản sao trong bộ nhớ. Đúng cái quầy tính tiền vẫn nhanh trong khi kiểm kê thì lâu.

Phân biệt này quan trọng khi chẩn đoán: "cụm chậm" gần như luôn có nghĩa là "một loại thao tác cụ thể chậm". Hỏi đúng câu hỏi — chậm khi làm gì — sẽ đưa thẳng tới nguyên nhân. Và nó cũng cho biết ai chịu thiệt trước: không phải ứng dụng của bạn, mà là Argo CD, Prometheus, bộ điều khiển Ingress — những thứ theo dõi mọi đối tượng.

Bộ nhớ của controller là chỗ vỡ thật

Mỗi controller dùng informer giữ toàn bộ đối tượng nó quan tâm trong RAM. 20.000 ConfigMap với 2 KB dữ liệu mỗi cái là khoảng 40 MB — nhân với mỗi controller theo dõi ConfigMap. Đó là lý do một cụm nhiều đối tượng làm kube-controller-manager phình ra, rồi bị OOM, rồi khởi động lại, rồi phải LIST lại từ đầu — mà LIST bây giờ mất 6,79 giây. Vòng lặp này tự nuôi chính nó.

Cách chữa gốc không phải là cho thêm RAM mà là giảm số đối tượng, hoặc thu hẹp phạm vi theo dõi bằng nhãn.

Hai trần kích thước, và chúng khác nhau

kubectl apply, 1.500.000 byte
   metadata.annotations: Too long: may not be more than 262144 bytes

kubectl create, 1.500.000 byte
   []: Too long: may not be more than 1048576 bytes

kubectl create, 1.000.000 byte
   configmap/to2 created

Trần thật của một đối tượng là 1.048.576 byte — giới hạn của etcd. Nhưng kubectl apply chết sớm gấp bốn lần, ở 262.144 byte. Lý do: apply nhét toàn bộ đối tượng vào chú thích kubectl.kubernetes.io/last-applied-configuration để so sánh lần sau, và chú thích có trần riêng.

Đây là cái bẫy đáng nhớ: cùng một tệp YAML, apply từ chối còn create chấp nhận, và thông báo lỗi nói về metadata.annotations — nghe như bạn khai sai chú thích nào đó, trong khi bạn không khai chú thích nào cả. Cách tránh: dùng kubectl apply --server-side. Server-side apply không dùng chú thích đó, nên nó lấy lại được đủ 1 MB.

Tốc độ tạo

10.000 đối tượng trong 34,3 giây  ≈ 290 mỗi giây

Đây không phải trần của hệ thống — nó là trần của một lệnh apply tuần tự, một luồng, gửi từng đối tượng một. Song song hoá thì nhanh hơn nhiều. Nhưng con số đó vẫn hữu dụng để ước lượng: dựng lại một namespace 5.000 đối tượng từ Git mất khoảng 17 giây bằng một tiến trình. Nếu quy trình khôi phục của bạn giả định "vài giây" thì nên đo lại.

Trần nào chạm trước

Theo thứ tự hay gặp trong thực tế, không theo thứ tự trong tài liệu:

Trần Giá trị Triệu chứng
Pod mỗi node 110 (mặc định kubelet) Pending dù node còn RAM
Kích thước đối tượng 1 MB (262 KB với apply) Too long
Số đối tượng không có trần cứng LIST chậm dần, controller OOM
Kích thước etcd 2 GB mặc định (--quota-backend-bytes) Cụm chuyển sang chỉ đọc

Dòng cuối là dòng nguy hiểm nhất, vì hậu quả của nó không phải "chậm" mà là dừng ghi hoàn toàn. Ở phép đo trên, 20.000 ConfigMap đã chiếm 119 MB — tức khoảng 6% hạn mức mặc định, chỉ với một loại tài nguyên. Và nhớ chuyện đã đo ở phần 41: sự kiện là nhóm khoá đông nhất trong etcd của một cụm bình thường. Chúng tự hết hạn sau một giờ, nhưng trong một giờ đó chúng vẫn chiếm chỗ.

Bốn việc giảm áp lực

  1. Đừng tạo đối tượng cho mỗi lần chạy. Job không có ttlSecondsAfterFinished tích lại mãi. Một dòng cấu hình xoá được hàng nghìn đối tượng.
  2. Thu hẹp phạm vi theo dõi. Controller và bộ giám sát nên lọc theo nhãn hoặc namespace thay vì theo dõi cả cụm.
  3. Đừng lưu dữ liệu lớn trong ConfigMap. Trần 1 MB có lý do; thứ gì lớn nên nằm trong volume hoặc kho đối tượng.
  4. Nén etcd định kỳ. etcd giữ nhiều phiên bản; không nén thì DB phình lên vì lịch sử chứ không vì dữ liệu hiện tại.

Mẫu số chung: thao tác điểm giữ nguyên, còn liệt-kê-toàn-bộ vỡ trước

Cái làm cụm "chậm ở LIST nhưng không chậm ở tạo pod" là một quy luật đúng với mọi kho dữ liệu: thao tác điểm (lấy/ghi một đối tượng theo khoá) giữ chi phí gần như cố định dù kho lớn cỡ nào, còn liệt kê hay vật chất hoá toàn bộ một tập tăng chi phí theo số phần tử — thường siêu tuyến tính khi thêm phân trang, giải mã, sao chép qua bộ đệm. Và điểm vỡ nặng nhất không phải thời gian mà là bộ nhớ: giữ nguyên cả tập trong RAM (informer cache) OOM trước khi độ trễ kịp làm phiền.

Trong thế giới Java, đây đúng là khác biệt giữa repository.findById(k) (điểm, O(1) theo index) và findAll() nạp cả bảng vào một List — cái sau chạy tốt lúc demo với nghìn hàng rồi OOM ở triệu hàng, y hệt informer. Lời giải cũng giống: phân trang (Pageable), streaming cursor thay vì vật chất hoá, và lọc ở tầng truy vấn thay vì tải hết rồi lọc trong bộ nhớ. Còn ở cơ sở dữ liệu, đây là SELECT theo khoá chính so với SELECT * không LIMIT không index — cái đầu ổn định, cái sau quét cả bảng và chậm dần theo kích thước; và một SELECT gom cả kết quả về client là cùng cái bẫy "giữ toàn bộ trong RAM".

Mẫu số chung: chi phí thao tác điểm gần như không đổi theo quy mô, còn chi phí liệt-kê/vật-chất-hoá-toàn-bộ tăng theo số phần tử và OOM là điểm vỡ trước tiên — nên khi hệ "chậm dần theo quy mô", hỏi ngay 'chậm ở thao tác nào', và cái cần sửa gần như luôn là một chỗ đang tải hết thay vì lọc/phân trang/streaming. Câu hỏi đúng không phải "hệ chịu được bao nhiêu" mà là "cái gì đang giữ toàn bộ tập trong bộ nhớ, và tôi có thu hẹp nó bằng nhãn/index/phân trang được không". Với Kubernetes, câu trả lời là giảm số đối tượng và lọc phạm vi informer.

Muốn tự xem cụm của bạn đang có bao nhiêu đối tượng, xếp theo loại:

for r in $(kubectl api-resources --verbs=list -o name 2>/dev/null | sort -u); do
  n=$(kubectl get "$r" -A --no-headers 2>/dev/null | wc -l | tr -d ' ')
  [ "$n" -gt 100 ] && echo "$n $r"
done | sort -rn | head -15

Loại nào lên tới hàng nghìn là loại đáng hỏi "có cần giữ hết không". Thường thủ phạm là Job đã xong, Secret của ServiceAccount đời cũ, hoặc bản ghi lịch sử của một Operator không tự dọn.

Phần sau: GitOps với Argo CD — đo độ trễ từ lúc đẩy commit tới lúc cụm thật sự đổi.