Tất cả bài viết

Kubernetes sẽ giết pod của bạn, và đó là tính năng

8 phút đọc

Trong bài này

Mình là người viết service, không phải người vận hành cụm. Nhưng có vài thứ về Kubernetes mà nếu dev không hiểu thì service sẽ rớt request mỗi lần deploy, dù code không sai dòng nào. Bài này gom lại những thứ đó.

Chào anh em.

Trước khi vào Vinova, mình deploy theo kiểu quen thuộc: đẩy code lên Vercel hoặc Render, hoặc chạy một tiến trình trên server và để nó sống ở đó tới khi mình tắt. Tiến trình là của mình, mình không đụng thì nó không chết.

Lên Kubernetes thì khác. Lần đầu thấy pod của mình ở trạng thái Terminating dù mình không làm gì, mình tưởng có sự cố và đi hỏi khắp nơi. Hóa ra hệ thống đang chạy đúng như thiết kế.

Mình nói trước là mình không phải DevOps. Bài này viết từ góc nhìn của một người viết service bằng Go, cần biết vừa đủ để code của mình không phá hỏng lần deploy. Chỗ nào về vận hành cụm thì anh em DevOps rành hơn mình nhiều.

Pod chết là chuyện thường ngày

Một pod có thể bị tắt vì rất nhiều lý do, và không cái nào là lỗi của mình:

  • Deploy bản mới, pod cũ phải nhường chỗ.
  • Node cần bảo trì hoặc nâng cấp, mọi pod trên đó bị dời đi.
  • Autoscaler thấy tải giảm nên bớt số pod.
  • Pod dùng quá bộ nhớ cho phép.

Nên câu hỏi không còn là "làm sao để service không bao giờ chết", mà là "làm sao để nó chết mà không ai nhận ra". Nghe hơi kỳ nhưng đổi được cách nghĩ này là hiểu được gần hết phần còn lại.

Ba loại probe, ba câu hỏi khác nhau

Kubernetes không tự biết service của mình có ổn không. Nó hỏi, bằng cách gọi vào những endpoint mình khai báo.

ProbeNó hỏi gìTrả lời sai thì sao
startupProbeKhởi động xong chưa?Container bị giết và khởi động lại. Trong lúc chờ, hai probe kia chưa chạy
readinessProbeGửi request cho mày được chưa?Pod bị gỡ khỏi danh sách nhận request, nhưng không bị giết
livenessProbeMày còn sống không?Container bị khởi động lại
containers:
  - name: api
    startupProbe:
      httpGet: {path: /healthz, port: 8080}
      failureThreshold: 30
      periodSeconds: 2
    readinessProbe:
      httpGet: {path: /ready, port: 8080}
    livenessProbe:
      httpGet: {path: /healthz, port: 8080}

Mặc định mỗi probe được gọi 10 giây một lần, và phải sai 3 lần liên tiếp thì mới tính là hỏng.

Chỗ mình hiểu sai lúc đầu là tưởng readiness với liveness giống nhau nên cho cả hai kiểm tra kết nối database. Nghe thì hợp lý: database chết thì service cũng vô dụng mà.

Nhưng thử nghĩ tiếp. Database chậm trong một phút. Readiness sai, pod tạm ngưng nhận request. Đúng. Rồi liveness cũng sai, và Kubernetes khởi động lại toàn bộ các pod, vốn chẳng làm gì sai. Lúc database hồi lại thì cả đám đang khởi động cùng lúc và cùng lao vào mở kết nối.

Nên quy tắc mình dùng bây giờ là: /healthz chỉ trả lời "tiến trình này còn chạy", không đụng tới thứ gì bên ngoài. /ready mới kiểm tra những thứ service cần để phục vụ.

requests và limits, và hai kiểu bị phạt

resources:
  requests:
    cpu: 100m
    memory: 128Mi
  limits:
    memory: 256Mi

requests là thứ scheduler dùng để xếp chỗ: node nào còn đủ chừng đó tài nguyên thì pod mới được đặt vào. Service vẫn dùng nhiều hơn mức này được nếu node còn dư.

limits là trần, và ở đây có một điểm mình phải đọc tài liệu mới biết: vượt trần CPU và vượt trần bộ nhớ bị xử lý hoàn toàn khác nhau.

  • Vượt CPU thì bị bóp. Kernel không cho dùng thêm, service chạy chậm lại nhưng vẫn sống.
  • Vượt bộ nhớ thì có thể bị giết. Kernel dọn bằng cách OOM kill, không báo trước, không cho dọn dẹp. Nhìn vào pod sẽ thấy OOMKilled với exit code 137.

Trong ví dụ trên mình cố tình không đặt limit cho CPU. Lý do là CPU bị bóp gây ra kiểu chậm rất khó truy: service không lỗi, chỉ thỉnh thoảng trả lời trễ. Chuyện này còn tùy quy định của từng cụm, nên anh em hỏi DevOps bên mình trước khi làm theo.

Chuyện gì xảy ra khi một pod bị xóa

Đây là phần quan trọng nhất của bài, và cũng là phần mình hiểu sai lâu nhất.

Mình từng nghĩ thứ tự là: Kubernetes ngừng gửi request tới pod, rồi mới báo pod tắt. Hợp lý mà, đúng không?

Thực tế không phải vậy.

  1. 0sCó lệnh xóa podPod được đánh dấu Terminating. Đồng hồ ân hạn 30 giây bắt đầu chạy
  2. 0sHai việc bắt đầu cùng lúcViệc A: gỡ pod khỏi danh sách nhận request. Việc B: kubelet bắt đầu tắt container
  3. 0spreStop chạy, nếu có khai báoVí dụ sleep 5 giây
  4. 5sSIGTERM tới tiến trình số 1Service ngừng nhận mới và làm nốt request đang dở
  5. 30sHết thời gian ân hạnThứ gì còn chạy sẽ nhận SIGKILL
Trình tự khi một pod bị xóa, với preStop ngủ 5 giây và thời gian ân hạn mặc định 30 giây.

Để ý dòng thứ hai. Hai việc A và B chạy song song, không cái nào chờ cái nào.

Việc A nghe thì nhanh nhưng thật ra phải lan ra rất nhiều nơi: kube-proxy trên từng node phải cập nhật lại luật, ingress controller phải biết, load balancer của cloud cũng phải biết. Việc đó mất vài giây. Trong vài giây đó, request vẫn được gửi tới một pod có thể đã tắt xong.

Kết quả là mỗi lần deploy lại có vài request nhận 502. Không nhiều, không đều, và không tái hiện được ở máy mình. Kiểu lỗi làm người ta nghi ngờ đủ thứ trừ đúng nguyên nhân.

Sửa thế nào

Ý tưởng rất đơn giản: đừng tắt vội. Chờ vài giây để việc A kịp lan ra, rồi mới tắt.

Cách ít phải sửa code nhất là thêm một cái preStop:

containers:
  - name: api
    lifecycle:
      preStop:
        exec:
          command: ["sleep", "5"]
terminationGracePeriodSeconds: 30

Có hai điều cần nhớ. Thứ nhất, thời gian preStop được tính vào 30 giây ân hạn. Nếu ngủ 5 giây thì service chỉ còn 25 giây để làm nốt việc. Request của anh em có thể chạy lâu hơn thế thì phải tăng terminationGracePeriodSeconds lên.

Thứ hai, lệnh sleep phải có trong image. Image kiểu distroless không có shell cũng không có sleep. Các bản Kubernetes gần đây có thêm một kiểu preStop ngủ có sẵn, không cần lệnh trong image; anh em kiểm tra xem cụm của mình hỗ trợ chưa.

Phần còn lại nằm trong code. Khi nhận SIGTERM, service phải ngừng nhận request mới và làm xong những cái đang dở:

ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT)
defer stop()

go func() {
	if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
		log.Fatal(err)
	}
}()

<-ctx.Done()       // Kubernetes báo: chuẩn bị tắt
ready.Store(false) // /ready bắt đầu trả 503

shutdownCtx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()

if err := server.Shutdown(shutdownCtx); err != nil { // chờ các request đang dở, tối đa 20 giây
	log.Printf("shutdown: %v", err)
}

Đặt 20 giây vì 5 giây đã dùng cho preStop, và mình muốn chừa vài giây dự phòng trước mốc 30.

Cái bẫy trong Dockerfile

Cái này làm mình mất nhiều thời gian nhất, vì code đúng mà không chạy.

# dạng shell: tiến trình số 1 là /bin/sh, service có thể không bao giờ nhận được SIGTERM
CMD ./api

# dạng exec: service là tiến trình số 1 và nhận tín hiệu trực tiếp
CMD ["./api"]

Kubernetes gửi SIGTERM cho tiến trình số 1 trong container. Viết CMD dạng shell thì tiến trình số 1 là sh, và nó thường không chuyển tín hiệu cho service của mình. Thế là đoạn code tắt cho tử tế ở trên không bao giờ được chạy. Service cứ ngồi đó cho hết 30 giây rồi bị SIGKILL.

Dấu hiệu nhận biết: mỗi lần deploy, pod cũ luôn mất đúng 30 giây mới biến mất.

Mấy dòng mình dán cạnh màn hình

  1. /healthz không kiểm tra thứ gì bên ngoài tiến trình.
  2. /ready trả 503 ngay khi nhận SIGTERM.
  3. Có preStop ngủ vài giây, hoặc service tự chờ trước khi tắt.
  4. Tổng thời gian preStop cộng thời gian tắt phải nhỏ hơn thời gian ân hạn.
  5. CMD viết dạng exec.
  6. Luôn đặt requests, và luôn đặt limit cho bộ nhớ.

Thứ thay đổi trong đầu mình

Sau hai tháng, thứ mình thấy khác nhất không phải là YAML. Nó là cách mình nhìn bộ nhớ của tiến trình.

Trước đây mình hay giữ vài thứ trong bộ nhớ cho tiện: một cái cache nhỏ, một danh sách việc đang làm dở. Giờ mình coi mọi thứ trong bộ nhớ là đồ tạm, có thể mất bất cứ lúc nào. Thứ gì cần sống lâu hơn một pod thì phải nằm ở chỗ khác: Redis, database hoặc Kafka.

Viết ra thì thấy hiển nhiên. Nhưng mình phải tự thấy pod của mình bị giết vài lần thì mới thật sự viết code theo cách đó.

Bài tới đây là hết. Mình viết từ góc của dev nên chắc chắn còn thiếu, anh em nào làm vận hành thấy chỗ nào chưa đúng thì chỉ mình với. Cảm ơn anh em đã đọc.

Đọc thêm