บทความ › เซิร์ฟเวอร์และ DevOps
ให้ เซิร์ฟเวอร์ รายงานตัวทาง Telegram สถานะ เตือนภัย และปิดเครื่องอย่างนุ่มนวล
สคริปต์ Python ไม่กี่สิบบรรทัดบนเครื่องเล็ก ส่งสถานะ CPU แรม ดิสก์ เข้า Telegram เตือนเมื่อเกินเกณฑ์โดยไม่เตือนซ้ำ และปิดเครื่องตามเวลาแบบหยุด container ตามลำดับ
- Telegram Bot API คือ HTTPS POST หนึ่งครั้ง ไม่ต้องลง library ใช้ urllib ของ Python ก็พอ
- การเตือนต้องมี state ว่าเตือนอะไรไปแล้ว ไม่งั้นทุก 5 นาทีจะได้ข้อความเดิมจนปิดแจ้งเตือน
- token ของบอทอยู่ใน env file นอก repo สิทธิ์ 600 ถ้าหลุดไปที่ไหน ต้อง revoke ที่ BotFather
- ปิดเครื่องอย่างนุ่มนวล = หยุด container ตามลำดับ sync แล้วค่อย shutdown และบอกก่อนปิด
เครื่องเล็กที่รันของสำคัญไว้หลายอย่าง ไม่ว่าจะเป็น VPS ตัวเดียวหรือเครื่องที่บ้าน มักไม่มีใครนั่งดูหน้าจอ รู้ว่าดิสก์เต็มก็ตอนที่ฐานข้อมูลเขียนไม่ได้แล้ว เซิร์ฟเวอร์ที่บ้านของเราจึงมีสคริปต์เล็ก ๆ ตัวเดียวทำสามอย่าง ส่งสถานะเข้า Telegram เมื่อถามหรือตามเวลา เตือนเมื่อค่าใดเกินเกณฑ์ และปิดเครื่องอย่างนุ่มนวลตามเวลาทุกวัน บทความนี้เอาโครงของมันมาเขียนใหม่แบบใช้แค่ standard library ของ Python
ทดสอบบน Ubuntu 24.04, Python 3.12 และ Docker Compose v2 บทความเรื่องเครื่อง 2GB พูดถึงการตั้งเพดานไว้แล้ว บทนี้คือการรู้ให้ทันเมื่อมีอะไรแตะเพดาน
ทำไม Telegram
- ฟรี และ push ถึงมือถือทันที ไม่ต้องตั้ง SMTP ไม่ต้องมีแอปของตัวเอง
- Bot API คือ HTTPS POST หนึ่งครั้ง ไปที่
https://api.telegram.org/bot<BOT_TOKEN>/sendMessageพร้อมchat_idและtextไม่ต้องลง library ใด ๆ - ทางเดียวก็พอ เราให้บอทพูดอย่างเดียว ไม่รับคำสั่ง ลดเรื่องความปลอดภัยไปได้มาก (ดูท้ายบท)
สร้างบอทและหา chat id
ทำครั้งเดียวในแอป Telegram
- ทักบอทชื่อ BotFather พิมพ์
/newbotตั้งชื่อและ username (ต้องลงท้ายด้วยbot) จะได้ token หนึ่งบรรทัด หน้าตาเป็นตัวเลข ตามด้วย:ตามด้วยตัวอักษรยาว ๆ - ทักบอทของตัวเอง กด Start แล้วพิมพ์อะไรก็ได้หนึ่งข้อความ (บอทเริ่มคุยก่อนไม่ได้ ต้องให้เราทักก่อน)
- บนเครื่องของเรา เรียก
getUpdatesแล้วอ่านchat.id
# รันบนเครื่องไหนก็ได้ที่มี curl
curl -s "https://api.telegram.org/bot$BOT_TOKEN/getUpdates" | grep -o '"chat":{"id":[0-9-]*'
ตัวเลขที่ได้คือ CHAT_ID ของแชทระหว่างเรากับบอท ถ้าอยากให้ส่งเข้ากลุ่ม ให้เชิญบอทเข้ากลุ่มแล้วทำข้อ 3 ซ้ำ id ของกลุ่มจะติดลบ
เก็บ token ให้ถูกที่
token นี้คือทุกอย่าง ใครมีก็ส่งข้อความในนามบอทเราได้ จึง ห้ามอยู่ในสคริปต์และห้ามอยู่ใน repo เก็บไว้ในไฟล์ของ root ที่อ่านได้คนเดียว
# บนเซิร์ฟเวอร์ รันเป็น root
cat > /etc/monitor.env <<'EOF'
export BOT_TOKEN=123456:REPLACE_ME
export CHAT_ID=REPLACE_ME
EOF
chmod 600 /etc/monitor.env
mkdir -p /var/lib/monitor
ถ้า token เคยถูก commit ที่ไหนก็ตาม แม้จะลบ commit แล้ว ให้ถือว่าหลุด ไปที่ BotFather พิมพ์
/mybotsเลือกบอท กด API Token แล้ว Revoke current token จะได้ token ใหม่ ตัวเก่าใช้ไม่ได้ทันที
สคริปต์ตัวเดียว สามโหมด
ไฟล์ /usr/local/bin/monitor.py ใช้แค่ standard library อ่านค่าจาก /proc และ /sys ตรง ๆ ไม่ต้องลง psutil
#!/usr/bin/env python3
"""monitor.py — status | alert | shutdown ผ่าน Telegram (stdlib only)"""
import json, os, shutil, subprocess, sys, urllib.request
from pathlib import Path
TOKEN = os.environ["BOT_TOKEN"] # มาจาก /etc/monitor.env (chmod 600)
CHAT_ID = os.environ["CHAT_ID"]
LIMITS = {"load": 2 * os.cpu_count(), "ram": 85, "disk": 90, "temp": 85}
STATE = Path("/var/lib/monitor/alerts.json") # จำว่าเตือนอะไรไปแล้ว
def send(text):
body = json.dumps({"chat_id": CHAT_ID, "text": text}).encode()
req = urllib.request.Request(f"https://api.telegram.org/bot{TOKEN}/sendMessage",
data=body, headers={"Content-Type": "application/json"})
with urllib.request.urlopen(req, timeout=10) as r:
return r.status == 200
def load(): # load average 1 นาที
return float(open("/proc/loadavg").read().split()[0])
def ram_pct(): # ใช้ MemAvailable ไม่ใช่ MemFree
m = {l.split()[0].rstrip(":"): int(l.split()[1]) for l in open("/proc/meminfo")}
return 100 * (1 - m["MemAvailable"] / m["MemTotal"])
def disk_pct(path="/"):
d = shutil.disk_usage(path)
return 100 * d.used / d.total
def cpu_temp(): # VPS ส่วนใหญ่ไม่มี → None
try:
return int(open("/sys/class/thermal/thermal_zone0/temp").read()) / 1000
except OSError:
return None
def metrics():
return {"load": load(), "ram": ram_pct(), "disk": disk_pct(), "temp": cpu_temp()}
def status():
m = metrics()
t = f"{m['temp']:.0f}°C" if m["temp"] else "n/a"
return f"server ok\nload {m['load']:.2f} · ram {m['ram']:.0f}% · disk {m['disk']:.0f}% · cpu {t}"
def alert():
m = metrics()
bad = {k: v for k, v in m.items() if v is not None and v > LIMITS[k]}
prev = json.loads(STATE.read_text()) if STATE.exists() else {}
new = [k for k in bad if k not in prev] # เพิ่งเกินเกณฑ์
cleared = [k for k in prev if k not in bad] # กลับมาปกติ
if new:
send("ALERT " + ", ".join(f"{k} {bad[k]:.1f} > {LIMITS[k]}" for k in new))
if cleared:
send("ok again: " + ", ".join(cleared))
STATE.write_text(json.dumps(bad))
def shutdown():
send("shutting down (graceful) ...")
subprocess.run(["/usr/local/sbin/shutdown-graceful.sh"], check=True, timeout=300)
if __name__ == "__main__":
cmd = sys.argv[1] if len(sys.argv) > 1 else "status"
{"status": lambda: send(status()), "alert": alert, "shutdown": shutdown}[cmd]()
ลองด้วยมือก่อนตั้งเวลา
# บนเซิร์ฟเวอร์ รันเป็น root
chmod 755 /usr/local/bin/monitor.py
. /etc/monitor.env && monitor.py status
ถ้าข้อความเข้ามือถือ แปลว่า token, chat id และเครือข่ายใช้ได้ครบ
จุดที่ตัดสินใจไว้ในโค้ด
| ค่า | อ่านจาก | หมายเหตุ |
|---|---|---|
| load | /proc/loadavg ตัวแรก | ใช้ load แทน CPU % เพราะไม่ต้องรอ sample 1 วินาที และบอกคิวงานได้ตรงกว่า |
| ram | /proc/meminfo | ต้องใช้ MemAvailable ถ้าใช้ MemFree จะเตือนทั้งวัน เพราะ Linux เอาแรมว่างไปทำ cache เสมอ |
| disk | shutil.disk_usage("/") | เครื่องที่มีดิสก์ข้อมูลแยก (เช่น /mnt/data) เพิ่มอีกบรรทัด |
| temp | /sys/class/thermal/thermal_zone0/temp | หน่วยเป็นพันเท่าขององศา zone0 อาจไม่ใช่ CPU ดูด้วย cat /sys/class/thermal/thermal_zone*/type ถ้าไม่มีให้ apt install lm-sensors แล้วใช้ sensors -j |
ของจริงที่บ้านอ่าน CPU % จาก /proc/stat สองครั้งห่างกัน 1 วินาที อ่านอุณหภูมิ GPU จาก nvidia-smi และนับ container ที่ไม่ได้ running จาก docker ps -a ด้วย โครงเดียวกัน แค่เพิ่มฟังก์ชันอ่านค่า
เลือกเกณฑ์
| ค่า | เกณฑ์ | เหตุผล |
|---|---|---|
| load (1 นาที) | 2 × จำนวนคอร์ | load เท่าจำนวนคอร์คือเต็มพอดี เกินสองเท่าคืองานต่อคิวจริง |
| ram | 85% | เหลือที่ให้ spike ก่อน OOM killer ทำงาน |
| disk | 90% | ที่ 90% ยังมีเวลาลบ log เป็นวัน ที่ 98% มักเหลือเป็นนาที |
| CPU temp | 85°C | เครื่องที่บ้านไม่มีแอร์ ชิปส่วนใหญ่เริ่มลดความเร็วแถว 90-100°C |
ลองคิดจากของจริง ดิสก์ 877 GB ที่ 90% เหลือ 87 GB ถ้า log โตวันละ 2 GB ยังมีเวลา 40 กว่าวัน แต่ดิสก์ 25 GB ของ VPS ที่ 90% เหลือ 2.5 GB โตวันละ 2 GB เหลือเวลาแค่วันเดียว เครื่องเล็กควรตั้งเกณฑ์ดิสก์ต่ำกว่า เช่น 80%
ตั้งเวลา: เตือนทุก 5 นาที สรุปวันละครั้ง
# /etc/cron.d/monitor (root เพราะ shutdown ต้องการ)
*/5 * * * * root . /etc/monitor.env && /usr/local/bin/monitor.py alert
0 8 * * * root . /etc/monitor.env && /usr/local/bin/monitor.py status
0 18 * * * root . /etc/monitor.env && /usr/local/bin/monitor.py shutdown
หัวใจของโหมด alert คือไฟล์ alerts.json ซึ่งจำว่า ค่าไหนเกินเกณฑ์อยู่แล้ว รอบถัดไปจะส่งเฉพาะค่าที่เพิ่งเกินใหม่ และส่ง “ok again” เมื่อค่าใดกลับมาปกติ ถ้าไม่มีไฟล์นี้ ดิสก์เต็มหนึ่งครั้งจะได้ข้อความเดิม 288 ครั้งต่อวัน แล้วเราก็จะปิดแจ้งเตือนของบอท ซึ่งเท่ากับไม่มี monitoring
สรุปวันละครั้งมีประโยชน์กว่าที่คิด มันคือหลักฐานว่าบอทยังมีชีวิต ถ้าเช้าไหนไม่มีข้อความ 8 โมง นั่นคือ alert ในตัว
ปิดเครื่องอย่างนุ่มนวล
เครื่องที่บ้านของเราตั้ง cron ให้ปิดตัวเองทุกวันราว 18:00 แล้วเปิดใหม่ด้วยมือเมื่อเริ่มงาน เพื่อให้แรม log และ container ที่ค้างได้เริ่มใหม่ จุดที่ต้องระวังคือ ห้ามปิดทั้งที่ฐานข้อมูลกำลังเขียน shutdown -h now ตรง ๆ จะส่ง SIGTERM ให้ทุก container พร้อมกัน แอปอาจยังเขียนเข้าฐานข้อมูลที่กำลังปิดอยู่
#!/bin/bash
# /usr/local/sbin/shutdown-graceful.sh (chmod 700, root)
set -e
cd /srv/app
docker compose stop -t 30 app worker # ตัวที่สร้างงานหยุดก่อน
docker compose stop -t 60 redis db # ที่เก็บข้อมูลหยุดทีหลัง
sync # ไล่ buffer ลงดิสก์
shutdown -h now
ลำดับคือ หยุดตัวที่ สร้าง งานก่อน แล้วค่อยหยุดตัวที่ เก็บ งาน -t คือเวลาที่ให้ container ปิดเอง ก่อน Docker จะฆ่าทิ้ง ฐานข้อมูลควรได้เวลามากกว่าแอป sync บังคับให้ข้อมูลที่ยังอยู่ใน buffer ของ kernel ลงดิสก์จริง
สคริปต์ Python ส่งข้อความ “shutting down” ก่อน เรียกสคริปต์นี้ เพราะหลังจากนั้นจะไม่มีเครือข่ายให้ส่ง ข้อความนี้ยังช่วยแยกเหตุการณ์ได้ด้วย ถ้าเครื่องหายไปเฉย ๆ โดยไม่มีข้อความนี้นำมา คือไฟดับหรือค้าง ไม่ใช่ปิดตามเวลา
สั่งปิดเองได้ทุกเมื่อด้วย monitor.py shutdown ผ่าน SSH ไม่ต้องรอเวลา
สิ่งที่ไม่ควรทำ
- ไม่รับคำสั่งจาก Telegram กลับมาที่เครื่อง โดยไม่มีการยืนยันตัวตน การเรียก
getUpdatesแล้วทำตามข้อความที่ได้ แปลว่าใครก็ตามที่หา username บอทเจอจะสั่งเครื่องได้ ถ้าจะทำจริง ต้องตรวจfrom.idกับรายชื่อที่อนุญาตทุกข้อความ และยังต้องคิดเรื่องข้อความซ้ำที่getUpdatesส่งมาอีกรอบ - ไม่ส่งรัว Telegram บอกว่าบอทไม่ควรส่งเกินประมาณ 1 ข้อความต่อวินาทีต่อแชท และในกลุ่มไม่เกิน 20 ข้อความต่อนาที เกินแล้วจะได้ HTTP 429 state file ข้างบนทำให้เราไม่มีทางเข้าใกล้เลขนี้
- ไม่ใส่ข้อมูลภายในลงข้อความ IP ภายใน ชื่อผู้ใช้ path ของ secret ข้อความ Telegram อยู่บนเซิร์ฟเวอร์ของคนอื่นและค้างอยู่ในมือถือ ส่งแค่ตัวเลขที่ต้องตัดสินใจ
- ไม่ให้ cron รันโดยไม่มี timeout
urlopen(..., timeout=10)สำคัญ ถ้าเครือข่ายค้าง สคริปต์ที่แขวนทุก 5 นาทีจะกองกันเอง
ข้อผิดพลาดที่เจอบ่อย
- ใช้
MemFreeแทนMemAvailableแล้วได้เตือนแรมทั้งวัน - token อยู่ใน
config.jsonที่git add .ติดไปด้วย - cron ไม่เห็นตัวแปร env เพราะลืม
. /etc/monitor.envหน้าคำสั่ง thermal_zone0เป็นเซนเซอร์ของเมนบอร์ด ไม่ใช่ CPU เลยไม่เคยเตือน- shutdown ตรง ๆ แล้วเช้ามาฐานข้อมูลเข้าโหมดกู้ข้อมูล
แหล่งอ้างอิง
- Telegram Bot API —
sendMessage,getUpdates - Telegram Bots FAQ — ข้อจำกัดความถี่การส่ง
- From BotFather to Hello World — สร้างบอทและรับ token
- proc_meminfo(5) — ความหมายของ
MemAvailable
คำถามที่พบบ่อย
ทำไมไม่ใช้ Grafana หรือ Uptime Kuma
เครื่องที่ดูแลอยู่มีไม่กี่เครื่อง และคำถามที่อยากรู้มีแค่ "ตอนนี้โอเคไหม" กับ "มีอะไรเกินเกณฑ์ไหม" สคริปต์ 60 บรรทัดตอบได้ครบ ไม่ต้องเลี้ยงระบบ monitoring อีกตัวบนเครื่องเล็ก ถ้าเครื่องมีสิบเครื่องขึ้นไปค่อยคิดใหม่
สั่งเครื่องกลับจาก Telegram ได้ไหม เช่นพิมพ์ /reboot
ทำได้ แต่ไม่แนะนำสำหรับคนเริ่มต้น เพราะทุกคนที่คุยกับบอทได้จะสั่งเครื่องได้ ต้องตรวจ chat id ของผู้ส่งทุกครั้ง มี allowlist และคิดเรื่องคำสั่งซ้ำ บทความนี้ให้บอทพูดทางเดียว ส่วนสั่งงานใช้ SSH ซึ่งมีการยืนยันตัวตนอยู่แล้ว
คำสั่งและค่าตั้งในบทความทดสอบบนระบบตัวอย่าง ลองบนเครื่องทดสอบก่อนใช้กับระบบจริงเสมอ