Hãy hình dung một toà nhà văn phòng có hệ thống cảm biến: mỗi lần một nhân viên mới vào, hệ thống tự gắn thêm một loạt đồng hồ đo — nhiệt độ chỗ ngồi, lượng điện, ra vào cửa. Hợp lý. Nhưng có một chi tiết kỳ lạ: khi nhân viên đó nghỉ việc, phần lớn đồng hồ biến mất theo — trừ vài cái ghi lại "đã từng có một loại phòng ban mới xuất hiện", và những cái đó ở lại mãi. Chỉ số của một cụm Kubernetes hành xử đúng như toà nhà đó, và bài này tôi đo cho ra cái phần "ở lại mãi" ấy tốn bao nhiêu.
Sê-ri này đã đo chi phí quan sát của một tiến trình đơn lẻ khá kỹ. Kubernetes đổi bài toán đó theo một cách ít ai tính trước: bản thân cụm cũng là một nguồn phát chỉ số, và nó phát ngay cả khi bạn chưa triển khai gì. Tôi đo đúng cái phần thêm vào ấy. Hệ đo là một cụm k3s thật chạy trong container, một node, dựng lên rồi xoá đi. Cái được đếm là số chuỗi chỉ số — mỗi dòng trong bản phơi ra của một endpoint là một chuỗi — ở hai nguồn: cAdvisor của kubelet và /metrics của control plane. (Không có phần cứng đặc biệt nào; đây là đo số dòng metric, không phải perf counter.)
Bảng số liệu
| Trạng thái cụm | Chuỗi cAdvisor | Byte mỗi lần thu thập |
|---|---|---|
| Rỗng (chỉ 1 pod CoreDNS) | 572 | 122,2 KB |
| Thêm 10 pod | 2 442 | 757,2 KB |
| Thêm 30 pod | 6 182 | 2 027,1 KB |
| Xoá sạch, về lại rỗng | 572 | 122,2 KB |
Ba lần chạy độc lập cho ra những con số này lệch nhau không quá 3 byte. Hệ số góc tính được từ ba cặp điểm khác nhau:
(2 442 − 572) / 10 = 187,0 chuỗi mỗi pod
(6 182 − 2 442) / 20 = 187,0 chuỗi mỗi pod
(6 182 − 572) / 30 = 187,0 chuỗi mỗi pod
Đúng 187 chuỗi cho mỗi pod, tuyến tính hoàn hảo, và 62,0 KB mỗi lần thu thập. Pod dùng để đo là pause — một container không làm gì cả, không nghe cổng nào, không tiêu CPU, không ghi log. Nó vẫn tốn đúng 187 chuỗi như một dịch vụ thật.
Ba con số làm tôi phải kiểm lại
Một pod tốn nhiều byte hơn cả cái node. Trong 122,2 KB của cụm rỗng, 62,0 KB thuộc về pod CoreDNS duy nhất; phần còn lại — 57,4 KB và 385 chuỗi — mới là chỉ số của node. Nói cách khác, pod thứ nhất đã đắt hơn toàn bộ phần nền, và pod thứ hai lại thêm chừng ấy nữa.
Control plane phình lên và không co lại. Đây là con số làm tôi phải kiểm ba lần. Tạo một namespace, triển khai 3 pod trong đó, rồi xoá sạch cả namespace — không còn lại một đối tượng nào:
Chuỗi /metrics |
|
|---|---|
| Trước | 25 923 |
| Sau khi đã xoá sạch | 30 503 |
+4 580 chuỗi vĩnh viễn, tăng 17,7%, từ một thí nghiệm để lại con số không. Để so sánh: 30 pod đang chạy tốn 5 610 chuỗi cAdvisor — nhưng số đó biến mất cùng với pod. Ba pod sống một phút rồi chết để lại lượng chuỗi bằng 8,2 lần thứ chúng chiếm lúc còn sống.
Nhưng nó bão hoà. Lặp lại đúng chu trình ấy bốn lần nữa, mỗi lần một tên namespace hoàn toàn mới:
vong 1: 30 503 -> 30 503 (+0)
vong 2: 30 503 -> 30 503 (+0)
vong 3: 30 503 -> 30 503 (+0)
vong 4: 30 503 -> 30 505 (+2)
Vì sao control plane phình rồi dừng
Toàn bộ 4 580 chuỗi mới nằm trong các thùng histogram của apiserver, không có họ chỉ số nào mới xuất hiện:
| Họ chỉ số | Trước | Sau |
|---|---|---|
apiserver_request_duration_seconds_bucket |
4 632 | 6 048 |
apiserver_request_sli_duration_seconds_bucket |
2 970 | 4 268 |
apiserver_request_body_size_bytes_bucket |
1 504 | 2 592 |
etcd_request_duration_seconds_bucket |
4 200 | 4 344 |
Apiserver gắn nhãn chỉ số theo loại tài nguyên, động từ và mã trả về — chứ không theo tên đối tượng. Mỗi tổ hợp mới sinh ra một bộ thùng histogram đầy đủ, và bộ ấy nằm lại trong sổ đăng ký mãi mãi.
Xoá một namespace là thao tác chạm vào mọi loại tài nguyên: Kubernetes phải duyệt qua toàn bộ danh mục để dọn rác. Lần đầu làm việc đó, hàng loạt tổ hợp (tài nguyên, động từ, mã) chưa từng gặp được tạo ra cùng lúc. Lần thứ hai trở đi thì chúng đã có sẵn — nên +0.
Đây là chỗ trực giác dễ sai theo cả hai hướng. Nhìn +4 580 mà chưa lặp lại thì sẽ kết luận "cụm càng hoạt động càng phình, không có điểm dừng" — sai. Số chuỗi của control plane bị chặn bởi số loại thao tác cụm từng làm, chứ không phải số lần làm. Một cụm chạy một triệu pod mỗi ngày không tốn nhiều chuỗi apiserver hơn cụm chạy mười pod, miễn là chúng làm cùng những loại việc.
Nghĩa là gì trong thực tế
Lấy 187 chuỗi mỗi pod, chu kỳ thu thập 15 giây, và 2,16 byte mỗi mẫu (con số đã đo ở phần 19):
187 chuoi x 5 760 lan thu thap moi ngay = 1 077 120 mau
1 077 120 x 2,16 byte = 2,33 MB moi pod moi ngay
- 100 pod → 233 MB mỗi ngày → 7,0 GB mỗi 30 ngày, chỉ riêng chỉ số container, chưa tính log, chưa tính trace, chưa tính chỉ số của chính ứng dụng.
- Con số ấy không phụ thuộc pod làm gì. Một job cron chạy 5 giây rồi thoát vẫn được tính chỉ số như một dịch vụ chạy suốt, trong lúc nó còn sống.
- Nếu bạn chạy CI sinh pod liên tục, chi phí thật nằm ở chỗ khác: mỗi pod chỉ tốn chỉ số lúc nó còn sống, nhưng nhãn
podthì đổi mỗi lần, và trong Prometheus mỗi giá trị nhãn mới là một chuỗi mới cần lưu.
Việc cần làm là bỏ bớt, không phải thu thập thêm: cAdvisor phơi ra hàng chục chỉ số cho mỗi container mà phần lớn cụm không bao giờ truy vấn. Cắt xuống còn nhóm CPU, bộ nhớ, và số lần khởi động lại là giảm được phần lớn trong 187 chuỗi đó — và đó là thao tác lọc lúc thu thập, không mất gì.
Lần đo hỏng: hai endpoint khác nhau trả về cùng một thứ
Bước đầu tiên tôi đọc bốn nguồn chỉ số và ghi lại số dòng:
/metrics 24 258 dong
/api/v1/nodes/<node>/proxy/metrics 24 352 dong
/api/v1/nodes/<node>/proxy/metrics/cadvisor 571 dong
Hai dòng đầu là apiserver và kubelet — hai thành phần khác hẳn nhau, mà lệch nhau chưa tới 0,4%. Con số ấy hoàn toàn nằm trong khoảng hợp lý, và nếu tôi ghi thẳng vào bài thì không ai phát hiện được.
Cách kiểm mất ba mươi giây: tìm một chỉ số mà chỉ một trong hai được phép có. apiserver_request_total có 200 dòng ở cả hai. Kubelet không phơi ra chỉ số ấy. So trực tiếp thì 23 652 trong số 24 258 dòng là giống hệt nhau. Nguyên nhân: k3s gói apiserver, kubelet, scheduler và controller-manager vào một tiến trình duy nhất, dùng chung một sổ đăng ký chỉ số. Hỏi endpoint nào cũng ra cùng một thứ. Đó là đặc thù của k3s chứ không phải của Kubernetes, và nó khiến mọi con số "control plane" trong bài này là số gộp — tôi giữ chúng vì phần phình và phần bão hoà vẫn đo đúng, nhưng không được đem so với một cụm nhiều thành phần tách rời.
Bài học lặp lại lần thứ ba trong sê-ri này: một con số trông hợp lý không tự tố cáo mình. Thứ bắt được nó là một phép kiểm chuẩn bị trước — ở đây là "chỉ số nào chỉ được phép xuất hiện ở một bên".
Cùng một cái bẫy "cardinality tích luỹ", đổi ngăn xếp
Chuyện "chi phí quan sát tăng theo số tổ hợp nhãn chứ không theo lưu lượng, và một số nhãn không bao giờ co lại" không riêng gì Kubernetes — nó là cái bẫy cardinality kinh điển, hiện ra ở mọi hệ metric có nhãn.
Trong thế giới Java, Micrometer/Prometheus client là nơi lập trình viên hay tự bắn vào chân: gắn một Tag có giá trị không bị chặn — user id, request path đầy đủ, thậm chí message lỗi — vào một Timer hay Counter. Mỗi giá trị nhãn mới là một chuỗi mới, và với dữ liệu người dùng thì tập giá trị là vô hạn. Kết quả là một "cardinality explosion" làm sập Prometheus, đúng cùng cơ chế nhãn pod đổi mỗi lần ở trên. Chuẩn an toàn là chỉ gắn nhãn có tập giá trị hữu hạn biết trước.
Trong Go — ngôn ngữ viết cả Prometheus lẫn Kubernetes — thư viện client cảnh báo thẳng chuyện này trong tài liệu: prometheus.Labels với giá trị mở là nguồn số một của rò rỉ bộ nhớ metric. Đây chính là bài học cụm k3s ở trên, chỉ ở tầng thư viện thay vì tầng cluster.
Còn ở cơ sở dữ liệu, họ hàng gần nhất là cardinality của index và của bảng thống kê: một cột có độ phân biệt cực cao (UUID, timestamp chính xác) làm index phình và làm bộ tối ưu ước lượng lệch; và các bảng audit/log tăng tuyến tính theo số loại sự kiện chứ không theo dung lượng dữ liệu. DBA nào cũng biết chọn cột đánh index phải cân nhắc cardinality — cùng một phép cân nhắc với việc chọn nhãn metric.
Mẫu số chung: trong mọi hệ gắn nhãn cho số liệu, chi phí thật không đến từ lượng sự kiện mà từ số tổ hợp nhãn phân biệt — và những tổ hợp sinh ra từ giá trị không bị chặn (id, đường dẫn, tên đối tượng) sẽ tích luỹ và hiếm khi co lại. Nên câu hỏi đúng khi thêm bất kỳ nhãn hay chiều nào không phải "nó có hữu ích không" mà là "tập giá trị của nó có hữu hạn và biết trước không" — vì mỗi giá trị mới là một chuỗi phải lưu mãi.
Muốn tự đo phần chỉ số bạn chưa từng chủ động bật, chạy trên cụm của bạn:
kubectl get --raw "/api/v1/nodes/$(kubectl get nodes -o jsonpath='{.items[0].metadata.name}')/proxy/metrics/cadvisor" \
| grep -vc '^#'
Chia con số đó cho số pod trên node ấy. Nhân với số node, nhân với 5 760, nhân với 2,16 byte. Đó là hoá đơn mỗi ngày cho phần chỉ số mà bạn chưa từng chủ động bật.