0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

G22 【外伝】時間とタイムゾーンがズレる混入点:UTC・オフセット・地域ルールを境界で揃える

0
Last updated at Posted at 2026-01-21

連載Index(読む順・公開済リンクはここが最新): S00: 門前の誓い(総合Index)

障害調査で、ログの時刻とDBの時刻が噛み合わない。
「頭で補正できるし、いったん放置でいいか」と一瞬思った。
ログとクエリ結果を並べ、UTC表記とオフセットの有無を見比べた。

同じ「10:15」が2つある。けれど、片方はUTC、片方はローカルで、頭の補正が追いつかなくなる。

このページではズレの混入点と、最初から起きにくくする揃え方(保存/表示の境界)を整理する。


このページで手に入るもの

  • 逆引き(症状→混入点→対策)が1枚で揃う
  • タイムゾーンの直感を掴む図(グリニッジ→UTC→東西→日付の早い/遅い)
  • 最短テンプレ(コピペ)2本:UTCで保存する/ローカル入力をUTCへ正規化する
  • よくあるズレの判例(悪い例→直す→ポイント)が揃う
  • レビューのチェックリスト(表)で確認観点が揃う
  • セルフチェック(5問)で、ズレの説明と対応が自走できる

先に逆引き(症状→混入点→対策)

まず「どこでズレたか」を当てる。時間は混入点が定形化しやすい。

症状 混入点(どこでズレるか) 事実を揃える観測 まず当てる対策
予定が1日ズレる(0:00跨ぎ) UI入力直後/API受信直後 入力値の文字列、オフセット有無、Kind/Offset 入力はローカル+タイムゾーンで受け、UTCへ正規化
ログの時系列が壊れる ログ出力直前 ログにUTC/オフセットが出ているか ログはUTCを基準にし、表示で変換
“毎日9:00”が季節でズレる スケジュール確定直後 対象地域がDSTありか、タイムゾーン情報が保持されているか “壁時計の9:00”+タイムゾーン情報で保持し、都度UTC化
外部APIの時刻が解釈できない API受信直後 Z / +09:00 / 何も無し(曖昧) オフセット付きで受け渡しする規約へ寄せる
環境(Windows/Linux)で落ちる タイムゾーンID解決時 Windows ID / IANA ID のどちらを使っているか OSを跨ぐならID変換方針を決め、失敗時の扱いも決める

タイムゾーンの直感(地球→グリニッジ→UTC→東西)

ここでは「壁時計の時刻」と「基準の時刻(UTC)」の関係を先に揃える。難しい話は後段へ送る。

この図で揃えるのは次の3点だけ。

  • UTC:基準の時刻(世界で揃える“瞬間”の基準)
  • オフセット(UTC+9 など):UTCとの差(その時点の差)
  • タイムゾーン(Asia/Tokyo など):地域ルール(季節でオフセットが変わる地域がある)

最短テンプレ(コピペ)1:保存はUTC、表示で変換する

ここは最も効く規約。境界(どこで揃えるか)を先に置く。

  • 貼る場所:DB保存直前/外部API送信直前/ログ出力直前
  • 境界:この境界より内側はUTCで揃える(瞬間を揃える)
  • 例外方針:入力不正は ArgumentException 系、状態不正は InvalidOperationException 系に寄せる
using System;

internal static class TimeNormalization
{
    /// <summary>
    /// DateTimeOffset を「UTCの瞬間」へ揃える(保存・送信・ログの基準)。
    /// </summary>
    /// <param name="value">変換対象の日時(オフセット付き)。</param>
    /// <returns>UTC(+00:00)へ揃えた DateTimeOffset。</returns>
    public static DateTimeOffset ToUtcInstant(DateTimeOffset value)
    {
        // DateTimeOffset は “瞬間” を持つので UTC へ揃えやすい
        return value.ToUniversalTime();
    }

    /// <summary>
    /// DateTime を「UTCの瞬間」へ揃える(保存・送信・ログの基準)。
    /// </summary>
    /// <remarks>
    /// 境界:DB保存直前/外部API送信直前/ログ出力直前で当てる想定。
    /// DateTime.Kind が Unspecified の場合は解釈が曖昧なため、ここで止める。
    /// </remarks>
    /// <param name="value">変換対象の日時(Kindが重要)。</param>
    /// <returns>UTC(+00:00)へ揃えた DateTimeOffset。</returns>
    /// <exception cref="ArgumentException">value.Kind が Unspecified の場合。</exception>
    public static DateTimeOffset ToUtcInstant(DateTime value)
    {
        // DateTime は Kind によって解釈が変わるため、曖昧はここで止める
        if (value.Kind == DateTimeKind.Unspecified)
            throw new ArgumentException("DateTime.Kind=Unspecified は解釈が曖昧。入力側でUTC/ローカルを決めて渡す。", nameof(value));

        // Local / Utc のどちらでも UTC の瞬間へ変換できる
        var utc = value.ToUniversalTime();
        return new DateTimeOffset(utc, TimeSpan.Zero);
    }
}

最短テンプレ(コピペ)2:ローカルの日時+タイムゾーンからUTCへ正規化する

ここは「ログ/DBがズレて見える」の根を減らすテンプレ。壁時計の日時を、地域ルール付きで“瞬間”へ落とす。

  • 貼る場所:UI入力直後/API受信直後(ローカル日時として入ってくる所)
  • 境界:入力の直後にUTCの瞬間へ正規化し、以降はUTCで流す
  • 例外方針:曖昧/無効の日時は ArgumentException(入力不正扱いに寄せる)
using System;

internal static class LocalTimeToUtc
{
    /// <summary>
    /// 「壁時計の日時」+「タイムゾーン情報」から、UTCの瞬間へ正規化する。
    /// </summary>
    /// <remarks>
    /// 境界:UI入力直後/API受信直後で当て、以降はUTCで流す想定。
    /// DSTがある地域では曖昧時刻/無効時刻が発生し得るため、ここで明示的に扱う。
    /// </remarks>
    /// <param name="localDateTime">壁時計の日時(Kind=Unspecified を前提)。</param>
    /// <param name="timeZone">対象地域のタイムゾーン。</param>
    /// <returns>UTC(+00:00)へ正規化した DateTimeOffset。</returns>
    /// <exception cref="ArgumentNullException">timeZone が null の場合。</exception>
    /// <exception cref="ArgumentException">無効時刻、または運用ルールが未整備の曖昧時刻を扱う方針の場合。</exception>
    public static DateTimeOffset Convert(DateTime localDateTime, TimeZoneInfo timeZone)
    {
        if (timeZone is null) throw new ArgumentNullException(nameof(timeZone));

        // Kind は Unspecified を前提に扱う(「壁時計の日時」だから)
        if (localDateTime.Kind != DateTimeKind.Unspecified)
            localDateTime = DateTime.SpecifyKind(localDateTime, DateTimeKind.Unspecified);

        // 無効時刻:存在しない(DSTで飛ぶなど)
        if (timeZone.IsInvalidTime(localDateTime))
            throw new ArgumentException("指定タイムゾーンでは存在しない日時(無効時刻)。入力値の見直しが必要。", nameof(localDateTime));

        // 曖昧時刻:同じ壁時計が2回現れる(DSTで戻るなど)
        if (timeZone.IsAmbiguousTime(localDateTime))
        {
            // ここは運用ルールが必要。
            // 例:早い方(標準時)を採用 / 遅い方(夏時間)を採用 / 追加UIで選ばせる など
            // 最短では「どちらかを選ぶ」をコード化しておく。
            var offsets = timeZone.GetAmbiguousTimeOffsets(localDateTime);

            // 例:より小さいオフセット(標準時側)を採用する
            // ※運用の要請で逆にすることもある
            var chosenOffset = offsets[0] <= offsets[1] ? offsets[0] : offsets[1];
            var ambiguousLocal = new DateTimeOffset(localDateTime, chosenOffset);
            return ambiguousLocal.ToUniversalTime();
        }

        // 通常:オフセットは地域ルールから求める
        var offset = timeZone.GetUtcOffset(localDateTime);
        var local = new DateTimeOffset(localDateTime, offset);
        return local.ToUniversalTime();
    }
}

解説:定義→評価規則→型の変化→落とし穴

ここでは「どの情報が揃っていると安全か」を言語化して、混入点で止める判断を作る。

定義:壁時計/瞬間/地域ルール

  • 壁時計の時刻:現地の「9:00」などの表示上の時刻。単体では“瞬間”が一意にならない
  • 瞬間:UTC基準で一意な時刻。DB・API・ログの芯
  • 地域ルール(タイムゾーン):オフセットが季節で変わる地域がある(DSTなど)

評価規則:どこまで情報が揃っているか

  • オフセット付き(例:2026-01-21T09:00+09:00
    → 瞬間が一意になりやすい(UTCへ直行できる)
  • タイムゾーン付き(例:2026-01-21 09:00Asia/Tokyo
    → 地域ルールでオフセットを求めて瞬間に落とす
  • どちらも無い(例:2026-01-21 09:00 だけ)
    → 受信側で解釈が割れ、ズレの最初の火種になりやすい

型の変化:C#で何を持つか

  • DateTimeOffset:日時+オフセットを持つ。瞬間へ寄せやすい
  • DateTime:Kindで解釈が変わる。Unspecifiedは曖昧になりやすい

落とし穴:二重変換・曖昧時刻・環境差

  • ToLocalTime() / ToUniversalTime() を境界で複数回当て、二重変換になる
  • DSTの曖昧時刻/無効時刻を「存在する前提」で扱い、予定がズレる
  • タイムゾーンIDがOS依存で解決できず、環境差で落ちる

判例(混入経路が多い順に潰す)

ここは「頭で補正して放置」が起きやすい所を、混入点から潰す。

1) “9:00” をそのまま保存して、後でUTC扱いになる

壁時計の9:00を、瞬間として扱ってしまうパターン。

  • 悪い例
    UIの DateTime をそのままDBへ保存し、どこかでUTC扱いになる
  • 直す
    UI入力直後に TimeZoneInfo を添えてUTCへ正規化し、以降はUTCで流す
  • ポイント
    混入点はUI入力直後。ここで揃えると、後段の変換が激減する

2) DateTime.Kind=Unspecified を勝手にUTC扱いしてズレる

曖昧な値を勝手に解釈し、後から誰も追えなくなるパターン。

  • 悪い例
    DateTime.Kind=UnspecifiedToUniversalTime() へ通してしまう
  • 直す
    Unspecifiedは境界で止め、入力側でUTC/ローカルを決めて渡す
  • ポイント
    “曖昧を通す” のが一番長引く。境界で止める方が回復が早い

3) 文字列化でオフセットを落とし、再構築でズレる

時刻の見た目だけが残り、瞬間が落ちるパターン。

  • 悪い例
    yyyy/MM/dd HH:mm:ss へ整形して送る(オフセット無し)
  • 直す
    ISO 8601でオフセット付きに寄せる(Z+09:00 を残す)
  • ポイント
    送受信は “瞬間を落とさない” が最優先。表示用整形は最後に回す

4) “毎日9:00” をUTCで決め打ちし、地域ルールを落とす

定期実行を瞬間だけで持ち、壁時計の意味を落とすパターン。

  • 悪い例
    “毎日9:00” をUTCの時刻として保存する
  • 直す
    “壁時計の9:00” と “タイムゾーン情報” を保持し、実行時にUTCへ落とす
  • ポイント
    定期実行は「壁時計の約束」。瞬間は都度生成で揃える

チェックリスト:レビューで見る所(表)

レビューでは「境界で揃える」が守れているかを表で潰す。

観点 見るポイント よくある落とし穴
保存 DB/API/ログはUTCを基準にしているか ローカル混入で時系列が壊れる
入力 UI入力直後にUTCへ正規化しているか “9:00” が曖昧なまま流れる
送受信 オフセット付き(ISO 8601)で渡しているか 文字列化でオフセットが落ちる
変換回数 境界で一回に揃えているか 二重変換でズレる
定期実行 壁時計+タイムゾーン情報で保持しているか UTC決め打ちで季節にズレる
例外方針 無効/曖昧時刻の扱いが決まっているか DSTで揺れる日時が残る

セルフチェック(5問)

読後に「説明できる/直せる」へ寄せるための確認。

  1. 壁時計の時刻と、記録すべき瞬間(UTC)が別物だと説明できるか
  2. 混入点を「UI入力直後/API受信直後/DB保存直前/表示直前/スケジュール確定直後」に分解できるか
  3. DateTime.Kind=Unspecified を境界で止める理由を説明できるか
  4. DST(夏時間)の曖昧時刻/無効時刻を、例外または運用ルールで扱えるか
  5. 保存・送信・ログはUTC、表示で変換という規約を言語化できるか
回答の目安
  • 1: 壁時計は地域の表示、瞬間は世界で一意な基準(UTC)
  • 2: 混入点を特定すると、どこで揃えるかが決まり、二重変換が減る
  • 3: Unspecifiedは解釈が割れ、勝手なUTC扱いがズレの火種になる
  • 4: DST(夏時間)の曖昧/無効は “存在しない/2回ある” を明示し、例外か選択規則を決める
  • 5: UTCで揃えるのは記録と比較、表示は利用者の地域へ合わせる

関連トピック


連載Index(読む順・公開済リンクはここが最新): S00: 門前の誓い(総合Index)

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?