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

Deploy ทีเดียวทั้งก้อน ไม่มีเว็บครึ่ง ๆ และย้อนกลับได้ในคำสั่งเดียว

ก๊อปไฟล์ทับโฟลเดอร์เว็บตรง ๆ ผู้ใช้จะเจอเว็บที่อัปโหลดไปครึ่งเดียว วิธีสลับโฟลเดอร์ที่เว็บนี้ใช้ ช่องว่างเล็ก ๆ ที่ยังเหลือ และแบบ symlink ที่สลับได้ทันที

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

อ่านจบแล้วจะรู้
  • อย่าอัปโหลดทับของที่กำลังให้บริการ เตรียมของใหม่ให้ครบในที่อื่นก่อน แล้วสลับทีเดียว
  • การเปลี่ยนชื่อในดิสก์เดียวกันเกิดทันที ไม่มีสถานะครึ่ง ๆ แต่การสลับสองขั้นยังมีช่องว่างสั้น ๆ
  • เก็บเวอร์ชันก่อนไว้เสมอ ย้อนกลับควรเป็นคำสั่งเดียวที่ไม่ต้องคิด

วิธี deploy ที่ง่ายที่สุดคือก๊อปไฟล์ใหม่ทับโฟลเดอร์เว็บ ใช้ได้ส่วนใหญ่ แต่ในช่วงไม่กี่วินาทีที่ไฟล์กำลังส่ง ผู้ใช้ที่เข้ามาพอดีจะได้หน้า HTML ใหม่ที่ชี้ไปหาไฟล์ CSS ที่ยังไม่มาถึง หน้าเว็บก็โผล่มาแบบไม่มีสไตล์ หรือได้ HTML เก่าผสม JavaScript ใหม่ แล้วพังแบบอธิบายไม่ได้ บทความนี้เล่าวิธีที่เว็บนี้ใช้ deploy ทุกครั้ง จุดอ่อนเล็ก ๆ ที่ยังมี และวิธีที่ไม่มีจุดอ่อนนั้น

ทดสอบบน Ubuntu 24.04 กับ nginx เว็บนี้เป็น static site ขนาดราว 1.3MB ทำด้วย Astro

ปัญหาของการก๊อปทับ

เว็บที่ build ด้วยเครื่องมือสมัยใหม่ ตั้งชื่อไฟล์ CSS และ JavaScript ตามเนื้อหา เช่น index.xB_X6hmL.css แก้เนื้อหาเมื่อไหร่ชื่อก็เปลี่ยน ข้อดีคือให้เบราว์เซอร์ cache ไฟล์พวกนี้ได้เป็นปี แต่ก็แปลว่า HTML รุ่นใหม่ใช้ได้กับไฟล์รุ่นใหม่เท่านั้น

ถ้าก๊อปทับ ลำดับที่ไฟล์มาถึงไม่แน่นอน

เวลาในโฟลเดอร์เว็บผู้ใช้ที่เข้ามาตอนนี้
วินาทีที่ 0ของเก่าครบปกติ
วินาทีที่ 1index.html ใหม่ แต่ CSS ใหม่ยังไม่มาหน้าไม่มีสไตล์
วินาทีที่ 2ครบปกติ

หลักการ: อย่าแก้ของที่กำลังให้บริการ เตรียมของใหม่ให้ครบในที่อื่น แล้วสลับทีเดียว

วิธีที่เว็บนี้ใช้

สคริปต์ deploy ทั้งหมดอยู่ในไฟล์เดียว รันด้วย npm run deploy

npm run build
tar -C dist -czf /tmp/sixam-dist.tgz .
scp -q /tmp/sixam-dist.tgz prod:/tmp/sixam-dist.tgz
ssh prod 'set -e
  rm -rf /tmp/sixam-new && mkdir -p /tmp/sixam-new
  tar -C /tmp/sixam-new -xzf /tmp/sixam-dist.tgz
  sudo rm -rf /var/www/6amdev.old
  if [ -d /var/www/6amdev ]; then sudo mv /var/www/6amdev /var/www/6amdev.old; fi
  sudo mv /tmp/sixam-new /var/www/6amdev
  sudo chown -R www-data:www-data /var/www/6amdev
  curl -s -o /dev/null -w "6amdev.com %{http_code}\n" https://6amdev.com/'

ทีละขั้น

  1. build ในเครื่อง แล้วอัดเป็นไฟล์เดียว ส่งไฟล์เดียวเร็วกว่าส่งไฟล์ย่อยหลายร้อยไฟล์ และไม่มีโอกาสส่งไปครึ่งเดียวแล้วถูกใช้งาน
  2. แตกไฟล์ในโฟลเดอร์ใหม่ /tmp/sixam-new ซึ่งยังไม่มีใครเห็น ถ้าขั้นนี้พัง set -e หยุดสคริปต์ เว็บจริงไม่ถูกแตะเลย
  3. ย้ายของเก่าไปเป็น .old เก็บไว้ย้อนกลับ
  4. ย้ายของใหม่เข้าที่
  5. ตรวจว่าเว็บตอบ 200

ทำไม mv ถึงเร็วทันที

mv ภายในดิสก์เดียวกัน ไม่ได้ก๊อปไฟล์ แค่เปลี่ยนชื่อในสารบัญของระบบไฟล์ เกิดขึ้นทันทีไม่ว่าโฟลเดอร์จะใหญ่แค่ไหน และไม่มีสถานะครึ่ง ๆ คือเปลี่ยนแล้วหรือยังไม่เปลี่ยน

แต่ต้องอยู่ ดิสก์เดียวกัน จริง ถ้า /tmp เป็นอีกพาร์ทิชันหรือเป็นหน่วยความจำ (tmpfs) mv จะกลายเป็นก๊อปแล้วลบ ซึ่งกลับไปมีสถานะครึ่ง ๆ อีก ตรวจได้ด้วย

df -h /tmp /var/www

บนเครื่องของเรา ทั้งสองอยู่บน /dev/vda1 เดียวกัน

ช่องว่างที่ยังเหลือ

วิธีนี้ดีกว่าก๊อปทับมาก แต่ยังไม่สมบูรณ์ ระหว่างขั้น 3 กับขั้น 4 มีเสี้ยววินาทีที่โฟลเดอร์ /var/www/6amdev ไม่มีอยู่เลย คำขอที่มาตรงจังหวะนั้นจะได้ 404 ช่องว่างนี้สั้นมาก สำหรับเว็บความรู้ยอมรับได้ แต่สำหรับระบบที่มีคนใช้ตลอดเวลา ควรปิดให้สนิท

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

1แตกไฟล์ลง /tmp/sixam-newยังไม่มีใครเห็น2mv 6amdev → 6amdev.oldเก็บไว้ย้อนกลับ!เสี้ยววินาทีที่ไม่มีโฟลเดอร์คำขอที่มาตรงนี้ได้ 4043mv sixam-new → 6amdevเปลี่ยนชื่อ ไม่ได้ก๊อป4curl → 200ตรวจว่าเว็บตอบsymlink: mv -T current.tmp currentเปลี่ยนชื่อครั้งเดียว ไม่มีช่องว่าง
แบบสลับโฟลเดอร์ของเว็บนี้มีช่องว่างเสี้ยววินาทีระหว่าง mv สองครั้ง แบบ symlink ปิดช่องนั้น

แทนที่ nginx จะชี้ไปโฟลเดอร์จริง ให้ชี้ไปที่ symlink ชื่อ current แล้วแต่ละรุ่นเป็นโฟลเดอร์แยก

/var/www/6amdev/
├── releases/
│   ├── 20261015-1731/
│   └── 20261016-0930/
└── current -> releases/20261016-0930

nginx ตั้ง root /var/www/6amdev/current; ตอน deploy

REL=/var/www/6amdev/releases/$(date +%Y%m%d-%H%M)
mkdir -p "$REL" && tar -C "$REL" -xzf /tmp/site.tgz
chown -R www-data:www-data "$REL"
ln -s "$REL" /var/www/6amdev/current.tmp
mv -T /var/www/6amdev/current.tmp /var/www/6amdev/current

บรรทัดสุดท้ายคือหัวใจ mv -T เปลี่ยนชื่อ symlink ใหม่ทับตัวเก่า ด้วยการเปลี่ยนชื่อครั้งเดียว ทุกคำขอเห็นรุ่นเก่าหรือรุ่นใหม่อย่างใดอย่างหนึ่งเสมอ ไม่มีจังหวะที่ไม่มีอะไรเลย การสร้าง symlink ชื่อชั่วคราวก่อนแล้วค่อย mv -T ทำให้มั่นใจได้ว่าการสลับเป็นการเปลี่ยนชื่อครั้งเดียวจริง โดยไม่ต้องพึ่งว่าเครื่องมือแต่ละเวอร์ชันทำงานข้างในยังไง

ย้อนกลับ

ทุกวิธีต้องย้อนกลับได้ด้วยคำสั่งเดียว เพราะตอนที่ต้องย้อน คือตอนที่กำลังมีปัญหาและไม่ควรต้องคิดเยอะ

แบบสลับโฟลเดอร์ของเว็บนี้

cd /var/www && sudo mv 6amdev 6amdev.bad && sudo mv 6amdev.old 6amdev

แบบ symlink แค่ชี้ current กลับไปรุ่นก่อน

ln -s /var/www/6amdev/releases/20261015-1731 /var/www/6amdev/current.tmp
mv -T /var/www/6amdev/current.tmp /var/www/6amdev/current

แบบ symlink เก็บได้หลายรุ่น ลบรุ่นที่เก่ากว่า 5 รุ่นทิ้งเป็นระยะ ส่วนแบบสลับโฟลเดอร์เก็บได้รุ่นเดียว ซึ่งพอสำหรับเว็บเล็ก

ตรวจหลังขึ้นให้มากกว่าหน้าแรก

สคริปต์ตรวจแค่ว่าหน้าแรกตอบ 200 ซึ่งเป็นขั้นต่ำ เราตรวจเพิ่มทุกครั้งที่มีหน้าใหม่

  • หน้าที่เพิ่งเพิ่ม ทั้งสองภาษา
  • ไฟล์ CSS หนึ่งไฟล์ ว่าได้ header ครบ (เล่าในบทความเรื่อง security headers)
  • เว็บอื่นที่อยู่เครื่องเดียวกัน ยังเปิดได้ เพราะเครื่องนี้มีหลายเว็บใช้ nginx ตัวเดียวกัน

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

  • ก๊อปหรือ rsync ทับโฟลเดอร์ที่กำลังให้บริการ
  • แตกไฟล์ใน /tmp ที่เป็น tmpfs แล้วคิดว่า mv เปลี่ยนชื่อทันที
  • ไม่เก็บรุ่นก่อน พอพังต้อง build ใหม่ในขณะที่เว็บล่ม
  • ย้อนกลับต้องทำหลายขั้นและต้องคิด
  • สลับ symlink ด้วยการลบตัวเก่าก่อนแล้วค่อยสร้างตัวใหม่ ซึ่งมีจังหวะที่ไม่มี symlink อยู่
  • deploy แล้วตรวจแค่หน้าแรก

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

ใช้กับเว็บที่มี backend ได้ไหม

หลักการเดียวกัน แต่ต้องคิดเรื่องฐานข้อมูลด้วย ถ้า deploy มีการเปลี่ยนโครงสร้างตาราง การย้อนโค้ดกลับอาจไม่พอ ต้องออกแบบการเปลี่ยนตารางให้โค้ดเก่าและใหม่ใช้ได้ทั้งคู่ในช่วงเปลี่ยนผ่าน

ทำไมไม่ใช้ rsync

rsync ดีสำหรับส่งไฟล์ แต่ถ้า rsync ลงโฟลเดอร์ที่กำลังให้บริการตรง ๆ ระหว่างที่ไฟล์ยังส่งไม่ครบ ผู้ใช้ก็เจอสถานะครึ่ง ๆ เหมือนเดิม ใช้ rsync ส่งไปโฟลเดอร์ใหม่ แล้วค่อยสลับ ได้ประโยชน์ทั้งสองทาง

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

อ่านต่อ

#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กลาง

รันแอปด้วย systemd แทน screen และ nohup: unit file, restart, timer แทน cron

เลิกเปิดแอปค้างไว้ใน screen หรือ nohup เขียน systemd unit ให้รันด้วย user แยก restart เองเมื่อพัง อ่าน env จากไฟล์ ล็อกสิทธิ์ และใช้ timer แทน cron

อ่าน 12 นาที