Lên lịch tác vụ (scheduling)
Xem thửTình huống: script backup chạy tốt bằng tay, nhưng ba đêm liền không có file nào
Đội GDev viết backup.sh để chụp snapshot dữ liệu của gdev-api mỗi đêm lúc 02:15. Chạy bằng tay thì xong trong vài giây và in backup done. Lên lịch bằng cron xong, sáng hôm sau thư mục backup trống. Ba đêm như vậy, và không ai biết vì cron không báo gì ra màn hình. Đến ngày thứ tư, một lần chạy kéo dài hơn dự kiến thì lần chạy kế tiếp lại bắt đầu chồng lên, hai bản snapshot ghi cùng một file.
Cả hai lỗi cùng gốc: cron không chạy lệnh trong phiên đăng nhập của bạn. Nó chạy trong một môi trường tối giản, không có chỗ để đọc lỗi, và không tự biết lần chạy trước đã xong chưa. Bài này dựng lại mô hình đúng, rồi chuyển sang systemd timer, công cụ thay thế hiện đại giải quyết nhiều vấn đề trên.
Sau bài này bạn làm được các việc sau:
- Đọc và viết lịch cron năm trường, tránh các bẫy như quy tắc "hoặc" giữa ngày trong tháng và ngày trong tuần.
- Giải thích vì sao script chạy tay được mà chạy từ cron thì lỗi, và sửa bằng cách làm script tự đủ.
- Chống chạy chồng bằng
flock, ghi log để biết chuyện gì đã xảy ra, và viết cặp file.servicevới.timer.
Bạn cần có sẵn: bài Tạo shell script, bài Câu lệnh điều kiện và Vòng lặp trước đó, phần chuyển hướng 2>&1 ở chương Linux, và một máy Ubuntu hoặc Debian để thử.
Ba mô hình cần nhớ
- Cron là daemon kiểm tra bảng lịch mỗi phút và chạy lệnh bằng
/bin/shvới môi trường tự dựng. Nó không đọc~/.bashrc, không có terminal và không có thư mục làm việc quen thuộc. - Lịch chỉ nói khi nào bắt đầu, không nói khi nào xong. Muốn tránh hai lần chạy chồng nhau và muốn biết kết quả, bạn phải tự thêm khóa và log.
- systemd timer tách "khi nào" ra khỏi "chạy gì": file
.timergiữ lịch, file.servicegiữ lệnh, và log, trạng thái, giới hạn tài nguyên đi kèm service.
Chuẩn bị môi trường lab
Lab cần một máy Ubuntu 22.04 hoặc mới hơn (hoặc Debian) có cron và systemd. Các kết quả về shell, flock và systemd-analyze trong bài được chạy thật (bash 5.0.17, systemd 245, Ubuntu 20.04). Chúng tôi không chạy được daemon cron thật trên máy soạn bài, nên phần "môi trường của cron" được mô phỏng bằng env -i với đúng các biến mà man 5 crontab mô tả, và có cách xác nhận bằng cron thật ngay bên dưới.
systemctl is-active cron
crontab -l
Lệnh đầu in active nếu cron đang chạy. Lệnh thứ hai in no crontab for <tên bạn> nếu bạn chưa có bảng lịch nào. Nếu bạn đã có crontab, hãy sao lưu bằng crontab -l > ~/crontab.bak trước khi thử.
Tạo công cụ giả gdev-ctl đặt trong ~/.local/bin, hai biến môi trường trong ~/.bashrc, và script backup. Đây là kiểu thiết lập rất thường gặp: công cụ và biến chỉ tồn tại vì phiên đăng nhập của bạn nạp .bashrc.
mkdir -p ~/gdev-lab/sched ~/.local/bin && cd ~/gdev-lab/sched
cat > ~/.local/bin/gdev-ctl <<'EOF'
#!/bin/sh
echo "gdev-ctl: $1 -> $2"
EOF
chmod +x ~/.local/bin/gdev-ctl
echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.bashrc
echo 'export GDEV_BACKUP_DIR="$HOME/gdev-lab/sched/backups"' >> ~/.bashrc
source ~/.bashrc
cat > backup.sh <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
mkdir -p "$GDEV_BACKUP_DIR"
gdev-ctl snapshot "$GDEV_BACKUP_DIR/api-$(date +%F).tar"
echo "backup done"
EOF
chmod +x backup.sh
./backup.sh
Kết quả minh họa (đường dẫn là của máy bạn):
gdev-ctl: snapshot -> /home/gdev/gdev-lab/sched/backups/api-2026-10-24.tar
backup done
Chạy tay thì mọi thứ ổn. Đó chính là điều khiến lỗi sau này khó thấy.
Mô hình 1: cron đọc bảng lịch năm trường
Mỗi dòng của crontab có năm trường thời gian, theo sau là lệnh. Cron kiểm tra bảng mỗi phút, và chạy lệnh khi phút, giờ và tháng khớp và ít nhất một trong hai trường ngày khớp.
| Trường | Giá trị | Ví dụ |
|---|---|---|
| Phút | 0 đến 59 | 15 là phút thứ 15 |
| Giờ | 0 đến 23 | 2 là 2 giờ sáng |
| Ngày trong tháng | 1 đến 31 | 1,15 là ngày 1 và 15 |
| Tháng | 1 đến 12 hoặc tên | * là mọi tháng |
| Ngày trong tuần | 0 đến 7 (0 và 7 đều là Chủ nhật) hoặc tên | 1-5 là thứ Hai tới thứ Sáu |
Dấu * nghĩa là mọi giá trị, dấu phẩy tạo danh sách, dấu gạch ngang tạo khoảng, và /n tạo bước nhảy. Bảng dưới đây liệt kê các lịch hay dùng, kèm số lần chạy trong một ngày thứ Hai (đã đếm bằng một chương trình mô phỏng lịch nhỏ):
| Dòng lịch | Ý nghĩa | Lần chạy trong thứ Hai |
|---|---|---|
*/5 * * * * | Mỗi 5 phút | 288 |
15 2 * * * | 02:15 mỗi ngày | 1 (lúc 02:15) |
0 9-17 * * 1-5 | Đầu mỗi giờ từ 9 đến 17 giờ, ngày làm việc | 9 |
*/15 9-17 * * 1-5 | Mỗi 15 phút từ 09:00 tới 17:45, ngày làm việc | 36 |
0 */6 * * * | Mỗi 6 giờ (00, 06, 12, 18) | 4 |
0 3 * * 0 | 03:00 sáng Chủ nhật | 0 |
0 0 1 * * | 00:00 ngày đầu mỗi tháng | 0 |
Cron cũng có các từ khóa viết tắt như @reboot, @hourly, @daily, @weekly, @monthly. Từ khóa @reboot chạy một lần khi cron daemon khởi động.
Bốn bẫy của cú pháp
Bẫy 1: hai trường ngày là phép "hoặc". Theo man 5 crontab, nếu cả ngày trong tháng và ngày trong tuần đều bị giới hạn (không bắt đầu bằng *), lệnh chạy khi một trong hai khớp. Dòng sau không có nghĩa "thứ Sáu đầu tiên của tháng":
30 4 1 * 5 /home/gdev/gdev-lab/sched/backup.sh
Nó chạy lúc 04:30 vào ngày 1 hàng tháng và vào mọi thứ Sáu. Trong tháng 10 năm 2026 dòng này chạy 6 lần (ngày 1 cộng 5 ngày thứ Sáu), không phải 1 lần. Muốn "thứ Sáu đầu tiên" hãy lên lịch mỗi thứ Sáu rồi kiểm tra ngày trong script: [[ $(date +%d) -le 7 ]] || exit 0.
Bẫy 2: dấu % có nghĩa đặc biệt. Trong phần lệnh của crontab, % bị đổi thành ký tự xuống dòng, và mọi thứ sau % đầu tiên được đưa vào stdin của lệnh. Vì vậy date +%F trong crontab sai. Phải viết date +\%F, hoặc tốt hơn là đặt lệnh vào script như backup.sh đã làm.
Bẫy 3: chú thích cùng dòng với lệnh. Cron không cho phép chú thích ở cuối dòng lệnh, nó coi chú thích là một phần của lệnh. Hãy đặt chú thích trên dòng riêng.
Bẫy 4: crontab -r xóa toàn bộ bảng lịch ngay, không hỏi. Phím r nằm cạnh e của crontab -e. Trên Debian và Ubuntu, bạn có thể dùng crontab -i -r để được hỏi xác nhận. Ngoài ra, crontab file thay thế toàn bộ crontab hiện có bằng file đó.
Múi giờ
Cron chạy theo múi giờ của máy chủ. Lịch 15 2 * * * trên máy đặt UTC và trên máy đặt giờ Việt Nam cho hai thời điểm khác nhau 7 tiếng. Hãy kiểm tra bằng timedatectl và ghi rõ múi giờ trong tài liệu vận hành, nhất là khi một đội dùng máy ở nhiều vùng.
Cron thật sự chạy lệnh của bạn như thế nào
Dự đoán trước khi xem sơ đồ: khi cron chạy dòng 15 2 * * * /home/gdev/gdev-lab/sched/backup.sh, nó dùng shell nào và PATH gồm những thư mục nào? Kết quả echo $PATH trong terminal của bạn có giống không?
Đang tải sơ đồ…
Cách đọc sơ đồ:
- Nhánh
noở trên cùng cho thấy cron không có khái niệm "chạy bù": nếu máy đang tắt đúng 02:15 thì lần chạy đó mất. Đây là lý do có anacron vàPersistent=của systemd timer ở phần sau. - Khối tạo môi trường là chỗ gây hầu hết các lỗi "chạy tay được mà cron không". Theo
man 5 crontab,SHELLlà/bin/sh,LOGNAMEvàHOMElấy từ/etc/passwd, cònPATHlà/usr/bin:/bin. Không có gì từ~/.bashrchay~/.profile. - Trên Ubuntu
/bin/shlà dash, nên lệnh trong crontab không dùng được[[ ]]haysource. Script có shebang bash thì không bị ảnh hưởng vì shebang chọn lại shell cho script. - Hai nhánh cuối cho thấy đầu ra không đi tới terminal nào. Nếu máy không có chương trình gửi mail (nhiều server không cài), cả lỗi lẫn kết quả biến mất. Kết luận: mỗi dòng cron thật nên tự redirect đầu ra vào file log.
Để xem môi trường thật của cron trên máy bạn, thêm tạm một dòng bằng crontab -e, đợi một phút, rồi xóa nó:
* * * * * env | sort > /home/gdev/gdev-lab/sched/cron-env.txt
Nội dung cron-env.txt theo tài liệu sẽ chỉ có vài biến, như sau (kết quả minh họa, tên người dùng và đường dẫn của bạn sẽ khác):
HOME=/home/gdev
LOGNAME=gdev
PATH=/usr/bin:/bin
PWD=/home/gdev
SHELL=/bin/sh
Một chi tiết nữa: file /etc/crontab và các file trong /etc/cron.d có thêm cột tên người dùng ở giữa, và file /etc/crontab tự đặt PATH dài hơn. Bảng lịch của từng người dùng, tạo bằng crontab -e, thì không có cột đó và dùng PATH ngắn.
Cùng một script, hai môi trường
Dự đoán trước khi bấm: khi cron chạy backup.sh, lỗi đầu tiên xuất hiện là gì, và sau khi bạn sửa lỗi đó thì có lỗi thứ hai không?
Đang tải Animation…
Failure lab: tái hiện "chạy tay được, cron thì không"
Ta mô phỏng môi trường cron bằng env -i (xóa sạch biến môi trường) rồi đặt lại đúng các biến theo man 5 crontab, và chạy lệnh bằng /bin/sh -c như cron làm:
cd ~/gdev-lab/sched
env -i HOME="$HOME" LOGNAME="$USER" SHELL=/bin/sh PATH=/usr/bin:/bin \
/bin/sh -c "$HOME/gdev-lab/sched/backup.sh"; echo "exit=$?"
Kết quả minh họa:
/home/gdev/gdev-lab/sched/backup.sh: line 3: GDEV_BACKUP_DIR: unbound variable
exit=1
Lớp lỗi thứ nhất: biến GDEV_BACKUP_DIR chỉ có vì .bashrc đặt, mà cron không đọc .bashrc. Cách kiểm chứng giả thuyết là chạy env -i ... /bin/sh -c env và thấy chỉ có năm biến. Giả sử bạn vá bằng cách truyền biến vào:
env -i HOME="$HOME" LOGNAME="$USER" SHELL=/bin/sh PATH=/usr/bin:/bin \
GDEV_BACKUP_DIR="$HOME/gdev-lab/sched/backups" \
/bin/sh -c "$HOME/gdev-lab/sched/backup.sh"; echo "exit=$?"
Kết quả minh họa:
/home/gdev/gdev-lab/sched/backup.sh: line 4: gdev-ctl: command not found
exit=127
Lớp lỗi thứ hai: gdev-ctl nằm ở ~/.local/bin, thư mục không có trong PATH=/usr/bin:/bin. Mã 127 là mã thoát chuẩn cho "command not found".
Hai lỗi nhỏ khác cũng đến từ cùng một nguồn. Chạy [[ ]] hoặc source trực tiếp trong dòng crontab (shell là /bin/sh, tức dash trên Ubuntu) cho kết quả thật như sau:
env -i PATH=/usr/bin:/bin /bin/sh -c 'if [[ -f /etc/hostname ]]; then echo yes; fi'
env -i PATH=/usr/bin:/bin /bin/sh -c 'source /etc/os-release'; echo "exit=$?"
Kết quả:
/bin/sh: 1: [[: not found
/bin/sh: 1: source: not found
exit=127
Đường dẫn tương đối là lỗi thứ ba: cron bắt đầu ở thư mục nhà của người dùng, không phải thư mục chứa script, nên cat config.env hay ./helper.sh trong script sẽ không tìm thấy file.
Cách sửa: làm script tự đủ
Thay vì rải biến và PATH vào từng dòng crontab, đặt chúng một lần trong script:
#!/usr/bin/env bash
set -euo pipefail
export PATH="$HOME/.local/bin:/usr/local/bin:/usr/bin:/bin"
: "${GDEV_BACKUP_DIR:=$HOME/backups}"
mkdir -p "$GDEV_BACKUP_DIR"
gdev-ctl snapshot "$GDEV_BACKUP_DIR/api-$(date +%F).tar"
echo "backup done"
Dòng : "${GDEV_BACKUP_DIR:=$HOME/backups}" đặt giá trị mặc định nếu biến chưa có, dấu : là lệnh rỗng chỉ để kích hoạt phép mở rộng. Lưu thành backup.sh rồi chạy lại trong môi trường mô phỏng:
env -i HOME="$HOME" LOGNAME="$USER" SHELL=/bin/sh PATH=/usr/bin:/bin \
/bin/sh -c "$HOME/gdev-lab/sched/backup.sh"; echo "exit=$?"
Kết quả minh họa:
gdev-ctl: snapshot -> /home/gdev/backups/api-2026-10-24.tar
backup done
exit=0
Với script cần cấu hình, đặt đường dẫn tuyệt đối cho mọi thứ, hoặc dùng cd "$(dirname "$0")" ở đầu script. Nếu vẫn muốn đặt PATH trong crontab, hãy viết đường dẫn đầy đủ, vì theo man 5 crontab giá trị của dòng gán biến không được mở rộng: PATH=$HOME/bin:$PATH sẽ không hoạt động như bạn nghĩ. Với bí mật như token, đừng đặt vào crontab (ai đọc được bảng lịch đều thấy): hãy để trong file riêng có quyền 600 mà script tự đọc.
Để xác nhận bằng cron thật, thêm dòng này vào crontab -e, đợi một phút rồi đọc log (nội dung minh họa):
* * * * * /home/gdev/gdev-lab/sched/backup.sh >> /home/gdev/gdev-lab/sched/backup.log 2>&1
cat ~/gdev-lab/sched/backup.log
gdev-ctl: snapshot -> /home/gdev/backups/api-2026-10-24.tar
backup done
Nhớ xóa dòng thử nghiệm sau khi xong. Khi cron chạy, bạn cũng có thể thấy dòng ghi nhận ở log hệ thống: trên Ubuntu là grep CRON /var/log/syslog | tail -n 3, mỗi dòng cho biết người dùng và lệnh đã được kích hoạt, nhưng không cho biết lệnh thành công hay không. Chỉ log của chính script mới nói điều đó.
Mô hình 2: lịch chỉ nói khi nào bắt đầu, nên cần khóa và log
Cron không biết lần chạy trước xong chưa. Nếu backup một đêm chạy lâu hơn khoảng cách giữa hai lần lên lịch, hai bản sẽ chạy chồng và ghi cùng một file. Cách chuẩn trên Linux là flock, dùng khóa mức nhân hệ điều hành gắn với file khóa.
Script thử nghiệm giả lập một job kéo dài 3 giây:
cat > job.sh <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
exec 9>/tmp/gdev-demo.lock
if ! flock -n 9; then
echo "$(date +%T) another run is active, skipping" >&2
exit 0
fi
echo "$(date +%T) start (pid $$)"
sleep 3
echo "$(date +%T) done"
EOF
chmod +x job.sh
./job.sh & sleep 1; ./job.sh; echo "second exit=$?"; wait
Kết quả:
06:43:46 start (pid 439223)
06:43:47 another run is active, skipping
second exit=0
06:43:49 done
Phân tích:
exec 9>/tmp/gdev-demo.lockmở file khóa trên descriptor số 9 và giữ nó trong suốt đời script.flock -n 9xin khóa không chờ (-n). Lần chạy thứ hai không xin được, in thông báo vào stderr rồi thoát. Ở đây ta thoát mã 0 vì "bỏ qua lần chạy chồng" là hành vi bình thường; nếu bạn muốn cảnh báo thì thoát mã khác 0.- Khóa tự nhả khi tiến trình giữ nó kết thúc, kể cả bị giết. Không có "file khóa mồ côi" như kiểu file PID phải xóa tay. Một điểm tinh tế: tiến trình con (như
sleepở trên) kế thừa descriptor 9, nên khi script cha bịkill -9, khóa vẫn được giữ cho tới khi con kết thúc. Chạy lại ngay saukill -9khisleepcòn sống, bạn vẫn thấyanother run is active.
Cách gọn hơn khi dùng ngay trong crontab, không phải sửa script:
15 2 * * * flock -n /tmp/gdev-backup.lock /home/gdev/gdev-lab/sched/backup.sh >> /home/gdev/gdev-lab/sched/backup.log 2>&1
Với dạng lệnh flock -n file command, nếu không xin được khóa thì flock thoát mã 1. Bạn đổi mã đó bằng -E, và chờ tối đa vài giây thay vì bỏ qua bằng -w:
flock -n /tmp/gdev-demo2.lock sleep 2 & sleep 0.5
flock -n /tmp/gdev-demo2.lock echo hi; echo "exit=$?"
flock -n -E 75 /tmp/gdev-demo2.lock echo hi; echo "exit=$?"
wait
Kết quả:
exit=1
exit=75
Log: biết chuyện gì đã xảy ra
Quy tắc tối thiểu cho mọi dòng cron thật: redirect cả stdout và stderr (>> file 2>&1) và để script tự in thời gian. Dòng echo "$(date -Is) start" ở đầu và echo "$(date -Is) done" ở cuối cho bạn biết lần chạy kéo dài bao lâu và có kết thúc không. Dòng start mà không có done tương ứng nghĩa là lần chạy đó chết giữa chừng. Nếu muốn log đi vào hệ thống thay vì file riêng, logger -t gdev-backup "backup done" gửi dòng đó tới syslog hoặc journal.
Tắt mail của cron bằng cách đặt MAILTO="" ở đầu crontab nếu bạn đã có log riêng và không muốn mail rác.
Mô hình 3: systemd timer, lịch tách khỏi lệnh
Systemd timer gồm hai file: .service mô tả chạy gì, .timer mô tả khi nào. Dưới đây là service của job backup. Type=oneshot nghĩa là service chạy xong một việc rồi kết thúc:
[Unit]
Description=GDev nightly backup
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
User=gdev
ExecStart=/opt/gdev/bin/backup.sh
TimeoutStartSec=30min
Nice=10
Và timer tương ứng:
[Unit]
Description=Run GDev backup every night
[Timer]
OnCalendar=*-*-* 02:15:00
Persistent=true
RandomizedDelaySec=300
[Install]
WantedBy=timers.target
Đọc từng dòng quan trọng:
OnCalendar=*-*-* 02:15:00là lịch, viết theo dạngthứ năm-tháng-ngày giờ:phút:giây. Dấu*có nghĩa quen thuộc.Persistent=truelưu lần kích hoạt cuối lên đĩa. Theo tài liệu, nếu máy tắt đúng giờ chạy thì khi timer hoạt động lại, service được chạy bù ngay. Đây là thứ cron không làm được.RandomizedDelaySec=300rải thời điểm chạy ngẫu nhiên trong tối đa 5 phút, để nhiều máy không cùng đập vào một dịch vụ dùng chung ở đúng 02:15.User=gdevcần là tài khoản có thật trên máy bạn, hãy sửa lại nếu khác.TimeoutStartSec=30minđặt trần thời gian chạy. Job quá hạn sẽ bị dừng, không treo đến sáng.
Lưu hai đoạn trên thành gdev-backup.service và gdev-backup.timer trong thư mục hiện tại. Sau đó cài đặt và kiểm tra. Lệnh systemd-analyze verify đọc file và báo cú pháp sai trước khi bạn bật chúng:
sudo mkdir -p /opt/gdev/bin
sudo install -m 0755 ~/gdev-lab/sched/backup.sh /opt/gdev/bin/backup.sh
sudo cp gdev-backup.service gdev-backup.timer /etc/systemd/system/
systemd-analyze verify /etc/systemd/system/gdev-backup.service /etc/systemd/system/gdev-backup.timer
sudo systemctl daemon-reload
sudo systemctl enable --now gdev-backup.timer
systemctl list-timers gdev-backup.timer
Kết quả minh họa của list-timers (thời điểm thật của bạn sẽ khác, và cộng thêm độ trễ ngẫu nhiên):
NEXT LEFT LAST PASSED UNIT ACTIVATES
Sun 2026-10-25 02:17:41 +07 19h left n/a n/a gdev-backup.timer gdev-backup.service
Chạy thử service ngay, không đợi tới 02:15, rồi đọc log nó ghi vào journal:
sudo systemctl start gdev-backup.service
systemctl status gdev-backup.service --no-pager
journalctl -u gdev-backup.service -n 10 --no-pager
Trong journalctl bạn sẽ thấy các dòng echo của script, mỗi dòng có thời gian, không cần redirect gì. Đây là điều khác biệt lớn so với cron: stdout và stderr của service mặc định đi thẳng vào journal.
Hãy lưu ý rằng service của systemd cũng không đọc ~/.bashrc. Môi trường của nó được khai báo bằng Environment= hoặc EnvironmentFile= trong file .service, và script tự đủ như ở phần trước vẫn là cách an toàn nhất.
Kiểm tra lịch trước khi tin nó
Lệnh systemd-analyze calendar in dạng chuẩn hóa và các lần chạy kế tiếp, nên bạn kiểm tra được lịch mà không cần đợi. Với --iterations=3 bạn thấy ba lần tới:
systemd-analyze calendar --iterations=3 "Mon..Fri *-*-* 09:00"
Kết quả minh họa (ngày của bạn sẽ khác):
Original form: Mon..Fri *-*-* 09:00
Normalized form: Mon..Fri *-*-* 09:00:00
Next elapse: Mon 2026-10-26 09:00:00 +07
(in UTC): Mon 2026-10-26 02:00:00 UTC
From now: 2 days left
Iter. #2: Tue 2026-10-27 09:00:00 +07
Iter. #3: Wed 2026-10-28 09:00:00 +07
Một lỗi dễ gặp là dán nguyên dòng cron vào OnCalendar=. Systemd không hiểu cú pháp cron, và lệnh kiểm tra cho kết quả thật như sau:
systemd-analyze calendar "*/5 * * * *"
Failed to parse calendar specification '*/5 * * * *': Invalid argument
Nếu bạn để dòng đó trong file timer, systemd-analyze verify báo Timer unit lacks value setting. Refusing. và timer không bao giờ chạy. Bảng chuyển đổi dưới đây đã được kiểm tra bằng systemd-analyze calendar:
| Cron | OnCalendar |
|---|---|
*/5 * * * * | *:0/5 |
15 2 * * * | *-*-* 02:15:00 |
0 9-17 * * 1-5 | Mon..Fri *-*-* 09..17:00:00 |
*/15 9-17 * * 1-5 | Mon..Fri *-*-* 09..17:0/15:00 |
0 3 * * 0 | Sun *-*-* 03:00:00 |
0 0 1 * * | *-*-01 00:00:00 (hoặc monthly) |
Dòng cron 30 4 1 * 5 với quy tắc "hoặc" có cách diễn đạt rõ hơn trong systemd: viết hai dòng OnCalendar=*-*-01 04:30:00 và OnCalendar=Fri *-*-* 04:30:00. Bạn thấy ngay đó là hai lịch độc lập.
So sánh cron và systemd timer
| Tiêu chí | cron | systemd timer |
|---|---|---|
| Chạy bù khi máy tắt | Không | Có, với Persistent=true |
| Chạy chồng | Phải tự dùng flock | Mặc định không: nếu service đang chạy, timer không khởi động thêm bản thứ hai |
| Log | Mail hoặc file tự redirect | Journal, đọc bằng journalctl -u |
| Môi trường | Tối giản, /bin/sh | Khai báo bằng Environment=, không đọc .bashrc |
| Giới hạn tài nguyên, timeout | Không | Có (TimeoutStartSec=, Nice=, MemoryMax=) |
| Độ phức tạp | Một dòng | Hai file và daemon-reload |
| Có sẵn ở | Hầu hết hệ Unix | Linux dùng systemd |
Về chạy chồng: tài liệu systemd.timer nói rõ rằng nếu unit cần kích hoạt đang hoạt động khi timer đến hạn thì nó không bị khởi động lại, chỉ để chạy tiếp, và không có khái niệm sinh thêm một bản mới. Vì vậy với Type=oneshot thông thường bạn không cần flock, nhưng lần chạy bị bỏ lỡ cũng không được xếp hàng.
Chọn thế nào: cron đủ cho vài job đơn giản và máy bạn không kiểm soát được (cron có mặt ở mọi nơi). Với server Linux hiện đại mà bạn quản lý, timer thường tốt hơn vì log, chạy bù và timeout có sẵn.
Anacron và máy không chạy 24 giờ
Anacron chạy các job theo chu kỳ tính bằng ngày, và không giả định máy luôn bật. Theo man anacron, mỗi job được kiểm tra xem đã chạy trong n ngày qua chưa, nếu chưa thì chạy sau một độ trễ. Chỉ ngày được ghi nhận, không phải giờ. Trên Ubuntu, các thư mục /etc/cron.daily, /etc/cron.weekly được kích hoạt qua anacron khi nó được cài. Đây là lý do job trong /etc/cron.daily không có giờ chính xác. Với laptop hay máy dev, đó là công cụ phù hợp; với server luôn bật, bạn không cần nó.
Lên lịch trong container và Kubernetes
Trong container, đừng chạy cron daemon cạnh ứng dụng. Container thường chỉ có một tiến trình chính (PID 1), image nhỏ không cài cron, và cron không truyền biến môi trường của container vào job nên tình trạng "thiếu biến" còn tệ hơn trên máy thường. Cách thông thường là chạy job như một container riêng, kích hoạt bởi bên ngoài.
Kubernetes có sẵn CronJob cho việc này. Theo tài liệu chính thức, concurrencyPolicy nhận Allow (mặc định), Forbid (bỏ qua lần mới nếu lần trước chưa xong) hoặc Replace. Trường timeZone ổn định từ Kubernetes 1.27, nếu bỏ trống thì lịch được hiểu theo múi giờ của kube-controller-manager. Dưới đây là ví dụ minh họa dùng image busybox công khai (không chạy trong bài này, bạn thử ở chương Kubernetes):
apiVersion: batch/v1
kind: CronJob
metadata:
name: gdev-nightly-report
namespace: gdev-staging
spec:
schedule: "15 2 * * *"
timeZone: "Etc/UTC"
concurrencyPolicy: Forbid
startingDeadlineSeconds: 300
jobTemplate:
spec:
template:
spec:
containers:
- name: report
image: busybox:1.36
command: ["sh", "-c", "date; echo report done"]
restartPolicy: OnFailure
Cú pháp schedule là cron năm trường, Forbid thay vai trò của flock, và startingDeadlineSeconds cho phép bỏ lần chạy lỡ nếu muộn quá 300 giây.
Lỗi thường gặp
| Triệu chứng | Nguyên nhân hay gặp | Cách kiểm chứng |
|---|---|---|
| Chạy tay được, cron không chạy được | PATH và biến từ .bashrc không có trong cron | Chạy lại bằng env -i hoặc thêm dòng env > file vào crontab |
[[: not found hoặc source: not found trong dòng cron | Lệnh chạy bằng /bin/sh (dash) | Chuyển logic vào script có shebang bash |
| Thư mục backup trống, không lỗi nào | Đầu ra không được redirect, máy không có mail | Thêm >> log 2>&1 và đọc log |
| Chạy 6 lần trong tháng thay vì 1 | Hai trường ngày đều bị giới hạn, quy tắc "hoặc" | Đếm số lần khớp, hoặc tách thành hai lịch |
date +%F cho kết quả lạ | % bị đổi thành xuống dòng trong crontab | Viết \% hoặc đặt lệnh trong script |
| Lịch chạy lệch giờ | Múi giờ máy chủ khác múi giờ bạn nghĩ | timedatectl và ghi múi giờ vào tài liệu |
| Hai lần chạy ghi đè nhau | Không có khóa | Dùng flock -n hoặc timer |
| Timer không bao giờ chạy | OnCalendar dán nhầm cú pháp cron, quên enable --now | systemd-analyze verify và systemctl list-timers |
| Mất sạch lịch | crontab -r hoặc crontab file ghi đè | Sao lưu bằng crontab -l > backup, dùng crontab -i -r |
Một thói quen đáng giá: xem bảng lịch như code, đặt trong repo (ví dụ deploy/crontab.txt hoặc file .service và .timer) và nạp bằng một lệnh, thay vì sửa tay trên server rồi không ai nhớ.
Tự kiểm tra
Hãy trả lời trước khi đọc đáp án ngay bên dưới mỗi câu.
Câu 1. Crontab có dòng 0 4 * * 1,3 /home/gdev/clean.sh. Script này chạy vào những lúc nào, và nếu đổi thành 0 4 15 * 1 thì sao?
Đáp án: Dòng đầu chạy 04:00 mỗi thứ Hai và thứ Tư, vì ngày trong tháng là * nên chỉ trường thứ trong tuần giới hạn. Dòng sau có cả hai trường ngày bị giới hạn nên chạy vào ngày 15 hàng tháng và vào mọi thứ Hai.
Câu 2. Script deploy.sh dùng lệnh kubectl ở /usr/local/bin và chạy tốt ở terminal, nhưng cron báo kubectl: command not found. Tại sao, và nên sửa ở đâu?
Đáp án: PATH của user crontab là /usr/bin:/bin, không có /usr/local/bin. Cách bền nhất là đặt export PATH=... hoặc đường dẫn đầy đủ trong script, để script chạy đúng dù ai gọi nó.
Câu 3. Backup mất 40 phút, lịch chạy mỗi 30 phút. Hai cách chống chạy chồng mà bài đã nêu là gì?
Đáp án: Dùng flock -n file trước lệnh trong crontab (hoặc flock -n 9 trong script), hoặc chuyển sang systemd timer với Type=oneshot, nơi unit đang chạy không bị khởi động thêm bản thứ hai.
Nếu chỉ nhớ 3 điều
- Cron chạy lệnh bằng
/bin/shvớiPATH=/usr/bin:/binvà không đọc.bashrc. Làm script tự đủ (PATH, biến, đường dẫn tuyệt đối) và luôn redirect đầu ra vào log. - Hai trường ngày trong cron là phép "hoặc", và
%trong crontab cần được escape. Dùngflock -nđể chống chạy chồng. - systemd timer tách lịch (
.timer) khỏi lệnh (.service), có log trong journal,Persistent=trueđể chạy bù vàOnCalendarkhông dùng cú pháp cron. Kiểm tra bằngsystemd-analyze calendar.
Bài tiếp theo là Script tự động hóa: ghép tất cả vào một script hoàn chỉnh có tham số, log, khóa, thử lại và dry-run.
Tài liệu chính thức: man 5 crontab, systemd.timer, systemd.time (cú pháp OnCalendar), flock(1) và CronJob trong Kubernetes.