UTCで動作中のサーバのcrontabの起動タイミングを別のTimezoneで設定する

# はじめに

世界の各国に向けたサービスを運用する場合におけるタイムゾーンの考え方としては、利用中の全てのサーバをそれぞれの対象のサービスごとに個別のタイムゾーンを設定すると、様々なトラブルが発生することが考えられます。
これを避けるために全ての関連サーバのタイムゾーンをUTCに統一して利用している環境でのクロン設定の話になります。

ぶっちゃけると、時差を考えてロケール毎にクロンタブの記述を変更するのってかなり面倒でかつ、間違えやすいんですね。

サービスの要件は以下のとおりとします。
* 一部のバッチにおけるクロンの起動時間をサービス展開国のタイムゾーン毎に変更したい
* 上記以外のバッチの起動時間はUTCで行ないたい
* サマータイムに対応した時間にてバッチを起動させたい

# 対応
クローンそのものは毎分起動(※1)とし、TZFilter.php(後述) というスクリプトにてもう一度、該当タイムゾーンにおいて対象の時刻となっているかどうかをチェックするようにし、時刻が異なっていた場合は異常終了とします。
※1 30分単位で時差があるタイムゾーンを指定しない限り、分部分は元のcrontabに記載しても問題はありません。

時刻の設定部分をcrontabの指定と同じ方法を利用できます。
日付変更線をまたいだ場合であっても、日付や曜日の変更を意識せずに現地日時そのままで指定可能です。

例：現地時間(Asia/Tokyo)での指定で
      10-30/10 10 * * * user [実行したいコマンド]
      
      としたい場合は以下の様に書く
      * * * * * user [path_to_file]/TZfilter.php Asia/Tokyo '10-30/10 10 * * *' && [実行したいコマンド]


# 注意事項
Summer Timeに対応させた時に、切り替えのタイミングではバッチが実行されないケースや複数回実行されるケースが存在することがあります。
※これはこのシステムを利用しなくても同じですが、、、。

```lang:TZFilter.php
#!/usr/bin/php
<?php
/*
    UTCで動作中のサーバにおいて、現地時間でのバッチの指定を可能にするためのスクリプト
    ※ Summer Timeに対応させた時に、切り替えのタイミングではバッチが実行されないケースや複数回実行されるケースが存在することに注意する

    利用例：
      現地時間(Asia/Tokyo)での指定で
      10-30/10 10 * * * user [実行したいコマンド]
      
      としたい場合は以下の様に書く
      * * * * * user [path_to_file]/TZfilter.php Asia/Tokyo '10-30/10 10 * * *' && [実行したいコマンド]
*/

class TZFilter{
    var $argv            = null;
    var $timezone        = null;
    var $time_config_arr = array();

    public function __construct($argv){
        if(count($argv) != 3){
            self::echoUsage();
        }
        $this->timezone = $argv[1];

        if(!date_default_timezone_set($this->timezone)){
            self::echoUsage("Invalid timezone (${timezone}) is specified.\n");
        }
        $time_str = $argv[2];
        $time_arr = explode(" ",$time_str);
        if(count($time_arr) <> 5){
            self::echoUsage("Invalid run time (${time_str}) is specified.\n");
        }

        $this->time_config_arr['i'] = self::explodeMinutes($time_arr[0]);
        $this->time_config_arr['H'] = self::explodeHours($time_arr[1]);
        $this->time_config_arr['d'] = self::explodeDays($time_arr[2]);
        $this->time_config_arr['m'] = self::explodeMonthes($time_arr[3]);
        $this->time_config_arr['w'] = self::explodeWeeks($time_arr[4]);
    }

    public function isOnTime($time = null){
        $time = (is_null($time))?time():$time;
        if(
            in_array(date("i", $time), $this->time_config_arr['i']) &&
            in_array(date("H", $time), $this->time_config_arr['H']) &&
            in_array(date("d", $time), $this->time_config_arr['d']) &&
            in_array(date("m", $time), $this->time_config_arr['m']) &&
            in_array(date("w", $time), $this->time_config_arr['w'])
        ){
            return true;
        }else{
            return false;
        }
    }

    /*  crontabの分指定部分に含まれる数値の配列を生成する。*/
    public static function explodeMinutes($str){
        return self::explodeTimeFormat($str, 0, 59, 'Minutes');
    }

    /*  crontabの時間指定部分に含まれる数値の配列を生成する。*/
    public static function explodeHours($str){
        return self::explodeTimeFormat($str, 0, 23, 'Hours');
    }

    /*  crontabの日付指定部分に含まれる数値の配列を生成する。*/
    public static function explodeDays($str){
        return self::explodeTimeFormat($str, 1, 31, 'Days');
    }

    /*  crontabの月指定部分に含まれる数値の配列を生成する。*/
    public static function explodeMonthes($str){
        return self::explodeTimeFormat($str, 1, 12, 'Monthes');
    }

    /*  crontabの曜日指定部分に含まれる数値の配列を生成する。*/
    public static function explodeWeeks($str){
        $weeks = self::explodeTimeFormat($str, 0, 7, 'Weeks');
        if(in_array(7, $weeks) && !in_array(0, $weeks)){
            $weeks[] = 0;
        }
        return $weeks;
    }

    private static function explodeTimeFormat($str, $min, $max, $label){
        $rtn = array();
        foreach(explode(",",$str) as $str_piece){
            if(preg_match("/^\*(\/([0-9]+))?$/", $str_piece, $regs)){
                $span = (isset($regs[2]))?$regs[2]:1;
                $rtn = array_merge($rtn, range($min, $max, $span));
                continue;
            }
            if(preg_match("/^([0-9]+)$/", $str_piece) && $min <= $str_piece && $str_piece <= $max){
                $rtn[] = $str_piece;
                continue;
            }elseif(preg_match("/^([0-9]+)-([0-9]+)(\/([0-9]+))?$/", $str_piece, $regs)){
                $from = $regs[1];
                $to   = $regs[2];
                $span = (isset($regs[4]))?$regs[4]:1;
                if($min <= $from && $from < $to && $to <= $max){
                    $rtn = array_merge($rtn, range($from, $to, $span));
                    continue;
                }
            }
            self::echoUsage("Invalid run time ${label}(${str}) is specified.\n");
        
        }
        return $rtn;
    }

    private static function echoUsage($msg = null){
        echo "Usage: ./TZfilter.php [timezone] [time_format]\n";
        echo "  Sample ./TZfilter.php Asia/Tokyo '10 1-10,12 * * *'\n";
        echo $msg;
        exit(1);
    }
}


$tz_filter = new TZFilter($argv);
if($tz_filter->isOnTime()){
    // 正常終了コードを送る
    echo "Ok\n";
    exit(0);
}else{
    // 異常終了コードを送る
    exit(1);
}
```

# ちなみに
crontabに、
TZ=Asia/Tokyo
などと指定しても、そのバッチによって起動されるプログラムの環境変数を設定するだけですので、バッチ起動時間そのものを制御することはできません。

また、crondの起動時に環境変数としてTZが設定されていた場合はそのタイムゾーンでのcron起動となりますが、その方法では全てのcronにこれが適用されてしまうため、影響範囲が大きくなります。