บทความ › เซิร์ฟเวอร์และ DevOps

รันแอปด้วย systemd แทน screen และ nohup: unit file, restart, timer แทน cron

เลิกเปิดแอปค้างไว้ใน screen หรือ nohup เขียน systemd unit ให้รันด้วย user แยก restart เองเมื่อพัง อ่าน env จากไฟล์ ล็อกสิทธิ์ และใช้ timer แทน cron

กลาง อ่าน 12 นาที 22 ตุลาคม 2569

อ่านจบแล้วจะรู้
  • unit file ไม่กี่บรรทัดให้ทุกอย่างที่ screen/nohup ไม่มี: เปิดเองตอนบูต restart เมื่อพัง log ไปที่ journal
  • Restart=on-failure ต้องคู่กับ RestartSec และเข้าใจ StartLimitBurst ไม่งั้นแอปที่พังวนจะถูกปล่อยทิ้งหลัง 5 ครั้งใน 10 วินาที
  • ProtectSystem=strict + ReadWritePaths ทำให้แอปเขียนได้เฉพาะโฟลเดอร์ที่อนุญาต ทั้งเครื่องที่เหลืออ่านอย่างเดียว
  • timer ที่มี Persistent=true งานที่พลาดเพราะเครื่องดับจะรันทันทีเมื่อบูต cron ทำแบบนี้ไม่ได้

ถ้าแอปบนเซิร์ฟเวอร์ยังรันอยู่ใน screen หรือ nohup ... & มันจะไม่กลับมาเองหลังรีบูต ไม่ restart ตอนพัง และ log ไปกองอยู่ในไฟล์ที่ไม่มีใครหมุน systemd มีอยู่แล้วในทุกเครื่อง Ubuntu เขียน unit file ไม่ถึง 20 บรรทัด ได้ครบทั้งสามอย่าง พร้อมล็อกสิทธิ์แอปให้แคบลง และใช้ timer แทน cron ได้ด้วย

ทดสอบบน Ubuntu 24.04, systemd 255 (แพ็กเกจ 255.4-1ubuntu8) ค่า default ทุกตัวตรวจกับ man page ของ systemd 255

screen, nohup, pm2 กับ systemd ต่างกันตรงไหน

screen / nohuppm2systemd
เปิดเองตอนบูตไม่ผ่าน pm2 startup (สร้าง unit ของ systemd)ได้ (enable)
restart เมื่อพังไม่ได้ได้ (Restart=)
รันด้วย user แยกตาม user ที่พิมพ์ตาม user ที่รัน pm2User=
logไฟล์/terminal ไม่หมุนไฟล์ของ pm2journal มีขนาดจำกัด ค้นตามเวลาได้
จำกัดสิทธิ์ไฟล์ระบบไม่ไม่ProtectSystem=, ReadWritePaths=
ต้องลงเพิ่มไม่Node + pm2ไม่ มีมากับระบบ

pm2 ยังมีที่ใช้ เช่น cluster mode ของ Node แต่แอป process เดียว systemd ทำครบโดยไม่ต้องมีชั้นกลาง

unit file ตัวแรก

สมมติแอปอยู่ที่ /srv/myapp รันด้วย node server.js สร้าง user ระบบที่ login ไม่ได้ก่อน

# บนเซิร์ฟเวอร์
sudo useradd --system --home /srv/myapp --shell /usr/sbin/nologin myapp
sudo chown -R myapp:myapp /srv/myapp

แล้วเขียน /etc/systemd/system/myapp.service

[Unit]
Description=My app (HTTP API)
After=network-online.target
Wants=network-online.target
StartLimitIntervalSec=300
StartLimitBurst=5

[Service]
User=myapp
Group=myapp
WorkingDirectory=/srv/myapp
EnvironmentFile=/etc/myapp/env
ExecStart=/usr/bin/node /srv/myapp/server.js
Restart=on-failure
RestartSec=5

# hardening
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
ReadWritePaths=/srv/myapp/data

[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl enable --now myapp
systemctl status myapp

enable ผูกกับ multi-user.target ให้เปิดตอนบูต --now start ทันที ไม่ระบุ Type= systemd ใช้ simple เองเมื่อมี ExecStart= คือถือว่า process ที่รันคือตัว service เลย ห้ามให้แอป fork ตัวเองไปอยู่เบื้องหลัง (ไม่ต้องใส่ & หรือโหมด daemon)

EnvironmentFile: เก็บ secret นอก unit

unit file อ่านได้ทุกคนบนเครื่อง (systemctl cat ก็โชว์) อย่าใส่รหัสผ่านใน Environment= ย้ายไปไฟล์แยก

sudo mkdir -p /etc/myapp
sudo tee /etc/myapp/env >/dev/null <<'EOF'
# บรรทัดที่ขึ้นต้นด้วย # ถูกข้าม
PORT=3000
DATABASE_URL=postgres://myapp:change-me@127.0.0.1/myapp
EOF
sudo chmod 600 /etc/myapp/env

ไฟล์เป็น KEY=value บรรทัดละตัว systemd (ที่รันเป็น root) อ่านก่อนเริ่ม process แล้วส่งเป็น environment ให้ แอปจึงไม่ต้องมีสิทธิ์อ่านไฟล์นี้เอง ใส่ - นำหน้า (EnvironmentFile=-/etc/myapp/env) ถ้ายอมให้ไฟล์ไม่มีได้โดยไม่ error เรื่องแยก credential แต่ละระบบอ่านต่อในบทความแยก credential

Restart: ทำไมต้องตั้ง RestartSec และ StartLimit

Restart=on-failure restart เมื่อ process จบด้วย exit code ไม่ใช่ 0, ตายด้วย signal (ยกเว้น SIGHUP, SIGINT, SIGTERM, SIGPIPE), timeout หรือ watchdog หมดเวลา ถ้าแอปจบเองด้วย exit 0 จะไม่ restart

กับดักอยู่ที่ค่า default สองตัวที่ทำงานคู่กัน

  • RestartSec= default 100 ms
  • rate limit: start ได้ไม่เกิน StartLimitBurst= 5 ครั้ง ภายใน StartLimitIntervalSec= 10 วินาที (ตั้งใน [Unit]) เกินแล้ว systemd เลิก restart และ service ค้างที่ failed

ตัวอย่างคำนวณ แอปพังทันทีที่เปิด เพราะ database ยังไม่พร้อม

  1. ค่า default: start ที่ 0, 0.1, 0.2, 0.3, 0.4 วินาที ครบ 5 ครั้งใน 0.4 วินาที → ชน limit → failed ถาวร ทั้งที่อีก 3 วินาที database ก็พร้อม
  2. RestartSec=5 อย่างเดียว: start ที่ 0, 5, 10 วินาที ในหน้าต่าง 10 วินาทีใด ๆ มีไม่เกิน 3 ครั้ง ไม่ถึง 5 → ไม่มีวันชน limit restart วนทุก 5 วินาทีไปเรื่อย ๆ ถ้าพังเพราะ config ผิด journal จะเต็มไปด้วย error เดิม
  3. RestartSec=5 + StartLimitIntervalSec=300 + StartLimitBurst=5 (แบบในไฟล์ด้านบน): start ที่ 0, 5, 10, 15, 20 → ครั้งที่ 5 ภายใน 300 วินาที → เลิกที่ราววินาทีที่ 20 แต่ถ้าแค่ database ช้า ครั้งที่ 2 หรือ 3 ก็ติดแล้ว

RestartSec กำหนดว่ารอนานแค่ไหน StartLimit กำหนดว่าจะยอมแพ้เมื่อไร ตั้งสองอย่างคู่กันเสมอ

พอชน limit แล้ว แก้สาเหตุ แล้ว sudo systemctl reset-failed myapp ก่อน start ใหม่ ถ้าอยากรู้ตอนแอปล่มจริง ต่อ alert ตามบทความ monitor ผ่าน Telegram

ค่าเริ่มต้นรอ 100 ms · เริ่มใหม่ 5 ครั้งใน 10 วิ แล้วยอมแพ้RestartSec=5 อย่างเดียวไม่ชนเพดาน วนเริ่มใหม่ไม่หยุด+ StartLimitIntervalSec=300พังซ้ำ 5 ครั้ง หยุดที่ราว 20 วิ แล้วแจ้งเตือน
RestartSec บอกว่ารอนานเท่าไร StartLimit บอกว่ายอมแพ้เมื่อไร

hardening พื้นฐาน

directiveทำอะไร
NoNewPrivileges=trueprocess และลูกยกสิทธิ์ผ่าน setuid/setgid หรือ file capabilities ไม่ได้
ProtectSystem=strictทั้ง filesystem เป็น read-only ยกเว้น /dev, /proc, /sys
ReadWritePaths=รายการโฟลเดอร์ที่ยังเขียนได้ภายใต้ strict
ProtectHome=true/home, /root, /run/user ว่างและเข้าไม่ได้
PrivateTmp=trueได้ /tmp และ /var/tmp ของตัวเอง ลบทิ้งเมื่อ service หยุด

(ค่าเทียบ: ProtectSystem=true ล็อกแค่ /usr และ /boot, full เพิ่ม /etc, strict ล็อกทั้งหมด)

ผลคือถ้าแอปโดนเจาะ ผู้บุกรุกได้แค่ user myapp ที่เขียนได้เฉพาะ /srv/myapp/data แก้ไฟล์ระบบหรือโค้ดของแอปเองไม่ได้ ตรวจคะแนนความเสี่ยงได้ด้วย

systemd-analyze security myapp

คำสั่งนี้ไล่ทุก directive แล้วให้คะแนน exposure ยิ่งต่ำยิ่งดี ใช้เป็นรายการว่าจะปิดอะไรเพิ่ม อย่าไล่ให้ได้ศูนย์ ทีละข้อแล้วทดสอบแอปทุกครั้ง อีกทางสำหรับโฟลเดอร์ข้อมูลคือ StateDirectory=myapp systemd สร้าง /var/lib/myapp ให้ เจ้าของเป็น User= และเขียนได้แม้ใช้ strict

timer แทน cron

งานตามเวลา (backup, ล้าง cache) ใช้คู่ service + timer ชื่อเดียวกัน

/etc/systemd/system/myapp-backup.service

[Unit]
Description=Nightly backup for myapp

[Service]
Type=oneshot
User=myapp
ExecStart=/srv/myapp/bin/backup.sh

/etc/systemd/system/myapp-backup.timer

[Unit]
Description=Run myapp backup every night

[Timer]
OnCalendar=*-*-* 03:00:00
Persistent=true
RandomizedDelaySec=15min

[Install]
WantedBy=timers.target
sudo systemctl daemon-reload
sudo systemctl enable --now myapp-backup.timer
systemctl list-timers myapp-backup.timer
systemd-analyze calendar "*-*-* 03:00:00"   # ดูว่า expression หมายถึงเวลาไหน

timer หา service ชื่อเดียวกันเอง (Unit= default) สิ่งที่ได้เหนือ cron

  • Persistent=true (default false) เก็บเวลารันล่าสุดลงดิสก์ ถ้าเครื่องดับตอนตี 3 แล้วบูตตอน 7 โมง งานจะรันทันทีหลังบูต cron ข้ามไปเลย
  • RandomizedDelaySec=15min (default 0) หน่วงสุ่ม 0-15 นาที เครื่องหลายตัวที่ตั้งเวลาเดียวกันจะไม่ยิง backup ใส่ storage พร้อมกัน
  • log ของงานอยู่ใน journal ไม่ต้อง >> /var/log/x.log 2>&1 และ systemctl status myapp-backup บอกว่ารอบล่าสุดสำเร็จไหม

ระวัง AccuracySec= default 1 นาที systemd อาจเลื่อนเวลาปลุกได้ถึง 1 นาทีเพื่อรวมรอบ ถ้างานต้องตรงวินาทีให้ตั้ง AccuracySec=1s

อ่าน log ด้วย journalctl

stdout/stderr ของ service ไปที่ journal โดย default ไม่ต้องเขียนไฟล์ log เอง

journalctl -u myapp -f                 # ตามดูสด
journalctl -u myapp -b                 # ตั้งแต่บูตครั้งนี้
journalctl -u myapp --since "1 hour ago"
journalctl -u myapp -p err             # เฉพาะ error ขึ้นไป
journalctl -u myapp -n 50 --no-pager   # 50 บรรทัดล่าสุด

journal มีเพดานขนาดของมันเอง เรื่องจำกัดพื้นที่ log อ่านต่อในบทความดิสก์เต็ม

ข้อผิดพลาดที่เจอบ่อย

  • แก้ unit แล้วลืม daemon-reload systemd ยังใช้ของเก่า systemctl status จะเตือนว่า unit file changed on disk
  • ใช้ ~, $(...) หรือ pipe ใน ExecStart= คำสั่งไม่ผ่าน shell ของพวกนี้ไม่ทำงาน ใช้ path เต็ม ถ้าต้องใช้ pipe หรือ && ให้เรียก /bin/sh -c '...' หรือย้ายไปไว้ใน script
  • ใส่ export ใน EnvironmentFile ไฟล์นี้ไม่ใช่ shell script บรรทัดต้องเป็น KEY=value เท่านั้น
  • ตั้ง strict แล้วแอปพังเพราะ read-only ดู journalctl -u myapp หา EROFS หรือ Read-only file system แล้วเพิ่ม path นั้นใน ReadWritePaths= เฉพาะที่จำเป็น
  • ใส่ StartLimit ไว้ใน [Service] ใน systemd ปัจจุบันสองตัวนี้อยู่ [Unit]
  • timer ลืม enable สร้างไฟล์แล้วไม่เปิด ไม่มีอะไรรัน เช็กด้วย systemctl list-timers --all

ถ้า deploy ด้วยการสลับโฟลเดอร์ ตามบทความ atomic deploy ให้ WorkingDirectory= และ ExecStart= ชี้ path คงที่ที่ถูกสลับ (ไม่ใช่โฟลเดอร์ที่มีเลขเวอร์ชัน) แล้ว systemctl restart myapp หลังสลับ

แหล่งอ้างอิง

คำถามที่พบบ่อย

ใช้ pm2 อยู่แล้ว ต้องเปลี่ยนไหม

ไม่จำเป็น pm2 มีของดีอย่าง cluster mode แต่การให้ pm2 เปิดเองตอนบูตก็ทำผ่าน pm2 startup ที่สร้าง unit ของ systemd อยู่ดี ถ้าแอปมี process เดียว ใช้ systemd ตรง ๆ ตัดชั้นกลางออกหนึ่งชั้น และได้ hardening กับ journal มาฟรี

แก้ unit file แล้วไม่มีผล

ต้องรัน sudo systemctl daemon-reload ก่อน แล้วค่อย restart service systemd อ่าน unit ตอน reload ไม่ใช่ทุกครั้งที่ start

แอปพังวนจนขึ้น start-limit-hit ทำยังไง

แก้สาเหตุก่อน (ดู journalctl -u ชื่อ service) แล้วรัน sudo systemctl reset-failed ชื่อ service เพื่อล้างตัวนับ จากนั้น start ใหม่

คำสั่งและค่าตั้งในบทความทดสอบบนระบบตัวอย่าง ลองบนเครื่องทดสอบก่อนใช้กับระบบจริงเสมอ

อ่านต่อ

#Serverกลาง

PostgreSQL 18: ใช้ uuidv7() แทน UUID v4 และเปิด async I/O ให้ถูก

PostgreSQL 18 มี uuidv7() ที่เรียงตามเวลา ทำให้ B-tree index เล็กและร้อนน้อยกว่า v4 และมี async I/O ผ่าน io_method เลือก worker หรือ io_uring พร้อมตัวอย่างคำนวณขนาด index

อ่าน 9 นาที
#Serverเริ่มต้น

ดิสก์เต็มจนเซิร์ฟเวอร์ล่ม: หาตัวการ แล้วจำกัด journald, logrotate, Docker logs

เซิร์ฟเวอร์หยุดเพราะดิสก์เต็ม หาว่าอะไรกินที่ด้วย df, du, ncdu ตัวการที่เจอบ่อย วิธีตั้งเพดาน journald, logrotate, Docker log และตั้งเตือนที่ 80%

อ่าน 11 นาที
#Serverเริ่มต้น

เซิร์ฟเวอร์ ที่บ้าน IP เปลี่ยนบ่อย ทำ DDNS เองด้วย API ของ DNS และ cron

ทำไม IP บ้านถึงเปลี่ยน และสคริปต์ Python สั้น ๆ ที่เช็ก IP ทุก 5 นาที แล้วแก้ A record ผ่าน API ของผู้ให้บริการ DNS ยกตัวอย่าง DigitalOcean พร้อมเรื่อง TTL และ token

อ่าน 13 นาที