โพสต์เรื่อง GitHub Actions แบ่ง workflow เป็นสาม job โพสต์นี้ลงรายละเอียด job กลาง คือ deploy ซึ่งรันบน server ใน checkout ที่เว็บใช้อยู่ ไม่ได้อัปโหลดไฟล์จากที่อื่นขึ้นไป จะใช้ self-hosted runner ที่ติดตั้งบน server หรือ ssh เข้าไปจาก runner ของ GitHub ก็ได้ ลำดับขั้นเหมือนกัน

ทุกโปรเจกต์ของเราเรียงตามนี้ บางโปรเจกต์อาจมีขั้นในแต่ละช่วงน้อยกว่า แต่ไม่สลับลำดับ
ให้แต่ละขั้นเป็น step ที่มีชื่อของตัวเอง อย่ารวมทั้งหมดไว้ใน run: | ก้อนเดียว ถ้าพัง หน้าของ GitHub Actions จะบอกได้เลยว่าพังที่ขั้นไหน และขั้นก่อนหน้าทำเสร็จไปแล้วแค่ไหน
ขั้นโค้ดใช้ git reset --hard แทน git pull
- name: Reset the checkout to the tested commit
env:
DEPLOY_SHA: ${{ github.sha }}
run: |
git fetch -q --prune origin main
git merge-base --is-ancestor "$DEPLOY_SHA" origin/main || { echo "::error::$DEPLOY_SHA is no longer on origin/main"; exit 1; }
git reset -q --hard "$DEPLOY_SHA"
git pull จะหยุดทันทีถ้ามีใครแก้ไฟล์ที่ git ติดตามอยู่ค้างไว้บน server เช่น เข้าไปแก้ hotfix ตรง ๆ แล้วลืมเอาออก git จะขึ้นว่า Your local changes to the following files would be overwritten by merge แล้วหยุด เว็บก็ยังรันโค้ดเก่าต่อไป ทั้งที่ CI ขึ้นว่า deploy พัง
git reset --hard ทับไฟล์ที่ git ติดตามให้ตรงกับ commit นั้นเสมอ รันซ้ำกี่รอบก็ได้ผลเท่าเดิม ส่วนไฟล์ที่อยู่ใน .gitignore อย่าง .env, public/build และ storage git จะไม่แตะเลย
อีกจุดคือ deploy ไปที่ github.sha ไม่ใช่ origin/main ระหว่างที่ job deploy รอคิวอยู่ อาจมีคน push commit ใหม่ขึ้นมาแล้ว commit นั้นยังไม่ผ่าน test ของตัวเอง ถ้า reset ไปที่ origin/main เราจะ deploy โค้ดที่ยังไม่ได้ test บรรทัด merge-base --is-ancestor เช็กว่า commit นี้ยังอยู่บน main จริง ถ้ามีคน force push ทับไปแล้ว deploy จะหยุด ข้อดีที่ได้มาด้วยคือ ถ้ากด re-run รอบเก่าใน GitHub ก็เท่ากับย้อนโค้ดกลับไปที่ commit นั้น แต่ migration ที่รันไปแล้วไม่ย้อนตาม
หลังได้โค้ดแล้ว ยังมีสามขั้นในช่วงนี้
- เช็กว่า
.envบน server ยังอยู่ และมีAPP_ENV=productionกับAPP_KEYครบ ถ้าไม่มีก็หยุดตั้งแต่ตรงนี้ ดีกว่าไปพังกลางทาง (อีกทางคือเก็บ.envแบบเข้ารหัสไว้ใน repo) composer install --no-dev --optimize-autoloaderphp artisan optimize:clearก่อนคำสั่ง artisan ตัวอื่น ไฟล์ในbootstrap/cacheอยู่ใน.gitignoregit resetจึงไม่ล้างให้ ถ้าไม่ล้างเอง คำสั่ง artisan ทุกตัวหลังจากนี้จะอ่าน config ของรอบก่อนกับโค้ดของรอบใหม่ migration ธรรมดาไม่เป็นไร แต่ data migration ที่อ่านค่าจาก config อาจทำงานผิดโดยไม่มี error ให้เห็น
ขั้นฐานข้อมูล snapshot ก่อน migrate เฉพาะตอนมี migration ค้าง
dump ฐานข้อมูลไว้ก่อน migrate ไว้ย้อนกลับได้ถ้า migration พังกลางทาง แต่ dump ของฐานข้อมูลใหญ่ใช้เวลาหลายนาที ถ้าไม่มี migration ใหม่ก็ไม่มีอะไรต้องย้อน เราจึงเช็กก่อน
- name: Check for pending migrations
id: migrations
run: |
set +e
status="$(php artisan migrate:status --pending --no-ansi 2>&1)"
code=$?
set -e
if [ "$code" -ne 0 ] || echo "$status" | grep -qw Pending; then
echo "has_pending=true" >> "$GITHUB_OUTPUT"
else
echo "has_pending=false" >> "$GITHUB_OUTPUT"
fi
- name: Create database snapshot
if: steps.migrations.outputs.has_pending == 'true'
run: php artisan snapshot:create --compress
- name: Run migrations
run: php artisan migrate --force
migrate:status --pending จบด้วย exit 0 ทั้งตอนที่มีและไม่มี migration ค้าง เราจึงต้องอ่านข้อความแทน ถ้าตัวคำสั่งเองพัง เช่น ต่อฐานข้อมูลไม่ได้ ก็ให้ snapshot ไว้ก่อน อย่าข้าม backup เพียงเพราะเช็กไม่ได้
snapshot:create มาจาก spatie/laravel-db-snapshots ตัวเดียวกับที่ใช้ดึงฐานข้อมูลลงเครื่อง ใส่ --compress เพราะเป็นข้อมูลจริงที่อาจใหญ่หลาย GB ไฟล์นี้อยู่บน server เท่านั้น ห้ามหลุดเข้า repo
ถ้าแอปให้ผู้ใช้อัปโหลดไฟล์ ขั้นนี้ก็ต่อด้วย php artisan storage:link --force อีกบรรทัด
ขั้น assets build หลังล้าง route cache
- name: Clear cached routes before build
run: php artisan route:clear
- name: Install Node dependencies and build frontend
run: npm ci --no-audit --no-fund && npm run build
route:clear ซ้ำกับ optimize:clear ข้างบน แต่เราตั้งใจวางไว้ตรงนี้ เครื่องมือที่อ่าน route ตอน build อย่าง Wayfinder หรือ Ziggy ถ้าได้ route cache ของรอบก่อน จะสร้างไฟล์ฝั่ง JavaScript ที่ไม่มี route ใหม่ CI ยังเขียว แต่หน้าเว็บเรียก route นั้นไม่ได้ วางไว้ติดกับ build จะได้ไม่มีใครย้าย optimize มาไว้ก่อนหน้านี้
ถ้ามีคนเปิดหน้าเว็บค้างไว้ตอน deploy หน้านั้นยังจะขอไฟล์ JavaScript ชุดเก่าที่ชื่อมี hash เดิม เว็บนี้จึงตั้ง Vite ไม่ให้ล้าง public/build ทุกครั้งที่ build แล้วลบเฉพาะไฟล์ที่ไม่มี build ไหนเขียนมาเกิน 7 วัน
ขั้น cache สั่ง optimize หลัง build เสมอ
php artisan optimize สร้าง cache ของ config, route, view และ event ที่เราล้างไปตอนต้น ต้องมีทุกครั้ง ไม่อย่างนั้น production จะอ่าน config และ route จากไฟล์ใหม่ทุก request และต้องอยู่หลัง build เพราะ build ต้องอ่าน route จริง ไม่ใช่ route ที่ cache ไว้
ขั้น reload ต้องทำทั้ง PHP-FPM และ queue worker
แอป Laravel บน production มีอย่างน้อยสอง process ที่ถือโค้ดไว้ในหน่วยความจำ
- PHP-FPM ตอบ request ของหน้าเว็บ production มักปิด
opcache.validate_timestampsไว้ให้เร็ว PHP จึงใช้โค้ดใน opcache ต่อไปจนกว่าจะ reload สั่งsystemctl reload php8.5-fpmแบบ reload ไม่ใช่ restart เพราะ FPM จะเปิด worker ใหม่ด้วยโค้ดใหม่โดยไม่ปิด socket request ที่เข้ามาช่วงนั้นจึงไม่ได้ 502 - queue worker รัน job ที่อยู่ในคิว เป็น process ที่เปิดค้างไว้นาน ถ้าไม่สั่ง restart ก็จะรัน job ด้วยโค้ดชุดเก่าไปเรื่อย ๆ ใช้
php artisan queue:restartถ้ารันด้วยqueue:workหรือphp artisan horizon:terminateถ้าใช้ Horizon แล้วให้ supervisor เปิดตัวใหม่ให้
ถ้า reload แค่ตัวเดียว แอปจะมีสองเวอร์ชันรันพร้อมกัน หน้าเว็บอาจส่ง job รูปแบบใหม่เข้าคิว แล้ว worker ที่ยังเป็นโค้ดเก่าอ่านไม่ออก
สองเรื่องที่มักเจอตอนตั้งครั้งแรก
- user ที่ deploy ไม่ควรเป็น root ให้ sudo ได้แค่คำสั่ง reload PHP-FPM คำสั่งเดียวใน sudoers
horizon:terminateส่งสัญญาณถึง process ของ Horizon ถ้า Horizon รันด้วย user อื่น เช่นwww-dataคำสั่งนี้จะส่งไม่ถึง ให้ใช้supervisorctl restartแทน แล้วตั้งสิทธิ์ socket ของ supervisor ให้ user ที่ deploy ใช้ได้
ถ้าเปิด Inertia SSR ไว้ ก็ต้อง restart SSR server ด้วยอีกตัว (วิธีตั้ง SSR บน production)
ขั้นสุดท้ายคือล้าง cache ของ Cloudflare
ถ้าโดเมนอยู่หลัง Cloudflare ไฟล์ CSS และ JavaScript ที่ edge เก็บไว้อาจค้างอยู่หลายชั่วโมงหลัง deploy ล้างเป็นขั้นสุดท้ายเพราะต้องรอให้ PHP-FPM reload เสร็จก่อน ถ้าล้างก่อน request ที่เข้ามาช่วงนั้นจะได้หน้าเว็บจากโค้ดเก่า แล้ว Cloudflare ก็เก็บของเก่าไว้ใหม่อีกรอบ
- name: Purge Cloudflare cache
run: php artisan cloudflare:purge || true
ท้ายคำสั่งมี || true ให้ deploy ผ่านแม้ล้าง cache ไม่สำเร็จ เพราะถึงตรงนี้โค้ดใหม่ขึ้นไปแล้ว ไม่ควรให้เรื่องเล็กทำให้ทั้งรอบแดง ล้างเฉพาะไฟล์ที่อยู่ใน Vite manifest กับหน้าแรกก็พอ อย่าใช้ purge everything ถ้าใน zone มีหลายเว็บ
คำสั่ง cloudflare:purge ในตัวอย่างมาจากแพ็กเกจ laravel-cloudflare ซึ่งเราทำไว้ใช้เอง จะเรียก API ของ Cloudflare ด้วย curl ตรง ๆ ก็ได้เหมือนกัน
deploy รอคิว ไม่ยกเลิกกันเอง
job deploy แก้ไฟล์ใน checkout เดียวกับที่เว็บใช้อยู่ ถ้า push สองครั้งติดกันแล้วสองรอบรันพร้อมกัน composer install กับ npm run build จะทับกันในโฟลเดอร์เดียว เราจึงให้ deploy มี concurrency group ของตัวเอง ตั้ง cancel-in-progress: false ให้รอบที่สองรอจนรอบแรกเสร็จ deploy ที่ทำไปครึ่งเดียวแก้ยากกว่า deploy ที่ต้องรอคิว
วิธีนี้แก้ไฟล์ในโฟลเดอร์ที่เว็บใช้อยู่ ระหว่าง composer install กับ reload จึงมีช่วงสั้น ๆ ที่โค้ดใหม่กับเก่าปนกัน เว็บที่คนใช้ไม่มากรับช่วงนี้ได้ ถ้าต้องการให้ไม่มีช่วงนี้เลย ต้องเปลี่ยนไป build แต่ละ release ในโฟลเดอร์ใหม่ แล้วสลับ symlink ทีเดียว ซึ่งเป็นอีกเรื่องหนึ่ง





