ระบบเฝ้าระวังที่ยิงถี่เกิน จนล้มอุปกรณ์ทั้งฟาร์มเอง
ระบบเฝ้าระวังที่เราเขียนเองมีสิทธิ์เป็นต้นเหตุของเหตุขัดข้องพอ ๆ กับสิ่งที่มันเฝ้าอยู่ และมันอันตรายกว่าตรงที่ไม่มีใครสงสัยมันเลย เพราะทุกคนคิดว่ามันเป็นฝ่ายมาช่วยดับไฟ ไม่ใช่ฝ่ายจุดไฟ
พอดีรับดูแลฟาร์มเกตเวย์ชุดหนึ่งต่อจากคนเดิม เป็นเครื่องโฮสต์ตัวเดียวที่มีอุปกรณ์เชื่อมต่อผ่านพอร์ตอนุกรมแบบยูเอสบีเสียบอยู่หลายตัว แต่ละตัวทำหน้าที่เป็นทางออกของงานคนละชุด อาการที่เรียกผมเข้าไปคือ "ช่วงสาย ๆ อุปกรณ์หลุดพร้อมกันหมดทุกตัว แล้วเดี๋ยวก็กลับมาเอง" เกิดวันละสองสามรอบ ทีมเดิมสรุปว่าฮับเสีย เปลี่ยนไปสองตัวแล้วยังเป็นเหมือนเดิม พอไล่ดูจริง ๆ ต้นเหตุไม่ได้อยู่ที่ฮาร์ดแวร์เลย มันคือตัวเฝ้าระวังที่ทีมเดิมเขียนไว้เอง
ชื่อโฮสต์ เลข IP และชื่อบริการในบทความเป็นค่าตัวอย่าง ส่วนคำสั่ง ข้อความจากเคอร์เนล และค่าที่แก้แล้วหาย เป็นของจริง
อาการ: อุปกรณ์ตัวเดียวค้าง แล้วล้มทั้งฟาร์ม
สิ่งแรกที่ต้องทำคือเลิกฟังคำสรุปของคนก่อนหน้า แล้วไปดูช่วงเวลาจริงว่าอะไรเกิดก่อนอะไร ผมดึงบันทึกของเคอร์เนลช่วงที่เกิดเหตุออกมาเรียงตามเวลา แล้วภาพก็ชัดขึ้นทันที
# ดูเฉพาะช่วงเหตุการณ์ ไม่ใช่ไล่ทั้งวัน
journalctl -k --since "2026-11-02 09:40" --until "2026-11-02 09:50" -o short-precise \
| grep -Ei 'usb|tty|disconnect|reset'
สิ่งที่เห็นคืออุปกรณ์ตัวหนึ่งหลุดก่อนเพื่อนราวสี่สิบวินาที จากนั้นมีการรีเซ็ตพอร์ตซ้ำ ๆ ถี่มาก แล้วอีกสักพักตัวที่เหลือถึงเริ่มทยอยหลุดตาม สุดท้ายบัสทั้งชุดล้มพร้อมกัน
usb 1-1.3: USB disconnect, device number 14
usb 1-1.3: new full-speed USB device number 15 using ehci-pci
usb 1-1.3: device descriptor read/64, error -110
usb 1-1.3: new full-speed USB device number 16 using ehci-pci
usb 1-1-port3: Cannot enable. Maybe the USB cable is bad?
hub 1-1:1.0: cannot reset port 3 (err = -32)
ลำดับแบบนี้อ่านได้อย่างเดียวคือมีคนสั่งรีเซ็ตซ้ำตอนที่ตัวเดิมยังลงทะเบียนตัวเองไม่เสร็จ ไม่ใช่อาการของสายหลวมที่ปกติจะหลุด ๆ ติด ๆ แบบไม่มีจังหวะ อันนี้มีจังหวะชัดเจนมาก และจังหวะนั้นคือทุกสามสิบวินาทีพอดี
ฮาร์ดแวร์ที่เสียจริงมักจะพังแบบไม่มีแบบแผน แต่ถ้าเปิดบันทึกแล้วเห็นเหตุการณ์เดิมซ้ำทุก 30 วินาทีหรือทุก 1 นาทีเป๊ะ ๆ ให้ไปหาตัวที่รันตามรอบก่อนเสมอ ไม่ว่าจะเป็น cron ตัวจับเวลาของ systemd หรือลูปในบริการที่เราเขียนเอง
ตัดสมมติฐานฮาร์ดแวร์ให้จบก่อน
ถึงจะเดาได้แล้วว่าเป็นซอฟต์แวร์ ผมก็ยังตัดเรื่องฮาร์ดแวร์ให้จบก่อนอยู่ดี เพราะถ้าฮับจ่ายไฟไม่พอจริง ต่อให้แก้สคริปต์ดีแค่ไหนก็ยังพัง ตัวเลขที่ต้องดูคือกำลังไฟที่ฮับประกาศว่าจ่ายได้ เทียบกับที่อุปกรณ์ทุกตัวรวมกันขอ
lsusb -t # ดูผังว่าตัวไหนห้อยอยู่ใต้ฮับตัวไหน
lsusb -v 2>/dev/null | grep -E 'MaxPower|bNumInterfaces|iProduct' | head -40
ผลที่ได้คือฮับตัวนั้นรายงานค่ากำลังไฟที่จ่ายได้เป็นศูนย์ ซึ่งแปลว่ามันเป็นฮับที่ดึงไฟจากพอร์ตต้นทางอย่างเดียว ไม่มีอะแดปเตอร์แยก ส่วนอุปกรณ์แต่ละตัวขอมาตัวละหลายร้อยมิลลิแอมป์ รวมแล้วเกินที่พอร์ตเดียวจะจ่ายไหวแน่นอน อันนี้เป็นปัญหาจริงที่ต้องแก้ แต่ยังไม่ใช่ต้นเหตุของอาการที่เกิดเป็นจังหวะ
ฮับเสีย เปลี่ยนฮับ
เปลี่ยนไปสองตัวแล้วอาการเหมือนเดิม เพราะซื้อรุ่นที่ดึงไฟจากพอร์ตต้นทางเหมือนกันทั้งสองครั้ง และไม่ได้แตะต้นเหตุจริงที่อยู่ในสคริปต์เลย เสียทั้งเงินและเวลาไปสองสัปดาห์
แก้ทั้งสองชั้น
เปลี่ยนเป็นฮับที่มีไฟเลี้ยงแยกเพื่อให้กำลังไฟพอจริง แล้วแก้ลูปที่กระแทกอุปกรณ์ซ้ำ ซึ่งเป็นตัวที่ทำให้อาการลุกลามจากตัวเดียวไปทั้งบัส สองข้อนี้แยกกันคนละเรื่อง ต้องทำทั้งคู่
ทำตัวจับว่าใครแตะอุปกรณ์บ้าง
พอสงสัยว่ามีคนสั่งรีเซ็ต คำถามถัดมาคือใคร บนเครื่องที่รับมาต่อจากคนอื่นมักมีของรันอยู่มากกว่าที่เอกสารบอก ผมเลยเขียนตัวจับง่าย ๆ ถ่ายภาพนิ่งทุกห้าวินาทีว่าตอนนั้นมีโปรเซสไหนเปิดไฟล์อุปกรณ์อยู่บ้าง และรายชื่ออุปกรณ์ที่ระบบมองเห็นเปลี่ยนไปหรือยัง
#!/usr/bin/env python3
"""flap-detector: ถ่ายภาพนิ่งทุก 5 วิ ว่ามีใครแตะอุปกรณ์ และรายชื่อเปลี่ยนไหม"""
import glob, os, subprocess, time, json, datetime
def holders(path):
"""คืนรายชื่อโปรเซสที่เปิดไฟล์อุปกรณ์นี้ค้างไว้"""
p = subprocess.run(["fuser", "-v", path], capture_output=True, text=True)
out = []
for line in (p.stderr or "").splitlines()[1:]:
f = line.split()
if len(f) >= 3:
out.append("%s/%s" % (f[1], f[-1])) # pid/ชื่อคำสั่ง
return out
prev = None
while True:
devs = sorted(glob.glob("/dev/ttyUSB*"))
snap = {d: holders(d) for d in devs}
if snap != prev:
rec = dict(ts=datetime.datetime.now().isoformat(timespec="seconds"),
count=len(devs), snap=snap)
print(json.dumps(rec, ensure_ascii=False), flush=True)
prev = snap
time.sleep(5)
รันทิ้งไว้ครึ่งชั่วโมงก็ได้คำตอบ มีสองฝ่ายที่แตะอุปกรณ์ชุดเดียวกัน ฝ่ายแรกคือบริการเฝ้าระวังที่รันตามรอบ ฝ่ายที่สองคือปุ่มบนหน้าเว็บจัดการที่ทีมเดิมทำไว้ให้กดสั่งกู้อุปกรณ์ด้วยมือ และทั้งสองฝ่ายไม่รู้จักกันเลย ไม่มีล็อกอะไรกั้นไว้ระหว่างกัน
# ยืนยันอีกทางว่าใครถืออุปกรณ์อยู่ตอนนี้
sudo lsof /dev/ttyUSB* 2>/dev/null | awk 'NR==1 || $0 ~ /ttyUSB/'
systemctl list-timers --all | grep -i gw
ต้นเหตุ: ลูปที่ไม่มีคูลดาวน์ และไม่มีล็อก
พอไปเปิดสคริปต์เฝ้าระวังตัวเดิมดู ตรรกะมันตรงไปตรงมามาก และนั่นแหละคือปัญหา ทุกสามสิบวินาที วนอุปกรณ์ทุกตัว ตัวไหน ping ปลายทางไม่ผ่านก็สั่งกู้ทันที ไม่มีการนับว่าล้มเหลวติดกันกี่ครั้ง ไม่มีการจำว่าเพิ่งกู้ตัวนี้ไปเมื่อไหร่ และไม่มีการจำกัดว่ากู้พร้อมกันได้กี่ตัว
# ตรรกะของตัวเดิม ย่อมาแต่ใจความครบ
while True:
for dev in devices:
if not ping(dev["ip"], timeout=2):
usb_reset(dev["bus_path"]) # สั่งทันที ไม่มีเงื่อนไขอะไรเลย
time.sleep(30)
ปัญหาคือการกู้ด้วยการรีเซ็ตพอร์ตใช้เวลานานกว่าสามสิบวินาทีเสมอ อุปกรณ์ต้องหลุดออก ลงทะเบียนใหม่ ขอค่าเชื่อมต่อปลายทางใหม่ รวมแล้วเกินหนึ่งนาที พอรอบถัดไปมาถึงตอนที่ตัวนั้นยังลงทะเบียนไม่เสร็จ ping ก็ยังไม่ผ่านอยู่แล้ว สคริปต์เลยสั่งรีเซ็ตซ้ำ วนแบบนี้ไปเรื่อย ๆ ตัวที่ค้างไม่มีวันกลับมา และการรีเซ็ตถี่ ๆ บนบัสเดียวกันก็ลากตัวอื่นล้มตามไปด้วย
ที่น่ากลัวของเคสนี้คือ ยิ่งระบบพัง ตัวเฝ้าระวังยิ่งทำงานหนักขึ้น เพราะจำนวนอุปกรณ์ที่เข้าเงื่อนไขเพิ่มขึ้นเรื่อย ๆ มันเลยไม่ใช่แค่ไม่ช่วย แต่เป็นตัวเร่ง และไม่มีใครสงสัยมันเลยตลอดสองสัปดาห์ที่ผ่านมา เพราะทุกคนมองว่ามันเป็นฝ่ายมาช่วย
เขียนการกู้ใหม่: ทีละตัว มีคูลดาวน์ มีเพดาน
ค่าที่แก้แล้วอาการหายไปเลยมีสี่ตัว ผมเขียนใหม่ทั้งก้อนแต่ตรรกะหลักสั้นมาก หัวใจคือก่อนจะลงมือกู้ ต้องผ่านเงื่อนไขสี่ข้อให้ครบ
import json, os, time, subprocess
STATE = "/var/lib/gw-watchdog/state.json"
INTERVAL = 60 # รอบละ 60 วิ ยาวกว่าที่การกู้ใช้เวลาจริง
NEED_FAIL = 3 # ต้องล้มติดกัน 3 รอบก่อน ไม่ใช่พลาดครั้งเดียวแล้วลุย
COOLDOWN = 300 # กู้ตัวเดิมซ้ำได้เร็วสุดทุก 5 นาที
MAX_PER_HR = 6 # เพดานต่อตัวต่อชั่วโมง เกินแล้วหยุดและแจ้งคน
def load():
try:
return json.load(open(STATE))
except Exception:
return {}
def save(s):
os.makedirs(os.path.dirname(STATE), exist_ok=True)
json.dump(s, open(STATE, "w"))
def should_recover(dev, st, now):
d = st.setdefault(dev, {"fail": 0, "last": 0, "hits": []})
if d["fail"] < NEED_FAIL:
return False, "ยังล้มไม่ครบ %d รอบ" % NEED_FAIL
if now - d["last"] < COOLDOWN:
return False, "อยู่ในคูลดาวน์อีก %ds" % int(COOLDOWN - (now - d["last"]))
d["hits"] = [t for t in d["hits"] if now - t < 3600]
if len(d["hits"]) >= MAX_PER_HR:
return False, "ชนเพดาน %d ครั้ง/ชม. ต้องให้คนเข้ามาดู" % MAX_PER_HR
return True, "ok"
def tick(devices):
st, now = load(), time.time()
recovered = 0
for dev in devices:
d = st.setdefault(dev["id"], {"fail": 0, "last": 0, "hits": []})
if ping(dev["ip"], timeout=2):
d["fail"] = 0
continue
d["fail"] += 1
ok, why = should_recover(dev["id"], st, now)
if not ok:
log("skip %s: %s" % (dev["id"], why))
continue
if recovered >= 1: # ทีละตัวต่อรอบเท่านั้น
log("skip %s: รอบนี้กู้ไปแล้วหนึ่งตัว" % dev["id"])
continue
log("recover %s (fail=%d)" % (dev["id"], d["fail"]))
usb_reset(dev["bus_path"])
d["last"] = now
d["hits"].append(now)
d["fail"] = 0 # เริ่มนับใหม่ ให้โอกาสมันฟื้น
recovered += 1
save(st)
เงื่อนไขข้อที่ผมคิดว่าสำคัญที่สุดคือเพดานต่อชั่วโมง เพราะมันเปลี่ยนพฤติกรรมของระบบตอนเจอปัญหาที่แก้เองไม่ได้ ระบบเดิมจะพยายามไปเรื่อย ๆ ไม่มีวันหยุด ระบบใหม่จะพยายามหกครั้งแล้วยอมแพ้พร้อมส่งเสียงเรียกคน ซึ่งเป็นพฤติกรรมที่ถูกต้องกว่า อุปกรณ์ที่กู้หกครั้งใน 1 ชั่วโมงแล้วยังไม่กลับมา มันต้องการมือคน ไม่ใช่ครั้งที่เจ็ด
ปิดทางฝั่งเว็บด้วยล็อกตัวเดียวกัน
แก้ฝั่งบริการอย่างเดียวไม่พอ เพราะปุ่มบนหน้าเว็บยังยิงคำสั่งเดิมได้อยู่ และเจ้าหน้าที่ที่เห็นว่าอุปกรณ์หลุดก็จะกดรัว ๆ ตามสัญชาตญาณ ผมเลยบังคับให้ทุกเส้นทางที่จะแตะอุปกรณ์ต้องผ่านล็อกไฟล์ตัวเดียวกัน ใครถืออยู่คนอื่นต้องรอหรือถอย
#!/bin/bash
# ตัวห่อเดียวที่ได้รับอนุญาตให้สั่งกู้ ทั้งบริการและหน้าเว็บต้องเรียกผ่านตัวนี้
exec 9>/var/lock/gw-recover.lock
if ! flock -n 9; then
echo '{"ok":false,"err":"มีการกู้อุปกรณ์อื่นอยู่ กรุณารอสักครู่"}'
exit 75
fi
/usr/local/bin/gw-recover.py "$@"
[Unit]
Description=Gateway watchdog
After=network-online.target
[Service]
Type=simple
ExecStart=/usr/local/bin/gw-watchdog.py
Restart=on-failure
RestartSec=30
# กันตัวเองพังแล้ววนรีสตาร์ตรัวจนกลายเป็นลูปกระแทกอีกแบบ
StartLimitIntervalSec=600
StartLimitBurst=5
[Install]
WantedBy=multi-user.target
ไม่ว่าจะเป็นบริการตามรอบ ปุ่มบนหน้าเว็บ สคริปต์ที่ช่างรันด้วยมือ หรืองานตามเวลาที่ตั้งไว้นานแล้วจนลืม ทั้งหมดต้องเรียกผ่านตัวห่อตัวเดียวที่ถือล็อก ถ้ามีทางไหนเหลืออยู่ที่เลี่ยงล็อกได้ วันหนึ่งมันจะถูกใช้ตอนที่เราไม่อยู่ แล้วเราจะกลับมาไล่หาสาเหตุเดิมอีกรอบ
ทำให้เห็นว่ามันทำอะไรไปบ้าง ก่อนจะเชื่อมัน
ของที่ขาดไปทั้งหมดในระบบเดิมคือร่องรอย มันสั่งกู้อุปกรณ์วันละหลายสิบครั้งโดยไม่มีใครรู้ ระบบใหม่ผมบังคับให้ทุกการตัดสินใจมีบรรทัดของตัวเอง ทั้งตอนที่ลงมือและตอนที่ตัดสินใจไม่ลงมือ บรรทัดที่บอกว่า "ข้ามเพราะอยู่ในคูลดาวน์" มีค่ามากกว่าบรรทัดที่บอกว่ากู้สำเร็จอีก เพราะมันคือหลักฐานว่ากลไกป้องกันทำงานอยู่จริง
journalctl -u gw-watchdog --since today | grep -c 'recover '
journalctl -u gw-watchdog --since today | grep 'skip ' | awk '{print $NF}' | sort | uniq -c
หลังแก้แล้วจำนวนครั้งที่สั่งกู้ต่อวันลดจากหลักหลายสิบเหลือหลักหน่วย และเหตุการณ์อุปกรณ์ล้มพร้อมกันทั้งชุดหายไปเลย ที่เหลือคือการหลุดของตัวเดี่ยว ๆ ที่กู้รอบเดียวก็กลับมา ซึ่งเป็นอาการปกติของอุปกรณ์ประเภทนี้อยู่แล้ว
กติกาที่ผมยึดกับ automation ที่แตะฮาร์ดแวร์
- รอบของลูปต้องยาวกว่าเวลาที่การกู้ใช้จริงเสมอ ถ้ากู้ใช้ 1 นาที ห้ามตั้งรอบไว้ 30 วินาที เพราะรอบถัดไปจะไปกระแทกของที่ยังทำงานค้างอยู่
- ต้องล้มเหลวติดกันหลายรอบก่อนถึงจะลงมือ พลาดครั้งเดียวไม่ใช่เหตุการณ์ขัดข้อง
- มีคูลดาวน์ต่ออุปกรณ์ และกู้ได้ทีละตัวต่อรอบ อย่าให้เหตุการณ์เดียวกลายเป็นการสั่งพร้อมกันทั้งฟาร์ม
- มีเพดานต่อชั่วโมง ชนเพดานแล้วให้หยุดและเรียกคน ไม่ใช่พยายามต่อไปเรื่อย ๆ
- ทุกเส้นทางที่แตะอุปกรณ์ต้องผ่านตัวห่อที่ถือล็อกร่วมกัน รวมถึงปุ่มบนหน้าเว็บและสคริปต์ที่รันด้วยมือ
- บันทึกทั้งตอนลงมือและตอนตัดสินใจไม่ลงมือ พร้อมเหตุผล ไม่งั้นเราจะไม่มีทางรู้ว่ามันทำอะไรไปบ้าง
- ตัดสมมติฐานฮาร์ดแวร์ให้จบคู่กันไปด้วย ไฟไม่พอกับลูปกระแทกเป็นคนละเรื่อง แก้ข้อเดียวไม่หาย
ส่วนตัวผมคิดว่าเครื่องมือเฝ้าระวังที่มีสิทธิ์สั่งการแก้ไขได้เอง ต้องถูกออกแบบด้วยความระมัดระวังมากกว่าตัวที่ได้แค่ดูแล้วแจ้ง เพราะตัวที่แจ้งอย่างเดียวพังมากที่สุดคือแจ้งผิด แต่ตัวที่สั่งการได้พังแล้วล้มระบบจริง กฎที่ผมใช้คือถ้าจะให้มันแก้เองได้ ต้องออกแบบให้มันยอมแพ้เป็นด้วย และต้องยอมแพ้เร็วกว่าที่จะสร้างความเสียหาย ใครจะเอาโครงนี้ไปใช้กับงานกู้บริการอื่นที่ไม่ใช่ฮาร์ดแวร์ เช่นรีสตาร์ตบริการที่ค้าง เงื่อนไขสี่ข้อชุดเดียวกันใช้ได้หมด เปลี่ยนแค่คำสั่งที่ใช้กู้
ที่ยังค้างในแผนของผมคือเก็บสถิติว่าอุปกรณ์ตัวไหนต้องกู้บ่อยกว่าเพื่อนในรอบสามสิบวัน เพื่อจะได้รู้ว่าตัวไหนใกล้ต้องเปลี่ยนจริง ไม่ใช่รอให้มันตายคาที่ กับลองย้ายการกู้ไปทำที่ระดับพอร์ตของฮับที่สั่งตัดไฟได้ทีละช่อง ซึ่งน่าจะสะอาดกว่าการรีเซ็ตที่ระดับบัส ตรงไหนผมอธิบายตกหล่นหรือพิมพ์ผิด เดี๋ยวกลับมาแก้เพิ่มให้ภายหลังครับ
อยากให้เราทำให้ไหม?
ให้เราช่วยติดตั้ง ตั้งค่า ย้ายระบบ หรือแก้ปัญหาเซิร์ฟเวอร์ เพื่อให้คุณมีเวลาไปทำงานที่สำคัญกว่า
เคยเจองานแบบนี้ไหม? ติดตรงไหน?
มาแลกวิธีกันต่อในบอร์ด LinuxThai



ความคิดเห็น 0
ยังไม่มีความคิดเห็น — เป็นคนแรกเลย