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

ให้ เซิร์ฟเวอร์ รายงานตัวทาง Telegram สถานะ เตือนภัย และปิดเครื่องอย่างนุ่มนวล

สคริปต์ Python ไม่กี่สิบบรรทัดบนเครื่องเล็ก ส่งสถานะ CPU แรม ดิสก์ เข้า Telegram เตือนเมื่อเกินเกณฑ์โดยไม่เตือนซ้ำ และปิดเครื่องตามเวลาแบบหยุด container ตามลำดับ

เริ่มต้น อ่าน 15 นาที 21 ตุลาคม 2569

อ่านจบแล้วจะรู้
  • 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 ใด ๆ
  • ทางเดียวก็พอ เราให้บอทพูดอย่างเดียว ไม่รับคำสั่ง ลดเรื่องความปลอดภัยไปได้มาก (ดูท้ายบท)
cron ทุก 5 นาทีอ่าน CPU RAM ดิสก์ อุณหภูมิเกินเกณฑ์ และยังไม่เคยเตือน?จำสถานะไว้ในไฟล์ ไม่สแปมส่งข้อความหา Telegram botHTTPS POST หนึ่งครั้งปิดเครื่องแบบนุ่มนวล: หยุด container → sync → shutdownแล้วบอก Telegram ก่อนดับ
แจ้งครั้งเดียวต่อปัญหา จนกว่าจะกลับมาปกติ

สร้างบอทและหา chat id

ทำครั้งเดียวในแอป Telegram

  1. ทักบอทชื่อ BotFather พิมพ์ /newbot ตั้งชื่อและ username (ต้องลงท้ายด้วย bot) จะได้ token หนึ่งบรรทัด หน้าตาเป็นตัวเลข ตามด้วย : ตามด้วยตัวอักษรยาว ๆ
  2. ทักบอทของตัวเอง กด Start แล้วพิมพ์อะไรก็ได้หนึ่งข้อความ (บอทเริ่มคุยก่อนไม่ได้ ต้องให้เราทักก่อน)
  3. บนเครื่องของเรา เรียก 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 เสมอ
diskshutil.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 เท่าจำนวนคอร์คือเต็มพอดี เกินสองเท่าคืองานต่อคิวจริง
ram85%เหลือที่ให้ spike ก่อน OOM killer ทำงาน
disk90%ที่ 90% ยังมีเวลาลบ log เป็นวัน ที่ 98% มักเหลือเป็นนาที
CPU temp85°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 ตรง ๆ แล้วเช้ามาฐานข้อมูลเข้าโหมดกู้ข้อมูล

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

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

ทำไมไม่ใช้ Grafana หรือ Uptime Kuma

เครื่องที่ดูแลอยู่มีไม่กี่เครื่อง และคำถามที่อยากรู้มีแค่ "ตอนนี้โอเคไหม" กับ "มีอะไรเกินเกณฑ์ไหม" สคริปต์ 60 บรรทัดตอบได้ครบ ไม่ต้องเลี้ยงระบบ monitoring อีกตัวบนเครื่องเล็ก ถ้าเครื่องมีสิบเครื่องขึ้นไปค่อยคิดใหม่

สั่งเครื่องกลับจาก Telegram ได้ไหม เช่นพิมพ์ /reboot

ทำได้ แต่ไม่แนะนำสำหรับคนเริ่มต้น เพราะทุกคนที่คุยกับบอทได้จะสั่งเครื่องได้ ต้องตรวจ chat id ของผู้ส่งทุกครั้ง มี allowlist และคิดเรื่องคำสั่งซ้ำ บทความนี้ให้บอทพูดทางเดียว ส่วนสั่งงานใช้ SSH ซึ่งมีการยืนยันตัวตนอยู่แล้ว

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

อ่านต่อ

#Serverกลาง

รันฐานข้อมูล cache แอป และ HTTPS บนเครื่อง 2GB ให้รอด ตั้งเพดานทุกอย่างที่โตได้

เครื่องเล็กไม่ได้พังเพราะเล็ก แต่พังเพราะมีอะไรสักอย่างโตไม่มีเพดาน ค่าตั้ง PostgreSQL Redis log ของ Docker และ swap ที่ใช้จริงบนเครื่อง 2GB พร้อมเหตุผลของแต่ละตัว

อ่าน 8 นาที
#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 นาที