บทความ › เซิร์ฟเวอร์และ DevOps
รันแอปด้วย systemd แทน screen และ nohup: unit file, restart, timer แทน cron
เลิกเปิดแอปค้างไว้ใน screen หรือ nohup เขียน systemd unit ให้รันด้วย user แยก restart เองเมื่อพัง อ่าน env จากไฟล์ ล็อกสิทธิ์ และใช้ timer แทน cron
- 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 / nohup | pm2 | systemd | |
|---|---|---|---|
| เปิดเองตอนบูต | ไม่ | ผ่าน pm2 startup (สร้าง unit ของ systemd) | ได้ (enable) |
| restart เมื่อพัง | ไม่ | ได้ | ได้ (Restart=) |
| รันด้วย user แยก | ตาม user ที่พิมพ์ | ตาม user ที่รัน pm2 | User= |
| log | ไฟล์/terminal ไม่หมุน | ไฟล์ของ pm2 | journal มีขนาดจำกัด ค้นตามเวลาได้ |
| จำกัดสิทธิ์ไฟล์ระบบ | ไม่ | ไม่ | 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 ยังไม่พร้อม
- ค่า default: start ที่ 0, 0.1, 0.2, 0.3, 0.4 วินาที ครบ 5 ครั้งใน 0.4 วินาที → ชน limit → failed ถาวร ทั้งที่อีก 3 วินาที database ก็พร้อม
RestartSec=5อย่างเดียว: start ที่ 0, 5, 10 วินาที ในหน้าต่าง 10 วินาทีใด ๆ มีไม่เกิน 3 ครั้ง ไม่ถึง 5 → ไม่มีวันชน limit restart วนทุก 5 วินาทีไปเรื่อย ๆ ถ้าพังเพราะ config ผิด journal จะเต็มไปด้วย error เดิม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
hardening พื้นฐาน
| directive | ทำอะไร |
|---|---|
NoNewPrivileges=true | process และลูกยกสิทธิ์ผ่าน 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(defaultfalse) เก็บเวลารันล่าสุดลงดิสก์ ถ้าเครื่องดับตอนตี 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-reloadsystemd ยังใช้ของเก่า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 ใหม่
คำสั่งและค่าตั้งในบทความทดสอบบนระบบตัวอย่าง ลองบนเครื่องทดสอบก่อนใช้กับระบบจริงเสมอ