Có một cách nhìn Kubernetes làm mọi thứ sáng ra: gần như toàn bộ hệ thống chỉ là sổ sách. Bộ lập lịch không chạy container — nó ghi một dòng vào etcd rằng "pod này thuộc node kia". Controller không chạy container — chúng đọc sổ, thấy lệch với mong muốn, rồi ghi thêm vài dòng cho khớp. Cả cái control plane đồ sộ ấy, rút gọn lại, là một máy sắp xếp bản ghi.

Vậy ở đâu bản ghi biến thành một tiến trình thật đang ngốn CPU? Đúng một chỗ: kubelet. Nó là thành phần duy nhất chạm vào thế giới thật — thứ thật sự gọi runtime để dựng container. Bài này đo đoạn đường đó, từ lúc bộ lập lịch buông tay tới lúc container chạy.

Bốn mốc, gần như cùng một giây

15:40:11   Scheduled     bộ lập lịch ghi spec.nodeName
15:40:12   Pulled        kubelet thấy pod của mình, ảnh đã có sẵn
15:40:12   Created       kubelet bảo containerd tạo container
15:40:12   Started       container chạy

Ba mốc cuối trong cùng một giây. Nhìn con số này lần đầu tôi hơi ngạc nhiên — cứ tưởng "khởi động pod" là một chuỗi việc nặng nề, hoá ra khi ảnh đã nằm sẵn trên node, kubelet gần như không tốn thời gian. Nó nhận pod qua watch (phần 46), gọi runtime qua giao diện CRI, và container chạy.

Nghĩa là: toàn bộ độ trễ khởi động pod nằm ở việc kéo ảnh, không nằm ở Kubernetes.

Kéo ảnh là phần đắt

alpine:3.20      4,1 MB    ->   5,9 giây
python:3.12     400 MB    ->  15,7 giây

Để ý tỷ lệ: gấp 100 lần kích thước nhưng chỉ gấp 2,7 lần thời gian. Phần cố định — kết nối tới registry, xác thực, tải và giải nén siêu dữ liệu — chiếm khá nhiều trong ảnh nhỏ, nên ảnh to hơn không đắt hơn theo tỷ lệ thẳng.

Nhưng đừng vội coi thường mười giây chênh lệch. Nhân nó với số pod và số lần triển khai thì con số phình rất nhanh: một deployment 20 bản, triển khai mười lần một ngày, là hơn nửa giờ mỗi ngày chỉ để ngồi kéo ảnh.

Bốn cách rút ngắn, theo thứ tự tôi thấy đáng làm trước:

Ảnh nhỏ hơn. Xây nhiều tầng (multi-stage build) và dùng ảnh nền tối giản. Đây là cách hiệu quả nhất, và tiện thể còn giảm bề mặt tấn công.

imagePullPolicy: IfNotPresent. Mặc định đã là IfNotPresent trừ khi tag là latest — và đó là một lý do nữa để không bao giờ dùng tag latest: nó ép kéo ảnh mỗi lần khởi động pod, kể cả khi ảnh y hệt đã nằm sẵn trên node.

Tận dụng tầng chung. Ảnh chia sẻ tầng nền chỉ kéo phần khác biệt. Chuẩn hoá ảnh nền trong toàn tổ chức tiết kiệm rất nhiều mà chẳng ai phải nghĩ thêm.

Registry gần. Registry trong cùng vùng, hoặc một bản sao đệm ngay trong cụm.

Mỗi pod có một container ẩn

crictl pods   ->  POD ID a9a7f065a4001   NAME tm   Ready
crictl ps     ->  CONTAINER 996a893...   NAME tm   POD a9a7f065a4001
số container pause trên node:  7

Bảy container pause cho bảy pod — và chúng không hề xuất hiện trong kubectl get pods. Lần đầu gặp con số lệch này người ta hay hoang mang, nên để tôi nói thẳng nó là gì.

pause là một tiến trình gần như không làm gì cả. Việc duy nhất của nó là giữ không gian mạng cho cả pod. Container ứng dụng vào rồi ra; pause thì ở lại.

Đây chính là cơ chế đằng sau hai điều đã đo ở phần 44:

  • IP của pod không đổi khi container khởi động lại, vì không gian mạng thuộc về pause chứ không thuộc container ứng dụng.
  • Các container trong pod gọi nhau qua localhost, vì chúng cùng tham gia vào không gian mạng của pause.

Nếu bạn từng thấy crictl ps báo nhiều container hơn kubectl get pods gợi ý, đây là lý do — không có gì hỏng cả.

Pod tĩnh: kubelet đọc thẳng từ đĩa

/etc/kubernetes/manifests/
   etcd.yaml
   kube-apiserver.yaml
   kube-controller-manager.yaml
   kube-scheduler.yaml

Bốn tệp này kubelet đọc trực tiếp từ đĩa, không qua apiserver. Và nếu bạn để ý, đây là lời giải cho một nghịch lý đủ để nhức đầu: nếu mọi pod cần apiserver mới chạy được, thì apiserver — vốn cũng chạy dưới dạng pod — lấy đâu ra để khởi động?

kubelet quét thư mục manifests và chạy mọi thứ trong đó ngay khi nó khởi động, không hỏi ai. Sau đó nó tạo một "pod gương" trong apiserver để bạn nhìn thấy chúng bằng kubectl — nhưng xoá pod gương đó không xoá được container, vì nguồn sự thật là tệp trên đĩa chứ không phải bản ghi trong etcd.

Đây cũng là cơ chế phần 43 dùng để tắt từng thành phần control plane: di chuyển tệp đi thì kubelet dừng container tương ứng.

Ba việc khác của kubelet

Kiểm tra sức khoẻ. livenessProbe và readinessProbe do kubelet chạy, không phải apiserver. Đó là lý do chúng vẫn hoạt động ngay cả khi control plane chết.

Dọn ảnh cũ. Khi đĩa vượt imageGCHighThresholdPercent, kubelet xoá ảnh không dùng tới. Trên cụm hay kéo nhiều ảnh lớn, đây chính là nguồn của cái cảnh "ảnh vừa có đó giờ lại phải kéo lại".

Đuổi pod khi node cạn tài nguyên. evictionHard đặt ngưỡng cho bộ nhớ và đĩa. Vượt ngưỡng là kubelet giết pod theo thứ tự: BestEffort trước (không khai requests), rồi Burstable, cuối cùng mới tới Guaranteed (requests bằng limits).

Đây là lý do thực tế nhất — thực tế hơn cả mấy bài giảng về "chất lượng dịch vụ" — để khai requests và limits: nó quyết định pod nào chết trước khi node hết RAM.

kubelet là thành phần duy nhất không phải pod

Mọi thứ khác trong Kubernetes chạy dưới dạng container. kubelet thì không — nó là dịch vụ hệ thống chạy bằng systemd, vì phải có nó trước thì container mới chạy được. Con gà và quả trứng, và kubelet là con gà.

Hệ quả khi vận hành đáng nhớ: kubelet chết thì kubectl bó tay, phải vào thẳng máy mà xử lý. Và log của nó nằm ở journalctl -u kubelet, không ở kubectl logs.

# trong kind
docker exec <node> journalctl -u kubelet --no-pager | tail -50

Đây là chỗ đầu tiên nên nhìn khi một node báo NotReady mà bản thân máy vẫn ping được ngon lành.

Cùng một lằn ranh "sổ sách vs người thực thi", đổi ngăn xếp

Cái ý "gần như cả hệ thống chỉ là bản ghi, và có đúng một chỗ biến bản ghi thành hành động thật" không phải chuyện riêng của Kubernetes — nó là kiến trúc của gần như mọi hệ nhiều tầng.

Trong thế giới Java, phía trên là cả một bộ máy sổ sách: nạp lớp, kiểm tra bytecode, phân giải tham chiếu — không dòng nào trong đó thật sự chạy trên CPU. Chỗ bytecode biến thành lệnh máy thật là trình thông dịch và JIT. Mọi tầng trên chỉ chuẩn bị; đúng một tầng dưới cùng chạm vào phần cứng.

Trong Go, bộ lập lịch M:N sắp xếp goroutine vào các OS thread — nhưng bản thân việc sắp xếp đó cũng chỉ là bản ghi trong runtime; chỉ có OS thread mới thật sự được nhân hệ điều hành đặt lên một lõi CPU. Runtime của Go quyết định, kernel mới là chỗ thực thi.

Còn ở cơ sở dữ liệu, trình tối ưu sinh ra một kế hoạch truy vấn — thuần tuý là một bản ghi mô tả ý định. Chỉ có executor mới thật sự chạm vào các trang dữ liệu trên đĩa. Và bootstrap paradox cũng lặp lại y hệt pod tĩnh: PostgreSQL phải replay WAL để dựng lại trạng thái trước khi lớp danh mục (catalog) đáng tin — cái phần chạy trước không thể phụ thuộc vào cái phần nó dựng ra.

Mẫu số chung: trong mọi hệ nhiều tầng, gần như tất cả các tầng chỉ thao tác trên bản ghi ý định, và có đúng một tầng thật sự chạm vào tài nguyên vật lý — nên khi gỡ lỗi, câu hỏi đầu tiên luôn là "tôi đang đứng ở bên nào của lằn ranh đó". Và tầng thực thi ấy có một đặc tính bắt buộc: nó phải khởi động được độc lập với lớp sổ sách mà sau này nó phục vụ — kubelet là systemd chứ không phải pod, pod tĩnh đọc từ đĩa chứ không qua apiserver, WAL replay chạy trước catalog, bootloader không thể nằm trong chính hệ điều hành nó nạp. Nếu người thực thi cần lớp sổ sách để tự khởi động, cả hệ không bao giờ nhấc nổi mình lên. Nên câu hỏi đúng khi thiết kế một hệ tự dựng không phải "làm sao mọi thứ đều được quản lý thống nhất" mà là "cái gì chạy trước tất cả, và nó tự đứng được mà không cần ai không".

Muốn tự nhìn đoạn đường từ lệnh tới container trên cụm của bạn, chạy thử:

kubectl run thu --image=nginx:alpine
kubectl get events --field-selector involvedObject.name=thu \
  --sort-by=.metadata.creationTimestamp \
  -o jsonpath='{range .items[*]}{.metadata.creationTimestamp}{" "}{.reason}{"\n"}{end}'

Khoảng cách giữa Scheduled và Pulled chính là thời gian kéo ảnh. Nếu nó lớn hơn vài giây với ảnh bạn dùng hàng ngày, đó là chỗ tiết kiệm được ở mọi lần triển khai — và giờ bạn biết nó nằm ở kéo ảnh chứ không phải ở Kubernetes.

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