ลำดับขั้นตอน deploy Laravel จาก GitHub Actions ตั้งแต่โค้ด ฐานข้อมูล ไปจนถึงล้าง cache ของ Cloudflare

job deploy ของเราเรียงหกขั้นเสมอ คือโค้ด ฐานข้อมูล assets cache reload และ purge โพสต์นี้อธิบายเหตุผลของแต่ละขั้น ตั้งแต่ใช้ git reset --hard ไปที่ commit ที่ test ผ่านแทน git pull, snapshot ฐานข้อมูลเฉพาะตอนมี migration ค้าง ไปจนถึง reload ทั้ง PHP-FPM และ queue worker

เผยแพร่
เวลาอ่าน
5 นาที
ในโพสต์นี้ 7 หัวข้อ
  1. ขั้นโค้ดใช้ git reset --hard แทน git pull
  2. ขั้นฐานข้อมูล snapshot ก่อน migrate เฉพาะตอนมี migration ค้าง
  3. ขั้น assets build หลังล้าง route cache
  4. ขั้น cache สั่ง optimize หลัง build เสมอ
  5. ขั้น reload ต้องทำทั้ง PHP-FPM และ queue worker
  6. ขั้นสุดท้ายคือล้าง cache ของ Cloudflare
  7. deploy รอคิว ไม่ยกเลิกกันเอง

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

ลำดับ deploy หกขั้น จากโค้ดไปจนถึง cache ของ Cloudflare

ทุกโปรเจกต์ของเราเรียงตามนี้ บางโปรเจกต์อาจมีขั้นในแต่ละช่วงน้อยกว่า แต่ไม่สลับลำดับ

ให้แต่ละขั้นเป็น 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-autoloader
  • php artisan optimize:clear ก่อนคำสั่ง artisan ตัวอื่น ไฟล์ใน bootstrap/cache อยู่ใน .gitignore git 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 ทีเดียว ซึ่งเป็นอีกเรื่องหนึ่ง

ตอนถัดไปของซีรีส์ CI/CD ใน Laravel

อ่านต่อ

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

ล่าสุด

ดูทั้งหมด →