SSD Nodes Learn 🎉 VPS เริ่ม $5.50/เดือน
คู่มือ Matt Connorโดย Matt Connor · อัปเดตเมื่อ 2026-08-13

ควรติดตั้งฐานข้อมูลใน Docker หรือบนโฮสต์โดยตรง

การรัน PostgreSQL, MySQL, MongoDB หรือ Redis ใน Docker สำหรับงาน Production ทำได้จริง แต่ต้องระวังเรื่องการจัดการ Volume การอัปเกรดเวอร์ชัน การสำรองข้อมูล และการจำกัด Memory

ควรเรียกใช้งานฐานข้อมูลใน Docker หรือบนโฮสต์โดยตรง?

ให้เรียกใช้งานฐานข้อมูลใน Docker สำหรับแอปพลิเคชันสแต็กเดียวบน VPS หนึ่งเครื่อง การใช้ PostgreSQL, MySQL, MongoDB หรือ Redis ในรูปแบบคอนเทนเนอร์ถือเป็นทางเลือกปกติสำหรับการใช้งานจริง และข้อโต้แย้งที่มักพบเห็นกันส่วนใหญ่มักเป็นประเด็นที่ไม่ถูกต้อง คอนเทนเนอร์คือกระบวนการ (process) บน Linux ที่ถูกจำกัดด้วย namespaces และ cgroups ไม่ใช่เครื่องเสมือน (virtual machine) ดังนั้นจึงไม่มี hypervisor คั่นกลางระหว่างฐานข้อมูลกับดิสก์ การใช้ bind mount หรือ local named volume จะทำให้การอ่านและเขียนข้อมูลเกิดขึ้นบนระบบไฟล์ของโฮสต์โดยตรง ซึ่งเป็นวิธีเดียวกับการติดตั้งผ่าน package manager

ต้นทุนที่แท้จริงคือเรื่องการปฏิบัติงาน มีปัจจัย 4 ประการที่จะตัดสินว่าการตั้งค่านี้จะราบรื่นหรือกลายเป็นหายนะ: ตำแหน่งที่จัดเก็บข้อมูล, ใครเป็นเจ้าของไดเรกทอรีนั้น, กระบวนการอัปเกรดเวอร์ชันหลักเป็นอย่างไร และคุณเคยทดสอบการกู้คืนข้อมูลสำรอง (backup) หรือไม่ หากจัดการสิ่งเหล่านี้ได้ถูกต้อง คอนเทนเนอร์ก็เป็นเพียงรายละเอียดทางเทคนิค แต่หากจัดการผิดพลาด คอนเทนเนอร์จะเป็นสิ่งที่คุณโทษเมื่อเกิดปัญหา

การตัดสินใจนี้ใช้หลักการเดียวกันกับฐานข้อมูลทุกประเภทบนเซิร์ฟเวอร์ ตัวอย่างด้านล่างนี้ใช้ PostgreSQL, MySQL, MongoDB และ Redis โดยจะระบุความแตกต่างเฉพาะของแต่ละผลิตภัณฑ์ไว้ในจุดที่จำเป็นต้องทราบ

สิ่งที่คอนเทนเนอร์เปลี่ยนแปลงจริง

ไม่ใช่ path ของที่จัดเก็บข้อมูล ตราบใดที่คุณ mount ไว้ เพราะใช้ kernel เดียวกัน, page cache เดียวกัน และระบบไฟล์เดียวกัน

มีกับดักด้านประสิทธิภาพที่สำคัญอยู่หนึ่งจุด คือกรณีที่คุณไม่ได้ mount อะไรเลย หากไม่มี volume ข้อมูลจะถูกเขียนลงใน writable layer ของคอนเทนเนอร์ ซึ่งเป็น overlay filesystem ที่ซ้อนทับอยู่บน image การเขียนข้อมูลในส่วนนี้จะช้ากว่า และ layer ทั้งหมดจะถูกลบเมื่อคอนเทนเนอร์ถูกลบ นี่คือสาเหตุที่ว่าทำไม "ฐานข้อมูลของฉันถึงว่างเปล่าในเช้านี้"

สิ่งที่เปลี่ยนแปลงอย่างแท้จริง:

  • วงจรชีวิต (Lifecycle): docker compose down จะทำลายคอนเทนเนอร์ ข้อมูลใดก็ตามที่ไม่ได้อยู่ใน volume จะหายไปพร้อมกับคอนเทนเนอร์
  • เวอร์ชัน (Version): image tag คือเวอร์ชันของซอฟต์แวร์ จะไม่มีการอัปเดต apt upgrade ภายในคอนเทนเนอร์ฐานข้อมูลที่คงอยู่ถาวรหลังจากการทำ docker compose pull ครั้งถัดไป
  • การจัดการหน่วยความจำ (Memory accounting): ขีดจำกัดของ cgroup คือกำแพงที่ kernel บังคับใช้โดยตรง และฐานข้อมูลจะไม่ทราบว่ามีขีดจำกัดนี้อยู่
  • ผู้ใช้งาน (User): กระบวนการทำงานในฐานะ numeric user id ภายในคอนเทนเนอร์ ซึ่งอาจไม่มีสิทธิ์เป็นเจ้าของไฟล์ใดๆ บนโฮสต์ของคุณ

ตำแหน่งที่จัดเก็บข้อมูลเป็นตัวกำหนดทุกอย่าง

มีทางเลือกที่ดีอยู่ 2 ทาง และข้อผิดพลาดที่พบบ่อยอยู่ 1 อย่าง

  • Named volume: pgdata:/var/lib/postgresql/data Docker จะสร้างไดเรกทอรีที่ /var/lib/docker/volumes/<project>_pgdata/_data และ entrypoint ของ image จะกำหนดสิทธิ์ความเป็นเจ้าของให้โดยอัตโนมัติในการรันครั้งแรก นี่คือวิธีที่เป็นค่าเริ่มต้น
  • Bind mount: /srv/appname/pg:/var/lib/postgresql/data คุณเป็นผู้เลือก path เอง ดังนั้นคุณต้องรับผิดชอบปัญหาเรื่องสิทธิ์การเข้าถึง (permissions) ด้วยตนเอง
  • ไม่มีการ mount เลย: ดูด้านบน ข้อมูลจะอยู่ใน container

ข้อดีข้อเสียโดยละเอียดเป็นหัวข้อเฉพาะทาง ซึ่ง bind mounts เทียบกับ named volumes ได้ครอบคลุมไว้แล้ว สำหรับฐานข้อมูล สรุปสั้นๆ คือ ให้ใช้ named volume เว้นแต่คุณจะมีเหตุผลเฉพาะเจาะจงที่ต้องระบุ path บนโฮสต์ และหากคุณจำเป็นต้องใช้ bind mount ให้วางไว้ในตำแหน่งที่เสถียร เช่น /srv/appname/pg แทนที่จะวางไว้ในไดเรกทอรีของโปรเจกต์ซึ่ง git clean อาจเข้าถึงได้

ข้อจำกัดที่สำคัญ: ห้ามวางไดเรกทอรีข้อมูลของฐานข้อมูลไว้บน NFS (network file system) หรือ network mount ใดๆ ที่คุณยังไม่ได้ทดสอบพฤติกรรมการล็อกและ fsync ของมัน ฐานข้อมูลถือว่าการทำ fsync ที่สำเร็จหมายถึงข้อมูลถูกเขียนลงบนสื่อบันทึกข้อมูลที่เสถียรแล้ว เมื่อสมมติฐานนั้นผิดพลาด คุณจะพบกับปัญหาข้อมูลเสียหายซึ่งอาจปรากฏให้เห็นในอีกหลายสัปดาห์ต่อมา

กำหนดชื่อ volume ให้คงที่เพื่อป้องกันกรณี volume หาย

Compose จะตั้งชื่อ volume โดยใช้รูปแบบ <project>_<volume> และชื่อโปรเจกต์จะใช้ชื่อไดเรกทอรีเป็นค่าเริ่มต้น ดังนั้นตัวตนของ volume จึงขึ้นอยู่กับชื่อไดเรกทอรี ซึ่งเป็นสิ่งที่ผู้ใช้มักเปลี่ยนโดยไม่ทันระวัง

หากคุณย้าย /srv/app ไปยัง /srv/app-old หรือเปลี่ยนชื่อคีย์ pgdata ในไฟล์ compose การสั่ง docker compose up -d ในครั้งถัดไปจะสร้าง volume ใหม่ที่ว่างเปล่าขึ้นมาทันที Postgres จะเริ่มต้นคลัสเตอร์ใหม่ในนั้น คอนเทนเนอร์จะทำงานได้ปกติ แอปพลิเคชันจะเริ่มทำงาน แต่ตารางข้อมูลทั้งหมดจะหายไป ข่าวดีคือ volume เก่ายังคงอยู่บนดิสก์ภายใต้ชื่อเดิม

docker volume ls
docker volume inspect app_pgdata

ให้กำหนดชื่อให้คงที่เพื่อป้องกันไม่ให้เหตุการณ์นี้เกิดขึ้น โดยระบุชื่อโปรเจกต์และชื่อ volume ให้ชัดเจน:

name: myapp

services:
  db:
    image: postgres:17
    volumes:
      - pgdata:/var/lib/postgresql/data

volumes:
  pgdata:
    name: myapp_pgdata

หากมี volume ตกค้างที่เก็บข้อมูลของคุณอยู่ ให้คัดลอกข้อมูลข้ามมาในขณะที่หยุดการทำงานของฐานข้อมูลไว้:

docker compose stop db
docker run --rm -v app_pgdata:/from -v myapp_pgdata:/to alpine sh -c 'cp -a /from/. /to/'
docker compose start db

หากคุณคัดลอกข้อมูลในขณะที่ฐานข้อมูลกำลังทำงาน คุณจะได้ไฟล์ที่เสียหายเนื่องจากมีการเขียนข้อมูลอยู่ตลอดเวลา ดังนั้นต้องหยุดการทำงานของฐานข้อมูลก่อนเสมอ

ใครเป็นเจ้าของไดเรกทอรีข้อมูล

อิมเมจอย่างเป็นทางการของ Postgres, MySQL และ MongoDB จะรันเซิร์ฟเวอร์ด้วย user id ที่ไม่มีสิทธิ์พิเศษ ซึ่งโดยปกติคือ 999 เมื่อคอนเทนเนอร์เริ่มทำงานในฐานะ root ตัว entrypoint จะเปลี่ยนความเป็นเจ้าของไดเรกทอรีข้อมูลให้เป็น user นั้นแล้วจึงลดสิทธิ์ลง นั่นคือเหตุผลที่การทำ bind mount ไดเรกทอรีว่างมักจะใช้งานได้ตั้งแต่ครั้งแรก

กระบวนการนี้จะล้มเหลวทันทีที่คุณตั้งค่า user: ในไฟล์ compose เพราะตัว entrypoint จะไม่มีสิทธิ์เหลือพอที่จะแก้ไขสิ่งใดได้ Postgres จะแจ้งเตือนเรื่องนี้โดยตรง:

initdb: error: could not change permissions of directory "/var/lib/postgresql/data": Operation not permitted

ไดเรกทอรีข้อมูลที่มีอยู่เดิมแต่ตั้งค่า mode ไม่ถูกต้องจะแสดงข้อความแจ้งเตือนที่ต่างออกไป ซึ่งควรจดจำไว้เพราะวิธีแก้ไขคือ chmod ไม่ใช่ chown:

FATAL:  data directory "/var/lib/postgresql/data" has invalid permissions
DETAIL:  Permissions should be u=rwx (0700) or u=rwx,g=rx (0750).

สำหรับ MongoDB หากใช้ bind mount ที่มี root เป็นเจ้าของ จะเกิดข้อผิดพลาดที่ไฟล์ lock:

Unable to create/open the lock file: /data/db/mongod.lock (Permission denied). Ensure the user executing mongod is the owner of the lock file and has the appropriate permissions.

วิธีแก้ไขคือการใช้คำสั่ง chown กับไดเรกทอรีบนโฮสต์โดยระบุเป็นตัวเลข id ไม่ใช่ชื่อ:

sudo chown -R 999:999 /srv/appname/pg
sudo chmod 700 /srv/appname/pg
ls -ldn /srv/appname/pg

ls -ldn จะแสดงผลเป็นตัวเลขแทนชื่อ และควรแสดงค่าเป็น 999 999 บัญชีชื่อ postgres บนโฮสต์ของคุณและบัญชีชื่อ postgres ภายในอิมเมจนั้นไม่เกี่ยวข้องกัน: เคอร์เนลจะเปรียบเทียบค่าตัวเลข ส่วนชื่อนั้นจะถูกค้นหาแยกกันในแต่ละฝั่ง วิธีที่ PUID และ PGID แมปผู้ใช้บนโฮสต์เข้ากับคอนเทนเนอร์ ได้อธิบายกระบวนการแมปนี้ไว้อย่างละเอียด ภายใต้การใช้งาน rootless Docker หรือการทำ user namespace remapping ค่าตัวเลขเหล่านี้จะเปลี่ยนไปอีก ดังนั้นควรตรวจสอบค่า id จากคอนเทนเนอร์ที่กำลังรันอยู่แทนการคาดเดาว่าเป็น 999

การใช้ named volumes จะทำให้ปัญหาในส่วนนี้หมดไปในการรันครั้งแรก เนื่องจาก Docker จะสร้างไดเรกทอรีว่างขึ้นมาและตัว entrypoint จะเป็นเจ้าของไดเรกทอรีนั้นโดยอัตโนมัติ

การอัปเกรด: การอัปเกรดแพ็กเกจเทียบกับการเปลี่ยน image tag

บนโฮสต์ apt upgrade จะช่วยให้คุณขยับเวอร์ชันย่อยไปได้ ระบบปฏิบัติการของคุณจะไม่ข้ามเวอร์ชันหลักของฐานข้อมูลโดยที่คุณไม่ต้องการ และเมื่อคุณตัดสินใจที่จะข้ามเวอร์ชัน คุณสามารถติดตั้งไบนารีทั้งสองชุดไว้พร้อมกันได้ ซึ่งเป็นสิ่งที่ pg_upgrade ต้องการพอดี

ในคอนเทนเนอร์ tag คือเวอร์ชัน ดังนั้นการอัปเกรดจึงทำได้เพียงแค่แก้ไขบรรทัดเดียว ซึ่งทำให้การอัปเกรดเวอร์ชันย่อยเป็นเรื่องง่าย แต่การอัปเกรดเวอร์ชันหลักต้องมีขั้นตอนที่ชัดเจน

เปลี่ยน postgres:16 เป็น postgres:17 แล้วรัน docker compose up -d จากนั้นคอนเทนเนอร์จะหยุดทำงานทันที:

PostgreSQL Database directory appears to contain a database; Skipping initialization
FATAL:  database files are incompatible with server
DETAIL:  The data directory was initialized by PostgreSQL version 16, which is not compatible with this version 17.

ไม่มีสิ่งใดเสียหาย ไบนารีใหม่ปฏิเสธที่จะอ่านโครงสร้างแคตตาล็อกบนดิสก์แบบเก่า ซึ่งมีการเปลี่ยนแปลงระหว่างเวอร์ชันหลัก ให้เปลี่ยน tag กลับเป็น postgres:16 แล้วมันจะเริ่มทำงานใหม่ การย้อนกลับ (rollback) เช่นนี้คือข้อได้เปรียบที่แท้จริงเพียงอย่างเดียวที่คอนเทนเนอร์มอบให้คุณในการอัปเกรด

แนวทางที่รองรับคือการ dump และ restore โดย PostgreSQL แนะนำให้ใช้ไคลเอนต์เวอร์ชันใหม่กว่าเป็นผู้ทำ dump ดังนั้นให้รันคำสั่งจาก image ใหม่ไปยังเซิร์ฟเวอร์เก่าที่ยังคงทำงานอยู่บน compose network:

docker run --rm --network myapp_default -e PGPASSWORD="$POSTGRES_PASSWORD" \
  postgres:17 pg_dumpall -h db -U postgres > /srv/backups/all.sql
ls -lh /srv/backups/all.sql
tail -n 2 /srv/backups/all.sql

ไฟล์ที่ได้ควรมีขนาดอย่างน้อยหลายสิบกิโลไบต์และลงท้ายด้วยบรรทัดที่ระบุว่า PostgreSQL database cluster dump complete หากไฟล์มีขนาดเพียงไม่กี่ร้อยไบต์ แสดงว่าการ dump ล้มเหลว และคุณกำลังจะลบ volume ทิ้งโดยเปล่าประโยชน์ หลังจากตรวจสอบแล้วเท่านั้นจึงค่อยดำเนินการ:

docker compose down
docker volume rm myapp_pgdata
# edit the compose file: image: postgres:17
docker compose up -d db
docker compose exec -T db psql -U postgres -f /dev/stdin < /srv/backups/all.sql

เอนจินอื่นมีรายละเอียดที่แตกต่างกัน:

  • MySQL 8 จะอัปเกรด data dictionary ของตัวเองเมื่อเริ่มทำงาน ดังนั้นการเปลี่ยน tag เวอร์ชันย่อยมักทำได้เพียงแค่รีสตาร์ท โปรดอ่านบันทึกการปล่อยเวอร์ชัน (release notes) ก่อนการข้ามเวอร์ชันหลัก และควรทำ dump ไว้ก่อนเสมอไม่ว่าจะกรณีใดก็ตาม
  • MariaDB คาดหวังให้รัน mariadb-upgrade หลังจากเซิร์ฟเวอร์เริ่มทำงานบนเวอร์ชันใหม่แล้ว
  • MongoDB ต้องอัปเกรดทีละเวอร์ชันหลัก และหลังจากแต่ละขั้นตอน คุณต้องตั้งค่า feature compatibility version ก่อนที่จะดำเนินการต่อ การข้ามเวอร์ชันจะทำให้ mongod ปฏิเสธที่จะเริ่มทำงานและบันทึก log บรรทัด UPGRADE PROBLEM ที่ระบุ featureCompatibilityVersion ตั้งแต่ MongoDB 7.0 เป็นต้นไป คำสั่งนี้ต้องการ flag ยืนยันที่ชัดเจนคือ db.adminCommand({ setFeatureCompatibilityVersion: "8.0", confirm: true })
  • Redis สามารถโหลดไฟล์ snapshot เวอร์ชันเก่าได้โดยไม่มีปัญหา แต่ไม่สามารถโหลดไฟล์เวอร์ชันใหม่กว่าได้ ดังนั้นการอัปเกรดจึงทำได้เพียงแค่รีสตาร์ท และการดาวน์เกรดอาจทำให้ไม่สามารถโหลดข้อมูลได้

กฎทั่วไปคือ: คอนเทนเนอร์ทำให้การดาวน์เกรดเป็นเรื่องง่าย แต่ไม่ได้ทำให้การอัปเกรดง่ายขึ้นแต่อย่างใด

ทำไม container ฐานข้อมูลของฉันถึงหยุดทำงานด้วย exit code 137?

สาเหตุเกิดจาก kernel's out of memory (OOM) killer สั่งยุติการทำงานของ container นั้น รหัส 137 มาจาก 128 บวกกับ signal 9

docker compose ps
docker inspect myapp-db-1 | grep -i oomkilled
journalctl -k | tail -n 20

เมื่อ docker compose ps แสดงผล Exited (137) และบรรทัด inspect อ่านได้ว่า "OOMKilled": true รวมถึงใน kernel log มีรายการที่ตรงกัน:

Memory cgroup out of memory: Killed process 4711 (postgres) total-vm:2170416kB

กลไกนี้มักสร้างความประหลาดใจให้กับผู้ใช้งาน โดยปกติ PostgreSQL และ MySQL จะคำนวณขนาด buffer จากหน่วยความจำรวมที่ระบบรายงาน ซึ่งการกำหนด cgroup limit ไม่ได้เปลี่ยนตัวเลขดังกล่าว ในโฮสต์ขนาด 16 GB ที่มีการจำกัดไว้ 2 GB ฐานข้อมูลจะวางแผนการทำงานเสมือนว่ามีหน่วยความจำ 16 GB ส่งผลให้ cgroup สั่งยุติการทำงานก่อนที่โฮสต์จะประสบปัญหาหน่วยความจำไม่เพียงพอ ดังนั้นการจำกัดหน่วยความจำเพียงอย่างเดียวจึงไม่เพียงพอ คุณต้องระบุขนาดหน่วยความจำให้ฐานข้อมูลทราบด้วย:

  • PostgreSQL: ตั้งค่า shared_buffers และให้ความสำคัญกับ work_mem โดย work_mem จะถูกจัดสรรต่อการดำเนินการ sort หนึ่งครั้งต่อการเชื่อมต่อ ดังนั้นการตั้งค่าที่สูงเกินไปเมื่อคูณกับจำนวนการเชื่อมต่อ 50 รายการ มักเป็นสาเหตุที่ทำให้ container หยุดทำงานเมื่อมีโหลดสูง แทนที่จะเป็นตอนเริ่มต้นระบบ
  • MySQL และ MariaDB: ตั้งค่า innodb_buffer_pool_size ซึ่งค่าเริ่มต้นคือ 128M และควรปิด innodb_dedicated_server ใน container เนื่องจากหน้าที่หลักของมันคือการปรับขนาดตัวเองตามหน่วยความจำที่ตรวจพบจากเครื่อง
  • MongoDB: ตั้งค่า WiredTiger cache size ให้ชัดเจน แทนที่จะปล่อยให้ระบบคาดเดาจากหน่วยความจำของโฮสต์
  • Redis: maxmemory มีค่าเริ่มต้นเป็นไม่จำกัด ทำให้ Redis ขยายตัวจนกว่า cgroup จะสั่งหยุด ให้ตั้งค่า maxmemory ให้ต่ำกว่าขีดจำกัดของ container อย่างเหมาะสม และเลือก maxmemory-policy ที่เหมาะสม

Postgres จะรายงานเหตุการณ์นี้จากฝั่งของมันเองด้วย ซึ่งคุณจะพบข้อความสองบรรทัดนี้ใน log:

LOG:  server process (PID 123) was terminated by signal 9: Killed
LOG:  terminating any other active sessions due to crash of another server process

การที่ backend หนึ่งถูกสั่งยุติการทำงานจะบังคับให้ backend อื่นๆ ทั้งหมดต้องเริ่มทำงานใหม่ เนื่องจากหน่วยความจำที่ใช้ร่วมกัน (shared memory) อาจอยู่ในสถานะไม่สอดคล้องกัน ซึ่งจะทำให้เกิดการเชื่อมต่อจำนวนมหาศาล (connection storm) กับแอปพลิเคชันของคุณ ไม่ใช่เหตุการณ์ที่เงียบสงบ การตั้งค่า memory limits ใน Docker Compose ครอบคลุมถึงไวยากรณ์และความแตกต่างระหว่าง mem_limit และรูปแบบ deploy.resources

ปัญหาเหล่านี้ไม่ได้หายไปไหนเมื่ออยู่บนโฮสต์ แต่เพียงแค่ย้ายที่ หากไม่มี cgroup ฐานข้อมูลจะแย่งชิงทรัพยากรกับทุกอย่างบนเครื่อง และ kernel OOM killer ของโฮสต์จะเลือกเหยื่อตามคะแนน (score) ซึ่งอาจรวมถึง sshd ด้วย การกำหนดขีดจำกัดที่ทำให้ฐานข้อมูลหยุดทำงานอย่างคาดการณ์ได้นั้นจัดการได้ง่ายกว่าการปล่อยให้โฮสต์เกิด OOM จนทำให้คุณไม่สามารถเข้าถึงระบบได้

การสำรองข้อมูล: ทำ dump ไว้ภายใน แล้วสำรองข้อมูลออกภายนอก

ห้ามสำรองข้อมูลฐานข้อมูลที่กำลังทำงานอยู่ด้วยการคัดลอกไดเรกทอรีข้อมูลโดยตรง การคัดลอกระดับไฟล์ในขณะที่เซิร์ฟเวอร์กำลังเขียนข้อมูลจะทำให้ได้ไฟล์ที่ไม่สมบูรณ์ (torn copy) และคุณจะพบปัญหานี้ในตอนที่กู้คืนข้อมูล

มีสองวิธีที่ถูกต้อง: ใช้เครื่องมือของฐานข้อมูลเองเพื่อทำ dump ในขณะที่ระบบทำงานอยู่แล้วค่อยสำรองไฟล์ dump นั้น หรือหยุด container แล้วคัดลอก volume ในขณะที่ระบบหยุดทำงาน (cold backup)

docker compose exec -T db pg_dump -U postgres -Fc appdb > /srv/backups/appdb.dump
docker compose exec -T db mysqldump -u root -p"$MYSQL_ROOT_PASSWORD" --single-transaction --all-databases > /srv/backups/mysql.sql
docker compose exec -T db mongodump --archive --gzip --db appdb > /srv/backups/appdb.archive.gz
docker compose exec -T redis redis-cli BGSAVE

-T มีความสำคัญ หากไม่มีค่านี้ docker compose exec อาจเชื่อมต่อ terminal เข้ากับคำสั่ง ซึ่งเลเยอร์ของ terminal จะเพิ่ม carriage return เข้าไปในสตรีมของข้อมูล ส่งผลให้ไฟล์ dump แบบข้อความเกิดข้อผิดพลาดแปลกๆ ในตอนกู้คืน และไฟล์ dump แบบไบนารีจะเสียหายทันที กระบวนการนี้จะล้มเหลวโดยไม่มีการแจ้งเตือนในขณะสำรองข้อมูล แต่จะแสดงข้อผิดพลาดรุนแรงในอีกหนึ่งเดือนถัดมา

--single-transaction ช่วยให้ mysqldump ได้รับ snapshot ของตาราง InnoDB ที่สอดคล้องกันโดยไม่ต้องล็อกทั้งเซิร์ฟเวอร์

คำสั่งเหล่านี้จะเขียนไฟล์ออกมาไฟล์ละหนึ่งชุด ซึ่งยังไม่ใช่ระบบสำรองข้อมูลที่สมบูรณ์ เนื่องจากไม่มีการกำหนดระยะเวลาเก็บรักษา (retention), ไม่มีการคัดลอกออกนอกเครื่อง และไม่มีการตรวจสอบความถูกต้อง คุณควรส่งไดเรกทอรีที่เก็บไฟล์ dump ให้กับเครื่องมือที่จัดการทั้งสามส่วนนี้ ซึ่งเป็นจุดประสงค์ของ การสำรองข้อมูลด้วย restic จาก VPS จงสำรองข้อมูล /srv/backups ไม่ใช่ /var/lib/docker/volumes

จากนั้นให้ทดสอบการกู้คืนข้อมูล เพราะการสำรองข้อมูลที่คุณไม่เคยทดสอบกู้คืนจริง ไม่ถือว่าเป็นข้อมูลสำรอง:

docker compose exec -T db createdb -U postgres restore_test
docker compose exec -T db pg_restore -U postgres -d restore_test < /srv/backups/appdb.dump
docker compose exec -T db psql -U postgres -d restore_test -c '\dt'

\dt ควรแสดงรายการตารางของแอปพลิเคชันของคุณ หากผลลัพธ์ว่างเปล่า หรือเกิด Did not find any relations. แสดงว่าไฟล์ dump ไม่ได้เป็นอย่างที่คุณคิด ให้ลบ restore_test ทิ้งเมื่อดำเนินการเสร็จสิ้น

คำสั่งที่ลบทุกอย่าง

docker compose down -v

คำสั่ง down แบบปกติจะลบเฉพาะคอนเทนเนอร์และเน็ตเวิร์ก ส่วน -v จะลบทุก named volume ที่ประกาศไว้ในไฟล์ compose นั้น รวมถึง anonymous volume ทั้งหมดที่เชื่อมต่อกับคอนเทนเนอร์เหล่านั้นด้วย คำสั่งนี้ไม่มีการถามยืนยันและไม่สามารถย้อนกลับได้ นี่เป็นวิธีที่พบบ่อยที่สุดที่ทำให้ฐานข้อมูลที่โฮสต์เองถูกลบโดยไม่ตั้งใจ ซึ่งมักเกิดขึ้นระหว่างการแก้ไขปัญหาอื่นที่ไม่เกี่ยวข้อง เพียงเพราะคำแนะนำในฟอรัมบอกให้รันคำสั่งนี้

สี่วิธีในการลดความเสียหายที่อาจเกิดขึ้น:

  • ประกาศ volume ของฐานข้อมูลเป็น external: true โดย Compose จะไม่ลบ volume ที่ไม่ได้อยู่ภายใต้การจัดการของมัน ดังนั้น -v จึงไม่สามารถลบข้อมูลส่วนนี้ได้ คุณต้องสร้าง volume นี้ขึ้นมาก่อนหนึ่งครั้งด้วย docker volume create myapp_pgdata
  • ใช้ docker compose stop และ docker compose start สำหรับการรีสตาร์ทตามปกติ โดย ความแตกต่างระหว่าง down กับ stop ใน Compose จะอธิบายรายละเอียดว่าแต่ละคำสั่งลบอะไรบ้าง
  • เก็บไฟล์ dump ไว้ใน path ของโฮสต์ที่อยู่นอกเหนือ volume ทุกตัวที่ compose จัดการ
  • ห้ามคัดลอก -v จากคำแนะนำในการแก้ไขปัญหามาใช้กับ stack ที่มีข้อมูลสำคัญของคุณโดยเด็ดขาด

ห้ามเปิดพอร์ตฐานข้อมูลสู่สาธารณะ

บรรทัดนี้ทำให้ฐานข้อมูลของคุณอยู่บนอินเทอร์เน็ตสาธารณะ:

    ports:
      - "5432:5432"

มันผูกเข้ากับทุกอินเทอร์เฟซ Docker จะเปิดพอร์ตโดยการเขียนปลายทางของแพ็กเก็ตใหม่ก่อนที่กฎ input ของไฟร์วอลล์จะได้รับข้อมูล และกฎของ ufw นั้นอยู่ใน chain ของ input ดังนั้น ufw deny 5432 จึงไม่มีผลใดๆ เหตุใดพอร์ตที่ Docker เปิดจึงข้าม ufw แสดงลำดับการทำงานของ chain

แอปพลิเคชันที่อยู่ใน compose project เดียวกันสามารถเข้าถึงฐานข้อมูลได้ผ่านชื่อ service บนเครือข่ายของ compose จึงไม่จำเป็นต้องเปิดพอร์ต ให้ลบบล็อกดังกล่าวทิ้ง หากคุณต้องการให้ไคลเอนต์บนโฮสต์เข้าถึงได้ ให้ผูกไว้กับ loopback เท่านั้น:

    ports:
      - "127.0.0.1:5432:5432"

ตรวจสอบว่าพอร์ตใดกำลังเปิดใช้งานอยู่จริง:

sudo ss -ltnp | grep 5432

127.0.0.1:5432 คือสิ่งที่คุณต้องการ ส่วน 0.0.0.0:5432 หมายความว่าใครก็ตามสามารถลองสุ่มรหัสผ่านของคุณได้

สิ่งที่ควรเรียกใช้งานในแต่ละจุด

แอปพลิเคชันหนึ่งรายการบนหนึ่ง VPS: ใช้ Container โดยกำหนด named volume ที่ระบุชื่อชัดเจน ไม่ต้องเปิดพอร์ตสาธารณะ กำหนดขีดจำกัดหน่วยความจำให้สอดคล้องกับการตั้งค่าฐานข้อมูล และทำ nightly dump ไปยัง path บนโฮสต์เพื่อให้ restic จัดเก็บข้อมูล เริ่มต้นจาก การติดตั้ง Docker บน VPS แบบสะอาด และเก็บ stack ทั้งหมดไว้ในไฟล์ compose เดียวที่คุณ commit ลง git ข้อดีคือคุณสามารถตรวจสอบเวอร์ชันของฐานข้อมูลได้ผ่านประวัติการแก้ไขใน git

โฮสต์ที่รันหลายบริการ: ใช้ Container โดยแยกฐานข้อมูลหนึ่งชุดต่อหนึ่งแอปพลิเคชัน ไม่ควรใช้เซิร์ฟเวอร์ฐานข้อมูลร่วมกันสำหรับทุกแอป เพราะการใช้เซิร์ฟเวอร์ร่วมกันจะผูกทุกแอปเข้ากับรอบการอัปเกรดเดียวกัน และหากมี query ที่ทำงานผิดปกติเพียงรายการเดียวอาจส่งผลให้ทุกบริการล่มได้ ให้กำหนดขีดจำกัดหน่วยความจำแยกกันในแต่ละ container เพื่อจำกัดผลกระทบของ query ที่มีปัญหาไว้เฉพาะแอปนั้นๆ การรัน Postgres หลาย instance ขนาดเล็กอาจใช้พื้นที่ดิสก์เพิ่มขึ้นเล็กน้อย แต่ช่วยลดความซับซ้อนในการจัดการลงได้มาก

ฐานข้อมูลคือหัวใจของผลิตภัณฑ์: ให้รันบนโฮสต์โดยติดตั้งผ่าน package repository ของผู้ผลิต หรือเลือกใช้บริการแบบ managed service pg_upgrade จำเป็นต้องติดตั้ง binary ทั้งสองเวอร์ชันหลักไว้พร้อมกัน ซึ่งการติดตั้งผ่าน package จะรองรับในขณะที่ image แบบเวอร์ชันเดียวทำไม่ได้ การทำ replication และการกู้คืนข้อมูล ณ เวลาใดเวลาหนึ่ง (point in time recovery) ด้วยการทำ WAL (write ahead log) archiving จะทำได้ง่ายกว่าเมื่อฐานข้อมูลเป็นเจ้าของเครื่องและดิสก์นั้นโดยตรง เลือกแนวทางที่เรียบง่ายและเสถียรสำหรับระบบที่จะแจ้งเตือนคุณตอน 03:00 น.

แอปพลิเคชันมีขนาดเล็ก: พิจารณาไม่ใช้เซิร์ฟเวอร์ฐานข้อมูลเลย สำหรับเว็บแอปพลิเคชันที่มีผู้เขียนข้อมูลเพียงคนเดียวบน VPS หนึ่งเครื่อง การใช้ SQLite ในสภาพแวดล้อม production บน VPS มักเป็นทางเลือกที่ดีกว่า โดยการสำรองข้อมูลจะเป็นเพียงการคัดลอกไฟล์เดียว และการอัปเกรดจะเป็นเพียงการอัปเดตเวอร์ชันของไลบรารีเท่านั้น

FAQ

การรันฐานข้อมูลสำหรับใช้งานจริง (production) ใน Docker ปลอดภัยหรือไม่?

ปลอดภัย หากเป็นแอปพลิเคชันที่รันบนเซิร์ฟเวอร์เดียว คอนเทนเนอร์คือกระบวนการหนึ่งใน Linux ที่ถูกจำกัดด้วย namespaces และ cgroups ดังนั้นเมื่อมีการ mount volume ฐานข้อมูลจะเขียนข้อมูลลงในระบบไฟล์ของโฮสต์โดยตรงเหมือนกับการติดตั้งผ่านแพ็กเกจปกติ ความเสี่ยงที่เกิดขึ้นมักเป็นเรื่องของการจัดการมากกว่าเรื่องความเร็ว เช่น การใช้ volume ที่ไม่ได้ระบุชื่อให้ชัดเจน, การทำ bind mount ที่เจ้าของไฟล์ (user id) ไม่ถูกต้อง, การกู้คืนข้อมูลที่ไม่เคยทดสอบมาก่อน และ docker compose down -v หากจัดการสี่ประเด็นนี้ได้ คอนเทนเนอร์ก็จะทำงานได้ตามปกติ ควรเปลี่ยนไปติดตั้งบนโฮสต์โดยตรงเมื่อฐานข้อมูลเป็นภาระงานหลักและคุณต้องการ pg_upgrade, การทำ replication หรือการกู้คืนข้อมูล ณ จุดเวลาใดเวลาหนึ่ง (point in time recovery)

ควรใช้ bind mount หรือ named volume สำหรับเก็บข้อมูลฐานข้อมูล?

ควรใช้ named volume เว้นแต่คุณจะมีเหตุผลเฉพาะเจาะจงที่ต้องระบุ path บนโฮสต์ Docker จะสร้างไดเรกทอรีและ image entrypoint จะกำหนดสิทธิ์ความเป็นเจ้าของให้โดยอัตโนมัติในการเริ่มทำงานครั้งแรก ทำให้ปัญหาเรื่อง permission ไม่เกิดขึ้น ควรระบุ volume ให้ชัดเจนด้วย name: หรือทำเครื่องหมายเป็น external: true มิฉะนั้นการเปลี่ยนชื่อไดเรกทอรีโปรเจกต์จะทำให้เกิด volume ใหม่ที่ว่างเปล่าและฐานข้อมูลที่ว่างเปล่าโดยที่คุณไม่รู้ตัว การใช้ bind mount สามารถทำได้หากคุณใช้คำสั่ง chown บนไดเรกทอรีของโฮสต์ให้ตรงกับ numeric user id ที่ image ใช้งาน ซึ่งสำหรับ image ทางการของ Postgres, MySQL และ MongoDB คือ 999 ให้ตรวจสอบด้วย ls -ldn เนื่องจาก ls -l จะแสดงชื่อผู้ใช้บนโฮสต์ของคุณสำหรับตัวเลขนั้น ซึ่งชื่อดังกล่าวไม่มีความหมายภายในคอนเทนเนอร์

คำสั่ง docker compose down -v ลบอะไรบ้าง?

คำสั่งนี้จะลบคอนเทนเนอร์และเครือข่ายเหมือนกับการใช้ down ปกติ แต่ -v จะลบทุก named volume ที่ประกาศไว้ในไฟล์ compose นั้น รวมถึง anonymous volume ทั้งหมดที่ผูกอยู่กับคอนเทนเนอร์เหล่านั้นด้วย ซึ่งรวมถึงฐานข้อมูลของคุณด้วย ทั้งนี้จะไม่มีการถามยืนยันและไม่สามารถกู้คืนได้ ส่วน volume ที่ทำเครื่องหมาย external: true จะไม่ถูกลบ ซึ่งเป็นเหตุผลหลักที่ควรทำเครื่องหมาย volume ของฐานข้อมูลว่าเป็น external สำหรับการรีสตาร์ทตามปกติ ให้ใช้ docker compose stop และ docker compose start แทน

จะอัปเกรด PostgreSQL เป็นเวอร์ชันหลัก (major version) ใหม่ใน Docker ได้อย่างไร?

ให้ใช้วิธี dump และ restore การเปลี่ยน postgres:16 เป็น postgres:17 แล้วรีสตาร์ทจะทำให้เกิด FATAL: database files are incompatible with server พร้อมข้อความ DETAIL ที่ระบุชื่อทั้งสองเวอร์ชัน เนื่องจาก binary ใหม่จะไม่สามารถอ่านโครงสร้าง catalog แบบเก่าได้ ข้อมูลจะไม่เสียหาย: หากเปลี่ยน tag กลับเป็นเวอร์ชันเดิมก็จะสามารถเริ่มทำงานได้ตามปกติ ให้ทำ pg_dumpall โดยใช้ client ของเวอร์ชันใหม่กับคอนเทนเนอร์เวอร์ชันเก่าที่กำลังทำงานอยู่ ตรวจสอบว่าไฟล์ลงท้ายด้วย PostgreSQL database cluster dump complete จากนั้นจึงเริ่มคอนเทนเนอร์ด้วย tag ใหม่บน volume ที่ว่างเปล่าแล้วโหลดไฟล์ dump เข้าไป สำหรับการอัปเกรดเวอร์ชันย่อย (minor upgrade) ภายในเวอร์ชันหลักเดียวกัน เพียงแค่ pull image ใหม่และรีสตาร์ทก็เพียงพอแล้ว

ทำไมคอนเทนเนอร์ฐานข้อมูลของฉันถึงหยุดทำงานด้วยรหัส 137?

137 คือ 128 บวกกับสัญญาณ 9 ซึ่งหมายความว่ามีบางอย่างสั่งยุติกระบวนการทำงานทันที ให้รัน docker inspect <container> | grep -i oomkilled หากค่าที่ได้คือ true แสดงว่าคอนเทนเนอร์ใช้งานหน่วยความจำเกินขีดจำกัดของ cgroup สาเหตุทั่วไปคือ PostgreSQL และ MySQL จะอ่านค่าหน่วยความจำรวมจากโฮสต์และไม่เห็นขีดจำกัดของคอนเทนเนอร์ ทำให้มันวางแผนการใช้หน่วยความจำสำหรับ 16 GB ในขณะที่อยู่ในพื้นที่จำกัดเพียง 2 GB ให้ตั้งค่า shared_buffers และ work_mem หรือ innodb_buffer_pool_size ให้เหมาะสมกับขีดจำกัดที่คุณกำหนดให้คอนเทนเนอร์ และตรวจสอบ journalctl -k เพื่อดูบรรทัด Memory cgroup out of memory ที่ตรงกันเพื่อยืนยันว่า kernel เลือกยุติกระบวนการใด