Linux
timezone

Linux でタイムゾーンを自作する

Linux でタイムゾーンを設定するときは /etc/localtime/usr/share/zoneinfo にあるファイルへのシンボリックリンクを作ります。このファイルはテキストファイルではないため直接編集できず、専用のコマンドを利用します。

タイムゾーンの情報 - tzfile(5)

/etc/localtime に置かれるファイルは tzfile(5) というタイムゾーンの情報専用のファイルフォーマットです。tzfile はヘッダは ascii でそのほかは独自のデータ構造になっています。

/etc/localtime
$ od -Ax -tx1z /usr/share/zoneinfo/Asia/Tokyo
000000 54 5a 69 66 32 00 00 00 00 00 00 00 00 00 00 00  >TZif2...........<
000010 00 00 00 00 00 00 00 04 00 00 00 04 00 00 00 00  >................<
(略)
000120 90 00 08 4c 4d 54 00 4a 44 54 00 4a 53 54 00 00  >...LMT.JDT.JST..<
000130 00 00 01 00 00 00 01 0a 4a 53 54 2d 39 0a        >........JST-9.<
00013e

zic(8) コマンドは tzfile を作るコマンドで、テキストファイルに書いたタイムゾーンの情報をタイムゾーン毎に tzfile として作ることができます。テキストファイルには
Zone NAME GMTOFF RULES FORMAT [UNTIL] でタイムゾーンを定義します。

フィールド 意味
Zone "Zone" 固定. その行がタイムゾーンの定義であることを示す文字 Zone
NAME タイムゾーンの名前 Australia/Adelaide
GMTOFF GMT からの時差。 UTC ではないので要注意 9:30
RULES タイムゾーンに適用されるルールの名前 Aus
FORMAT タイムゾーンの短縮形。サマータイムで変わる場合は %s を使う AC%sT
[UNTIL] タイムゾーンが変更になる時点。このフィールドの値までそのタイムゾーンが有効になる 1971

サマータイムはルールとして、Rule NAME FROM TO TYPE IN ON AT SAVE LETTER/S の並びで定義します。

フィールド 意味
Rule "Rule" 固定.その行がルールの定義であることを示す文字 Rule
Name このルールが適応されるタイムゾーン。 Zone 行の RULES に書いた文字と合わせる必要がある Aus
FROM このルールが有効になる年 2007
TO このルール無効になる年. only の場合は FROM と同じ値 2009
TYPE ルールが適応される期間。 - の場合は FROM から TO の間で適応される. -
IN ルールが有効になる月 Oct
ON そのルールが効力を持つ日 lastSun
AT ルールが効力を持つ一日のうちの時刻 2:00s
SAVE 適応されたときに時刻に追加される時間 1:00
LETTER/S タイムゾーン短縮型の %s に割り当てられる文字 D

現在使われているタイムゾーンをすべて書いたファイルは tz Database として IETF が管理しています。ちなみに管理方法は "RFC 6557 - Procedures for Maintaining the Time Zone Database" として公開されています。使い方も tz-how-to に公開されています。Rule 行の TYPE に使える文字のチェックを行うスクリプトも yearistype.sh として用意されています。
ちなみに、tz Database にはタイムゾーンの定義だけではなく、その根拠となる情報もコメントアウトして書かれています。

tz Database の Asia/Tokyo の JST は次のように書かれています。

asia(抜粋)
# Rule  NAME    FROM    TO      TYPE    IN      ON      AT      SAVE    LETTER/S
Rule    Japan   1948    only    -       May     Sat>=1  24:00   1:00    D
Rule    Japan   1948    1951    -       Sep     Sun>=9   0:00   0       S
Rule    Japan   1949    only    -       Apr     Sat>=1  24:00   1:00    D
Rule    Japan   1950    1951    -       May     Sat>=1  24:00   1:00    D

(略)
# Zone  NAME            GMTOFF  RULES   FORMAT  [UNTIL]
Zone    Asia/Tokyo      9:18:59 -       LMT     1887 Dec 31 15:00u
                        9:00    Japan   J%sT

これらの情報をもとに自作タイムゾーン (My/Zone) とサマータイムのルール (MyRule)を myzone に定義してみます。サマータイムのルールは、 2018/10/06 18:40 以降から 2018/10/13 0:00 までの間適応されるようにしています

# Rule  NAME    FROM    TO      TYPE    IN      ON      AT      SAVE    LETTER/S
Rule    MyRule  2018    Only    -       Oct     Mon>=1   0:00   0       S
Rule    MyRule  2018    Only    -       Oct     Sat>=6   18:40   1:00    D
Rule    MyRule  2018    Only    -       Oct     Sat>=13   0:00   0       S
# Zone  NAME            GMTOFF  RULES   FORMAT  [UNTIL]
Zone    My/Zone         9:00    MyRule  MY%sT

出来上がったファイルは zic コマンドでコンパイルします。 zic コマンドは何も指定しなければ /usr/share/zoneinfo に出力するため root 以外実行できません。そのため -d $PWD を付けて実行します。
出来上がった tzfile は Zone の Name に指定したパスに出力されます。

$ zic -d $PWD myzone
$ file My/Zone
My/Zone: timezone data, version 2, 2 gmt time flags, 2 std time flags, no leap seconds, 2 transition times, 2 abbreviation chars
$ od -Ax -tx1z My/Zone
000000 54 5a 69 66 32 00 00 00 00 00 00 00 00 00 00 00  >TZif2...........<
000010 00 00 00 00 00 00 00 02 00 00 00 02 00 00 00 00  >................<
000020 00 00 00 03 00 00 00 02 00 00 00 0a 5b b0 e4 f0  >............[...<
000030 5b b8 82 f0 5b c0 a8 e0 00 01 00 00 00 7e 90 00  >[...[........~..<
000040 00 00 00 8c a0 01 05 4d 59 53 54 00 4d 59 44 54  >.......MYST.MYDT<
000050 00 00 00 00 00 54 5a 69 66 32 00 00 00 00 00 00  >.....TZif2......<
000060 00 00 00 00 00 00 00 00 00 00 00 00 02 00 00 00  >................<
000070 02 00 00 00 00 00 00 00 03 00 00 00 02 00 00 00  >................<
000080 0a 00 00 00 00 5b b0 e4 f0 00 00 00 00 5b b8 82  >.....[.......[..<
000090 f0 00 00 00 00 5b c0 a8 e0 00 01 00 00 00 7e 90  >.....[........~.<
0000a0 00 00 00 00 8c a0 01 05 4d 59 53 54 00 4d 59 44  >........MYST.MYD<
0000b0 54 00 00 00 00 00 0a 4d 59 53 54 2d 39 0a        >T......MYST-9.<
0000be
$

中身を見ると MYDTMYST が見えていて正しく作られてそうです。TZif2 はヘッダです。
あとはこのファイルを通常通り /etc/localtime に置けば利用できます。ちなみにlocaltime(5)のパスは libc の再ビルド以外では変更できず、どのプロセスでも共通です。

タイムゾーンを利用する - ctime(3)

/etc/localtime に置かれた情報は tzset(3) でメモリ上に展開されます。localtime や strftime などの ctime(3) の関数から内部的に呼ばれるため直接呼び出すことはあまりありません.

特に、glibc-2.27 の実装では localtime の処理は tzset と同じ個所で実装されています。

localtime.c
struct tm *
localtime (const time_t *t)
{
  return __tz_convert (t, 1, &_tmbuf);
}
tzset.c
struct tm *
__tz_convert (const time_t *timer, int use_localtime, struct tm *tp)
{
  long int leap_correction;
  int leap_extra_secs;

  if (timer == NULL)
    {
      __set_errno (EINVAL);
      return NULL;
    }

  __libc_lock_lock (tzset_lock);

  /* Update internal database according to current TZ setting.
     POSIX.1 8.3.7.2 says that localtime_r is not required to set tzname.
     This is a good idea since this allows at least a bit more parallelism.  */
  tzset_internal (tp == &_tmbuf && use_localtime);

(snip...)

tzset_internal が tzfile を読み込む関数本体で、引数には tzfile を常に読み込むかどうかを true/false で指定します。
コメントにも書かれている通り localtime は true になり、関数が呼ばれるたびに常に読み込みが行われます。
一方で localtime_r は tp != &_tmbuf になるため初回の呼び出し時のみ読み込まれます。このあたりの localtime/localtime_r はマニュアルにも書かれていないため使用時は注意が必要です。

余談ですが tzset は libc の実装次第で挙動が変わり、例えば、glibc は tzset が呼ばれるたびに /etc/localtime を読み込みますが、 BSD libc は tzset を直接呼び出したとしても基本的に初回の1度しか読み込まれません。

このあたりの実装の違いによって、 /etc/localtime を書き換えた後にプロセスの再起動が必要だったり、不要だったりします。

ここまでの内容をもとに検証してみます。
タイムゾーンを反映した時刻は date コマンドで取得できます。ただし date コマンドは tzset を1度しか呼び出さないため、複数呼び出した時の挙動を見ることはできません。そこで適当な検証用プログラムを動かして動作を見てみます。

tztest.c
#include <sys/types.h>

#include <stdio.h>
#include <unistd.h>
#include <signal.h>
#include <time.h>
#include <err.h>

#define FMT     "%a, %d %b %Y %T (%Z)"

static volatile sig_atomic_t quit = 0;

static void
sighandler(int signo)
{

        switch (signo) {
        case SIGINT:
        case SIGTERM:
        case SIGQUIT:
        case SIGSTOP:
                quit = 1;
                break;
        default:
                break;
        }
}

int
main(int argc, char *argv[])
{
        struct tm               *tm_lt, tm_ltr;
        struct sigaction         sa;
        time_t                   now;
        char                     str_lt[BUFSIZ], str_ltr[BUFSIZ];

        sigemptyset(&sa.sa_mask);
        sa.sa_flags = SA_RESTART;
        sa.sa_handler = sighandler;
        sigaction(SIGINT, &sa, NULL);
        sigaction(SIGTERM, &sa, NULL);
        sigaction(SIGQUIT, &sa, NULL);
        sigaction(SIGSTOP, &sa, NULL);
        sigaction(SIGUSR1, &sa, NULL);

        printf("pid = %d\n", (int)getpid());

        while (quit == 0) {
                if (time(&now) == -1)
                        err(1, "time");

                if (localtime_r(&now, &tm_ltr) == NULL)
                        err(1, "localtime_r");

                if ((tm_lt = localtime(&now)) == NULL)
                        err(1, "localtime");


                strftime(str_lt, sizeof(str_lt), FMT, tm_lt);
                strftime(str_ltr, sizeof(str_ltr), FMT, &tm_ltr);

                printf("---------------------------------------------\n");
                printf("time                  : %ld\n", (int64_t)now);
                printf("ctime                 : %s\n", ctime(&now));
                printf("asctime(localtime)    : %s\n", asctime(tm_lt));
                printf("asctime(localtime_r)  : %s\n", asctime(&tm_ltr));
                printf("strftime(localtime)   : %s\n", str_lt);
                printf("strftime(localtime_r) : %s\n", str_ltr);

                sigsuspend(&sa.sa_mask);
        }

        return 0;
}

検証に使うプログラムはこんな感じで、time(2) を使って unix epoch を取得した後その値を localtime_r, localtime で変換します。起動する際は、 '&' を付けてバックグラウンドで動かします。動かした後はプロセスIDが出力されるのでそのIDにシグナルを送信すると、その時点のタイムゾーンに従った時刻を出力します。

$ ./tztest &
[5] 10446
pid = 10446
---------------------------------------------
time                  : 1538818008
ctime                 : Sat Oct  6 18:26:48 2018

asctime(localtime)    : Sat Oct  6 18:26:48 2018

asctime(localtime_r)  : Sat Oct  6 18:26:48 2018

strftime(localtime)   : Sat, 06 Oct 2018 18:26:48 (JST)            <=== 起動時はJSTになっている
strftime(localtime_r) : Sat, 06 Oct 2018 18:26:48 (JST)

$ sudo cp My/Zone /etc/localtime                                   <=== JSTから自作タイムゾーンに変更
$ kill -USR1 10446                                                 <=== SIGUSR1 で時刻を再表示
---------------------------------------------
time                  : 1538818032
ctime                 : Sat Oct  6 18:27:12 2018

asctime(localtime)    : Sat Oct  6 18:27:12 2018

asctime(localtime_r)  : Sat Oct  6 18:27:12 2018

strftime(localtime)   : Sat, 06 Oct 2018 18:27:12 (MYST)           <=== 再読み込みで MYST
strftime(localtime_r) : Sat, 06 Oct 2018 18:27:12 (JST)            <=== localtime_r はそのまま JST
$ kill -USR1 10446                                                 <=== 18:40 になって夏時間になったのでもう一度 USR1
---------------------------------------------
time                  : 1538818817
ctime                 : Sat Oct  6 19:40:17 2018

asctime(localtime)    : Sat Oct  6 19:40:17 2018

asctime(localtime_r)  : Sat Oct  6 19:40:17 2018

strftime(localtime)   : Sat, 06 Oct 2018 19:40:17 (MYDT)          <=== 'D' が 'S' になって1時間進んだ
strftime(localtime_r) : Sat, 06 Oct 2018 19:40:17 (MYDT)          <=== 1 度目の localtime で MYST になった後夏時間になった

実行結果はこんな感じです。/etc/localtime に作った tzfile を置くことで自作タイムゾーンが利用できました。
特に、サマータイムが実施されれば tzfile(5) が書き換えられることになるので localtime を使っているプログラムは再起動しなくても反映されますが、localtime_r を使っているプログラムは再起動が必要になります。
どのプログラムが tzset 関数を呼び出しているかがわからないときは OS 再起動して全プロセスで tzfile を再読み込みする必要があります。

タイムゾーンとサマータイムと時刻

  • サマータイムはタイムゾーンとして扱われる
  • PCの時刻がずれる ≠ サマータイムで時刻がずれる
    • PCの時刻がずれると time(3) で取得される時刻が変わる。サマータイムの時は変わらない。
    • サマータイムで時刻がずれると localtime などの関数の結果が変わる。time(3) はずれない。
    • サマータイムが有効になっても時刻がずれたわけではないので NTP は何もしない
  • サマータイムを検証するためにタイムゾーンの自作が必要になると思ったらサマータイムが無くなった