EST. 2026 — บันทึกงานเซิร์ฟเวอร์จริง SYSADMIN · SECURITY · AI · DIRECTADMIN เล่มที่ 01
THE SYSADMIN ALMANAC
หน้าหลัก / DirectAdmin / route:cache แล้วทุก GET กลายเป็น 405 — กับดักแอปใต้ sub-path
DirectAdmin

route:cache แล้วทุก GET กลายเป็น 405 — กับดักแอปใต้ sub-path

โดย Ton-Or ·7 ต.ค. 2026·อ่าน 9 นาที
route:cache แล้วทุก GET กลายเป็น 405 — กับดักแอปใต้ sub-path

405 คือคำตอบที่แปลว่า "เส้นทางนี้มีอยู่จริง แต่วิธีที่คุณเรียกมันไม่ถูก" — พอมันโผล่มาตอบ GET ธรรมดาที่เคยใช้ได้เมื่อห้านาทีที่แล้ว แปลว่าปัญหาไม่ได้อยู่ที่คนเรียก แต่อยู่ที่ของที่เราเพิ่งอบไว้ให้มันอ่าน

พอดีผมเอาแอป Laravel ตัวหนึ่งขึ้นเครื่องภายในขององค์กรแห่งหนึ่ง วางไว้ใต้ sub-path ของเว็บเซิร์ฟเวอร์เดิม (/tracker/) เพราะเครื่องนั้นมีระบบเก่าอยู่แล้วที่รากเว็บ ย้ายไม่ได้ ทุกอย่างไปได้สวยจนถึงขั้นตอนสุดท้ายที่ผมพิมพ์ php artisan optimize ตามที่คู่มือ deploy ทุกเล่มบอกให้ทำ แล้วหน้าเว็บที่เมื่อกี้ยังเปิดได้ก็กลายเป็น 405 Method Not Allowed ทุกหน้าในทันที

ที่ผมว่าน่าเล่าคือมันเป็นอาการที่หลอกดีมาก เพราะทุกเครื่องมือที่เราใช้ตรวจ route จะบอกว่าถูกหมด และ 405 เป็นรหัสที่คนส่วนใหญ่ตีความว่า "เราเรียกผิดวิธี" เลยไปเสียเวลาไล่ที่ฟอร์ม ที่ method ที่ .htaccess กันก่อน ทั้งที่ต้นเหตุอยู่คนละที่ ตอนท้ายผมเก็บของแถมอีกสามอย่างที่โผล่มาในรอบ deploy เดียวกันมาไว้ด้วย เพราะมันเป็นชุดปัญหาที่มากับการวางแอปใต้ sub-path แบบนี้เสมอ

ชื่อเครื่อง เลข IP และ path ในบทความเป็นค่าตัวอย่างทั้งหมด

อาการ: route:list บอกว่าถูก แต่เว็บตอบ 405

สิ่งแรกที่ผมทำคือยิงตรงด้วย curl ไม่ผ่านเบราว์เซอร์ เพราะอยากเห็น header ดิบ ๆ ไม่ใช่หน้า error ที่เบราว์เซอร์ตกแต่งมาให้

curl -sI http://10.10.10.7/tracker/
# HTTP/1.1 405 Method Not Allowed
# Allow: HEAD
# Content-Type: text/html; charset=UTF-8

แล้วก็เข้าไปถามแอปเองว่ามันรู้จัก route นี้ไหม

php artisan route:list --path=/ --columns=method,uri,action
# +-----------+------+--------------------------------------------------+
# | GET|HEAD  | /    | App\Http\Controllers\DashboardController@index    |
# +-----------+------+--------------------------------------------------+

สองอันนี้ขัดกันเองอย่างชัดเจน แอปบอกว่ารับ GET แต่ตอนยิงจริงมันบอกว่ารับแค่ HEAD ซึ่งเป็นข้อมูลที่มีค่ามาก และคนส่วนใหญ่มองข้ามบรรทัด Allow: ไปเลยเพราะรีบไปดู log

อ่าน Allow: ให้เป็น — router เจอแล้ว แต่ตอนส่งงานต่อไม่ผ่าน

กลไกของ Symfony router ที่อยู่ใต้ Laravel แบ่งการทำงานเป็นสองจังหวะ จังหวะแรกคือจับคู่ path ว่ามี route ไหนตรงบ้าง จังหวะที่สองคือเช็คว่า method ที่เข้ามาอยู่ในรายการที่ route นั้นรับหรือเปล่า ถ้าจังหวะแรกไม่เจอเลย เราจะได้ 404 แต่ถ้าเจอ path แล้วตกที่จังหวะสอง เราจะได้ 405 พร้อมกับรายการ method ที่มันคิดว่ารับได้ ใส่มาให้ใน header Allow:

จังหวะที่ request ถูกตัดสินApachealias /tracker->index.phpbootstrap->match pathเจอ = ไม่ 404->check methodตกที่นี่->405Allow: HEAD

แปลว่า route ถูกโหลดเข้ามาจริง มีตัวตนจริง แต่ชุดข้อมูลที่บอกว่ามันรับ method อะไรบ้างเหลือแค่ HEAD ซึ่งไม่ใช่สิ่งที่ไฟล์ routes/web.php เขียนไว้ ตรงนี้แหละที่ทำให้ผมเลิกสงสัยโค้ดแล้วหันไปสงสัยของที่เพิ่งอบเสร็จ เพราะสิ่งที่ต่างกันระหว่าง "เมื่อกี้ยังใช้ได้" กับ "ตอนนี้ 405" มีอย่างเดียวคือไฟล์ route cache

การพิสูจน์ใช้เวลาไม่ถึงครึ่งนาที

php artisan route:clear && curl -sI http://10.10.10.7/tracker/ | head -1
# HTTP/1.1 200 OK

php artisan route:cache && curl -sI http://10.10.10.7/tracker/ | head -1
# HTTP/1.1 405 Method Not Allowed

สลับกลับไปกลับมาได้ตามใจ อาการซ้ำทุกครั้ง แบบนี้เลิกเดาได้เลย

กฎที่ผมใช้กับ 405 ทุกครั้งตั้งแต่นั้น

เจอ 405 บน route ที่เคยใช้ได้ ให้ทำสามอย่างนี้ก่อนอย่างอื่น หนึ่ง ดู header Allow: ว่ามันลิสต์อะไรมา สอง สั่ง route:clear แล้วยิงซ้ำ สามถ้าหายแปลว่าเป็นเรื่องของ cache ไม่ใช่เรื่องของโค้ด อย่าเพิ่งไปแก้ controller หรือฟอร์มโดยที่ยังไม่ผ่านสามข้อนี้

สองเครื่อง สองหน้าตา — พังทั้งเว็บ กับ พังเฉพาะหน้าแรก

ผมเจออาการตระกูลนี้มาสองเครื่อง คนละงาน คนละปี แต่รากเดียวกัน

เครื่องภายใน

ทุก GET กลายเป็น 405

แอปวางใต้ sub-path ผ่าน Apache alias ทั้งเว็บล่มพร้อมกันหลัง route:cache ทุกหน้า ทุก path ตอบ 405 เหมือนกันหมด สั่ง route:clear ครั้งเดียวกลับมาปกติ

shared host

พังเฉพาะ route ราก

เฉพาะ / ที่ลงทะเบียนเป็น controller callable ตอบ 405 ส่วน /dashboard /login ที่เขียนแบบเดียวกันเป๊ะ ทำงานปกติ ต่างกันที่โฮสต์มี .htaccess จัดการ trailing slash คั่นอยู่

จุดร่วมของสองเคสคือ แอปไม่ได้อยู่ที่รากของ document root จริง ๆ ตัวแรกอยู่ใต้ alias ตัวที่สองอยู่หลังกฎ rewrite ที่เติม/ตัด slash ท้าย path ก่อนส่งเข้า index.php ส่วน route ที่เจอปัญหาคือ route ที่ path สั้นที่สุด ซึ่งเป็นตัวที่อ่อนไหวกับการเติมหรือตัด slash มากที่สุดพอดี

ผมไม่ขอสรุปแทนระดับซอร์สโค้ดว่าตัวไหนเขียนทับตัวไหนกันแน่ เพราะทั้งสองเคสจำลองบนเครื่อง dev ที่ไม่มี Apache ชุดเดียวกันไม่ขึ้นเลยสักครั้ง สิ่งที่ผมยืนยันได้คือเงื่อนไขที่ทำให้เกิดซ้ำได้ 100% และวิธีที่ทำให้มันหาย ซึ่งสำหรับงานที่ต้องส่งมอบ แค่นี้พอแล้วครับ ส่วนใครที่สนใจ ลองเปิดไฟล์ bootstrap/cache/routes-v7.php แล้วไล่ดูค่า methods ของ route ที่มีปัญหาเทียบกับตัวที่ไม่มีปัญหา จะเห็นภาพเร็วขึ้นเยอะ

ทางแก้ที่ผมใช้จริง — และทำไมผมไม่ทิ้ง cache ทั้งชุด

มีสองทาง ผมใช้คนละทางกับคนละเครื่อง

ทางแรกสำหรับกรณีที่พังเฉพาะ route ราก ให้เปลี่ยน route นั้นตัวเดียวเป็น closure เพราะ Laravel ไม่นำ closure เข้า route cache มันเลยตกไปใช้การลงทะเบียนสดทุก request ส่วน route อื่นยังได้ประโยชน์จาก cache ครบ

// routes/web.php — เฉพาะ route ราก
Route::get('/', function () {
    // ไม่ต้องยกตรรกะออกจาก controller ให้เรียกมันตรง ๆ
    return app(\App\Http\Controllers\DashboardController::class)->index();
});

ทางที่สองสำหรับเครื่องที่พังทั้งเว็บ คือเลิก cache route บนเครื่องนั้นไปเลย แต่ ยังเก็บ config cache กับ view cache ไว้ทั้งคู่ เพราะสองตัวนั้นเป็นตัวที่ให้ผลด้านความเร็วมากกว่าในงานที่ route ไม่ได้เยอะมาก ข้อควรระวังคือคำสั่ง php artisan optimize มันรวม route:cache ไว้ด้วย เลยใช้คำสั่งรวมไม่ได้ ต้องแยกสั่งเอง

# สคริปต์ deploy บนเครื่องที่ route cache ใช้ไม่ได้
php artisan config:cache
php artisan view:cache
php artisan route:clear          # ต้องมีบรรทัดนี้ ไม่ใช่ปล่อยว่าง
php artisan about --only=cache   # ยืนยันว่าสถานะแคชเป็นอย่างที่ตั้งใจ

บรรทัด route:clear สำคัญกว่าที่คิดครับ เพราะถ้าเครื่องเคยมีไฟล์ cache ค้างจากรอบก่อน การ "ไม่สั่ง route:cache" ไม่ได้แปลว่าไฟล์เก่าหายไป มันยังอยู่และยังถูกอ่านอยู่ ผมเคยเสียเวลาครึ่งชั่วโมงกับเรื่องนี้มาแล้วตอนแก้ไปแล้วแต่อาการยังอยู่

อย่าเอาคำสั่งชุดเดียวไปใช้ทุกเครื่อง

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

ของแถมชิ้นที่หนึ่ง: แอปใต้ sub-path ทำ asset หลุด prefix

พอ 405 หาย ปัญหาถัดมาโผล่ทันที หน้าเว็บขึ้นแต่ไม่มี CSS ไม่มีรูป เพราะ helper อย่าง asset() กับ url() สร้างลิงก์จากรากของโดเมน ไม่ใช่จากราก sub-path ที่เราวางแอปไว้ ลิงก์ที่ได้เลยเป็น /css/app.css แทนที่จะเป็น /tracker/css/app.css

วิธีที่ผมใช้คือบังคับรากของ URL ตอน boot แล้วอ่านค่าจาก config ไม่ hardcode ในโค้ด

// app/Providers/AppServiceProvider.php
public function boot(): void
{
    // APP_URL=http://srv1.example.local/tracker
    if (config('app.url')) {
        \Illuminate\Support\Facades\URL::forceRootUrl(config('app.url'));
    }
    if (str_starts_with((string) config('app.url'), 'https://')) {
        \Illuminate\Support\Facades\URL::forceScheme('https');
    }
}

ฝั่งเว็บเซิร์ฟเวอร์ก็ต้องชี้ให้ถูกด้วย ให้ alias ลงที่ public/ ของแอป ไม่ใช่โฟลเดอร์รากของโปรเจกต์ ไม่งั้นทั้ง .env ทั้ง storage/ เปิดจากเว็บได้หมด

Alias /tracker /var/www/tracker/public

<Directory /var/www/tracker/public>
    AllowOverride All
    Require all granted
</Directory>

ข้อควรระวังคือ config('app.url') จะถูกอ่านจาก config cache ถ้าเราแก้ .env แล้วไม่ config:cache ใหม่ ค่าเก่าจะยังอยู่ และอาการที่ได้คือ "แก้แล้วไม่เปลี่ยน" ซึ่งพาไปหลงทางได้อีกยาว

ของแถมชิ้นที่สอง: หน้าสรุป timeout 500 เพราะ subquery ไล่ทีละแถว

รอบ deploy เดียวกันนั้น มีหน้าตรวจสอบคุณภาพข้อมูลหน้าหนึ่งที่เปิดไม่ขึ้น รอจนหมดเวลาแล้วได้ 500 ทั้งที่หน้าอื่นเร็วปกติ ต้นเหตุคือ query นับรายการที่ "มีในตาราง A แต่ไม่มีในตาราง B" ที่เขียนด้วย correlated subquery บนคอลัมน์ที่ไม่มี index

// ช้าจนหมดเวลา — สำหรับทุกแถวใน A ฐานข้อมูลต้องกวาด B หนึ่งรอบ
$missing = DB::table('rep_records')
    ->whereNotExists(function ($q) {
        $q->select(DB::raw(1))->from('stm_records')
          ->whereColumn('stm_records.tran_id', 'rep_records.tran_id');
    })->count();

ตาราง A มีห้าหมื่นแถว ตาราง B สี่หมื่นห้า คูณกันแล้วเป็นหลักพันล้านครั้งของการเทียบ ซึ่งไม่มีทางจบใน 30 วินาที ผมมีสองทางเลือก ทางที่ถูกต้องที่สุดคือใส่ index ให้คอลัมน์ tran_id ทั้งสองตาราง แต่ฐานตัวนั้นเป็นไฟล์ที่สร้างมาแบบอ่านอย่างเดียวและมีขั้นตอน ETL คุมอยู่ ผมเลยแก้ที่ฝั่งแอปก่อนด้วยการดึงคีย์มาทำ set difference ในหน่วยความจำ แล้วค่อยไปเติม index ในรอบสร้างฐานถัดไป

// ดึงคีย์ล้วน ๆ สองชุด แล้วหาส่วนต่างในหน่วยความจำ
$a = DB::table('rep_records')->pluck('tran_id')->all();
$b = array_flip(DB::table('stm_records')->pluck('tran_id')->all());
$missing = count(array_filter($a, fn ($k) => ! isset($b[$k])));
50,000แถวตาราง A
45,000แถวตาราง B
>30 วิก่อนแก้
1.2 วิหลังแก้

วิธีนี้กินแรม ใช้ได้กับข้อมูลระดับหลักแสน ไม่ใช่หลักสิบล้าน ใครที่ข้อมูลใหญ่กว่านี้ให้ไปทาง index หรือ join แทน อย่าเอาไปใช้ดื้อ ๆ

ของแถมชิ้นที่สาม: .env ขึ้นโฮสต์มาเป็น 664

อันนี้ไม่เกี่ยวกับ 405 เลย แต่เป็นสิ่งที่ผมเจอตอนไล่ดู path ของแอปแล้วมันร้ายแรงกว่าทุกข้อข้างบนรวมกัน ไฟล์ที่อัปขึ้นไปจากเครื่อง Windows มักได้สิทธิ์ตามค่า umask ของฝั่งปลายทาง ซึ่งบนโฮสต์ที่แชร์กันหลายบัญชี ค่านั้นแปลว่า "คนอื่นบนเครื่องเดียวกันอ่านได้"

find /var/www/tracker -maxdepth 1 -name '.env' -printf '%m %u:%g %p\n'
# 664 tracker:tracker /var/www/tracker/.env

chmod 600 /var/www/tracker/.env
find /var/www/tracker/storage -type d -exec chmod 770 {} \;
find /var/www/tracker -type f -name '*.php' -perm /o+w -print   # ต้องไม่มีผลลัพธ์
  1. ตรวจทุกครั้งหลังอัปไฟล์ ไม่ใช่ตอนตั้งเครื่องครั้งแรกอย่างเดียว เพราะการ deploy รอบถัดไปทับสิทธิ์กลับมาเป็นค่าเดิมได้เสมอ ให้ใส่บรรทัด chmod ไว้ท้ายสคริปต์ deploy เลย
  2. อย่าตั้ง 777 กับโฟลเดอร์ที่มีโค้ดอยู่ เพราะนั่นแปลว่ากระบวนการของเว็บเขียนทับโค้ดตัวเองได้ ซึ่งเป็นบันไดขั้นสุดท้ายของการยึดเครื่อง ใช้ 770 กับโฟลเดอร์ที่ต้องเขียน และ 640 กับไฟล์โค้ด
  3. ถ้ารหัสในไฟล์เคยถูกเปิดกว้างไว้ ให้ถือว่าหลุดแล้ว เปลี่ยนรหัสฐานข้อมูลและคีย์ทั้งหมดที่อยู่ในไฟล์นั้น ไม่ใช่แค่ปิดสิทธิ์แล้วจบ

เช็คลิสต์ก่อนกด optimize บนเครื่องโปรดักชัน

สรุปสิ่งที่ผมทำเป็นกิจวัตรหลังจากเจอเรื่องนี้ ใครจะตัดข้อไหนออกก็ตามสะดวก แต่ผมว่าสี่ข้อนี้คุ้มเวลาที่เสียไป

ลำดับที่ผมใช้ทุกรอบ deployอัปไฟล์->config:cache->view:cache->route ตามเครื่องcache หรือ clear->curl -Iต้องได้ 200->chmod .env

ข้อแรก อย่าใช้ optimize รวบเดียวบนเครื่องที่ยังไม่เคยพิสูจน์ว่า route cache ใช้ได้ ให้แยกสั่งทีละคำสั่งในรอบแรก พอผ่านแล้วค่อยรวบ ข้อสอง หลัง deploy ทุกครั้งต้องมีบรรทัด curl ยิงหน้าจริงแล้วอ่านสถานะ ไม่ใช่เปิดเบราว์เซอร์ดูเองแล้วบอกว่าเสร็จ เพราะเบราว์เซอร์มีแคชของตัวเองที่หลอกเราได้ ข้อสาม เครื่องไหนมีข้อยกเว้นให้เขียนไว้ในสคริปต์ของเครื่องนั้น ข้อสี่ ปิดสิทธิ์ไฟล์ลับเป็นขั้นตอนสุดท้ายเสมอ

ส่วนใครที่กำลังไล่บั๊กแนว "แอปทำงานอยู่แต่ผลลัพธ์ไม่ใช่อย่างที่โค้ดเขียน" ผมเคยเขียนอีกเคสหนึ่งไว้ที่ Laravel หลัง proxy เห็น IP เป็น 127.0.0.1 ซึ่งเป็นชั้นคนละชั้นกัน แต่มีวิธีคิดแบบเดียวกันคือเลิกหาคำว่า error แล้วไปหาว่าอะไรที่ทำงาน "ถูกต้องตามที่มันถูกบอกไว้" แต่ข้อมูลที่มันถูกบอกนั้นผิด ตรงไหนผมอธิบายตกหล่นหรือพิมพ์ผิด เดี๋ยวกลับมาแก้เพิ่มให้ภายหลังครับ

อยากให้เราทำให้ไหม?

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

คุยเรื่องงานกับเรา

เคยเจองานแบบนี้ไหม? ติดตรงไหน?

มาแลกวิธีกันต่อในบอร์ด LinuxThai

→ คุยต่อในบอร์ด

ความคิดเห็น 0

ยังไม่มีความคิดเห็น — เป็นคนแรกเลย