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

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

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

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

อ่านจบแล้วจะรู้
  • เครื่องเล็กพังเพราะมีบางอย่างโตไม่มีเพดาน ไม่ใช่เพราะเล็ก ให้ตั้งเพดานทุกอย่างที่โตได้
  • หน่วยความจำของ PostgreSQL ไม่ได้มีแค่ shared_buffers work_mem คูณกับจำนวนการทำงานพร้อมกัน
  • log ของ Docker ไม่มีเพดานโดยค่าเริ่มต้น และจะกินดิสก์จนเต็มเงียบ ๆ

ระบบบันทึกผลเทรดของเรารันทุกอย่างบนเครื่องเดียว แรม 2GB ซีพียู 2 คอร์ ฐานข้อมูล TimescaleDB, Redis, แอป Go และ Caddy สำหรับ HTTPS ใช้ docker compose ตัวเดียว เครื่องเล็กแบบนี้รอดได้ แต่ต้องเข้าใจว่ามันพังยังไง บทความนี้ไล่ค่าตั้งจริงทีละส่วน พร้อมเหตุผล

ทดสอบบน Ubuntu 24.04, Docker Compose v2, PostgreSQL 16 (TimescaleDB) และ Redis 7

เครื่องเล็กพังยังไง

เครื่องเล็กไม่ได้พังเพราะเล็ก แต่พังเพราะ มีอะไรสักอย่างโตไม่มีเพดาน แล้วไปเบียดตัวอื่น

  • ฐานข้อมูลรับ query หนักพร้อมกัน หน่วยความจำพุ่ง ระบบปฏิบัติการฆ่า process ทิ้ง (OOM killer) ซึ่งมักเป็น process ที่ใหญ่ที่สุด คือฐานข้อมูลนั่นเอง
  • cache เก็บของไปเรื่อย ๆ จนแรมหมด
  • log เขียนไปเรื่อย ๆ จนดิสก์เต็ม พอดิสก์เต็ม ฐานข้อมูลเขียนไม่ได้ ทุกอย่างหยุด

หลักการ: ทุกอย่างที่โตได้ ต้องมีเพดาน และผลรวมของเพดานทั้งหมดต้องไม่เกินเครื่อง

PostgreSQL: ตั้งให้รู้ขอบเขต

db:
  command:
    - postgres
    - -c
    - shared_buffers=256MB
    - -c
    - effective_cache_size=512MB
    - -c
    - work_mem=8MB
    - -c
    - maintenance_work_mem=64MB
    - -c
    - max_connections=50
ค่าความหมายทำไมเลขนี้
shared_buffers=256MBหน่วยความจำ cache ของฐานข้อมูลเอง จองไว้ตลอดราว 1/8 ของแรม เหลือที่ให้คนอื่น
effective_cache_size=512MBบอกตัววางแผน query ว่าระบบปฏิบัติการน่าจะ cache ไฟล์ไว้ราวเท่านี้ไม่ได้จองจริง เป็นแค่ข้อมูลช่วยตัดสินใจ
work_mem=8MBหน่วยความจำต่อการเรียงหรือ hash หนึ่งครั้งเล็กไว้ เพราะคูณได้หลายเท่า (ดูข้างล่าง)
maintenance_work_mem=64MBใช้ตอนสร้าง index และ vacuumงานพวกนี้ไม่ได้รันพร้อมกันบ่อย
max_connections=50จำนวนการเชื่อมต่อสูงสุดแต่ละการเชื่อมต่อกินแรมของมันเอง

กับดัก: work_mem ไม่ได้ใช้ครั้งเดียว

คนมักคิดว่า work_mem=8MB คือฐานข้อมูลใช้ 8MB แต่จริง ๆ คือ 8MB ต่อการเรียงหรือ hash หนึ่งครั้ง query เดียวอาจมีหลายครั้ง และหลายการเชื่อมต่อทำพร้อมกันได้

ลองคิดกรณีแย่

50 การเชื่อมต่อ × 2 ขั้นตอนต่อ query × 8MB = 800MB

บวก shared_buffers 256MB ก็เกินครึ่งเครื่องแล้ว ถ้าตั้ง work_mem 64MB ตามบทความทั่วไปที่เขียนสำหรับเครื่องใหญ่ ตัวเลขนี้จะกลายเป็น 6.4GB บนเครื่อง 2GB ในทางปฏิบัติแอปของเราใช้การเชื่อมต่อจริงไม่กี่ตัวผ่าน connection pool แต่เพดานต้องคิดจากกรณีแย่ ไม่ใช่กรณีปกติ

Redis: เป็น cache ก็ตั้งให้เป็น cache

redis:
  image: redis:7-alpine
  command: ["redis-server", "--maxmemory", "128mb", "--maxmemory-policy", "allkeys-lru",
            "--save", "", "--appendonly", "no"]
  • เพดาน 128MB และเมื่อเต็ม ทิ้ง key ที่ไม่ได้ใช้นานที่สุด (allkeys-lru) แทนที่จะโตต่อ
  • ไม่บันทึกลงดิสก์ ทุกอย่างใน Redis ของเราสร้างใหม่ได้ ข้อมูลจริงอยู่ในฐานข้อมูล การบันทึกลงดิสก์จะเปลืองทั้ง I/O และตอนบันทึก Redis ต้อง fork process ซึ่งใช้แรมเพิ่มชั่วคราว

Log: เพดานที่คนลืมบ่อยที่สุด

Docker เก็บ log ของทุก container ลงไฟล์ด้วย driver json-file และโดยค่าเริ่มต้น ไม่มีเพดาน เราเรียนเรื่องนี้จากของจริง log การเข้าเว็บของ Caddy โตไปถึง 15GB ก่อนที่จะมีใครสังเกต

ตอนนี้ทุก service ใช้ค่าตั้ง log ชุดเดียวกัน

x-logging: &logging
  driver: json-file
  options:
    max-size: "10m"
    max-file: "3"

services:
  db:
    logging: *logging
  redis:
    logging: *logging

แต่ละ service เก็บได้ไม่เกิน 3 ไฟล์ ไฟล์ละ 10MB รวม 30MB พอสำหรับไล่ปัญหาที่เพิ่งเกิด และไม่มีทางกลายเป็นดิสก์เต็ม &logging กับ *logging เป็นฟีเจอร์ของ YAML ให้เขียนครั้งเดียวแล้วใช้ซ้ำได้

แรม 2,048 MB (+ swap 2 GB เป็นตาข่าย)PostgreSQL shared_buffers · 256 MB จองตลอดwork_mem กรณีแย่ · 50 × 2 × 8 MB = 800 MBRedis · เพดาน 128 MBที่เหลือ: แอป Go · Caddy · ระบบ · page cachelog ของ Docker: 10 MB × 3 ไฟล์ ต่อ service (เคยโตถึง 15 GB)
เพดานทุกตัวรวมกันต้องไม่เกินเครื่อง และต้องคิดจากกรณีแย่ ไม่ใช่กรณีปกติ

Swap: ตาข่าย ไม่ใช่แรมเพิ่ม

fallocate -l 2G /swapfile
chmod 600 /swapfile
mkswap /swapfile && swapon /swapfile
echo '/swapfile none swap sw 0 0' >> /etc/fstab
echo 'vm.swappiness=10' > /etc/sysctl.d/99-swap.conf

swap 2GB ช่วยให้ช่วงที่แรมพุ่งชั่วคราว ระบบช้าลงแทนที่จะถูกฆ่า process swappiness=10 บอกให้ใช้ swap เมื่อจำเป็นจริง ๆ ไม่ใช่ย้ายของออกจากแรมตั้งแต่ยังว่าง

ถ้าเห็น swap ถูกใช้เยอะตลอดเวลา แปลว่าเพดานรวมเกินเครื่อง ต้องลดค่า หรือขยายเครื่อง

เปิดให้โลกเห็นเท่าที่จำเป็น

เครื่องเล็กมักเป็นเครื่องเดียวที่ทำทุกอย่าง จึงต้องระวังว่าอะไรเปิดออกไปข้างนอก

db:
  ports:
    - "127.0.0.1:5432:5432"   # เฉพาะในเครื่อง ไว้ต่อ psql ตอนดูแล
app:
  expose:
    - "8080"                  # ไม่เปิดออกนอกเครื่อง เข้าได้ผ่าน Caddy เท่านั้น
caddy:
  ports:
    - "80:80"
    - "443:443"
    - "443:443/udp"           # HTTP/3
  • ฐานข้อมูลผูกกับ 127.0.0.1 ถ้าเขียนแค่ "5432:5432" Docker จะเปิดพอร์ตนี้ออกทุก interface และหลายครั้ง ข้าม firewall อย่าง ufw ไปเลย เพราะ Docker เขียนกฎ iptables ของตัวเอง
  • แอปไม่เปิดพอร์ตออกนอกเครื่อง คุยกับ Caddy ผ่านเครือข่ายภายในของ Docker ทุกคำขอจึงผ่าน HTTPS
  • firewall อนุญาต SSH ก่อน ตอนเปิด ufw ครั้งแรก ต้องอนุญาตพอร์ต 22 ก่อนเสมอ ไม่งั้นจะล็อกตัวเองออกจากเครื่อง

เริ่มตามลำดับ และฟื้นเองเมื่อล้ม

db:
  restart: unless-stopped
  healthcheck:
    test: ["CMD-SHELL", "pg_isready -U fxlog -d fxlog"]
    interval: 5s
app:
  restart: unless-stopped
  depends_on:
    db:
      condition: service_healthy
    redis:
      condition: service_healthy

แอปจะเริ่มเมื่อฐานข้อมูลพร้อมรับการเชื่อมต่อจริง ไม่ใช่แค่ container เริ่มแล้ว และทุก service ฟื้นเองถ้าล้ม รวมถึงหลังรีสตาร์ตเครื่อง

ดูว่าตอนนี้ใช้ไปเท่าไหร่

free -m                    # แรมและ swap ทั้งเครื่อง
docker stats --no-stream   # แรมและซีพียูของแต่ละ container
df -h /                    # ดิสก์

ดูเป็นประจำหลังเพิ่มฟีเจอร์ ไม่ใช่ดูตอนระบบล่มแล้ว

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

  • ก๊อปค่าตั้ง PostgreSQL จากบทความที่เขียนสำหรับเครื่อง 16GB
  • ไม่ตั้งเพดาน log ของ Docker
  • เปิดพอร์ตฐานข้อมูลด้วย "5432:5432" แล้วเชื่อว่า ufw กันไว้แล้ว
  • ใช้ Redis เป็น cache แต่เปิดการบันทึกลงดิสก์ทิ้งไว้
  • คิดว่ามี swap แล้วแรมเต็มไม่เป็นไร
  • ไม่มี healthcheck แอปเริ่มก่อนฐานข้อมูลพร้อม แล้วล้มตั้งแต่ต้น

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

เครื่อง 2GB พอสำหรับ production ไหม

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

มี swap แล้วไม่ต้องกลัวแรมเต็มใช่ไหม

ไม่ใช่ swap คือตาข่ายกันตก ไม่ใช่แรมเพิ่ม ถ้าระบบใช้ swap หนักตลอดเวลา ทุกอย่างจะช้ามาก ให้ถือว่าเป็นสัญญาณว่าต้องลดการใช้หรือเพิ่มแรม

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

อ่านต่อ

#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 นาที
#Serverเริ่มต้น

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

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

อ่าน 15 นาที