工程師與貓
Published on

Next.js 時區陷阱:當 K8s 環境讓你的日期判斷晚了 8 小時

Authors
  • avatar
    Name
    Alex Yu
    Twitter

前陣子收到 SEO 團隊回報,說網站有些文章明明已經發布了,但在 Google Search Console 上卻顯示 robots: noindex,導致文章無法被 Google 索引。

這問題有點詭異,因為同樣的邏輯在另一個專案運作正常,偏偏這個網站就出包。追查之後發現,這是一個時區處理的經典陷阱。

問題現象

以文章 ID 360131 為例:

  • API 回傳的 publish_time2026-01-05 16:33:00(台北時間)
  • Googlebot 爬取時間2026-01-05 22:50:33(台北時間)
  • 預期結果:文章已發布 6 小時,應該顯示 index, follow
  • 實際結果:顯示 noindex, nofollow

文章明明已經發布超過 6 小時,為什麼系統還判斷它是「未來稿」?

根本原因:伺服器時區不是你想的那樣

問題出在這段判斷「未來稿」的程式碼:

// ❌ 有問題的程式碼
import { parse, isAfter } from "date-fns";
 
export function isFutureDraft(publishTime: string): boolean {
  const now = new Date();
  const publishTime_UTC8 = parse(publishTime, "yyyy-MM-dd HH:mm:ss", new Date());
  return isAfter(publishTime_UTC8, now);
}

這段程式碼看起來沒問題,但有一個隱藏的假設:伺服器的本地時區是 UTC+8(台北時間)

K8s 環境的時區

問題在於,我們的應用部署在 K8s(Kubernetes) 環境,而 K8s Pod 預設的時區是 UTC,不是 UTC+8。

date-fnsparse 函數解析時間字串時,它會把結果當作本地時區的時間:

// 在 K8s (UTC 時區) 環境下執行
const publishTime = "2026-01-05 16:33:00";
const parsed = parse(publishTime, "yyyy-MM-dd HH:mm:ss", new Date());
 
console.log(parsed.toISOString());
// 輸出:2026-01-05T16:33:00.000Z  ← 被當成 UTC 時間!
// 實際應該是:2026-01-05T08:33:00.000Z(台北 16:33 = UTC 08:33)

錯誤的時間差

這導致時間判斷晚了 8 小時

時間點正確值錯誤值
publish_time(UTC)08:33:0016:33:00
Googlebot 爬取時間(UTC)14:50:3314:50:33
判斷結果08:33 < 14:50 → 已發布16:33 > 14:50 → 未來稿

因為錯誤地把台北時間當成 UTC 解析,導致判斷時多了 8 小時的偏差。

為什麼另一個專案沒問題?

同事之前在另一個專案就有處理過這個情況,使用了不同的寫法:

// ✅ 另一個專案的寫法(正確)
const publishTime = new Date(
  `${article.data.publish_time.replace(' ', 'T')}+08:00`
);

這種寫法明確指定了 +08:00 時區偏移量,所以不管伺服器是什麼時區,解析結果都是正確的。

解決方案:使用 date-fns-tz

修復方式是使用 date-fns-tz 套件,明確處理時區轉換:

// ✅ 修復後的程式碼
import { isAfter } from "date-fns";
import { fromZonedTime } from "date-fns-tz";
 
export function isFutureDraft(publishTime: string): boolean {
  try {
    const now = new Date();
 
    // publishTime 是台北時區的時間,需要明確轉換
    const TAIPEI_TIMEZONE = "Asia/Taipei";
    const publishTimeInTaipei = fromZonedTime(publishTime, TAIPEI_TIMEZONE);
 
    return isAfter(publishTimeInTaipei, now);
  } catch {
    return false; // 解析失敗時,預設為非未來稿
  }
}

fromZonedTime 的作用

fromZonedTime 的功能是:把一個「某時區的本地時間字串」轉換成正確的 UTC 時間

import { fromZonedTime } from "date-fns-tz";
 
const taipeiTimeString = "2026-01-05 16:33:00";
const result = fromZonedTime(taipeiTimeString, "Asia/Taipei");
 
console.log(result.toISOString());
// 輸出:2026-01-05T08:33:00.000Z  ← 正確的 UTC 時間

這樣不管伺服器是 UTC、UTC+8 還是其他時區,轉換結果都會是正確的。

補上單元測試

為了確保這個 bug 不會再發生,我補上了模擬 K8s 環境的測試:

import { isFutureDraft } from "../index";
 
describe("isFutureDraft", () => {
  beforeEach(() => {
    // 模擬 K8s UTC 環境:2026-01-05 14:50:33 UTC
    jest.useFakeTimers();
    jest.setSystemTime(new Date("2026-01-05T14:50:33.000Z"));
  });
 
  afterEach(() => {
    jest.useRealTimers();
  });
 
  it("should correctly identify published article in UTC environment", () => {
    // 台北 16:33 = UTC 08:33,在 UTC 14:50 時已經發布
    const publishTime = "2026-01-05 16:33:00";
 
    expect(isFutureDraft(publishTime)).toBe(false); // ← 應該是已發布
  });
 
  it("should correctly identify future article", () => {
    // 台北 23:00 = UTC 15:00,在 UTC 14:50 時還沒到發布時間
    const publishTime = "2026-01-05 23:00:00";
 
    expect(isFutureDraft(publishTime)).toBe(true); // ← 應該是未來稿
  });
});

重點整理

  1. 不要假設伺服器時區:在容器化環境(Docker、K8s)中,時區通常是 UTC,不是你開發機的時區
  2. 明確處理時區:使用 date-fns-tz 或在時間字串中加上時區資訊(如 +08:00
  3. date-fns parse 的陷阱parse 函數會將結果當作本地時區,這在不同環境可能產生不同結果
  4. 寫測試模擬生產環境:用 jest.setSystemTime 模擬 UTC 環境,確保時區邏輯正確

個人心得

這個 bug 讓我體會到,時區問題真的是「開發機上跑得好好的,部署後就爆炸」的經典代表。

以前總覺得「時區問題很簡單啊,就加個偏移量」,但實際上要考慮的細節很多:伺服器時區、資料庫時區、API 回傳格式、前端顯示...每個環節都可能出錯。

這次的教訓是:永遠明確指定時區,不要依賴隱式的本地時區轉換

希望這篇文章能幫助到遇到類似問題的開發者。

參考資料