Đổi ca gác mà không để cửa hở một giây nào có đúng một quy tắc: người mới phải tới, nhận bàn giao, và đứng vào vị trí trước khi người cũ rời đi. Nếu người cũ vừa nghe hết ca là bỏ đi ngay, sẽ có một khoảng — dù chỉ vài giây — không ai gác cửa, và đúng lúc đó kẻ trộm lọt vào. Bí quyết không nằm ở chỗ có bao nhiêu lính gác, mà ở cái khoảng chồng lấn giữa lúc người mới sẵn sàng và lúc người cũ đi. Nâng cấp một cụm Kubernetes không đứt quãng dựa vào đúng cái khoảng chồng lấn ấy, và bài này đo để chỉ ra thứ thật sự tạo ra nó — không phải thứ mọi hướng dẫn hay nhắc.

Nâng cấp Kubernetes gồm hai phần rất khác nhau: nâng control plane, và thay từng node. Phần thứ hai mới là phần làm gián đoạn dịch vụ.

kubeadm upgrade plan nói thẳng phần nó không làm

Trên một cụm v1.33.1:

Components that must be upgraded manually after you have upgraded
the control plane with 'kubeadm upgrade apply':

   kubelet   up-control-plane   v1.33.1  ->  v1.33.13
   kubelet   up-worker          v1.33.1  ->  v1.33.13
   kubelet   up-worker2         v1.33.1  ->  v1.33.13

kubeadm upgrade apply lo kube-apiserver, kube-controller-manager, kube-scheduler, kube-proxy, CoreDNS và etcd. kubelet trên từng node là việc của bạn. Đó là chỗ hay bị hiểu nhầm: chạy xong upgrade apply rồi thấy kubectl get nodes vẫn hiện phiên bản cũ, và tưởng nâng cấp thất bại. Không — cột đó hiện phiên bản kubelet, mà kubelet chưa được đụng tới.

Thứ tự bắt buộc, và không đảo được:

1. control plane   (kubeadm upgrade apply)
2. kubelet + kubectl trên control plane
3. từng worker: drain -> nâng kubelet -> uncordon

Lý do thứ tự đó: kubelet được phép cũ hơn API server tối đa ba phiên bản phụ, nhưng không được mới hơn. Nâng kubelet trước là tạo ra một cụm ở trạng thái không được hỗ trợ, và triệu chứng thường là những thứ khó lần — pod không xếp được lịch, trường mới trong spec bị bỏ qua im lặng. Cũng đừng nhảy cóc: v1.31 lên v1.33 phải qua v1.32. kubeadm từ chối thẳng, nhưng người ta hay quên khi lập kế hoạch.

Đo gián đoạn thật

Phần đáng đo là bước 3: kubectl drain một node trong lúc dịch vụ đang phục vụ. Tôi bắn liên tục vào một Service trong 70 giây (~1.300 yêu cầu) và drain node giữa chừng, ở ba cấu hình.

Cấu hình Số lỗi
2 bản + PDB + preStop + readinessProbe 0
1 bản, không PDB, có preStop + readinessProbe 0
1 bản, không PDB, không preStop, không probe 3 / 3 / 3

Thời gian drain: 7,1 giây với 2 bản, 6,1 giây với 1 bản. Dòng thứ hai là chỗ tôi phải dừng lại. Bỏ PodDisruptionBudget, hạ xuống một bản duy nhất, và vẫn không mất yêu cầu nào.

Thứ cứu được không phải PDB

Dòng thứ ba trả lời: bỏ preStop và readinessProbe thì mất 3 yêu cầu, lặp lại ba lần ra đúng 3 mỗi lần. Cơ chế đã đo ở phần về tín hiệu dừng: preStop: sleep 5 giữ pod cũ phục vụ thêm 5 giây sau khi bị đuổi — đúng người gác cũ nán lại. Trong 5 giây đó, pod thay thế được tạo trên node còn lại và sẵn sàng — image đã có sẵn trên node nên nó khởi động trong vài giây — đồng thời kube-proxy trên mọi node kịp cập nhật iptables.

Nói cách khác, cái tạo ra khoảng chồng lấn không phải là số bản sao mà là năm giây trì hoãn.

Điều này không có nghĩa PDB vô dụng. Phép thử này không chạm tới việc của nó: PDB chặn việc drain nhiều node cùng lúc làm mất hết bản sao — đúng quy định "không cho cả kíp nghỉ một lúc". Ở đây tôi chỉ drain một node, nên PDB không có gì để chặn.

Kết luận đúng là: PDB và preStop bảo vệ hai thứ khác nhau, và hướng dẫn nâng cấp thường chỉ nhắc cái đầu. Có PDB mà thiếu preStop thì vẫn mất yêu cầu ở mỗi node được drain.

Quy trình đủ

# --- control plane ---
kubeadm upgrade plan
kubeadm upgrade apply v1.33.13
kubectl drain <cp> --ignore-daemonsets
apt-get install -y kubelet=1.33.13-* kubectl=1.33.13-*
systemctl daemon-reload && systemctl restart kubelet
kubectl uncordon <cp>

# --- từng worker, một node một lần ---
kubectl drain <node> --ignore-daemonsets --delete-emptydir-data
kubeadm upgrade node
apt-get install -y kubelet=1.33.13-*
systemctl daemon-reload && systemctl restart kubelet
kubectl uncordon <node>

Ba cờ của drain đáng hiểu chứ đừng chép:

  • --ignore-daemonsets gần như luôn cần: DaemonSet pod sẽ được tạo lại ngay trên chính node đó, nên không có ý nghĩa gì khi đuổi chúng.
  • --delete-emptydir-data xoá dữ liệu trong emptyDir. Với bộ đệm thì không sao; với thứ ai đó dùng làm chỗ lưu tạm quan trọng thì mất.
  • --force xoá cả pod không có bộ điều khiển — tức là pod trần sẽ không bao giờ quay lại. Dùng cờ này là phải biết chắc không có pod nào như vậy.

Và luôn có --timeout: thiếu nó, một PDB quá chặt sẽ làm drain treo vô hạn mà không nói gì.

Trước khi bắt đầu, kiểm ba thứ

Ghi chú nâng cấp của đúng phiên bản đó. API bị gỡ là nguyên nhân vỡ số một. Công cụ kubent hoặc pluto quét manifest tìm API sắp bị gỡ.

Sức chứa còn dư. Drain một node trong cụm ba node nghĩa là mọi pod của nó phải vừa vào hai node còn lại. Cụm chạy 80% thì bước này gây Pending, và nó xảy ra ở giữa quá trình nâng cấp.

Snapshot etcd ngay trước khi bắt đầu. Mất 0,1 giây, như đã đo ở phần 49. Và như phần 50 cho thấy, khôi phục là quay ngược toàn bộ cụm — nên hãy chụp ngay trước bước đầu tiên, đừng chụp từ đêm hôm trước.

Mẫu số chung: chuyển giao không đứt quãng cần chồng lấn, không cần dư thừa

Cái làm một-bản-sao vẫn không mất yêu cầu là nguyên tắc make-before-break: để chuyển giao không đứt quãng, cái mới phải sẵn sàng nhận việc trước khi cái cũ ngừng — một khoảng chồng lấn, chứ không phải một con số bản sao. preStop: sleep 5 chính là khoảng chồng lấn đó: nó giữ điểm cũ phục vụ đủ lâu để điểm mới lên và để mọi bộ định tuyến kịp biết. Và bài này gói một bài học thứ hai: hai cơ chế nghe na ná (PDB "giữ đủ bản sao" và preStop "nán lại khi tắt") bảo vệ hai thứ khác nhau — nhầm chúng làm một là để hở đúng chỗ mình tưởng đã che.

Trong thế giới Java và mọi dịch vụ mạng, đây đúng là graceful shutdown / connection draining: khi nhận SIGTERM, ứng dụng phải ngừng nhận kết nối mới nhưng xử lý nốt các request đang bay rồi mới thoát (Spring Boot server.shutdown=graceful, spring.lifecycle.timeout-per-shutdown-phase) — thoát ngay là cắt ngang request đang chạy, đúng 3 yêu cầu bị mất ở đây. Bộ cân bằng tải cũng cần một deregistration delay để ngừng gửi lưu lượng tới instance sắp tắt trước khi nó biến mất; đổi DNS thì cần chồng lấn TTL để bản ghi cũ còn sống tới khi bản mới lan hết. Rolling/blue-green deploy đều là make-before-break: dựng phiên bản mới, chờ nó khoẻ, rồi mới rút phiên bản cũ.

Mẫu số chung: không-đứt-quãng đến từ khoảng chồng lấn giữa cái-mới-sẵn-sàng và cái-cũ-ngừng (make-before-break), không đến từ số lượng dư thừa — và các cơ chế nghe giống nhau thường bảo vệ những vế khác nhau của quá trình, phải biết cái nào che cái gì. Câu hỏi đúng khi thay thế một thành phần đang phục vụ không phải "tôi có mấy bản sao" mà là "có khoảng nào cái mới đã sẵn sàng mà cái cũ vẫn còn nhận việc không, và ai đảm bảo khoảng đó" — với Kubernetes, câu trả lời là preStop + readinessProbe, còn PDB lo một chuyện riêng là đừng-rút-hết-cùng-lúc.

Muốn tự kiểm cụm có chịu nổi việc mất một node không, mà không cần mất node nào:

for n in $(kubectl get nodes -o name | cut -d/ -f2); do
  echo -n "$n: "
  kubectl drain "$n" --ignore-daemonsets --delete-emptydir-data \
    --dry-run=server 2>&1 | grep -cE "cannot|error|would violate" \
    | xargs -I{} echo "{} van de"
done

--dry-run=server chạy đủ mọi kiểm tra của drain — kể cả PDB — nhưng không đuổi pod nào. Node nào báo vấn đề là node sẽ làm bạn kẹt giữa chừng lúc nâng cấp thật, và bây giờ là lúc tốt hơn nhiều để biết điều đó.

Phần sau: nhiều cụm và liên kết giữa chúng — đo chi phí thật của việc chia nhỏ.