CakePHP1系からCakePHP3系へ移行した直後に起きたサービス障害と学び

f:id:toropoko002:20180717200616j:plain

はじめに

18年度新卒の垣花です。

僕は現在サグーワークス開発チームに所属しています。

インターン時代に過去に発生した障害の事後対応を行ったことがありました。

内定者がチャレンジ!遠方インターンで事業と会社を知る - WILLGATE tech blog

今回はその障害の内容とそこからの学びを紹介しようと思います。

障害の背景

サグーワークスではCakePHP3系へ移行した直後に、ユーザの生年月日を登録できるページを作成し、以下のように保存処理を書いていました。

<?php 
    # CakePHP3
    $birthday = '1969-01-01';
    $userTable = TableRegistry::get('Users');
    $user = $userTable->get($userId);
    $user->birthday = date("Y-m-d H:m:s", strtotime($birthday);
    $userTable->save($users);
?>

この処理を通して1969年が誕生日のユーザが保存されると、2069年が誕生日として保存されてしまっていました。

障害の原因を調査後に下記のように修正することで無事1969年生まれとして誕生日が保存されます。

<?php 
    # CakePHP3
    $birthday = new Time('1969-01-01');

    $userTable = TableRegistry::get('Users');
    $user = $userTable->get($userId);
    $user->birthday = $birthday->i18nFroamt('yyyy-MM-dd HH:mm:ss');
    $userTable->save($users);
?>

なぜ障害が発生したのか

2069年として保存されてしまう原因を調査した結果、下記のことがわかりました。 問題点について順番に説明していきます。

  • CakePHP1系とCakePHP3系のfind結果の違い
    • datetime型がCakePHP1系ではstringが、CakePHP3系ではFrozenTimeClassのオブジェクトが返ってくる
  • strtotimeの仕様
    • 年を下2桁で指定した場合に2069年など、未来の年月日のunixtimeが出力されてしまう。
  • FrozenTimeClassの仕様
    • マジックメソッドである_toString()が下2桁の年を返す

CakePHP1系とCakePHP3系のfindの違い

まずはfind結果を見比べてみます。

今回は関係ありませんが、CakePHP1系は結果が配列で返ってくるのに対し、CakePHP3系ではEntityClassのオブジェクトが返っていますね。

見るべきポイントは"birthday"がどのように返ってくるのかです。

CakePHP1系ではstringで、CakePHP3系ではFrozenTimeClassのオブジェクトが返っています。

<?php
    # Cakephp1
    $this->loadModel('User');
    $example = $this->Users->find('first', ['fields' => ['birthday']]);
    var_dump($example);
?>
    /* 出力結果
    * array (size=1)
    * 'User' =>
    *   array (size=1)
    *     'birthday' => string '1969-01-01 18:00:00' (length=19)
    * /
<?php 
    # Cakephp3
    $Users = TableRegistry::get('Users');
    $example  = $Users->find('all')->select('birthday')->first();
    var_dump($example);
?>

    /* 出力結果
    * object(App\Model\Entity\User)[1346]
    *   public 'birthday' =>
    *     object(Cake\I18n\FrozenTime)[1335]
    *       public 'time' => string '1969-01-01T18:00:00+09:00' (length=25)
    *       public 'timezone' => string 'Asia/Tokyo' (length=10)
    *       public 'fixedNowTime' => boolean false
    *       ...
    * /

CakePHP3系の場合、FrozenTimeClassはDateFormatTraitを読み込んでいるので、i18nFormatが使えるためデータのフォーマットが簡単ですね。

<?php 
    # CakePHP3
    $Users = TableRegistry::get('Users');
    $example  = $Users->find('all')->select('birthday')->first();
    var_dump($example['birthday']->i18nFormat('yyyy-MM-dd HH:mm:ss'));
?>

    /* 出力結果
    * string '1969-01-01 18:00:00' (length=19)
    */

一方CakePHP1系の場合、string型の日付だと以下のようにフォーマットしなければいけません。

<?php
    # CakePHP1
    $this->loadModel('User');
    $example = $this->Users->find('first', ['fields' => ['birthday']]);
    var_dump(date("Y-m-d H:m:s", strtotime($example['User']['birthday'])));
?>

    /* 出力結果
    * string '1969-01-01 18:00:00' (length=19)
    */

strtotimeの仕様

PHP Manualのstrtotimeのページをみていると以下の注意事項が書かれています。

年を 2 桁の数値で指定した場合、その値が 00-69 なら 2000-2069 に、 70-99 なら 1970-1999 にそれぞれ変換されます。

PHP: strtotime - Manualより引用

今回の障害の対象となったユーザの誕生日が1969年です。

00-69の間は2000-2069年としてunixtimeに変換される為、下2桁だけ見ると00-69の間に入っていました。

次はFrozenTimeの設計をみていきます。

FrozenTimeClassの仕様

FrozenTimeにはDateFormatTraitが読み込まれているので、DateFormatTraitを確認してみます。

cakephp/FrozenTime.php at 3.2.10 · cakephp/cakephp · GitHub

マジックメソッドの__toStringが存在していました。返り値はi18nFormat()の返り値です。

<?php
    # DateFormatTrait.php
    public function __toString()
    {
        return $this->i18nFormat();
    }
?>

cakephp/DateFormatTrait.php at 3.2.10 · cakephp/cakephp · GitHub

i18nFormat()では$formatを引数として渡さない場合、$ _toStringFormatを日時の形式として使っています。

<?php 
    # DateFormatTrait.php
    public function i18nFormat($format = null, $timezone = null, $locale = null)
    {
        $time = $this;
        if ($timezone) {
            // Handle the immutable and mutable object cases.
            $time = clone $this;
            $time = $time->timezone($timezone);
        }
        $format = $format !== null ? $format : static::$_toStringFormat;
        $locale = $locale ?: static::$defaultLocale;
        return $this->_formatObject($time, $format, $locale);
    }
?>

cakephp/DateFormatTrait.php at 3.2.10 · cakephp/cakephp · GitHub

DateFormatTraitを読み込んでいたFrozenTimeClassに戻ってみると$_toStringFormatのデフォルトが設定されています。

# FrozenTimeClass
protected static $_toStringFormat = [IntlDateFormatter::SHORT, IntlDateFormatter::SHORT];

cakephp/FrozenTime.php at 3.2.10 · cakephp/cakephp · GitHub

IntlDateFormatterClassの定数としてSHORTが呼び出されているのでみてみると、12/13/52のような日時形式が定義されています。

年が下2桁で表現されていたようです。

<?php
    # IntlDateFormatterClass
    /**
    * Most abbreviated style, only essential data (12/13/52 or 3:30pm)
    * @link http://php.net/manual/en/intl.intldateformatter-constants.php
    */
    const SHORT = 3;
?>

3つの問題点のまとめと問題点を踏まえた解決策

以前CakePHP1系からCakePHP3系へ移行に関しての取り組みをブログ記事で紹介しましたが、サグーワークスでは長期間CakePHP1系で開発をしてきていたことで移行後すぐはまだCakePHP1系の方法で日付をフォーマットしていました。

PHPフレームワークのバージョンを上げるための取り組み - WILLGATE tech blog

そのため、CakePHP1系のfind結果を想定してCakePHP3系のFrozenTimeオブジェクトを日付フォーマットしたため以下のようなことが起こっていました。

$user['birthday']はFrozenTimeClassのオブジェクトのため、文字列出力しようとすると"1969/01/01"が"01/01/69"としてフォーマットされてしまいます。

その後、strtotime()の引数として"01/01/69"が渡り、2069年として解釈されstring型でunixtimeの"3124256400"が返ります。

そのunixtimeをdate()で見やすいようにフォーマットすると、2069年生まれのユーザが誕生してしまいます。

これらを踏まえると以下の点に気をつけることで未来に生まれるユーザの発生を防げます。

  • 年を下2桁の形式で取り扱わないこと
  • CakePHP3系ならTimeClassで定義されている関数を用いること(find結果のカラムがdatetime型であればFrozenTimeClass)

最初に紹介したコードの解決策として以下のように修正しました。

<?php
    # CakePHP3
    $birthday = new Time('1969-01-01');

    $userTable = TableRegistry::get('Users');
    $user = $userTable->get($userId);
    $user->birthday = $birthday->i18nFroamt('yyyy-MM-dd HH:mm:ss');
    $userTable->save($users);
?>

終わりに

CakePHP1系からCakePHP3系のfind結果が同じだろうとこれまで通りの実装してしまうことはあっても、strtotimeの仕様やFrozenTimeClassの設計を把握していれば防げた問題だろうと思います。

納期の問題などで言語やフレームワークの全ての仕様を把握することは膨大で難しいですが、今回のような障害を防ぐためにも利用する関数の仕様だけでも把握することが大切です。