เปรียบเทียบ Django กับ Flask บน VPS ขนาด 1-2GB
วิเคราะห์การใช้หน่วยความจำของ Gunicorn worker สำหรับ Django และ Flask บน VPS ขนาดเล็ก เพื่อคำนวณจำนวน worker ที่เหมาะสมโดยอิงจากค่า RAM จริงบน Ubuntu 24.04 และ Python 3.12
ต้นทุนของ Django และ Flask บน VPS ขนาดเล็ก
การเลือกระหว่าง Django กับ Flask บน VPS ขนาดเล็กเป็นเรื่องของหน่วยความจำเป็นอันดับแรก Django จะโหลด Object Relational Mapper (ORM), กลไกการทำ migration และหน้า admin (หากเปิดใช้งาน) เข้าไปในทุก worker process ที่คุณเริ่มทำงาน ในขณะที่ Flask จะโหลดเพียงตัว router และ request object เท่านั้น บนเซิร์ฟเวอร์ขนาด 1 GB ความแตกต่างนี้จะเป็นตัวกำหนดจำนวน worker ที่สามารถรันได้ และจำนวน worker ก็จะเป็นตัวกำหนดจำนวนคำขอที่คุณสามารถให้บริการได้พร้อมกัน
ต้นทุนนี้จะถือเป็นภาระของ Django ก็ต่อเมื่อคุณไม่ได้ใช้ประโยชน์จากสิ่งที่ Django เตรียมไว้ให้ แอปพลิเคชันที่มีระบบบัญชีผู้ใช้, session และแผงควบคุม admin ควรใช้ Django เพราะ RAM ที่เสียไปต่อ worker คือราคาของโค้ดที่คุณไม่ต้องเขียนเอง ส่วน JSON API ที่อยู่หน้า datastore ซึ่งคุณใช้งานอยู่แล้วควรใช้ Flask เพราะฟีเจอร์สำเร็จรูปเหล่านั้นจะไม่ได้ถูกโหลดขึ้นมาใช้งานเลย นี่จึงเป็นเรื่องของความเหมาะสมในการใช้งาน ข้อมูลการวัดผลด้านล่างนี้จะช่วยให้คุณทราบว่าแอปพลิเคชันของคุณควรเลือกใช้ฝั่งใด
Gunicorn worker หนึ่งตัวใช้หน่วยความจำเท่าใด
The data behind this chart
[
{
"label": "Bare Python 3.12 process",
"rss_mb": 14,
"pss_mb": 9
},
{
"label": "Flask, one route",
"rss_mb": 42,
"pss_mb": 26
},
{
"label": "Flask + SQLAlchemy",
"rss_mb": 58,
"pss_mb": 38
},
{
"label": "Django, admin disabled",
"rss_mb": 78,
"pss_mb": 47
},
{
"label": "Django, admin enabled",
"rss_mb": 96,
"pss_mb": 58
}
]ตัวเลขเหล่านี้เป็นค่ามาตรฐานสำหรับแอปพลิเคชันแบบ "hello world" ในแต่ละรูปแบบบน Ubuntu 24.04 ที่รันด้วย Python 3.12 โดยใช้ gunicorn worker จำนวน 3 ตัวและเปิดใช้งาน preload ให้ถือว่าค่าเหล่านี้เป็นค่าต่ำสุด เนื่องจาก library ที่คุณ import เข้ามาจะเพิ่มภาระหน่วยความจำขึ้นไปอีก Django worker ที่เปิดใช้งาน admin จะใช้หน่วยความจำส่วน resident อยู่ที่ 96 MB ในขณะที่ส่วนแบ่งหน่วยความจำตามสัดส่วน (proportional share) อยู่ที่ 58 MB ช่องว่างระหว่างตัวเลขสองค่านี้คือหัวข้อหลักของส่วนถัดไป
คุณสามารถวัดค่าเหล่านี้บนเซิร์ฟเวอร์ของคุณเองได้ดังนี้
sudo apt update && sudo apt install -y python3-venv
python3 -m venv /srv/site1/.venv
/srv/site1/.venv/bin/pip install django gunicorn setproctitleติดตั้ง setproctitle เมื่อติดตั้งแล้ว gunicorn จะเปลี่ยนชื่อ process เป็น gunicorn: master [site1] และ gunicorn: worker [site1] ซึ่งช่วยให้คำสั่งถัดไปสามารถค้นหา worker ได้โดยระบุชื่อแทนการคาดเดา
pgrep -af gunicorn
ps -o pid,rss,args -p $(pgrep -d, -f 'gunicorn: worker')คอลัมน์ rss คือ resident set size ในหน่วยกิโลไบต์ ซึ่งหมายถึงทุกหน้าหน่วยความจำที่ process นั้นถือครองอยู่ใน RAM ขณะนี้ การนำค่านี้มารวมกันทุก worker จะได้ตัวเลขที่สูงเกินจริง เนื่องจาก worker ที่ถูก fork มาจะแชร์หน้าหน่วยความจำร่วมกับ parent และ sibling ทำให้หน้าหน่วยความจำเดียวกันถูกนับซ้ำหลายครั้ง ให้เรียกดูค่า proportional set size (PSS) จาก kernel แทน ซึ่งจะแบ่งสัดส่วนของหน้าหน่วยความจำที่แชร์กันให้กับแต่ละ process ที่เรียกใช้งาน
for pid in $(pgrep -f 'gunicorn: worker'); do awk -v p="$pid" '/^Pss:/ {printf "%s %d MB\n", p, $2/1024}' /proc/$pid/smaps_rollup; doneให้รันคำสั่งในฐานะผู้ใช้ที่เป็นเจ้าของ worker หรือใช้ sudo ค่า PSS คือค่าที่ควรนำมาใช้ในการวางแผนทรัพยากร เพราะ PSS สามารถนำมารวมกันได้อย่างถูกต้องในขณะที่ RSS ทำไม่ได้
Django มีขนาดใหญ่กว่าเนื่องจากสิ่งที่ django.setup() ทำ โดยมันจะ import ทุกรายการใน INSTALLED_APPS, สร้าง application registry และสร้าง instance ของ model class ทุกตัวพร้อมกับ Python object สำหรับทุก field ในนั้น การเพิ่ม django.contrib.admin จะทำให้เกิดการค้นหา admin อัตโนมัติ ซึ่งจะ import โมดูล admin ของแต่ละแอปและดึงเอาเลเยอร์ของ form และ template เข้ามาด้วย ในขณะที่ Flask worker จะ import เพียง Werkzeug และ Jinja2 แล้วหยุดทำงาน
ข้อควรระวังที่สำคัญคือ ตัว framework มักจะเป็นเพียงส่วนเล็กๆ เท่านั้น worker ที่ import cloud SDK หรือ library สำหรับงานคำนวณตัวเลขจะมีขนาดใหญ่กว่าตัว Django เองเสียอีก ดังนั้นควรวัดค่าจากแอปพลิเคชันจริงของคุณก่อนที่จะสรุปว่า framework คือปัญหา
Copy on write และเหตุผลที่การ preload ส่งผลต่อจำนวนหน่วยความจำ
กระบวนการ master ของ Gunicorn จะทำการ fork กระบวนการลูก (worker) ขึ้นมา ทันทีหลังจาก fork() กระบวนการลูกจะแชร์ทุกหน้าหน่วยความจำ (memory page) ร่วมกับกระบวนการแม่ โดย kernel จะคัดลอกหน้าหน่วยความจำก็ต่อเมื่อฝั่งใดฝั่งหนึ่งมีการเขียนข้อมูลลงไปเท่านั้น ดังนั้น การที่ model registry ของ Django จะมีอยู่เพียงชุดเดียวหรือสี่ชุดบนเครื่องนั้น ขึ้นอยู่กับว่ามันถูกสร้างขึ้นที่ฝั่งใดของการ fork
เมื่อปิด preload_app กระบวนการลูกแต่ละตัวจะ import แอปพลิเคชันของคุณหลังจากที่ถูก fork ออกมาแล้ว ทำให้แต่ละตัวสร้างสำเนาส่วนตัวของตนเองขึ้นมา แต่เมื่อเปิดใช้งาน กระบวนการ master จะ import แอปพลิเคชันเพียงครั้งเดียว และกระบวนการลูกจะรับช่วงหน้าหน่วยความจำเหล่านั้นไปใช้งานต่อ
import gc
bind = "unix:/run/site1/gunicorn.sock"
umask = 0o007
workers = 3
timeout = 30
preload_app = True
max_requests = 500
max_requests_jitter = 50
def when_ready(server):
gc.freeze()CPython ทำงานขัดกับหลักการ copy on write เนื่องจาก header ของ object ทุกตัวจะมี reference count เก็บอยู่ และการเข้าถึง object จะส่งผลให้เกิดการเขียนลงใน header นั้น หน้าหน่วยความจำที่แชร์กันอยู่จึงถูกคัดลอกแยกออกมาทีละหน้าในขณะที่ garbage collector ทำการไล่ตรวจสอบ heap ทั้งนี้ gc.freeze() จะย้ายทุกสิ่งที่ถูกจองหน่วยความจำไว้ก่อนหน้านี้ไปยัง permanent generation ซึ่งตัว collector จะไม่เข้าไปตรวจสอบอีกต่อไป ทำให้หน้าหน่วยความจำเหล่านั้นยังคงถูกแชร์ไว้ได้มากขึ้น when_ready เป็น hook ที่ถูกต้องเพราะมันจะทำงานหลังจาก preload เสร็จสิ้นและก่อนที่ worker ตัวแรกจะถูก fork ให้วัดค่า PSS ก่อนและหลังการเพิ่ม hook นี้ เนื่องจากปริมาณหน่วยความจำที่ประหยัดได้ขึ้นอยู่กับว่าสถานะในช่วง import ของแอปพลิเคชันของคุณมีขนาดเท่าใด
การ preload มีต้นทุนอย่างหนึ่งที่มักสร้างความประหลาดใจในวัน deploy งาน systemctl reload จะส่งสัญญาณ HUP และพฤติกรรมของ gunicorn ตามเอกสารเมื่อได้รับ HUP คือการโหลดการตั้งค่าใหม่และเริ่มกระบวนการลูกชุดใหม่ เมื่อแอปพลิเคชันถูก preload ไว้แล้ว มันจะไม่ทำการ import โค้ดของคุณใหม่ ส่งผลให้ release ใหม่ของคุณไม่ได้ถูกเรียกใช้งานแม้ว่ากระบวนการ worker จะเป็นชุดใหม่ก็ตาม ให้ใช้ systemctl restart หลังจากมีการเปลี่ยนแปลงโค้ด หรือใช้ลำดับคำสั่ง USR2 ตามด้วย WINCH หากคุณต้องการให้ worker ชุดเก่าทำงานค้างอยู่จนเสร็จสิ้นก่อน
VPS ขนาด 1 GB สามารถรัน worker ได้กี่ตัวกันแน่?
The data behind this chart
[
{
"label": "Ubuntu 24.04 base",
"ram_mb": 190
},
{
"label": "nginx",
"ram_mb": 12
},
{
"label": "PostgreSQL, default config",
"ram_mb": 120
},
{
"label": "Headroom you must leave",
"ram_mb": 150
},
{
"label": "Left for gunicorn workers",
"ram_mb": 550
}
]ตัวเลขเหล่านั้นเป็นค่าขณะ idle บนเซิร์ฟเวอร์ที่ไม่มีการประมวลผลใดๆ คุณเหลือ RAM ประมาณ 550 MB สำหรับ application worker ซึ่งเป็นปริมาณก่อนที่จะมี request แรกเข้ามาเสียอีก
จากนั้นให้หารจำนวนลง และต้องหารแบบเผื่อเหลือเผื่อขาดไว้เสมอ แต่ละ request จะใช้หน่วยความจำขณะทำงาน เช่น การดึงข้อมูล queryset จำนวนหลายพันแถว แล้วตามด้วยการ render template โดยปกติแล้วค่า peak ต่อ worker จะสูงเกือบสองเท่าของค่า idle ดังนั้นควรคำนวณงบประมาณหน่วยความจำไว้ที่สองเท่า หากใช้ Django พร้อมหน้า admin ที่กิน RAM ขณะ idle 58 MB คุณจะรัน worker ได้สี่ตัวบนเซิร์ฟเวอร์นี้ หรือหากใช้ Flask ร่วมกับ SQLAlchemy ที่กิน RAM 38 MB คุณจะรันได้เจ็ดตัว
คำแนะนำของ Gunicorn ใน (2 x cores) + 1 ตั้งสมมติฐานว่า CPU เป็นทรัพยากรที่หายากและ RAM ไม่ใช่ แต่บน VPS ขนาดเล็กนั้นกลับกัน vCPU แบบแชร์หนึ่งตัวจะให้ประสิทธิภาพการทำงานน้อยกว่าหนึ่งคอร์จริงเมื่อโฮสต์มีภาระงานสูง ซึ่งเป็นเรื่องที่ควรทำความเข้าใจก่อนจะโทษโค้ดของคุณ โดย CPU steal time จากเพื่อนบ้านที่ใช้งานหนัก จะปรากฏใน top ในฐานะค่า st
หาก view ของคุณส่วนใหญ่รอการตอบกลับจากฐานข้อมูลหรือ upstream API การใช้ threads จะได้เปรียบกว่าการใช้ processes โดย --worker-class gthread --workers 2 --threads 4 จะช่วยให้รองรับ concurrent requests ได้แปดรายการโดยใช้หน่วยความจำเท่ากับ worker สองตัว เพราะ threads จะแชร์สำเนาของ interpreter และ framework ที่โหลดไว้เพียงชุดเดียว อย่างไรก็ตาม Global Interpreter Lock หมายความว่า threads จะไม่ช่วยในกรณีที่ view ของคุณมีการประมวลผล CPU หนักๆ
ควรตั้งค่า swap ให้กับเซิร์ฟเวอร์ VPS ขนาด 1 GB ที่ไม่มี swap จะทำให้เมื่อเกิด memory spike ระบบจะสั่ง kill process ทันที ในขณะที่ swapfile จะเปลี่ยนเหตุการณ์เดียวกันให้กลายเป็นเพียง request ที่ช้าลงเท่านั้น
sudo fallocate -l 1G /swapfile
sudo chmod 600 /swapfile
sudo mkswap /swapfile
sudo swapon /swapfile
echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab
echo 'vm.swappiness = 10' | sudo tee /etc/sysctl.d/99-swappiness.conf
sudo sysctl --systemจากนั้นให้จำกัดการใช้ทรัพยากรของตัวแอปพลิเคชันเอง การตั้งค่า MemoryMax=600M ใน unit ของ gunicorn จะช่วยให้ kernel ดึงหน่วยความจำคืนจาก cgroup ของแอปคุณ แทนที่จะเลือก kill process อื่นในเครื่อง ดังนั้นหากเกิด request ที่ทำงานผิดปกติจนกินทรัพยากรสูง ก็จะไม่ส่งผลกระทบจนทำให้ session SSH ของคุณหลุด
พฤติกรรมการเริ่มทำงานแบบ Cold start และการรีสตาร์ท
The data behind this chart
[
{
"label": "Flask, one route",
"cold_start_ms": 90
},
{
"label": "Flask + SQLAlchemy",
"cold_start_ms": 260
},
{
"label": "Django, admin disabled",
"cold_start_ms": 480
},
{
"label": "Django, admin enabled",
"cold_start_ms": 720
}
]ต้นทุนในการบูตจะเกิดขึ้นสองครั้ง คือในทุกครั้งที่มีการ deploy และทุกครั้งที่มีการรีสตาร์ทอัตโนมัติหลังจากเกิด crash แอปพลิเคชัน Flask ขนาดเล็กจะพร้อมทำงานในเวลาประมาณ 90 ms และ Django ที่เปิดใช้งาน admin จะใช้เวลาประมาณ 720 ms บน vCPU แบบแชร์เดียวกัน ทั้งสองค่านี้เป็นตัวเลขมาตรฐานที่เผยแพร่ทั่วไป คุณควรวัดค่าด้วยตนเองเนื่องจาก dependency ของคุณเป็นปัจจัยหลักที่มีผลต่อเวลาดังกล่าว
cd /srv/site1
DJANGO_SETTINGS_MODULE=site1.settings /srv/site1/.venv/bin/python -X importtime -c "import django; django.setup()" 2>&1 | tail -20บรรทัดท้ายๆ จะแสดงรายการการ import ที่ช้าที่สุดพร้อมเวลาสะสมในหน่วยไมโครวินาที สำหรับ Flask ให้รัน flag เดียวกันกับโมดูลของคุณ: python -X importtime -c "import app"
เมื่อเปิดใช้งาน preload กระบวนการ master จะรับภาระต้นทุนนั้นเพียงครั้งเดียว และ worker ที่ถูก fork ออกมาจะเริ่มทำงานได้ทันที หากปิด preload ไว้ worker แต่ละตัวจะต้องรับภาระต้นทุนนั้นเอง และ timeout ของ gunicorn จะครอบคลุมทั้งช่วงการบูตและช่วงการประมวลผลคำขอ worker ที่ไม่ได้ส่งสัญญาณตอบกลับภายใน timeout วินาทีจะถูกสั่ง kill และสร้างใหม่ ดังนั้นแอปพลิเคชันที่มีขนาดใหญ่บน vCPU แบบแชร์ที่ทำงานช้าอาจติดอยู่ในลูปการรีสตาร์ทจนไม่สามารถให้บริการได้เลย โดย log จะแสดงข้อความดังนี้:
[2026-08-09 09:14:02 +0000] [981] [CRITICAL] WORKER TIMEOUT (pid:1004)การทำ migrations ควรอยู่ใน unit ไม่ใช่ในโค้ดเริ่มต้นของแอปพลิเคชัน ExecStartPre จะทำงานเพียงครั้งเดียวโดยที่ยังไม่มี worker ใดเกิดขึ้น การใส่ migrate ไว้ภายในแอปของคุณจะทำให้ worker ทั้งสามตัวแย่งกันทำงานกับ schema lock เดียวกัน
รูปแบบการปรับใช้ (deployment shape) แทบไม่ต่างกัน
ตัวจัดการกระบวนการ (Process manager)
เฟรมเวิร์กทั้งสองรันภายใต้ gunicorn และ gunicorn รันภายใต้ systemd
[Unit]
Description=gunicorn for site1
After=network.target
[Service]
User=site1
Group=www-data
WorkingDirectory=/srv/site1
RuntimeDirectory=site1
Environment="PATH=/srv/site1/.venv/bin"
ExecStartPre=/srv/site1/.venv/bin/python manage.py migrate --noinput
ExecStart=/srv/site1/.venv/bin/gunicorn -c /srv/site1/gunicorn.conf.py site1.wsgi:application
Restart=on-failure
RestartSec=3
MemoryMax=600M
[Install]
WantedBy=multi-user.targetRuntimeDirectory=site1 จะสร้าง /run/site1 เมื่อเริ่มทำงานและลบออกเมื่อหยุดทำงาน ดังนั้น path ของ socket จึงมีอยู่เสมอด้วยเจ้าของที่ถูกต้อง บรรทัด umask = 0o007 ในไฟล์ config ของ gunicorn คือสิ่งที่ทำให้กลุ่ม www-data สามารถเขียนข้อมูลลงใน socket นั้นได้ ซึ่งเป็นวิธีที่ nginx ใช้ในการเชื่อมต่อ
unit ของ Flask เป็นไฟล์เดียวกันโดยเปลี่ยนเพียงบรรทัดเดียวคือ ExecStart=... gunicorn -c /srv/site1/gunicorn.conf.py app:app และไม่มี ExecStartPre อาร์กิวเมนต์ app:app คือชื่อโมดูลตามด้วยชื่อ callable ดังนั้นข้อผิดพลาด Failed to find attribute 'app' in 'app'. จึงหมายความว่าโมดูลของคุณไม่ได้กำหนดตัวแปรด้วยชื่อดังกล่าว งานที่ตั้งเวลาไว้ใช้รูปแบบเดียวกัน และ systemd timer จะเข้ามาแทนที่ cron สำหรับคำสั่ง Django management โดยไม่ต้องเพิ่ม task queue เข้าไปในเซิร์ฟเวอร์ขนาดนี้
ไฟล์สแตติก (Static files)
Django ที่ใช้ DEBUG = False จะไม่ให้บริการไฟล์สแตติกใดๆ เลย ให้ตั้งค่า STATIC_ROOT, รันคำสั่ง python manage.py collectstatic และชี้ web server ไปยังไดเรกทอรีผลลัพธ์ หากข้ามขั้นตอนนี้ไป หน้า admin จะโหลดโดยไม่มีการจัดรูปแบบ (styling) และ log จะเต็มไปด้วย Not Found: /static/admin/css/base.css
มีสองวิธีที่เหมาะสมในการให้บริการไฟล์เหล่านี้ บล็อก alias ใน nginx ไม่กินทรัพยากรของแอปคุณ ส่วน WhiteNoise ซึ่งเพิ่มเข้ามาในฐานะ middleware จะให้บริการไฟล์จาก worker โดยตรงและช่วยให้คุณไม่ต้องตั้งค่าบล็อกใน nginx แลกกับการใช้เวลาของ worker เล็กน้อยต่อไฟล์ สำหรับ Flask จะให้บริการโฟลเดอร์ static/ ของตัวเองในระหว่างการพัฒนา และในการใช้งานจริง คุณควรชี้ proxy ไปที่โฟลเดอร์ดังกล่าวด้วยเหตุผลเดียวกัน
Reverse proxy
server {
listen 80;
server_name example.com;
location /static/ {
alias /srv/site1/static/;
expires 30d;
}
location / {
proxy_pass http://unix:/run/site1/gunicorn.sock;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}เมื่ออยู่หลัง proxy ใดๆ Django จำเป็นต้องได้รับแจ้งว่าคำขอต้นทางเป็น HTTPS มิฉะนั้นการตรวจสอบ cross site request forgery (CSRF) จะปฏิเสธฟอร์มของคุณเอง
ALLOWED_HOSTS = ["example.com"]
CSRF_TRUSTED_ORIGINS = ["https://example.com"]
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")หากเซิร์ฟเวอร์รัน container อยู่แล้ว การใช้ Traefik ไว้หน้าแอป Docker Compose หลายตัว จะทำงานในลักษณะเดียวกันโดยใช้ labels บน container แทนการตั้งค่าไฟล์แยกต่อเว็บไซต์
เลือกฐานข้อมูลใดดี
SQLite เพียงพอสำหรับการใช้งานบนเซิร์ฟเวอร์แอปพลิเคชันเดียวที่มีอัตราการเขียนไม่กี่ครั้งต่อวินาที และช่วยลดภาระการรัน daemon เพิ่มเติมในหน่วยความจำ ให้เปิดใช้งาน write ahead logging (WAL) และกำหนด busy timeout ให้กับ driver
DATABASES = {
"default": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": BASE_DIR / "db.sqlite3",
"OPTIONS": {
"timeout": 20,
"init_command": "PRAGMA journal_mode=WAL;",
},
}
}หากไม่มีสองตัวเลือกนี้ คุณจะพบกับ django.db.utils.OperationalError: database is locked ในครั้งแรกที่มี worker สองตัวเขียนข้อมูลพร้อมกัน เนื่องจากโหมด journal เริ่มต้นจะบล็อกผู้อ่านในระหว่างการเขียน และค่า timeout เริ่มต้นจะยอมแพ้เกือบจะทันที คำอธิบายโดยละเอียดรวมถึงจุดที่ SQLite ไม่ใช่คำตอบที่เหมาะสมอีกต่อไป อยู่ใน การรัน SQLite ในสภาพแวดล้อมจริงบน VPS
PostgreSQL บนเซิร์ฟเวอร์ขนาด 1 GB เดียวกันจะใช้ทรัพยากร 120 MB ตามงบประมาณข้างต้น บวกกับกระบวนการ backend หนึ่งรายการต่อการเชื่อมต่อที่ค้างอยู่ (persistent connection) ตัวแปร CONN_MAX_AGE ของ Django จะคงการเชื่อมต่อไว้หนึ่งรายการต่อ worker ดังนั้น worker สี่ตัวจึงหมายถึง backend สี่รายการ ซึ่งโดยปกติแล้วเป็นการแลกเปลี่ยนที่คุ้มค่า เพียงแค่คำนวณจำนวนให้ดีก่อนกำหนดจำนวน worker หากคุณต้องการเก็บฐานข้อมูลไว้ใน container ข้างแอป การรัน Docker บน VPS เป็นการแลกเปลี่ยนในลักษณะเดียวกันแต่มีฐานการใช้ทรัพยากรที่สูงกว่า เนื่องจาก daemon และแต่ละ container จะเพิ่ม overhead ซึ่งมีผลอย่างมากกับเซิร์ฟเวอร์ขนาดนี้
สิ่งที่ได้มาพร้อมกับเครื่องมือสำเร็จรูปและต้นทุนที่ต้องจ่าย
สิ่งที่ Django เพิ่มเข้ามาคือรายการของเครื่องมือที่มีอยู่แล้วและทำงานร่วมกันได้เป็นอย่างดี ได้แก่ ORM พร้อมระบบ migrations, ระบบ session และการยืนยันตัวตน, โมเดลการจัดการสิทธิ์, เลเยอร์สำหรับจัดการฟอร์มพร้อมการป้องกัน CSRF, เอนจินสำหรับ template, คำสั่ง management และหน้า admin ซึ่งหน้า admin นี้เป็นส่วนที่ผู้คนมักประเมินค่าต่ำเกินไป มันคือเครื่องมือแก้ไขฐานข้อมูลที่ใช้งานได้จริงสำหรับโมเดลของคุณ พร้อมระบบค้นหาและตัวกรอง โดยใช้โค้ดเพียงบรรทัดเดียวใน INSTALLED_APPS
Flask เป็นสิ่งที่ตรงกันข้าม คุณจะได้ระบบ routing, object สำหรับจัดการ request, template แบบ Jinja2 และ object สำหรับจัดการ config ส่วนที่เหลือทั้งหมดคือทางเลือกที่คุณต้องตัดสินใจเอง ซึ่งถือเป็นมูลค่าที่แท้จริงเมื่อแอปพลิเคชันยังมีขนาดเล็ก เพราะหากคุณไม่เคย import ORM เข้ามา มันก็จะไม่ถูกโหลดขึ้นมาใช้งาน
กับดักที่สำคัญคือการอยู่ตรงกลาง หากคุณเพิ่ม SQLAlchemy สำหรับโมเดล, Alembic สำหรับ migrations, Flask-Login สำหรับ session, Flask-WTF สำหรับฟอร์มและการป้องกัน CSRF และส่วนขยาย admin สำหรับจัดการหลังบ้าน คุณกำลังประกอบร่างสิ่งที่ใช้หน่วยความจำเท่ากับ Django แต่กลับขาดความสอดประสานกันของระบบ แต่ละส่วนมีรอบการปล่อยเวอร์ชันของตัวเองและมีแนวคิดที่แตกต่างกันในการเชื่อมต่อแอปพลิเคชัน นั่นคือจุดที่ Django กลายเป็นคำตอบที่คุ้มค่ากว่า ทั้งในแง่ของการใช้ RAM และชั่วโมงการทำงานที่คุณต้องเสียไปกับการอัปเกรดระบบ
Django กับ Flask: กฎการตัดสินใจ
ให้ใช้ Django เมื่อแอปพลิเคชันมีระบบบัญชีผู้ใช้, เนื้อหาที่แก้ไขได้, สกีมาที่ต้องเปลี่ยนแปลงอยู่ตลอด และระบบหลังบ้าน (back office) ที่มีการใช้งานจริง ให้ใช้ Flask เมื่อแอปพลิเคชันเป็นเพียงอินเทอร์เฟซ JSON ที่ครอบอยู่บนคลังข้อมูลที่มีอยู่แล้ว หรือเป็นตัวรับ webhook ที่ไม่มีส่วนประกอบของ HTML เลย
ตัวตัดสินชี้ขาดคือรายการที่เขียนขึ้นมา ให้เขียนรายการแพ็กเกจทั้งหมดที่คุณต้องติดตั้งใน Flask เพื่อให้ได้ฟีเจอร์ตามที่ต้องการ หากรายการนั้นมี ORM และเครื่องมือจัดการ migration รวมอยู่ด้วย แสดงว่าคุณได้เลือก Django ไปแล้ว และกำลังจ่ายเงินเพิ่มเพื่อค่อยๆ สร้างสิ่งนั้นขึ้นมาอย่างล่าช้า
กรณีหนึ่งที่ Flask ได้เปรียบอย่างแท้จริงบนฮาร์ดแวร์ขนาดเล็ก คือการรันบริการขนาดเล็กหลายตัวบนเครื่องเดียว บริการ Flask แต่ละตัวจะเป็นโพรเซสแยกต่างหากที่มีต้นทุนต่ำภายใต้ unit ของตนเอง การรันเว็บไซต์ Django 3 แห่งบน VPS ขนาด 1 GB หมายถึงการมีเฟรมเวิร์ก 3 ชุดทำงานค้างอยู่ในหน่วยความจำพร้อมกัน ซึ่งจะทำให้การคำนวณข้างต้นใช้ไม่ได้ผล หากผลลัพธ์ยังคงไม่เพียงพอ การอัปเกรดแผนบริการให้ใหญ่ขึ้นมักเป็นการแก้ไขที่ตรงไปตรงมาที่สุด และ ค่าใช้จ่ายจริงของ VPS ต่อเดือน เป็นหัวข้อที่คุยจบเร็วกว่าการเขียนแอปพลิเคชันที่ทำงานได้ดีอยู่แล้วขึ้นมาใหม่ทั้งหมด
รูปแบบความล้มเหลวที่พบได้บ่อย
Worker หายไปแล้วกลับมาใหม่ Gunicorn จะแสดงข้อความ [ERROR] Worker (pid:1234) was sent SIGKILL! Perhaps out of memory? ซึ่งข้อความนี้จะปรากฏทั้งในกรณีที่ระบบสั่งยุติ worker เนื่องจากไม่ได้รับ heartbeat ภายในเวลาที่กำหนด และกรณีที่ kernel สั่งยุติกระบวนการทำงาน ให้แยกแยะสองกรณีนี้ด้วย dmesg -T | grep -i "killed process" หากพบข้อความในส่วนนั้นแสดงว่าเป็นปัญหาเรื่องหน่วยความจำ ให้ลดจำนวน worker ลงหรือเพิ่ม swap
ทุกหน้าแสดงผล 400 และ log ระบุ Invalid HTTP_HOST header ข้อความเต็มคือ Invalid HTTP_HOST header: 'example.com'. You may need to add 'example.com' to ALLOWED_HOSTS. ซึ่งหมายความว่า Django ปฏิเสธคำขอก่อนที่จะส่งถึงโค้ดของคุณ เนื่องจาก ALLOWED_HOSTS ว่างเปล่าหรือไม่รวมชื่อโดเมนที่ proxy ส่งผ่านมาใน Host
แบบฟอร์มใช้งานไม่ได้และแสดง Origin checking failed หน้าเว็บแจ้งว่าการตรวจสอบ CSRF ล้มเหลว ปัญหานี้เกิดขึ้นเมื่อใช้งานอยู่หลัง proxy ที่ทำ TLS termination โดยแอปพลิเคชันมองเห็นเป็น HTTP ธรรมดา จึงสร้าง origin เป็น http:// และนำไปเปรียบเทียบกับคำขอที่ส่งผ่าน https:// ให้ตั้งค่า SECURE_PROXY_SSL_HEADER และ CSRF_TRUSTED_ORIGINS พร้อมทั้งตรวจสอบให้แน่ใจว่า proxy ได้ส่งค่า X-Forwarded-Proto มาจริง
nginx ส่งคืน 502 ทันที log ของข้อผิดพลาดจะระบุสาเหตุไว้ โดย connect() to unix:/run/site1/gunicorn.sock failed (2: No such file or directory) หมายความว่า unit ไม่ได้ทำงานอยู่ และ (13: Permission denied) หมายความว่ามี socket อยู่จริงแต่ nginx ไม่สามารถเปิดใช้งานได้ ซึ่งเป็นปัญหาที่การตั้งค่า umask และกลุ่มผู้ใช้งาน
หน้า admin ไม่แสดงรูปแบบ (styling) ยังไม่ได้รัน collectstatic หรือ path ของ alias ไม่ตรงกับ STATIC_ROOT โดย access log จะแสดงสถานะ 404 ภายใต้ /static/admin/
การเขียนข้อมูลล้มเหลวแม้มีโหลดน้อย ข้อความ database is locked จาก SQLite หมายความว่าโหมด WAL ปิดอยู่ หรือตั้งค่า busy timeout สั้นเกินไปสำหรับการที่ worker สองตัวพยายามเขียนข้อมูลพร้อมกันในเวลาเดียวกัน
FAQ
Django กินทรัพยากรมากเกินไปสำหรับ VPS ขนาด 1 GB หรือไม่?
ไม่ Django ที่มี worker จำนวนน้อย โดยมี nginx อยู่ด้านหน้าและใช้ SQLite เป็นฐานข้อมูล สามารถทำงานบน 1 GB ได้อย่างราบรื่น ปัญหาจะเริ่มเกิดขึ้นเมื่อคุณเพิ่ม PostgreSQL ที่ตั้งค่าเริ่มต้น, ระบบแคช, background worker และ Docker เข้าไปในเครื่องเดียวกัน ให้วัดขนาด proportional set size ของ worker หนึ่งตัว แล้วคูณสองเพื่อรองรับช่วงที่มีคำขอเข้ามามาก จากนั้นนำไปเทียบกับทรัพยากรที่เหลือหลังจากหักส่วนของระบบปฏิบัติการและฐานข้อมูลออกแล้ว
ควรใช้ gunicorn worker กี่ตัวบน 1 vCPU?
ให้เริ่มที่ 3 ตัวแล้ววัดผล โดยปกติแล้วหน่วยความจำจะเป็นข้อจำกัดหลักบน VPS ขนาดเล็ก ดังนั้นให้แบ่ง RAM ที่เหลือหลังจากหักส่วนของระบบปฏิบัติการและฐานข้อมูลออกด้วยค่าสองเท่าของ proportional set size ของ worker หนึ่งตัว หาก view ของคุณส่วนใหญ่รอการตอบกลับจากฐานข้อมูลหรือ upstream API ให้เปลี่ยนไปใช้ worker class แบบ gthread โดยกำหนดจำนวน worker น้อยๆ และใช้หลาย thread ต่อหนึ่ง worker เนื่องจาก thread จะใช้ copy ของ framework ร่วมกัน ทำให้ประหยัดหน่วยความจำได้มากกว่าการเพิ่มจำนวน process
จำเป็นต้องใช้ PostgreSQL หรือ SQLite ก็เพียงพอแล้ว?
SQLite เพียงพอสำหรับ application server หนึ่งเครื่องที่มีอัตราการเขียนข้อมูลไม่สูงมาก และยังช่วยลดภาระการรัน daemon เพิ่มเติมในหน่วยความจำ ให้เปิดใช้งาน write ahead logging และตั้งค่า busy timeout ไม่เช่นนั้นการเขียนข้อมูลพร้อมกันจะล้มเหลวด้วย database is locked ให้ย้ายไปใช้ PostgreSQL เมื่อต้องมีการเขียนข้อมูลจากหลายเครื่อง หรือเมื่อคุณต้องการฟีเจอร์ที่ SQLite ไม่มี เช่น การเขียนข้อมูลหนักๆ พร้อมกันหลายจุด หรือการควบคุมสิทธิ์การเข้าถึงแยกตามบทบาท
ควรใช้ uvicorn แทน gunicorn หรือไม่?
ควรใช้ก็ต่อเมื่อคุณมี async view และมีงานที่ต้องรอการตอบกลับจริงๆ เท่านั้น Flask เป็นแอปพลิเคชันแบบ WSGI ดังนั้น async view จะรันใน event loop ใหม่ภายใน worker thread และทำงานเสร็จสิ้นก่อนที่คำขอถัดไปจะเริ่ม ซึ่งไม่ได้ช่วยเพิ่ม concurrency แต่อย่างใด Django async view จำเป็นต้องใช้ ASGI server เพื่อให้ได้ประโยชน์อย่างเต็มที่ uvicorn เวอร์ชันใหม่ได้ย้าย worker class สำหรับ gunicorn ไปไว้ในแพ็กเกจแยกต่างหาก ดังนั้นควรอ่านเอกสารประกอบของ uvicorn ฉบับปัจจุบัน แทนการคัดลอก flag ของ worker class จากบทเรียนเก่า
ทำไม worker ของฉันถึงหายไปโดยไม่มี traceback?
หาก process ถูก kernel สั่งปิดด้วย out of memory killer มันจะได้รับสัญญาณ SIGKILL และไม่สามารถบันทึก log ใดๆ ก่อนจบการทำงานได้ ทำให้ log ของแอปพลิเคชันหยุดลงเฉยๆ Gunicorn จะตรวจพบช่องว่างดังกล่าวและแสดงข้อความ Worker (pid:1234) was sent SIGKILL! Perhaps out of memory? ให้ยืนยันสาเหตุด้วย dmesg -T | grep -i "killed process" วิธีแก้ไขคือลดจำนวน worker ลง หรือเพิ่ม swapfile เพื่อให้ช่วงที่หน่วยความจำพุ่งสูงขึ้นกลายเป็นการประมวลผลที่ช้าลงแทนที่จะทำให้ process ตาย