ในโพสต์เรื่องลำดับขั้นตอน deploy job deploy ทำงานใน checkout ที่เว็บใช้อยู่ git reset --hard ไปที่ commit ใหม่ แล้ว composer install, migrate, build แล้วค่อย reload ระหว่างนั้นมีช่วงที่โค้ดเป็นรุ่นใหม่แต่ vendor ยังเป็นรุ่นเก่า หรือ assets ยัง build ไม่เสร็จ request ที่เข้ามาตรงนั้นอาจเจอ error หรือหน้าที่โหลด JavaScript ไม่ครบ ถ้าจะให้ปลอดภัยก็ต้องเปิด php artisan down คลุมไว้ ผู้ใช้เห็นหน้า 503 ราว 30–60 วินาทีทุกครั้งที่ deploy
สำหรับเว็บส่วนตัวหรือระบบภายในเล็ก ๆ แบบนี้ก็พอ แต่ระบบที่ลูกค้าใช้ทั้งวัน หรือ deploy วันละหลายรอบ ทั้งหน้า 503 และหน้าพังครึ่ง ๆ เริ่มเป็นเรื่อง โพสต์นี้เล่าวิธีที่เราใช้กับระบบของลูกค้าที่ต้องเปิดตลอด
เตรียม release ใหม่ไว้ข้าง ๆ แล้วค่อยสลับ
แทนที่จะมี checkout เดียว เราแบ่งโฟลเดอร์ของแอปบน server เป็นแบบนี้
/var/www/app/
├── releases/
│ ├── 20261024093012-3f9a1c2b7e/
│ └── 20261026141530-8d02e4a19c/ ← release ล่าสุด
├── shared/
│ └── storage/ ← ใช้ร่วมกันทุก release
└── current -> releases/20261026141530-8d02e4a19c
releases/เก็บโค้ดแต่ละรอบที่ deploy ตั้งชื่อด้วยเวลาและ commit เก็บไว้ 5 รอบล่าสุดshared/storageคือ log, ไฟล์ที่ผู้ใช้อัปโหลด และ session ทุก release ลิงก์มาที่เดียวกัน ข้อมูลจึงไม่หายตอนสลับcurrentเป็น symlink ชี้ไป release ที่เว็บใช้อยู่ nginx, cron และ queue worker อ้างถึงcurrentเสมอ
deploy รอบใหม่แตกโค้ดลงโฟลเดอร์ใหม่ใน releases/ ลิงก์ storage เข้าไป ถอดรหัส .env รัน migrate และ php artisan optimize ให้เสร็จใน release นั้นทั้งหมด ระหว่างนี้เว็บยังเสิร์ฟจาก release เดิมตามปกติ พอทุกอย่างพร้อมจึงสลับ current มาชี้ release ใหม่
ln -sfn releases/20261026141530-8d02e4a19c current.new
mv -T current.new current
ใช้สองบรรทัดนี้แทน ln -sfn ตรง ๆ เพราะ ln -sfn ลบลิงก์เดิมก่อนแล้วค่อยสร้างใหม่ มีเสี้ยววินาทีที่ current ไม่มีอยู่ ส่วน mv -T เปลี่ยนชื่อทับในคำสั่งเดียว request ที่เข้ามาจะเห็นแค่ release เก่าหรือใหม่ ไม่มีช่วงที่หาไฟล์ไม่เจอ
nginx กับ opcache ต้องรู้ว่า current เปลี่ยนแล้ว
ถ้าตั้ง nginx ให้ root ชี้ไปที่ current/public เฉย ๆ PHP-FPM จะได้ path ที่มี current อยู่ข้างใน OPcache เก็บไฟล์ที่ compile แล้วตาม path นั้น หลังสลับ symlink จึงยังรันโค้ดของ release เก่าอยู่ ให้ส่ง path จริงที่ resolve symlink แล้วแทน
root /var/www/app/current/public;
location ~ \.php$ {
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
fastcgi_param DOCUMENT_ROOT $realpath_root;
fastcgi_pass unix:/run/php/php8.4-fpm.sock;
}
$realpath_root คือ path หลัง resolve symlink แล้ว แต่ละ release จึงได้ path ไม่ซ้ำกัน opcache ไม่สับสน และ __DIR__ ในโค้ดก็ชี้ release ที่ถูกต้อง หลังสลับเรายัง reload PHP-FPM อีกรอบเพื่อเคลียร์ opcache ของ release เก่า
ส่วน queue worker, Horizon หรือ Reverb เป็น process ที่รันค้างไว้ โหลดโค้ดเข้า memory ครั้งเดียวตอนเริ่ม ต้อง restart หลังสลับทุกครั้ง supervisor ตั้ง directory= และ path ของ artisan ไว้ที่ current เพื่อให้ process ใหม่เริ่มจาก release ใหม่
build ครั้งเดียวใน CI แล้วส่งไปทั้งก้อน
ใน release ใหม่ต้องมี vendor และ public/build ครบก่อนสลับ ทำได้สองทาง
- build บน server แตกโค้ดจาก git แล้วรัน
composer installกับnpm ci && npm run buildในโฟลเดอร์ release ทางนี้ง่ายกว่า แต่ server ต้องมี Node และเครื่องมือ dev ครบ - build ใน CI หลัง test ผ่าน job build รวมโค้ด,
vendorแบบ--no-devและpublic/buildเป็นไฟล์release.tgzอัปโหลดเป็น artifact พร้อม sha256 แล้ว job deploy ดาวน์โหลด ตรวจ checksum ให้ตรง แล้วจึงแตกไฟล์ server ไม่ต้องมี git, composer หรือ npm เลย
ระบบหนึ่งของลูกค้าที่เราดูแลใช้ทางที่สอง ข้อดีที่เห็นชัดคือไฟล์ที่ขึ้น production เป็นไฟล์เดียวกับที่ test ผ่าน ไม่ได้ build ใหม่บนเครื่องที่สภาพแวดล้อมต่างกัน ถ้าใช้ทางนี้ระวังเรื่อง artifact ของ GitHub บน plan ฟรีที่มีพื้นที่จำกัด ตั้ง retention-days: 1 แล้วลบหลังแตกไฟล์เสร็จ
ก่อนสลับยังมีขั้น preflight บน server ที่เรารันใน release ใหม่ ถ้าขั้นไหนไม่ผ่าน deploy หยุดโดยเว็บยังอยู่บน release เดิม
composer check-platform-reqs --lock --no-devเช็กว่า PHP บน server เป็นเวอร์ชันที่ lock ต้องการ และมี extension ครบ ถ้าแอปใช้ extension ที่ไม่มีแพ็กเกจไหนประกาศไว้ อย่างext-pdo_pgsqlหรือext-redisให้ใส่ในcomposer.jsonเอง เช็กนี้จะได้ครอบคลุมphp artisan aboutboot แอปได้ ไม่มี error ตอนโหลด config
หลังสลับเรียก /up ของ Laravel ผ่าน vhost จริง ถ้าไม่ตอบ 200 ให้ชี้ current กลับไป release เดิม reload แล้ว restart worker อีกรอบ ขั้นนี้ใช้เวลาไม่กี่วินาที เพราะ release เดิมยังอยู่ครบ
migration ต้องใช้ได้กับโค้ดทั้งสองรุ่น
นี่คือเรื่องที่ต้องเปลี่ยนวิธีคิดมากที่สุด release ใหม่ migrate ฐานข้อมูลตั้งแต่ก่อนสลับ ระหว่างนั้น release เก่ายังเสิร์ฟผู้ใช้อยู่บน schema ใหม่ และถ้า rollback กลับ release เดิม schema ก็ยังเป็นแบบใหม่ ทุก migration จึงต้องใช้ได้กับโค้ดทั้งสองรุ่น วิธีนี้เรียกว่า expand/contract
- เพิ่ม (expand) ตารางใหม่ คอลัมน์ใหม่ที่เป็น nullable หรือมีค่า default ทำได้เลย โค้ดเก่าไม่รู้จักคอลัมน์ใหม่ก็ไม่เป็นไร
- ลบหรือเปลี่ยน (contract) อย่าทำในรอบเดียว อยากเปลี่ยนชื่อคอลัมน์
phoneเป็นmobileรอบแรกเพิ่มmobileแล้วให้โค้ดเขียนทั้งสองคอลัมน์ รอบถัดไปเมื่อไม่มีโค้ดอ่านphoneแล้วจึงลบทิ้ง
// รอบที่ 1 เพิ่มคอลัมน์ใหม่ โค้ดเขียนทั้ง phone และ mobile
$table->string('mobile')->nullable();
// รอบที่ 2 (deploy ถัดไป) ไม่มีโค้ดอ่าน phone แล้ว
$table->dropColumn('phone');
ใน workflow เราใส่ตัวกันไว้อีกชั้น ก่อน migrate จะค้นใน migration ที่ยังค้างว่ามี dropColumn, renameColumn, ->change() หรือ Schema::drop ไหม ถ้ามี deploy รอบนั้นจะเปิด php artisan down คลุมช่วง migrate และสลับไว้ แล้วขึ้นคำเตือนใน GitHub Actions ว่ารอบนี้ไม่ใช่ zero-downtime เพราะ storage ใช้ร่วมกัน คำสั่ง down จาก release เก่าจึงมีผลกับ release ใหม่ด้วย
เรื่องที่เสียเวลาตอนย้ายมาใช้จริง
.gitattributesของ Laravel มี/.github export-ignoreถ้าใช้git archiveสร้าง release ไฟล์ template สำหรับ deploy ใน.github/จะหายไป ต้อง copy เข้าไปเอง- ค่า
VITE_*ฝังตอน build build ครั้งเดียวแล้วใช้หลาย environment ค่าใน.envของแต่ละที่จะไม่ถูกอ่าน ต้องย้ายไปอ่านตอนรันผ่าน script ในหน้า HTML แทน - Wayfinder boot แอปตอน build ถ้าใช้ Wayfinder ให้ build ใน CI ด้วย
.envที่APP_ENV=productionไม่ต้องต่อฐานข้อมูล route ที่สร้างจะได้ตรงกับ production - view ที่ compile แล้วสะสม Blade compile view เก็บใน
storage/framework/viewsตาม path ของ release เมื่อstorageใช้ร่วมกัน ไฟล์ของ release เก่าก็ค้างอยู่ ไม่อันตราย แต่view:clearบ้างเป็นครั้งคราว
ย้ายจาก checkout เดิมมาเป็นโครงแบบนี้ทำครั้งเดียวต่อ server สร้างโฟลเดอร์ใหม่ไว้ข้าง ๆ แล้วสลับชื่อโฟลเดอร์ แก้ root ใน nginx และ path ใน supervisor กับ cron แล้วเก็บโฟลเดอร์เดิมไว้สักสัปดาห์เผื่อต้องย้อนกลับ ขั้นอื่นของ deploy อย่าง snapshot ฐานข้อมูลก่อน migrate หรือ purge cache ของ Cloudflare ยังเหมือนในลำดับขั้นตอน deployทุกอย่าง
deploy แบบนี้ทำ workflow ให้ยาวขึ้นพอสมควร เราจึงใช้เฉพาะระบบที่หน้า 503 ทุกรอบเป็นเรื่องจริง ถ้ายังไม่ถึงตรงนั้น deploy ใน checkout เดิมด้วย git reset --hard ก็ยังเป็นทางที่ดูแลง่ายกว่าครับ






