บทความ › เซิร์ฟเวอร์และ DevOps
รันฐานข้อมูล cache แอป และ HTTPS บนเครื่อง 2GB ให้รอด ตั้งเพดานทุกอย่างที่โตได้
เครื่องเล็กไม่ได้พังเพราะเล็ก แต่พังเพราะมีอะไรสักอย่างโตไม่มีเพดาน ค่าตั้ง PostgreSQL Redis log ของ Docker และ swap ที่ใช้จริงบนเครื่อง 2GB พร้อมเหตุผลของแต่ละตัว
- เครื่องเล็กพังเพราะมีบางอย่างโตไม่มีเพดาน ไม่ใช่เพราะเล็ก ให้ตั้งเพดานทุกอย่างที่โตได้
- หน่วยความจำของ 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 ให้เขียนครั้งเดียวแล้วใช้ซ้ำได้
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 หนักตลอดเวลา ทุกอย่างจะช้ามาก ให้ถือว่าเป็นสัญญาณว่าต้องลดการใช้หรือเพิ่มแรม
คำสั่งและค่าตั้งในบทความทดสอบบนระบบตัวอย่าง ลองบนเครื่องทดสอบก่อนใช้กับระบบจริงเสมอ