บทความ › การเขียนโปรแกรม

ผ่านในเครื่อง พังบน production: สามเรื่องจริง และวิธีให้เครื่องเรา = เครื่องจริง

library ที่มีแค่ในเครื่องเรา library ที่ติดมาเองแล้วเปลี่ยนผลเงียบ ๆ ค่าอ้างอิงที่บันทึกผิดเครื่อง สามบั๊กจริง และวิธีกันด้วย image เดียว ตรึงเวอร์ชัน และเทสต์สภาพแวดล้อม

กลาง อ่าน 6 นาที 16 ตุลาคม 2569

อ่านจบแล้วจะรู้
  • เครื่องนักพัฒนามักมีของมากกว่าเครื่องจริง โค้ดจึงผ่านในเครื่องแล้วไปพังบน production
  • เทสต์ต้องรันใน image เดียวกับที่ขึ้นจริง และตรึงเวอร์ชัน library ที่มีผลต่อผลลัพธ์
  • เขียนเทสต์ที่ตรวจสภาพแวดล้อมเอง เช่น library ที่ต้องไม่มี หรือฟอนต์ที่ต้องมี แทนที่จะเชื่อว่าเหมือนกัน

ประโยค “ในเครื่องผมใช้ได้นะ” มีมานานเท่ากับการเขียนโปรแกรม และยังเกิดขึ้นทุกวัน เพราะเครื่องนักพัฒนาไม่เคยเหมือนเครื่องจริง มี library ที่ลงไว้นานแล้วลืม เวอร์ชันต่างกัน ระบบปฏิบัติการคนละตัว บทความนี้เล่าสามบั๊กจริงจาก Forge เว็บออกแบบชิ้นงานพิมพ์ 3D ที่เราทำ ซึ่งผู้ใช้เข้ามาสร้างโมเดลแบบปรับค่าได้ในเบราว์เซอร์ ทั้งสามเรื่องมีรากเดียวกัน และแก้ด้วยหลักการเดียวกัน

Forge เขียนด้วย Python 3.11 ใช้ trimesh, shapely และ manifold3d สร้างรูปทรง รันใน Docker image python:3.11-slim

เรื่องที่ 1: library ที่มีแค่ในเครื่องเรา

trimesh มีฟังก์ชันหลายตัวที่เรียกใช้ library เสริมโดยไม่บอก เช่น ตัดรูปทรงแล้วปิดหน้าตัด (slice_plane แบบ cap=True) ต้องใช้ library ชื่อ rtree อยู่เบื้องหลัง

เครื่องนักพัฒนามี rtree ติดตั้งไว้นานแล้ว จากงานอื่น เทสต์ทุกตัวผ่าน แต่ image ที่ขึ้นจริงไม่มี พอขึ้นระบบ แบบกล่องที่เป็นรูปตัวอักษรทั้งคำก็พังให้ผู้ใช้ทุกคนด้วย

ModuleNotFoundError: No module named 'rtree'

ไม่มีใครเห็นตอนเทสต์ เพราะเทสต์รันในเครื่องที่มีของครบ

แก้ยังไง เราเลิกใช้ฟังก์ชันที่พึ่ง rtree ในโค้ดที่ขึ้นจริงทั้งหมด เปลี่ยนไปตัดรูปทรงด้วยการลบกล่องออก (boolean ของ manifold3d) แทน และเขียนรายการฟังก์ชันต้องห้ามไว้ในคู่มือของโปรเจกต์ ให้ทั้งคนและ AI ที่ช่วยเขียนโค้ดเห็นทุกครั้ง

เรื่องที่ 2: library ที่ติดมาเองแล้วเปลี่ยนผลแบบเงียบ ๆ

เรื่องนี้น่ากลัวกว่า เพราะไม่มี error เลย

trimesh มีวิธีแบ่งรูปหลายเหลี่ยมเป็นสามเหลี่ยมหลายแบบ ถ้าเครื่องมี library ชื่อ mapbox_earcut มันจะเลือกใช้ตัวนั้นเอง ซึ่งให้ผลต่างออกไปกับรูปทรงบางแบบ ในกรณีของเรา ตัวสร้างปุ่มคีย์บอร์ดคืนค่าว่าง แล้วระบบก็ถอยไปใช้รูปทรงพื้นฐาน ปุ่มทุกปุ่มออกมาเป็นแท่งแบน ๆ แทนที่จะเป็นรูปที่ออกแบบไว้

ไม่มี exception ไม่มี log เตือน ได้แค่ชิ้นงานผิดรูป

ทำไม library นี้ถึงมาอยู่ในเครื่อง เราไม่ได้สั่งติดตั้ง มันติดมากับ library อื่นในฐานะส่วนเสริม (transitive dependency) แปลว่าแค่ build image ใหม่ในวันที่ library อื่นปรับเวอร์ชัน ผลลัพธ์ของเว็บก็เปลี่ยนได้โดยไม่มีใครแก้โค้ดสักบรรทัด

แก้ยังไง ตรึงเวอร์ชันของทุก library ที่มีผลต่อรูปทรง

numpy==2.4.6
scipy==1.17.1
shapely==2.1.2
trimesh==5.0.0
manifold3d==3.5.2

แล้วเขียนเทสต์ที่ ยืนยันว่าสิ่งที่ต้องไม่มี ไม่มีจริง

import importlib.util

def test_forbidden_libraries_absent():
    for name in ["rtree", "embree", "mapbox_earcut"]:
        assert importlib.util.find_spec(name) is None, f"{name} must not be installed"

เทสต์นี้รันใน image ที่ขึ้นจริง ถ้าวันไหนมี library ต้องห้ามติดเข้ามา เทสต์จะแดงก่อนถึงผู้ใช้

เรื่องที่ 3: ค่าอ้างอิงที่บันทึกผิดเครื่อง

เพื่อจับว่ารูปทรงเปลี่ยนไปหรือไม่ เราเก็บ “ลายนิ้วมือ” ของแต่ละแบบ เช่น ปริมาตรถึงทศนิยม 6 ตำแหน่ง แล้วเทียบทุกครั้งที่แก้โค้ด

วันหนึ่งเทสต์แดงทั้ง 11 แถว ดูเหมือนรูปทรงเปลี่ยนหมด แต่จริง ๆ ไม่มีอะไรเปลี่ยน ค่าอ้างอิงชุดนั้นถูกบันทึกใน virtualenv ของผู้ช่วย AI ที่ใช้ library คนละเวอร์ชันกับ image จริง ตัวเลขจึงต่างกันตั้งแต่ต้น

บทเรียนคือ ค่าอ้างอิงก็ต้องมาจากสภาพแวดล้อมเดียวกับที่ตรวจ ไม่งั้นเทสต์จะเตือนเรื่องที่ไม่ได้เกิด จนคนเลิกเชื่อเทสต์ ซึ่งอันตรายกว่าไม่มีเทสต์

โบนัส: ฟอนต์ที่ “มีอยู่แล้ว”

Forge ใส่ตัวอักษรบนชิ้นงานได้ ฟอนต์ค่าเริ่มต้นมาจากไฟล์ที่ติดมากับ matplotlib ถ้าวันหนึ่ง build image แบบตัดไฟล์ข้อมูลของ matplotlib ออก หรือ matplotlib รุ่นใหม่เลิกแถมฟอนต์ ตัวอักษรทุกตัวจะกลายเป็นกล่องสี่เหลี่ยม

เทสต์ของเราจึงไม่เชื่อแค่เลขเวอร์ชัน แต่ วัดตัวอักษรจริง สร้างตัวอักษรหนึ่งตัวแล้วตรวจว่าพื้นที่ได้ค่าที่ควรเป็น

เครื่องนักพัฒนา✓ trimesh 5.0.0✓ rtree✓ mapbox_earcutเทสต์ผ่านproduction image✓ trimesh 5.0.0✗ rtree✗ mapbox_earcutล่ม / ผลเพี้ยนimage เดียว: build → เทสต์ → ขึ้นจริง
เครื่องเรามีของมากกว่าเครื่องจริง เทสต์ผ่านจึงไม่ได้แปลว่าใช้ได้จริง จนกว่าจะเทสต์ใน image ที่ขึ้นจริง

หลักการ: สิ่งที่เทสต์ต้องเป็นสิ่งเดียวกับที่รัน

ถ้าเทสต์รันในที่หนึ่ง แล้วโค้ดไปรันอีกที่หนึ่ง เทสต์ผ่านก็บอกได้แค่ว่ามันใช้ได้ในที่แรก

จากสามเรื่อง สรุปเป็นข้อปฏิบัติได้แบบนี้

  1. image เดียวทั้งเทสต์และขึ้นจริง build ครั้งเดียว แล้วใช้ image นั้นรันเทสต์ ถ้าผ่าน ก็ขึ้น image ตัวเดียวกันนั้น ไม่ build ใหม่ระหว่างทาง

    docker build -f apps/forge/Dockerfile -t forge:candidate kura3d/
    docker run --rm forge:candidate python -m pytest tests/
  2. ตรึงเวอร์ชันสิ่งที่มีผลต่อผลลัพธ์ ทั้ง library และ base image ใช้ tag ที่ระบุเวอร์ชัน เช่น python:3.11-slim แทน latest ซึ่งเปลี่ยนเองได้ทุกเมื่อ

  3. เทสต์สภาพแวดล้อม ไม่ใช่แค่โค้ด ตรวจว่าของที่ต้องไม่มี ไม่มี ของที่ต้องมี มีจริงและทำงานได้

  4. ค่าอ้างอิงบันทึกจาก image จริง เมื่อปรับเวอร์ชัน ให้บันทึกค่าใหม่จาก image ใหม่ ไม่ใช่จากเครื่องใครคนหนึ่ง

  5. ตรวจหลังขึ้นระบบด้วยคำขอจริง หลัง deploy ให้สร้างชิ้นงานหนึ่งชิ้นผ่านเว็บจริง ไม่ใช่แค่เช็กว่าหน้าเว็บตอบ 200

ความต่างที่ไม่ใช่ library: ระบบปฏิบัติการ

เรื่องเดียวกันเกิดได้กับระบบปฏิบัติการด้วย อีกโปรเจกต์ของเราคุมเครื่องพิมพ์ 3D ผ่าน MQTT บน Windows library MQTT ตัวที่ใช้ต้องการ event loop ของ Python แบบหนึ่ง แต่เซิร์ฟเวอร์ต้องใช้อีกแบบเพื่อรันโปรแกรมย่อย สองแบบอยู่ในโปรแกรมเดียวกันบน Windows ไม่ได้ การเชื่อมต่อจึงหมดเวลาทุกครั้ง ทั้งที่รหัสผ่านและเครือข่ายถูกหมด โค้ดเดียวกันบน Linux ไม่มีปัญหานี้ (เล่าละเอียดในบทความเรื่อง ต่อเครื่องพิมพ์ 3D ด้วย MQTT)

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

  • รันเทสต์ใน virtualenv บนเครื่องตัวเอง แล้วเรียกว่า “ผ่าน”
  • build image ใหม่หลังเทสต์ผ่าน แล้วขึ้น image ใหม่ที่ไม่ได้เทสต์
  • ใช้ tag latest กับ base image หรือ image ของฐานข้อมูล
  • ไม่ตรึงเวอร์ชันเพราะ “ตอนนี้ใช้ได้อยู่”
  • ตรวจหลัง deploy แค่ว่าเว็บเปิดได้ ไม่ได้ลองฟีเจอร์หลักสักครั้ง

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

ใช้ Docker อยู่แล้ว ยังเจอปัญหานี้ได้ไหม

ได้ ถ้าเทสต์รันใน virtualenv บนเครื่องเรา ไม่ใช่ใน image ที่ขึ้นจริง หรือถ้า build image ใหม่แล้ว library ที่ไม่ได้ตรึงเวอร์ชันขยับไปเอง Docker ช่วยให้เหมือนกันได้ แต่ต้องใช้ image นั้นจริงตอนเทสต์

ตรึงเวอร์ชันทุกตัวเลยไหม

ตัวที่มีผลต่อผลลัพธ์ควรตรึงแน่นอน เช่น library คำนวณ ตัวที่เป็นแค่ทางผ่าน เช่น web framework อาจตรึงหลวมกว่าได้ แต่ทุกครั้งที่ปรับเวอร์ชัน ให้รันเทสต์ใน image ใหม่ก่อนขึ้นจริง

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

อ่านต่อ

#โค้ดกลาง

Go ใน Docker: GOMAXPROCS ตาม CPU limit (Go 1.25) และ Green Tea GC (Go 1.26)

Go 1.25 ตั้ง GOMAXPROCS ตาม CPU limit ของ container เอง Go 1.26 เปิด Green Tea GC เป็นค่าเริ่มต้น สิ่งที่ต้องเช็กใน Dockerfile และ go.mod พร้อมผลวัดจริง

อ่าน 9 นาที
#โค้ดกลาง

Python 3.15: lazy import, frozendict และ UTF-8 ค่าเริ่มต้น แก้ไฟล์ไทยบน Windows

Python 3.15 ออกแล้ว lazy import ทำให้ CLI เปิดเร็วขึ้นหลายเท่า frozendict ใช้เป็น key ได้ และ open() อ่านเขียน UTF-8 เสมอ ไฟล์ภาษาไทยบน Windows ไม่เพี้ยนอีก

อ่าน 12 นาที
#โค้ดเริ่มต้น

เก็บวันเวลาให้ถูก: เก็บเป็น UTC เก็บ timezone ผู้ใช้ แล้วค่อยแปลงตอนแสดง

เวลาเพี้ยน 7 ชั่วโมง วันเกิดเลื่อน นัดหมายเคลื่อนตอน DST แก้ด้วยกฎสามข้อ ใช้ timestamptz ใน Postgres, zoneinfo ใน Python, Temporal ใน JS และแปลงเป็น พ.ศ. ตอนแสดงเท่านั้น

อ่าน 13 นาที