EST. 2026 — บันทึกงานเซิร์ฟเวอร์จริง SYSADMIN · SECURITY · AI · DIRECTADMIN เล่มที่ 01
THE SYSADMIN ALMANAC
หน้าหลัก / Linux / แอปเป็น UTC คนคิดเป็นเวลาไทย — เพี้ยน 7 ชั่วโมงแบบไม่มี error
Linux

แอปเป็น UTC คนคิดเป็นเวลาไทย — เพี้ยน 7 ชั่วโมงแบบไม่มี error

โดย Ton-Or ·11 ต.ค. 2026·อ่าน 9 นาที
แอปเป็น UTC คนคิดเป็นเวลาไทย — เพี้ยน 7 ชั่วโมงแบบไม่มี error

บั๊กเรื่องเวลาเป็นบั๊กที่น่ากลัวกว่าบั๊กที่ทำให้เว็บล่ม เพราะเว็บล่มมีคนบอกภายในสามนาที แต่เวลาเพี้ยนเจ็ดชั่วโมงจะเงียบสนิทจนกว่าจะมีคนบังเอิญนั่งเฝ้าหน้าจอตอนที่มันควรทำงาน แล้วพบว่ามันไม่ทำงาน

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

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

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

สามจุดที่ต้องตรงกัน ไม่ใช่จุดเดียว

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

เวลาหนึ่งค่า เดินทางผ่านสี่จุดช่องกรอกหน้าปัดไทย ไม่มี tz→PHPCarbon→คอลัมน์ใน DBทุกคนอ่านเป็น UTC→หน้าจอแปลงกลับเป็นไทย

กฎที่ผมยึดหลังจากเคสนี้คือ เก็บอะไรลงฐาน อ่านกลับได้อะไร หน้าจอโชว์อะไร สามอย่างนี้ต้องตรงกันทั้งหมด ถ้าตรวจแค่จุดเดียวแล้วบอกว่าผ่าน แปลว่ายังไม่ได้ตรวจอะไรเลย เพราะบั๊กประเภทนี้เกิดจากการที่แต่ละจุดตีความค่าเดียวกันคนละแบบ ไม่ใช่จากการที่จุดใดจุดหนึ่งเสีย

ตั้งค่าใน env แล้วไม่มีผล

ทางแรกที่คนส่วนใหญ่ลองคือไปเปลี่ยนเขตเวลาของแอปให้เป็นเวลาไทยไปเลย จบเรื่อง ซึ่งในระบบนี้ทำไม่ได้ และลองแล้วก็ไม่มีอะไรเปลี่ยน เพราะไฟล์ตั้งค่าเขียนค่าตายตัวไว้ ไม่ได้อ่านจาก env

// config/app.php — ค่าตายตัว ใส่ APP_TIMEZONE ใน .env แล้วไม่มีผล
'timezone' => 'UTC',

// เขตเวลาสำหรับ "แสดงผล" แยกออกมาต่างหาก
'display_tz' => env('APP_DISPLAY_TZ', 'Asia/Bangkok'),

ตรงนี้มีคนเข้าใจผิดกันบ่อยมาก คือไปแก้ .env แล้วรัน config:cache แล้วเห็นว่าไม่มีอะไรเปลี่ยน ก็สรุปว่าแคชไม่ยอมอัปเดต แล้วไปนั่งไล่เรื่องแคชอยู่ครึ่งวัน ทั้งที่ความจริงคือไฟล์ตั้งค่าไม่เคยเรียก env() ตรงบรรทัดนั้นตั้งแต่แรก

# เช็คให้แน่ก่อนว่าค่าที่แอปใช้จริงคืออะไร อย่าเดาจากไฟล์ .env
php artisan tinker --execute='
  echo "app.timezone  = ", config("app.timezone"), PHP_EOL;
  echo "display_tz    = ", config("app.display_tz"), PHP_EOL;
  echo "php ini       = ", ini_get("date.timezone") ?: "(ว่าง)", PHP_EOL;
  echo "now()         = ", now()->toIso8601String(), PHP_EOL;
'
อย่าแก้ด้วยการเปลี่ยนเขตเวลาของแอปเป็นเวลาไทย

มันดูเหมือนทางลัดที่จบในบรรทัดเดียว แต่ผลคือค่าที่เขียนลงฐานทุกค่านับจากนั้นจะเป็นหน้าปัดไทย ในขณะที่ค่าเก่าทั้งหมดที่มีอยู่แล้วยังเป็น UTC ตารางเดียวกันเลยมีเวลาสองมาตรฐานปนกันโดยไม่มีอะไรบอกว่าแถวไหนเป็นแบบไหน ซึ่งแก้ยากกว่าปัญหาเดิมหลายเท่า ถ้าจะเปลี่ยนจริงต้องมี migration ที่ไล่แปลงค่าเก่าทั้งหมดพร้อมกัน และต้องหยุดระบบตอนทำ

ช่องกรอกเวลาไม่ได้ส่งเขตเวลามาด้วย

จุดที่สองอยู่ที่ฝั่งเบราว์เซอร์ ช่องกรอกชนิด datetime-local ส่งค่าออกมาเป็นสตริงหน้าปัดล้วนๆ เช่น 2026-10-21T15:00 โดยไม่มีข้อมูลเขตเวลาติดมาเลย ซึ่งเป็นพฤติกรรมตามมาตรฐาน ไม่ใช่บั๊ก ชื่อชนิดมีคำว่า local อยู่แล้ว

ปัญหาคือฝั่ง PHP รับสตริงนั้นมาแล้วต้องเดาเองว่าหน้าปัดนี้เป็นหน้าปัดของเขตเวลาไหน และค่าเริ่มต้นที่มันเดาคือเขตเวลาของแอป ซึ่งในระบบนี้คือ UTC

// ผิด — Carbon ตีความ 15:00 เป็นหน้าปัด UTC ทั้งที่คนกรอกหมายถึงบ่ายสามบ้านเรา
$at = Carbon::parse($request->input('publish_at'));

// ถูก — บอกให้ชัดว่าหน้าปัดนี้เป็นของเขตเวลาไหน
$at = Carbon::parse($request->input('publish_at'), config('app.display_tz'));

ความต่างของสองบรรทัดนี้คือเจ็ดชั่วโมงพอดี และไม่มีอะไรฟ้องเลย เพราะทั้งคู่คืนอ็อบเจกต์ที่ถูกต้องตามชนิดของมัน ไม่มี exception ไม่มี warning

// เทียบให้เห็นกับตา
$s = '2026-10-21T15:00';
echo Carbon::parse($s)->toIso8601String(), PHP_EOL;
// 2026-10-21T15:00:00+00:00   <- บ่ายสามที่ลอนดอน = สี่ทุ่มบ้านเรา

echo Carbon::parse($s, 'Asia/Bangkok')->toIso8601String(), PHP_EOL;
// 2026-10-21T15:00:00+07:00   <- บ่ายสามบ้านเรา ตามที่คนกรอกตั้งใจ

บรรทัดที่เขียนหน้าปัดไทยลงคอลัมน์ UTC

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

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

ทางที่ถูก: ระบุ tz ตอนรับ และ utc() ตอนเขียนช่องกรอก15:00 ไม่มี tzparse พร้อม tzแล้ว utc() = 08:00คอลัมน์ใน DB08:00 (UTC)หน้าจอแปลงกลับ 15:00ทางที่ผิด: เขียนหน้าปัดไทยลงคอลัมน์ที่อ่านเป็น UTCช่องกรอก15:00 ไม่มี tzPHPไม่ระบุ tz หรือไม่เรียก utc()คอลัมน์ใน DB15:00 หน้าปัดไทยในคอลัมน์ UTCหน้าจอแสดง 22:00เลื่อนไป 7 ชม.
เวลา 15:00 ที่ปรับเป็น UTC ก่อนเขียนจะเก็บเป็น 08:00 และแสดงกลับเป็น 15:00 แต่ถ้าเขียนหน้าปัดไทย 15:00 ลงไปตรงๆ จะถูกอ่านเป็น UTC และแสดงเป็น 22:00
$at = Carbon::parse('2026-10-21T15:00', 'Asia/Bangkok');   // 15:00 +07:00

// ผิด — ตัวสร้างคำสั่งเขียน "2026-10-21 15:00:00" ลงคอลัมน์ที่ทุกคนอ่านเป็น UTC
DB::table('posts')->where('id', $id)->update(['publish_at' => $at]);

// ถูก — ปรับเป็นเวลาสากลก่อนเสมอ
DB::table('posts')->where('id', $id)->update(['publish_at' => $at->utc()]);
ผ่าน Eloquent

แปลงให้ ถ้าประกาศ cast ไว้

โมเดลที่มี casts บอกว่าคอลัมน์นี้เป็น datetime จะมีชั้นแปลงค่าคั่นให้ตอนเขียนและตอนอ่าน ทำให้ค่าที่ลงฐานเป็นเวลาสากลเสมอ แต่ต้องประกาศไว้จริง ไม่ใช่คิดเอาเองว่ามี

ผ่าน query builder

ไม่มีชั้นแปลงให้เลย

จัดรูปเป็นสตริงแล้วส่งลงไปตรงๆ ค่าที่ได้คือหน้าปัดของอ็อบเจกต์นั้น ซึ่งถ้าเป็นหน้าปัดไทยก็จะเขียนหน้าปัดไทยลงคอลัมน์ที่ทุกคนที่เหลือในระบบอ่านเป็น UTC

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

# ไล่หาทุกจุดที่เขียนคอลัมน์เวลาผ่านตัวสร้างคำสั่ง
grep -rnE "DB::table\(.*\)->(update|insert)" app/ \
  | grep -E "_at|_time|date" 

# และไล่หาโมเดลที่ยังไม่ได้ประกาศ cast ให้คอลัมน์เวลาของตัวเอง
grep -rn "publish_at" app/Models/

สัญญาณเตือนที่ควรจับให้ได้เร็วกว่านี้

ย้อนกลับไปดู มีสัญญาณอยู่ก่อนแล้วสองอย่าง แต่ตอนนั้นไม่มีใครเอามาต่อกัน

อย่างแรกคือข้อความยืนยันหลังกดบันทึก กับเวลาที่โชว์ในหน้ารายการไม่ตรงกัน ข้อความยืนยันมักถูกสร้างจากตัวแปรในหน่วยความจำที่ยังมีเขตเวลาถูกต้องอยู่ ส่วนหน้ารายการอ่านค่ากลับมาจากฐาน ถ้าสองอันนี้ต่างกันเมื่อไหร่ แปลว่าขั้นตอนเขียนลงฐานทำอะไรกับค่านั้นระหว่างทาง

อย่างที่สองคือเวลาที่โชว์ในหน้ารายการต่างจากเวลาที่คนกรอกเป็นจำนวนคงที่เสมอ ไม่ว่าจะกรอกค่าไหน ความต่างที่คงที่เป๊ะแบบนี้บอกได้เลยว่าเป็นเรื่องเขตเวลา ไม่ใช่เรื่องการคำนวณ

7 ชม.ระยะห่างจาก UTC
15:00ที่คนกรอก
22:00ที่ของขึ้นจริง
0error ที่เห็น

ชั้นเดียวที่ตัดสินใจเรื่องเขตเวลา

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

namespace App\Support;

use Carbon\CarbonImmutable;

final class Clock
{
    /** รับหน้าปัดไทยจากฟอร์ม คืนค่าที่พร้อมเก็บลงฐาน (UTC เสมอ) */
    public static function fromForm(?string $wall): ?CarbonImmutable
    {
        if ($wall === null || trim($wall) === '') {
            return null;
        }
        return CarbonImmutable::parse($wall, config('app.display_tz'))->utc();
    }

    /** รับค่าจากฐาน คืนหน้าปัดไทยสำหรับเอาไปโชว์ */
    public static function toDisplay(?CarbonImmutable $utc): ?CarbonImmutable
    {
        return $utc?->setTimezone(config('app.display_tz'));
    }

    /** ค่าที่เอาไปใส่ค่าเริ่มต้นให้ช่องกรอก — ต้องเป็นหน้าปัดไทยไม่มี tz */
    public static function toFormValue(?CarbonImmutable $utc): string
    {
        return self::toDisplay($utc)?->format('Y-m-d\TH:i') ?? '';
    }
}

แล้วบังคับให้ทุกเส้นทางวิ่งผ่านสามฟังก์ชันนี้เท่านั้น ข้อดีคือเวลาจะตรวจ ไม่ต้องไล่ทั้งโปรเจกต์ ตรวจแค่ว่ามีใครเรียก Carbon::parse หรือ setTimezone นอกไฟล์นี้ไหม

grep -rn "Carbon::parse\|CarbonImmutable::parse\|setTimezone" app/ resources/views/ \
  | grep -v "app/Support/Clock.php"
# ผลลัพธ์ที่ต้องการคือว่างเปล่า

ชุดทดสอบที่ต้องพังด้วยเหตุผลที่ถูก

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

ทางแก้คือตัวทดสอบต้องยืนยันด้วยค่าดิบที่อยู่ในฐานจริง ไม่ใช่ค่าที่อ่านผ่านชั้นแปลงของแอปเอง

public function test_publish_at_is_stored_as_utc(): void
{
    config(['app.display_tz' => 'Asia/Bangkok']);

    $this->post('/posts', [
        'title'      => 'ทดสอบ',
        'publish_at' => '2026-10-21T15:00',
    ])->assertRedirect();

    // อ่านค่าดิบ ไม่ผ่าน Eloquent ไม่ผ่าน cast ไม่ผ่าน Clock
    $raw = DB::selectOne('select publish_at from posts order by id desc limit 1');
    $this->assertSame('2026-10-21 08:00:00', $raw->publish_at);

    // แล้วค่อยเช็คว่าอ่านกลับมาโชว์แล้วได้หน้าปัดไทยเดิม
    $post = Post::latest('id')->first();
    $this->assertSame('2026-10-21 15:00', Clock::toDisplay($post->publish_at)->format('Y-m-d H:i'));
}
ยืนยันสามจุดในการทดสอบเดียว

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

รายการตรวจที่ผมใช้

  1. ถามให้ได้ก่อนว่าคอลัมน์เวลาในฐานถูกตกลงกันว่าเป็นเขตเวลาอะไร แล้วเขียนไว้ในเอกสารของตาราง ไม่ใช่ปล่อยให้แต่ละคนเดาเอง
  2. ตรวจว่าค่าเขตเวลาที่แอปใช้จริงคืออะไร ด้วยการอ่านจากตัวแอปเอง ไม่ใช่อ่านจากไฟล์ env
  3. ไล่ทุกจุดที่รับค่าเวลาจากผู้ใช้ ต้องระบุเขตเวลาของหน้าปัดตอนแปลงทุกครั้ง ห้ามปล่อยให้เดา
  4. ไล่ทุกจุดที่เขียนคอลัมน์เวลาผ่านตัวสร้างคำสั่ง ต้องปรับเป็นเวลาสากลก่อนเขียนทุกจุด
  5. เทียบข้อความยืนยันหลังบันทึกกับค่าที่โชว์ในหน้ารายการ ต้องตรงกัน ถ้าต่างกันแปลว่ายังมีรูอยู่
  6. เขียนตัวทดสอบที่อ่านค่าดิบจากฐาน แล้วยืนยันว่ามันพังจริงถ้าเอา utc ออก ตัวทดสอบที่ไม่เคยเห็นมันแดงคือตัวทดสอบที่เชื่อไม่ได้

สรุป

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

สิ่งที่ผมทำหลังเคสนี้เลยไม่ใช่แค่แก้บรรทัดที่ผิด แต่เขียนข้อตกลงลงไปในเอกสารของตารางว่าคอลัมน์เวลาทุกตัวเป็นเวลาสากล และรวบการแปลงไว้ชั้นเดียว ที่เหลือคือตัวทดสอบหนึ่งตัวที่คอยเฝ้าไว้ ใครจะเลือกเก็บเป็นเวลาไทยทั้งระบบแทนก็ทำได้เหมือนกันครับ ไม่ผิด แค่ต้องเลือกอย่างใดอย่างหนึ่งแล้วเขียนไว้ให้ชัด อย่าปล่อยให้มีสองมาตรฐานอยู่ในตารางเดียวกัน ตรงไหนผมอธิบายตกหล่นหรือพิมพ์ผิด เดี๋ยวกลับมาแก้เพิ่มให้ภายหลังครับ

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

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

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

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

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

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

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

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