Bỏ qua tới nội dung

Kỹ thuật

Cron job chạy mãi không xong: heartbeat thật ra nhìn thấy gì?

Mình dừng một job bằng SIGTERM và SIGKILL, mỗi kiểu ba lần. Một cách gửi ping rất phổ biến báo “thành công” cho job đã bị giết.

Andrew Babo7 phút đọc

Chia sẻ
Trong bài này
Cron job chạy mãi không xong: heartbeat thật ra nhìn thấy gì?

Hãy gửi heartbeat từ chính dòng cron khởi động job, đừng gửi từ bên trong job, và đặt cho monitor một thời gian chạy tối đa. Trong thí nghiệm của mình, ping gửi từ EXIT trap trong script bash báo thành công cho một job đã bị SIGTERM dừng, cả 3/3 lần. Cùng job đó, ping từ dòng cron báo đúng mã thoát thật: 143 hoặc 137.

Chỉ có một trường hợp không còn ping cuối nào: cả dòng cron bị giết. Khi đó, thứ duy nhất báo động cho bạn là kiểm tra thời gian chạy tối đa.

Job định kỳ có thể chết theo bốn kiểu#

Một heartbeat monitor (còn gọi là dead man's switch) chỉ biết ping nào đã đến và đến lúc nào. Vậy nên trước khi quyết định ping gửi từ đâu, nên liệt kê xem job có thể gặp chuyện gì:

  • Lỗi. Script thoát với mã khác 0. Dễ báo, miễn là còn thứ gì đó đang chạy để báo.
  • Bị dừng. Có thứ gửi SIGTERM: timeout, systemd hết thời gian dừng unit, docker stop, một lần deploy khởi động lại máy. Trang manual của timeout(1) ghi rõ: "Upon timeout, send the TERM signal to COMMAND, if no other SIGNAL specified."
  • Bị giết. SIGKILL không bắt được. OOM killer dùng nó, kill -9 dùng nó, và systemd gửi nó cho mọi tiến trình còn sót sau thời gian chờ dừng (systemd.kill).
  • Không chạy, hoặc chạy mãi. Máy chết, dòng crontab bị mất khi chuyển server, hoặc job đứng chờ một lock hay một lời gọi mạng không bao giờ trả về.

Ba kiểu đầu còn một tiến trình có thể kịp gửi ping cuối, hoặc không. Kiểu thứ tư thì chẳng còn ai để gửi.

Thí nghiệm#

Mình muốn có số đo thay vì đoán, nên chạy một thí nghiệm nhỏ ngày 25/10/2026. Một HTTP receiver chạy trên 127.0.0.1 ghi lại mọi ping kèm thời điểm. Job ngủ 20 giây và bị dừng ở giây thứ 2. Mỗi trường hợp chạy ba lần. Phiên bản: GNU bash 5.3.3, dash 0.5.13.1, curl 8.18.0, Python 3.13.12, trên sandbox Linux 4.19 (gVisor). Không dùng ping URL thật nào.

Phần một gửi ping từ bên trong job, đúng kiểu nhiều script vẫn viết:

Terminal
#!/usr/bin/env bash# job2.sh <kiểu> <số giây> — chỉ ping tới receiver cục bộset -uV="$1"; WORK="$2"; PING="http://127.0.0.1:8765/hb/$V"ping() { curl -fsS -m 10 -o /dev/null "$PING/$1" || true; }ping startcase "$V" in  naive)     trap 'ping $?' EXIT             sleep "$WORK" ;;  term-trap) trap 'ping fail; exit 143' TERM INT             trap 'ping $?' EXIT             sleep "$WORK" ;;  term-wait) trap 'ping fail; exit 143' TERM INT             trap 'ping $?' EXIT             sleep "$WORK" & wait $! ;;esac

Phần hai gửi ping từ bên ngoài, ngay trên dòng mà cron chạy, với cả bash lẫn dash (Debian và Ubuntu chạy dòng cron bằng dash):

Terminal
curl -fsS -m 10 -o /dev/null "$P/start"; ./work.sh; curl -fsS -m 10 -o /dev/null "$P/$?"

Kết quả nhận được#

Cả ba lần lặp của mọi trường hợp đều cho cùng một chuỗi ping.

Ping cuối gửi từ đâuJob bị dừng (SIGTERM)Job bị giết (SIGKILL)
Trong job, chỉ có EXIT trapstart, rồi 0: bị coi là thành côngchỉ start
Trong job, có TERM trap, việc chạy foregroundstart, fail, 143, nhưng 18,1 giây sau tín hiệuchỉ start
Trong job, có TERM trap, việc chạy qua waitstart, fail, 143, 0,07 giây sau tín hiệuchỉ start
Ngoài job, trên dòng cron (bash và dash)start, 143start, 137
Cả dòng cron bị giết—chỉ start
Dòng thời gian các ping mà từng kiểu gửi sau khi job bị dừng ở giây thứ 2
Thí nghiệm 25/10/2026: ping cuối của từng kiểu đến lúc nào

EXIT trap báo một job bị giết là khoẻ#

Bash vẫn chạy trap khi SIGTERM kết thúc script, nhưng $? bên trong trap lại giữ mã của lệnh cuối cùng đã chạy xong, tức là lệnh ping start, bằng 0. Monitor nào tin vào mã thoát sẽ đánh dấu lần chạy đó là ổn. Log của job cũng không nói gì khác, vì job chưa kịp ghi dòng nào.

TERM trap phải đợi lệnh foreground chạy xong#

Khi phần việc chính chạy ở foreground, trap chỉ chạy khi sleep kết thúc, tức 18 giây sau. Điều này khớp với manual của bash: nếu bash "receives a signal for which a trap has been set, it will not execute the trap until the command completes." Cùng trang đó có luôn cách sửa: khi bash đang chờ qua lệnh wait, wait trả về ngay với mã lớn hơn 128 và trap được chạy. Trong một lần dừng thật, 18 giây thường dài hơn thời gian ân hạn, nên trap sẽ bị SIGKILL cắt ngang và không bao giờ chạy.

Không gì bên trong job sống sót qua SIGKILL#

Mọi kiểu ping từ trong job đều gửi start rồi im bặt. Còn khi ping từ dòng cron, shell chạy dòng đó vẫn sống và báo 137, mã của tiến trình bị giết bằng tín hiệu số 9.

Ai còn sống để gửi ping cuối?#

Nhìn dưới dạng cây tiến trình sẽ dễ hiểu hơn. Cron mở một shell cho dòng lệnh, shell đó mới chạy job của bạn. Thứ gửi ping cuối phải sống lâu hơn job.

Cây tiến trình từ cron tới job, cho thấy tiến trình nào còn gửi được ping cuối sau mỗi tín hiệu
Ping từ tiến trình cha: nó sống lâu hơn tiến trình con

Từ đó, cách thiết lập đã đứng vững trong thí nghiệm:

Terminal
# crontab: ping start, chạy job, rồi gửi mã thoát (0 = OK, 1–255 = lỗi)0 2 * * * curl -fsS -m 10 --retry 5 -o /dev/null "$PING_URL/start" ; /usr/local/bin/nightly-job.sh ; curl -fsS -m 10 --retry 5 -o /dev/null "$PING_URL/$?"

Nếu bắt buộc job phải tự gửi ping, hãy đặt phần việc chính sau wait và bắt TERM rõ ràng, như kiểu term-wait. Sau đó thêm thời gian chạy tối đa trên monitor cho những gì trap không bao giờ thấy được.

Những gì heartbeat vẫn không thấy#

Còn hai lỗ hổng, và không kiểu ping nào bịt được.

Lỗ hổng thứ nhất là dòng cuối của bảng: cả dòng cron chết, ví dụ container bị kill hoặc máy khởi động lại. Monitor nhận start rồi không còn gì nữa. Chỉ có thời gian chạy tối đa mới biến sự im lặng đó thành cảnh báo, vì vậy hãy đặt nó dài hơn một chút so với lần chạy khoẻ chậm nhất của bạn.

Lỗ hổng thứ hai là job thoát với mã 0 mà chẳng làm được gì, ví dụ một lần export ghi ra 0 dòng. Heartbeat đo việc job đã chạy, không đo việc job chạy đúng. Nếu kết quả quan trọng, hãy cho job thoát với mã khác 0 khi kết quả rỗng, hoặc gửi kèm số dòng trong ping để thấy nó ngay trong lần chạy.

Belvador giúp ở đâu#

Ping URL monitor của Belvador dùng đúng khuôn như thí nghiệm: gọi /start khi job bắt đầu, rồi gọi URL gốc khi thành công, /fail, hoặc gắn mã thoát làm hậu tố (/143). Lần chạy đã gọi /start mà vẫn mở sau thời gian chạy tối đa (mặc định 30 phút) bị tính là lỗi. Lần chạy không hề bắt đầu sẽ bị đánh dấu khi đã qua lịch chạy cộng thời gian trễ cho phép (mặc định 5 phút).

Hạn chót được giữ ở edge, nên cảnh báo vẫn đi khi chính server của bạn là thứ đã chết. Tài liệu ping URL liệt kê mọi hậu tố và trạng thái; nếu bạn đang dùng cron monitor của Sentry SDK, xem check-in từ Sentry SDK. Trang theo dõi cron giải thích phần này nằm ở đâu trong Belvador.

Đọc tiếp#

Andrew Babo

Nhà sáng lập

Thuộc bài hướng dẫn: Giám sát AI agent chạy trên serverless function

Mọi bài viết

Hướng dẫn

Giám sát AI agent chạy trên serverless function

Một lần chạy agent trên serverless là một chuỗi lần gọi ngắn, mỗi lần có giới hạn thời gian riêng. Bài này chỉ ra cần theo dõi gì ở từng mắt xích, kèm ba thí nghiệm nhỏ cho thấy tín hiệu bị mất ở đâu.

Andrew Babo8 phút đọc

Đọc tiếp trong tài liệu

Xem lỗi production của chính bạn như thế này

Belvador chạy trên máy chủ Hetzner của chính bạn: lỗi, cron, uptime và trace — dữ liệu vẫn là của bạn.