SSD Nodes Learn 🎉 VPS เริ่ม $5.50/เดือน
คู่มือ Matt Connorโดย Matt Connor

ทำไม systemd ไม่ยอม restart service เมื่อ process ลูกตาย

สาเหตุที่ systemd ไม่สั่ง restart service คือคำสั่ง Restart ตรวจสอบเฉพาะ main process เท่านั้น แม้ process ลูกใน cgroup จะตายไป แต่สถานะ unit ยังคงเป็น active อยู่เสมอ

คำตอบสั้นๆ: นโยบายการ restart ของ systemd ตรวจสอบเพียงกระบวนการเดียว

นโยบายการ restart ของ systemd จะตรวจสอบเพียงกระบวนการเดียวต่อหนึ่ง unit นั่นคือกระบวนการหลัก (main process) Restart= จะอ่านสถานะการจบการทำงาน (exit status) ของกระบวนการนั้นเพียงอย่างเดียวเท่านั้น แม้ว่า control group ของ unit หนึ่งจะประกอบด้วยกระบวนการยี่สิบรายการ และมีกระบวนการหนึ่งตายไป แต่ unit นั้นจะยังคงสถานะเป็น active (running) เนื่องจากกระบวนการหลักยังคงทำงานอยู่ ในมุมมองของ systemd ถือว่าไม่มีสิ่งใดล้มเหลว จึงไม่มีการ restart เกิดขึ้น

systemd รับรู้ถึงการมีอยู่ของกระบวนการอื่นๆ มันจะสั่งยุติการทำงานของกระบวนการเหล่านั้นเมื่อ unit หยุดทำงาน, คำนวณการใช้หน่วยความจำของกระบวนการเหล่านั้นเทียบกับขีดจำกัดของ unit, บังคับใช้โควตา CPU ของ unit กับกระบวนการเหล่านั้น และแสดงรายการกระบวนการเหล่านั้นใน systemctl status เพียงแต่ systemd ไม่เคยอ่านสถานะการจบการทำงานของกระบวนการเหล่านั้นเลย ตรรกะการ restart และ cgroup เป็นสิ่งที่แยกจากกัน และเนื้อหาส่วนใหญ่ของคู่มือนี้จะกล่าวถึงช่องว่างระหว่างสองสิ่งนี้

สิ่งที่ cgroup เก็บรักษาไว้ และสิ่งที่ตรรกะการรีสตาร์ทอ่านค่า

cgroup (control group) คือออบเจกต์ในเคอร์เนลที่ทำหน้าที่ดูแลกลุ่มของโพรเซส โดย service unit แต่ละตัวจะมี cgroup เป็นของตนเองและใช้ชื่อเดียวกับ unit นั้น โพรเซสไม่สามารถย้ายออกจากกลุ่มได้ ส่วนโพรเซสลูกจะสืบทอด cgroup มาจากโพรเซสแม่ และโพรเซสที่ไม่มีสิทธิ์ระดับสูงจะไม่สามารถย้ายตัวเองไปยังกลุ่มอื่นได้ นี่คือเหตุผลที่ systemd สามารถจัดการล้างโพรเซส daemon ที่มีการ fork สองชั้นได้อย่างหมดจด ซึ่งเป็นสิ่งที่สคริปต์ init แบบเก่าไม่สามารถทำได้อย่างน่าเชื่อถือ

ให้พิจารณาข้อเท็จจริงทั้งสองประการควบคู่กันไป:

systemd-cgls --unit myapp.service
systemctl show -p MainPID -p NRestarts -p Restart -p RestartUSec myapp.service

systemd-cgls จะแสดงรายการโพรเซสทั้งหมดที่อยู่ใน unit นั้น ส่วน MainPID คือตัวเลขเพียงตัวเดียวที่นโยบายการรีสตาร์ทจะอ่านค่า เมื่อข้อมูลทั้งสองส่วนนี้ไม่ตรงกับความเข้าใจของคุณ ความขัดแย้งนั้นคือจุดที่เกิดบั๊กขึ้น MainPID=0 ถือว่าแย่ยิ่งกว่าการได้ PID ที่ผิดพลาด เพราะนั่นหมายความว่า systemd ไม่ได้ติดตามโพรเซสใดๆ เลย ทำให้ค่า Restart= ไม่มีทางทำงานได้

มีข้อยกเว้นประการเดียวสำหรับกฎเรื่องโพรเซสหลัก หาก kernel out-of-memory killer สั่งฆ่าโพรเซสใดก็ตามที่อยู่ภายใน cgroup ของ unit นั้น systemd จะรับทราบเหตุการณ์ดังกล่าวเนื่องจากมีการเฝ้าดูไฟล์ memory.events ของ cgroup อยู่ OOMPolicy= จะเป็นตัวตัดสินว่าจะเกิดอะไรขึ้นต่อไป โดยค่าเริ่มต้นคือ stop ซึ่งหมายความว่า unit ทั้งหมดจะถูกหยุดการทำงาน ผลลัพธ์จะถูกบันทึกเป็น oom-kill และนับว่าเป็นความล้มเหลว ส่งผลให้ Restart=on-failure ทำงาน ซึ่งใน journal จะระบุเหตุการณ์นี้ไว้อย่างชัดเจน

myapp.service: A process of this unit has been killed by the OOM killer.
myapp.service: Failed with result 'oom-kill'.

ดังนั้น โพรเซสลูกที่ถูกสั่งฆ่าเนื่องจากปัญหาหน่วยความจำจะส่งผลให้ unit หยุดทำงาน ในขณะที่โพรเซสลูกตัวเดียวกันหากตายจาก segmentation fault จะไม่ส่งผลเช่นนั้น หากคุณตั้งค่าจำกัดหน่วยความจำให้กับ unit โปรดอ่าน วิธีการที่ MemoryMax และ CPUQuota มีผลต่อ cgroup ของ unit ก่อนที่จะปรับแต่งนโยบายการรีสตาร์ท เนื่องจากคุณสมบัติทั้งสองนี้ทำงานร่วมกันที่จุดนี้เพียงจุดเดียวเท่านั้น

วิธีที่ Type= เลือกกระบวนการหลัก

Type= ในส่วน [Service] ไม่ได้มีไว้เพื่อกำหนดลำดับการเริ่มทำงานเท่านั้น แต่เป็นกฎที่ตัดสินว่า PID (process ID) ใดจะกลายเป็น MainPID ซึ่งเท่ากับการตัดสินว่า Restart= จะสามารถมองเห็นอะไรได้บ้าง

  • Type=simple เป็นค่าเริ่มต้น กระบวนการที่ systemd ทำการ fork ออกมาจาก ExecStart= คือกระบวนการหลัก systemd จะทำเครื่องหมายว่า unit เริ่มทำงานทันที ก่อนที่จะทราบว่า exec ทำงานสำเร็จหรือไม่ การพิมพ์ path ของ binary ผิดจะทำให้งานเริ่มทำงานสำเร็จ แล้ว Main process exited, code=exited, status=203/EXEC ในเวลาต่อมา
  • Type=exec ทำงานเหมือนกับ simple ต่างกันตรงที่งานเริ่มทำงานจะรอจนกว่า exec จะสำเร็จ ซึ่งจะเปลี่ยนข้อผิดพลาดจากการพิมพ์ผิดข้างต้นให้กลายเป็นความล้มเหลวในการเริ่มทำงานอย่างตรงไปตรงมา วิธีนี้ต้องการ systemd 240 หรือใหม่กว่า ซึ่งมีอยู่ในทุก distribution ที่รองรับ ควรเลือกใช้แทน simple
  • Type=forking คาดหวังว่ากระบวนการจาก ExecStart= จะทำการ fork daemon ไปไว้เบื้องหลังแล้วจบการทำงานลง systemd จะรอให้ parent จบการทำงาน จากนั้นจึงมองหากระบวนการ daemon ที่แท้จริง คุณต้องระบุ PIDFile= ให้ หากไม่มี GuessMainPID= (ซึ่งเปิดใช้งานเป็นค่าเริ่มต้น) จะทำงานได้ก็ต่อเมื่อเหลือกระบวนการเพียงหนึ่งเดียวใน cgroup เท่านั้น หากทิ้งกระบวนการไว้สองตัว MainPID จะยังคงเป็น 0
  • Type=notify หมายความว่า service จะเรียก sd_notify(3) และส่ง READY=1 เมื่อพร้อมให้บริการรับส่งข้อมูล นอกจากนี้ยังอาจส่ง MAINPID= เพื่อระบุกระบวนการอื่นให้ systemd ติดตาม NotifyAccess= มีค่าเริ่มต้นเป็น main ดังนั้นการแจ้งเตือนที่ส่งมาจาก child จึงถูกละเลย และ journal จะระบุชื่อ PID ที่ส่งมานั้น
  • Type=oneshot ไม่มีกระบวนการหลักที่คงอยู่ถาวร unit จะเปลี่ยนสถานะเป็น inactive ทันทีที่ ExecStart= ทำงานเสร็จ เว้นแต่คุณจะตั้งค่า RemainAfterExit=yes ไว้ Restart=always และ Restart=on-success จะถูกปฏิเสธในกรณีนี้ พร้อมข้อความ Service has Restart= set to either always or on-success, which isn't allowed for Type=oneshot services. Refusing. ส่วนค่าอื่นๆ รวมถึง on-failure สามารถใช้งานได้

ข้อผิดพลาด Type=forking สองรายการที่ควรจำไว้ เพราะแต่ละรายการจะทำให้ unit ของคุณดูเหมือนพังโดยไม่มีสาเหตุที่ชัดเจน:

myapp.service: Can't open PID file /run/myapp.pid (yet?) after start: No such file or directory
myapp.service: New main PID 4711 does not belong to service, and PID file is not owned by root. Refusing.

รายการแรกหมายความว่า daemon เขียนไฟล์ PID ไว้ที่อื่น หรือเขียนไฟล์ช้ากว่าที่ systemd ตรวจสอบ รายการที่สองหมายความว่าไฟล์ PID ระบุกระบวนการที่อยู่นอก cgroup ของ unit นั้น ซึ่ง systemd ปฏิเสธที่จะรับรอง เพราะหากไฟล์ PID ที่แก้ไขได้ถูกนำมาใช้ อาจกลายเป็นช่องทางให้ systemd ส่งสัญญาณไปยังกระบวนการใดก็ได้บนเครื่อง

เหตุใด wrapper script จึงบดบังการสิ้นสุดของกระบวนการลูก

นี่คือรูปแบบที่ทำให้เกิดคำถามตามชื่อหัวข้อ

#!/bin/bash
/usr/local/bin/myapp-web &
/usr/local/bin/myapp-worker &
wait

unit นี้คือ Type=simple ดังนั้นกระบวนการหลักจึงเป็น shell คำสั่ง wait ที่ไม่มีอาร์กิวเมนต์จะทำงานจนกว่ากระบวนการลูกทุกตัวจะสิ้นสุดลง หากคุณสั่ง kill worker ตัว shell จะยังคงรอ web process ต่อไป ทำให้ shell ไม่สิ้นสุด ส่งผลให้ MainPID ไม่สิ้นสุด และ Restart= จึงไม่ถูกเรียกใช้งาน ขณะนี้ cgroup มีกระบวนการลดลงหนึ่งตัว systemctl status แสดงแผนผังที่สั้นลง และ unit ยังคงมีสถานะเป็น active (running) โดยไม่มีส่วนใดใน systemd คอยตรวจสอบการเปลี่ยนแปลงของแผนผังกระบวนการนั้น

ความผิดพลาดรูปแบบที่สองที่เงียบเชียบกว่ามีดังนี้:

ExecStart=/bin/sh -c 'export APP_ENV=production; /usr/local/bin/myapp'

กระบวนการหลักคือ shell ไม่ใช่ myapp เมื่อเกิด systemctl stop ทาง systemd จะส่ง SIGTERM ไปยังกระบวนการหลัก แต่ shell ที่กำลังรอการทำงานของกระบวนการลูกใน foreground จะไม่ส่งสัญญาณนั้นต่อไป การหยุดทำงานจึงใช้เวลาเต็ม TimeoutStopSec ซึ่งโดยปกติคือ 90 วินาที และจบลงด้วยลักษณะนี้:

myapp.service: State 'stop-sigterm' timed out. Killing.
myapp.service: Killing process 4711 (myapp) with signal SIGKILL.

วิธีแก้ไขคือการใช้ exec หากคุณเขียน exec /usr/local/bin/myapp ตัว shell จะถูกแทนที่ด้วยโปรแกรมนั้น ทำให้ MainPID กลายเป็นตัวโปรแกรมโดยตรงและได้รับสัญญาณต่างๆ ทางที่ดีกว่าคือให้ลบ shell ออกแล้วใช้ Environment= หรือ EnvironmentFile= ใน unit แทน โปรดทราบว่าบั๊กนี้จะซ่อนตัวอยู่หากสตริง -c มีเพียงคำสั่งเดียว เพราะทั้ง bash และ dash จะปรับแต่งกรณีนั้นให้กลายเป็นการใช้ exec โดยตรง หากคุณเพิ่มคำสั่งที่สองเข้าไปในสตริง shell จะยังคงทำงานค้างอยู่หน้าโปรแกรมของคุณ

ทำซ้ำเหตุการณ์บน VPS ทดสอบภายในสองนาที

บันทึก wrapper ด้านบนเป็น /usr/local/bin/two-children.sh, ทำให้เป็นไฟล์ที่รันได้ด้วย chmod +x และแทนที่ path ของโปรแกรมทั้งสองด้วย sleep 3600 กำหนด unit ให้ชี้มาที่ไฟล์นี้ด้วย Type=simple และ Restart=on-failure จากนั้นใช้ systemctl daemon-reload และเริ่มการทำงาน รันคำสั่ง systemd-cgls --unit two-children.service และสังเกต PID ทั้งสามตัว ได้แก่ shell และกระบวนการลูกอีกสองตัว สั่ง kill กระบวนการลูกหนึ่งตัวด้วย sudo kill <pid> ตรวจสอบ unit อีกครั้ง แผนผังกระบวนการจะสั้นลงหนึ่งตัว สถานะยังคงเป็น active (running) และไม่มีข้อมูลใหม่ใน journal จากนั้นให้รัน sudo kill -9 <shell pid> แทน unit จะล้มเหลว กระบวนการลูกที่เหลือจะถูกล้างออกเนื่องจาก KillMode=control-group เป็นค่าเริ่มต้น และ journal จะแสดง Scheduled restart job, restart counter is at 1.

คำศัพท์ทั้งหมดของ Restart= และเหตุผลที่ on-failure ดีกว่า always

Restart= สามารถกำหนดค่าได้ 7 รูปแบบ โดยความแตกต่างอยู่ที่นิยามของคำว่าการทำงานที่เสร็จสิ้นอย่างปกติ (clean exit) systemd ถือว่า exit code 0, รหัสใดๆ ที่ระบุใน SuccessExitStatus= รวมถึงสัญญาณ SIGHUP, SIGINT, SIGTERM และ SIGPIPE เป็นการจบการทำงานที่ปกติ ส่วนกรณีอื่นทั้งหมดรวมถึง SIGKILL และ SIGSEGV ถือว่าไม่ปกติ

  • no เป็นค่าเริ่มต้น หน่วยบริการจะไม่เริ่มทำงานใหม่ด้วยตัวเอง ซึ่งเป็นเหตุผลว่าทำไมหน่วยบริการที่ไม่มีบรรทัด Restart= จะหยุดทำงานทันทีเมื่อเกิดการ crash ครั้งแรก
  • on-success จะเริ่มทำงานใหม่เฉพาะเมื่อจบการทำงานอย่างปกติเท่านั้น
  • on-failure จะเริ่มทำงานใหม่เมื่อพบ exit code ที่ไม่ใช่ 0, ได้รับสัญญาณที่ไม่ปกติ, เกิด timeout ระหว่าง start หรือ stop หรือ watchdog หมดเวลา
  • on-abnormal จะเริ่มทำงานใหม่เมื่อได้รับสัญญาณที่ไม่ปกติ, เกิด timeout หรือ watchdog หมดเวลา แต่จะไม่เริ่มใหม่หากเป็นเพียง exit code ที่ไม่ใช่ 0
  • on-abort จะเริ่มทำงานใหม่เฉพาะเมื่อได้รับสัญญาณที่ไม่ปกติ ซึ่งหมายถึงการ crash
  • on-watchdog จะเริ่มทำงานใหม่เฉพาะเมื่อ WatchdogSec= หมดเวลา
  • always จะเริ่มทำงานใหม่ในทุกกรณีข้างต้น รวมถึงการจบการทำงานอย่างปกติด้วยสถานะ 0

on-failure เป็นค่าเริ่มต้นที่เหมาะสมสำหรับ daemon ที่ทำงานต่อเนื่อง มันจะกู้คืนบริการเมื่อเกิดการ crash และปล่อยให้การจบการทำงานที่ตั้งใจไว้ exit 0 เป็นไปตามปกติ always เหมาะสำหรับโปรแกรมที่จบการทำงานอย่างปกติจากปัจจัยภายนอก เช่น tunnel client ที่ส่งค่า 0 เมื่อฝั่งตรงข้ามตัดการเชื่อมต่อ ข้อเสียของ always คือมันจะบดบังข้อผิดพลาด: บริการที่เริ่มทำงาน อ่านไฟล์ config ที่เสียหาย บันทึก log ข้อผิดพลาด แล้วจบการทำงานด้วย 0 จะวนลูปไม่สิ้นสุด โดยมีเพียงตัวนับการ restart ที่เพิ่มขึ้นเท่านั้นที่บ่งบอกถึงปัญหา

SuccessExitStatus= ใช้ปรับเปลี่ยนเส้นแบ่งระหว่างการจบการทำงานที่ปกติและไม่ปกติ Borg จะส่งค่า 1 สำหรับคำเตือนและ 2 สำหรับข้อผิดพลาด ดังนั้นหน่วยบริการสำรองข้อมูลที่ไม่มี SuccessExitStatus=1 จะถูกทำเครื่องหมายว่าล้มเหลวทุกครั้งที่ข้ามไฟล์ที่อ่านไม่ได้ RestartPreventExitStatus= ใช้ระบุรหัสที่จะป้องกันการ restart แม้จะใช้ always ซึ่งเป็นวิธีที่ถูกต้องสำหรับโปรแกรมในการระบุว่าไม่ควรเริ่มทำงานใหม่ RestartForceExitStatus= ทำหน้าที่ตรงกันข้าม งานสำรองข้อมูลควรอยู่ในหน่วยบริการ Type=oneshot ที่ควบคุมด้วย timer แทนที่จะใช้ลูปการ restart และ คู่ของ service และ timer ที่รันงานตามกำหนดเวลา คือรูปแบบที่ควรนำไปใช้

คำเตือนเกี่ยวกับการทดสอบ: การสั่ง kill บริการด้วย kill <pid> ปกติจะส่งสัญญาณ SIGTERM ซึ่งอยู่ในรายการการจบการทำงานที่ปกติ ดังนั้น Restart=on-failure จะไม่ทำงานตามที่คุณคาดหวัง และคุณอาจสรุปผิดว่า config ของคุณมีปัญหา ให้ใช้ kill -9 <pid> หรือ systemctl kill -s SIGKILL myapp.service แทน นอกจากนี้ โปรดจำไว้ว่าไม่มีค่าใดของ Restart= ที่จะทำงานหลังจาก systemctl stop หรือเมื่อหน่วยบริการถูกหยุดเนื่องจาก dependency ประเภท BindsTo= หรือ PartOf= หายไป การหยุดทำงานในลักษณะนี้ไม่ถือว่าเป็นความล้มเหลว

RestartSec และค่าเริ่มต้น 100 มิลลิวินาที

RestartSec= คือระยะเวลาหยุดพักระหว่างที่ unit หยุดทำงานและ systemd เริ่มการทำงานใหม่อีกครั้ง โดยค่าเริ่มต้นคือ 100 มิลลิวินาที ให้ตรวจสอบว่า unit ของคุณโหลดค่าใดอยู่จริง:

systemctl show -p RestartUSec -p StartLimitIntervalUSec -p StartLimitBurst myapp.service

Unit ที่ไม่ได้ตั้งค่าไว้จะแสดงผลเป็น RestartUSec=100ms ค่าเริ่มต้นนี้เหมาะสมสำหรับบริการที่เกิดการ crash เป็นครั้งคราวแล้วกลับมาทำงานได้ใหม่ แต่จะไม่เหมาะสมสำหรับบริการที่ไม่สามารถเริ่มทำงานได้เลย เพราะจะเกิดการ restart ถึง 5 ครั้งภายในเวลาไม่ถึงครึ่งวินาที ซึ่งเป็นสาเหตุที่ทำให้เกิดการจำกัดอัตรา (rate limit) ตามที่อธิบายไว้ในหัวข้อถัดไป สำหรับบริการใดก็ตามที่ต้องรอการเชื่อมต่อฐานข้อมูล, การ mount หรือเส้นทางเครือข่าย ให้ตั้งค่า RestartSec=5s หรือมากกว่านั้น

ตั้งแต่เดือนสิงหาคม 2026 เป็นต้นไป systemd เวอร์ชัน 254 ขึ้นไปได้เพิ่มตัวเลือก RestartSteps= และ RestartMaxDelaySec= ซึ่งจะช่วยเพิ่มระยะเวลาหน่วงจาก RestartSec= ขึ้นไปจนถึงค่าสูงสุดตามจำนวนครั้งที่พยายามเริ่มทำงาน Ubuntu 24.04 มาพร้อมกับ systemd 255 ซึ่งรองรับฟีเจอร์นี้ ในขณะที่ Debian 12 มาพร้อมกับ systemd 252 ซึ่งยังไม่รองรับ การเพิ่มระยะเวลาหน่วงแบบทวีคูณเป็นวิธีที่ถูกต้องเมื่อ dependency อาจต้องใช้เวลานานในการกลับมาพร้อมใช้งาน

ความหมายที่แท้จริงของ "start request repeated too quickly"

นี่คือสถานะที่ผู้ใช้งานมักเข้าใจผิดว่า systemd ยอมแพ้โดยไม่มีเหตุผล แต่จริงๆ แล้วมันคือตัวนับ (counter) กฎมีอยู่ว่า: หาก unit ถูกสั่งให้เริ่มทำงานเกิน StartLimitBurst= ครั้งภายในเวลา StartLimitIntervalSec= systemd จะปฏิเสธการเริ่มทำงานและเปลี่ยนสถานะเป็น failed โดยค่าเริ่มต้นคือ 5 ครั้งใน 10 วินาที

journal จะแสดงลำดับเหตุการณ์ดังนี้:

myapp.service: Scheduled restart job, restart counter is at 5.
myapp.service: Start request repeated too quickly.
myapp.service: Failed with result 'start-limit-hit'.
Failed to start myapp.service - My application.

และ systemctl start จะให้คำตอบพร้อมวิธีแก้ไขที่ระบุไว้แล้ว:

Job for myapp.service failed because start of the service was attempted too often. See "systemctl status myapp.service" and "journalctl -xeu myapp.service" for details. To force a start use "systemctl reset-failed myapp.service" followed by "systemctl start myapp.service" again.

systemctl reset-failed myapp.service จะทำการล้างตัวนับและสถานะ failed ออกไป หากไม่ทำเช่นนี้ การสั่ง systemctl start แบบปกติจะถูกปฏิเสธซ้ำๆ จนกว่าคุณจะรันคำสั่งดังกล่าว การสั่งเริ่มทำงานด้วยตนเองก็นับรวมในขีดจำกัดนี้ด้วย ดังนั้นการรัน systemctl restart หลายครั้งในขณะที่คุณกำลังแก้ไขไฟล์ config อาจทำให้เกิดปัญหานี้ได้โดยที่บริการไม่ได้ crash จริงๆ

ส่วนที่ทำให้คนเข้าใจผิดคือ start-limit-hit ไม่เคยบอกสาเหตุว่าทำไมบริการถึงล้มเหลว มันบอกเพียงว่าบริการล้มเหลวซ้ำๆ อย่างรวดเร็วเท่านั้น สาเหตุที่แท้จริงจะอยู่ในบรรทัดของ journal ที่อยู่ก่อนหน้าข้อความนี้

การตั้งค่าทั้งสองอย่างควรอยู่ในส่วน [Unit] คุณอาจพบตัวอย่างที่ใส่ไว้ใน [Service] ซึ่ง systemd รุ่นเก่าเคยยอมรับได้ และนั่นคือจุดเริ่มต้นของความสับสน ให้เขียนไว้ใน [Unit] จากนั้นตรวจสอบว่า systemd โหลดค่าใดไปใช้ด้วย systemctl show เพราะค่าที่โหลดไปใช้จริงเท่านั้นที่มีผล

[Unit]
Description=My application
StartLimitIntervalSec=300
StartLimitBurst=5

[Service]
Type=exec
ExecStart=/usr/local/bin/myapp
Restart=on-failure
RestartSec=10s

การตั้งค่านี้จะให้ unit พยายามเริ่มทำงาน 5 ครั้งภายในกรอบเวลา 5 นาที ก่อนที่จะยอมแพ้ StartLimitIntervalSec=0 จะเป็นการปิดขีดจำกัดนี้โดยสมบูรณ์ และคุณควรทราบผลที่จะตามมา: บริการที่ไม่สามารถเริ่มทำงานได้จะพยายามเริ่มใหม่ตลอดไปและเขียน log ลงใน journal ทุกครั้ง ค่าเริ่มต้นระดับเครื่องจะอยู่ใน /etc/systemd/system.conf ในชื่อ DefaultStartLimitIntervalSec= และ DefaultStartLimitBurst=

มีการตั้งค่าข้างเคียงหนึ่งรายการที่ควรระวัง คือ StartLimitAction= ซึ่งกำหนดว่าจะเกิดอะไรขึ้นเมื่อถึงขีดจำกัด โดยรองรับค่าต่างๆ เช่น reboot, reboot-force และ poweroff ค่าเริ่มต้นคือ none ซึ่งจะทำให้ unit อยู่ในสถานะ failed และไม่ส่งผลกระทบต่อเครื่อง สำหรับ VPS ที่ใช้งานจากระยะไกล การใช้ poweroff อาจหมายถึงเครื่องจะดับไปจนกว่าคุณจะเข้าไปเปิดผ่าน console ของผู้ให้บริการ

แก้ไขปัญหาที่หนึ่ง: หนึ่งกระบวนการต่อหนึ่ง unit

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

ให้ระบุความสัมพันธ์ระหว่าง unit ไว้ในไฟล์ unit โดยตรง ไม่ใช่ใน shell script

  • After= กำหนดลำดับการเริ่มทำงานเท่านั้น ไม่ได้ระบุถึงกรณีที่เกิดความล้มเหลว
  • Requires= เริ่ม unit อื่นควบคู่ไปกับ unit นี้ และจะหยุด unit นี้หากอีก unit ถูกสั่งหยุดโดยตรง
  • BindsTo= คือ Requires= บวกกับกรณีที่คุณต้องให้ความสำคัญ: unit นี้จะหยุดทำงานเมื่ออีก unit หยุดทำงานไม่ว่าด้วยเหตุผลใดก็ตาม รวมถึงกรณีที่เกิด crash ให้ใช้คู่กับ After= มิฉะนั้นลำดับการทำงานจะไม่ชัดเจน
  • PartOf= ส่งต่อการหยุดและการรีสตาร์ทลงไปยัง unit ที่เกี่ยวข้อง ดังนั้น systemctl restart myapp.target จะส่งผลถึงทุก unit ที่ถูก PartOf= ไว้
  • Upholds= (systemd 249 ขึ้นไป ซึ่งรวมถึง Ubuntu 22.04 เป็นต้นไป) จะคอยรักษาให้ unit ที่ระบุทำงานอยู่เสมอ หาก unit นั้นหยุดทำงาน systemd จะเริ่มใหม่ให้โดยอัตโนมัติ โดยอยู่ภายใต้ข้อจำกัดอัตราการเริ่มทำงาน (start rate limit) เช่นเดียวกับบริการอื่นๆ

ตัวอย่าง worker ที่ต้องไม่ทำงานหากไม่มี API server และ systemd จะคอยรักษาให้ทำงานตราบเท่าที่ API ยังเปิดอยู่:

# /etc/systemd/system/myapp-api.service
[Unit]
Description=myapp API server
Wants=network-online.target
After=network-online.target
Upholds=myapp-worker.service

[Service]
Type=exec
User=myapp
ExecStart=/usr/local/bin/myapp serve
Restart=on-failure
RestartSec=5s

[Install]
WantedBy=multi-user.target
# /etc/systemd/system/myapp-worker.service
[Unit]
Description=myapp background worker
BindsTo=myapp-api.service
After=myapp-api.service
StartLimitIntervalSec=120
StartLimitBurst=5

[Service]
Type=exec
User=myapp
ExecStart=/usr/local/bin/myapp worker
Restart=on-failure
RestartSec=5s

ตัว worker ไม่จำเป็นต้องมีส่วน [Install] และไม่ควรถูกสั่ง enable ด้วยตนเอง ตัว API unit จะดึงมันขึ้นมาด้วย Upholds= ดังนั้น systemctl enable --now myapp-api.service จึงเป็นคำสั่งเดียวที่คุณต้องใช้ ให้ reload และตรวจสอบสิ่งที่ systemd จัดการกับคู่นี้:

sudo systemctl daemon-reload
systemd-analyze verify /etc/systemd/system/myapp-worker.service
systemctl list-dependencies myapp-api.service

systemd-analyze verify จะไม่แสดงผลลัพธ์ใดๆ หากไฟล์ตั้งค่าถูกต้อง หากมีข้อความแสดงขึ้นมา แสดงว่ามีปัญหา ซึ่งมักเกิดจากคีย์ที่ systemd ไม่รู้จักในส่วนที่คุณเขียน หรือการอ้างอิงถึง unit ที่ไม่มีอยู่จริง

วิธีแก้ไขที่สอง: ใช้ Type=notify เพื่อให้ systemd ทราบสถานะมากกว่าแค่ PID

หากโปรแกรมรองรับโปรโตคอลการแจ้งเตือนของ systemd ให้เลือกใช้งาน ด้วย Type=notify บริการจะแจ้งให้ systemd ทราบเมื่อพร้อมทำงาน ซึ่งทำให้การจัดลำดับการทำงานมีความแม่นยำจริงแทนที่จะเป็นการคาดเดา และยังสามารถส่ง MAINPID= เพื่อระบุให้ systemd ทราบถึงกระบวนการทำงานหลัก แทนที่จะชี้ไปยังตัวเรียกใช้งาน (launcher)

WatchdogSec= คือส่วนที่คุ้มค่าแก่การตั้งค่า เมื่อกำหนดค่านี้แล้ว บริการจะต้องส่ง WATCHDOG=1 ผ่าน sd_notify(3) อย่างน้อยตามความถี่ที่กำหนด หากข้อความหยุดส่ง systemd จะยุติการทำงานของบริการด้วย SIGABRT และทำเครื่องหมายว่าล้มเหลว เพื่อให้ Restart=on-failure หรือ Restart=on-watchdog ทำการเริ่มบริการใหม่ นี่เป็นวิธีเดียวที่มีมาให้ในตัวสำหรับการรีสตาร์ทกระบวนการที่ยังทำงานอยู่แต่เกิดอาการค้าง ซึ่งนโยบายการตรวจสอบ exit-status แบบปกติไม่สามารถตรวจพบได้

[Service]
Type=notify
NotifyAccess=main
ExecStart=/usr/local/bin/myapp serve
WatchdogSec=30s
Restart=on-failure
RestartSec=5s

การที่ watchdog ทำงานจะปรากฏใน journal ว่าเป็น myapp.service: Watchdog timeout (limit 30s)! ตามด้วยการสั่ง kill หาก unit ค้างอยู่ในสถานะ activating (start) จนกระทั่ง TimeoutStartSec หมดเวลา แสดงว่า READY=1 ไม่เคยถูกส่งมาถึง ซึ่งอาจเกิดจากโปรแกรมไม่รองรับโปรโตคอลนี้ หรือ NotifyAccess=main ปฏิเสธการแจ้งเตือนที่มาจากกระบวนการลูก (child process) ซึ่ง journal จะรายงานเหตุการณ์นี้โดยระบุ PID ทั้งสองตัว

สำหรับซอฟต์แวร์ที่มี HTTP health endpoint แต่ไม่มีการรองรับ sd_notify ทางเลือกที่ตรงไปตรงมาคือการใช้ timer unit ขนาดเล็กเพื่อตรวจสอบ endpoint และเรียกใช้ systemctl restart หรือปล่อยให้ container runtime เป็นผู้ตรวจสอบ ซึ่งเป็นจุดประสงค์ของ การตรวจสอบสุขภาพใน Compose และพฤติกรรมการรีสตาร์ท

วิธีแก้ไขที่สาม: การใช้ supervisor ภายใน unit ในกรณีที่ไม่มีทางเลือกอื่น

ซอฟต์แวร์บางตัวถูกจัดส่งมาในรูปแบบชุดของกระบวนการที่ทำงานอยู่เบื้องหลังตัวเรียกใช้งาน (launcher) ซึ่งคุณไม่สามารถแยกออกจากกันได้ ในกรณีนี้ คุณต้องรัน supervisor ไว้ภายใน unit และต้องยอมรับผลที่ตามมาคือ systemd จะคอยเฝ้าดู supervisor ส่วน supervisor จะคอยเฝ้าดูส่วนประกอบอื่นๆ ทั้งหมด ส่งผลให้กลยุทธ์การรีสตาร์ท (restart policy) ของคุณถูกกำหนดไว้ในไฟล์สองแห่ง

รูปแบบที่พบบ่อยที่สุดคือ container runtime โดย unit ประเภท docker compose หรือ podman จะใช้รูปแบบนี้โดยตรง ซึ่งกลยุทธ์การรีสตาร์ทสำหรับแต่ละคอนเทนเนอร์จะถูกระบุไว้ในไฟล์ Compose ในขณะที่ systemd unit จะทำหน้าที่เพียงรักษาให้ runtime ทำงานอยู่เท่านั้น หากโครงสร้างระบบของคุณเป็นเช่นนี้ unit ที่ใช้สั่งรัน Compose stack ตอนบูตเครื่อง จะแสดงตัวอย่างการทำงานที่ถูกต้อง รวมถึงเหตุผลว่าทำไมการใช้ Type=oneshot ร่วมกับ RemainAfterExit=yes จึงมักเป็นวิธีที่เหมาะสมที่สุดในกรณีนี้

cgroup ยังคงทำงานเพื่อสนับสนุนคุณได้เป็นอย่างดี ทุกกระบวนการที่ supervisor เริ่มทำงานจะยังคงอยู่ภายใน cgroup ของ unit นั้น ดังนั้น MemoryMax=, CPUQuota= และการล้างข้อมูลเมื่อหยุดการทำงาน (cleanup at stop time) จึงยังคงครอบคลุมทั้งโครงสร้างกระบวนการทั้งหมด มีเพียงการตัดสินใจเรื่องการรีสตาร์ทเท่านั้นที่ถูกมอบหมายออกไป

ไม่ว่าคุณจะเลือกใช้ supervisor ตัวใดก็ตาม อย่าตั้งค่า Restart=always บน unit ชั้นนอกควบคู่ไปกับกลยุทธ์การรีสตาร์ทที่รุนแรงภายในตัว supervisor โดยไม่ไตร่ตรองให้ดี การมีตรรกะการรีสตาร์ทสองชั้นที่ต่างฝ่ายต่างมีระยะเวลา backoff ของตัวเอง จะทำให้บริการเกิดอาการติดๆ ดับๆ (flapping) เป็นเวลานาน และทำให้ log ใน journal ไม่สามารถอธิบายสาเหตุที่แท้จริงได้

ExitType=cgroup ไม่ได้หมายความว่า "ให้รีสตาร์ทเมื่อมีโพรเซสใดๆ ตาย"

ExitType= (มีใน systemd 250 ขึ้นไป ซึ่งรวมถึง Ubuntu 24.04 และ Debian 12) เป็นการตั้งค่าที่ผู้ใช้มักพบเมื่อค้นหาวิธีแก้ปัญหานี้ แต่มันทำงานตรงกันข้ามกับชื่อที่สื่อ ค่าเริ่มต้นคือ ExitType=main ซึ่งหมายความว่าบริการจะถือว่าหยุดทำงานเมื่อโพรเซสหลักจบการทำงาน ส่วน ExitType=cgroup หมายความว่าบริการจะยังคงถือว่าทำงานอยู่จนกว่าโพรเซสสุดท้ายใน cgroup จะจบการทำงาน

ดังนั้น ExitType=cgroup จึงทำให้ unit มีความไวต่อการตายของโพรเซสน้อยลง ไม่ใช่มากขึ้น นี่เป็นการตั้งค่าที่เหมาะสมสำหรับโปรแกรมที่ทำการ fork โพรเซสทำงานจริงออกมาแล้วจบการทำงานของโพรเซสแม่โดยไม่เขียน PID file ซึ่งในกรณีนี้ Type=forking จะไม่สามารถหา daemon เจอ แต่นี่ไม่ใช่การตั้งค่าที่ถูกต้องสำหรับปัญหาความล้มเหลวที่กล่าวถึงในที่นี้

ไม่มีค่า Restart= ใดที่หมายถึง "ให้รีสตาร์ท unit เมื่อโพรเซสใดๆ ใน cgroup ตาย" หากคุณต้องการพฤติกรรมดังกล่าว คุณจำเป็นต้องใช้หนึ่งโพรเซสต่อหนึ่ง unit หากคุณไม่สามารถแยกโปรแกรมออกจากกันได้และคุณสามารถควบคุม wrapper script ได้ วิธีที่ใกล้เคียงที่สุดคือ wait -n ซึ่งจะส่งค่ากลับทันทีที่ child โพรเซสแรกจบการทำงาน:

#!/bin/bash
/usr/local/bin/myapp-web &
/usr/local/bin/myapp-worker &
wait -n
exit 1

เมื่อ child โพรเซสใดก็ตามตายลงในตอนนี้ มันจะทำให้ wrapper จบการทำงานด้วยสถานะที่ไม่ใช่ศูนย์ ส่งผลให้ Restart=on-failure ทำงาน นี่เป็นเพียงวิธีแก้ปัญหาเฉพาะหน้า ไม่ใช่การแก้ไขที่ต้นเหตุ คุณยังคงต้องใช้ตัวนับการรีสตาร์ทชุดเดียวกันสำหรับสองโปรแกรม มี log stream เดียวกัน และไม่มีวิธีรีสตาร์ทเฉพาะส่วนที่ล้มเหลวได้ด้วยตัวเอง

วิธีการตรวจสอบสิ่งที่เกิดขึ้นจริง

ใช้คำสั่ง 4 คำสั่งตามลำดับดังนี้

systemctl status myapp.service
systemd-cgls --unit myapp.service
systemctl show -p MainPID -p NRestarts -p Result -p ExecMainStatus myapp.service
journalctl -u myapp.service -b -o short-precise

systemctl status จะแสดงสถานะ, PID หลัก และโครงสร้าง cgroup ในหน้าจอเดียว หน่วยบริการที่ทำงานปกติจะมีสถานะเป็น Active: active (running) พร้อมบรรทัด Main PID: ที่ระบุชื่อกระบวนการที่คุณคาดหวัง หากโครงสร้างที่ด้านล่างแสดงรายการกระบวนการที่คุณไม่รู้จัก หรือขาดกระบวนการที่ควรจะมี คุณก็จะได้คำตอบทันที

systemd-cgls --unit จะพิมพ์โครงสร้างเดียวกันโดยไม่มีการตัดทอน ซึ่งจะเริ่มมีความสำคัญเมื่อหน่วยบริการหนึ่งมีกระบวนการทำงานมากกว่าจำนวนเล็กน้อย

systemctl show จะให้ข้อมูลในรูปแบบที่เครื่องอ่านได้ NRestarts= คือตัวนับการรีสตาร์ท และเป็นวิธีที่เร็วที่สุดในการแยกแยะบริการที่รีสตาร์ทไปแล้ว 40 ครั้ง ออกจากบริการที่ทำงานมาตั้งแต่เปิดเครื่อง Result= จะเก็บสาเหตุความล้มเหลวครั้งล่าสุด ได้แก่ exit-code, signal, timeout, oom-kill, watchdog หรือ start-limit-hit ส่วน ExecMainStatus= คือสถานะการออก (exit status) ดั้งเดิมของกระบวนการหลักล่าสุด

journal จะเก็บลำดับเหตุการณ์ไว้ นี่คือ 3 บรรทัดที่คุณควรค้นหา:

myapp.service: Main process exited, code=exited, status=1/FAILURE
myapp.service: Failed with result 'exit-code'.
myapp.service: Scheduled restart job, restart counter is at 1.

code=exited, status=N หมายความว่าโปรแกรมเลือกที่จะส่งค่า N กลับมา ดังนั้นข้อผิดพลาดจึงอยู่ที่ตัวโปรแกรมหรือการตั้งค่าของโปรแกรม code=killed, signal=SEGV หมายความว่าโปรแกรมเกิดการขัดข้อง (crash) code=killed, signal=TERM มักหมายความว่ามีสิ่งอื่นสั่งให้หยุดทำงาน ซึ่งไม่ใช่ความล้มเหลวและจะไม่กระตุ้นการทำงานของ Restart=on-failure ส่วน code=dumped หมายความว่าโปรแกรมทิ้งไฟล์ core ไว้ ซึ่ง coredumpctl list จะแสดงให้คุณเห็นเมื่อมีการติดตั้ง systemd-coredump ไว้แล้ว

หากมีหลายเครื่อง NRestarts คือตัวเลขที่ควรเก็บรวบรวมตามกำหนดเวลา หน่วยบริการที่มีตัวนับเพิ่มขึ้นทุกวันแสดงว่ากำลังล้มเหลวทุกวัน ไม่ว่าจะมีใครสังเกตเห็นหรือไม่ก็ตาม เมื่อคุณดูแลเซิร์ฟเวอร์เกิน 2 หรือ 3 เครื่อง วิธีที่สม่ำเสมอในการรันคำสั่งเดียวบนทุกเซิร์ฟเวอร์ จะเปลี่ยนการคาดเดาให้กลายเป็นรายงานที่ชัดเจน

FAQ

ทำไม systemctl ถึงแจ้งว่า service ของฉันยัง active อยู่ทั้งที่ process ตายไปแล้ว?

systemd จะติดตาม process หลักเพียงหนึ่งรายการต่อหนึ่ง service unit และ Restart= จะอ่านเฉพาะสถานะการจบการทำงานของ process นั้นเท่านั้น ส่วน process อื่นๆ ที่ unit เริ่มทำงานจะอยู่ใน cgroup เดียวกัน ซึ่ง systemd จะสั่งปิด process เหล่านั้นเมื่อ unit หยุดทำงาน แต่จะไม่คอยตรวจสอบสถานะการจบการทำงานของ process เหล่านั้น ให้รันคำสั่ง systemctl show -p MainPID myapp.service แล้วเปรียบเทียบจำนวนกับ systemd-cgls --unit myapp.service หาก process ที่ตายไปปรากฏอยู่ใน tree แต่ไม่ใช่ MainPID แสดงว่า systemd ทำงานตามที่ออกแบบไว้ วิธีแก้ไขคือให้ใช้หนึ่ง process ต่อหนึ่ง unit โดยกำหนดความสัมพันธ์ระหว่าง unit ด้วย BindsTo= และ Upholds=

"start request repeated too quickly" หมายความว่าอย่างไร?

หมายความว่า unit ถูกสั่งให้เริ่มทำงานเกิน StartLimitBurst= ครั้งภายในระยะเวลา StartLimitIntervalSec= ซึ่งค่าเริ่มต้นคือ 5 ครั้งใน 10 วินาที systemd จึงหยุดพยายามเริ่มบริการดังกล่าว นี่เป็นข้อจำกัดด้านอัตราการทำงาน (rate limit) และไม่ได้ระบุสาเหตุที่ service ล้มเหลว ดังนั้นให้ตรวจสอบบรรทัด log ใน journal ที่อยู่เหนือข้อความนี้ขึ้นไป ล้างสถานะด้วย systemctl reset-failed myapp.service แล้วจึงแก้ไขข้อผิดพลาดที่เป็นต้นเหตุ หาก service ต้องรอการทำงานของบางอย่างที่ใช้เวลานาน ให้เพิ่มค่า RestartSec= เนื่องจากค่าเริ่มต้นที่ 100 มิลลิวินาทีจะทำให้ใช้โควตาครบ 5 ครั้งภายในเวลาไม่ถึงหนึ่งวินาที

ควรใช้ Restart=always หรือ Restart=on-failure?

ให้ใช้ on-failure สำหรับเกือบทุกกรณี เพราะจะสั่ง restart เมื่อเกิดการ crash, exit code ที่ไม่ใช่ศูนย์, timeout และ watchdog trip โดยจะปล่อยให้การสั่งหยุดทำงานด้วย exit 0 เป็นไปตามปกติ ให้ใช้ always เฉพาะในกรณีที่โปรแกรมจบการทำงานอย่างปกติด้วยเหตุผลที่อยู่นอกเหนือการควบคุม เช่น client ที่ส่งค่า 0 กลับมาเมื่อ peer ตัดการเชื่อมต่อ ข้อเสียของ always คือหาก service อ่านไฟล์ config ที่เสียหายแล้ว log ข้อผิดพลาดหนึ่งบรรทัดก่อนจะจบการทำงานด้วยค่า 0 มันจะวนลูปไม่รู้จบ โดยมีอาการเดียวที่สังเกตได้คือค่า NRestarts ที่เพิ่มขึ้นเรื่อยๆ ใน systemctl show

ทำไมการสั่ง kill process ด้วยตัวเองถึงไม่ทำให้เกิดการ restart?

เพราะ systemd ถือว่า SIGHUP, SIGINT, SIGTERM และ SIGPIPE เป็นการจบการทำงานที่ปกติ และการสั่ง kill <pid> แบบปกติจะเป็นการส่งสัญญาณ SIGTERM ภายใต้ Restart=on-failure การจบการทำงานที่ปกติจะไม่ถือว่าเป็นความล้มเหลว จึงไม่มีการ restart และทำให้ดูเหมือนว่าการตั้งค่าผิดพลาดทั้งที่จริงๆ แล้วไม่ผิด ให้ทดสอบด้วย kill -9 <pid> หรือ systemctl kill -s SIGKILL myapp.service ซึ่งเป็นการปิด process แบบไม่ปกติและจะกระตุ้นนโยบาย restart ให้ทำงาน กฎเดียวกันนี้อธิบายว่าทำไม systemctl stop ถึงไม่ขัดแย้งกับนโยบาย restart ของคุณ

StartLimitIntervalSec และ StartLimitBurst ควรวางไว้ที่ไหน?

ให้วางไว้ในส่วน [Unit] เอกสารเก่าและ systemd เวอร์ชันเก่ามักวางไว้ใน [Service] ทำให้ตัวอย่างที่คัดลอกมามีความขัดแย้งกัน อย่าเดาว่าเวอร์ชันของคุณรองรับตำแหน่งใด หลังจาก systemctl daemon-reload ให้ตรวจสอบว่า systemd โหลดค่าใดมาใช้ด้วยคำสั่ง systemctl show -p StartLimitBurst -p StartLimitIntervalUSec myapp.service และให้ยึดตัวเลขเหล่านั้นเป็นหลัก systemd-analyze verify /etc/systemd/system/myapp.service จะตรวจจับคีย์ที่ systemd ไม่รู้จัก และจะไม่แสดงผลใดๆ หากไฟล์ตั้งค่าถูกต้องสมบูรณ์

#systemd#restart#service-unit#cgroups#reliability