crontab ของ user เว็บ ไม่ใช่ crontab ที่ cron รันจริง
อาการคลาสสิกที่กินเวลาหาสาเหตุทั้งบ่าย — หน้า admin บอกว่าบันทึกสำเร็จ ฐานข้อมูลอัปเดตแล้ว แต่งานตามเวลายังยิงตามตารางเก่าเป๊ะๆ ทุกวัน ต้นเหตุไม่ได้อยู่ที่โค้ดผิด แต่อยู่ที่มันไปเขียน crontab ผิดคน
พอดีมีระบบตัวหนึ่งที่ให้แก้ช่วงเวลาทำงานได้สองทาง — ทางแรกสั่งผ่านบอทที่รันเป็น service ทางที่สองแก้ผ่านหน้า admin บนเว็บ ทั้งสองทางเรียกฟังก์ชันเดียวกันที่เขียน crontab ใหม่ให้อัตโนมัติ วันหนึ่งผมแก้ผ่านหน้าเว็บ กดบันทึก หน้าจอขึ้นว่าสำเร็จ ฐานข้อมูลอัปเดตถูกต้อง dashboard แสดงช่วงเวลาใหม่เรียบร้อย แต่พอถึงเวลาจริง งานยังยิงตามตารางเก่า ผมเลยไปไล่ดูตั้งแต่โค้ดจนถึง log สุดท้ายเจอว่าปัญหาไม่ได้อยู่ในโค้ดเลย
รากของเรื่องคือประโยคเดียวสั้นๆ ครับ: คำสั่ง crontab เขียนตารางของ user ที่รันคำสั่งนั้น เว็บรันเป็น www-data งานที่ตั้งไว้จริงอยู่ใน crontab ของ root พอสคริปต์ฝั่งเว็บสั่ง crontab ไฟล์ใหม่ มันเลยไปสร้างตารางใหม่ให้ www-data โดยที่ตารางของ root ยังนอนอยู่ที่เดิมไม่ขยับสักบรรทัด
ตัวอย่างชื่อ path และชื่อสคริปต์ในบทความเป็นค่าสมมุติ แต่โครงสร้างและวิธีแก้คือของจริงที่ใช้อยู่
อาการที่เจอ — และที่หลอกที่สุด
ที่ทำให้หาสาเหตุยากคือมันไม่ได้ "ไม่รัน" ครับ มันรันสองชุดพร้อมกัน
ตารางเก่าใน crontab ของ root ยังทำงานปกติ ยิงตามเวลาเดิม เขียน log สวยงาม ส่วนตารางใหม่ใน crontab ของ www-data ก็ถูก cron หยิบไปรันเหมือนกัน แต่รันในสิทธิ์ www-data ซึ่งเขียนไฟล์ log ใน /var/log/ ที่เจ้าของเป็น root ไม่ได้ เลยตายตั้งแต่บรรทัดแรกแบบไม่มีใครเห็น ผลรวมที่ผู้ใช้เห็นคือ "งานยังยิงตามเวลาเก่า" ส่วนผลรวมที่คนดูแลระบบเห็นคือ "log ไม่มีอะไรผิดปกติเลย"
ทำงานถูกมาตลอด
service ตั้ง User=root ไว้ พอ shell_exec เรียก crontab มันเลยเขียนตารางของ root ตรงๆ — ทางนี้ไม่เคยมีปัญหา เลยไม่มีใครสงสัยว่าโค้ดที่ใช้ร่วมกันจะพัง
เขียนผิดคนเงียบๆ
process เป็น www-data คำสั่งเดียวกันเป๊ะเลยไปตกที่ตารางของ www-data แทน ไม่มี error ไม่มี exit code ผิด เพราะคำสั่งมันสำเร็จจริงๆ — แค่สำเร็จผิดที่
ประเด็นที่ผมอยากเน้นคือคำสั่งนี้ไม่ได้ล้มเหลว ถ้ามันคืน error กลับมาเราคงเจอตั้งแต่วันแรก แต่มันทำงานสำเร็จตามที่สั่งทุกประการ ผิดแค่ว่าสิ่งที่เราสั่งกับสิ่งที่เราคิดว่าสั่งคนละอย่างกัน
ตรวจให้เจอใน 3 คำสั่ง
ขั้นแรก ดูว่าเว็บรันเป็น user อะไร — อย่าเดา ให้ถามจากในโค้ดที่รันจริงเลย:
// วางชั่วคราวในหน้าที่มีปัญหา แล้วลบทิ้งหลังตรวจเสร็จ
echo shell_exec('id -un'); // -> www-data
ขั้นสอง เทียบ crontab ของทั้งสอง user ตรงๆ:
sudo crontab -l -u root | grep -n 'app/cron/'
sudo crontab -l -u www-data | grep -n 'app/cron/'
ถ้าฝั่ง www-data มีบรรทัดขึ้นมาแปลว่าโดนแล้วครับ — ตารางที่แอปคิดว่าเขียนไปนั้นไปกองอยู่ตรงนั้น ขั้นสาม ดูจากฝั่งไฟล์ว่ามี user ไหนบ้างที่มีตารางค้างอยู่ในเครื่อง:
sudo ls -l /var/spool/cron/crontabs/ # Debian / Ubuntu
sudo ls -l /var/spool/cron/ # RHEL / AlmaLinux / Rocky
# -rw------- 1 root crontab 1204 Aug 14 09:12 root
# -rw------- 1 www-data crontab 612 Aug 14 09:12 www-data <- ตัวปัญหา
ดูเวลาแก้ไขไฟล์ก็รู้เรื่องทันที ไฟล์ของ www-data ถูกแตะพร้อมกับตอนที่เรากดบันทึกบนเว็บพอดี ส่วนไฟล์ของ root ค้างวันที่เก่า และถ้าอยากดูว่า cron หยิบของใครไปรันบ้างในแต่ละรอบ:
grep CRON /var/log/syslog | tail -20 # Debian / Ubuntu
journalctl -u cron --since today | tail -20
# Aug 14 10:01:01 host CRON[24518]: (root) CMD (/usr/bin/php /var/www/app/cron/pull_1.php ...)
# Aug 14 10:31:01 host CRON[24902]: (www-data) CMD (/usr/bin/php /var/www/app/cron/pull_1.php ...)
บรรทัดที่วงเล็บเป็น (www-data) คือหลักฐานปิดคดี — สองชุดรันจริงทั้งคู่ คนละเวลา คนละสิทธิ์
ทางแก้: sudoers drop-in แคบๆ ตัวเดียว
ให้ฝั่งเว็บเขียน crontab ของ root ได้ ผ่าน sudo เฉพาะคำสั่งเดียว ไม่ให้เกินนี้:
# /etc/sudoers.d/app-crontab (สิทธิ์ต้องเป็น 0440 เจ้าของ root:root)
www-data ALL=(root) NOPASSWD: /usr/bin/crontab
ติดตั้งแล้วต้องตรวจไวยากรณ์ก่อนเสมอ ไฟล์ sudoers พังหมายถึง sudo ใช้ไม่ได้ทั้งเครื่อง:
sudo install -m 0440 -o root -g root /tmp/app-crontab /etc/sudoers.d/app-crontab
sudo visudo -c -f /etc/sudoers.d/app-crontab
# /etc/sudoers.d/app-crontab: parsed OK
sudo -u www-data sudo -n /usr/bin/crontab -l | head -3 # ทดสอบว่าใช้ได้จริงโดยไม่ถามรหัส
ฝั่งโค้ดเลือกคำสั่งตามสิทธิ์ที่กำลังรันอยู่ ไม่ต้องแยก branch ตามที่มาของ request:
// เป็น root อยู่แล้ว (เช่นถูกเรียกจาก systemd service) -> ใช้ตรงๆ
// ไม่ใช่ root (ถูกเรียกจากเว็บ) -> ผ่าน sudo -n ที่เปิดไว้เฉพาะคำสั่งนี้
$isRoot = function_exists('posix_geteuid') && posix_geteuid() === 0;
$crontabCmd = $isRoot ? '/usr/bin/crontab' : 'sudo -n /usr/bin/crontab';
// อ่านของเดิม -> ตัดบล็อกที่ระบบเราจัดการออก -> ต่อบล็อกใหม่ท้ายไฟล์
$current = (string) shell_exec("{$crontabCmd} -l 2>/dev/null");
$current = preg_replace('/'.preg_quote(self::CRON_BEGIN, '/').'.*?'
.preg_quote(self::CRON_END, '/').'\n?/s', '', $current);
$newBlock = self::CRON_BEGIN."\n".implode("\n", $generated)."\n".self::CRON_END."\n";
$tmp = tempnam(sys_get_temp_dir(), 'app_cron_');
file_put_contents($tmp, rtrim($current, "\n")."\n".$newBlock);
$out = shell_exec("{$crontabCmd} {$tmp} 2>&1");
unlink($tmp);
if ($out !== null && trim($out) !== '') {
return ['ok' => false, 'error' => trim($out)]; // อย่ากลืน error ตรงนี้
}
-n สำคัญนะครับ แปลว่า "ห้ามถามรหัสผ่าน" — ถ้าวันไหน sudoers หายไปหรือเขียนผิด คำสั่งจะล้มเหลวทันทีพร้อม error ให้เห็น แทนที่จะไปค้างรอ input ที่ไม่มีวันมาจนกว่า PHP จะ timeout
ใครเขียน crontab ของ root ได้ ก็ตั้งงานให้รันคำสั่งอะไรก็ได้ในสิทธิ์ root ได้ — พูดง่ายๆ คือ sudoers บรรทัดเดียวข้างบนนี้เท่ากับยกสิทธิ์ root ให้ user ของเว็บเต็มๆ ถ้าเว็บโดนเจาะ ผู้บุกรุกได้ทั้งเครื่องทันทีโดยไม่ต้องหา exploit อะไรเพิ่ม ผมยอมรับความเสี่ยงนี้ได้เพราะเป็นระบบภายในที่หน้า admin อยู่หลัง auth และเข้าจากวงในเท่านั้น ถ้าเป็นเว็บสาธารณะห้ามทำแบบนี้ ให้ใช้วิธีในหัวข้อถัดไปแทน
วิธีที่ดีกว่า: อย่าให้เว็บแตะ crontab เลย
คิดอีกที ปัญหาทั้งหมดเกิดจากการที่เราให้ตารางเวลาอยู่สองที่ — ในฐานข้อมูลที่หน้าเว็บแก้ กับใน crontab ที่ cron อ่าน แล้วต้อง sync กันตลอดเวลา ทางที่ผมคิดว่าถูกต้องกว่าคือให้ crontab มีบรรทัดเดียวถาวร แล้วให้ตารางจริงอยู่ในฐานข้อมูลที่เดียว
# crontab ของ root — บรรทัดเดียว ไม่มีวันเปลี่ยน ไม่มีใครต้องเขียนทับ
* * * * * /usr/bin/php /var/www/app/cron/dispatcher.php >> /var/log/app/dispatch.log 2>&1
ตัว dispatcher.php ตื่นทุกนาที อ่านตารางจาก DB แล้วตัดสินใจเองว่านาทีนี้ต้องยิงงานไหนบ้าง หน้าเว็บก็แค่แก้แถวใน DB ตามปกติ ไม่ต้องมี sudo ไม่ต้องมี shell_exec ไม่มีอะไรให้ desync เพราะมีตารางอยู่ชุดเดียวในระบบ ราคาที่จ่ายคือ process หนึ่งตัวตื่นทุกนาที ซึ่งบนเครื่องสมัยนี้แทบไม่รู้สึกอะไร ส่วนตัวผมถ้าเริ่มงานใหม่จะเลือกทางนี้ทุกครั้ง ทางที่ให้เว็บเขียน crontab เก็บไว้ใช้เฉพาะระบบที่ทำค้างมาแล้วและยังไม่คุ้มจะรื้อ
กัน desync ไม่ให้กลับมาอีก
สามอย่างที่ผมใส่ไว้หลังแก้เคสนี้:
- ครอบบล็อกที่ระบบจัดการด้วย marker # BEGIN APP_SCHEDULE / # END APP_SCHEDULE แล้ว regenerate ทั้งบล็อก ห้ามแก้ทีละบรรทัด — ของที่คนอื่นเติมไว้นอก marker จะได้ไม่หาย
- ตั้ง cron ตรวจ desync ทุกชั่วโมง ถ้าเจอ entry ของระบบเราไปโผล่ใน crontab ของ user ที่ไม่ใช่ root ให้แจ้งเตือนทันที
- ทำความสะอาดของเก่าให้หมดหลังแก้เสร็จ — crontab -r -u www-data ทิ้งตารางที่หลงเหลือ ไม่งั้นมันจะรันคู่ขนานต่อไปเรื่อยๆ อย่างเงียบๆ
สคริปต์ตรวจ desync สั้นๆ แค่นี้พอ:
#!/bin/bash
# /usr/local/sbin/cron-desync-check.sh — เตือนถ้างานของเราไปโผล่ใน crontab ผิด user
MARK='app/cron/'
for u in $(cut -d: -f1 /etc/passwd); do
[ "$u" = "root" ] && continue
n=$(crontab -l -u "$u" 2>/dev/null | grep -c "$MARK")
[ "$n" -gt 0 ] && echo "[WARN] เจอ $n บรรทัดของระบบใน crontab ของ $u — ต้องมีแค่ root เท่านั้น"
done
exit 0
ตอนไล่เคสนี้ผมเสียเวลาไปพักใหญ่กับหน้า dashboard ที่ยังโชว์ตารางเก่า ทั้งที่ DB อัปเดตไปแล้ว เพราะเปิด tab ค้างไว้แล้ว browser หยิบของใน cache มาแสดง ทางแก้คือ controller ต้องส่ง Cache-Control: no-store สำหรับหน้าที่แสดงสถานะที่เปลี่ยนตลอด ไม่งั้นเวลา debug เราจะไม่รู้ว่ากำลังดูของจริงหรือดูของเก่าอยู่
สรุปสั้นๆ ที่ผมอยากให้จำจากบทความนี้คือ คำสั่ง crontab ไม่มีคำว่า "crontab ของระบบ" — มีแต่ crontab ของ user คนใดคนหนึ่งเสมอ เวลาสคริปต์อะไรก็ตามจะไปแตะมัน คำถามแรกที่ต้องตอบให้ได้คือตอนนี้เรากำลังเป็นใครอยู่ ไม่ใช่ตอนนี้เรากำลังสั่งอะไร ใครจะเอา sudoers drop-in ไปใช้ก็ตามสะดวกครับ แต่ถ้าเป็นระบบที่เปิดออกอินเทอร์เน็ต ผมแนะนำให้ไปทาง dispatcher อ่าน DB แทนตั้งแต่แรกจะปลอดภัยกว่ากันเยอะ ตรงไหนผมอธิบายตกหล่นหรือพิมพ์ผิด เดี๋ยวกลับมาแก้เพิ่มให้ภายหลังครับ
อยากให้เราทำให้ไหม?
ให้เราช่วยติดตั้ง ตั้งค่า ย้ายระบบ หรือแก้ปัญหาเซิร์ฟเวอร์ เพื่อให้คุณมีเวลาไปทำงานที่สำคัญกว่า
เคยเจองานแบบนี้ไหม? ติดตรงไหน?
มาแลกวิธีกันต่อในบอร์ด LinuxThai



ความคิดเห็น 0
ยังไม่มีความคิดเห็น — เป็นคนแรกเลย