Có hai kiểu việc trong đời một tiến trình: việc có điểm kết thúc (nấu xong bữa cơm rồi tắt bếp) và việc chạy song song không hồi kết (cái quạt trần cứ quay suốt bữa). Nhầm hai kiểu này với nhau là nguồn của một lớp lỗi rất khó chịu — mà rõ nhất là khi bạn dọn dẹp xong xuôi định đi ngủ, nhưng "cả nhà" vẫn bị coi là chưa nghỉ vì cái quạt chưa ai tắt. Kubernetes gặp đúng chuyện đó với sidecar, và bài này đo để chỉ ra cái giá của sự nhầm lẫn. Init container có từ lâu: chạy trước, tuần tự, xong mới tới container chính. Từ Kubernetes 1.29 nó gánh thêm việc thứ hai, và việc đó chữa một lỗi mà ai chạy Job với service mesh đều từng gặp.
Sidecar là một init container không bao giờ kết thúc
initContainers:
- name: sc
image: busybox:1.36
restartPolicy: Always # <- dòng này biến nó thành sidecar
command: ["sh","-c","while true; do sleep 5; done"]
restartPolicy: Always trên một init container đổi hai điều: nó không chặn bước sau, và nó sống suốt đời pod.
Thứ tự, đo bằng dấu thời gian
Pod có ba init container: i1 thường, sc sidecar, i2 thường; rồi tới app.
i1 16 -> 22 (thường, 6 giây)
sc 23 -> ... (sidecar, còn chạy)
i2 24 -> 30 (thường, 6 giây)
app 31 -> ...
sc bắt đầu lúc giây 23 và giây 24 thì i2 đã chạy — không đợi sc kết thúc, vì nó không bao giờ kết thúc. Còn i1 thì chặn: sc chỉ khởi động sau khi i1 xong hẳn. Sau khi mọi init container "xong" theo nghĩa của nó — thường thì thoát, sidecar thì khởi động — app mới chạy. Pod hiện 2/2 vì sc được đếm vào tổng số container đang sống.
Điều này giải quyết vấn đề kinh điển của service mesh: proxy phải sẵn sàng trước ứng dụng, nếu không mọi kết nối trong vài giây đầu đều hỏng. Đặt proxy vào containers thì thứ tự không có gì bảo đảm; đặt vào initContainers với restartPolicy: Always thì có.
Job có sidecar: hai cách khai, hai kết cục
Hai Job giống hệt nhau — một container làm việc sleep 5 rồi thoát, một sidecar chạy vô hạn — chỉ khác chỗ khai sidecar.
| Cách khai | Sau 105 giây |
|---|---|
sidecar trong containers |
Running 0/1, pod 1/2 NotReady |
sidecar trong initContainers + restartPolicy: Always |
Complete 1/1 |
Job thứ nhất không bao giờ kết thúc. Nó đợi mọi container trong pod dừng, mà sidecar thì không tự dừng. Ai chạy CronJob trong cụm có service mesh đều gặp: công việc xong từ lâu, pod vẫn NotReady mãi, và bản chạy tiếp theo đâm vào concurrencyPolicy. Cách chữa cũ là cho container chính curl vào cổng quản trị của proxy để tự tắt nó — một mẹo vặt phải nhớ ở từng Job. Với cách khai mới, kubelet giết sidecar khi container chính thoát. Không cần mẹo gì.
Nhưng Job "xong" vẫn tốn 41 giây
Job thứ hai kết thúc, nhưng DURATION là 41 giây cho một công việc 5 giây. Tôi tưởng đo nhầm. Dấu thời gian nói rõ:
viec 16 -> 21 (5 giây, đúng)
sidecar 16 -> 46 (bị giết lúc giây 46)
Container chính xong lúc giây 21, sidecar mãi giây 46 mới chết — chậm 25 giây. Đo lại với terminationGracePeriodSeconds: 5:
| Hạn ân hạn | DURATION |
|---|---|
| 30 (mặc định) | 41 giây |
| 5 | 16 giây |
Chênh đúng 25 giây, bằng 30 − 5. Nguyên nhân giống hệt điều đo được ở phần nói về kubectl delete: sleep của busybox không bắt SIGTERM, nên kubelet phải chờ hết hạn ân hạn rồi mới SIGKILL. Nghĩa là sidecar không bắt tín hiệu sẽ cộng thẳng hạn ân hạn vào thời lượng của mọi lần chạy Job. CronJob mỗi phút một lần, mỗi lần công việc 5 giây, mà mất 41 giây — nhìn vào bảng điều khiển thì cứ tưởng công việc chậm.
Hai cách chữa, ưu tiên cách đầu:
- Làm sidecar bắt
SIGTERMvà thoát ngay. Với proxy thật (Envoy, linkerd-proxy) thì chúng đã bắt sẵn. - Hạ
terminationGracePeriodSecondscho Job. Chỉ là che triệu chứng, và hạ quá tay thì sidecar không kịp xả log hoặc đóng kết nối.
Khi nào dùng init thường, khi nào dùng sidecar
| Việc | Loại |
|---|---|
| Chờ CSDL sẵn sàng | init thường |
| Chạy migration lược đồ | init thường |
Tải cấu hình về một emptyDir |
init thường |
| Proxy của service mesh | sidecar |
| Bộ gom log đọc thư mục dùng chung | sidecar |
| Bộ làm mới bí mật định kỳ | sidecar |
Quy tắc: việc có điểm kết thúc thì là init thường; việc chạy song song với ứng dụng thì là sidecar.
Init container thường còn một điểm hay bị quên: nó dùng chung volume với container chính nhưng có thể có securityContext riêng. Cần root để chown một volume ư — cho init container chạy root, còn container chính vẫn runAsNonRoot: true. Đặc quyền chỉ tồn tại trong vài giây đầu.
Mẫu số chung: một tập hợp chỉ "xong" khi thành viên sống lâu nhất xong
Bài này gói một luật đúng ở mọi hệ có nhiều phần chạy cùng nhau: một tập hợp chỉ được coi là hoàn thành khi thành viên sống lâu nhất của nó hoàn thành — nên một thành viên không có điểm kết thúc tự nhiên sẽ treo cả tập hợp, trừ khi bạn bảo nó dừng hoặc đánh dấu nó không-chặn. Job không kết thúc vì nó chờ mọi container dừng, mà sidecar thì không tự dừng; lời giải của Kubernetes 1.29 là phân loại lại sidecar thành "phần không tính vào điều kiện hoàn thành, và bị giết khi phần chính xong".
Trong thế giới Java, đây chính xác là chuyện luồng daemon và non-daemon: JVM chỉ thoát khi mọi luồng non-daemon kết thúc, nên một luồng nền chạy vòng lặp vô hạn mà quên đánh dấu setDaemon(true) sẽ giữ cả tiến trình sống mãi sau khi main đã xong — đúng cái Job treo ở đây, chỉ khác tên. Cùng họ với CompletableFuture.allOf / Promise.all không bao giờ resolve vì một future thành viên không bao giờ settle, với một ExecutorService không shutdown() nên ứng dụng không tắt, với một tiến trình cha wait() một tiến trình con không bao giờ thoát. Ở đâu cũng vậy: hình dạng vòng đời của một thành phần quyết định cách bạn phải nối nó — thứ chạy-đến-hết (init/migration/luồng non-daemon) và thứ sống-song-song (sidecar/quạt trần/luồng daemon) cần hai cách xử lý khác nhau, và nhầm chúng thì hoặc cả hệ treo, hoặc bạn trả một cái giá chờ vô ích.
Và bài học phụ đo được: một phần không chịu dừng khi được yêu cầu (sidecar bỏ qua SIGTERM) sẽ cộng trọn thời gian chờ-cưỡng-chế vào mọi lần chạy — một khoản thuế ẩn mà nhìn bảng điều khiển cứ tưởng "công việc chậm".
Mẫu số chung: khi ghép nhiều phần vào một đơn vị, hãy phân loại từng phần theo nó có tự kết thúc hay không, và đảm bảo phần nào không tự kết thúc thì hoặc không tính vào điều kiện "xong", hoặc được ra lệnh dừng — nếu không, đơn vị của bạn hoàn thành theo nhịp của kẻ chậm nhất, và kẻ chậm nhất có thể là kẻ không bao giờ dừng.
Muốn tự soi, tìm Job và CronJob đang khai sidecar theo cách cũ:
kubectl get jobs,cronjobs -A -o json | python3 -c '
import sys,json
for o in json.load(sys.stdin)["items"]:
sp=o["spec"]
t=sp.get("template") or sp["jobTemplate"]["spec"]["template"]
n=len(t["spec"]["containers"])
if n>1:
print(o["kind"], o["metadata"]["namespace"], o["metadata"]["name"],
f"-> {n} container trong containers")
'
Mỗi dòng in ra là một Job có thể không bao giờ kết thúc. Kiểm nhanh bằng cách so DURATION với thời gian công việc thật — chênh đúng bằng hạn ân hạn thì thủ phạm là sidecar chứ không phải công việc.
Phần sau: kubectl từ phía người dùng — cache nằm ở đâu và vì sao lệnh đầu tiên sau khi đổi cụm luôn chậm hơn hẳn.