audit ระบบ prod ที่มีข้อมูลลูกค้าจริง โดยไม่ทิ้งขยะสักแถว
การทดสอบบนเครื่องจริงไม่ได้อันตรายเพราะมันแตะข้อมูล แต่อันตรายเพราะเราไม่มีหลักฐานว่ามันไม่ได้แตะ ความต่างอยู่ตรงที่ว่าเราวัดก่อนกับหลังหรือเปล่า
สรุปใน 1 นาที
- อาการ/ปัญหา
- ต้องทดสอบระบบบนเครื่องจริงที่มีข้อมูลลูกค้า โดยห้ามเหลือแถวขยะแม้แถวเดียว
- ใครควรอ่าน
- คนดูแลแอป Laravel บน production ที่ต้องพิสูจน์ว่าการทดสอบไม่แตะข้อมูล
- ทางแก้แบบสั้น
- bootstrap Laravel เองแล้วเรียก controller ตรง ๆ ใน transaction ที่ rollback ทิ้ง และนับ COUNT ทุกตารางก่อนกับหลังเป็นหลักฐาน
พอดีมีระบบตัวหนึ่งที่ใช้งานจริงอยู่แล้ว มีข้อมูลลูกค้าเดินอยู่ทุกวัน แล้วเจ้าของสั่งมาว่าให้ตรวจทั้งระบบ จำลองการใช้งานจริงให้ครบทุกหน้า แล้วลบร่องรอยออกให้หมดโดยห้ามทำข้อมูลเดิมเสียหาย โจทย์แบบนี้ฟังดูขัดกันเองในตัวครับ เพราะการจำลองการใช้งานคือการเขียนข้อมูลลงไป
เครื่อง staging ที่มีข้อมูลเหมือน prod ก็ไม่มี และการก็อปข้อมูลลูกค้าออกไปวางเครื่องอื่นเพื่อทดสอบนั้นเป็นความเสี่ยงที่ใหญ่กว่าปัญหาที่จะแก้เสียอีก ผมเลยเลือกทางที่สาม คือทดสอบบนเครื่องจริงนั่นแหละ แต่ล้อมทุกอย่างไว้ใน transaction แล้วม้วนกลับทิ้งตอนจบ พร้อมนับจำนวนแถวทุกตารางก่อนและหลังเป็นหลักฐานว่าไม่มีอะไรตกค้าง
ชื่อระบบ โดเมน ชื่อฐานข้อมูล และพาธในบทความเป็นค่าตัวอย่าง ส่วนวิธีและกับดักที่เจอเป็นของจริง
ทำไมไม่ใช้ tinker
คนส่วนใหญ่พอได้ยินว่า "จะรันโค้ด Laravel บนเซิร์ฟเวอร์" ก็จะนึกถึง artisan tinker ทันที ผมลองสองแบบและไม่ผ่านทั้งคู่ครับ
quoting พังก่อนได้เริ่ม
โค้ดที่จะรันต้องผ่านทั้ง shell ฝั่งเรา ทั้งชั้น ssh ทั้ง shell ฝั่งโน้น แล้วถึงเข้า php อีกที เครื่องหมายคำพูด ดอลลาร์ แบ็กสแลช ทุกตัวถูกแปลความสามรอบ พอโค้ดยาวเกินสองสามบรรทัดคือแตกทันที และดีบักไม่ได้เลยเพราะไม่รู้ว่าพังชั้นไหน
ค้างจน timeout
รูปแบบที่สั่ง tinker ให้อ่านสคริปต์จากไฟล์ ตัวมันเปิด session แบบโต้ตอบรอ input ค้างอยู่อย่างนั้นจนกว่า ssh จะตัด สุดท้ายไม่ได้ผลลัพธ์อะไรกลับมาเลยนอกจากรอเก้อ
ทางที่ใช้ได้จริงคือเลิกพึ่ง tinker แล้วเขียนสคริปต์ php ธรรมดาวางไว้ที่รากของแอป แล้วเรียกด้วย php ตรง ๆ ครับ ได้ทั้งความยาวที่ไม่จำกัด ได้ทั้ง stack trace เต็ม ๆ ตอนพัง และจบเมื่อสคริปต์จบ
# วางไฟล์ขึ้นไป รัน แล้วลบทิ้งทันทีในบรรทัดเดียว
scp -P 2233 audit-harness.php user@host:~/app/
ssh -p 2233 user@host '/usr/local/php82/bin/php ~/app/audit-harness.php; rm -f ~/app/audit-harness.php'
บนเครื่องที่มี php หลายเวอร์ชัน (ซึ่งคือเครื่อง shared host เกือบทุกตัว) คำสั่ง php เปล่า ๆ มีสิทธิ์ไปโดนคนละเวอร์ชันกับที่เว็บใช้อยู่ แล้วจะได้ error เรื่อง extension หายหรือ syntax ที่ไม่รู้จัก ทั้งที่โค้ดถูกทุกบรรทัด ดูให้ชัดก่อนว่าเว็บรันด้วยตัวไหนแล้วเรียกตัวนั้นตรง ๆ
บรรทัดที่ขาดแล้วทุกอย่างพัง
ตรงนี้คือจุดที่ผมเสียเวลาไปมากที่สุด การ bootstrap Laravel ด้วยมือมีสองบรรทัดที่ทุกคนรู้ คือ autoload กับ bootstrap/app.php แต่มีบรรทัดที่สามที่ตัวอย่างส่วนใหญ่ไม่ได้บอก
<?php
require __DIR__.'/vendor/autoload.php';
$app = require_once __DIR__.'/bootstrap/app.php';
// บรรทัดนี้ขาดไม่ได้ — ถ้าไม่มี Eloquent จะเด้ง "Call to a member function connection() on null"
$app->make(Illuminate\Contracts\Console\Kernel::class)->bootstrap();
ที่ขาดไม่ได้เพราะตัว bootstrap/app.php แค่ประกอบ container ขึ้นมาเฉย ๆ ยังไม่ได้รัน service provider ตัวไหนเลย แปลว่าตัวจัดการฐานข้อมูลยังไม่ถูกผูกเข้า container พอเรียก Model ตัวแรกมันเลยไปหา connection ที่ยังเป็น null แล้วเด้งออกมาด้วยข้อความที่ชวนให้เข้าใจผิดว่าตั้งค่า .env ผิด ซึ่งไม่ใช่เลย
นับก่อน ทำ แล้วนับหลัง
หัวใจของงานนี้ไม่ได้อยู่ที่การ rollback ครับ เพราะ rollback มันเป็นแค่คำสั่ง หัวใจอยู่ที่หลักฐาน ว่ามันม้วนกลับได้จริง ผมเลยเริ่มจากการนับแถวทุกตารางในฐานข้อมูลเก็บไว้ก่อนแตะอะไรทั้งนั้น
function snapshotCounts(): array {
$db = DB::connection()->getDatabaseName();
$tables = DB::select(
'SELECT TABLE_NAME AS t FROM information_schema.TABLES WHERE TABLE_SCHEMA = ?', [$db]
);
$out = [];
foreach ($tables as $row) {
// นับจริงด้วย COUNT(*) เท่านั้น — TABLE_ROWS ของ information_schema เป็นค่าประมาณ
$out[$row->t] = (int) DB::table($row->t)->count();
}
ksort($out);
return $out;
}
$before = snapshotCounts();
ค่า TABLE_ROWS ใน information_schema ของ InnoDB เป็นค่าที่ตัวเก็บสถิติเดามาจากการสุ่มหน้าข้อมูล ไม่ใช่จำนวนจริง มันเพี้ยนได้หลายสิบเปอร์เซ็นต์และเปลี่ยนเองได้แม้ไม่มีใครเขียนอะไร ถ้าเอามาทำหลักฐานว่า "ก่อนกับหลังเท่ากัน" จะได้ทั้งผลบวกลวงและผลลบลวง ตารางเยอะแค่ไหนก็ยอมเสียเวลา COUNT ให้ครบเถอะครับ
จบงานแล้วนับใหม่ แล้วเทียบทีละตาราง ตัวไหนต่างคือมีของตกค้าง
$after = snapshotCounts();
$diff = [];
foreach ($after as $t => $n) {
$b = $before[$t] ?? 0;
if ($n !== $b) { $diff[$t] = "$b -> $n"; }
}
if ($diff) {
echo "RESULT: INCOMPLETE\n";
foreach ($diff as $t => $d) { echo "PROBLEM: ตาราง $t เปลี่ยน $d\n"; }
exit(1);
}
echo "RESULT: OK (ทุกตารางเท่าเดิม)\n";
ผมให้มันจบด้วย exit(1) ตอนไม่ผ่าน เพราะถ้าเอาไปต่อกับงานอัตโนมัติทีหลัง จะได้ไม่มีใครเผลออ่านว่าผ่านทั้งที่ไม่ผ่าน คำตัดสินต้องอยู่ที่บรรทัด RESULT บรรทัดเดียว ไม่ใช่อยู่ที่ว่าสคริปต์รันจบโดยไม่ error
จำลองการบันทึกจริง — เรียก controller ตรง ๆ
จุดที่ผมยืนยันคือต้องเรียกโค้ดเส้นเดียวกับที่ผู้ใช้จริงเดิน ไม่ใช่เขียน Model::create() เองแล้วบอกว่าทดสอบแล้ว เพราะสิ่งที่อยากรู้คือ validation ผ่านไหม การคำนวณเลขเอกสารทำงานไหม การลงบัญชีอัตโนมัติที่ผูกอยู่กับ event ทำงานครบไหม ของพวกนั้นอยู่ในชั้น controller กับ service ไม่ได้อยู่ใน model
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use App\Http\Controllers\PatientController;
DB::beginTransaction();
try {
$req = Request::create('/patients', 'POST', [
'first_name' => 'ทดสอบ',
'last_name' => 'ระบบ',
'phone' => '0800000000',
'birth_date' => '2530-01-01',
]);
// controller หลายตัวเรียก auth()->user() ข้างใน ต้องผูกผู้ใช้ให้ก่อน
$req->setUserResolver(fn () => $tester);
$controller = new PatientController();
$resp = $controller->store($req);
echo "store() คืนสถานะ ".$resp->getStatusCode()."\n";
echo "แถวใหม่ในตาราง patients: ".DB::table('patients')->count()."\n";
} catch (\Throwable $e) {
echo "PROBLEM: ".get_class($e).' — '.$e->getMessage()."\n";
} finally {
DB::rollBack(); // ต้องอยู่ใน finally เสมอ ไม่ว่าจะพังหรือไม่
}
finally สำคัญมากครับ ถ้าเอา rollBack() ไปวางไว้ท้ายบล็อก try เฉย ๆ แล้วโค้ดโยน exception ออกกลางทาง transaction จะค้างเปิดอยู่จนกว่า connection จะปิด ซึ่งบนเครื่องจริงแปลว่าล็อกแถวค้างและอาจไปขวางผู้ใช้จริงที่กำลังทำงานอยู่
ข้อกังวลที่คนถามบ่อยคือ ถ้าข้างใน controller มี DB::transaction() ของมันเองอีกชั้น การ rollback ชั้นนอกจะยังคืนได้ครบไหม คำตอบคือได้ครับ เพราะ Laravel ใช้ SAVEPOINT สำหรับ transaction ชั้นในเสมอ ไม่ได้เปิด transaction ใหม่จริง การ commit ของชั้นในแค่ปล่อย savepoint ทิ้ง ของทั้งหมดยังอยู่ใต้ transaction ชั้นนอกที่เราคุมอยู่
จำลองการเปิดหน้า — วนทุก GET route
ส่วนที่สองคือตรวจว่าหน้าเว็บทุกหน้ายังเปิดได้จริงหลังการแก้โค้ดรอบล่าสุด อันนี้ไม่ต้องใช้ transaction เพราะ GET ไม่ควรเขียนอะไรอยู่แล้ว แต่ก็ยังนับแถวก่อนหลังอยู่ดี เพราะถ้ามี route ไหนแอบเขียน ผมอยากรู้
เคล็ดที่ทำให้ใช้ได้จริงคือการเติมค่าให้พารามิเตอร์ใน URL ด้วย id ที่มีอยู่จริงในฐานข้อมูล ไม่ใช่ยัด 1 ไปดื้อ ๆ เพราะแถว id 1 มักถูกลบไปนานแล้ว แล้วจะได้ 404 ทั้งกระดานโดยที่ระบบไม่ได้พังจริง
use Illuminate\Contracts\Http\Kernel as HttpKernel;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Route;
$kernel = $app->make(HttpKernel::class);
Auth::loginUsingId($testerId);
$pass = $fail = 0;
foreach (Route::getRoutes() as $route) {
if (!in_array('GET', $route->methods(), true)) continue;
$uri = $route->uri();
if (str_starts_with($uri, 'api/') || str_contains($uri, '{')) {
$uri = fillParams($uri); // เติม id จริงจาก DB
if ($uri === null) continue; // ไม่มีข้อมูลให้ทดสอบ ข้ามไป
}
$resp = $kernel->handle(Request::create('/'.$uri, 'GET'));
$code = $resp->getStatusCode();
if ($code === 200) { $pass++; }
else { $fail++; echo "PROBLEM: /$uri -> $code\n"; }
}
echo "GET route ผ่าน $pass / ล้มเหลว $fail\n";
รอบล่าสุดที่ผมรัน ได้ 49 route ตอบ 200 ครบทุกตัว ซึ่งเป็นตัวเลขที่มีความหมายกว่าการนั่งคลิกดูเองสิบหน้าแล้วบอกว่าเรียบร้อย
สิ่งที่ rollback คืนให้ไม่ได้
ตรงนี้ต้องพูดให้ชัดครับ ไม่อย่างนั้นคนอ่านจะเข้าใจผิดว่าวิธีนี้ปลอดภัยร้อยเปอร์เซ็นต์ transaction คุมได้แค่สิ่งที่อยู่ในฐานข้อมูลที่รองรับ transaction เท่านั้น ของอีกหลายอย่างที่ controller ไปแตะระหว่างทางนั้นออกไปแล้วออกเลย
เมล ที่ยิงออกจริงตอน store สำเร็จ · ไฟล์ ที่เขียนลงดิสก์หรืออัปขึ้นที่เก็บภายนอก · job ที่ถูกโยนเข้าคิว (ถ้าคิวไม่ได้อยู่บนฐานข้อมูลเดียวกันก็ไม่ถูก rollback) · การเรียก API ภายนอก เช่นยิงข้อความแจ้งเตือนหรือตัดสต็อกที่ระบบอื่น · ตารางที่ยังเป็น MyISAM ซึ่งไม่รู้จัก transaction เลย เขียนแล้วเขียนเลย
สองข้อแรกกันได้ง่าย ๆ ด้วยการสลับ driver ตอนรันสคริปต์ ผมทำแบบนี้
// ตัดขาไม่ให้ของหลุดออกนอกเครื่องระหว่างทดสอบ
config([
'mail.default' => 'log', // เมลลง log ไม่ส่งจริง
'queue.default' => 'sync', // job ทำทันทีในเส้นเดียวกัน จะได้อยู่ใต้ transaction
'filesystems.default'=> 'local',
]);
ส่วนข้อสุดท้ายต้องเช็กก่อนเริ่ม เพราะถ้ามีตาราง MyISAM ปนอยู่แม้ตารางเดียว หลักฐานเรื่อง "ไม่มีอะไรตกค้าง" จะไม่จริงทันที
-- ต้องได้ผลลัพธ์ว่าง ถ้ามีแถวโผล่มา แปลว่าตารางนั้นไม่รองรับ transaction
SELECT TABLE_NAME, ENGINE
FROM information_schema.TABLES
WHERE TABLE_SCHEMA = DATABASE()
AND ENGINE <> 'InnoDB';
ของที่ต้องทำก่อนแตะเครื่องจริง
ต่อให้มั่นใจแค่ไหน ผมก็ยังทำสองอย่างนี้ก่อนเสมอ ใช้เวลารวมกันไม่ถึงห้านาที
- dump ฐานข้อมูลเก็บไว้ก่อน ตั้งชื่อไฟล์ให้มีทั้งวันที่และเหตุผล จะได้รู้ทีหลังว่าก้อนนี้ทำไว้ทำไม
- เทียบไฟล์ทั้งต้นไม้ระหว่างเครื่องเรากับเครื่องจริงด้วย md5sum ให้แน่ใจว่ากำลังทดสอบโค้ดเวอร์ชันเดียวกันจริง
- ตรวจ engine ของทุกตารางว่าเป็น InnoDB ล้วน
- รันสคริปต์ อ่านบรรทัด RESULT แล้วลบไฟล์ทิ้งทันทีในคำสั่งเดียวกัน
# 1) dump ก่อน ชื่อไฟล์บอกเหตุผลด้วย
mysqldump --single-transaction --quick --no-tablespaces appdb \
| gzip > ~/backups/appdb-20260914-before-audit.sql.gz
# 2) เทียบ tree สองฝั่ง (ควรต่างแค่ไฟล์ที่ตั้งใจแก้กับ bootstrap/cache)
find app config routes resources -type f -name '*.php' -exec md5sum {} \; | sort -k2 > /tmp/prod.md5
# ฝั่งเครื่องเราทำแบบเดียวกันแล้ว diff กัน
ข้อสองนี้สำคัญกว่าที่คิดครับ ผมเคยเจอเคสที่นั่งไล่บั๊กบนเครื่องตัวเองไม่เจอ เพราะไฟล์บนเครื่องจริงกับบนเครื่องเราต่างกันอยู่สองไฟล์ที่ใครสักคนแก้ตรงหน้าเครื่องเมื่อหลายเดือนก่อนแล้วลืมบอก การเทียบ md5 ทั้งต้นไม้ใช้เวลาไม่กี่วินาทีและตอบคำถามนั้นได้เด็ดขาด
ปิดงานอย่างสะอาด
ข้อสุดท้ายที่ผมถือเป็นกฎคือสคริปต์แบบนี้ต้องไม่ค้างอยู่บนเครื่องจริงแม้แต่นาทีเดียวหลังใช้เสร็จ มันเป็นไฟล์ php ที่วางอยู่ในรากของแอป ซึ่งบางการตั้งค่าอาจเข้าถึงได้จากเว็บโดยตรง และมันมีสิทธิ์เต็มกับฐานข้อมูล ถ้าลืมทิ้งไว้คือช่องโหว่ที่เราสร้างขึ้นมาเอง
ผมเลยเขียนคำสั่งลบไว้ในบรรทัดเดียวกับคำสั่งรันเสมอ คั่นด้วย semicolon ไม่ใช่ && เพราะต้องการให้ลบทิ้งแม้สคริปต์จะพังกลางคัน แล้วปิดท้ายด้วยการยืนยันว่าไม่เหลือจริง
ssh -p 2233 user@host '
cd ~/app
/usr/local/php82/bin/php audit-harness.php; rc=$?
rm -f audit-harness.php
ls -la audit-harness.php 2>&1 | head -n1
exit $rc
'
วิธีนี้ใช้ได้ดีกับระบบที่ผูกกันแน่นจนแยกออกมาทดสอบไม่ได้ และกับเครื่องที่ยกชุดข้อมูลออกไปไม่ได้ด้วยเหตุผลเรื่องความเป็นส่วนตัว ส่วนตัวผมมองว่ามันไม่ได้มาแทนชุดทดสอบอัตโนมัติที่ควรจะมีนะครับ มันเป็นเครื่องมือสำหรับตอบคำถามว่าของจริงวันนี้ยังเดินอยู่ไหม ซึ่งเป็นคนละคำถามกับที่ unit test ตอบ ใครมีเงื่อนไขต่างจากนี้ก็ปรับเอาตามสะดวก ตรงไหนผมอธิบายตกหล่นหรือพิมพ์ผิด เดี๋ยวกลับมาแก้เพิ่มให้ภายหลังครับ
อยากให้เราทำให้ไหม?
ให้เราช่วยติดตั้ง ตั้งค่า ย้ายระบบ หรือแก้ปัญหาเซิร์ฟเวอร์ เพื่อให้คุณมีเวลาไปทำงานที่สำคัญกว่า
เคยเจองานแบบนี้ไหม? ติดตรงไหน?
มาแลกวิธีกันต่อในบอร์ด LinuxThai



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