Hình dung một camera an ninh bị đơ khung hình cuối. Phòng trực vẫn thấy một hành lang vắng lặng, yên bình — không phải vì hành lang thật sự yên, mà vì luồng hình đã kẹt cứng ở khoảnh khắc cuối cùng trước khi dây tín hiệu đứt. Kẻ trực ca nhìn màn hình xanh và yên tâm đi pha cà phê. Đây chính xác là chuyện xảy ra khi một node Kubernetes chết, và nó là một trong những bài học về giám sát mà tôi nghĩ mọi người vận hành nên tự tay đo một lần.

kind chạy Kubernetes bằng cách đặt mỗi node vào một container Docker. Bài này dựng cụm một node và ba node, đo tài nguyên, rồi giết một node để xem "tự chữa" thật sự mất bao lâu.

Dựng

kind create cluster --name do

Cụm 3 node cần một tệp cấu hình:

kind: Cluster
apiVersion: kind.x-k8s.io/v1alpha4
nodes:
  - role: control-plane
  - role: worker
  - role: worker
kind create cluster --name ba --config kind3.yaml
Tạo xong Tất cả Ready
cụm 1 node 10,4 giây +6,2 giây
cụm 3 node 17,2 giây ở giây thứ 29,8

Ba mươi giây cho một cụm Kubernetes ba node. Đây là lý do kind là công cụ mặc định để thử nghiệm và chạy CI.

Tài nguyên

ba-control-plane   555,5 MB
ba-worker          116,9 MB
ba-worker2         118,7 MB
------------------------------
tổng                 791 MB     cho 13 pod hệ thống, chưa có ứng dụng nào

Node worker rẻ: 117 MB mỗi cái. Cái đắt là control plane — etcd, kube-apiserver, kube-controller-manager, kube-scheduler cộng lại gần 556 MB.

Điều này khớp với trực giác về kiến trúc: control plane giữ toàn bộ trạng thái cụm và phục vụ mọi lời gọi API; worker chỉ chạy kubelet và kube-proxy.

Với máy phát triển 16 GB, chạy một cụm 3 node tốn khoảng 5% bộ nhớ. Thêm worker gần như miễn phí.

Pod được xếp lên node nào

kubectl create deployment app --image=nginx:alpine --replicas=6
3 ba-worker
3 ba-worker2

Chia đều, và không pod nào lên control plane — kind đánh dấu node đó bằng một vết (taint) mà pod thường không chịu được. Đó là mặc định đúng: control plane không nên tranh tài nguyên với ứng dụng.

Node chết: gần mười phút và không có gì xảy ra

Đây là phép đo quan trọng nhất của bài.

docker stop ba-worker2     # 3 trong 6 pod đang chạy trên đó
Thời điểm Node NotReady Pod trên node chết kubectl báo Running
0 s 0 3 6
61 s 1 3 6
122 s 1 3 6
304 s 1 3 6
426 s 1 3 6
547 s 1 3 6
593 s 1 3 6

Gần mười phút. Không pod nào được chuyển đi, và kubectl get pods báo đủ sáu Running suốt thời gian đó — trong khi ba trong sáu bản đã không phục vụ được từ giây thứ 61.

Ba pod đó là pod ma. kubelet trên node chết không cập nhật được trạng thái, nên chúng đứng nguyên ở Running — đúng như khung hình camera đơ lại.

Vì sao Kubernetes không dựng bản thay thế

Đây là phần phản trực giác, và nó là hệ quả trực tiếp của hiện tượng trên.

Pod ma vẫn được tính là Ready. ReplicaSet đếm được 6 pod Ready, thấy đúng bằng số mong muốn, và không làm gì cả. Nó không biết ba trong số đó đã chết — theo mọi thông tin nó có, cụm đang ở trạng thái đúng.

Chỉ khi cơ chế đuổi pod theo vết (taint-based eviction) gỡ pod ma đi thì ReplicaSet mới thấy thiếu và dựng bản thay thế. Hai đồng hồ quyết định việc đó:

node-monitor-grace-period      40 giây     (đo được 61 giây)
tolerationSeconds mặc định    300 giây     trước khi đuổi pod

Cộng lại là khoảng sáu phút theo lý thuyết — nhưng phép đo này chạy tới 593 giây mà chưa thấy nó xảy ra, và tôi ghi lại đúng như vậy.

Ở một lần chạy trước đó, tôi có thấy ba pod thay thế được tạo. Nhưng lần chạy đó bị lẫn vì tôi đã bật lại node giữa chừng, nên tôi không dùng số của nó. Bảng trên là lần chạy sạch, và nó chỉ nói được một điều — nhưng nói chắc chắn: trong mười phút đầu, không có gì được sửa.

Hệ quả cho việc theo dõi

Bài học không nằm ở con số chính xác của đồng hồ, mà ở chỗ:

Đếm pod Running không cho biết ứng dụng có đủ bản đang phục vụ hay không.

Bảng theo dõi dựa vào kubectl get pods sẽ báo xanh trong suốt mười phút đó. Thứ báo đúng là:

# node nào không khoẻ
kubectl get nodes | grep -v ' Ready '

# endpoint thật sự đang nhận lưu lượng
kubectl get endpoints web-svc -o jsonpath='{.subsets[*].addresses[*].ip}' | wc -w

Lệnh thứ hai là lệnh đáng đặt cảnh báo: Endpoints chỉ chứa pod qua được kiểm tra sẵn sàng, nên pod ma trên node chết sẽ rơi khỏi danh sách sớm hơn nhiều so với việc nó rơi khỏi kubectl get pods.

Nếu muốn phản ứng nhanh hơn, hạ ngưỡng chịu đựng ngay trên pod:

tolerations:
  - key: node.kubernetes.io/not-ready
    operator: Exists
    effect: NoExecute
    tolerationSeconds: 30
  - key: node.kubernetes.io/unreachable
    operator: Exists
    effect: NoExecute
    tolerationSeconds: 30

Cái giá: mạng chập chờn 30 giây sẽ gây dựng lại pod. Với ứng dụng khởi động nhanh và không giữ trạng thái, đó là đánh đổi tốt. Với ứng dụng khởi động mất vài phút, nó làm mọi thứ tệ hơn.

Và so với phần trước để thấy khoảng cách: pod bị xoá được thay trong 1,1 giây; node chết thì mười phút chưa thấy gì.

Ba điều kind làm khác cụm thật

Không có bộ cân bằng tải. Service kiểu LoadBalancer sẽ mãi ở trạng thái <pending>. Dùng NodePort hoặc cài metallb.

Lưu trữ là local-path. PersistentVolume nằm trên đĩa của node, nên pod chuyển sang node khác thì mất dữ liệu. Cụm thật dùng lưu trữ mạng, và hành vi khác hẳn.

Ảnh phải được nạp thủ công. kind không thấy registry cục bộ của Docker:

kind load docker-image ten-anh:tag --name ba

Quên bước này là pod treo ở ErrImagePull với ảnh mà docker images rõ ràng đang có — một trong những chỗ mất thời gian nhất khi mới dùng kind.

Ba lệnh nên thuộc

# xem node và IP
kubectl get nodes -o wide

# xem pod ở mọi namespace, kèm node
kubectl get pods -A -o wide

# xem chuyện gì vừa xảy ra
kubectl get events --sort-by=.lastTimestamp | tail -20

Lệnh thứ ba là lệnh hữu ích nhất khi có gì đó không như mong đợi, và cũng là lệnh ít được dùng nhất. Nó cho biết bộ lập lịch đã quyết định gì và vì sao.

Cùng một cái bẫy "im lặng không phải là khoẻ", đổi ngăn xếp

Cái bẫy pod ma — "không có tin xấu không có nghĩa là tin tốt" — không phải đặc sản Kubernetes. Nó là một trong những sai lầm giám sát lặp đi lặp lại ở mọi hệ phân tán, và gốc của nó là một câu hỏi tinh tế: làm sao biết một thứ còn sống khi kẻ duy nhất báo tin về nó chính là nó.

Trong thế giới cơ sở dữ liệu, đây là bài toán kết nối nửa-mở và khoá hết hạn. pg_stat_activity có thể hiện một phiên đang active trong khi client đã biến mất từ lâu — TCP half-open, và phiên chỉ bị dọn khi tcp_keepalives hay một statement timeout kịp bắt. Tệ hơn là một khoá phân tán do một tiến trình đã chết đang giữ: nó còn giữ cho tới khi TTL/lease hết hạn, đó là lý do mọi hệ khoá nghiêm túc đều gắn lease có thời hạn thay vì tin vào việc kẻ giữ khoá sẽ tự trả.

Trong thế giới Java, một connection trong pool có thể bị đánh dấu "idle, tốt" trong khi thực ra đã chết ở đầu kia — nên mới cần testOnBorrow hay validation query: bản ghi của pool nói nó sống, chỉ có một phép kiểm chủ động mới biết thật. Và mọi failure detector kiểu heartbeat đều buộc bạn chọn một ngưỡng chờ: dài quá thì ôm dữ liệu cũ, ngắn quá thì báo chết nhầm khi mạng chỉ chớp.

Trong Go, vắng một giá trị trên channel không có nghĩa "ổn cả" — phải ghép select với time.After thì mới phân biệt được "vẫn đang chạy" với "goroutine đã chết âm thầm".

Mẫu số chung: "số bản ghi nói rằng khoẻ" khác hẳn "số thứ đang thật sự khoẻ ngay bây giờ" — khi chính kẻ cập nhật bản ghi có thể chết, một bản ghi không được cập nhật là dữ liệu cũ chứ không phải tin tốt, nên hệ buộc phải phân biệt "còn sống vì vừa có nhịp tim" với "vẫn hiện trạng thái cũ vì không ai báo khác đi" — và sự phân biệt đó luôn đòi một ngưỡng thời gian chờ mà bạn phải tự chọn, không có giá trị nào đúng cho mọi ứng dụng. Một hệ quả đi kèm: hãy đo tín hiệu do chính cái bạn quan tâm sinh ra (Endpoints được dựng từ pod qua được kiểm tra sẵn sàng), đừng tin một proxy rẻ nhưng dối (đếm Running). Nên câu hỏi đúng khi dựng cảnh báo không phải "có bao nhiêu bản đang Running" mà là "con số này được cập nhật bởi ai, và nếu kẻ đó chết thì con số đứng im bao lâu trước khi có ai nhận ra".

Muốn tự đo trên cụm của bạn, chạy thử:

kubectl create deployment app --image=nginx:alpine --replicas=6
docker stop <ten-node-worker>

while true; do
  echo "$(date +%T) NotReady=$(kubectl get nodes --no-headers | grep -c NotReady) \
Running=$(kubectl get pods -l app=app --no-headers | grep -c Running) \
Endpoints=$(kubectl get endpoints app -o jsonpath='{.subsets[*].addresses[*].ip}' 2>/dev/null | wc -w)"
  sleep 15
done

Cột Running sẽ đứng yên rất lâu. Cột Endpoints là cột nói thật. Khoảng cách giữa hai cột đó chính là khoảng thời gian bảng theo dõi của bạn đang báo sai.

Phần sau đo Pod: đơn vị nhỏ nhất của Kubernetes, và vì sao nó không phải container.