Deploy Laravel แบบไม่มี downtime ด้วยโฟลเดอร์ releases และ symlink current

แทนที่จะอัปเดตโค้ดในโฟลเดอร์ที่เว็บใช้อยู่ เราเตรียม release ใหม่ไว้ข้าง ๆ ให้พร้อมทุกอย่าง แล้วสลับ symlink current ในคำสั่งเดียว ผู้ใช้จึงไม่เห็นหน้า 503 และถ้า release ใหม่มีปัญหาก็ชี้กลับไปตัวเดิมได้ทันที แต่ migration ต้องเขียนให้โค้ดทั้งสองรุ่นใช้ได้

เผยแพร่
เวลาอ่าน
5 นาที
ในโพสต์นี้ 5 หัวข้อ
  1. เตรียม release ใหม่ไว้ข้าง ๆ แล้วค่อยสลับ
  2. nginx กับ opcache ต้องรู้ว่า current เปลี่ยนแล้ว
  3. build ครั้งเดียวใน CI แล้วส่งไปทั้งก้อน
  4. migration ต้องใช้ได้กับโค้ดทั้งสองรุ่น
  5. เรื่องที่เสียเวลาตอนย้ายมาใช้จริง

ในโพสต์เรื่องลำดับขั้นตอน 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 about boot แอปได้ ไม่มี 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 ก็ยังเป็นทางที่ดูแลง่ายกว่าครับ

อ่านต่อ

ถูกอ้างถึงใน

ล่าสุด

ดูทั้งหมด →