EST. 2026 — บันทึกงานเซิร์ฟเวอร์จริง SYSADMIN · SECURITY · AI · DIRECTADMIN เล่มที่ 01
THE SYSADMIN ALMANAC
หน้าหลัก / Linux / crontab ของ user เว็บ ไม่ใช่ crontab ที่ cron รันจริง
Linux

crontab ของ user เว็บ ไม่ใช่ crontab ที่ cron รันจริง

โดย Ton-Or ·19 ส.ค. 2026·อ่าน 7 นาที
crontab ของ user เว็บ ไม่ใช่ crontab ที่ cron รันจริง

อาการคลาสสิกที่กินเวลาหาสาเหตุทั้งบ่าย — หน้า admin บอกว่าบันทึกสำเร็จ ฐานข้อมูลอัปเดตแล้ว แต่งานตามเวลายังยิงตามตารางเก่าเป๊ะๆ ทุกวัน ต้นเหตุไม่ได้อยู่ที่โค้ดผิด แต่อยู่ที่มันไปเขียน crontab ผิดคน

พอดีมีระบบตัวหนึ่งที่ให้แก้ช่วงเวลาทำงานได้สองทาง — ทางแรกสั่งผ่านบอทที่รันเป็น service ทางที่สองแก้ผ่านหน้า admin บนเว็บ ทั้งสองทางเรียกฟังก์ชันเดียวกันที่เขียน crontab ใหม่ให้อัตโนมัติ วันหนึ่งผมแก้ผ่านหน้าเว็บ กดบันทึก หน้าจอขึ้นว่าสำเร็จ ฐานข้อมูลอัปเดตถูกต้อง dashboard แสดงช่วงเวลาใหม่เรียบร้อย แต่พอถึงเวลาจริง งานยังยิงตามตารางเก่า ผมเลยไปไล่ดูตั้งแต่โค้ดจนถึง log สุดท้ายเจอว่าปัญหาไม่ได้อยู่ในโค้ดเลย

รากของเรื่องคือประโยคเดียวสั้นๆ ครับ: คำสั่ง crontab เขียนตารางของ user ที่รันคำสั่งนั้น เว็บรันเป็น www-data งานที่ตั้งไว้จริงอยู่ใน crontab ของ root พอสคริปต์ฝั่งเว็บสั่ง crontab ไฟล์ใหม่ มันเลยไปสร้างตารางใหม่ให้ www-data โดยที่ตารางของ root ยังนอนอยู่ที่เดิมไม่ขยับสักบรรทัด

ตัวอย่างชื่อ path และชื่อสคริปต์ในบทความเป็นค่าสมมุติ แต่โครงสร้างและวิธีแก้คือของจริงที่ใช้อยู่

อาการที่เจอ — และที่หลอกที่สุด

ที่ทำให้หาสาเหตุยากคือมันไม่ได้ "ไม่รัน" ครับ มันรันสองชุดพร้อมกัน

สิ่งที่เกิดขึ้นจริงตอนกดบันทึกบนเว็บหน้า adminwww-data→shell_exec crontabไม่ได้ระบุ user→crontab ของ www-dataตารางใหม่→รันจริงแต่ตายเงียบเขียน log ไม่ได้

ตารางเก่าใน crontab ของ root ยังทำงานปกติ ยิงตามเวลาเดิม เขียน log สวยงาม ส่วนตารางใหม่ใน crontab ของ www-data ก็ถูก cron หยิบไปรันเหมือนกัน แต่รันในสิทธิ์ www-data ซึ่งเขียนไฟล์ log ใน /var/log/ ที่เจ้าของเป็น root ไม่ได้ เลยตายตั้งแต่บรรทัดแรกแบบไม่มีใครเห็น ผลรวมที่ผู้ใช้เห็นคือ "งานยังยิงตามเวลาเก่า" ส่วนผลรวมที่คนดูแลระบบเห็นคือ "log ไม่มีอะไรผิดปกติเลย"

cron อ่านทั้งสองตารางcrontab ของ rootตารางเก่า ยังอยู่ ไม่ถูกแตะcrontab ของ www-dataตารางใหม่ที่หน้าเว็บเขียนลงไปรันด้วยสิทธิ์ rootเขียน log ได้ ยิงตามเวลาเดิมทุกวันรันด้วยสิทธิ์ www-dataเขียน log ใน /var/log/ ไม่ได้ ตายเงียบผลที่เห็น: งานยังยิงตามเวลาเก่า และ log ไม่มีอะไรผิดปกติ
ตารางที่เว็บเขียนไปอยู่ในชุดของ www-data ส่วนตารางเดิมของ root ไม่ถูกแตะ cron เลยรันทั้งสองชุด
ทางบอท (systemd)

ทำงานถูกมาตลอด

service ตั้ง User=root ไว้ พอ shell_exec เรียก crontab มันเลยเขียนตารางของ root ตรงๆ — ทางนี้ไม่เคยมีปัญหา เลยไม่มีใครสงสัยว่าโค้ดที่ใช้ร่วมกันจะพัง

ทางหน้าเว็บ (Apache/PHP-FPM)

เขียนผิดคนเงียบๆ

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) คือหลักฐานปิดคดี — สองชุดรันจริงทั้งคู่ คนละเวลา คนละสิทธิ์

www-datauser ที่เว็บเขียนไป
rootuser ที่ตารางจริงอยู่
2 ชุดตารางที่รันพร้อมกัน
0error ที่ระบบฟ้อง

ทางแก้: 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

บรรทัดนี้ให้สิทธิ์เท่ากับ root

ใครเขียน 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 ไม่ให้กลับมาอีก

สามอย่างที่ผมใส่ไว้หลังแก้เคสนี้:

  1. ครอบบล็อกที่ระบบจัดการด้วย marker # BEGIN APP_SCHEDULE / # END APP_SCHEDULE แล้ว regenerate ทั้งบล็อก ห้ามแก้ทีละบรรทัด — ของที่คนอื่นเติมไว้นอก marker จะได้ไม่หาย
  2. ตั้ง cron ตรวจ desync ทุกชั่วโมง ถ้าเจอ entry ของระบบเราไปโผล่ใน crontab ของ user ที่ไม่ใช่ root ให้แจ้งเตือนทันที
  3. ทำความสะอาดของเก่าให้หมดหลังแก้เสร็จ — 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 ที่ browser cache ไว้

ตอนไล่เคสนี้ผมเสียเวลาไปพักใหญ่กับหน้า dashboard ที่ยังโชว์ตารางเก่า ทั้งที่ DB อัปเดตไปแล้ว เพราะเปิด tab ค้างไว้แล้ว browser หยิบของใน cache มาแสดง ทางแก้คือ controller ต้องส่ง Cache-Control: no-store สำหรับหน้าที่แสดงสถานะที่เปลี่ยนตลอด ไม่งั้นเวลา debug เราจะไม่รู้ว่ากำลังดูของจริงหรือดูของเก่าอยู่

สรุปสั้นๆ ที่ผมอยากให้จำจากบทความนี้คือ คำสั่ง crontab ไม่มีคำว่า "crontab ของระบบ" — มีแต่ crontab ของ user คนใดคนหนึ่งเสมอ เวลาสคริปต์อะไรก็ตามจะไปแตะมัน คำถามแรกที่ต้องตอบให้ได้คือตอนนี้เรากำลังเป็นใครอยู่ ไม่ใช่ตอนนี้เรากำลังสั่งอะไร ใครจะเอา sudoers drop-in ไปใช้ก็ตามสะดวกครับ แต่ถ้าเป็นระบบที่เปิดออกอินเทอร์เน็ต ผมแนะนำให้ไปทาง dispatcher อ่าน DB แทนตั้งแต่แรกจะปลอดภัยกว่ากันเยอะ ตรงไหนผมอธิบายตกหล่นหรือพิมพ์ผิด เดี๋ยวกลับมาแก้เพิ่มให้ภายหลังครับ

อยากให้เราทำให้ไหม?

ให้เราช่วยติดตั้ง ตั้งค่า ย้ายระบบ หรือแก้ปัญหาเซิร์ฟเวอร์ เพื่อให้คุณมีเวลาไปทำงานที่สำคัญกว่า

คุยเรื่องงานกับเรา

เคยเจองานแบบนี้ไหม? ติดตรงไหน?

มาแลกวิธีกันต่อในบอร์ด LinuxThai

→ คุยต่อในบอร์ด

ความคิดเห็น 0

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