Job, CronJob & workload đặc biệt: việc chạy-rồi-xong

Tới giờ mọi workload ta gặp đều chạy mãi mãi: Deployment giữ web sống 24/7, StatefulSet giữ database luôn bật. Nhưng đời thật đầy việc chạy một lần rồi…

13 tháng 7, 2026 · 30 phút đọc

Tới giờ mọi workload ta gặp đều chạy mãi mãi: Deployment giữ web sống 24/7, StatefulSet giữ database luôn bật. Nhưng đời thật đầy việc chạy một lần rồi xong, hoặc chạy theo lịch:

  • Chạy một lần: migration schema DB, import dữ liệu, xử lý một batch ảnh, gửi một đợt email.
  • Theo lịch: backup mỗi đêm 2 giờ sáng, dọn log mỗi tuần, tổng hợp báo cáo mỗi đầu tháng.

Nếu ép những việc này vào Deployment, bạn dính một nghịch lý chết người (xem ngay dưới). K8s có đúng công cụ: Job (chạy tới khi hoàn tất), CronJob (Job theo lịch), và tiện thể ta đào sâu DaemonSet (một Pod trên mỗi node) — nhóm workload có vòng đời rất khác Deployment.


Vấn đề: Deployment không biết khái niệm "xong"

Thử chạy một script migration bằng Deployment:

   Deployment mặc định: restartPolicy = Always
        │
        ▼
   Script migration chạy xong → thoát code 0 (thành công)
        │
        ▼
   Deployment thấy container "chết" → KHỞI ĐỘNG LẠI
        │
        ▼
   Migration chạy lại... lại xong... lại restart...
   → CrashLoopBackOff dù script HOÀN TOÀN ĐÚNG. 💥

Đây là nghịch lý: với Deployment, thoát ra = lỗi cần khởi động lại. Nhưng với việc batch, thoát ra code 0 = thành công, đừng chạy lại. Deployment không phân biệt được. Ta cần một workload hiểu rằng "hoàn tất" là trạng thái đích, không phải sự cố — đó là Job.

(Nhớ Bài 02: Pod phase Succeeded hợp với Job, Running mãi hợp với web. Giờ ta dùng tới phân biệt đó.)


Job — chạy tới khi hoàn tất

Job tạo một (hoặc nhiều) Pod, chạy tới khi hoàn tất thành công, rồi dừng — không khởi động lại Pod đã xong.

apiVersion: batch/v1
kind: Job
metadata:
  name: db-migration
  namespace: myapp
spec:
  template:
    spec:
      restartPolicy: OnFailure     # KHÁC Deployment: Never hoặc OnFailure
      containers:
        - name: migrate
          image: myapp:1.0
          command: ["python", "manage.py", "migrate"]
  backoffLimit: 4                  # thử lại tối đa 4 lần nếu FAIL rồi mới bỏ cuộc
kubectl apply -f job.yaml
kubectl get jobs
# NAME           COMPLETIONS   DURATION   AGE
# db-migration   1/1           8s         30s      ← 1/1 = xong 1 Pod thành công
kubectl logs job/db-migration       # xem output migration

Các núm điều khiển quan trọng

   completions: 5      cần 5 Pod chạy XONG THÀNH CÔNG thì Job mới coi là done
   parallelism: 2      chạy tối đa 2 Pod SONG SONG cùng lúc (tăng tốc batch)
   backoffLimit: 4     fail thì thử lại tối đa 4 lần rồi bỏ (tránh lặp vô hạn)
   activeDeadlineSeconds: 600   quá 10 phút chưa xong → giết Job (chống treo)
   ttlSecondsAfterFinished: 3600  tự XÓA Job + Pod sau 1h khi xong (dọn rác)

Ví dụ batch 100 việc, chạy 5 Pod song song:

spec:
  completions: 100        # tổng 100 đơn vị việc
  parallelism: 5          # 5 Pod cùng lúc
  backoffLimit: 6

⚠️ restartPolicy của Job chỉ được Never hoặc OnFailure — không được Always (vì Always là bản chất của "chạy mãi", trái ngược với Job). Đây là lỗi copy-paste hay gặp khi bê template Deployment sang.

💡 Job vs khởi động lại: backoffLimit đếm số lần cả Job thử lại khi fail, khác với việc container tự restart. Fail quá backoffLimit → Job chuyển Failed, không thử nữa. Dùng activeDeadlineSeconds để chặn Job treo vô hạn.


CronJob — Job theo lịch

CronJob tạo Job theo lịch định kỳ — đúng như crontab bạn đã biết ở chặng Bash, nhưng chạy trên cluster.

apiVersion: batch/v1
kind: CronJob
metadata:
  name: nightly-backup
  namespace: myapp
spec:
  schedule: "0 2 * * *"            # 2 giờ sáng mỗi ngày (cú pháp cron chuẩn)
  concurrencyPolicy: Forbid        # không cho 2 lần backup chồng nhau
  successfulJobsHistoryLimit: 3    # giữ log 3 lần chạy thành công gần nhất
  failedJobsHistoryLimit: 1
  jobTemplate:
    spec:
      template:
        spec:
          restartPolicy: OnFailure
          containers:
            - name: backup
              image: myapp-backup:1.0
              command: ["/bin/sh", "-c", "pg_dump ... > /backup/db-$(date +%F).sql"]
   schedule "0 2 * * *"
             │ │ │ │ └─ thứ trong tuần (0-6, 0=CN)
             │ │ │ └─── tháng (1-12)
             │ │ └───── ngày trong tháng (1-31)
             │ └─────── giờ (0-23)
             └───────── phút (0-59)

   Ví dụ:  "*/5 * * * *"  mỗi 5 phút
           "0 */6 * * *"  mỗi 6 tiếng
           "0 0 * * 0"    0h Chủ Nhật hằng tuần

concurrencyPolicy — chống chồng chéo

Đây là núm quan trọng nhất, và là nguồn của một sự cố kinh điển (kể ở phần chuyện):

   Allow    (mặc định)  cho phép nhiều lần chạy CHỒNG nhau
                        → nguy hiểm nếu 1 lần chạy lâu hơn chu kỳ!
   Forbid               nếu lần trước CHƯA xong, BỎ QUA lần này
                        → an toàn cho backup, ETL (không muốn chồng)
   Replace              hủy lần đang chạy, thay bằng lần mới

Núm khác: startingDeadlineSeconds (nếu lỡ lịch quá lâu thì bỏ), suspend: true (tạm dừng CronJob mà không xóa).

kubectl get cronjobs
kubectl get jobs                    # thấy các Job do CronJob sinh ra theo thời gian
kubectl create job --from=cronjob/nightly-backup test-now   # chạy thử NGAY, không đợi lịch

💡 Mẹo test: đừng ngồi đợi 2 giờ sáng. kubectl create job --from=cronjob/<tên> chạy ngay một lần để kiểm tra logic.


DaemonSet — một Pod trên mỗi node

Bài 03 mới nhắc tên; giờ mở ra. DaemonSet đảm bảo đúng một bản Pod chạy trên MỖI node (hoặc mỗi node khớp selector).

   Thêm node mới vào cluster  →  DaemonSet TỰ đặt 1 Pod lên node đó
   Gỡ node                     →  Pod trên đó biến mất theo

   ┌────────┐  ┌────────┐  ┌────────┐
   │worker-1│  │worker-2│  │worker-3│   ← thêm worker-4 thì
   │ [agent]│  │ [agent]│  │ [agent]│      tự có [agent] th4
   └────────┘  └────────┘  └────────┘

Dùng cho những thứ thuộc về node, không thuộc về app:

  • Log agent — Fluent Bit, Promtail (gom log mọi container trên node).
  • Monitoring — Node Exporter (metric CPU/RAM/disk từng node).
  • Networking — CNI (Bài 01), kube-proxy thực ra chạy kiểu này.
  • Storage — driver CSI trên mỗi node.
apiVersion: apps/v1
kind: DaemonSet
metadata:
  name: node-exporter
  namespace: monitoring
spec:
  selector:
    matchLabels: { app: node-exporter }
  template:
    metadata:
      labels: { app: node-exporter }
    spec:
      tolerations:                    # để chạy được cả trên node master
        - key: node-role.kubernetes.io/control-plane
          effect: NoSchedule
      containers:
        - name: node-exporter
          image: prom/node-exporter:latest

⚠️ Nhớ taint ở Bài 03: node master bị taint nên DaemonSet thường không đặt Pod lên master. Nhưng agent giám sát/log thì cần cả master → phải thêm tolerations để "được phép" lên master. Đây là ứng dụng thực tế của taint/toleration.


Bức tranh đầy đủ — chọn workload nào

Gom lại toàn bộ họ workload đã học để bạn không bao giờ chọn nhầm:

   Workload      Vòng đời                  Dùng cho
   ────────      ────────                  ────────
   Deployment    chạyi, Pod vô danh      web, API stateless          (B03)
   StatefulSet   chạyi, Pod có danh tính  DB, Kafka, app có trạng thái (B06)
   DaemonSet     chạyi, 1 Pod/node        log/monitor/CNI agent       (B07)
   Job           chạy tới khi XONG           migration, batch một lần    (B07)
   CronJob       Job theo lịch               backup, báo cáo định kỳ     (B07)

→ Câu hỏi vàng khi tạo workload: "Việc này chạy mãi hay chạy-rồi-xong? Thuộc về app hay thuộc về node?" — trả lời được là chọn đúng ngay.


🚀 Lab — Job, CronJob, DaemonSet

Dùng cluster kind.

1. Job chạy một lần

cat <<EOF | kubectl apply -f -
apiVersion: batch/v1
kind: Job
metadata: { name: pi }
spec:
  backoffLimit: 2
  template:
    spec:
      restartPolicy: Never
      containers:
        - name: pi
          image: perl:5.34
          command: ["perl","-Mbignum=bpi","-wle","print bpi(200)"]
EOF
kubectl get jobs --watch          # COMPLETIONS 0/1 → 1/1
kubectl logs job/pi               # in 200 chữ số của pi, rồi Job dừng (không restart)

2. Job song song

cat <<EOF | kubectl apply -f -
apiVersion: batch/v1
kind: Job
metadata: { name: batch }
spec:
  completions: 6
  parallelism: 2                  # 2 Pod cùng lúc, tổng 6 lần
  template:
    spec:
      restartPolicy: Never
      containers:
        - name: work
          image: busybox
          command: ["sh","-c","echo xu ly 1 viec; sleep 3"]
EOF
kubectl get pods --watch          # thấy 2 Pod chạy song song, lần lượt đủ 6

3. CronJob mỗi phút

cat <<EOF | kubectl apply -f -
apiVersion: batch/v1
kind: CronJob
metadata: { name: hello }
spec:
  schedule: "*/1 * * * *"         # mỗi phút
  concurrencyPolicy: Forbid
  successfulJobsHistoryLimit: 2
  jobTemplate:
    spec:
      template:
        spec:
          restartPolicy: OnFailure
          containers:
            - name: hello
              image: busybox
              command: ["sh","-c","echo Chao luc \$(date)"]
EOF
# Không đợi 1 phút — chạy thử ngay:
kubectl create job --from=cronjob/hello hello-now
kubectl logs job/hello-now
kubectl get jobs --watch          # đợi ~1 phút sẽ thấy CronJob tự đẻ Job mới

4. Xem DaemonSet có sẵn của hệ thống

kubectl get daemonset -n kube-system
# kube-proxy chạy kiểu DaemonSet — 1 bản mỗi node. CNI cũng vậy.

5. Dọn

kubectl delete job pi batch hello-now 2>/dev/null
kubectl delete cronjob hello 2>/dev/null

→ Khoảnh khắc "à há" ở bước 1: Job in xong pi rồi dừng hẳn, không CrashLoop — đúng thứ Deployment không làm được. Và bước 3 cho thấy CronJob tự đẻ Job theo lịch y như crontab, nhưng phân tán trên cluster.


Câu chuyện thực tế

Hai sự cố "chạy-rồi-xong" dạy tôi hai bài khác nhau.

Sự cố một — migration chạy trong Deployment. Dev mới lên K8s, cần chạy một script migration DB. Bạn ấy làm cái quen tay nhất: bọc vào Deployment. Script chạy 5 giây, xong, thoát code 0. Deployment thấy "container chết" → restart → migration chạy lại. Vì migration đó không idempotent (chạy lại là lỗi "column already exists"), lần thứ hai trở đi nó fail → CrashLoopBackOff. Nhìn log thì thấy lỗi SQL, tưởng script sai, sửa mãi không được. Thật ra script đúng — sai ở chỗ dùng Deployment cho việc chạy-một-lần. Đổi sang Job, chạy đúng một lần, xong là dừng. 30 phút hoang mang chỉ vì chọn sai loại workload.

Sự cố hai — CronJob backup chồng nhau. Chúng tôi đặt CronJob backup DB mỗi giờ, để mặc định concurrencyPolicy: Allow. Bình thường backup mất ~10 phút, không sao. Cho tới ngày DB phình to, một lần backup kéo hơn 1 tiếng. Giờ tiếp theo, CronJob thản nhiên khởi động lần backup thứ hai trong khi lần đầu chưa xong. Rồi lần thứ ba. Ba tiến trình pg_dump cùng cày một database → I/O nghẽn → database gần như đứng, app production chậm rề. 2 giờ chiều, khách phàn nàn, chúng tôi mới phát hiện cả một đống Job backup đang chạy chồng lên nhau. Đổi concurrencyPolicy: Forbid — nếu lần trước chưa xong thì bỏ qua lần này — và thêm activeDeadlineSeconds. Từ đó êm.

Bài học:

  1. "Chạy-rồi-xong" thì dùng Job, không dùng Deployment. Deployment coi "thoát ra" là lỗi và restart mãi. Migration/batch bọc Deployment = CrashLoop dù code đúng.

  2. concurrencyPolicy: Forbid cho việc không được chồng. Backup, ETL, dọn dẹp — mặc định Allow là bom hẹn giờ khi một lần chạy lâu hơn chu kỳ. Đây là lỗi tôi mong bạn không phải tự vấp.

  3. Migration nên idempotent, và có activeDeadlineSeconds. Chạy lại không được gây hại, và có trần thời gian để không treo vô hạn.

  4. ttlSecondsAfterFinished để dọn rác. Job xong không tự biến mất — hàng trăm Job cũ đọng lại làm rối cluster. Đặt TTL cho nó tự dọn.

  5. DaemonSet cho thứ thuộc về node. Log/monitor agent phải có mặt trên mọi node kể cả master (nhớ toleration) — đó là lúc DaemonSet đúng, không phải Deployment.


Pitfalls

  1. Chạy batch/migration bằng Deployment → CrashLoopBackOff dù code đúng, vì restartPolicy: Always restart mãi khi việc xong. Dùng Job.

  2. Đặt restartPolicy: Always cho Job → K8s từ chối. Job chỉ nhận Never hoặc OnFailure.

  3. CronJob concurrencyPolicy: Allow cho việc dài → các lần chạy chồng nhau, nghẽn tài nguyên. Dùng Forbid/Replace.

  4. Quên activeDeadlineSeconds → một Job treo (vd chờ mạng) chạy mãi, ăn tài nguyên. Đặt trần thời gian.

  5. Không dọn Job cũ → hàng trăm Job/Pod Completed đọng lại. Dùng ttlSecondsAfterFinishedsuccessfulJobsHistoryLimit.

  6. Migration không idempotent rồi bị retry (backoffLimit) → lỗi "already exists". Viết migration chạy lại được, hoặc backoffLimit: 0.

  7. DaemonSet quên toleration → agent giám sát không lên được node master (do taint Bài 03) → mù nửa cluster.

  8. Nhầm parallelism với replicas. parallelism là số Pod chạy song song của một Job hữu hạn; không phải "giữ N bản mãi" như Deployment.

  9. CronJob lịch sai timezone. CronJob chạy theo giờ của control plane (thường UTC) — "2 giờ sáng" có thể lệch múi giờ VN. Kiểm tra kỹ, hoặc dùng field timeZone (K8s ≥ 1.27).

  10. Tưởng xóa CronJob sẽ dừng Job đang chạy. Xóa CronJob không giết Job/Pod nó đã sinh ra — dọn cả các Job con nếu cần.


Tóm tắt

  • Không phải workload nào cũng chạy mãi. Việc "chạy-rồi-xong" hoặc "theo lịch" cần Job/CronJob, không phải Deployment.
  • Deployment coi "thoát ra" là lỗi → restart mãi. Bọc batch/migration vào Deployment = CrashLoop dù code đúng.
  • Job chạy tới khi hoàn tất. restartPolicy chỉ Never/OnFailure. Núm: completions, parallelism, backoffLimit, activeDeadlineSeconds, ttlSecondsAfterFinished.
  • CronJob = Job theo lịch cron. concurrencyPolicy (Forbid cho việc không được chồng), history limit, suspend. Test ngay bằng kubectl create job --from=cronjob/....
  • DaemonSet = 1 Pod/mỗi node, tự theo số node. Cho log/monitor/CNI agent. Cần tolerations để lên master.
  • Chọn workload: hỏi "chạy mãi hay chạy-rồi-xong? thuộc app hay thuộc node?".

Bài sau (Bài 08 — Bảo mật & giới hạn tài nguyên) là bài "làm cho cluster đủ an toàn để lên production". Mặc định, container chạy bằng root, được ăn vô hạn CPU/RAM, và nói chuyện được với mọi Pod khác — ba quả bom. Ta sẽ dựng hàng rào: SecurityContext & Pod Security Standards (container chạy quyền tối thiểu), QoS/LimitRange (chia tài nguyên công bằng), và NetworkPolicy (tường lửa giữa các Pod).

Nếu hôm nay bạn không còn bọc migration vào Deployment, và biết concurrencyPolicy: Forbid cứu bạn khỏi một đêm mất ngủ — bạn đã hiểu nhóm workload chạy-rồi-xong.

Minh Hưng

← Bài trước
Lưu trữ & StatefulSet: chạy database trên K8s
Bài tiếp theo →
Bảo mật & giới hạn tài nguyên: chạy an toàn, chạy công bằng
Zalo tư vấn