目次
「ローカルでは正しく動いた日次集計が、本番だけ前日分になる」「深夜0時に走るはずのバッチが朝9時に動いた」「CSVをダウンロードしたら日本語が化けていた」。こうした問題はコードのバグに見えて、実はサーバーのタイムゾーン・ロケール・文字コードの設定に起因していることが少なくありません。手元のPCが日本語・JST前提なのに対して、サーバーはUTC・英語・最小構成で動いていることを意識していないと、本番に出した瞬間にずれが顕在化します。
この記事では、「サーバーはUTC、表示はJST」の原則がなぜ合理的なのかから始めて、timedatectlでの設定、DBのtimestamp with time zoneの挙動、cronがどの時刻で動くか、LANGとUTF-8の設定、文字化けの典型パターン、コンテナのTZ環境変数、そして日付を跨ぐバグの実例までを一気に整理します。時計そのものの正確さ(NTP)はサーバーの時刻同期入門で扱っているため、本記事は「正しい時刻をどう解釈し表示するか」に集中します。
サーバーはUTC・表示はJSTの原則
なぜサーバーをJSTにしないのか
日本国内向けのサービスなら、サーバーもJSTに揃えたほうが直感的に思えます。しかし実務ではサーバーとDBの内部はUTCで統一し、ユーザーへの表示の直前でJSTに変換するのが原則です。理由は3つあります。第一に、UTCには夏時間がなく、一年中一貫しているので「存在しない時刻」「2回ある時刻」を扱う必要がありません。第二に、クラウドのマネージドサービスやログ基盤、外部APIの多くがUTCを前提にしているため、自社サーバーだけJSTにするとログの突き合わせで毎回9時間の換算が発生します。第三に、将来海外ユーザーやリージョンが増えたときに、内部がUTCであれば表示層の変更だけで済みます。
「内部はUTC、境界で変換」というルールに例外を作らないことが重要です。一部のサーバーだけJSTにすると、どのログがどちらの基準か分からなくなり、障害調査が著しく難しくなります。
timedatectlで設定と確認をする
# 現在の設定を確認
timedatectl
# Local time: Tue 2026-09-02 00:15:32 UTC
# Universal time: Tue 2026-09-02 00:15:32 UTC
# Time zone: Etc/UTC (UTC, +0000)
# タイムゾーンを変更する場合(JSTにするなら Asia/Tokyo)
sudo timedatectl set-timezone Asia/Tokyo
# 利用できるタイムゾーン名の確認
timedatectl list-timezones | grep Tokyo
# 一時的にJSTで表示したいだけならTZ環境変数で足りる
TZ=Asia/Tokyo date
タイムゾーンの実体は/etc/localtimeで、これは/usr/share/zoneinfo/Asia/Tokyoのような定義ファイルへのシンボリックリンクです。timedatectl set-timezoneはこのリンクを張り替えているにすぎません。既に稼働中のプロセスは起動時のタイムゾーンを保持していることが多いので、変更後はサービスの再起動が必要です。
データベースのタイムゾーン
timestamp with time zoneの挙動
PostgreSQLにはtimestamp(without time zone)とtimestamptz(with time zone)の2つの型があります。名前から誤解されがちですが、timestamptzはタイムゾーン情報を保存するのではなく、入力値をUTCに変換して保存し、取り出すときにセッションのタイムゾーンに変換する型です。一方のtimestampは文字どおり「書かれた数字をそのまま保存する」ため、誰がどのタイムゾーンで書いたかは記録されず、後から解釈が揺れます。原則として日時列にはtimestamptzを使うのが、PostgreSQLにおける定石です。
-- セッションのタイムゾーンを確認・変更
SHOW TimeZone;
SET TimeZone = 'Asia/Tokyo';
-- timestamptz は内部でUTC。表示時にセッションのTZへ変換される
SELECT now(), now() AT TIME ZONE 'UTC', now() AT TIME ZONE 'Asia/Tokyo';
-- JSTでの「今日」の注文を正しく数える(境界をJSTで作ってからtimestamptzと比較)
SELECT count(*) FROM orders
WHERE created_at >= (date_trunc('day', now() AT TIME ZONE 'Asia/Tokyo') AT TIME ZONE 'Asia/Tokyo')
AND created_at < (date_trunc('day', now() AT TIME ZONE 'Asia/Tokyo') AT TIME ZONE 'Asia/Tokyo') + interval '1 day';
MySQLではTIMESTAMP型がUTC変換を行い、DATETIME型は変換しない、という同様の違いがあります。接続ごとのtime_zone変数と、JDBCなどドライバ側のタイムゾーン設定が一致していないと、保存と読み出しで9時間ずれる典型的なトラブルになります。
cronとバッチの実行時刻
cronはシステムのタイムゾーンで動く
cronのスケジュールは、システムのタイムゾーンで解釈されます。サーバーがUTCなら0 0 * * *はUTCの0時、つまりJSTの朝9時です。「深夜0時に締め処理を走らせたい」なら、UTC運用のサーバーでは0 15 * * *(前日15時UTC=JST 0時)と書く必要があります。
# サーバーがUTCのとき、JST 0:00 に日次締め処理を実行する
0 15 * * * /opt/app/bin/daily-close.sh >> /var/log/app/daily-close.log 2>&1
# cronie系(RHEL等)では CRON_TZ でスケジュールの基準TZを指定できる
CRON_TZ=Asia/Tokyo
0 0 * * * /opt/app/bin/daily-close.sh
ただしCRON_TZはcronの実装によって対応が異なるため、使う前に自分の環境のマニュアル(man 5 crontab)で確認してください。対応していない実装でも、スクリプトの先頭でexport TZ=Asia/Tokyoすれば、スクリプト内の日付処理はJSTになります。systemdタイマーも同様にシステムのタイムゾーンで解釈されるので、考え方は同じです。
ロケールと文字コード
LANGとUTF-8の設定
ロケールは、言語・日付や数値の書式・文字の並び順・文字コードをまとめた設定で、環境変数LANGやLC_*で指定します。現在のサーバーはC.UTF-8またはen_US.UTF-8を既定にすることが多く、この状態で困ることはほとんどありません。重要なのは「UTF-8であること」で、日本語ロケール(ja_JP.UTF-8)にする必要があるのは、コマンドのメッセージやsortの並び順を日本語化したい場合に限られます。
# 現在のロケールと利用可能なロケール
locale
localectl status
locale -a
# ja_JP.UTF-8 を追加する(Debian/Ubuntu)
sudo locale-gen ja_JP.UTF-8
sudo update-locale LANG=ja_JP.UTF-8
# システム全体の既定を設定する
sudo localectl set-locale LANG=C.UTF-8
SSH接続時にsetlocale: LC_ALL: cannot change localeという警告が出る場合は、手元のPCのLANGがSSH経由で送られ、サーバーにそのロケールが存在しないことが原因です。サーバー側でlocale-genするか、手元の~/.ssh/configでSendEnvを無効にすれば解消します。
文字化けの典型パターン
- Excelで開いたCSVが化ける:Excelは既定でBOMなしUTF-8を正しく判定しません。出力時に先頭にBOM(
\xEF\xBB\xBF)を付けるか、Shift_JIS(正確にはCP932)で出力します。逆に受け取ったCSVはiconv -f CP932 -t UTF-8で変換してから処理します - MySQLで絵文字が保存できない:MySQLの
utf8は最大3バイトで、4バイト文字(絵文字や一部の漢字)を扱えません。テーブルと接続の文字セットをutf8mb4に統一します - DBに保存した日本語が「????」になる:接続時の
client_encoding(PostgreSQL)やcharacter_set_client(MySQL)がサーバー側と一致していません。接続文字列で明示します - ログの日本語が化ける:
LANGがUTF-8でない状態で起動したプロセスや、CロケールでJavaを起動した場合に起きます。サービスのユニットファイルでEnvironment=LANG=C.UTF-8を指定します
コンテナのタイムゾーンとロケール
TZ環境変数とtzdata
Dockerコンテナは時刻自体をホストと共有しますが、タイムゾーンとロケールはイメージの中身で決まります。多くの公式イメージはUTC・最小ロケールで、AlpineやDebianのslim系イメージにはタイムゾーン定義(tzdata)すら入っていないことがあります。この場合、TZ=Asia/Tokyoを設定しても定義ファイルが存在せずUTCのまま、という現象が起きます。
# Dockerfile(Debian系)でtzdataとUTF-8ロケールを用意する例
RUN apt-get update && apt-get install -y --no-install-recommends tzdata locales \
&& sed -i 's/^# *ja_JP.UTF-8/ja_JP.UTF-8/' /etc/locale.gen \
&& locale-gen \
&& rm -rf /var/lib/apt/lists/*
ENV LANG=C.UTF-8
# TZは実行時に環境変数で切り替えるのが柔軟(既定はUTCのまま)
# docker-compose.yml でJST表示が必要なコンテナにだけ指定
services:
app:
environment:
- TZ=Asia/Tokyo
- LANG=C.UTF-8
ここでも原則は同じで、アプリコンテナの内部はUTCのままにし、JSTが必要なのは「人が読むログを出力する」「レポートを生成する」といった表示の責務を持つコンテナだけに限定するのが整理しやすい設計です。
トラブル事例:日次集計が「前日分」になる
症状:毎朝送られる「昨日の注文件数」レポートの数字が、管理画面で見る件数と合わない。特に深夜〜早朝の注文が翌日分に含まれているように見える。
原因:集計SQLがWHERE created_at::date = CURRENT_DATE - 1のように書かれており、::dateへの変換がサーバー(UTC)の日付で行われていた。JSTの0時から9時までの注文は、UTCでは前日の15時から24時に当たるため、「昨日」の集計から1日ずれていた。開発者のPCはJSTだったため、ローカルでは正しく動いていた。
対処:日付の境界をAT TIME ZONE 'Asia/Tokyo'で明示的にJSTで作り、timestamptzのまま範囲比較するSQLに書き換えた(前掲のコード例)。あわせて、チームのルールとして「日付に変換する処理は必ずタイムゾーンを明示する」「ローカル環境のDBもUTCで動かす」を追加し、開発と本番の差をなくした。日付を跨ぐ処理は、月末・年末・夏時間の切り替えでも同種のバグを生むため、境界値のテストを用意しておくと安心です。
まとめ
時刻と文字の問題は、「内部はUTC・UTF-8で統一し、境界で変換する」という一貫した原則で大半が防げます。timedatectlでサーバーはUTCのまま、DBはtimestamptz(MySQLならTIMESTAMPと接続時のtime_zone)を使い、cronはシステムのタイムゾーンで解釈されることを踏まえてスケジュールを書き、LANGはUTF-8、MySQLはutf8mb4、コンテナにはtzdataとロケールを入れたうえで表示が必要なものにだけTZを渡す。まずは本番サーバーとローカル環境でtimedatectlとlocaleを実行し、両者の差分を把握するところから始めてください。
本番環境の構築や運用ルールの整備でお困りの際は、Harmonic Societyのシステム開発・インフラ支援にご相談ください。
Harmonic Society
この記事の内容、自社の業務でも活かせそうですか?
ローカルLLM・AI・クラウドなどの技術導入を、要件整理からPoC・社内展開まで代表エンジニアが伴走します。オンライン対応・全国OK。まずは30分の無料相談から。売り込みはしません。
関連記事
Related / 9 articles
Notes & Insights
- プログラミング
DDoS攻撃の仕組みと対策入門|レイヤー別の防御とCDN・クラウドの活用
DDoS攻撃をボリューム型・プロトコル型・アプリ層に分けて仕組みを解説し、自前サーバーで防げない理由、CloudflareやAWS Shieldの標準防御、オリジンIPの隠し方、レートリミットとBot対策、攻撃を受けたときの初動、費用が跳ね上がるDenial of Walletへの備えまでわかります。
- プログラミング
WAFとは?仕組み・導入パターン・誤検知対策|Webアプリを攻撃から守る実践ガイド
WAFがファイアウォールやIDSと何が違うのか、シグネチャとマネージドルールの仕組み、Cloudflare WAF・AWS WAF・ModSecurityの比較、フォーム送信がブロックされる誤検知の調査と例外設定、ログ監視、WAFが代替できないことまで実践的に解説します。
- プログラミング
セキュリティヘッダー入門|CSP・HSTS・X-Frame-Optionsの設定と効果を実践解説
CSP・HSTS・X-Frame-Options・X-Content-Type-Optionsなど主要セキュリティヘッダーが防ぐ攻撃と、CSPのReport-Onlyからの段階導入、nonce/hash、HSTS preloadの不可逆リスク、Nginx・Next.js・Astroでの設定例、確認方法を解説します。
- プログラミング
クラウドの通信費(Egress)入門|データ転送量課金の仕組みと転送コストを抑える設計
クラウドの「受信無料・送信有料」の原則、AZ間・リージョン間・インターネット向けの単価差、NATゲートウェイ処理料の罠、CDNで転送量を減らす方法、バックアップやログ転送の見落とし、請求書で転送料を特定する手順を解説。想定外の請求を防げます。
- プログラミング
秘密情報をGitに入れない仕組み|.gitignore・git-secrets・履歴から漏れた鍵の削除
APIキーや.envをGitにコミットしてしまう典型経路と、.gitignore・.env.exampleの運用、pre-commitでのgitleaks検知、GitHub secret scanningの活用、漏れた鍵の無効化と履歴書き換え(git filter-repo)の手順を解説。仕組みで再発を防げます。
- プログラミング
開発・ステージング・本番環境の分離設計|環境差分をなくす構成とアクセス制御
開発・ステージング・本番それぞれの目的と、構成をコードで揃える方法、環境別の設定注入、本番データを使わないテストデータ戦略、ステージングの保護(Basic認証・IP制限・noindex)、コストを抑える運用までを解説。環境差分による本番障害を防げます。
- プログラミング
ngrok・Cloudflare Tunnelでローカルを公開|Webhook開発とデモ環境の作り方
NAT内のローカル環境にStripeやLINEのWebhookを届けるトンネリングの仕組みを解説。ngrok・Cloudflare Tunnel・localtunnelの比較、固定ドメインと認証、リクエスト検査、公開時のセキュリティ、自宅サーバー公開への応用までわかります。
- プログラミング
ローカル開発環境のHTTPS化|mkcert・hostsファイル・自己署名証明書の正しい使い方
ローカル開発をHTTPS前提にすべき理由(Secure Cookie・Service Worker・OAuth)と、mkcertでローカルCAを作りhostsで独自ドメインを割り当ててVite・Next.js・Dockerで使う手順を解説。証明書警告を無視する癖の危険も理解できます。
- プログラミング
localhost・0.0.0.0・127.0.0.1の違い|ポートとUnixソケットを理解して「つながらない」を解決
localhost・127.0.0.1・0.0.0.0の意味の違い、Dockerで外から接続できない原因、host.docker.internal、ポート競合の調べ方、Unixソケットの利点と権限、1024未満ポートの制約を解説。「つながらない」を仕組みから解決できます。
Harmonic Society
「読んで終わり」にせず、自社の業務で試してみませんか?
AI・ローカルLLM・クラウドの導入を、要件整理からPoC・社内展開まで代表エンジニアが伴走します。オンライン対応・全国OK・売り込みなし。
無料・30分・オンラインOK|1営業日以内に返信します